feat(ai-outfit): C 列支持图片目录 → 多图扇出到同名子目录 (§19.12)

Excel C 列除单张图片外也可填一个目录(d:/images/a/):对目录内每张图各
生成一张穿搭图(N→N),全部存到 输出目录/<目录叶子名>/,文件名沿用源图名;
Excel 仍整行一个状态:D=子目录、E=全成功才「完成」、F=失败张数/原因。

- core/ai_outfit.py: list_directory_images(顶层、扩展名过滤、排序、忽略子目录)、
  make_outfit_subdir_path(不加 _n 后缀)、generate_outfit_image 目录分支
  (新参 request_interval/image_log;逐张跳过已存在→幂等重试、本地节流、发日志、
  聚合成单个 OutfitResult)
- core/models.py: OutfitResult 加 output_paths(目录行各 jpg,供缩略图)
- ai_outfit_panel.py: gen 闭包传 request_interval/image_log;缩略图逐张;
  明细「结果」列显示「子目录(N 张)」;_basename 处理目录末尾分隔符
- tests/test_ai_outfit.py: 扇出/幂等跳过/空目录/缺目录/部分失败/命名过滤等用例
- docs/11 §4.1+§9.1、tasks.md §19.12

离屏冒烟:3 图目录 + mock API → output/<dir>/ 3 jpg、D=子目录、E=完成、缩略图 3 张;
全套 12 测试文件在 Python 3.7 全绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:03:04 +08:00
co-authored by Claude Opus 4.8
parent 89ead379d2
commit abe46c4aeb
6 changed files with 354 additions and 10 deletions
+22 -8
View File
@@ -106,9 +106,13 @@ class _OutfitWorker(QObject):
return
def gen(task):
# request_interval/image_log pace and narrate directory rows that
# fan out into many images (docs/11 §9.1); single-file rows ignore them.
return generate_outfit_image(
task, self._prompt, self._output_dir, self._model_config,
quality=self._quality, resolution=self._resolution,
request_interval=self._options.request_interval,
image_log=self.log.emit,
)
def on_progress(completed, total, result):
@@ -870,7 +874,11 @@ class AiOutfitPanel(QWidget):
if row is not None:
if result.success:
self._set_cell(row, 4, "完成")
self._set_cell(row, 5, result.output_path)
if result.output_paths: # 目录行:子目录 + 张数
self._set_cell(row, 5, "{}({} 张)".format(
result.output_path, len(result.output_paths)))
else:
self._set_cell(row, 5, result.output_path)
else:
self._set_cell(row, 4, "失败")
self._set_cell(row, 5, result.error)
@@ -936,12 +944,17 @@ class AiOutfitPanel(QWidget):
# -- helpers --------------------------------------------------------
def _add_result_thumb(self, result):
pix = QPixmap(result.output_path)
item = QListWidgetItem(result.task.product_id)
if not pix.isNull():
item.setIcon(QIcon(pix))
item.setData(Qt.UserRole, result.output_path)
self._results.insertItem(0, item)
# Directory rows produce several images (output_paths); single-file rows
# one (output_path). Add a thumbnail for each (docs/11 §9.1).
for path in (result.output_paths or [result.output_path]):
if not path:
continue
pix = QPixmap(path)
item = QListWidgetItem(result.task.product_id)
if not pix.isNull():
item.setIcon(QIcon(pix))
item.setData(Qt.UserRole, path)
self._results.insertItem(0, item)
def _open_result(self, item):
path = item.data(Qt.UserRole)
@@ -991,7 +1004,8 @@ class AiOutfitPanel(QWidget):
def _basename(path):
import os
return os.path.basename(str(path))
# normpath so a directory path "d:/images/a/" shows its leaf "a" (docs/11 §4.1).
return os.path.basename(os.path.normpath(str(path)))
def _csv(value):
+133 -1
View File
@@ -1,5 +1,7 @@
import logging
import os
import re
import time
from io import BytesIO
from pathlib import Path
@@ -24,6 +26,7 @@ QUALITY_PRESETS = {
MAX_JPG_BYTES = 2 * 1024 * 1024
_INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# Auto-appended to every prompt (ported from 标题生成产品图工具; docs/11 §7.1).
# Only 参考解析度 is dynamic (the chosen resolution); the rest is fixed.
@@ -74,6 +77,40 @@ def make_outfit_output_path(output_dir, product_id):
return candidate
def looks_like_directory(path):
"""Return True if *path* should be treated as an image directory (docs/11 §4.1)."""
text = str(path)
if os.path.isdir(text):
return True
return text.endswith("/") or text.endswith("\\")
def list_directory_images(dir_path):
"""Return top-level image files in *dir_path*, sorted by filename.
Non-recursive; only known image extensions; subdirectories are ignored
(docs/11 §4.1).
"""
directory = Path(dir_path)
images = []
for entry in sorted(directory.iterdir(), key=lambda p: p.name.lower()):
if entry.is_file() and entry.suffix.lower() in _IMAGE_EXTS:
images.append(entry)
return images
def make_outfit_subdir_path(output_dir, subdir_name, source_stem):
"""Return output_dir/<safe subdir>/<safe stem>.jpg, no de-dup suffix.
Used with skip-existing so a directory row can be retried idempotently
(docs/11 §9.1). Both parts are run through safe_product_filename so illegal
characters cannot escape the output tree.
"""
safe_sub = safe_product_filename(subdir_name)
safe_stem = safe_product_filename(source_stem)
return Path(output_dir) / safe_sub / (safe_stem + ".jpg")
def save_jpg_under_limit(image_bytes, output_path, quality=QUALITY_BALANCED, max_bytes=MAX_JPG_BYTES):
"""Save image bytes as 1:1 JPG, reducing quality/size until under limit."""
output_path = Path(output_path)
@@ -106,11 +143,26 @@ def generate_outfit_image(
quality=QUALITY_BALANCED,
resolution="1K",
api_client=None,
request_interval=0.0,
image_log=None,
):
"""Generate one outfit image and return OutfitResult. Never raises."""
"""Generate outfit image(s) for one Excel row and return OutfitResult.
If task.garment_path is a directory (docs/11 §4.1), generate one image per
source picture into output_dir/<dir leaf>/ and return a single aggregated
result. Otherwise generate one image to output_dir/<product_id>.jpg. Never
raises.
"""
if not isinstance(task, OutfitTask):
raise TypeError("task must be OutfitTask")
if looks_like_directory(task.garment_path):
return _generate_directory_outfit(
task, prompt_template, output_dir, model_config,
quality=quality, resolution=resolution, api_client=api_client,
request_interval=request_interval, image_log=image_log,
)
try:
prompt = render_prompt(prompt_template, task, resolution=resolution)
client = api_client or ImageApiClient(model_config)
@@ -134,6 +186,86 @@ def generate_outfit_image(
)
def _generate_directory_outfit(
task,
prompt_template,
output_dir,
model_config,
quality,
resolution,
api_client,
request_interval,
image_log,
):
"""Fan one directory row out into per-image generations (docs/11 §4.1/§9.1).
Each source image -> output_dir/<dir leaf>/<source name>.jpg. Existing outputs
are skipped so a failed row can be retried idempotently. Returns one
aggregated OutfitResult: output_path = the subdirectory (Excel D), output_paths
= each produced jpg, success only when every image succeeded.
"""
def emit(message):
logger.info(message)
if image_log is not None:
try:
image_log(message)
except Exception: # noqa: BLE001 - logging must not break the run
pass
directory = task.garment_path
if not os.path.isdir(directory):
return OutfitResult(task=task, success=False,
error="目录不存在:{}".format(directory), attempts=1)
images = list_directory_images(directory)
if not images:
return OutfitResult(task=task, success=False,
error="目录内没有图片:{}".format(directory), attempts=1)
subdir_name = os.path.basename(os.path.normpath(directory))
subdir_path = str(Path(output_dir) / safe_product_filename(subdir_name))
prompt = render_prompt(prompt_template, task, resolution=resolution)
client = api_client or ImageApiClient(model_config)
interval = float(request_interval or 0.0)
total = len(images)
outputs = []
failures = []
called = False
for index, image_path in enumerate(images, start=1):
output_path = make_outfit_subdir_path(output_dir, subdir_name, image_path.stem)
if output_path.exists():
outputs.append(str(output_path))
emit("第 {} 行 第 {}/{} 张已存在,跳过:{}".format(
task.row_index, index, total, image_path.name))
continue
try:
if called and interval > 0:
time.sleep(interval)
image_bytes = client.generate(prompt, str(image_path), resolution=resolution)
called = True
save_jpg_under_limit(image_bytes, output_path, quality=quality)
outputs.append(str(output_path))
emit("第 {} 行 第 {}/{} 张完成:{}".format(
task.row_index, index, total, image_path.name))
except Exception as exc: # noqa: BLE001 - record and continue
called = True
failures.append("{}:{}".format(image_path.name, exc))
emit("第 {} 行 第 {}/{} 张失败:{}({})".format(
task.row_index, index, total, image_path.name, exc))
if failures:
error = "{} 张中 {} 张失败:{}".format(total, len(failures), ";".join(failures))
logger.warning("Outfit dir row %s partial failure: %s", task.row_index, error)
return OutfitResult(task=task, success=False, output_path=subdir_path,
error=error, attempts=1, output_paths=outputs)
logger.info("Generated outfit dir row %s -> %s (%d images)",
task.row_index, subdir_path, len(outputs))
return OutfitResult(task=task, success=True, output_path=subdir_path,
attempts=1, output_paths=outputs)
def _coerce_quality(quality):
if isinstance(quality, str):
return QUALITY_PRESETS.get(quality, QUALITY_BALANCED)
+6 -1
View File
@@ -169,12 +169,17 @@ class OutfitTask:
@dataclass
class OutfitResult:
"""AI 穿搭单行生成结果。"""
"""AI 穿搭单行生成结果。
output_path: 单文件行 = 结果图路径;目录行 = 输出子目录路径(写回 Excel D 列)。
output_paths: 目录行下每张结果图的路径(供缩略图逐张展示);单文件行留空。
"""
task: OutfitTask
success: bool
output_path: str = ""
error: str = ""
attempts: int = 0
output_paths: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------