docs: P10 图片处理文档同步 — iter-22 迭代记录、SKILL.md 第十五章(可复用模式/设计决策/踩坑总结)、project_memory P10 约定;与 8e3ddc5 代码提交一并推送
CI / Lint, Typecheck & Test (push) Has been cancelled

This commit is contained in:
2026-07-07 20:10:44 +08:00
parent 8e3ddc5a1e
commit 919c6a4544
3 changed files with 412 additions and 2 deletions
+174
View File
@@ -0,0 +1,174 @@
# 迭代 22 — P10 图片处理(imagetool + image_pipeline
## 本轮目标
延续 P9 功能扩展后,补齐 PyFlowX 在图片处理场景的能力,覆盖三个方向:
1. **P10.1 基础操作子命令**`imagetool` 工具的 7 个基础操作子命令
resize/crop/rotate/flip/convert/watermark/compress)。
2. **P10.2 元数据与信息子命令**4 个元数据子命令
info/exif/histogram/colors)。
3. **P10.3 批量 DAG 编排**`image_pipeline` 链式 DAG 构造器,
将多步图片操作封装为 `Graph`,前一步输出作为后一步输入。
工具形态:单一 `imagetool` + `office` extraPillow 通过 `pyflowx[office]` 安装),
不破坏项目零运行时依赖原则。
## 改动文件清单
### 新增
- `src/pyflowx/ops/files/imagetool.py` — 11 个 `@px.tool("imagetool", ...)`
子命令 + `_save_image`/`_load_font`/`_resolve_position`/`_print_exif`/
`_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif`/
`_print_histogram_buckets` 辅助函数(520 行)。
- `src/pyflowx/imaging.py``image_pipeline()` 链式 DAG 构造器 +
`_make_step_fn` 闭包工厂(130 行)。
- `tests/cli/test_imagetool.py` — 25 用例覆盖 11 子命令 + `_require_pil`
守卫(316 行)。
- `tests/test_imaging.py` — 9 用例覆盖 DAG 拓扑与执行(158 行)。
### 修改
- `src/pyflowx/__init__.py` — 导出 `image_pipeline`,加入 `__all__`
- `src/pyflowx/cli/pf.py``_TOOL_MODULES` 注册
`"imagetool": "pyflowx.ops.files.imagetool"``_TOOL_ALIASES` 添加
`"image"`/`"imagetool"`/`"img"` 三个别名。
## 关键决策与依据
### 1. 单一 `imagetool` + office extra
不拆分为 `imageops`/`imagemeta` 等多工具,所有图片操作集中在
`ops/files/imagetool.py`。Pillow 通过 `pyproject.toml``[office]`
extra 安装(`pillow>=10.4.0`),运行时 try/except 导入 + `HAS_PIL` 标志,
未安装时 `_require_pil()` 打印 `pip install pyflowx[office]` 提示而非崩溃。
依据:项目零运行时依赖原则不可破坏;pdftool 已有相同模式
`pypdf` 通过 `[office]` extra)。
### 2. `_save_image` 统一保存逻辑
所有需要保存图片的子命令(resize/crop/rotate/flip/convert/watermark/compress
委托 `_save_image(img, output, fmt, quality)` 统一处理:
- **JPG → JPEG 规范化**`save_fmt == "JPG"` 时改写为 `"JPEG"`
否则 Pillow `img.save(format="JPG")``KeyError: 'JPG'`
- **JPEG 强制 RGB 转换**JPEG 不支持 alpha 通道,保存前
`img.convert("RGB")` 去 alpha,避免 `cannot write mode RGBA as JPEG`
- **JPEG/WEBP 应用 quality**:其他格式(PNG/BMP/GIF 等)忽略 quality。
依据:避免每个子命令重复处理格式规范化与 alpha 通道问题。
### 3. EXIF 用 `exif.tobytes()` 而非 `bytes(exif)`
Pillow 10+ 的 `Image.Exif` 对象,`bytes(exif)` 调用抛
`ValueError: bytes must be in range(0, 256)`。必须用 `exif.tobytes()`
序列化为字节串后再传给 `img.save(exif=exif_bytes)`
`_save_exif` 辅助函数封装此逻辑:
```python
exif_bytes = exif.tobytes() if exif else b""
img.save(output_path, exif=exif_bytes)
```
### 4. `quantize(colors=N)` 实际色数可能 < N
`Image.quantize(colors=count)` 对简单图片(如纯色)可能返回少于
请求色数的调色板。直接按 `count` 遍历会 `IndexError: list index out of range`
修复:用 `actual_count = len(palette) // 3` 动态计算实际色数,
`min(count, actual_count)` 防越界:
```python
actual_count = len(palette) // 3
print(f"主色调 (前 {min(count, actual_count)} 色):")
for i in range(min(count, actual_count)):
...
```
### 5. Pillow 10+ Transpose 枚举迁移
`Image.FLIP_LEFT_RIGHT`(模块级常量)在 Pillow 10+ 已弃用,
pyrefly 无法解析。必须用 `Image.Transpose.FLIP_LEFT_RIGHT`
(枚举形式)。
`image_flip` 中根据 direction 选择枚举:
```python
method = Image.Transpose.FLIP_LEFT_RIGHT if direction == "horizontal" else Image.Transpose.FLIP_TOP_BOTTOM
flipped = img.transpose(method)
```
### 6. `_make_step_fn` 闭包工厂避免循环变量捕获
`image_pipeline` 中若直接在 for 循环内定义闭包:
```python
for i, (op_name, params) in enumerate(steps):
def _run():
base_fn(input_path, output_path, **params) # 陷阱!
...
```
所有任务的 `_run` 会捕获**最后一次循环**的 `input_path`/`output_path`/`params`
导致所有步骤操作同一文件。
修复:提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)`
利用每次调用的独立作用域正确捕获:
```python
def _make_step_fn(base_fn, input_path, output_path, params):
def _run() -> None:
base_fn(input_path, output_path, **params)
return _run
```
每次循环调用 `_make_step_fn(...)` 创建新作用域,闭包绑定该次调用的参数。
### 7. PLR0912 分支数控制
`image_exif` 函数最初 13 分支(show/clear/set 各分支 + 错误处理)超过
ruff `PLR0912` 限制(≤12)。提取 4 个辅助函数降至合规:
- `_print_exif(exif)` — 打印 EXIF 标签
- `_apply_exif_modifications(exif, set_items, clear)` — 应用 clear + set
- `_apply_single_exif_set(exif, item)` — 解析单个 KEY=VALUE
- `_save_exif(img, exif, output_path)` — 保存图片与 EXIF
## 验证结果
```
ruff check . → All checks passed!
ruff format --check → 131 files already formatted
pyrefly check . → 0 errors (68 suppressed)
pytest --cov=pyflowx → 1560 passed in 11.72s
coverage → 97.15%branch;门槛 95%
```
各模块覆盖率(P10 新增部分):
- `imagetool.py` 88%213 stmts, 19 miss — 主要是 ImportError 回退分支
与少量错误路径;总覆盖率 97.15% 不受影响)
- `imaging.py` 100%`image_pipeline` 拓扑/执行/convert 扩展名/naming 模板
全覆盖)
CLI 注册验证:
- `imagetool``_TOOL_MODULES`
- `img`/`image`/`imagetool` 三个别名在 `_TOOL_ALIASES`
- `tests/cli/test_tool_modules.py` 自动覆盖新工具注册
## 遗留事项
- **P11 候选方向**(用户已确认为后续阶段输入,不在 P10 范围内):
- OCR 文字识别(需 `pytesseract` 新依赖,违反零依赖原则,需用户确认)
- 滤镜与增强(模糊/锐化/边缘检测/浮雕/去噪,纯 Pillow 可实现)
- 批量文件夹处理(遍历目录批量应用同一操作,生成缩略图网格)
- 图片拼接与 GIF(多图水平/垂直拼接,GIF 帧拆分与合成)
- **`imagetool.py` 覆盖率 88%**ImportError 回退分支(`HAS_PIL = False`
在已安装 Pillow 的环境中无法触发,属可接受缺口。
- **`image_pipeline` 仅支持链式 DAG**:当前实现是单链(每步依赖前一步),
不支持分叉/汇合(如同一源图应用多操作后合并)。如需分叉,可扩展
`steps` 为 DAG 描述结构。
## 归档说明
iter-22 暂不归档(前 5 次 iter-16~20 已归档至
`.trae/skills/pyflowx-development/SKILL.md`iter-21/22 保留在 docs/)。
下次清理窗口为 iter-26 前夕,届时 iter-21/22 一并归档。
@@ -0,0 +1,167 @@
# P10 图片处理收尾计划(文档同步 + 提交)
## Context
P10 图片处理代码已完整实现并通过全部门禁,但**尚未提交**,且过程文档、SKILL 行为同步、project_memory 约定记录三项收尾工作未完成。本计划聚焦于把 P10 工作闭环:编写 iter-22 迭代记录、同步 SKILL.md 行为变更、追加 project_memory 约定、git commit + push。
用户已确认本次计划范围 = 仅收尾 P10(不含 P11 扩展)。P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)作为后续阶段输入参考,不在本计划内。
## 当前状态分析
### 已完成(未提交)
```
git status --short
M src/pyflowx/__init__.py # 导出 image_pipeline
M src/pyflowx/cli/pf.py # 注册 imagetool + 别名
?? src/pyflowx/imaging.py # image_pipeline DAG 构造器 (130 行)
?? src/pyflowx/ops/files/imagetool.py # 11 子命令 (520 行)
?? tests/cli/test_imagetool.py # 25 测试 (316 行)
?? tests/test_imaging.py # 9 测试 (158 行)
?? .trae/documents/ # 本计划文件所在目录
```
### 门禁状态(已验证通过)
- ruff check / format → 全绿
- pyrefly check → 0 错误(68 suppressed
- pytest → 1560 passed
- coverage → 97.15%branch;门槛 95%
- CLI 注册 → `imagetool``_TOOL_MODULES``img`/`image`/`imagetool``_TOOL_ALIASES`
### 分支跟踪
- 当前分支:`develop`
- 跟踪远程:`origin/develop`push 允许,无需询问)
## 实现步骤
### 步骤 1:创建 iter-22 迭代记录
**新建 `.trae/docs/iter-22-image-processing.md`**,参照 [iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md) 的结构:
1. **本轮目标** — P10 三方向:基础操作 7 子命令 / 元数据 4 子命令 / `image_pipeline` DAG 构造器
2. **改动文件清单** — 新增 4 文件 + 修改 2 文件(列出每个文件的职责)
3. **关键决策与依据**6 条,基于实际实现踩坑):
- **JPG → JPEG 规范化**`_save_image``save_fmt == "JPG"` 时改写为 `"JPEG"`,否则 Pillow `img.save(format="JPG")``KeyError`
- **JPEG 强制 RGB 转换**JPEG 不支持 alpha 通道,保存前 `img.convert("RGB")` 去 alpha
- **EXIF 用 `exif.tobytes()` 而非 `bytes(exif)`**Pillow 10+ 的 `Image.Exif` 对象 `bytes()` 调用抛 `ValueError: bytes must be in range(0, 256)`,必须用 `tobytes()`
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板可能少于请求色数,用 `actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)` 防越界
- **Pillow 10+ Transpose 枚举**`Image.FLIP_LEFT_RIGHT`(旧)已弃用,用 `Image.Transpose.FLIP_LEFT_RIGHT`(新)
- **`_make_step_fn` 闭包工厂**`image_pipeline` 中若直接在循环内定义闭包,所有任务会捕获最后一次循环的 `input_path`/`output_path`/`params`;提取为独立函数 `_make_step_fn(base_fn, input_path, output_path, params)` 利用每次调用的独立作用域正确捕获
4. **验证结果** — 贴上门禁输出(ruff/pyrefly/pytest 1560 passed/coverage 97.15%
5. **遗留事项** — P11 候选方向(OCR/滤镜/批量文件夹/拼接GIF);`imagetool.py` 覆盖率 88%ImportError 回退分支未覆盖,可接受)
6. **归档说明** — iter-22 暂不归档(下次清理窗口为 iter-26 前夕,与 iter-21 一并归档)
### 步骤 2:同步 SKILL.md 行为变更
**修改 [.trae/skills/pyflowx-development/SKILL.md](file:///home/zhou/pyflowx/.trae/skills/pyflowx-development/SKILL.md)**
1. **更新 frontmatter description** — 末尾追加 "并同步迭代 22(P10)的行为变更",提及图片处理工具
2. **更新开头说明**(第 8 行附近)— "并同步迭代 21(P9 功能扩展)与迭代 22(P10 图片处理)的行为变更"
3. **新增第十五章 `## 十五、图片处理(P10,iter-22`**(追加在第十四 章之后),包含 4 个小节:
#### 可复用模式
- **可选依赖 try/except + `_require_*` 守卫**:顶部 `try: from PIL import Image; HAS_PIL = True; except ImportError: HAS_PIL = False`,每个子命令入口 `if not _require_pil(): return` 打印 `pip install pyflowx[office]` 提示
- **`@px.tool` 装饰器注册子命令**`@px.tool("imagetool", subcommand="r", help="调整尺寸")`subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co
- **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器,每个 step 生成 `TaskSpec``depends_on` 链式依赖,`_make_step_fn` 闭包工厂正确捕获每轮参数
- **`naming` 模板占位符**`{stem}`(累积 stem/`{step}`(操作名)/`{ext}`(当前扩展名,convert 步骤可能改变)
#### 设计决策
- **单一 `imagetool` + office extra**:不拆分为多个工具,所有图片操作集中在 `ops/files/imagetool.py`Pillow 通过 `pyflowx[office]` extra 安装,零运行时依赖原则不破坏
- **`_save_image` 统一保存逻辑**JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality 参数;避免每个子命令重复处理
- **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作,`operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责
#### 踩坑总结
- **Pillow 10+ API 迁移**`Image.FLIP_LEFT_RIGHT` 已弃用 → `Image.Transpose.FLIP_LEFT_RIGHT``bytes(exif)` 抛错 → `exif.tobytes()`
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片量化后调色板少于请求色数,必须用 `actual_count = len(palette) // 3` 动态计算
- **闭包捕获循环变量陷阱**`image_pipeline` 中直接在循环内定义闭包会捕获最后一次循环的变量;提取 `_make_step_fn(base_fn, input_path, output_path, params)` 为独立函数利用每次调用的独立作用域
- **PLR0912 分支数限制**`image_exif` 函数最初 13 分支超过 12 限制,提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/`_save_exif` 4 个辅助函数降至合规
### 步骤 3:更新 project_memory.md
**修改 [project_memory.md](file:///home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md)**
1. **Engineering Conventions 末尾追加** P10 约定(约 8 条):
- `imagetool` 模式:try/except PIL 导入 + `HAS_PIL` 标志 + `_require_pil()` 守卫 + `@px.tool` 装饰器
- `_save_image` 统一保存:JPG→JPEG 规范化 + JPEG 强制 RGB + JPEG/WEBP 应用 quality
- EXIF 序列化用 `exif.tobytes()`Pillow 10+),不用 `bytes(exif)`
- `quantize(colors=N)` 实际色数可能 < N,用 `actual_count = len(palette) // 3` 动态计算
- Pillow 10+ 用 `Image.Transpose.FLIP_LEFT_RIGHT`(非弃用的 `Image.FLIP_LEFT_RIGHT`
- `image_pipeline``_make_step_fn(base_fn, input_path, output_path, params)` 闭包工厂避免循环变量捕获陷阱
- `image_pipeline` 委托 `imagetool.image_*` 函数,不重新实现操作(单一职责)
- `pytest.importorskip("PIL")` 跳过未安装 Pillow 的测试模块
2. **Lessons Learned 末尾追加** iter-22 总结条目:
- `iter-22 (P10 image processing) completed: imagetool 11 subcommands (resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG constructor; 1560 tests green, coverage 97.15%; SKILL.md synced (added section 15); next archive window at iter-26`
### 步骤 4Git commit + push
按文件名逐个 `git add`(不用 `git add .`/`-A`),然后 commit + push。
**暂存文件**8 个):
```
git add src/pyflowx/ops/files/imagetool.py
git add src/pyflowx/imaging.py
git add src/pyflowx/__init__.py
git add src/pyflowx/cli/pf.py
git add tests/cli/test_imagetool.py
git add tests/test_imaging.py
git add .trae/docs/iter-22-image-processing.md
git add .trae/skills/pyflowx-development/SKILL.md
```
**Commit message**(遵循 [.trae/rules/git-commit-message.md](file:///home/zhou/pyflowx/.trae/rules/git-commit-message.md) 中文风格,参照 `5ac7fd3` P9 提交):
```
feat: P10 图片处理 — imagetool 11 子命令(resize/crop/rotate/flip/convert/watermark/compress/info/exif/histogram/colors) + image_pipeline DAG 构造器;1560 测试全绿,覆盖率 97.15%
```
**Push**`git push`develop 跟踪 origin/develop,直接 push
## 关键文件
| 操作 | 文件 |
|------|------|
| 新建 | `.trae/docs/iter-22-image-processing.md`iter-22 迭代记录) |
| 修改 | `.trae/skills/pyflowx-development/SKILL.md`(新增第十五章 + frontmatter 更新) |
| 修改 | `/home/zhou/.trae-cn/memory/projects/-home-zhou-pyflowx/project_memory.md`(追加 P10 约定) |
| 提交 | 上述 3 文件 + 已实现的 5 文件(imagetool.py/imaging.py/__init__.py/pf.py/2 测试文件) |
## 复用的现有模式
- **iter-21 迭代记录结构**[iter-21-p9-feature-extension.md](file:///home/zhou/pyflowx/.trae/docs/iter-21-p9-feature-extension.md)(本轮目标 / 改动文件清单 / 关键决策与依据 / 验证结果 / 遗留事项 / 归档说明)
- **SKILL.md 章节结构**:参照第十四章 `## 十四、任务编排与持久化(P9,iter-21)``### 可复用模式` / `### 设计决策` / `### 踩坑总结` 三段式
- **commit message 风格**:参照 `5ac7fd3` P9 提交(`feat: P9 功能扩展 — ...1525 测试全绿,覆盖率 97.58%`
- **project_memory 条目风格**:参照第 125 行 iter-21 总结条目
## 验证步骤
1. **文档完整性**
- `iter-22-image-processing.md` 包含全部 6 个小节(目标/文件/决策/验证/遗留/归档)
- `SKILL.md` 第十五章包含可复用模式/设计决策/踩坑总结三小节
- `project_memory.md` Engineering Conventions 追加 8 条、Lessons Learned 追加 1 条
2. **Git 状态**
- `git status` 干净(无未提交文件)
- `git log --oneline -1` 显示新 commit
- `git push` 成功(origin/develop 已更新)
3. **门禁复跑**(可选,确保文档修改未误改代码):
```bash
uvx --from pyflowx pymake tc # ruff + pyrefly + pytest
```
## 验收标准
- `.trae/docs/iter-22-image-processing.md` 创建完成,结构对齐 iter-21
- `.trae/skills/pyflowx-development/SKILL.md` 新增第十五章,frontmatter description 更新
- `project_memory.md` 追加 P10 约定(Engineering Conventions + Lessons Learned
- Git commit 成功,push 到 origin/develop 成功
- `git status` 干净
## 不在范围内
- P11 高级功能(OCR/滤镜/批量文件夹/拼接GIF)— 用户已确认为后续阶段输入,本次不实现
- 代码修改 — P10 代码已实现完成,本次仅文档同步 + 提交
- 归档 — iter-22 暂不归档(下次清理窗口 iter-26 前夕,与 iter-21 一并归档到 SKILL.md
+71 -2
View File
@@ -1,11 +1,12 @@
---
name: "pyflowx-development"
description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档迭代 06-20 中可复用的架构模式、踩坑总结与设计决策,并同步迭代 21(P9)的行为变更。在设计与调度器、CLI、YAML 编排、取消机制、序列化、观测性、错误诊断、状态后端、性能优化、任务编排、检查点恢复、监控导出相关的功能时参考。"
description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档迭代 06-20 中可复用的架构模式、踩坑总结与设计决策,并同步迭代 21(P9)与迭代 22P10 图片处理)的行为变更。在设计与调度器、CLI、YAML 编排、取消机制、序列化、观测性、错误诊断、状态后端、性能优化、任务编排、检查点恢复、监控导出、图片处理相关的功能时参考。"
---
# PyFlowX 开发知识库
本技能归档自迭代 06-20 的过程记录,并同步迭代 21(P9 功能扩展)的行为变更。
本技能归档自迭代 06-20 的过程记录,并同步迭代 21(P9 功能扩展)
迭代 22(P10 图片处理)的行为变更。
按主题分类整理可复用知识。过程性细节(覆盖率数字、命令输出)已剔除,
仅保留架构模式、设计依据与陷阱总结。
@@ -713,3 +714,71 @@ description: "PyFlowX 项目(DAG 任务调度库)的开发知识库。归档
- **`metrics_text` 条件化输出**:最初实现总是输出 HELP/TYPE 行(即便无数据),
导致 `reset()` 后文本仍含 `pyflowx_task_total`,违反测试预期。所有
section 都应按数据存在性条件化,与 retries/run_total 一致。
## 十五、图片处理(P10iter-22
### 可复用模式
- **可选依赖 try/except + `_require_*` 守卫**:模块顶部
`try: from PIL import Image, ImageDraw, ImageFont; HAS_PIL = True;
except ImportError: HAS_PIL = False`,每个子命令入口
`if not _require_pil(): return` 打印 `pip install pyflowx[office]`
提示而非崩溃。与 `pdftool.py` 的 `pypdf` 守卫模式一致。
- **`@px.tool` 装饰器注册子命令**`@px.tool("imagetool", subcommand="r",
help="调整尺寸")`subcommand 短别名(r/c/ro/fl/cv/wm/cp/i/e/hi/co
供 CLI 快捷调用(如 `pf img r input.png out.png --width 100`)。
- **`image_pipeline` DAG 构造器**:参照 `compose()` 函数式构造器,
每个 step 生成 `TaskSpec``depends_on` 链式依赖形成单链 DAG
`operations` 字典映射操作名到 `imagetool.image_*` 函数,不重新实现操作。
- **`naming` 模板占位符**`{stem}`(累积 stem,每步更新为输出文件 stem)/
`{step}`(操作名)/`{ext}`(当前扩展名,`convert` 步骤可能改变)。
默认 `"{stem}_{step}{ext}"`(如 `input_resize.jpg`)。
- **`_make_step_fn` 闭包工厂**:提取为独立函数
`_make_step_fn(base_fn, input_path, output_path, params)` 返回 `_run`
闭包,利用每次调用的独立作用域正确捕获每轮参数,避免循环变量捕获陷阱。
- **`pytest.importorskip("PIL")`**:测试模块顶部跳过未安装 Pillow 的环境,
避免在未安装 `[office]` extra 的 CI 上失败。
### 设计决策
- **单一 `imagetool` + office extra**:不拆分为 `imageops`/`imagemeta`
等多工具,所有图片操作集中在 `ops/files/imagetool.py`。Pillow 通过
`pyproject.toml` 的 `[office]` extra 安装(`pillow>=10.4.0`),
零运行时依赖原则不破坏。
- **`_save_image` 统一保存逻辑**:所有需要保存图片的子命令委托
`_save_image(img, output, fmt, quality)` 处理 JPG→JPEG 规范化 +
JPEG 强制 RGB 转换 + JPEG/WEBP 应用 quality 参数,避免每个子命令
重复处理格式与 alpha 通道问题。
- **`image_pipeline` 委托 imagetool 函数**:不重新实现图片操作,
`operations` 字典映射操作名到 `imagetool.image_*` 函数,保持单一职责
与单一实现源。
- **EXIF 用 `exif.tobytes()`**Pillow 10+ 的 `Image.Exif` 对象
`bytes(exif)` 抛 `ValueError`,必须用 `exif.tobytes()` 序列化。
`_save_exif` 辅助函数封装此逻辑。
- **PLR0912 分支数控制**`image_exif` 函数最初 13 分支超过 12 限制,
提取 `_print_exif`/`_apply_exif_modifications`/`_apply_single_exif_set`/
`_save_exif` 4 个辅助函数降至合规。
### 踩坑总结
- **Pillow 10+ API 迁移**`Image.FLIP_LEFT_RIGHT`(模块级常量)已弃用,
pyrefly 无法解析 → 改用 `Image.Transpose.FLIP_LEFT_RIGHT`(枚举形式);
`bytes(exif)` 抛 `ValueError: bytes must be in range(0, 256)` →
改用 `exif.tobytes()`。
- **`quantize(colors=N)` 实际色数可能 < N**:简单图片(如纯色)量化后
调色板可能少于请求色数,直接按 `count` 遍历会 `IndexError`。必须用
`actual_count = len(palette) // 3` 动态计算并 `min(count, actual_count)`。
- **JPEG 不支持 alpha 通道**RGBA 模式图片直接保存为 JPEG 抛
`cannot write mode RGBA as JPEG`。`_save_image` 中 JPEG 分支强制
`img.convert("RGB")` 去 alpha。
- **JPG 不是 Pillow 合法格式名**`img.save(format="JPG")` 抛
`KeyError: 'JPG'`。`_save_image` 中 `save_fmt == "JPG"` 时改写为
`"JPEG"` 规范化。
- **闭包捕获循环变量陷阱**`image_pipeline` 中若直接在 for 循环内
定义闭包 `def _run(): base_fn(input_path, output_path, **params)`
所有任务的 `_run` 会捕获**最后一次循环**的变量。必须提取为独立函数
`_make_step_fn(base_fn, input_path, output_path, params)` 利用每次
调用的独立作用域。
- **`draw.textbbox()` 返回 float**`text_w = bbox[2] - bbox[0]` 是
float,传给 `_resolve_position`(期望 int)会触发类型警告。用
`int(bbox[2] - bbox[0])` 显式转换。