Files
HomeAgent/providers/chineseclip/image.go
JianFeeeee 6327578693 fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)

启动日志只有一行,没有任何细节:

    [media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
    [media] 向量迁移完成: 成功=0 失败=4 总计=4

反查 `memory/graph.db` 才找到根因:

| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |

而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:

    _ "image/gif"
    _ "image/jpeg"
    _ "image/png"

⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。

## 两个独立缺陷

### ① 功能缺口:webp 不可解码

加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。

代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。

### ② 可观测性缺口:失败被 `continue` 吞掉

原实现:

    if err := a.reembedOneBlock(b, fp); err != nil {
        atomic.AddInt64(&failed, 1)
        continue          ← 原因就此消失
    }

现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):

    [media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
    [media] 失败原因分类(按 mime | error 聚合):
    [media]   4 个: image/webp | chineseclip: 解码图像: image: unknown format

同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。

## 判据(4 条,全部变异自证)

| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |

webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。

## 验证

- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
2026-10-23 15:50:59 +08:00

183 lines
5.2 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.

package chineseclip
import (
"bytes"
"fmt"
"image"
// 契约要求 provider 自行解码 Data,所以这里注册常见图像格式。
//
// ★ webp 必须注册(2026-10-23 实测):线上 551 个图像块里有 4 个是
// `image/webp`(由 see_image 抓取的第三方图片),它们**永远嵌不进去** ——
// 启动时向量迁移报「失败=4」,但错误被 continue 吞掉、没有任何日志,
// 只看到数字对不上。根因就是这里少了 webp 注册:
//
// image.Decode(...) → "image: unknown format"
//
// Go 标准库不含 webp 解码器(那是 x/image 的子包),所以这是新增依赖的
// 唯一理由。x/image 是纯 Go、无 cgo,不改变二进制形态。
_ "golang.org/x/image/webp"
_ "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
}