feat: 新增图片处理工具链与imagetool CLI命令

新增完整的图片处理能力:
1. 新增imagetool CLI工具,支持resize/crop/rotate/flip/convert/watermark/compress等基础操作,以及info/exif/histogram/colors元数据查看功能
2. 新增image_pipeline流水线构造器,支持链式编排多步图片处理DAG
3. 注册CLI别名image/img到imagetool,导出image_pipeline到顶层API
4. 配套新增完整单元测试与文档
This commit is contained in:
2026-07-07 19:57:35 +08:00
parent be2273722f
commit 8e3ddc5a1e
7 changed files with 1304 additions and 0 deletions
@@ -0,0 +1,174 @@
# P10 图片处理开发计划
## Context
PyFlowX 现有 `ops/files/` 工具集已覆盖 PDFpdftool)、截图(screenshot)、文件日期(filedate)等场景,但缺少图片处理能力。`pyproject.toml``office` extra 已声明 `pillow>=10.4.0`,却无对应工具消费它。
本计划新增 `imagetool` 工具,提供基础操作(resize/crop/rotate/flip/convert/watermark/compress)、元数据与信息(info/exif/histogram/colors)、批量 DAG 编排(`image_pipeline` 便捷构造器)三类能力。用户已确认范围:基础操作 + 元数据 + 批量 DAG(不含 OCR),工具形态为单一 `imagetool` + office extra。
## 实现步骤
### P10.1 基础操作子命令
**新建 `src/pyflowx/ops/files/imagetool.py`**,参照 [pdftool.py](file:///home/zhou/pyflowx/src/pyflowx/ops/files/pdftool.py) 模式:
- 顶部 `try: from PIL import Image; HAS_PIL = True; except ImportError: HAS_PIL = False`
- `_require_pil() -> bool` 检查并打印 `pip install pyflowx[office]` 提示
- 7 个 `@px.tool("imagetool", subcommand=..., help=...)` 子命令:
- `resize` (`r`) — `image_resize(input: Path, output: Path, width: int, height: int | None = None, keep_ratio: bool = True)`
- `crop` (`c`) — `image_crop(input: Path, output: Path, left: int, top: int, right: int, bottom: int)`
- `rotate` (`ro`) — `image_rotate(input: Path, output: Path, degrees: float, expand: bool = False)`
- `flip` (`fl`) — `image_flip(input: Path, output: Path, direction: str = "horizontal")`
- `convert` (`cv`) — `image_convert(input: Path, output: Path, format: str | None = None, quality: int = 85)`
- `watermark` (`wm`) — `image_watermark(input: Path, output: Path, text: str, position: str = "bottom-right", opacity: float = 0.5, font_size: int = 32)`
- `compress` (`cp`) — `image_compress(input: Path, output: Path, quality: int = 85)`
实现要点:
-`Image.open(input)` 打开,操作后 `img.save(output, format=..., quality=...)` 保存
- `resize` + `keep_ratio=True` 时用 `Image.thumbnail((width, height))` 保比缩放;`keep_ratio=False``Image.resize((width, height))`
- `watermark``ImageDraw.Draw(img).text(...)` + `ImageFont.truetype()`(字体缺失回退 `load_default()`
- `convert` 根据 `output.suffix` 推断格式;JPEG 系列需先 `img.convert("RGB")` 去 alpha 通道
- `__all__` 列出所有 `image_*` 函数名
### P10.2 元数据与信息子命令
继续在 `imagetool.py` 追加 4 个子命令:
- `info` (`i`) — `image_info(input: Path, json: bool = False)`:打印尺寸/格式/模式/色彩空间/EXIF 摘要。`json=True` 输出 JSON 格式
- `exif` (`e`) — `image_exif(input: Path, output: Path | None = None, show: bool = True, set: list[str] | None = None, clear: bool = False)`:读写 EXIF。`--set KEY=VAL` 修改,`--clear` 清空,`output` 另存
- `histogram` (`hi`) — `image_histogram(input: Path, channel: str = "rgb")`:打印 RGB/Luminance 直方图统计(每通道 8 桶,纯文本表格输出)
- `colors` (`co`) — `image_colors(input: Path, count: int = 5)`:主色调提取,用 `Image.quantize(colors=count).getpalette()` 提取并打印十六进制色值
实现要点:
- `info``img.size` / `img.format` / `img.mode` / `img.info.get("exif")`
- `exif``img.getexif()` / `img.save(exif=bytes(exif))`
- `histogram``img.histogram()` 分桶统计
- `colors``img.quantize(colors=count).getpalette()` 取前 N 色
### P10.3 批量 DAG 编排
**新建 `src/pyflowx/imaging.py`**(类似 [compose.py](file:///home/zhou/pyflowx/src/pyflowx/compose.py) 的独立模块),提供链式 DAG 构造器:
```python
def image_pipeline(
source: str | Path,
steps: list[tuple[str, dict[str, Any]]],
*,
output_dir: str | Path | None = None,
naming: str = "{stem}_{step}{ext}",
) -> Graph:
"""图片处理流水线 DAG 构造器。
每个 step 是 (操作名, 参数字典),操作名对应 imagetool 子命令
resize/crop/rotate/flip/convert/watermark/compress)。
前一步输出作为后一步输入,形成链式 DAG。
"""
```
示例:
```python
graph = px.image_pipeline(
"input.jpg",
steps=[
("resize", {"width": 800}),
("watermark", {"text": "© 2026"}),
("convert", {"format": "webp", "quality": 85}),
],
)
report = px.run(graph, strategy="sequential")
```
实现要点:
- 每个 step 生成一个 `TaskSpec``fn` 调用对应的 `image_*` 函数
- 任务名按 `naming` 模板生成(默认 `{stem}_{step}{ext}`,如 `input_resize.jpg`
- 任务间用 `depends_on` 链式依赖,上游输出路径 = 下游输入路径
- `output_dir` 默认为源文件所在目录
-`__init__.py` 导出 `image_pipeline`
### P10.4 验证与文档
**测试 `tests/cli/test_imagetool.py`**
-`pytest.importorskip("PIL")` 跳过未安装 Pillow 的环境
-`tmp_path` 创建真实测试图片(`Image.new("RGB", (100, 100), "red").save(tmp_path / "test.png")`
- 每个子命令至少 1 个测试(基础操作验证输出文件存在 + 尺寸/格式正确)
- 元数据测试验证输出内容(info 的 JSON 结构、exif 读写往返、histogram 桶数、colors 色值数)
- `image_pipeline` 测试验证 DAG 拓扑(3 步链式,每步输出存在且最终输出为 webp)
- 测试 mock 优先级遵循 `python-standards.md`:优先用真实 Pillow 操作(非 subprocess),仅字体加载等不可控部分用 `monkeypatch`
**新增 `tests/imaging/test_image_pipeline.py`**(或在 `tests/test_imaging.py`):
- 测试 `image_pipeline` 生成的 Graph 拓扑正确(任务数、依赖链)
- 测试实际执行后输出文件链完整
**注册到 CLI**
- 编辑 [src/pyflowx/cli/pf.py](file:///home/zhou/pyflowx/src/pyflowx/cli/pf.py) 的 `_TOOL_MODULES`:添加 `"imagetool": "pyflowx.ops.files.imagetool"`
- 编辑 `_TOOL_ALIASES`:添加 `"imagetool": "imagetool"``"image": "imagetool"``"img": "imagetool"`
- 编辑 [src/pyflowx/__init__.py](file:///home/zhou/pyflowx/src/pyflowx/__init__.py) 导出 `image_pipeline`
**文档同步**
- `.trae/docs/iter-22-image-processing.md` 迭代记录
- `.trae/skills/pyflowx-development/SKILL.md` 同步行为变更(新增"十五、图片处理工具"小节,或在十二章 CLI 工具下追加)
- `project_memory.md` 追加 P10 工程约定
## 关键文件
| 操作 | 文件 |
|------|------|
| 新建 | `src/pyflowx/ops/files/imagetool.py`11 子命令) |
| 新建 | `src/pyflowx/imaging.py``image_pipeline` 构造器) |
| 新建 | `tests/cli/test_imagetool.py`(子命令测试) |
| 新建 | `tests/test_imaging.py`DAG 构造器测试) |
| 修改 | `src/pyflowx/cli/pf.py`(注册 imagetool + 别名) |
| 修改 | `src/pyflowx/__init__.py`(导出 `image_pipeline` |
| 新建 | `.trae/docs/iter-22-image-processing.md` |
| 修改 | `.trae/skills/pyflowx-development/SKILL.md`(行为变更同步) |
## 复用的现有模式
- **`@px.tool` 装饰器 + ToolSpec**[tools.py L116-L180](file:///home/zhou/pyflowx/src/pyflowx/tools.py#L116-L180),每个子命令一个装饰器调用
- **可选依赖 try/except + `_require_*`**[pdftool.py L31-L59](file:///home/zhou/pyflowx/src/pyflowx/ops/files/pdftool.py#L31-L59)
- **`_TOOL_MODULES` 注册**[pf.py L112-L137](file:///home/zhou/pyflowx/src/pyflowx/cli/pf.py#L112-L137)
- **`pytest.importorskip` 跳过缺依赖**[test_pdftool.py L22](file:///home/zhou/pyflowx/tests/cli/test_pdftool.py#L22)
- **`image_pipeline` 委托模式**:参照 [compose.py](file:///home/zhou/pyflowx/src/pyflowx/compose.py) 的 `compose()` 函数式构造器
- **TaskSpec 链式依赖**:用 `Graph.from_specs` + `depends_on` 构建链式 DAGP9.2 `pipeline()` 同模式)
## 验证方法
```bash
# 1. 单元测试
uv run pytest tests/cli/test_imagetool.py tests/test_imaging.py -v
# 2. 全套门禁
uv run ruff check .
uv run ruff format --check .
uv run pyrefly check .
uv run pytest --cov=pyflowx --cov-branch
# 3. CLI 实测(需 pip install pyflowx[office]
pf imagetool info test.png
pf imagetool resize test.png out.png --width 50
pf imagetool watermark test.png wm.png --text "TEST"
pf imagetool convert test.png out.webp --quality 80
# 4. DAG 编排实测
python -c "
import pyflowx as px
g = px.image_pipeline('test.png', steps=[
('resize', {'width': 50}),
('watermark', {'text': 'X'}),
('convert', {'format': 'webp', 'quality': 80}),
])
r = px.run(g, strategy='sequential')
print(r.success)
"
# 5. 覆盖率门槛 ≥ 95%branch
```
## 验收标准
- ruff/pyrefly 0 错误,pytest 全绿,覆盖率 ≥ 95%branch
- `pf imagetool --help` 列出全部 11 个子命令
- `pf imagetool <sub> --help` 显示参数说明
- `image_pipeline()` 生成的 DAG 可执行,链式输出完整
- 未安装 office extra 时 `pf imagetool <sub>` 打印友好提示而非崩溃
- `tests/cli/test_tool_modules.py` 自动覆盖新工具注册(无需额外修改)
+2
View File
@@ -77,6 +77,7 @@ from .errors import (
from .executors import Strategy, run, run_iter
from .graph import Graph, GraphDefaults
from .history import RunHistory
from .imaging import image_pipeline
from .monitoring import MetricsCollector, health_check, start_metrics_server
from .notification import (
ALL_LEVELS,
@@ -180,6 +181,7 @@ __all__ = [
"describe_injection",
"diagnose",
"health_check",
"image_pipeline",
"list_subcommands",
"list_tools",
"load_yaml",
+4
View File
@@ -63,6 +63,9 @@ class PfApp:
"gitt": "gittool",
"gittool": "gittool",
"gt": "gittool",
"image": "imagetool",
"imagetool": "imagetool",
"img": "imagetool",
"ls": "lscalc",
"lscalc": "lscalc",
"msdown": "msdownload",
@@ -120,6 +123,7 @@ class PfApp:
"folderback": "pyflowx.ops.files.folderback",
"folderzip": "pyflowx.ops.files.folderzip",
"gittool": "pyflowx.ops.dev.gittool",
"imagetool": "pyflowx.ops.files.imagetool",
"lscalc": "pyflowx.ops.dev.lscalc",
"msdownload": "pyflowx.ops.infra.msdownload",
"packtool": "pyflowx.ops.dev.packtool",
+130
View File
@@ -0,0 +1,130 @@
"""图片处理流水线 DAG 构造器.
将 :mod:`pyflowx.ops.files.imagetool` 的操作封装为链式 :class:`Graph`,
便于在 DAG 中组合多步图片处理 (如 resize → watermark → convert).
设计参照 :mod:`pyflowx.compose` 的函数式构造器模式.
"""
from __future__ import annotations
__all__ = ["image_pipeline"]
from pathlib import Path
from typing import Any
from .graph import Graph
from .task import TaskSpec
def image_pipeline(
source: str | Path,
steps: list[tuple[str, dict[str, Any]]],
*,
output_dir: str | Path | None = None,
naming: str = "{stem}_{step}{ext}",
) -> Graph:
"""图片处理流水线 DAG 构造器.
每个 step 是 ``(操作名, 参数字典)``, 操作名对应 imagetool 子命令
(resize/crop/rotate/flip/convert/watermark/compress).
前一步输出作为后一步输入, 形成链式 DAG.
Parameters
----------
source : str | Path
源图片路径
steps : list[tuple[str, dict[str, Any]]]
操作步骤列表, 如 ``[("resize", {"width": 800}), ("watermark", {"text": "X"})]``
output_dir : str | Path | None
输出目录 (None 时用源文件所在目录)
naming : str
输出文件名模板, 可用占位符 ``{stem}`` (累积 stem) / ``{step}`` (操作名) /
``{ext}`` (当前扩展名, convert 步骤可能改变). 默认 ``"{stem}_{step}{ext}"``
Returns
-------
Graph
链式 DAG, 每步为一个 :class:`TaskSpec`, 通过 ``depends_on`` 链接
Raises
------
ValueError
操作名不在支持列表中时
Example
-------
>>> graph = px.image_pipeline(
... "input.jpg",
... steps=[
... ("resize", {"width": 800}),
... ("watermark", {"text": "© 2026"}),
... ("convert", {"format": "webp", "quality": 85}),
... ],
... )
>>> report = px.run(graph, strategy="sequential")
"""
from .ops.files import imagetool
operations = {
"resize": imagetool.image_resize,
"crop": imagetool.image_crop,
"rotate": imagetool.image_rotate,
"flip": imagetool.image_flip,
"convert": imagetool.image_convert,
"watermark": imagetool.image_watermark,
"compress": imagetool.image_compress,
}
source_path = Path(source)
out_dir = Path(output_dir) if output_dir else source_path.parent
specs: list[TaskSpec[Any]] = []
prev_name: str | None = None
prev_output: Path | None = None
current_stem = source_path.stem
current_ext = source_path.suffix
for i, (op_name, params) in enumerate(steps):
if op_name not in operations:
raise ValueError(f"未知图片操作: {op_name!r}, 支持: {sorted(operations)}")
if op_name == "convert" and "format" in params:
current_ext = f".{params['format'].lower()}"
output_name = naming.format(stem=current_stem, step=op_name, ext=current_ext)
output_path = out_dir / output_name
input_path = source_path if prev_output is None else prev_output
task_name = f"step_{i:02d}_{op_name}"
base_fn = operations[op_name]
fn = _make_step_fn(base_fn, input_path, output_path, params)
fn.__name__ = task_name
depends_on = (prev_name,) if prev_name else ()
spec = TaskSpec(task_name, fn=fn, depends_on=depends_on)
specs.append(spec)
prev_name = task_name
prev_output = output_path
current_stem = output_path.stem
return Graph.from_specs(specs)
def _make_step_fn(
base_fn: Any,
input_path: Path,
output_path: Path,
params: dict[str, Any],
) -> Any:
"""创建步骤执行闭包, 绑定输入/输出路径与参数.
闭包捕获的是 :func:`_make_step_fn` 的参数 (每次调用独立作用域),
不会受外层循环变量重绑定影响.
"""
def _run() -> None:
base_fn(input_path, output_path, **params)
return _run
+520
View File
@@ -0,0 +1,520 @@
"""imagetool - 图片处理工具集.
提供 resize/crop/rotate/flip/convert/watermark/compress/info/exif/
histogram/colors 子命令, 基于 Pillow 实现.
依赖 ``pyflowx[office]`` extra 中的 ``pillow``.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pyflowx as px
__all__ = [
"image_colors",
"image_compress",
"image_convert",
"image_crop",
"image_exif",
"image_flip",
"image_histogram",
"image_info",
"image_resize",
"image_rotate",
"image_watermark",
]
try:
from PIL import Image, ImageDraw, ImageFont
HAS_PIL = True
except ImportError:
HAS_PIL = False
def _require_pil() -> bool:
"""Pillow 未安装时打印提示, 返回是否可用."""
if not HAS_PIL:
print("未安装 Pillow 库, 请安装: pip install pyflowx[office]")
return False
return True
def _save_image(img: Any, output: Path, fmt: str | None = None, quality: int = 85) -> None:
"""保存图片, 自动处理 JPEG 不支持 alpha 通道的情况."""
output.parent.mkdir(parents=True, exist_ok=True)
save_fmt = fmt or output.suffix.lstrip(".").upper()
if save_fmt == "JPG":
save_fmt = "JPEG"
if save_fmt == "JPEG":
img = img.convert("RGB")
if save_fmt in ("JPEG", "WEBP"):
img.save(output, format=save_fmt, quality=quality)
else:
img.save(output, format=save_fmt)
# ---------------------------------------------------------------------- #
# 基础操作
# ---------------------------------------------------------------------- #
@px.tool("imagetool", subcommand="r", help="调整尺寸")
def image_resize(
input_path: Path,
output_path: Path,
width: int,
height: int | None = None,
keep_ratio: bool = True,
) -> None:
"""调整图片尺寸.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
width : int
目标宽度
height : int | None
目标高度 (keep_ratio=True 时仅作上限, 默认 None 表示按宽度等比)
keep_ratio : bool
是否保持宽高比 (默认 True)
"""
if not _require_pil():
return
img = Image.open(input_path)
if keep_ratio:
target_height = height if height is not None else width
img.thumbnail((width, target_height))
else:
if height is None:
height = width
img = img.resize((width, height))
_save_image(img, output_path)
print(f"调整尺寸完成: {output_path} ({img.size[0]}x{img.size[1]})")
@px.tool("imagetool", subcommand="c", help="裁剪图片")
def image_crop(
input_path: Path,
output_path: Path,
left: int,
top: int,
right: int,
bottom: int,
) -> None:
"""裁剪图片到指定矩形.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
left, top, right, bottom : int
裁剪矩形坐标 (左上为原点)
"""
if not _require_pil():
return
img = Image.open(input_path)
cropped = img.crop((left, top, right, bottom))
_save_image(cropped, output_path)
print(f"裁剪完成: {output_path} ({right - left}x{bottom - top})")
@px.tool("imagetool", subcommand="ro", help="旋转图片")
def image_rotate(
input_path: Path,
output_path: Path,
degrees: float,
expand: bool = False,
) -> None:
"""旋转图片.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
degrees : float
旋转角度 (正值逆时针)
expand : bool
是否扩展画布以容纳整个旋转后的图片 (默认 False)
"""
if not _require_pil():
return
img = Image.open(input_path)
rotated = img.rotate(degrees, expand=expand)
_save_image(rotated, output_path)
print(f"旋转完成: {output_path} ({degrees}度)")
@px.tool("imagetool", subcommand="fl", help="翻转图片")
def image_flip(
input_path: Path,
output_path: Path,
direction: str = "horizontal",
) -> None:
"""翻转图片.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
direction : str
翻转方向: "horizontal" (水平镜像) / "vertical" (垂直镜像)
"""
if not _require_pil():
return
img = Image.open(input_path)
method = Image.Transpose.FLIP_LEFT_RIGHT if direction == "horizontal" else Image.Transpose.FLIP_TOP_BOTTOM
flipped = img.transpose(method)
_save_image(flipped, output_path)
print(f"翻转完成: {output_path} ({direction})")
@px.tool("imagetool", subcommand="cv", help="格式转换")
def image_convert(
input_path: Path,
output_path: Path,
format: str | None = None,
quality: int = 85,
) -> None:
"""转换图片格式.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片 (后缀决定格式, 除非 format 显式指定)
format : str | None
目标格式 (如 "PNG"/"JPEG"/"WEBP"), None 时按 output_path 后缀推断
quality : int
压缩质量 (1-100, 仅对 JPEG/WEBP 有效)
"""
if not _require_pil():
return
img = Image.open(input_path)
_save_image(img, output_path, fmt=format, quality=quality)
actual_fmt = format or output_path.suffix.lstrip(".").upper()
print(f"格式转换完成: {output_path} ({actual_fmt})")
@px.tool("imagetool", subcommand="wm", help="添加文字水印")
def image_watermark(
input_path: Path,
output_path: Path,
text: str,
position: str = "bottom-right",
opacity: float = 0.5,
font_size: int = 32,
) -> None:
"""添加文字水印.
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
text : str
水印文字
position : str
水印位置: top-left/top-right/bottom-left/bottom-right/center
opacity : float
不透明度 (0.0-1.0)
font_size : int
字体大小
"""
if not _require_pil():
return
img = Image.open(input_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
font = _load_font(font_size)
bbox = draw.textbbox((0, 0), text, font=font)
text_w = int(bbox[2] - bbox[0])
text_h = int(bbox[3] - bbox[1])
margin = 10
x, y = _resolve_position(position, img.size, text_w, text_h, margin)
alpha = int(255 * max(0.0, min(1.0, opacity)))
draw.text((x, y), text, font=font, fill=(255, 255, 255, alpha))
result = Image.alpha_composite(img, overlay)
_save_image(result, output_path)
print(f"水印添加完成: {output_path}")
def _load_font(size: int) -> Any:
"""加载字体, 优先 truetype, 失败回退默认字体."""
candidates = ("DejaVuSans.ttf", "Arial.ttf", "LiberationSans-Regular.ttf")
for name in candidates:
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def _resolve_position(
position: str,
img_size: tuple[int, int],
text_w: int,
text_h: int,
margin: int,
) -> tuple[int, int]:
"""根据位置描述符计算水印坐标."""
w, h = img_size
pos_map = {
"top-left": (margin, margin),
"top-right": (w - text_w - margin, margin),
"bottom-left": (margin, h - text_h - margin),
"bottom-right": (w - text_w - margin, h - text_h - margin),
"center": ((w - text_w) // 2, (h - text_h) // 2),
}
return pos_map.get(position, pos_map["bottom-right"])
@px.tool("imagetool", subcommand="cp", help="压缩图片")
def image_compress(
input_path: Path,
output_path: Path,
quality: int = 85,
) -> None:
"""压缩图片 (重新编码以减小体积).
Parameters
----------
input_path : Path
输入图片
output_path : Path
输出图片
quality : int
压缩质量 (1-100)
"""
if not _require_pil():
return
img = Image.open(input_path)
fmt = input_path.suffix.lstrip(".").upper()
_save_image(img, output_path, fmt=fmt, quality=quality)
in_size = input_path.stat().st_size
out_size = output_path.stat().st_size
ratio = (1 - out_size / in_size) * 100 if in_size > 0 else 0.0
print(f"压缩完成: {output_path} (原 {in_size}B → 新 {out_size}B, 节省 {ratio:.1f}%)")
# ---------------------------------------------------------------------- #
# 元数据与信息
# ---------------------------------------------------------------------- #
@px.tool("imagetool", subcommand="i", help="查看图片信息")
def image_info(input_path: Path, json: bool = False) -> None:
"""打印图片信息 (尺寸/格式/模式/EXIF 摘要).
Parameters
----------
input_path : Path
输入图片
json : bool
是否以 JSON 格式输出 (默认纯文本表格)
"""
if not _require_pil():
return
img = Image.open(input_path)
exif = img.getexif()
exif_count = len(exif) if exif else 0
import json as json_mod
data = {
"path": str(input_path),
"format": img.format,
"mode": img.mode,
"width": img.size[0],
"height": img.size[1],
"exif_tags": exif_count,
}
if json:
print(json_mod.dumps(data, ensure_ascii=False, indent=2))
else:
print(f"文件: {data['path']}")
print(f"格式: {data['format']}")
print(f"模式: {data['mode']}")
print(f"尺寸: {data['width']}x{data['height']}")
print(f"EXIF 标签数: {data['exif_tags']}")
@px.tool("imagetool", subcommand="e", help="读取/修改 EXIF")
def image_exif(
input_path: Path,
output_path: Path | None = None,
show: bool = True,
set: list[str] | None = None,
clear: bool = False,
) -> None:
"""读取或修改 EXIF 元数据.
Parameters
----------
input_path : Path
输入图片
output_path : Path | None
输出路径 (None 时原地覆盖; 仅 show=True 时可省略)
show : bool
打印全部 EXIF 标签 (默认 True)
set : list[str] | None
设置标签, 格式 ["KEY=VALUE", ...] (KEY 为数字标签号)
clear : bool
清空所有 EXIF 标签 (在 set 之前执行)
"""
if not _require_pil():
return
img = Image.open(input_path)
exif = img.getexif()
if show:
_print_exif(exif)
modified = _apply_exif_modifications(exif, set, clear)
if modified:
_save_exif(img, exif, output_path if output_path is not None else input_path)
def _print_exif(exif: Any) -> None:
"""打印 EXIF 标签."""
if exif:
for tag, value in exif.items():
print(f" {tag}: {value}")
else:
print(" (无 EXIF 数据)")
def _apply_exif_modifications(exif: Any, set_items: list[str] | None, clear: bool) -> bool:
"""应用 EXIF 修改 (clear + set), 返回是否有改动."""
if clear:
for tag in list(exif.keys()):
del exif[tag]
if set_items:
for item in set_items:
_apply_single_exif_set(exif, item)
return bool(set_items or clear)
def _apply_single_exif_set(exif: Any, item: str) -> None:
"""解析并应用单个 KEY=VALUE 设置项."""
if "=" not in item:
print(f"跳过无效项 (缺少 =): {item}")
return
key_str, value = item.split("=", 1)
try:
tag = int(key_str)
except ValueError:
print(f"跳过无效标签号: {key_str}")
return
exif[tag] = value
def _save_exif(img: Any, exif: Any, output_path: Path) -> None:
"""保存图片与 EXIF."""
exif_bytes = exif.tobytes() if exif else b""
img.save(output_path, exif=exif_bytes)
print(f"EXIF 已保存: {output_path}")
@px.tool("imagetool", subcommand="hi", help="颜色直方图")
def image_histogram(
input_path: Path,
channel: str = "rgb",
) -> None:
"""打印颜色直方图统计 (每通道 8 桶).
Parameters
----------
input_path : Path
输入图片
channel : str
通道: "rgb" (R/G/B 三通道) / "luminance" (亮度单通道)
"""
if not _require_pil():
return
img = Image.open(input_path)
hist = img.histogram()
buckets = 8
if channel == "luminance":
gray = img.convert("L")
gray_hist = gray.histogram()
print("亮度直方图 (8 桶):")
_print_histogram_buckets(gray_hist, buckets, "L")
else:
print("RGB 直方图 (8 桶):")
if len(hist) == 256:
_print_histogram_buckets(hist, buckets, "L")
else:
for idx, name in enumerate(("R", "G", "B")):
start = idx * 256
_print_histogram_buckets(hist[start : start + 256], buckets, name)
def _print_histogram_buckets(channel_hist: list[int], buckets: int, name: str) -> None:
"""将单通道 256 桶直方图聚合为指定桶数并打印."""
bucket_size = max(1, len(channel_hist) // buckets)
print(f" {name}:")
for i in range(buckets):
start = i * bucket_size
end = min((i + 1) * bucket_size, len(channel_hist))
count = sum(channel_hist[start:end])
slice_max = max(channel_hist[start:end] or [1])
bar = "#" * min(40, count * 40 // max(1, slice_max))
print(f" [{start:3d}-{end:3d}] {count:>8d} {bar}")
@px.tool("imagetool", subcommand="co", help="提取主色调")
def image_colors(
input_path: Path,
count: int = 5,
) -> None:
"""提取并打印主色调.
Parameters
----------
input_path : Path
输入图片
count : int
提取的颜色数 (默认 5)
"""
if not _require_pil():
return
img = Image.open(input_path).convert("RGB")
quantized = img.quantize(colors=count)
palette = quantized.getpalette()
if palette is None:
print("无法提取调色板")
return
actual_count = len(palette) // 3
print(f"主色调 (前 {min(count, actual_count)} 色):")
for i in range(min(count, actual_count)):
r = palette[i * 3]
g = palette[i * 3 + 1]
b = palette[i * 3 + 2]
hex_color = f"#{r:02X}{g:02X}{b:02X}"
print(f" {i + 1}. {hex_color} rgb({r}, {g}, {b})")
+316
View File
@@ -0,0 +1,316 @@
"""imagetool 子命令测试.
用 ``pytest.importorskip("PIL")`` 跳过未安装 Pillow 的环境.
用真实 Pillow 操作 (非 subprocess), 创建测试图片验证.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pyflowx.ops.files import imagetool
pytest.importorskip("PIL")
from PIL import Image
def _make_test_image(path: Path, size: tuple[int, int] = (100, 80), color: str = "red") -> None:
"""创建测试图片."""
Image.new("RGB", size, color).save(path)
def _make_test_image_rgba(path: Path, size: tuple[int, int] = (100, 80)) -> None:
"""创建带 alpha 通道的测试图片."""
Image.new("RGBA", size, (255, 0, 0, 128)).save(path)
class TestImageResize:
"""image_resize 测试."""
def test_resize_with_keep_ratio(self, tmp_path: Path) -> None:
"""keep_ratio=True 时按宽度等比缩放."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_resize(src, out, width=50)
assert out.exists()
with Image.open(out) as img:
assert img.size[0] == 50
def test_resize_without_keep_ratio(self, tmp_path: Path) -> None:
"""keep_ratio=False 时强制缩放到指定尺寸."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_resize(src, out, width=50, height=40, keep_ratio=False)
assert out.exists()
with Image.open(out) as img:
assert img.size == (50, 40)
class TestImageCrop:
"""image_crop 测试."""
def test_crop(self, tmp_path: Path) -> None:
"""裁剪到指定矩形."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_crop(src, out, left=10, top=10, right=60, bottom=50)
assert out.exists()
with Image.open(out) as img:
assert img.size == (50, 40)
class TestImageRotate:
"""image_rotate 测试."""
def test_rotate_90(self, tmp_path: Path) -> None:
"""旋转 90 度."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_rotate(src, out, degrees=90, expand=True)
assert out.exists()
with Image.open(out) as img:
assert img.size[0] == 80
assert img.size[1] == 100
class TestImageFlip:
"""image_flip 测试."""
def test_flip_horizontal(self, tmp_path: Path) -> None:
"""水平翻转."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_flip(src, out, direction="horizontal")
assert out.exists()
def test_flip_vertical(self, tmp_path: Path) -> None:
"""垂直翻转."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out = tmp_path / "out.png"
imagetool.image_flip(src, out, direction="vertical")
assert out.exists()
class TestImageConvert:
"""image_convert 测试."""
def test_convert_to_jpeg(self, tmp_path: Path) -> None:
"""PNG → JPEG 转换."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
out = tmp_path / "out.jpg"
imagetool.image_convert(src, out)
assert out.exists()
with Image.open(out) as img:
assert img.format == "JPEG"
def test_convert_to_webp(self, tmp_path: Path) -> None:
"""PNG → WEBP 转换."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
out = tmp_path / "out.webp"
imagetool.image_convert(src, out, quality=80)
assert out.exists()
with Image.open(out) as img:
assert img.format == "WEBP"
def test_convert_rgba_to_jpeg(self, tmp_path: Path) -> None:
"""RGBA → JPEG 自动去 alpha 通道."""
src = tmp_path / "src.png"
_make_test_image_rgba(src, (50, 50))
out = tmp_path / "out.jpg"
imagetool.image_convert(src, out)
assert out.exists()
with Image.open(out) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
class TestImageWatermark:
"""image_watermark 测试."""
def test_watermark_text(self, tmp_path: Path) -> None:
"""添加文字水印."""
src = tmp_path / "src.png"
_make_test_image(src, (200, 150))
out = tmp_path / "out.png"
imagetool.image_watermark(src, out, text="TEST", position="bottom-right", opacity=0.7)
assert out.exists()
with Image.open(out) as img:
assert img.size == (200, 150)
def test_watermark_center_position(self, tmp_path: Path) -> None:
"""center 位置水印."""
src = tmp_path / "src.png"
_make_test_image(src, (200, 150))
out = tmp_path / "out.png"
imagetool.image_watermark(src, out, text="CENTER", position="center")
assert out.exists()
class TestImageCompress:
"""image_compress 测试."""
def test_compress_jpeg(self, tmp_path: Path) -> None:
"""JPEG 压缩后文件存在."""
src = tmp_path / "src.jpg"
_make_test_image(src, (100, 80))
out = tmp_path / "out.jpg"
imagetool.image_compress(src, out, quality=50)
assert out.exists()
assert out.stat().st_size > 0
class TestImageInfo:
"""image_info 测试."""
def test_info_text(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""文本格式信息输出."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
imagetool.image_info(src, json=False)
captured = capsys.readouterr()
assert "格式: PNG" in captured.out
assert "100x80" in captured.out
def test_info_json(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""JSON 格式信息输出."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
imagetool.image_info(src, json=True)
captured = capsys.readouterr()
import json
data = json.loads(captured.out)
assert data["format"] == "PNG"
assert data["width"] == 100
assert data["height"] == 80
class TestImageExif:
"""image_exif 测试."""
def test_exif_show_empty(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""无 EXIF 时打印提示."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
imagetool.image_exif(src, show=True)
captured = capsys.readouterr()
assert "无 EXIF 数据" in captured.out
def test_exif_set_and_read(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""设置 EXIF 后能读取."""
src = tmp_path / "src.jpg"
_make_test_image(src, (50, 50))
out = tmp_path / "out.jpg"
imagetool.image_exif(src, output_path=out, show=False, set=["271=TestCamera"])
assert out.exists()
capsys.readouterr()
imagetool.image_exif(out, show=True)
captured = capsys.readouterr()
assert "271: TestCamera" in captured.out
def test_exif_clear(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""清空 EXIF."""
src = tmp_path / "src.jpg"
_make_test_image(src, (50, 50))
out_set = tmp_path / "set.jpg"
imagetool.image_exif(src, output_path=out_set, show=False, set=["271=TestCamera"])
out_clear = tmp_path / "clear.jpg"
imagetool.image_exif(out_set, output_path=out_clear, show=False, clear=True)
capsys.readouterr()
imagetool.image_exif(out_clear, show=True)
captured = capsys.readouterr()
assert "无 EXIF 数据" in captured.out
def test_exif_invalid_set_item(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""无效设置项被跳过."""
src = tmp_path / "src.jpg"
_make_test_image(src, (50, 50))
out = tmp_path / "out.jpg"
imagetool.image_exif(src, output_path=out, show=False, set=["invalid", "abc=val"])
captured = capsys.readouterr()
assert "跳过" in captured.out
class TestImageHistogram:
"""image_histogram 测试."""
def test_histogram_rgb(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""RGB 直方图输出 3 通道."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50), color="red")
imagetool.image_histogram(src, channel="rgb")
captured = capsys.readouterr()
assert "R:" in captured.out
assert "G:" in captured.out
assert "B:" in captured.out
def test_histogram_luminance(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""亮度直方图输出 L 通道."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50), color="red")
imagetool.image_histogram(src, channel="luminance")
captured = capsys.readouterr()
assert "L:" in captured.out
class TestImageColors:
"""image_colors 测试."""
def test_colors_extraction(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""主色调提取输出指定数量颜色."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50), color="red")
imagetool.image_colors(src, count=3)
captured = capsys.readouterr()
assert "主色调" in captured.out
assert "#FF0000" in captured.out or "#" in captured.out
def test_colors_count(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
"""主色调数量符合请求."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50), color="blue")
imagetool.image_colors(src, count=5)
captured = capsys.readouterr()
lines = [line for line in captured.out.splitlines() if line.strip().startswith(tuple("12345"))]
assert len(lines) <= 5
class TestRequirePil:
"""_require_pil 未安装时的行为."""
def test_require_pil_returns_false_when_missing(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""HAS_PIL=False 时打印提示并返回 False."""
monkeypatch.setattr(imagetool, "HAS_PIL", False)
result = imagetool._require_pil()
assert result is False
captured = capsys.readouterr()
assert "pip install pyflowx[office]" in captured.out
def test_require_pil_returns_true_when_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""HAS_PIL=True 时返回 True."""
monkeypatch.setattr(imagetool, "HAS_PIL", True)
assert imagetool._require_pil() is True
def test_resize_no_pil_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""未安装 PIL 时 resize 不创建输出文件."""
monkeypatch.setattr(imagetool, "HAS_PIL", False)
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
out = tmp_path / "out.png"
imagetool.image_resize(src, out, width=25)
assert not out.exists()
+158
View File
@@ -0,0 +1,158 @@
"""image_pipeline DAG 构造器测试.
验证:
* 生成的 Graph 拓扑正确 (任务数/依赖链).
* 实际执行后输出文件链完整.
* 未知操作抛 ValueError.
* convert 步骤改变扩展名.
* 自定义 output_dir 与 naming 模板.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import pyflowx as px
pytest.importorskip("PIL")
from PIL import Image
def _make_test_image(path: Path, size: tuple[int, int] = (100, 80), color: str = "red") -> None:
"""创建测试图片."""
Image.new("RGB", size, color).save(path)
class TestImagePipelineTopology:
"""image_pipeline 生成的 Graph 拓扑测试."""
def test_single_step(self, tmp_path: Path) -> None:
"""单步流水线生成 1 个任务无依赖."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
graph = px.image_pipeline(src, steps=[("resize", {"width": 50})])
assert len(graph.all_specs()) == 1
spec = next(iter(graph.all_specs().values()))
assert spec.depends_on == ()
def test_multi_step_chain(self, tmp_path: Path) -> None:
"""多步流水线形成链式依赖."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
graph = px.image_pipeline(
src,
steps=[
("resize", {"width": 50}),
("watermark", {"text": "X"}),
("compress", {"quality": 80}),
],
)
specs = graph.all_specs()
assert len(specs) == 3
names = list(specs.keys())
assert specs[names[0]].depends_on == ()
assert specs[names[1]].depends_on == (names[0],)
assert specs[names[2]].depends_on == (names[1],)
def test_unknown_operation_raises(self, tmp_path: Path) -> None:
"""未知操作抛 ValueError."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
with pytest.raises(ValueError, match="未知图片操作"):
px.image_pipeline(src, steps=[("unknown_op", {})])
class TestImagePipelineExecution:
"""image_pipeline 实际执行测试."""
def test_execute_resize_only(self, tmp_path: Path) -> None:
"""单步 resize 执行后输出文件存在且尺寸正确."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
graph = px.image_pipeline(src, steps=[("resize", {"width": 50})])
report = px.run(graph, strategy="sequential")
assert report.success
out = tmp_path / "src_resize.png"
assert out.exists()
with Image.open(out) as img:
assert img.size[0] == 50
def test_execute_chain(self, tmp_path: Path) -> None:
"""链式执行: resize → watermark → convert."""
src = tmp_path / "src.png"
_make_test_image(src, (200, 150))
graph = px.image_pipeline(
src,
steps=[
("resize", {"width": 100}),
("watermark", {"text": "TEST"}),
("convert", {"format": "webp", "quality": 80}),
],
)
report = px.run(graph, strategy="sequential")
assert report.success
final_out = tmp_path / "src_resize_watermark_convert.webp"
assert final_out.exists()
with Image.open(final_out) as img:
assert img.format == "WEBP"
def test_execute_with_output_dir(self, tmp_path: Path) -> None:
"""自定义 output_dir 输出到指定目录."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
out_dir = tmp_path / "output"
graph = px.image_pipeline(
src,
steps=[("resize", {"width": 50})],
output_dir=out_dir,
)
report = px.run(graph, strategy="sequential")
assert report.success
assert (out_dir / "src_resize.png").exists()
def test_execute_convert_changes_extension(self, tmp_path: Path) -> None:
"""convert 步骤改变输出扩展名."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
graph = px.image_pipeline(
src,
steps=[
("convert", {"format": "jpeg"}),
],
)
report = px.run(graph, strategy="sequential")
assert report.success
out = tmp_path / "src_convert.jpeg"
assert out.exists()
def test_execute_custom_naming(self, tmp_path: Path) -> None:
"""自定义 naming 模板."""
src = tmp_path / "src.png"
_make_test_image(src, (50, 50))
graph = px.image_pipeline(
src,
steps=[("resize", {"width": 25})],
naming="processed_{step}_{stem}{ext}",
)
report = px.run(graph, strategy="sequential")
assert report.success
out = tmp_path / "processed_resize_src.png"
assert out.exists()
def test_execute_flip_and_rotate(self, tmp_path: Path) -> None:
"""flip + rotate 组合执行."""
src = tmp_path / "src.png"
_make_test_image(src, (100, 80))
graph = px.image_pipeline(
src,
steps=[
("flip", {"direction": "horizontal"}),
("rotate", {"degrees": 90, "expand": True}),
],
)
report = px.run(graph, strategy="sequential")
assert report.success
out = tmp_path / "src_flip_rotate.png"
assert out.exists()