Files
HomeAgent/providers/qwen3vl/image.go
JianFeeeee a37bc7333e refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题: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)
2026-09-11 18:26:19 +08:00

219 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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)))))
}