fix(vlm): 送模型前按 EXIF 方向摆正像素,横躺的扫描件不再错位识别 - #3912
linus-liu-web wants to merge 1 commit into
Conversation
9a9aa08 to
18c1f3e
Compare
相机与扫描仪按传感器姿态存像素,把"该怎么转"写在 EXIF tag 0x0112 里。 浏览器与图片查看器读这个 tag,所以 UI 里一切正常;视觉模型读像素矩阵、 普遍不读 tag,于是 3/6/8 的图到达模型时是躺着的(或倒着的):模型既要认 旋转后的字形,又要在旋转过的坐标系里做表格二维推理,合并单元格锚点、 姓名、电话成片错,且每次错的位置都不一样。 新增 orientationVLM 装饰器,装在最内层(newVLM 之后、debug/langfuse 之前), 一次覆盖全部 8 个 VLM 调用点与 3 个实现: - jpegEXIFOrientation 只走段标记读 APP1/IFD0/tag 0x0112,支持 II/MM 字节序; - applyEXIFOrientation 按 EXIF 定义实现 2/4/3/6/8/5/7 八种变换; - 重新编码后 tag 不再残留(否则浏览器会再转一次); - 无 tag、tag=1、非 JPEG、解码失败一律原字节按引用透传,99% 的图零开销; - 超过 80MP 的画布保持原样并打 warning,避免解码撑爆 worker。
18c1f3e to
a16ef0e
Compare
|
一份真实扫描件的端到端验证(919,752 字节的 JPEG,直接喂给本实现):
重编码体积(同一页,供参考;
即"重编码必然比原件大"(Go 的编码器固定 4:2:0 + 标准量化表,扫描仪原编码更省)。如果更在意上行体积,把常量降到 85 可省约 12%,对文字识别没有可感差异;我按"保真优先"留了 90。 成本边界:旋转需要整图解码,瞬时内存 ≈ 4 字节/像素(这一页约 140MB),且只对带旋转 tag 的图发生(真实语料里是少数)。 |
|
自查后补了三处,commit 1. 装饰器位置修正(原注释与正文有一处说法是错的) 原先装在 debug / Langfuse 之内。但这两个 wrapper 记录的是它们收到的字节( 这样 trace 忠实反映真正发出的字节,同时整图解码(≈4 字节/像素)仍落在每模型的并发槽里,不会被并发洪峰放大内存。正文已同步更正。 2. 补两个用例
3. 外部真值交叉验证(PIL 变换表与 PIL 在 8 个方向上都逐像素一致,不是照着记忆拍的。 |
Fixes #3911
改动
相机与扫描仪按传感器姿态存像素,把"该怎么转"写在 EXIF tag
0x0112里。浏览器与图片查看器读这个 tag(所以 UI 里看一切正常),视觉模型读像素矩阵、普遍不读 tag ——3/6/8的图到达模型时是倒着或躺着的,模型既要认旋转后的字形、又要在旋转过的坐标系里做表格二维推理,合并单元格锚点、姓名、电话成片错,且每次错的位置都不一样。新增
internal/models/vlm/image_orientation.go,并把它装成最内层的 VLM 装饰器(NewVLM里newVLM之后、debug/langfuse 之前):Predict在生产代码里有 8 个调用点(agent_service.go:269、image_action_loop.go:85/109、image_multimodal.go:512、temporary_document.go:600/624、handler/model.go:613、handler/session/image_upload.go:86)与 3 个实现(remote API / ollama / weknoracloud)。修在调用点会打地鼠;装在装配点则一次覆盖。位置有讲究:装饰器放在 debug / Langfuse 之外、并发闸门之内 —— 这两个 wrapper 记录的是它们收到的字节,装在它们之内会让 trace 显示模型从未收到的原始字节;而装到并发闸门之外,整图解码(≈4 字节/像素)就跑出了每模型并发槽。现在 trace 忠实、内存仍由闸门兜住。jpegEXIFOrientation:只走 JPEG 段标记读 APP1 → IFD0 → tag0x0112,支持II/MM两种字节序,不触碰图像数据,代价与头部成正比。applyEXIFOrientation:按 EXIF 定义实现 2/4(镜像)、3(180°)、6(顺时针 90°)、8(逆时针 90°)、5/7(对角镜像)八种变换。assert.Same锁死),绝大多数图片字节不变、零额外开销。maxOrientationPixels = 80MP:旋转需要整图解码(≈4 字节/像素),超过预算的画布保持原样并打 warning——不静默、也不让畸形大图拖垮 worker。600 DPI 的 A3 扫描(≈70MP)仍在预算内。验证
新增 5 组用例(
internal/models/vlm/image_orientation_test.go):TestOrientImageBytesTurnsPixelsPerEXIFTagTestOrientImageBytesReadsBothTIFFByteOrdersII与MM两种字节序都能读出 tagTestOrientImageBytesLeavesUntouchedPayloadsAloneTestOrientImageBytesSkipsCanvasesOverThePixelBudgetTestOrientationVLMPredictsWithUprightPixels/...PassesPayloadsThroughUntouchedTestOrientImageBytesFindsEXIFBehindJFIFApp0TestOrientImageBytesIsIdempotent反向验证(去掉修复必红):
变异 B 只红装饰器用例、其余保持绿,说明用例能精确定位是"接线断了"还是"变换算错了"。
外部真值交叉验证(用 PIL 的
ImageOps.exif_transpose当参考,逐像素):即变换表与 PIL 在 8 个方向上都逐像素一致,"亮点角位 + 尺寸"之外多一个外部真值。
其余校验:
go test ./internal/models/vlm/ -count=1全绿;go test于internal/application/service、internal/handler/...、internal/agent/...(-run "VLM|Multimodal|Image|image")全绿;go vet ./internal/models/...、go build ./...、gofmt、gofumpt、lll(120,tab=4)全部通过。边界(本 PR 不做,附理由)
local/minio/cos/obs/ks3/backend_scoped六个后端)ImageInfo.SHA256(现契约是"原始文件的哈希",image_multimodal_attrs_test.go三处断言依赖它)与去重语义,改动面远超收益。eXIf、HEIC/WebP)useDocOrientationClassify也处于关闭状态。关于
maxOrientationPixels这个像素预算(与仓库既有口径对齐,不是新发明的概念):maxFeishuDownloadBytesLimitReader(+1)超限报错)maxDownloadBytesmaxRemoteImageSizeminImageDimensionbrowserskill_image.goDecodeConfig后判w*h,超限判非法)字节上限防的是"下载缓冲无界",而压缩比可以差两个数量级,字节数完全约束不了解码后的内存(50 MB 的 JPEG 可能是五亿像素)——真正防解码内存的是像素数,
browserskill_image.go的 4,000 万就是先例。阈值取 80 MP 是按扫描件标定的:A4@600DPI = 35 MP、A3@600DPI = 70 MP,留一档余量。超限后选择"保持原样 + warning"而不是像截图那样直接判非法:拒绝会让这类页面从「可能错位」变成「完全不识别」,那是在修复里新增失败路径。| 历史数据重跑 | 不在本 PR 范围内。 |