feat(export): 批量导出后生成 AI 穿搭 Excel,每印花子目录一行 (§17.23)

添加印花批量导出完成后,自动在输出目录生成与时间戳目录同名的 xlsx
文件(docs/02 §6.12);每个有效印花子目录一行,C 列指向该子目录路径,
AI 穿搭页直接选取即可触发目录扇出逻辑(§19.12),无需手工填写 Excel。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:33:28 +08:00
co-authored by Claude Sonnet 4.6
parent e83dd07dc0
commit a3f4c39821
5 changed files with 144 additions and 3 deletions
+22 -1
View File
@@ -244,6 +244,24 @@
- 日志保存到本地 `logs` 目录。 - 日志保存到本地 `logs` 目录。
- 日志文件按日期或启动时间区分。 - 日志文件按日期或启动时间区分。
### 6.12 批量导出后生成 AI 穿搭 Excel
批量合成导出完成后,自动在输出目录下生成与本次时间戳子目录同名的 Excel 文件,供「AI 穿搭」模块直接读取(衔接 §19 AI 穿搭功能)。
要求:
- **文件位置**:`<output>/<时间戳>.xlsx`,与 `<时间戳>/` 子目录同级。
- **格式**:与 AI 穿搭读取的 `标题生成产品图.xlsx` 完全一致,共六列(首行为表头,数据从第 2 行起):
- A 标题:印花文件名(不含扩展名)
- B 货号:留空(货号可选,AI 穿搭支持空货号)
- C 衣服图路径:对应印花子目录的绝对路径,末尾含路径分隔符(`/`)
- D 生成结果图片路径:留空(由 AI 穿搭写回)
- E 完成状态:留空(由 AI 穿搭写回)
- F 失败原因:留空(由 AI 穿搭写回)
- **行数**:等于本次批量产出的有效印花子目录数(即目录内至少有一个合成图文件),按印花文件名字母序排列。
- 若本次批量无任何成功的印花子目录,则不生成 Excel。
- 用户在「AI 穿搭」页选择此 Excel,即可直接对合成图批量生成穿搭效果图(C 列为目录,触发目录扇出逻辑,详见 `docs/11-ai-outfit.md` §4.1 / §9.1)。
## 7. 非功能需求 ## 7. 非功能需求
### 7.1 平台与运行环境 ### 7.1 平台与运行环境
@@ -294,11 +312,14 @@ CMBot/
app_config.json app_config.json
templates.json templates.json
logs/ logs/
output/ 合并后的图片/
<时间戳>/ # 每次导出运行一个时间戳文件夹(如 20260617_143022) <时间戳>/ # 每次导出运行一个时间戳文件夹(如 20260617_143022)
<印花文件名>/ # 其内按印花分组,子文件夹以印花文件名命名 <印花文件名>/ # 其内按印花分组,子文件夹以印花文件名命名
<衣服文件名>_<印花文件名>.png # 文件名 = 衣服名_印花名,如 1_TY030.png <衣服文件名>_<印花文件名>.png # 文件名 = 衣服名_印花名,如 1_TY030.png
... ...
<时间戳>.xlsx # 与时间戳子目录同级、同名的 Excel(§6.12)
# 每印花一行,C 列 = 对应子目录路径,供 AI 穿搭直接读取
穿搭图片/ # AI 穿搭默认输出目录(docs/11-ai-outfit.md §9)
resources/ resources/
``` ```
+23
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -23,6 +24,7 @@ from PySide6.QtWidgets import (
from core.composer import compose, image_size, resolve_transform from core.composer import compose, image_size, resolve_transform
from core.models import BatchMode, ExportOptions, ImageAsset, Template, TransformState from core.models import BatchMode, ExportOptions, ImageAsset, Template, TransformState
from services.excel_service import write_outfit_source_excel
from services.file_service import ( from services.file_service import (
get_output_dir, get_output_dir,
make_safe_output_path, make_safe_output_path,
@@ -393,6 +395,27 @@ class QueuePanel(QWidget):
self._running = False self._running = False
self._batch_btn.setText("开始批量导出") self._batch_btn.setText("开始批量导出")
self._write_outfit_excel(run_dir)
def _write_outfit_excel(self, run_dir):
"""Generate an AI-outfit source Excel alongside *run_dir* (docs/02 §6.12)."""
run_path = Path(run_dir)
if not run_path.is_dir():
return
_img_exts = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
rows = []
for subdir in sorted(run_path.iterdir(), key=lambda p: p.name.lower()):
if not subdir.is_dir():
continue
if any(f.suffix.lower() in _img_exts for f in subdir.iterdir() if f.is_file()):
rows.append((subdir.name, str(subdir) + os.sep))
if not rows:
return
excel_path = run_path.with_suffix(".xlsx")
try:
write_outfit_source_excel(excel_path, rows)
except Exception as exc:
logger.error("Failed to write outfit source Excel: %s", exc)
def _image_size(self, path): def _image_size(self, path):
"""Return (w, h) for path, caching within a run. None if unreadable.""" """Return (w, h) for path, caching within a run. None if unreadable."""
+23 -2
View File
@@ -1,8 +1,8 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Sequence, Tuple
from openpyxl import load_workbook from openpyxl import Workbook, load_workbook
from core.models import OutfitResult, OutfitTask from core.models import OutfitResult, OutfitTask
@@ -149,6 +149,27 @@ def read_all_rows(excel_path):
workbook.close() workbook.close()
_OUTFIT_SOURCE_HEADERS = ["标题", "货号", "衣服图路径", "生成结果图片路径", "完成状态", "失败原因"]
def write_outfit_source_excel(excel_path, rows):
"""Create an AI-outfit source Excel alongside a compose run directory (docs/02 §6.12).
rows: ordered sequence of (print_name, subdir_path) pairs.
subdir_path should end with a path separator so AI穿搭 treats it as a directory.
Columns: A=print_name, B=empty(货号可空), C=subdir_path, D/E/F=empty (AI穿搭 writes back).
"""
rows = list(rows)
wb = Workbook()
ws = wb.active
ws.append(_OUTFIT_SOURCE_HEADERS)
for print_name, subdir_path in rows:
ws.append([print_name, None, subdir_path, None, None, None])
wb.save(str(excel_path))
wb.close()
logger.info("Wrote outfit source Excel: %s (%d rows)", excel_path, len(rows))
def write_outfit_result(excel_path, result): def write_outfit_result(excel_path, result):
"""Write one outfit result to columns D/E/F and save immediately.""" """Write one outfit result to columns D/E/F and save immediately."""
if not isinstance(result, OutfitResult): if not isinstance(result, OutfitResult):
+28
View File
@@ -1006,6 +1006,34 @@
- [x] 导出面板默认值随之变化(沿用 `get_output_dir()`,无需单独改) - [x] 导出面板默认值随之变化(沿用 `get_output_dir()`,无需单独改)
- [ ] GUI 实测:打包运行后默认导出到 `<安装根>\合并后的图片`,且更新后仍在 - [ ] GUI 实测:打包运行后默认导出到 `<安装根>\合并后的图片`,且更新后仍在
### 17.23 批量导出后生成 AI 穿搭 Excel — docs/02 §6.12 / §9
前置阅读:
- `docs/02-prd.md`(§6.9 批量合成导出、§6.12 AI 穿搭 Excel、§9 目录结构)
- `docs/11-ai-outfit.md`(§4 列定义、§4.1 目录行、§9.1 目录行输出)
- `src/app/widgets/queue_panel.py`(`_start_batch`、`_new_run_dir`、`_export_item`)
- `src/services/excel_service.py`(`write_outfit_result`,了解现有 openpyxl 写法)
背景:
添加印花批量导出后,输出到 `<合并后的图片>/<时间戳>/<印花名>/`。AI 穿搭的「C 列支持目录」(§19.12)可直接消费这些子目录,只缺一个指向它们的 Excel 文件。批量结束后自动生成 `<合并后的图片>/<时间戳>.xlsx`(六列格式,每印花子目录一行),用户切到「AI 穿搭」页、选该 Excel,即可直接对合成图批量生成穿搭效果图。
任务:
- [ ] `src/services/excel_service.py`:新增 `write_outfit_source_excel(excel_path, rows)`
- `rows` 为有序列表,每项为 `(print_name: str, subdir_path: str)`
- 写表头 `["标题","货号","衣服图路径","生成结果图片路径","完成状态","失败原因"]`,数据从第 2 行起
- A=`print_name`,B=空,C=`subdir_path`(末尾含 `/`),D/E/F=空
- 用 openpyxl 创建新工作簿并保存;不抛出、不改动 AI 穿搭现有函数
- [ ] `src/app/widgets/queue_panel.py`:`_start_batch` 完成后
- 扫描 `run_dir` 的直接子目录,过滤出**包含至少一个图片文件**的(即有成功合成图),按目录名排序
- 若有效子目录 > 0,调用 `write_outfit_source_excel` 生成 `<run_dir>.xlsx`(`Path(run_dir).with_suffix(".xlsx")`)
- 写入失败只记日志,不影响批量完成的交互反馈
- [ ] `tests/test_excel_service.py`:
- `write_outfit_source_excel` 生成的文件表头正确、行数 = 传入 rows 数、A/B/C 列值正确(A=print_name,B=空,C=subdir 末尾有 `/`)、D/E/F 为空
- [ ] 验收:批量导出完成后 `<合并后的图片>/` 下出现 `<时间戳>.xlsx`;打开 AI 穿搭、选该文件,预览下拉显示各印花名行(货号列显示「无货号」);「开始生成」触发目录扇出,正常输出穿搭图
## 18. 后续暂缓任务 ## 18. 后续暂缓任务
以下任务第一阶段暂不做,后续需要时再新增设计文档: 以下任务第一阶段暂不做,后续需要时再新增设计文档:
+48
View File
@@ -138,6 +138,54 @@ class TestOutfitExcelService(unittest.TestCase):
with self.assertRaises(ExcelInUseError): with self.assertRaises(ExcelInUseError):
ensure_excel_writable(self.excel_path) ensure_excel_writable(self.excel_path)
# -- write_outfit_source_excel (docs/02 §6.12) ----------------------------
def test_write_outfit_source_excel_header_and_rows(self):
"""One row per (print_name, subdir_path); header + A/B/C values correct."""
import os
from services.excel_service import write_outfit_source_excel
p = self.tmp / "batch.xlsx"
rows = [
("floral", r"D:\out\20260623\floral" + os.sep),
("stripe", r"D:\out\20260623\stripe" + os.sep),
]
write_outfit_source_excel(p, rows)
wb = load_workbook(str(p))
ws = wb.active
try:
self.assertEqual(
[ws.cell(1, c).value for c in range(1, 7)],
["标题", "货号", "衣服图路径", "生成结果图片路径", "完成状态", "失败原因"],
)
self.assertEqual(ws.max_row, 3) # 1 header + 2 data
self.assertEqual(ws.cell(2, 1).value, "floral")
self.assertIsNone(ws.cell(2, 2).value) # 货号留空
self.assertIn("floral", ws.cell(2, 3).value)
self.assertTrue(ws.cell(2, 3).value.endswith(os.sep))
self.assertEqual(ws.cell(3, 1).value, "stripe")
for col in (4, 5, 6): # D/E/F 留空
self.assertIsNone(ws.cell(2, col).value)
finally:
wb.close()
def test_write_outfit_source_excel_ai_outfit_can_read_it(self):
"""AI穿搭 read_all_rows picks up the generated Excel as a directory row."""
import os
from services.excel_service import read_all_rows, write_outfit_source_excel
subdir = self.tmp / "floral"
subdir.mkdir()
p = self.tmp / "batch.xlsx"
write_outfit_source_excel(p, [("floral", str(subdir) + os.sep)])
rows = read_all_rows(p)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].title, "floral")
self.assertEqual(rows[0].product_id, "")
self.assertTrue(rows[0].garment_path.endswith(os.sep))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()