diff --git a/docs/11-ai-outfit.md b/docs/11-ai-outfit.md index 9da62d9..403d424 100644 --- a/docs/11-ai-outfit.md +++ b/docs/11-ai-outfit.md @@ -55,8 +55,8 @@ services/excel_service:把结果写回 Excel(D 新图路径 / E 状态 / F | 列 | 含义 | 读/写 | |---|---|---| | A | 标题 | 读 | -| B | 货号(商品 ID) | 读 | -| C | 衣服图路径(本机绝对路径) | 读 | +| B | 货号(商品 ID,**可空**) | 读 | +| C | 衣服图路径(本机绝对路径,文件或目录) | 读 | | D | 生成结果图片路径 | 写回 | | E | 完成状态:`完成` / `失败`(空=未处理) | 写回 | | F | 失败原因(仅人工查看) | 写回 | @@ -65,7 +65,7 @@ services/excel_service:把结果写回 Excel(D 新图路径 / E 状态 / F - 默认读第一个工作表,第 1 行表头,从第 2 行开始。 - **跳过 E=`完成` 的行**;是否处理 E=`失败` 的行由界面「重试失败行」决定(勾选则处理「空白 + 失败」,否则只处理「空白」)。 -- 标题/货号/衣服图任一为空 → 跳过该行,不写状态、不中断。 +- **行有效性只看「标题 + 衣服图路径」两者非空**;**货号(B 列)可空**——它不进提示词,仅用于单文件行的输出命名(见 §9),为空时命名回退用源图名,因此不应再因缺货号而跳过整行。标题或衣服图为空 → 跳过该行,不写状态、不中断。 - 字段按原样读取,不清理空格、不回写清理值。 - **开始前检测 Excel 是否被占用**(Office 打开会锁文件,openpyxl 无法写回)→ 提示「请关闭 Excel 后再开始」。 - **每处理完一行即保存 Excel**(降低崩溃丢结果风险)。 @@ -174,7 +174,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只 - 格式 JPG,1:1,压缩到 ≤2MB;质量三档(小文件 75 / 均衡 85 / 高清 92)。 - 默认输出目录沿用 cmbot `get_output_dir()`(程序旁的「合并后的图片」,见 `docs/10` §5),界面可改。 -- 命名:`货号.jpg`,重名自动 `_1`/`_2`,非法字符替换为 `_`(不改 Excel 原始货号)。 +- 命名:`货号.jpg`,重名自动 `_1`/`_2`,非法字符替换为 `_`(不改 Excel 原始货号)。**货号为空时命名回退用源图名**(`<源图名>.jpg`,与目录行一致)。 - 成功后把**实际新图绝对路径**写回 Excel D 列。 ### 9.1 目录行的输出(多图 → 同名子目录) diff --git a/src/app/widgets/ai_outfit_panel.py b/src/app/widgets/ai_outfit_panel.py index 234eb88..564bd2d 100644 --- a/src/app/widgets/ai_outfit_panel.py +++ b/src/app/widgets/ai_outfit_panel.py @@ -687,8 +687,9 @@ class AiOutfitPanel(QWidget): self._sample_combo.clear() if tasks: for t in tasks: + pid = t.product_id if (t.product_id and t.product_id.strip()) else "(无货号)" self._sample_combo.addItem( - "第 {} 行 · {} · {}".format(t.row_index, t.product_id, t.title), t) + "第 {} 行 · {} · {}".format(t.row_index, pid, t.title), t) else: self._sample_combo.addItem("(选 Excel 后显示替换效果)", None) self._sample_combo.blockSignals(False) diff --git a/src/core/ai_outfit.py b/src/core/ai_outfit.py index a6873eb..97b00d5 100644 --- a/src/core/ai_outfit.py +++ b/src/core/ai_outfit.py @@ -167,7 +167,10 @@ def generate_outfit_image( prompt = render_prompt(prompt_template, task, resolution=resolution) client = api_client or ImageApiClient(model_config) image_bytes = client.generate(prompt, task.garment_path, resolution=resolution) - output_path = make_outfit_output_path(output_dir, task.product_id) + # 货号可空:为空时输出名回退用源图名(docs/11 §9)。 + name = task.product_id if (task.product_id and str(task.product_id).strip()) \ + else Path(task.garment_path).stem + output_path = make_outfit_output_path(output_dir, name) save_jpg_under_limit(image_bytes, output_path, quality=quality) logger.info("Generated outfit row %s -> %s", task.row_index, output_path) return OutfitResult( diff --git a/src/services/excel_service.py b/src/services/excel_service.py index 095b6d0..85b0f0f 100644 --- a/src/services/excel_service.py +++ b/src/services/excel_service.py @@ -72,8 +72,9 @@ def load_outfit_tasks(excel_path, retry_failed=False): A title, B product id, C garment path, D output path, E status, F error. Rows start at 2. Completed rows are always skipped. Failed rows are included - only when *retry_failed* is True. Rows missing title/product/path are skipped - silently and are not written back. + only when *retry_failed* is True. Row validity needs only 标题 + 衣服图路径; + 货号 (B) is optional (docs/11 §4). Rows missing title/path are skipped silently + and are not written back. """ path = Path(excel_path) workbook = load_workbook(str(path)) @@ -86,7 +87,8 @@ def load_outfit_tasks(excel_path, retry_failed=False): product_id = sheet.cell(row_index, COL_PRODUCT_ID).value garment_path = sheet.cell(row_index, COL_GARMENT_PATH).value - if _is_empty(title) or _is_empty(product_id) or _is_empty(garment_path): + # 货号(B)可空:只要求 标题 + 衣服图路径 非空(docs/11 §4)。 + if _is_empty(title) or _is_empty(garment_path): logger.debug("Skipped incomplete outfit row %s in %s", row_index, path) continue @@ -117,9 +119,9 @@ def read_all_rows(excel_path): """Return an OutfitTask for every valid data row, ignoring E-column status. Unlike load_outfit_tasks (which filters for *processable* rows), this returns - all rows whose title/product/garment are filled — including 「完成」/「失败」 — - for preview/sample purposes only (docs/11 §10.3). Rows missing any of those - fields are skipped. + all rows whose 标题 + 衣服图路径 are filled — including 「完成」/「失败」, and + regardless of 货号 (which is optional, docs/11 §4) — for preview/sample purposes + only (docs/11 §10.3). Rows missing title/garment are skipped. """ path = Path(excel_path) workbook = load_workbook(str(path)) @@ -130,7 +132,7 @@ def read_all_rows(excel_path): title = sheet.cell(row_index, COL_TITLE).value product_id = sheet.cell(row_index, COL_PRODUCT_ID).value garment_path = sheet.cell(row_index, COL_GARMENT_PATH).value - if _is_empty(title) or _is_empty(product_id) or _is_empty(garment_path): + if _is_empty(title) or _is_empty(garment_path): # 货号可空 continue raw_status = sheet.cell(row_index, COL_STATUS).value rows.append( diff --git a/tasks.md b/tasks.md index ed26a1f..3a05832 100644 --- a/tasks.md +++ b/tasks.md @@ -1160,3 +1160,15 @@ - [x] `ai_outfit_panel.py`:`gen` 闭包传 `request_interval`/`image_log=self.log.emit`;`_add_result_thumb` 逐张加缩略图;`_on_progress` 明细「结果」列显示「子目录 (N 张)」;`_basename` 处理目录末尾分隔符 - [x] `tests/test_ai_outfit.py`:扇出/幂等跳过/空目录/部分失败/命名过滤等用例;既有单文件用例保持绿;全套 12 文件 py37 全绿 - [x] 离屏冒烟:临时 Excel 的 C 指向含 3 张图的目录 + mock API,断言 `输出/<目录名>/` 下 3 个 jpg、D=子目录、E=完成、缩略图 3 张 + +### 19.14 货号(B列)改为可选 — docs/11 §4 / §9 + +前置阅读:`docs/11-ai-outfit.md`(§4、§9、§4.1)、`src/services/excel_service.py`(`load_outfit_tasks`/`read_all_rows` 完整性判定)、`src/core/ai_outfit.py`(`make_outfit_output_path`/`generate_outfit_image` 单文件分支)、`src/app/widgets/ai_outfit_panel.py`(`_fill_sample_combo`) + +背景:用户表的「商品id(货号)」整列为空,而行有效性要求 标题+货号+图三者非空 → `read_all_rows` 与 `load_outfit_tasks` 都返回 0 行:预览下拉显示占位符,且「开始生成」也判定无待处理行。货号不进提示词、目录行输出名沿用源图名,对目录用法是多余约束。已确认:货号改可选。 + +- [x] `excel_service.py`:`read_all_rows` 与 `load_outfit_tasks` 完整性判定改为**只要求 标题 + 衣服图路径**非空;货号可空(状态过滤不变) +- [x] `core/ai_outfit.py`:单文件分支货号为空时输出名回退 `Path(garment_path).stem`(货号非空仍用 `货号.jpg`);目录行不变 +- [x] `ai_outfit_panel.py`:样本下拉标签货号为空时显示「(无货号)」,明细表货号列允许空 +- [x] `tests/test_excel_service.py` / `test_ai_outfit.py`:空货号行被 `read_all_rows`/`load_outfit_tasks` 收录;单文件空货号 → 输出按源图名命名;既有用例保持绿 +- [x] 离屏冒烟:用「标题填、货号空、C=目录」的表,断言预览下拉有行、生成产出到子目录、E=完成;全套 py37 全绿 diff --git a/tests/test_ai_outfit.py b/tests/test_ai_outfit.py index 1957fee..1d4281a 100644 --- a/tests/test_ai_outfit.py +++ b/tests/test_ai_outfit.py @@ -124,6 +124,22 @@ class TestAiOutfitCore(unittest.TestCase): self.assertFalse(result.success) self.assertIn("boom", result.error) + def test_generate_single_file_empty_product_id_uses_source_name(self): + from core.ai_outfit import generate_outfit_image + from core.models import OutfitTask + + garment = self.tmp / "shirt.png" + self._make_image_file(garment) + task = OutfitTask(row_index=2, title="款", product_id="", + garment_path=str(garment)) + + result = generate_outfit_image( + task, "穿 {title}", self.tmp / "o", + model_config={}, api_client=_RecordingClient(self._image_bytes())) + + self.assertTrue(result.success, result.error) + self.assertEqual(Path(result.output_path).name, "shirt.jpg") + # -- directory rows (docs/11 §4.1 / §9.1) --------------------------- def _make_image_file(self, path, color=(10, 20, 30)): diff --git a/tests/test_excel_service.py b/tests/test_excel_service.py index b9392bc..562f9dc 100644 --- a/tests/test_excel_service.py +++ b/tests/test_excel_service.py @@ -108,6 +108,24 @@ class TestOutfitExcelService(unittest.TestCase): self.assertEqual(by_idx[4].status, "失败") self.assertEqual(by_idx[6].title, " 保留空格 ") # raw text preserved + def test_empty_product_id_rows_are_included(self): + """货号(B)可空:只要求 标题 + 衣服图路径(docs/11 §4)。""" + from services.excel_service import load_outfit_tasks, read_all_rows + + p = self.tmp / "nopid.xlsx" + wb = Workbook() + ws = wb.active + ws.append(["标题", "货号", "衣服图", "结果", "状态", "原因"]) + ws.append(["有标题无货号", "", r"D:\img\a.png", "", "", ""]) # 收录 + ws.append(["缺图也缺货号", "", "", "", "", ""]) # 缺图 → 仍跳过 + wb.save(str(p)) + wb.close() + + tasks = load_outfit_tasks(p) + self.assertEqual([t.row_index for t in tasks], [2]) + self.assertEqual(tasks[0].product_id, "") + self.assertEqual([r.row_index for r in read_all_rows(p)], [2]) + def test_check_excel_writable_true_for_existing_workbook(self): from services.excel_service import check_excel_writable