Skip to content

fix(image): 支持 HEIC/HEIF 上传,并堵住静态图被误当视频处理的静默路径 - #505

Open
ExWang wants to merge 11 commits into
mainfrom
fix/pet-image-format-heic
Open

fix(image): 支持 HEIC/HEIF 上传,并堵住静态图被误当视频处理的静默路径#505
ExWang wants to merge 11 commits into
mainfrom
fix/pet-image-format-heic

Conversation

@ExWang

@ExWang ExWang commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

要解决的问题

iPhone 相机自 iOS 11 起默认存 HEIC,而 cv2.imdecode 解不了它。已在部署机复现,两个端点都是硬失败:

通路 改前
pet observe --images x.HEIC(Agent 注册主流程) 400 无法解码上传的图片(仅支持常见 jpg/png/webp 格式)
pet avatar --image x.HEIC 400 无法识别的图片
Web 上传素材做分析 同上 400

命中面是「照片先落到电脑再上传」——AirDrop 到 Mac、数据线/iCloud 下载、以及 Agent 走 CLI 读本地文件,这三条都保留 HEIC。在 iPhone 上直接用 web 传通常不受影响(iOS 相册选择器一般会转 JPEG)。

另一件事:一条今天就在损坏的静默路径

这条与 HEIC 支持没有因果关系,是修上面那个问题时顺着查出来的,独立成立:

flowchart LR
  A["identity register preview<br/>--image x.HEIC"] --> B["cli/identity.py<br/>只看 4..8 == ftyp<br/>判成 mp4/mov"]
  B --> C["报错文案指示<br/>请改用 --video"]
  C --> D["SKILL.md 要求 agent<br/>按提示自我纠正"]
  D --> E["person/router.py<br/>信客户端自报 media_kind<br/>不嗅一个字节"]
  E --> F["ffmpeg 不拼 HEIC 的 tile grid<br/>只暴露 512x512 瓦片"]
  F --> G["200 no valid subject<br/>零提示"]
Loading

HEIF/AVIF 与 mp4/mov 共用同一套 ISO BMFF 容器,只靠「字节 4..8 == ftyp」判不出图与视频。而这个误判会被 CLI 的报错文案和 SKILL 的纠错表放大成一次「自我纠正」,把照片送进视频抽帧路径。ffmpeg 把一张 3024×4032 的 HEIC 暴露成 61 条独立 hevc 流(60 块 512×512 瓦片 + 1 张 320×240 缩略图),不做 grid 拼接,于是流水线在一块瓦片上跑完全程、不报任何错。

Agent 于是告诉住户「没识别到人,换张素材」——这是假话,真实原因是格式被误路由。

修法是按容器品牌区分,接在三处:CLI 判据、pet observe 读到字节后复核、person register/preview 复核客户端自报的 media_kind。两个 SKILL.md 的话术同步更新(Agent 照它告诉住户能传什么)。

改动

解码单一入口 — 新增 identity/_image_utils.decode_image()cv2 快路径(jpg/png/webp/bmp/tiff/gif/avif)→ Pillow(pi-heif) 回退。接到全部 9 处「用户原始字节」入口(person/router.py 7 处 + pet/observe.py 1 处 + _avatar.normalize_for_storage 1 处,后者被三个存储端点共用)。

它对外的契约是「解不出返回 None、不抛」,两条支都守住:cv2.imdecode 对超出 OpenCV 自身尺寸上限的图是 CV_Assert cv2.error(一个 29 字节的 GIF 头就能声明 40000×40000 触发),不接住会让这 9 个入口把本该 400 的请求变成 500。

解码本身一律经 asyncio.to_thread:格式放开后 HEIC 走 libheif 是几百毫秒量级(改前只解 jpg/png 是几十毫秒),而后端是单进程 asyncio、同一个循环上还跑着直播转码 / 录制切片 / MQTT 感知推理。这条口径由一条扫源码的测试钉住,新增裸调直接红。

落盘归一化 — 新增 _avatar.normalize_for_storage():jpg/png/webp 原字节直通(先完整解码验真——只查魔数不够,\xff\xd8\xff 后接垃圾同样命中前 3 字节,直通落盘后识别侧解不出、界面会显示「3 张参考图」而实际注入 0 张)。其余格式解码后重编,并按用途封顶长边:

  • 头像 256 + 无损 WebP。256 不是拍脑袋的数——它就是 web 裁剪器 AvatarCropEditor.OUT,让 CLI/API 的重编支与 web 主路径产出同规格;有跨端对齐测试守着,改一侧不改另一侧就红。实测 12MP HEIC:不封顶 5.26MB、封到 1024 是 0.56MB、封到 256 是 50KB,与 web 裁剪器产物的 20–50KB 同量级。改前非白名单格式一律 400,落盘物件被 5MB 上传闸隐含夹住;本 PR 放开了格式,就得自己补回这个上界。
  • 宠物参考图 640 + JPEG q90。它唯一的消费者是 omni,而 pet_refs 拼图时恒缩到高 320 并重编 JPEG q85——640 留了一倍余量,存 WebP 只是多一道转换;且 ref_crop_N.jpg 的硬编码后缀牵动 glob 与下标解析,不宜与内容脱钩。

封顶只作用于重编支,白名单直通仍逐字节不动。

行为扩面 — BMP/TIFF/GIF/AVIF 原先能过 observe 却被头像/参考图端点 400。这条「observe 宽、存储窄」的不对称正是本轮一半文案要改的根源,一并消掉(归一化成 webp)。相应改了 test_person_router_avatar 里那条用真 BMP 断言 400 的用例。

资源与安全 — 解码后像素量加上限:字节闸挡不住解码炸弹,HEIF 的网格容器能让小文件解出上亿像素。imencodeok 判定:WebP 边长 >16383 时它返回 ok=False 且只往 stderr 打一行,不判就会把空字节当图落盘。

依赖选型

pi-heif 而非更常见的 pillow-heif:

PyPI 分类器 捆绑的编解码器
pillow-heif 1.5.0 GPLv2 libheif + x265 编码器
pi-heif 1.4.0 LGPLv3 libheif + libde265,仅解码

我们只需要解码,拿不到编码器零损失。已核实:1.4.0 在 py3.11–3.14 × darwin/linux/windows 五个平台均有 wheel、不会退化成源码编译(部署机的 tool env 是 py3.14);register_heif_opener() 不影响 Pillow 12 自带的 AVIF 插件。

绕不开这个依赖:ffmpeg 只暴露瓦片(见上一节),Pillow 12 的 features.check("heif")False

一个副作用值得记:pi-heif 不能编码 HEIF(正是它 LGPLv3 的原因),所以测试无法现造 HEIC fixture,用了 466 字节的 base64 常量内嵌,不引二进制资产。

验证

部署机端到端复测(改动已装到 tool env,pi_heif + decode_image 都在已装 wheel 里):

通路 改前 改后
pet observe --images x.HEIC 400 code: 0warnings: [](图确实被解开)
pet avatar --image x.HEIC 400 200,avatar_ext: "webp"
identity register preview --image x.HEIC CLI exit 1「请改用 --video」 code: 0,走图片路径

同一只狗的照片,HEIC 与 JPEG 各跑一遍完整链路(解码 → YOLO → 门控 → omni):

HEIC : detected=True 候选=1 warnings=[] species=狗
       summary=一只白色短毛狗,最显著特征是双耳外侧有明显的黑色斑块,鼻头粉色带黑色斑点。
JPEG : detected=True 候选=1 warnings=[] species=狗
       summary=一只白色短毛狗,最显著特征是双耳外侧有明显的黑色斑块,鼻头粉色带黑斑。

检测置信度两边都是 0.957,crop 尺寸一致;描述末尾用词差异来自模型生成的随机性。

朝向做了像素级比对:以 sips 导出的 JPEG 走 cv2 作参照,相关系数 0.9998,三个旋转对照都趋近 0。这里踩过一个坑并修掉——起初按 pi-heif 的 info["original_orientation"] 自己转向,结果把苹果 HEIC 转了两次变成横图(libheif 已按 irot 转好,而该键仍带原值)。已把「为什么不能信这个键」写进 docstring。

测试 新增 37 个测试函数(含 7 处 parametrize 展开):HEIC 解码与 BGR 通道序、既有 7 种格式不回归、白名单逐字节直通、合法魔数 + 坏 body 必拒、超大图不抛、像素炸弹(快路径与回退各一组)、重编封顶分档、brand 判定表全覆盖 + CLI 与后端两份手工表的字面量快照与交叉一致性、两侧误路由回归、以及「解码不得留在事件循环上」的结构性护栏。均已负向验证——退回对应实现后各自会红。

后端 3284 passed(4 个失败已确认在未改动的 main 上同样红)、CLI 579 passed、ruff check 全绿。

已知限制

  • AVIF 竖拍可能仍是歪的:cv2 能解 AVIF 但不应用它的 EXIF Orientation(实测 4.13),而 AVIF 走 cv2 快路径、回退不触发。属既有行为,本 PR 不改快路径判据以免动到所有既有格式的解码结果。

  • 非苹果 HEIF 的 EXIF-only 方向不支持:若某个编码器只用 EXIF 记方向、不写 irot,pi-heif 的无条件重置会让它解出来是歪的,且无法与「已转好」区分。取舍上保住绝对多数的苹果 HEIC。

  • Web 手动换头像仍不支持 HEIC:那条路是 AvatarCropEditor<img> + canvas 渲染源文件,Chrome/Firefox 解不了 HEIC(Safari 17+ 可以),且它恒导出 256×256 JPEG 才发给后端——所以后端改动对它无效,需要前端配合,不在本 PR 范围。

  • Web 上传 HEIC 做观测:能传通,但看不到本地缩略图PetAutoGenFlow 对图片走 FileReader.readAsDataURL 后直接把 data:image/heic;base64,… 交给 <img>(不经 canvas),Chrome/Firefox 渲染成空白,且 onerrorresolve("")、不出提示。这条是本 PR 带来的观感变化:改前这条路后端 400、住户至少看得到一句错误;现在字节能完整送达、观测正常返回,唯一残留的可见异常就变成了那张空白图。后端分析不受影响(候选 crop 由后端回传并正常显示),修它需要前端补一层解码或兜底文案,不在本 PR 范围。

给维护者

dependency-guard 会因 pyproject.toml + uv.lock 变更而阻塞,需要评论放行:

(放行口令须由维护者以评论形式发出,写在本正文里 dependency-guard 读不到——它只扫 issue comments。当前 head 的口令见下方评论。)

另:仓库目前没有 NOTICE / 第三方许可清单,而 LICENSE.md 写的是「entire content ... exclusively owned by Xiaomi」。这句话相对已捆绑的第三方原生库本来就不够准确——av 的 wheel 里带着编入 libx264/libx265 的 FFmpeg,opencv-python-headless 也自带一份。pi-heif 的 LGPLv3 不构成新的合规类别(比现状更轻),且平台归档里只有 miloco 自己的 3 个 wheel、不分发第三方二进制。补 NOTICE 属既有事项,没塞进本 PR。

iPhone 相机默认存 HEIC,而 cv2.imdecode 解不了它:住户把照片 AirDrop 到电脑再上传、或 Agent 走 pet observe --images 读本地文件,都会被 400 拒掉(已在部署机复现两个端点)。

- 新增 identity/_image_utils.decode_image():cv2 快路径(jpg/png/webp/bmp/tiff/gif/avif)→ Pillow(pi-heif) 回退。接到 6 处「用户原字节」入口:pet observe 的主路径与 _first_decodable(必须成对,否则主路径解得开而回退解不开,会谎报「画面确无动物」)、pet 头像/参考图端点、person 的 _decode_image_upload / _decode_b64_image / extract / register-preview / select。
- 方向:不能用 pi_heif 的 info["original_orientation"] 自己转——libheif 已按 irot 转好时该键仍带原值,拿它转会把 iPhone 竖拍 HEIC 再转一次变成横图(实测踩到并修掉)。已用 sips 导出 JPEG 作参照做像素级比对,相关系数 0.9998。
- 新增 _avatar.normalize_for_storage():jpg/png/webp 原字节直通(「存储层零转码」这条既有承诺有测试钉住),其余解码后重编——头像走无损 WebP,宠物参考图走 JPEG q90(唯一消费者 omni 恒收 JPEG q85,且 ref_crop_N.jpg 后缀是硬编码、不宜与内容脱钩)。带 imencode 的 ok 判定:WebP 边长 >16383 时它返回 ok=False 且只往 stderr 打一行,不判就会把空字节当图落盘。
- 行为扩面:BMP/TIFF/GIF/AVIF 原先能过 observe 却被头像/参考图端点 400,现一并接受(归一化成 webp)。这条不对称正是本轮一半文案要改的根源。相应改 test_person_router_avatar 里那条断言 400 的 BMP 用例。
- 堵一条今天就在损坏的自伤闭环:CLI 的 _looks_like_video_bytes 只看「字节 4..8 == ftyp」,把 HEIC/HEIF/AVIF 全判成视频,报错文案又指示 agent 改用 --video;而 person register/preview 无条件相信客户端自报的 media_kind,于是 HEIC 进 extract_from_video → ffmpeg 不拼 HEIC 的 tile grid、只暴露 512x512 瓦片 → 200「no valid subject」,零提示。新增 is_still_image_container() 按 brand 区分,接到 CLI 判据、pet observe 读字节后复核、person register/preview 复核三处;两个 SKILL.md 的话术同步(Agent 照它告诉住户能传什么)。
- 解码后像素量加上限(_MAX_DECODE_PIXELS):字节闸挡不住解码炸弹——HEIF 的网格容器能让小文件解出上亿像素。
- 依赖选 pi-heif 而非 pillow-heif:两者同作者、同样封装 libheif,但 pi-heif 只带解码器(libde265),不捆绑 x265 编码器,PyPI 分类器因此是 LGPLv3 而非 GPLv2,而我们只需要解码。已核实 1.4.0 在 py3.11-3.14 × darwin/linux/windows 五个平台均有 wheel、不会退化成源码编译(部署机的 tool env 是 py3.14),且注册后不影响 Pillow 12 自带的 AVIF 插件。ffmpeg 走不通:它把 HEIC 的 tile grid 当成几十条独立 512x512 视频流暴露、不做拼接。
- 测试 30 例:HEIC 解码、既有 7 种格式不回归、白名单逐字节直通、webp 超限退 JPEG、像素炸弹、brand 判定表(heic/heix/mif1/msf1/avif 判图,isom/mp42/qt/3gp 判视频)、两侧误路由回归。均已负向验证(退回旧实现会红)。
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

👋 感谢提交 PR @ExWang!维护者会尽快 review。

提交前请确认:

  • CI 全绿(test / lint / build)
  • 改动聚焦单一主题,便于审阅
  • 若改动了依赖(lockfile / pyproject.toml / package.json),需维护者评论 /allow-dependencies-change <当前 head SHA> 放行(之后再 push 需重新放行)

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

[PR #505]: fix(image): 支持 HEIC/HEIF 上传,并堵住静态图被误当视频处理的静默路径

作者: ExWang
范围: fix/pet-image-format-heic → main(+1073/-91,19 文件)

修改方案

要解决的问题

iPhone 自 iOS 11 起相机默认存 HEIC,而后端所有图片入口都只走 OpenCV 解码,解不了这个格式——照片经 AirDrop / 数据线 / iCloud 落到电脑再上传(以及 Agent 走 CLI 读本地文件)时全线 400。修这个问题时顺带查出一条独立成立的静默损坏路径:HEIF 与 mp4/mov 共用同一套 ISO BMFF 容器,只看「第 4~8 字节是 ftyp」判不出图与视频,而客户端自报的媒体类型后端从不复核;照片于是被送进视频抽帧路径,ffmpeg 又不拼 HEIC 的瓦片网格,流水线在一块 512×512 瓦片上跑完全程,最后回一个 200 +「没识别到人」,零报错。

整体方案 — 6 条正交主线

主线 1:解码收成单一入口,四层防线兜住「只返 None、绝不抛」

用户原始字节
  │
  ├─ OpenCV 直接解 ──成功──→ 像素量闸(1.2 亿) ──通过──→ BGR 图
  │        │                       └── 超限 ──→ None
  │        └─ 抛 cv2.error(尺寸断言)→ 记 warning,当成「没解出来」↓
  │
  └─ 没解出来 → 是纯 HEIF 品牌且没装 pi-heif?── 是 ──→ None(日志点明缺依赖)
                       │ 否
                       ↓
             Pillow(libheif) 开图 → header 宽高闸(1.2 亿) → exif_transpose
                       │                    └── 超限 / 为 0 ──→ None
                       ├─ 解码炸弹异常 ──→ None
                       ├─ 其它解码异常 ──→ None
                       ↓
                  RGB→BGR ──→ BGR 图
  • 所有「用户原始字节」现在都过同一个函数:先用 OpenCV 直接解(jpg/png/webp/bmp/tiff/gif/avif 都在这条快路径上),解不出来才退到 Pillow 经 libheif 读 HEIC/HEIF,最后统一转成全仓通用的 BGR 通道序(decode_image)。接入点共 9 处,已逐一核对:人像路由 7 处(L329 / L365 / L613 / L1258 / L1283 / L1686 / L2223)、宠物观测 1 处、落盘归一化 1 处(后者被三个存储端点共用)
  • 这个函数对外承诺「解不出返回 None、不抛」,所以把 OpenCV 自己的断言异常也接住了:一个 29 字节的 GIF 头就能声明自己 40000×40000,OpenCV 在尺寸校验里直接抛异常而非返回空——不接住,这 9 个入口会把本该 400 的请求变成 500(except cv2.error
  • 接住之后有个容易写错的细节做对了:不是就地 return None,而是把结果落成「没解出来」继续往下走回退支——这样一个被 OpenCV 尺寸断言拒掉、但 libheif 读得动的文件仍有第二次机会
  • 解码炸弹在两条支上各卡一次:快路径解完看真实 shape,回退支在 Pillow 惰性加载像素之前先读 header 里的宽高,超过 1.2 亿像素就拒(_MAX_DECODE_PIXELS)。字节闸挡不住这个——HEIF 的网格容器能用很小的文件解出上亿像素
  • 依赖缺失不静默降级也不炸后端:注册 HEIF 插件的动作包在 try 里,装不上只置一个标志位并打一行日志,等真收到 HEIC 时才在解码点报「缺 pi-heif」(_HEIF_OK)。这条短路只对纯 HEIF 品牌生效,AVIF 有意排除在外——它走 OpenCV 快路径,没 pi-heif 也能解
  • 朝向那个坑写进了 docstring 当反面教材:libheif 读取时已按容器里的 irot 转好,但 EXIF 里的方向标记仍留着原值、被 pi-heif 挪到了另一个键上;照那个键再转一次,苹果的竖拍 HEIC 会变成横图

主线 2:按容器品牌判形,三处复核客户端自报的类型

通路 客户端自报依据 本 PR 加的复核 复核命中后
CLI identity register / preview 本地文件头 4~8 字节是 ftyp 就当视频 见到 ftyp 后再看紧随的 4 字节品牌,命中 14 个静态图品牌则判为图片(_looks_like_video_bytes 不再输出「请改用 --video」,走 --image
POST /pets/observe(multipart) Content-Type / 文件名后缀 读到字节后嗅前 16 字节(pet/router.py#L150 翻回图片处理,并补一道 15MB 单张闸——视频闸比图片闸松,翻回来必须重新过紧的那道
POST /persons/register/preview(base64) body 里的 media_kind 字段 只 base64-decode 前 64 字符拿到文件头(person/router.py#L1209 就地把 media_kind 改成 "image"
  • 判形与解码共用一套品牌定义,放在同一个模块里:14 个静态图品牌(12 个 HEIF + 2 个 AVIF)拆成两个子集再取并集,纯 HEIF 那个子集单独留着给主线 1 的「缺解码器短路」用(_HEIF_BRANDS
  • 三处复核的共同口径是只改路由、不改数据:宠物侧翻 is_video 标志、人像侧改 media_kind 字段,字节本身一个都不动,都各打一行 info 日志留痕
  • CLI 侧因为跨进程拿不到后端常量,只能手抄一份平行表;这份重复不是靠注释「记得同步」维持的,而是有一条测试把两侧表取集合直接比相等,另有两条各自钉住整表字面量快照——单靠「两侧相等」漏掉「两边同时删同一个品牌」这种协同漂移(test_cli_and_backend_brand_tables_agree)。已核对该测试用 sys.path 直接指到 cli/src 导入,不依赖 CLI 包被装进后端测试环境,也不会静默 skip
  • CLI 那处品牌比较要求头部至少 12 字节,调用方读的正好是 16 字节(identity.py#L98),前置条件成立

主线 3:落盘前先验真,再按用途归一化

上传内容 落盘结果
jpg / png / webp,且确实能完整解开 原字节逐字节直通,不重编
其余任何能解开的格式(HEIC/HEIF/BMP/TIFF/GIF/AVIF…) 头像:长边封 256 + 无损 WebP;宠物参考图:长边封 640 + JPEG q90
解不开(含魔数合法但 body 损坏) 返回 None → 端点 400
  • 新增的落盘归一化函数把「验真」放在最前面:先整张解一次,解不开直接拒,之后才嗅魔数决定直通还是重编(normalize_for_storage)。顺序不能反——只查魔数的话,\xff\xd8\xff 后面接一堆垃圾同样命中前 3 字节,直通落盘后识别侧解不出,界面会显示「3 张参考图」而实际注入 0 张
  • 两个封顶值都锚到了下游的真实规格,不是拍脑袋:头像的 256 就是 web 裁剪器的输出边长,让 CLI/API 重编支与 web 主路径产出同规格;参考图的 640 是拼图环节固定缩到高 320 的两倍余量(_AVATAR_MAX_SIDE / _REF_CROP_MAX_SIDE
  • 参考图选 JPEG 而非 WebP 有两个理由:唯一消费者是 omni,而拼图时反正会重编 JPEG,存 WebP 只是多一道转换;且盘上 ref_crop_N.jpg 的硬编码后缀牵动 glob 与下标解析,不宜与内容脱钩
  • 编码失败不落空字节:OpenCV 编码在 WebP 边长超 16383 时只返回一个失败标志、不抛异常,这里判了这个标志并降级到 JPEG,JPEG 也失败才返回 Noneimencode 判 ok
  • 魔数嗅探函数的 docstring 同步改了语义说明——返回 None 现在表示「不是这三种白名单格式」,不再等于「不支持」,支不支持要看归一化函数的返回值

主线 4:解码一律离开事件循环,并用一条扫源码的测试钉住

  • 格式放开后单张 12MP HEIC 走 libheif 是几百毫秒量级(改前只解 jpg/png 是几十毫秒),而后端是单进程 asyncio,同一个循环上还跑着直播转码 / 录制切片 / MQTT 感知推理——所以人像侧 7 处解码、三个存储端点的归一化,全部丢进工作线程执行;宠物观测那处是整段选帧/检测/门控本来就已包在工作线程里
  • 这条口径不靠人读注释维持:新增一条测试逐行扫两个 router 源码,发现没走工作线程的裸调就红(test_no_sync_decode_image_in_routers)。它同时盯解码和归一化两个符号——只盯前者的话,「新加一个头像端点裸调归一化」照样能绿
  • 为配合这条,人像侧那个 base64 解码小函数改成了协程;已核对它与另一个上传解码函数的全部调用点都 await 了(分别在 L423、L274、L280),没有漏 await 导致「拿到协程对象、后续取 .shape 崩」的漏网点
  • 观测侧顺手删掉了一个会重复解码的辅助函数:兜底帧改成在既有循环里就地留一份首个解得开的图,不在返回语句里再解一次——那一次在绝大多数请求里(检测器框到了)根本用不上(pet/observe.py#L745)。语义等价性成立:批次是原列表的前缀,且「整批解不出」在下一行就抛异常了,走不到用它的地方

主线 5:抹平「观测宽、存储窄」的不对称,文案随之收口

  • BMP/TIFF/GIF/AVIF 原先能过观测端点、却被头像与参考图端点 400;归一化上线后这三个存储端点统一收下并重编,不对称消失。相应地把那条用真 BMP 断言 400 的旧用例改成断言归一化成 webp
  • 用户可见文案分三处同步:两个 Agent SKILL 的话术(照片一律走 --image,含 .HEIC/.HEIF/.AVIF;并新增一行明确告诉 Agent「照片被说成视频时不要改用 --video」)、前端中英文案里「仅支持 jpg/png/webp」的措辞、以及端点自己的 OpenAPI 描述

主线 6:依赖选 pi-heif 而非 pillow-heif

  • 两者同作者、同样封装 libheif,但 pillow-heif 捆绑 x265 编码器因而是 GPLv2,pi-heif 只带解码器 libde265、分类器是 LGPLv3;本仓只需要解码,拿不到编码器零损失
  • 绕不开这个依赖:ffmpeg 只暴露瓦片不拼网格,Pillow 12 自身的 HEIF 能力检测为假
  • 副作用记在了 PR 描述里:pi-heif 不能编码 HEIF,所以测试无法现造 HEIC 素材,用了一个 466 字节的 base64 常量内嵌,不引二进制资产
  • 版本约束是 pi-heif>=1.4.0,与本仓其余依赖的写法一致(>= 为主,仅 apscheduler 因已知大版本破坏加了上界),锁文件另有精确版本

主线之间的依赖

主线 依赖谁 依赖什么
2(判形复核) 主线 1 品牌表与 ftyp 解析跟解码同住一个模块,判形与「缺解码器短路」共用一套品牌定义
3(落盘归一化) 主线 1 直通前整解一次验真,全靠主线 1 的「解不出返 None 不抛」契约;否则坏 body 会在端点里炸成 500
4(工作线程 + 护栏) 主线 1、3 护栏测试盯的两个符号正是主线 1 与主线 3 的入口
5(文案扩面) 主线 3 存储端点不再拒非白名单格式,才敢删掉「仅支持 jpg/png/webp」的措辞
6(pi-heif) 主线 1 只有回退支用它;快路径与判形都不依赖,装不上也不影响既有格式

关键设计原则

  1. 验真先于直通 —— 白名单格式仍逐字节直通(保住「零转码」承诺),但直通的前提是整张解得开。宁可多花一次解码,也不让「魔数对、内容坏」的字节计进参考图张数,因为那种不一致到识别侧才暴露,且表现为「界面显示 3 张、实际注入 0 张」,比 400 难查得多。
  2. 封顶只作用于重编支 —— 改前非白名单格式一律 400,落盘物件被 5MB 上传闸隐含夹住;放开格式就得自己补回这个上界。但封顶不能顺手加到直通支上,否则「零转码」失效,所以两条支刻意不共享这一步。
  3. 依赖缺失降级要精确到品牌 —— HEIF 插件注册失败只置标志位不抛,避免一个可选依赖拖垮整个后端;同时短路只对纯 HEIF 品牌生效,避免误伤本来就能靠 OpenCV 解的 AVIF。
  4. 复核只改路由不改数据 —— 三处复核都只翻标志位 / 改字段,字节原样往下传,并各留一行日志。这样万一品牌表判错,坏的也只是路由,不会产生被改写过的落盘物件。
  5. 跨端口径靠测试钉死,不靠注释 —— 三处「必须同步」的隐性约定各配一条测试:解码不许留在事件循环上(扫源码)、CLI 与后端两份品牌表相等(跨包导入比集合)、头像封顶值与 web 裁剪器输出相等。三者都属于「靠人读注释守不住」的类型。

测试覆盖

主线 测试文件::用例 覆盖的 case
1 test_image_decode.py::test_decode_* HEIC 经回退支解开、既有 7 种格式不回归、垃圾字节返回 None、通道序是 BGR 不是 RGB、超大图不抛只返 None、像素炸弹在快路径与回退支各一组
1 test_image_decode.py::test_heic_without_decoder_logs_at_failure_sitetest_avif_not_short_circuited_when_heif_decoder_missingtest_non_heif_unaffected_when_decoder_missing 缺 pi-heif 时:HEIC 在解码点报缺依赖、AVIF 不被短路、非 HEIF 格式完全不受影响
2 test_image_decode.py::test_brand_table_*test_video_brands_never_treated_as_still_imagetest_non_isobmff_and_short_headerstest_cli_and_backend_brand_tables_agree 14 个静态图品牌全覆盖(非抽查)、整表字面量快照、9 个真视频品牌不得误判、非 ISO BMFF 与短头部、CLI↔后端两表相等
2 cli/tests/test_identity_video_magic.py(5 例) CLI 侧同一组:静态图品牌不判视频、真视频仍判出、非 ISO BMFF 不变、ftyp 后缺品牌字节时仍回落成视频、整表快照
2 test_person_preview_media_kind.py(2 例)、test_pet_router.py::test_observe_heic_declared_as_video_is_treated_as_image 两侧误路由回归:HEIC 自报 video 不得进抽帧器、真视频仍进抽帧器、观测端点同口径
3 test_image_decode.py::test_normalize_*test_avatar_cap_matches_web_cropper_output 白名单逐字节直通、HEIC→无损 WebP / →JPEG 两个用途、原先被拒格式现在收下、合法魔数+坏 body 必拒、封顶分档、WebP 编码失败降级 JPEG、封顶值与 web 裁剪器对齐
3 test_pet_router.py::test_avatar_accepts_heic_and_stores_webptest_reference_crops_accept_heic_and_store_jpegtest_person_router_avatar.py::test_post_non_passthrough_format_normalized 三个存储端点端到端:HEIC 进、webp/jpg 出;旧的「真 BMP 断言 400」改为断言归一化
4 test_image_decode.py::test_no_sync_decode_image_in_routers 扫两个 router 源码,裸调解码/归一化直接红(观测文件的豁免与理由写在测试内)
4 test_pet_observe.py::test_prepare_crops_goes_through_decode_imagetest_fallback_frame_is_first_decodable_not_index_zero 删掉旧辅助函数后的两条不变量:确实走统一解码入口、兜底帧取首个解得开的而非下标 0

净新增 37 个测试函数(38 增 1 删)、7 处参数化展开,与 PR 描述所声明的数字一致。

问题

本轮未发现新问题。上一轮 ci-bot review 的两条 🔵 对账如下:

上轮问题 现状
🔵 PR 描述把 dependency-guard 放行口令硬编码成某个 SHA,push 后失效 已修。正文改成「当前 head 的口令见下方评论」,不再内嵌 SHA
🔵 已知限制未提「Web 上传 HEIC 做观测能传通、但本地缩略图空白」这一由本 PR 引入的观感变化 已修。新增第四条已知限制,写明是本 PR 带来的变化、原因(FileReader 结果直接交给 <img>、错误回调静默返回空串)、以及后端分析不受影响

代码部分自上轮 review 后无改动(HEAD 仍是 08256fd,其后三个 commit 修的注释/常量论据问题均已包含在上轮范围内)。本轮独立复核的重点及结论:

  1. 解码契约的四层防线:异常接住后落成「没解出来」继续回退、而非提前返回,两条支各有独立像素闸——均已在代码中走通
  2. 三处字节复核的完备性:观测端点在任一 upload 被判视频时已强制单文件,所以只嗅第 0 个字节流不漏;人像预览改写的 media_kind 有两个下游读取点(L1281/L1295 的分派与 L1367 的判定),都在改写之后
  3. 护栏测试无假阳性/假阴性:正则要求符号后紧跟左括号,因此 to_thread(decode_image, raw) 这种传函数引用的写法不会误报;而 img = decode_image(raw) 这种裸调会命中
  4. 残留的直接解码调用:全仓只剩解码入口自身与拼图模块两处,后者安全——归一化保证盘上字节恒为 jpg/png/webp
  5. 陈旧措辞清扫:除测试内的字面量断言外,全仓已无「仅支持 jpg/png/webp」类表述

结论

LGTM — HEIC 支持与误路由修复各自独立成立、边界(异常、炸弹、编码失败、缺依赖、体积闸重过)都有代码级交代,三处「必须同步」的隐性约定各配了钉死它的测试;上一轮两条 🔵 均已在 PR 描述中处理。


由 review-pr skill v1.6 生成

上一版把落盘验真委托给 normalize_for_storage,但该函数先嗅魔数、命中即原样返回,**不经过解码**——于是 b"\xff\xd8\xff" + 垃圾字节(传输截断、拷了一半的照片)会被当成合法 jpg 落盘并计入 reference_crop_count,识别侧解不出再静默跳过,正好复现参考图端点注释点名要防的「显示 3 张、实际注入 0 张」。改前三个端点本来就先 cv2.imdecode 验真,所以把解码提到魔数嗅探之前不是新增成本;非直通格式复用这次解码结果去重编,全程只解一次。

- _MAX_DECODE_PIXELS 原先只在 Pillow 回退分支里检查,而注释点名的 PNG 炸弹走的正是 cv2 快路径。快路径补上同一道闸;cv2 无懒加载,那一次分配躲不掉,拦的是它继续进 YOLO / omni / 编码链路被反复复制放大——注释口径相应从「挡住解码炸弹」改为「挡住炸弹进入下游」。
- person extract_samples 补上字节级复核:pet observe 与 register_preview 都已按 ftyp brand 掰正被谎报成视频的静态图,这个端点漏了。三处必须同口径,缺一处就留一个同形状的洞。
- _HEIF_OK 落到实处(CodeQL py/unused-global-variable #610/#611 报得对,此前只赋值不读):decode_image 在 cv2 失败后若认出是 HEIF 家族容器而解码器不可用,就地打 event=heif_upload_without_decoder。原先只在 import 期打一次 warning,等住户几天后传 HEIC 失败时那条早已滚走,现场只剩一句笼统的「打不开」。
- CLI pet avatar 的 --image help 仍写着旧白名单 jpg/jpeg/png/webp——Agent 读 --help 决定能传什么,功能通了入口话术没通。
- 测试:新增「合法魔数 + 坏 body」三例(原有用例只测了无魔数字节,拦不住这条回归)、cv2 快路径像素闸三例、缺解码器时现场留日志、解码器缺失不牵连既有格式;把「webp 超限退 JPEG」拆成尺寸预检与 imencode ok=False 两条,后者用 mock 才真正覆盖到。均已负向验证。

订正上一个 commit 的一处计数:那条 message 写「接到 6 处用户原始字节入口」,实际列举已是 7 项且漏列 upload_person_avatar,diff 里的调用点约 12 处。历史不改,在此备注。
@github-actions github-actions Bot added size/XL and removed size/L labels Aug 6, 2026
- web i18n 的 partial_decode_failed 仍写着「仅支持 jpg/png/webp」。上一版改了 SKILL.md 里的同一句却漏了这份,而 PetAutoGenFlow 用 t(key, {defaultValue: w.message}) 渲染——key 存在时 i18n 覆盖后端 message,住户看到的就是过期文案,会被引去做无用的格式转换。zh/en 两份同步。
- 缺 pi-heif 时的短路收窄到真正需要 libheif 的 brand:AVIF 同为 ftyp 容器但由 Pillow 自带插件解,一并短路会凭空砍掉一种本可用的格式。拆出 _HEIF_ONLY_BRANDS 与 _AVIF_BRANDS,判形仍用二者并集(都不得进视频抽帧路径)。普通 AVIF 走 cv2 快路径、到不了这个分支,所以测试里把 cv2 打成解不出来逼它走回退,否则这条修复没有任何用例能触发。
- pet observe 掰正后按图片档重卡体积:上面按视频档放过了 100MB,改判成图片却不重卡,「谎报成 video」就成了绕过 15MB 图片闸的口子(像素闸能兜住内存,请求体上限该由这里守)。
- brand 表测试改成遍历表本身全覆盖(原先两侧各只抽查 6~7 个 / 共 14 个),并新增一条「CLI 与后端两份手工表必须一致」的结构性护栏——两份表在不同进程里无法共享常量,错位会造成「CLI 放行、后端当视频」。
- normalize_for_storage 的 docstring 写明 prefer 只管重编那一支:白名单恒原字节直通,故 prefer="jpg" 下一张 PNG 参考图仍以 PNG 字节存进 ref_crop_N.jpg。读取方都按内容而非后缀判型,这是刻意保留的既有行为。
@github-actions github-actions Bot added the web label Aug 10, 2026
ExWang added 8 commits August 10, 2026 09:55
_prepare_crops 的返回值里 _first_decodable(medias) 是无条件求值的,而这个兜底帧只在「检测器一个目标都没框到」时才会被用上。改动前那多出来的一次是 OpenCV 解 JPEG、可忽略;引入 HEIC 支持后它走 Pillow + libheif,一张 12MP 的 iPhone 照单次解码是几百毫秒量级,而这段跑在 asyncio.to_thread 里、同进程还并行着直播转码与感知推理。改为在既有循环里顺手记下第一张解得开的画面:语义等价(batch 与 medias 同序,且「全解不出」在此之前已抛 MediaDecodeError),实测 3 张 HEIC 的 decode_image 调用从 4 次降到 3 次。

_first_decodable 随之失去生产调用方,连同测试里 6 处已失效的 monkeypatch 一并删除——那些 patch 现在打在没人调的函数上,留着会让后来人以为兜底帧还从那里来。

另补强 brand 表的回归钉子:上一版只有「遍历表本身」+「CLI 与后端两份表必须相等」,抓得住单边漂移,抓不住「两边同时删掉同一个 brand」——那时循环变短、交叉检查仍相等,两侧全绿。两侧各加一条整表字面量快照,已负向验证(同时删掉 mia1 后两边都红)。
325e927 把兜底帧从「return 时重解第一张」改成「循环里留存」,是这个 PR 里唯一没配回归钉子的改动。补的用例同时覆盖两点:

- 语义:兜底帧是循环内**第一个解得开的**画面,不是 medias[0]。既有用例的 imdecode 替身是「第 1 次成功、第 2 次 None」,first_ok 仍与下标 0 重合——恰好绕开了不重合的那一半,新用例让首图解不开、断言兜底帧取第二张。
- 次数:3 张图就该解 3 次。改回 decode_image(medias[0]) 那种写法整套测试仍会全绿,而省下的那次解码在 HEIC 下是几百毫秒量级(libheif,且跑在 to_thread 里与直播转码抢 CPU)。已负向验证:改回旧写法该用例变红。

normalize_for_storage 是本 PR 新增的公开 API、已被三个端点调用,却漏在 __all__ 外。三处都是属性访问,行为不受影响,只是这份「本模块对外提供什么」的索引开始漏项。
对整个 PR 做了一次多视角对抗审查(正确性/安全/一致性/测试/模拟 reviewer 五路并行,每条发现再单独证伪),41 条发现里 16 条经验证成立,去重为下面几组。

- **cv2.imdecode 会抛而不是返 None**:OpenCV 对「像素 > 1<<30 或边长 > 1<<20」是 CV_Assert。一个 29 字节的 GIF 头就能声明 40000x40000 触发它,字节闸放行、异常穿透全部 9 个接入点,把本该 400 的请求变成 500——而 decode_image 的 docstring 承诺「解不出返回 None(不抛)」,同函数的 Pillow 回退分支也确实守着这条纪律,只有快路径没守。落成 img=None 而非 return None:后面的 HEIF 短路与 Pillow 回退还要跑。
- **重编产物没有上界**:改前非白名单格式一律 400,落盘物件被 5MB 上传闸隐含夹住;本 PR 放开了格式却没补回这个上界。实测 24MP HEIC 无损 WebP 是 15.6MB / 9.7s,而这些字节的唯一消费者是 28~48px 的圆头像(前端整个 blob fetch、cache: no-store)与 omni 的 320px 拼图。重编前长边封顶 1024:12MP 那张从 5.26MB / 2.6s 降到 0.56MB / 0.37s。封顶只作用于重编支,白名单直通仍逐字节不动。顺带:封顶让 _WEBP_MAX_SIDE 预检变成走不到的死分支,删掉(imencode 的 ok 判定保留,它还兜编码器 OOM)。
- **归一化在 async 端点里同步直调**:解码 + 重编是纯 CPU 活,本仓别处对这类活儿一律包 asyncio.to_thread(person extract 那几处),这三个端点是例外。补上。
- **四条关键路径零钉子**:HEIC 回退的 BGR 通道序(删掉 cvtColor 后 132 条用例全绿——红蓝调换无人发现,而毛色是识别核心特征)、observe 是否真走 decode_image(既有用例全 mock cv2.imdecode,改回裸 cv2 照样全绿)、超大图不抛、preview 用例别裸吞 HTTPException(原先连「HEIC 没解开」也吞)。四条均已负向验证。
- Pillow 的 DecompressionBombError 归入预期拒绝、不再打整段 traceback;_MAX_DECODE_PIXELS 的注释写清它是事后闸而非内存峰值上界(真实天花板是 OpenCV 的 1<<30 px)。
- 六处文案与新行为对齐:SKILL 异常表、人侧两处 OpenAPI description、两处测试注释、以及把 b"heic-bytes" 这个已失真的桩数据名改掉。
上一版把重编产物封到 1024 是对的方向但数字太松:一张 12MP HEIC 仍产出 560KB,而消费端是 28~48px 的圆头像、前端把整个 blob fetch 下来且 cache: no-store,比 web 主路径产出的常规头像(256x256 JPEG,20-50KB)高一个量级。

改为按用途分档,两个数字都锚在真实消费端而非拍脑袋:

- 头像 256 —— 正是 web 裁剪器 AvatarCropEditor 的 OUT。让 CLI/API 直传这条路与 web 主路径产出同规格的东西,而不是各走各的。实测 12MP HEIC 从 560KB 降到 50,572 字节,与 web 那条同量级。新增一条跨端对齐测试:改任一侧而不改另一侧就会红,「头像该多大」以后有唯一答案。
- 参考图 640 —— 它喂 omni 的横拼图(pet_refs 统一缩到高 320),640 留一倍余量给非方形 crop;不能与头像同档,否则拼图会被放大糊掉。

顺带订正一处自相矛盾的论证:既然已经缩放丢掉 94% 的像素,再以「避免叠加第二代损失」为由坚持无损就说不通了。无损保留,但理由改成站得住的那个——这一档体积已落在 web 主路径同量级,无损换来的是两条入口口径统一。
371a7b9 把重编封顶拆成头像 256 / 参考图 640,但有两处文字没跟上:WebP 边长那段仍写「下面的长边封顶(1024)」,normalize_for_storage 的 docstring 也没提两档取值。补齐并指向常量处的取值依据(web 裁剪器的 OUT / omni 拼图的统一高度),免得下一个人以为这两个数字是随手定的。纯注释,零行为变更。
上一版只在三个存储端点上把「解码 + 重编」挪进工作线程,却在注释里写「同 person extract_samples 的处理」、commit message 写「本仓别处一律包 to_thread」——而被引用的那处恰恰只包了推理、解码是裸的同步直调。注释和事实不符,且漏掉的正是本 PR 让它变贵的那一环:改前这些路径只解 jpg/png(几十毫秒),格式放开后 HEIC 走 libheif 是几百毫秒量级;register/preview 的多图批量分支逐张同步解,而 media_b64_list 只有 docstring 一句「建议 ≤10」、代码不校验,也没有请求体体积闸——10 张 12MP HEIC 能把事件循环连续占住约 3 秒,期间直播画面卡住、感知事件积压。

- 人像侧 7 处解码统一 await asyncio.to_thread:_decode_image_upload、_decode_b64_image、extract_samples、register/preview 的单图与多图批量、extract 端点、select 端点。其中 _decode_b64_image 原为同步函数,唯一调用方 register_sample_batch 本就是 async,整体改 async 而非在调用点包一层,免得留下同步孔。
- 三处 to_thread 注释的措辞订正为事实。
- 新增 test_no_sync_decode_image_in_routers:扫 router 源码,出现未经 to_thread 的 decode_image( 直接红。靠人读注释守不住这条口径——上一轮就是这么漏的。已负向验证:改回任一处裸调该用例失败。
两条都是上一轮 review 挑出的、我自己留下的问题:

- 护栏有个真漏洞:它只盯 decode_image,而三个存储端点丢进工作线程的其实是 normalize_for_storage(解码 + 重编两步,比裸解码更贵)。将来新加一个头像类端点裸调它,护栏照样全绿——而那正是这条护栏写来防的事。两个符号并入同一条正则,字符类里带 . 以覆盖 `_avatar.normalize_for_storage(` 这种带模块前缀的写法。已负向验证:把 pet 头像端点改回裸调,护栏现在会红(旧版抓不到)。
- pet/observe.py 原先"扫完再把结果整文件丢掉"来表达豁免。豁免本身没错(唯一解码点在 _prepare_crops,整段已由 observe_pet 包在 to_thread 里,逐行扫全是假阳性),错的是这个形状——读代码的人会以为它也在覆盖范围内。改成直接不进扫描列表,理由写在注释里。
- 头像常量的论据是错的:注释写「展示端是 28~48px 的圆」,实测调用点是 24~96(FamilyStrip 34 / HeroNow 28 / ProfilePanel 48 / Drawer 96),按注释自己的 3x 算法 96×3=288 > 256,结论悬空。论据换成站得住的那个:256 的依据不是「展示多大」,而是「与 web 裁剪器 AvatarCropEditor.OUT 逐字对齐」——web 上传的每张头像本来就恒为 256×256,那点 3x 屏欠采样是既有行为、两条入口一视同仁;真要提高得两侧一起调,有对齐测试守着。
- 同段「让 CLI/API 直传这条路与 web 产出同规格」措辞偏宽:只有重编支产出 256,白名单直通的长边完全不受限。docstring 里限定清楚。
上一轮给三处调用加 to_thread 注释时,参考图那处整段抄了头像的措辞,说这里做「无损 WebP 编码」——而它传的是 prefer="jpg"、从不走 WebP。同一段上面两行还写着「存 webp 只是多一道转换」,自相矛盾。改一句话,两处注释现与各自的 prefer 实参一致。
@ExWang

ExWang commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

/allow-dependencies-change 08256fd

@ExWang

ExWang commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

/review

@ExWang
ExWang requested review from Molly-3000 and idootop August 10, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants