mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
## 为什么 用户决定「本轮不覆盖 video,先支持 text+image」。这一刀正好解锁了此前 「小 + 可商用 + 覆盖视频」三者不可兼得的僵局:不要求视频后,唯一同时满足 **小、可商用、中文原生** 的选项是 Chinese-CLIP ViT-B/16。 实测对比(同机、真实跑出来的数字): | | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B | |---|---|---|---| | 参数量 | 188M | 1.04B | 2B | | 产物 / 常驻内存 | 754MB / **1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB | | 维度 | 512 | 768 | 2048 | | 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 | | 视频 | 无 | 有 | 有 | 本机可用内存只有 5.3GB,Qwen 的 9.4GB 无法进程内使用;而 ORT format + mmap 那条路被证实当前不通(转换器对三段图段错误;走通还需同时升 ORT 运行时与 Go 绑定,v1.36 要求 API 29 而本机只有 28)。1.15GB 则可以直接进程内跑。 **代价已写进包注释与文档**:CLIP 是双塔对比学习,text↔image 是强项,但纯文本 语义明显弱于 MLLM 型嵌入器;文本检索仍由既有词向量/TF-IDF 路径兜底。 需要更强文本语义或视频时切回 qwen3vl。 ## 内容 - `providers/chineseclip/`:按公共 SPI 实现的 provider(注册名 `chineseclip`), 含 BERT WordPiece 分词器、图像预处理、ONNX 双塔推理、无标签 stub。 - `scripts/export_chineseclip_onnx.py`:从官方权重导出规范产物 + 冻结参考, 自带逐用例 PyTorch 对比与覆盖度断言(计划集合≠执行集合即非零退出)。 - `cmd/homed/main.go`:空白导入两个 provider,由配置选其一。 - `go.mod`:`golang.org/x/text` 由间接依赖转为直接依赖(删音标需要 NFD)。 ## 实现要点 - **分词器逐 token 对齐官方**。第一版探针自己拼 BertTokenizer(只给 vocab.txt、 没删音标、中文没逐字切),中文被整体切成 [UNK],三个不同句子产出几乎相同的 向量(余弦 0.98)——差点把「模型坏了」当成结论。官方配置是 do_lower_case=true + 删音标生效 + 中文逐字切分;`TestTokenizerMatchesOfficialReference` 钉住 逐 token 一致。 - **图像缩放自写 bicubic**(复刻 PIL 的 precompute_coeffs + a=-0.5 核),不引 golang.org/x/image:它未进本机模块缓存,且最新版要求把整个工具链升到 Go 1.26, 为一个缩放函数动工具链不划算。 - **归一化在 provider 侧**(两个塔的图里都没归一化),检索按余弦。 - **指纹覆盖全部影响语义的产物**:两个 ONNX 图 + vocab.txt + embed_config.json, 读不到就写 MISSING(跳过等于对缺件不敏感)。 - 会话 Run 用 runMu 串行化(ORT 会话不保证并发安全),创建/销毁用 mu。 ## 模态范围 只声明 `text` 与 `image`;`audio`/`video` 明确返回 `ErrUnsupportedModality`, 绝不用别的模型向量冒充(这是「音频明确 unsupported」纪律的落地)。 ## 验证(实测) 导出侧:10 个用例(5 文本 + 5 图像)ONNX vs 官方 PyTorch 全部 `cos = 1.000000000`,覆盖度断言 10/10 通过。 Go 侧(`CHINESECLIP_MODEL_DIR=... go test -tags onnxruntime ./providers/chineseclip/ -v`): 11/11 通过,其中 - 文本 5 用例 `cos = 1.000000000000`(逐位一致) - 图像 4 纯色用例 `cos = 1.000000`(与官方预处理在 6 位小数内一致) - 跨模态判别:红图对「红色」文本高于「蓝色」文本 - 模态拒绝 / 空输入 / 指纹稳定 / 产物缺失报错 顺带修掉测试自身的一个假通过:参考向量是**未归一化**的原始输出(模长 10~36), 原先「点积当余弦 + 单侧下界」会让 13.6 也判过,已改为真余弦 + 双侧容差。 构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过; `providers/... pkg/... internal/config/... internal/memory/vector/...` 回归通过 (qwen3vl 的 TestVideoModelInputMRope 需要 QWEN_ONNX_MODEL_DIR 指向含视频档的 v3 目录,缺该环境变量时用的是只有文本+图像的目录,与本改动无关)。 ## 未做(明确记录) - 发行版默认 provider 与构建标签变更:留下一提交(涉及打包与模型分发策略)。 - 模型产物(754MB)不进仓库,由导出脚本生成。
172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
package chineseclip
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"image"
|
||
|
||
// 契约要求 provider 自行解码 Data,所以这里注册常见图像格式。
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
)
|
||
|
||
// plane 是单通道浮点平面。
|
||
type plane struct {
|
||
w, h int
|
||
data []float32
|
||
}
|
||
|
||
// preprocessImage 把原始图像字节变成 ONNX 需要的 NCHW 张量:
|
||
// 缩放到 size×size(双三次,复刻 PIL 的系数)→ 归一化(x/255 - mean)/ std。
|
||
//
|
||
// 缩放在 RGB 三个通道上分别进行,与官方 ChineseCLIPFeatureExtractor 一致
|
||
// (do_resize=true、do_center_crop=false、resample=BICUBIC、rescale 1/255)。
|
||
func preprocessImage(data []byte, size int, mean, std []float64) ([]float32, error) {
|
||
img, _, err := image.Decode(bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("chineseclip: 解码图像: %w", err)
|
||
}
|
||
bounds := img.Bounds()
|
||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
|
||
return nil, fmt.Errorf("chineseclip: 图像尺寸非法 %dx%d", bounds.Dx(), bounds.Dy())
|
||
}
|
||
|
||
planes := [3]plane{}
|
||
for c := range planes {
|
||
planes[c] = plane{w: bounds.Dx(), h: bounds.Dy(), data: make([]float32, bounds.Dx()*bounds.Dy())}
|
||
}
|
||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||
r, g, b, _ := img.At(x, y).RGBA()
|
||
idx := (y-bounds.Min.Y)*bounds.Dx() + (x - bounds.Min.X)
|
||
// RGBA() 返回的是 16 位预乘值;不透明图像下右移 8 位即得 8 位分量。
|
||
planes[0].data[idx] = float32(r >> 8)
|
||
planes[1].data[idx] = float32(g >> 8)
|
||
planes[2].data[idx] = float32(b >> 8)
|
||
}
|
||
}
|
||
|
||
out := make([]float32, 3*size*size)
|
||
for c := range planes {
|
||
resized := resizeBicubic(planes[c], size, size)
|
||
for i, v := range resized.data {
|
||
scaled := float64(v) / 255.0
|
||
out[c*size*size+i] = float32((scaled - mean[c]) / std[c])
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// resizeBicubic 复刻 PIL 的可分离双三次缩放(系数来自 PIL 的
|
||
// precompute_coeffs + bicubic_filter,a=-0.5)。
|
||
//
|
||
// 为什么不引第三方 resize:本机 x/image 未进模块缓存,而它最新版还要求把整个
|
||
// 工具链升到 Go 1.26;为一个缩放函数动工具链不划算。PIL 的算法只有几十行,
|
||
// 照抄系数能保证与官方预处理足够接近(已用端到端 cos 验证)。
|
||
func resizeBicubic(src plane, dstW, dstH int) plane {
|
||
if src.w == dstW && src.h == dstH {
|
||
return src
|
||
}
|
||
wsX := buildWeights(src.w, dstW)
|
||
tmp := plane{w: dstW, h: src.h, data: make([]float32, dstW*src.h)}
|
||
for y := 0; y < src.h; y++ {
|
||
row := y * src.w
|
||
for dx := 0; dx < dstW; dx++ {
|
||
var sum float32
|
||
for _, t := range wsX[dx] {
|
||
sum += t.w * src.data[row+t.i]
|
||
}
|
||
tmp.data[y*dstW+dx] = sum
|
||
}
|
||
}
|
||
|
||
wsY := buildWeights(src.h, dstH)
|
||
dst := plane{w: dstW, h: dstH, data: make([]float32, dstW*dstH)}
|
||
for dy := 0; dy < dstH; dy++ {
|
||
for x := 0; x < dstW; x++ {
|
||
var sum float32
|
||
for _, t := range wsY[dy] {
|
||
sum += t.w * tmp.data[t.i*dstW+x]
|
||
}
|
||
dst.data[dy*dstW+x] = sum
|
||
}
|
||
}
|
||
return dst
|
||
}
|
||
|
||
type weightTerm struct {
|
||
i int
|
||
w float32
|
||
}
|
||
|
||
// buildWeights 按 PIL 的 precompute_coeffs 计算每个目标像素的源像素权重。
|
||
func buildWeights(srcLen, dstLen int) [][]weightTerm {
|
||
const support = 2.0 // BICUBIC 的支撑半径
|
||
|
||
filterScale := float64(srcLen) / float64(dstLen)
|
||
if filterScale < 1.0 {
|
||
filterScale = 1.0
|
||
}
|
||
scale := filterScale
|
||
filterSupport := support * filterScale
|
||
invScale := 1.0 / filterScale
|
||
|
||
out := make([][]weightTerm, dstLen)
|
||
for d := 0; d < dstLen; d++ {
|
||
center := (float64(d) + 0.5) * scale
|
||
xmin := int(center - filterSupport + 0.5)
|
||
if xmin < 0 {
|
||
xmin = 0
|
||
}
|
||
xmax := int(center + filterSupport + 0.5)
|
||
if xmax > srcLen {
|
||
xmax = srcLen
|
||
}
|
||
if xmax <= xmin {
|
||
// 极端缩放下的兜底:退化为最近邻,避免空权重导致除零。
|
||
idx := int(center)
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
if idx >= srcLen {
|
||
idx = srcLen - 1
|
||
}
|
||
out[d] = []weightTerm{{i: idx, w: 1}}
|
||
continue
|
||
}
|
||
terms := make([]weightTerm, 0, xmax-xmin)
|
||
var total float64
|
||
for x := xmin; x < xmax; x++ {
|
||
w := bicubicKernel((float64(x) - center + 0.5) * invScale)
|
||
if w == 0 {
|
||
continue
|
||
}
|
||
terms = append(terms, weightTerm{i: x, w: float32(w)})
|
||
total += w
|
||
}
|
||
if total != 0 {
|
||
for i := range terms {
|
||
terms[i].w = float32(float64(terms[i].w) / total)
|
||
}
|
||
}
|
||
out[d] = terms
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bicubicKernel 是 PIL 的 bicubic_filter(a = -0.5)。
|
||
func bicubicKernel(x float64) float64 {
|
||
const a = -0.5
|
||
if x < 0 {
|
||
x = -x
|
||
}
|
||
switch {
|
||
case x < 1.0:
|
||
return ((a+2.0)*x-(a+3.0))*x*x + 1.0
|
||
case x < 2.0:
|
||
return (((x-5.0)*x+8.0)*x - 4.0) * a
|
||
}
|
||
return 0
|
||
}
|