refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合

背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
This commit is contained in:
JianFeeeee
2026-09-11 11:45:24 +08:00
parent dae01f9c06
commit 5836c2ce5c
56 changed files with 2686 additions and 2834 deletions

View File

@ -1,14 +1,8 @@
//go:build onnxruntime
// Package qwen 的 ONNX 推理实现(构建标签 onnxruntime,与 internal/memory/clip 同模式)。
//
// 加载契约:调用方传入模型目录,内核不硬编码模型名。
//
// TextTower.onnx + 外部权重分片 — 文本塔图(input_ids/attention_mask → embedding)
// tokenizer.json — 字节级 BPE 词表与 merges
// embed_config.json — dim / max_length / instruction / pooling
//
// 图内已含 last-token 池化,输出即 [batch, dim];L2 归一化在 Go 侧做(便宜且便于测试)。
// Package qwen 提供 Qwen3-VL-Embedding 的完整图文共享 ONNX 编码器。
// 文本和图像共用 token embedding、28 层 Transformer、last-token 池化与
// fingerprint;Vision.onnx 只产生注入 Transformer 的中间特征。
package qwen
import (
@ -26,34 +20,39 @@ import (
ort "github.com/yalue/onnxruntime_go"
)
// embedConfig 对应导出脚本产出的 embed_config.json。
type embedConfig struct {
Dimension int `json:"dim"`
MaxLength int `json:"max_length"`
Instruction string `json:"instruction"`
Pooling string `json:"pooling"`
Arch string `json:"arch"`
Dimension int `json:"dim"`
MaxLength int `json:"max_length"`
Instruction string `json:"instruction"`
Pooling string `json:"pooling"`
ImageSize int `json:"image_size"`
PatchSize int `json:"patch_size"`
TemporalPatch int `json:"temporal_patch_size"`
SpatialMerge int `json:"spatial_merge_size"`
ImageMean []float64 `json:"image_mean"`
ImageStd []float64 `json:"image_std"`
RopeTheta float64 `json:"rope_theta"`
MRopeSection []int `json:"mrope_section"`
}
// Embedder 是基于内嵌 ONNX 文本塔的稠密嵌入器。
//
// 只提供文本能力:导出的是文本塔,视觉塔未导出。EmbedImageDense 会明确报错,
// 而不是返回一个「看起来能用」的零向量——后者会让跨模态检索静默失效。
type Embedder struct {
mu sync.RWMutex
loaded bool
config embedConfig
tok *Tokenizer
sess *ort.DynamicAdvancedSession
fp string
loaded bool
config embedConfig
tok *Tokenizer
token *ort.DynamicAdvancedSession
transform *ort.DynamicAdvancedSession
vision *ort.DynamicAdvancedSession
fp string
close sync.Once
}
// New 从模型目录加载文本塔。
func New(modelDir string) (*Embedder, error) {
if modelDir == "" {
return nil, fmt.Errorf("qwen model dir not specified")
}
cfgRaw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json"))
if err != nil {
return nil, fmt.Errorf("read embed_config.json: %w", err)
@ -62,22 +61,20 @@ func New(modelDir string) (*Embedder, error) {
if err := json.Unmarshal(cfgRaw, &cfg); err != nil {
return nil, fmt.Errorf("parse embed_config.json: %w", err)
}
if cfg.Dimension <= 0 {
return nil, fmt.Errorf("embed_config.json 的 dim 无效: %d", cfg.Dimension)
if cfg.Dimension != 2048 || cfg.MaxLength < 598 || cfg.Pooling != "last_token" {
return nil, fmt.Errorf("qwen: incompatible config dim=%d max_length=%d pooling=%q", cfg.Dimension, cfg.MaxLength, cfg.Pooling)
}
if cfg.MaxLength <= 0 {
cfg.MaxLength = 512
if cfg.ImageSize != qwenImageSize || cfg.PatchSize != qwenPatchSize || cfg.TemporalPatch != qwenTemporalPatch || cfg.SpatialMerge != qwenSpatialMerge {
return nil, fmt.Errorf("qwen: incompatible vision layout image=%d patch=%d temporal=%d merge=%d", cfg.ImageSize, cfg.PatchSize, cfg.TemporalPatch, cfg.SpatialMerge)
}
if cfg.Pooling != "" && cfg.Pooling != "last_token" {
return nil, fmt.Errorf("不支持的池化方式 %q(导出脚本只产出 last_token)", cfg.Pooling)
if cfg.RopeTheta <= 0 || len(cfg.MRopeSection) != 3 || cfg.MRopeSection[0]+cfg.MRopeSection[1]+cfg.MRopeSection[2] != qwenRotaryHalfDim {
return nil, fmt.Errorf("qwen: incompatible rope theta=%g section=%v", cfg.RopeTheta, cfg.MRopeSection)
}
tok, err := LoadTokenizer(modelDir)
if err != nil {
return nil, err
}
tok.MaxLen = cfg.MaxLength
if !ort.IsInitialized() {
if lib := findOnnxLib(); lib != "" {
ort.SetSharedLibraryPath(lib)
@ -87,74 +84,269 @@ func New(modelDir string) (*Embedder, error) {
}
}
sess, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "TextTower.onnx"),
[]string{"input_ids", "attention_mask"},
[]string{"embedding"},
nil,
token, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "TokenEmbedding.onnx"),
[]string{"input_ids"}, []string{"hidden"}, nil,
)
if err != nil {
return nil, fmt.Errorf("create text tower session: %w", err)
return nil, fmt.Errorf("create qwen token embedding session: %w", err)
}
transform, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "Transformer.onnx"),
[]string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2", "rotary_cos", "rotary_sin", "causal_mask"},
[]string{"embedding"}, nil,
)
if err != nil {
token.Destroy()
return nil, fmt.Errorf("create qwen transformer session: %w", err)
}
vision, err := ort.NewDynamicAdvancedSession(
filepath.Join(modelDir, "Vision.onnx"), []string{"pixel_values"},
[]string{"deepstack_feature_0", "deepstack_feature_1", "deepstack_feature_2", "vision_hidden_states"}, nil,
)
if err != nil {
token.Destroy()
transform.Destroy()
return nil, fmt.Errorf("create qwen vision session: %w", err)
}
return &Embedder{
loaded: true,
config: cfg,
tok: tok,
sess: sess,
fp: computeFingerprint(modelDir),
loaded: true, config: cfg, tok: tok,
token: token, transform: transform, vision: vision,
fp: computeFingerprint(modelDir),
}, nil
}
// renderInput 按模型自带的对话模板拼输入(实现在 tokenizer.go,无构建标签)。
func (e *Embedder) renderInput(text string) string {
return renderInstructionInput(e.config.Instruction, text)
}
// VectorizeDense 把文本编码为 L2 归一化的稠密向量。
func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("qwen embedder not loaded")
}
ids, err := e.tok.encodeModelInput(e.renderInput(text), e.config.MaxLength)
ids, _, position, _, err := e.tok.textModelInput(e.config.Instruction, text, e.config.MaxLength)
if err != nil {
return nil, err
}
hidden, err := e.runTokenEmbedding(ids)
if err != nil {
return nil, err
}
deep := make([][]float32, 3)
for i := range deep {
deep[i] = make([]float32, len(hidden))
}
return e.runTransformer(hidden, deep, position, len(ids))
}
seq := len(ids)
inputIDs := make([]int64, seq)
attn := make([]int64, seq)
func (e *Embedder) EmbedImageDense(raw []byte, _ string) ([]float64, error) {
e.mu.RLock()
defer e.mu.RUnlock()
if !e.loaded {
return nil, fmt.Errorf("qwen embedder not loaded")
}
pixels, err := preprocessImage(raw)
if err != nil {
return nil, err
}
features, err := e.runVision(pixels)
if err != nil {
return nil, err
}
ids, _, position, visual, err := e.tok.imageModelInput(e.config.Instruction, e.config.MaxLength)
if err != nil {
return nil, err
}
hidden, err := e.runTokenEmbedding(ids)
if err != nil {
return nil, err
}
deep := make([][]float32, 3)
for i := range deep {
deep[i] = make([]float32, len(hidden))
}
visualIndex := 0
for tokenIndex, isVisual := range visual {
if !isVisual {
continue
}
dst := tokenIndex * e.config.Dimension
src := visualIndex * e.config.Dimension
copy(hidden[dst:dst+e.config.Dimension], features[3][src:src+e.config.Dimension])
for layer := range deep {
copy(deep[layer][dst:dst+e.config.Dimension], features[layer][src:src+e.config.Dimension])
}
visualIndex++
}
if visualIndex != qwenVisualTokens {
return nil, fmt.Errorf("qwen: injected visual tokens=%d, want %d", visualIndex, qwenVisualTokens)
}
return e.runTransformer(hidden, deep, position, len(ids))
}
func (e *Embedder) runTokenEmbedding(ids []int) ([]float32, error) {
inputIDs := make([]int64, len(ids))
for i, id := range ids {
inputIDs[i] = int64(id)
attn[i] = 1
}
idTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, inputIDs)
in, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, inputIDs)
if err != nil {
return nil, fmt.Errorf("input_ids tensor: %w", err)
return nil, fmt.Errorf("qwen token input: %w", err)
}
defer idTensor.Destroy()
defer in.Destroy()
outs := make([]ort.Value, 1)
if err := e.token.Run([]ort.Value{in}, outs); err != nil {
return nil, fmt.Errorf("qwen token embedding run: %w", err)
}
if outs[0] == nil {
return nil, fmt.Errorf("qwen token embedding output is nil")
}
defer outs[0].Destroy()
tensor, ok := outs[0].(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("qwen token embedding output type %T", outs[0])
}
shape := tensor.GetShape()
if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(len(ids)) || shape[2] != int64(e.config.Dimension) {
return nil, fmt.Errorf("qwen token embedding shape=%v", shape)
}
return append([]float32(nil), tensor.GetData()...), nil
}
maskTensor, err := ort.NewTensor(ort.Shape{1, int64(seq)}, attn)
func (e *Embedder) runVision(pixels []float32) ([][]float32, error) {
in, err := ort.NewTensor(ort.Shape{qwenImagePatches, qwenPatchVectorSize}, pixels)
if err != nil {
return nil, fmt.Errorf("attention_mask tensor: %w", err)
return nil, fmt.Errorf("qwen vision input: %w", err)
}
defer maskTensor.Destroy()
defer in.Destroy()
outs := make([]ort.Value, 4)
if err := e.vision.Run([]ort.Value{in}, outs); err != nil {
return nil, fmt.Errorf("qwen vision run: %w", err)
}
features := make([][]float32, 4)
for i, value := range outs {
if value == nil {
return nil, fmt.Errorf("qwen vision output %d is nil", i)
}
defer value.Destroy()
tensor, ok := value.(*ort.Tensor[float32])
if !ok {
return nil, fmt.Errorf("qwen vision output %d type %T", i, value)
}
shape := tensor.GetShape()
if len(shape) != 2 || shape[0] != qwenVisualTokens || shape[1] != int64(e.config.Dimension) {
return nil, fmt.Errorf("qwen vision output %d shape=%v", i, shape)
}
features[i] = append([]float32(nil), tensor.GetData()...)
}
return features, nil
}
outTensor, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
func (e *Embedder) runTransformer(hidden []float32, deep [][]float32, position []int64, seq int) ([]float64, error) {
if len(hidden) != seq*e.config.Dimension || len(deep) != 3 || len(position) != 3*seq {
return nil, fmt.Errorf("qwen: invalid transformer inputs hidden=%d deep=%d position=%d seq=%d", len(hidden), len(deep), len(position), seq)
}
cos, sin := e.rotary(position, seq)
causal := causalMask(seq)
hiddenTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, hidden)
if err != nil {
return nil, fmt.Errorf("output tensor: %w", err)
return nil, fmt.Errorf("qwen hidden tensor: %w", err)
}
defer outTensor.Destroy()
if err := e.sess.Run([]ort.Value{idTensor, maskTensor}, []ort.Value{outTensor}); err != nil {
return nil, fmt.Errorf("text tower run: %w", err)
defer hiddenTensor.Destroy()
inputs := []ort.Value{hiddenTensor}
var deepTensors []*ort.Tensor[float32]
for i, data := range deep {
if len(data) != len(hidden) {
return nil, fmt.Errorf("qwen deepstack %d length=%d, want %d", i, len(data), len(hidden))
}
t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(e.config.Dimension)}, data)
if err != nil {
return nil, fmt.Errorf("qwen deepstack %d tensor: %w", i, err)
}
deepTensors = append(deepTensors, t)
inputs = append(inputs, t)
}
defer func() {
for _, t := range deepTensors {
t.Destroy()
}
}()
cosTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos)
if err != nil {
return nil, fmt.Errorf("qwen rotary cos: %w", err)
}
defer cosTensor.Destroy()
sinTensor, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin)
if err != nil {
return nil, fmt.Errorf("qwen rotary sin: %w", err)
}
defer sinTensor.Destroy()
causalTensor, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal)
if err != nil {
return nil, fmt.Errorf("qwen causal mask: %w", err)
}
defer causalTensor.Destroy()
inputs = append(inputs, cosTensor, sinTensor, causalTensor)
raw := outTensor.GetData()
out, err := ort.NewEmptyTensor[float32](ort.Shape{1, int64(e.config.Dimension)})
if err != nil {
return nil, fmt.Errorf("qwen output tensor: %w", err)
}
defer out.Destroy()
if err := e.transform.Run(inputs, []ort.Value{out}); err != nil {
return nil, fmt.Errorf("qwen transformer run: %w", err)
}
return normalize(out.GetData()), nil
}
const (
qwenRotaryHalfDim = 64
qwenRotaryDim = 128
)
func (e *Embedder) rotary(position []int64, seq int) ([]float32, []float32) {
cos := make([]float32, seq*qwenRotaryDim)
sin := make([]float32, seq*qwenRotaryDim)
inv := make([]float64, qwenRotaryHalfDim)
for i := range inv {
inv[i] = 1 / math.Pow(e.config.RopeTheta, float64(2*i)/qwenRotaryDim)
}
for token := 0; token < seq; token++ {
freq := make([]float64, qwenRotaryHalfDim)
for i := range freq {
freq[i] = float64(position[token]) * inv[i]
}
for dim, offset := range []int{0, 1, 2} {
if dim == 0 {
continue
}
limit := e.config.MRopeSection[dim] * 3
for i := offset; i < limit; i += 3 {
freq[i] = float64(position[dim*seq+token]) * inv[i]
}
}
for i, f := range freq {
c, s := float32(math.Cos(f)), float32(math.Sin(f))
cos[token*qwenRotaryDim+i] = c
cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c
sin[token*qwenRotaryDim+i] = s
sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s
}
}
return cos, sin
}
func causalMask(seq int) []float32 {
out := make([]float32, seq*seq)
for row := 0; row < seq; row++ {
for col := row + 1; col < seq; col++ {
out[row*seq+col] = -math.MaxFloat32
}
}
return out
}
func normalize(raw []float32) []float64 {
out := make([]float64, len(raw))
var norm float64
for i, v := range raw {
@ -167,48 +359,39 @@ func (e *Embedder) VectorizeDense(text string) ([]float64, error) {
out[i] /= norm
}
}
return out, nil
}
// EmbedImageDense 不支持:导出的是**文本塔**,视觉塔未导出。
//
// 明确报错而不是返回零向量或占位:调用方(mediaref.go)会 log 后跳过写向量,
// 若返回零向量则「写入了但检索不到」,失败会静默化。要支持图像检索需另外
// 导出视觉塔并实现 Qwen3-VL 的图像预处理(patch/merge/缩放规则)。
func (e *Embedder) EmbedImageDense(_ []byte, _ string) ([]float64, error) {
return nil, fmt.Errorf("qwen text tower 不支持图像嵌入;图像检索请用 clip 或 http 路径")
return out
}
func (e *Embedder) Fingerprint() string { return e.fp }
func (e *Embedder) Dim() int { return e.config.Dimension }
func (e *Embedder) Loaded() bool {
e.mu.RLock()
defer e.mu.RUnlock()
return e.loaded
}
func (e *Embedder) Close() {
e.mu.Lock()
defer e.mu.Unlock()
if e.sess != nil {
e.sess.Destroy()
e.sess = nil
}
e.loaded = false
e.close.Do(func() {
e.mu.Lock()
defer e.mu.Unlock()
if e.token != nil {
e.token.Destroy()
e.token = nil
}
if e.transform != nil {
e.transform.Destroy()
e.transform = nil
}
if e.vision != nil {
e.vision.Destroy()
e.vision = nil
}
e.loaded = false
})
}
// computeFingerprint 计算模型指纹,用于 vec_model 持久化与切换后重算判定。
//
// 为什么不直接哈希全部权重:这个模型目录有 6.5GB 外部权重分片,启动时读一遍
// 要几十秒,会阻塞 homeagent 启动。这里哈希「图文件 + 配置 + 全部外部权重的
// 文件名与大小」——换模型(哪怕只是换了权重)几乎必然改变文件集合或大小,
// 足以识别切换;代价是理论上存在「大小相同但内容不同」的漏判,对本地单机
// 部署可接受。
func computeFingerprint(modelDir string) string {
h := sha256.New()
for _, name := range []string{"TextTower.onnx", "embed_config.json"} {
for _, name := range []string{"TokenEmbedding.onnx", "Transformer.onnx", "Vision.onnx", "embed_config.json"} {
if data, err := os.ReadFile(filepath.Join(modelDir, name)); err == nil {
h.Write([]byte(name))
h.Write([]byte{0})
@ -216,35 +399,25 @@ func computeFingerprint(modelDir string) string {
h.Write([]byte{0})
}
}
entries, _ := os.ReadDir(modelDir)
var names []string
for _, e := range entries {
n := e.Name()
// 外部权重分片:torch 新版导出器使用 onnx__<op>_<id> 与模型张量同名文件。
if strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".weight") || strings.HasSuffix(n, ".onnx.data") {
for _, entry := range entries {
n := entry.Name()
if strings.HasPrefix(n, "embed_tokens.") || strings.HasPrefix(n, "layers.") || strings.HasPrefix(n, "onnx__") || strings.HasSuffix(n, ".onnx.data") {
names = append(names, n)
}
}
sort.Strings(names)
for _, n := range names {
info, err := os.Stat(filepath.Join(modelDir, n))
if err != nil {
continue
if info, err := os.Stat(filepath.Join(modelDir, n)); err == nil {
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
}
fmt.Fprintf(h, "%s:%d\n", n, info.Size())
}
return hex.EncodeToString(h.Sum(nil))
}
// findOnnxLib 在常见路径中查找 libonnxruntime.so。
func findOnnxLib() string {
for _, p := range []string{
"/opt/onnxruntime/libonnxruntime.so",
"/opt/onnxruntime/lib/libonnxruntime.so",
"/usr/local/lib/libonnxruntime.so",
"/usr/lib/libonnxruntime.so",
} {
for _, p := range []string{"/opt/onnxruntime/libonnxruntime.so", "/usr/local/lib/libonnxruntime.so", "/usr/lib/libonnxruntime.so"} {
if _, err := os.Stat(p); err == nil {
return p
}

View File

@ -4,7 +4,7 @@ package qwen
import "fmt"
// Embedder 在未启用 onnxruntime 时为 no-op 实现(与 internal/memory/clip 同模式)。
// Embedder 在未启用 onnxruntime 时为 no-op 实现。
// 默认构建不链接 onnxruntime,保持原有 fastText/TF-IDF 行为不变。
type Embedder struct {
loaded bool

View File

@ -0,0 +1,157 @@
//go:build onnxruntime
package qwen
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
)
// preprocessImage 把任意图片转成固定 768×768 视觉塔输入。
//
// Vision.onnx 是经过 PyTorch 逐输出验证的固定 48×48 patch 图。为避免拉伸物体,
// 这里保持宽高比缩放并在中心补中性灰(归一化后约为 0);这与直接把长方形
// 强拉成正方形相比更能保留 Qwen 的视觉语义。已是 768×768 的输入不做插值,
// 便于用跨语言冻结向量精确回归 patch 排列。
func preprocessImage(raw []byte) ([]float32, 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))
}
}
// 与 transformers Qwen2VLImageProcessor 的排列严格一致:
// [grid_h/merge, grid_w/merge, merge_h, merge_w, channel,
// temporal_patch, patch_h, patch_w],然后 flatten。
out := make([]float32, 0, qwenImagePatches*qwenPatchVectorSize)
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++ {
_ = temporal // 静态图复制同一图片形成 2 帧 temporal patch
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, 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)))))
}

View File

@ -0,0 +1,86 @@
//go:build onnxruntime
package qwen
import (
"fmt"
"strings"
)
// imageModelInput 构造 Qwen3-VL 单图对话模板及对应 M-RoPE 位置。
// 固定 768×768 视觉塔产生 576 个合并后的视觉 token。
func (t *Tokenizer) imageModelInput(instruction string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
if instruction == "" {
instruction = DefaultInstruction
}
text := "<|im_start|>system\n" + instruction +
"<|im_end|>\n<|im_start|>user\n<|vision_start|>" +
strings.Repeat("<|image_pad|>", qwenVisualTokens) +
"<|vision_end|><|im_end|>\n<|im_start|>assistant\n"
ids, err = t.encodeModelInput(text, maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
imageID, ok := t.SpecialID("<|image_pad|>")
if !ok {
return nil, nil, nil, nil, fmt.Errorf("tokenizer.json 缺少 <|image_pad|>")
}
visual = make([]bool, len(ids))
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
for i, id := range ids {
attention[i] = 1
visual[i] = id == imageID
}
current := int64(0)
for start := 0; start < len(ids); {
isVisual := visual[start]
end := start + 1
for end < len(ids) && visual[end] == isVisual {
end++
}
if !isVisual {
for i := start; i < end; i++ {
p := current + int64(i-start)
position[i] = p
position[len(ids)+i] = p
position[2*len(ids)+i] = p
}
current += int64(end - start)
} else {
if end-start != qwenVisualTokens {
return nil, nil, nil, nil, fmt.Errorf("qwen: image token count=%d, want %d", end-start, qwenVisualTokens)
}
side := qwenImageSize / qwenPatchSize / qwenSpatialMerge
for i := start; i < end; i++ {
j := i - start
position[i] = current
position[len(ids)+i] = current + int64(j/side)
position[2*len(ids)+i] = current + int64(j%side)
}
current += int64(side)
}
start = end
}
return ids, attention, position, visual, nil
}
// textModelInput 执行完整 tokenizer post_processor,并构造纯文本标准 RoPE 位置。
func (t *Tokenizer) textModelInput(instruction, text string, maxLen int) (ids []int, attention, position []int64, visual []bool, err error) {
ids, err = t.encodeModelInput(renderInstructionInput(instruction, text), maxLen)
if err != nil {
return nil, nil, nil, nil, err
}
attention = make([]int64, len(ids))
position = make([]int64, 3*len(ids))
visual = make([]bool, len(ids))
for i := range ids {
attention[i] = 1
position[i] = int64(i)
position[len(ids)+i] = int64(i)
position[2*len(ids)+i] = int64(i)
}
return ids, attention, position, visual, nil
}