mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心, `type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码 (BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对 `//go:build onnxruntime` 的 stub。加任何新模型都要改内核。 现在核心只认一个模型无关的公共契约(pkg/embedding): - 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider - 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口 - 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别) - 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释 改动: - 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表 (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值) - providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册 - internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口); ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口 (那正是「核心为每个新模态长方法」的坏味道) - http embedder 也变成普通 provider(注册名 http) - cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 + 透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动 - config:multimodal_space.type/onnx./http.* → provider + options.* - 删除 internal/memory/qwen(整体搬迁) 测试: - pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector - vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、 两个哨兵 errors.Is 互通 - providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→ 未知模态哨兵),并明确断言 Info 不声明 video 已知未完成(不得当作已验证): - 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧 video 模板缺少 processor 按时间组插入的字面时间戳文本 (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、 Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言 通过不能证明与官方实现一致。修复属 provider 内部工作。 - 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000) 验证:go build ./... ;go vet -tags onnxruntime ./... ; go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/... ;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
219 lines
7.3 KiB
Go
219 lines
7.3 KiB
Go
//go:build onnxruntime
|
||
|
||
package qwen3vl
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"math"
|
||
)
|
||
|
||
const (
|
||
qwenImageSize = 768
|
||
qwenPatchSize = 16
|
||
qwenTemporalPatch = 2
|
||
qwenSpatialMerge = 2
|
||
qwenImagePatches = (qwenImageSize / qwenPatchSize) * (qwenImageSize / qwenPatchSize)
|
||
qwenVisualTokens = qwenImagePatches / (qwenSpatialMerge * qwenSpatialMerge)
|
||
qwenPatchVectorSize = 3 * qwenTemporalPatch * qwenPatchSize * qwenPatchSize
|
||
|
||
// qwenVisionScale 是视觉塔空间步长:grid_h / spatial_merge。
|
||
// 每个时间组消耗这么多 M-RoPE 位置(见 model_input.go 的说明)。
|
||
qwenVisionScale = (qwenImageSize / qwenPatchSize) / qwenSpatialMerge
|
||
|
||
// maxVideoGroupsSafety 是分配安全上限,**不是**能力上限。
|
||
// 真正能导出哪些档由产物目录决定(Vision_g{N}.onnx);内核不硬编码
|
||
// 导出清单,否则别人导出 G=8 就会被内核莫名拒绝。
|
||
// 这个上限只用来防住「丢了上千帧进来」导致的巨量分配。
|
||
maxVideoGroupsSafety = 64
|
||
)
|
||
|
||
// fitCanvas 把任意图片解码并转成固定 768×768 画布。
|
||
//
|
||
// 保持宽高比缩放并在中心补中性灰(归一化后约为 0);直接强拉成正方形会
|
||
// 破坏物体形状。已是 768×768 的输入不做插值,以便用跨语言冻结向量
|
||
// 精确回归 patch 排列。
|
||
func fitCanvas(raw []byte) (*image.NRGBA, error) {
|
||
src, _, err := image.Decode(bytes.NewReader(raw))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("qwen: decode image: %w", err)
|
||
}
|
||
b := src.Bounds()
|
||
if b.Dx() <= 0 || b.Dy() <= 0 {
|
||
return nil, fmt.Errorf("qwen: empty image")
|
||
}
|
||
|
||
scale := math.Min(float64(qwenImageSize)/float64(b.Dx()), float64(qwenImageSize)/float64(b.Dy()))
|
||
w := max(1, int(math.Round(float64(b.Dx())*scale)))
|
||
h := max(1, int(math.Round(float64(b.Dy())*scale)))
|
||
if w > qwenImageSize {
|
||
w = qwenImageSize
|
||
}
|
||
if h > qwenImageSize {
|
||
h = qwenImageSize
|
||
}
|
||
|
||
resized := resizeBicubic(src, w, h)
|
||
canvas := image.NewNRGBA(image.Rect(0, 0, qwenImageSize, qwenImageSize))
|
||
neutral := color.NRGBA{R: 128, G: 128, B: 128, A: 255}
|
||
for i := 0; i < len(canvas.Pix); i += 4 {
|
||
canvas.Pix[i], canvas.Pix[i+1], canvas.Pix[i+2], canvas.Pix[i+3] = neutral.R, neutral.G, neutral.B, neutral.A
|
||
}
|
||
ox, oy := (qwenImageSize-w)/2, (qwenImageSize-h)/2
|
||
for y := 0; y < h; y++ {
|
||
for x := 0; x < w; x++ {
|
||
canvas.SetNRGBA(ox+x, oy+y, resized.NRGBAAt(x, y))
|
||
}
|
||
}
|
||
return canvas, nil
|
||
}
|
||
|
||
// appendPatches 按 Qwen2VLImageProcessor 的排列把一个时间组的两个画布写入 out。
|
||
//
|
||
// 排列:[grid_h/merge, grid_w/merge, merge_h, merge_w, channel,
|
||
// temporal_patch, patch_h, patch_w],然后 flatten。
|
||
//
|
||
// 图像与视频共用本函数:图像的两个时间槽传同一张画布,视频传相邻两帧。
|
||
// 共用是刻意的——两处各写一份排列,迟早会在某次修改后漂移,
|
||
// 而排列错了只会得到一个语义偏移的向量,不会报错。
|
||
func appendPatches(out []float32, slots *[qwenTemporalPatch]*image.NRGBA) []float32 {
|
||
blocks := qwenImageSize / qwenPatchSize / qwenSpatialMerge
|
||
for bh := 0; bh < blocks; bh++ {
|
||
for bw := 0; bw < blocks; bw++ {
|
||
for mh := 0; mh < qwenSpatialMerge; mh++ {
|
||
for mw := 0; mw < qwenSpatialMerge; mw++ {
|
||
baseY := (bh*qwenSpatialMerge + mh) * qwenPatchSize
|
||
baseX := (bw*qwenSpatialMerge + mw) * qwenPatchSize
|
||
for c := 0; c < 3; c++ {
|
||
for temporal := 0; temporal < qwenTemporalPatch; temporal++ {
|
||
canvas := slots[temporal]
|
||
for py := 0; py < qwenPatchSize; py++ {
|
||
for px := 0; px < qwenPatchSize; px++ {
|
||
p := canvas.NRGBAAt(baseX+px, baseY+py)
|
||
v := [3]uint8{p.R, p.G, p.B}[c]
|
||
out = append(out, float32(v)/127.5-1)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// preprocessImage 把任意图片转成固定 768×768 视觉塔输入(单个时间组)。
|
||
func preprocessImage(raw []byte) ([]float32, error) {
|
||
canvas, err := fitCanvas(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
slots := [qwenTemporalPatch]*image.NRGBA{canvas, canvas}
|
||
out := make([]float32, 0, qwenImagePatches*qwenPatchVectorSize)
|
||
return appendPatches(out, &slots), nil
|
||
}
|
||
|
||
// preprocessVideoFrames 把已按时间排序的帧转成 G 个时间组的视觉塔输入,
|
||
// 返回 patch 张量与时间组数 G。
|
||
//
|
||
// 时间组 g 的两个时间槽依次取帧 2g 与 2g+1,这与处理器实测逐字节一致
|
||
// (纯色与异色两组对照均 torch.equal 通过);布局整体是
|
||
// [G, blocks_h, blocks_w, merge_h, merge_w, c, temporal, patch_h, patch_w],
|
||
// 即图像排列以 grid_t 为最外层堆叠。
|
||
//
|
||
// 帧数为奇数时不补帧:只用得上的帧参与编码,多余的一帧被丢弃,
|
||
// 以免用重复帧伪造时序——那会改变跨帧注意力看到的运动。
|
||
func preprocessVideoFrames(frames [][]byte) ([]float32, int, error) {
|
||
if len(frames) < qwenTemporalPatch {
|
||
return nil, 0, fmt.Errorf("qwen: video needs at least %d frames, got %d", qwenTemporalPatch, len(frames))
|
||
}
|
||
groups := len(frames) / qwenTemporalPatch
|
||
if groups > maxVideoGroupsSafety {
|
||
return nil, 0, fmt.Errorf("qwen: video groups %d exceeds safety limit %d(请先对帧采样)", groups, maxVideoGroupsSafety)
|
||
}
|
||
canvases := make([]*image.NRGBA, groups*qwenTemporalPatch)
|
||
for i := 0; i < groups*qwenTemporalPatch; i++ {
|
||
canvas, err := fitCanvas(frames[i])
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("qwen: frame %d: %w", i, err)
|
||
}
|
||
canvases[i] = canvas
|
||
}
|
||
out := make([]float32, 0, groups*qwenImagePatches*qwenPatchVectorSize)
|
||
for g := 0; g < groups; g++ {
|
||
slots := [qwenTemporalPatch]*image.NRGBA{canvases[2*g], canvases[2*g+1]}
|
||
out = appendPatches(out, &slots)
|
||
}
|
||
return out, groups, nil
|
||
}
|
||
|
||
// resizeBicubic 使用半像素中心的 Catmull-Rom 三次卷积。
|
||
func resizeBicubic(src image.Image, dstW, dstH int) *image.NRGBA {
|
||
b := src.Bounds()
|
||
if b.Dx() == dstW && b.Dy() == dstH {
|
||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||
for y := 0; y < dstH; y++ {
|
||
for x := 0; x < dstW; x++ {
|
||
dst.SetNRGBA(x, y, color.NRGBAModel.Convert(src.At(b.Min.X+x, b.Min.Y+y)).(color.NRGBA))
|
||
}
|
||
}
|
||
return dst
|
||
}
|
||
|
||
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
|
||
sx, sy := float64(b.Dx())/float64(dstW), float64(b.Dy())/float64(dstH)
|
||
for y := 0; y < dstH; y++ {
|
||
fy := (float64(y)+0.5)*sy - 0.5
|
||
y0 := int(math.Floor(fy))
|
||
for x := 0; x < dstW; x++ {
|
||
fx := (float64(x)+0.5)*sx - 0.5
|
||
x0 := int(math.Floor(fx))
|
||
var sum [4]float64
|
||
var weight float64
|
||
for j := -1; j <= 2; j++ {
|
||
wy := cubicWeight(fy - float64(y0+j))
|
||
yy := min(max(y0+j, 0), b.Dy()-1)
|
||
for i := -1; i <= 2; i++ {
|
||
w := wy * cubicWeight(fx-float64(x0+i))
|
||
xx := min(max(x0+i, 0), b.Dx()-1)
|
||
p := color.NRGBAModel.Convert(src.At(b.Min.X+xx, b.Min.Y+yy)).(color.NRGBA)
|
||
sum[0] += float64(p.R) * w
|
||
sum[1] += float64(p.G) * w
|
||
sum[2] += float64(p.B) * w
|
||
sum[3] += float64(p.A) * w
|
||
weight += w
|
||
}
|
||
}
|
||
if weight == 0 {
|
||
weight = 1
|
||
}
|
||
dst.SetNRGBA(x, y, color.NRGBA{
|
||
R: clampByte(sum[0] / weight), G: clampByte(sum[1] / weight),
|
||
B: clampByte(sum[2] / weight), A: clampByte(sum[3] / weight),
|
||
})
|
||
}
|
||
}
|
||
return dst
|
||
}
|
||
|
||
func cubicWeight(x float64) float64 {
|
||
x = math.Abs(x)
|
||
if x <= 1 {
|
||
return 1.5*x*x*x - 2.5*x*x + 1
|
||
}
|
||
if x < 2 {
|
||
return -0.5*x*x*x + 2.5*x*x - 4*x + 2
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func clampByte(v float64) uint8 {
|
||
return uint8(min(255, max(0, int(math.Round(v)))))
|
||
}
|