internal/application/service/image_multimodal.go:781 的 readImageBytes 把知识库图片的原样字节取出,vlm.VLM.Predict 直接 base64 成 data: URI 发给模型(internal/models/vlm/remote_api.go:103)。全链路没有任何 EXIF 方向处理:git grep -ni "exif\|orientation" -- '*.go' 除文档解析链路的 internal/infrastructure/docparser/paddleocr_vl_converter.go:98(useDocOrientationClassify: false)外 0 命中,非测试代码里也没有任何图片解码/缩放/重编码。
后果
相机与扫描仪按传感器姿态存像素,把"该怎么转"写在 EXIF tag 0x0112(Orientation)里:
- 浏览器与图片查看器读这个 tag —— 所以
/preview 返回原始字节,人眼看 UI 一切正常;
- 视觉模型读像素矩阵、普遍不读 tag —— tag=3/6/8 的图到达模型时是倒着的、或躺着的。模型既要认旋转后的字形,又要在旋转过的坐标系里做表格二维推理,合并单元格锚点、姓名、电话就会成片错,而且每次错的位置都不一样(模型在猜归属,这比稳定错更难发现)。
实物取证
拿一份真实扫描件(JPEG,919,752 字节)解析其头部:
| 项 |
值 |
| SOF0 尺寸 |
7016 × 4961(横躺;A4@600DPI 的纵向尺寸正是 4961 × 7016) |
| EXIF IFD0 |
Orientation(0x0112) = 6 |
同一批图片里带旋转 tag 的是少数(几十张,且都是"原件 + 导出副本"成对出现):这是"少数派路径、但一旦命中就影响整份文档"的形态。
A/B 实测(同一模型、同一 prompt):原样字节两次跑出不同的错误位置(表格分界线漂移、姓名与手机号成片错),按 EXIF 摆正后两次都正确。错误位置每次都变,说明模型在猜布局归属——比稳定出错更难被发现。
涉及链路
Predict 在生产代码里共 8 个调用点,全部直接把原始字节交给 VLM,没有任何一处做方向处理:
internal/application/service/agent_service.go:269
internal/application/service/image_action_loop.go:85,109
internal/application/service/image_multimodal.go:512
internal/application/service/temporary_document.go:600,624
internal/handler/model.go:613
internal/handler/session/image_upload.go:86
建议修法
修在装配点而不是调用点:internal/models/vlm/vlm.go:93 NewVLM 里给实例套一个方向归一化装饰器,一次覆盖全部调用点与三个实现(remote API / ollama / weknoracloud):
- 只走 JPEG 段标记读 APP1 → IFD0 → tag
0x0112(支持 II/MM 字节序),不触碰图像数据;
- 按 EXIF 定义实现 2/4(镜像)、3(180°)、6(顺时针 90°)、8(逆时针 90°)、5/7(对角镜像)八种变换,重新编码后 tag 不再残留(否则浏览器会再转一次);
- 无 tag、tag=1、非 JPEG、解码失败一律原字节按引用透传,绝大多数图片零开销;
- 超大树画布(解码要占 ≈4 字节/像素)保持原样并打 warning,避免拖垮 worker。
不建议在入库时归一化:那会改写用户存储的原始文件,并改变 ImageInfo.SHA256(现契约是"原始文件的哈希")与去重语义,还要同时改 6 个存储后端。
我们按上面的口径实现并提了 PR:见下条评论。
internal/application/service/image_multimodal.go:781的readImageBytes把知识库图片的原样字节取出,vlm.VLM.Predict直接 base64 成data:URI 发给模型(internal/models/vlm/remote_api.go:103)。全链路没有任何 EXIF 方向处理:git grep -ni "exif\|orientation" -- '*.go'除文档解析链路的internal/infrastructure/docparser/paddleocr_vl_converter.go:98(useDocOrientationClassify: false)外 0 命中,非测试代码里也没有任何图片解码/缩放/重编码。后果
相机与扫描仪按传感器姿态存像素,把"该怎么转"写在 EXIF tag
0x0112(Orientation)里:/preview返回原始字节,人眼看 UI 一切正常;实物取证
拿一份真实扫描件(JPEG,919,752 字节)解析其头部:
Orientation(0x0112) = 6同一批图片里带旋转 tag 的是少数(几十张,且都是"原件 + 导出副本"成对出现):这是"少数派路径、但一旦命中就影响整份文档"的形态。
A/B 实测(同一模型、同一 prompt):原样字节两次跑出不同的错误位置(表格分界线漂移、姓名与手机号成片错),按 EXIF 摆正后两次都正确。错误位置每次都变,说明模型在猜布局归属——比稳定出错更难被发现。
涉及链路
Predict在生产代码里共 8 个调用点,全部直接把原始字节交给 VLM,没有任何一处做方向处理:建议修法
修在装配点而不是调用点:
internal/models/vlm/vlm.go:93 NewVLM里给实例套一个方向归一化装饰器,一次覆盖全部调用点与三个实现(remote API / ollama / weknoracloud):0x0112(支持II/MM字节序),不触碰图像数据;不建议在入库时归一化:那会改写用户存储的原始文件,并改变
ImageInfo.SHA256(现契约是"原始文件的哈希")与去重语义,还要同时改 6 个存储后端。我们按上面的口径实现并提了 PR:见下条评论。