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:
+22
-1
@@ -244,6 +244,24 @@
|
||||
- 日志保存到本地 `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.1 平台与运行环境
|
||||
@@ -294,11 +312,14 @@ CMBot/
|
||||
app_config.json
|
||||
templates.json
|
||||
logs/
|
||||
output/
|
||||
合并后的图片/
|
||||
<时间戳>/ # 每次导出运行一个时间戳文件夹(如 20260617_143022)
|
||||
<印花文件名>/ # 其内按印花分组,子文件夹以印花文件名命名
|
||||
<衣服文件名>_<印花文件名>.png # 文件名 = 衣服名_印花名,如 1_TY030.png
|
||||
...
|
||||
<时间戳>.xlsx # 与时间戳子目录同级、同名的 Excel(§6.12)
|
||||
# 每印花一行,C 列 = 对应子目录路径,供 AI 穿搭直接读取
|
||||
穿搭图片/ # AI 穿搭默认输出目录(docs/11-ai-outfit.md §9)
|
||||
resources/
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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.models import BatchMode, ExportOptions, ImageAsset, Template, TransformState
|
||||
from services.excel_service import write_outfit_source_excel
|
||||
from services.file_service import (
|
||||
get_output_dir,
|
||||
make_safe_output_path,
|
||||
@@ -393,6 +395,27 @@ class QueuePanel(QWidget):
|
||||
|
||||
self._running = False
|
||||
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):
|
||||
"""Return (w, h) for path, caching within a run. None if unreadable."""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
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
|
||||
|
||||
@@ -149,6 +149,27 @@ def read_all_rows(excel_path):
|
||||
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):
|
||||
"""Write one outfit result to columns D/E/F and save immediately."""
|
||||
if not isinstance(result, OutfitResult):
|
||||
|
||||
@@ -1006,6 +1006,34 @@
|
||||
- [x] 导出面板默认值随之变化(沿用 `get_output_dir()`,无需单独改)
|
||||
- [ ] 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. 后续暂缓任务
|
||||
|
||||
以下任务第一阶段暂不做,后续需要时再新增设计文档:
|
||||
|
||||
@@ -138,6 +138,54 @@ class TestOutfitExcelService(unittest.TestCase):
|
||||
with self.assertRaises(ExcelInUseError):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user