Skip to content

fix(vlm): 送模型前按 EXIF 方向摆正像素,横躺的扫描件不再错位识别 - #3912

Open
linus-liu-web wants to merge 1 commit into
Tencent:mainfrom
linus-liu-web:fix/vlm-exif-orientation
Open

linus-liu-web wants to merge 1 commit into
Tencent:mainfrom
linus-liu-web:fix/vlm-exif-orientation

Conversation

@linus-liu-web

@linus-liu-web linus-liu-web commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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 → tag 0x0112,支持 II/MM 两种字节序,不触碰图像数据,代价与头部成正比。
  • applyEXIFOrientation:按 EXIF 定义实现 2/4(镜像)、3(180°)、6(顺时针 90°)、8(逆时针 90°)、5/7(对角镜像)八种变换。
  • 旋转后重新编码,tag 不会残留——否则浏览器会照着 tag 再转一次,反而转坏。
  • 快速路径:无 tag、tag=1、非 JPEG、解码失败、段结构异常,一律原字节按引用透传(测试用 assert.Same 锁死),绝大多数图片字节不变、零额外开销。
  • 像素预算 maxOrientationPixels = 80MP:旋转需要整图解码(≈4 字节/像素),超过预算的画布保持原样并打 warning——不静默、也不让畸形大图拖垮 worker。600 DPI 的 A3 扫描(≈70MP)仍在预算内。
  • 只对带旋转 tag 的图重编码为 JPEG q90。这类图在真实语料里是少数(几十张量级)。

验证

新增 5 组用例(internal/models/vlm/image_orientation_test.go):

用例 断言
TestOrientImageBytesTurnsPixelsPerEXIFTag 2/3/4/5/6/7/8 七种变换的输出尺寸(换不换宽高)与亮点角位(横躺转 90° 顺时针还是逆时针),并断言 tag 已消失
TestOrientImageBytesReadsBothTIFFByteOrders II 与 MM 两种字节序都能读出 tag
TestOrientImageBytesLeavesUntouchedPayloadsAlone tag=1、无 EXIF、PNG、截断、TIFF 头损坏、空、非图片、tag=0/9 —— 九种载荷都按引用原样返回
TestOrientImageBytesSkipsCanvasesOverThePixelBudget 超预算画布保持原样(临时调低预算,不构造巨型图)
TestOrientationVLMPredictsWithUprightPixels / ...PassesPayloadsThroughUntouched 装饰器交给下游的是摆正后的字节,且不改写调用方的切片
TestOrientImageBytesFindsEXIFBehindJFIFApp0 真实相机/扫描仪布局(JFIF APP0 在 EXIF APP1 之前)仍能读到 tag。Go 自带编码器不写 APP0,用例自建该段
TestOrientImageBytesIsIdempotent 归一化两遍,第二遍必须按引用透传(tag 已随像素消失)

反向验证(去掉修复必红):

########## 变异 A:把归一化函数改成 no-op ##########
--- FAIL: TestOrientImageBytesTurnsPixelsPerEXIFTag (0.00s)
    --- FAIL: TestOrientImageBytesTurnsPixelsPerEXIFTag/2 (0.00s)
    ... (2/3/4/5/6/7/8 七个方向子用例全红)
--- FAIL: TestOrientImageBytesReadsBothTIFFByteOrders (0.00s)
    --- FAIL: TestOrientImageBytesReadsBothTIFFByteOrders/big-endian (0.00s)
    --- FAIL: TestOrientImageBytesReadsBothTIFFByteOrders/little-endian (0.00s)
--- FAIL: TestOrientationVLMPredictsWithUprightPixels (0.00s)

########## 变异 B:只把装饰器改成透传(断调用点接线)##########
--- FAIL: TestOrientationVLMPredictsWithUprightPixels (0.00s)

########## 恢复后 ##########
ok  	github.com/Tencent/WeKnora/internal/models/vlm	0.244s

变异 B 只红装饰器用例、其余保持绿,说明用例能精确定位是"接线断了"还是"变换算错了"。

外部真值交叉验证(用 PIL 的 ImageOps.exif_transpose 当参考,逐像素):

=== 合成图:本实现 vs PIL exif_transpose(8 个方向,逐像素)===
orientation 1..8   尺寸一致   不同像素 0/192   平均差 0.0000   最大差 0

=== 真实扫描件(919,752 字节,orientation 6)===
尺寸 (4961, 7016) 一致;不同像素 387674/34806376(均为灰阶换算舍入,最大差 4/255)
墨迹像素 IoU = 0.999901(仅 PIL 有 23、仅本实现有 35,占并集 0.004% / 0.006%)

即变换表与 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 三处断言依赖它)与去重语义,改动面远超收益。
非 JPEG 的 EXIF(PNG eXIf、HEIC/WebP) 当前语料与扫描仪产出都是 JPEG;未命中即按引用透传,与修复前行为一致,不引入回归。
用方向分类模型猜方向 tag 是权威值,猜测会引入新的不确定性;仓库现有的 useDocOrientationClassify 也处于关闭状态。

关于 maxOrientationPixels 这个像素预算(与仓库既有口径对齐,不是新发明的概念):

路径 上限 层面
飞书连接器 maxFeishuDownloadBytes 512 MiB / 单文件 字节(下载层,LimitReader(+1) 超限报错)
IMA 连接器 maxDownloadBytes 200 MiB(注释:对齐厂商最大媒体尺寸) 字节(下载层)
docparser 远程图片 maxRemoteImageSize 10 MiB 字节(下载层)
docparser 图片过滤 minImageDimension 最小 64px 像素(只设下限,过滤图标)
Agent 截图 browserskill_image.go 8 MiB 且 4,000 万像素 字节 + 像素上限(DecodeConfig 后判 w*h,超限判非法)
本 PR 80 MP 像素上限(解码前预检)

字节上限防的是"下载缓冲无界",而压缩比可以差两个数量级,字节数完全约束不了解码后的内存(50 MB 的 JPEG 可能是五亿像素)——真正防解码内存的是像素数,browserskill_image.go 的 4,000 万就是先例。阈值取 80 MP 是按扫描件标定的:A4@600DPI = 35 MP、A3@600DPI = 70 MP,留一档余量。超限后选择"保持原样 + warning"而不是像截图那样直接判非法:拒绝会让这类页面从「可能错位」变成「完全不识别」,那是在修复里新增失败路径。
| 历史数据重跑 | 不在本 PR 范围内。 |

相机与扫描仪按传感器姿态存像素,把"该怎么转"写在 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。
@linus-liu-web
linus-liu-web force-pushed the fix/vlm-exif-orientation branch from 18c1f3e to a16ef0e Compare September 30, 2026 13:07
@linus-liu-web

Copy link
Copy Markdown
Contributor Author

一份真实扫描件的端到端验证(919,752 字节的 JPEG,直接喂给本实现):

stored:     7016x4961, 919752 bytes      (EXIF Orientation = 6)
normalized: 4961x7016, 1459958 bytes     (tag gone, 耗时 1.95s)

4961 × 7016 正是 A4@600DPI 的纵向尺寸,说明变换方向与页面几何自洽。

重编码体积(同一页,供参考;orientationJPEGQuality 目前取 90):

quality 输出 base64 后
原件 919,752 1,226,336
75 1,124,483 1,499,310
80 1,191,141 1,588,188
85 1,283,246 1,710,994
90 1,459,958 1,946,610
95 1,823,951 2,431,934

即"重编码必然比原件大"(Go 的编码器固定 4:2:0 + 标准量化表,扫描仪原编码更省)。如果更在意上行体积,把常量降到 85 可省约 12%,对文字识别没有可感差异;我按"保真优先"留了 90。

成本边界:旋转需要整图解码,瞬时内存 ≈ 4 字节/像素(这一页约 140MB),且只对带旋转 tag 的图发生(真实语料里是少数)。maxOrientationPixels = 80MP 是上限,超过则保持原样并打 warning,不静默、也不拖垮 worker。

@linus-liu-web

Copy link
Copy Markdown
Contributor Author

自查后补了三处,commit a16ef0ed(CI 重跑中):

1. 装饰器位置修正(原注释与正文有一处说法是错的)

原先装在 debug / Langfuse 之内。但这两个 wrapper 记录的是它们收到的字节(llm_debug.go 直接打 imgBytes、langfuse_wrapper.go 统计 totalImgSize),所以 trace 里显示的是模型从未收到的原始字节——正文里"debug 与 Langfuse 记录到的正是真正发出去的字节"不成立。现改为在 debug/Langfuse 之外、并发闸门之内:

concurrency( orientation( langfuse( debug( impl ) ) ) )

这样 trace 忠实反映真正发出的字节,同时整图解码(≈4 字节/像素)仍落在每模型的并发槽里,不会被并发洪峰放大内存。正文已同步更正。

2. 补两个用例

  • TestOrientImageBytesFindsEXIFBehindJFIFApp0:APP0(JFIF) 在 APP1 之前的真实相机/扫描仪布局。Go 自带 jpeg.Encode 不写 APP0,用例自建该段,防止解析器"假设 EXIF 是第一段"。
  • TestOrientImageBytesIsIdempotent:归一化两遍,第二遍必须按引用透传(tag 已随像素消失,再解码只是白烧 CPU)。

3. 外部真值交叉验证(PIL ImageOps.exif_transpose 作参考)

合成图 8 个方向      尺寸一致   不同像素 0/192   平均差 0.0000   最大差 0
真实扫描件(orient 6) 尺寸一致   不同像素 387674/34806376(最大差 4/255,灰阶换算舍入)
                     墨迹像素 IoU = 0.999901(仅 PIL 有 23、仅本实现有 35)

变换表与 PIL 在 8 个方向上都逐像素一致,不是照着记忆拍的。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: 图片送 VLM 前不做 EXIF 方向归一化,横躺的扫描件(Orientation=6)整页 OCR 错位

1 participant