fix(ai-outfit): 货号(B列)改为可选,空货号不再跳过整行 (§19.14)
用户表的「商品id(货号)」整列为空时,行有效性判定(标题+货号+图三者非空) 把每一行都判为不完整 → read_all_rows 与 load_outfit_tasks 都返回 0:预览下拉 只剩占位符,且「开始生成」也判定无待处理行。货号不进提示词、目录行输出名沿用 源图名,对该用法是多余约束。 - excel_service.py: read_all_rows / load_outfit_tasks 完整性判定改为只要求 标题 + 衣服图路径 非空,货号可空(状态过滤不变) - core/ai_outfit.py: 单文件行货号为空时输出名回退 Path(garment).stem (货号非空仍用 货号.jpg);目录行不变 - ai_outfit_panel.py: 样本下拉标签货号为空显示「(无货号)」 - tests: 空货号行被 read_all_rows/load_outfit_tasks 收录、单文件空货号按源图名命名 - docs/11 §4(表+规则)/§9(命名回退)、tasks.md §19.14 离屏冒烟:标题填/货号空/C=目录 → 预览下拉有行、生成产出到子目录、E=完成; 全套测试在 Python 3.7 全绿(test_config_service 的失败属并行的出厂模板工作,与本次无关)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -55,8 +55,8 @@ services/excel_service:把结果写回 Excel(D 新图路径 / E 状态 / F
|
|||||||
| 列 | 含义 | 读/写 |
|
| 列 | 含义 | 读/写 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| A | 标题 | 读 |
|
| A | 标题 | 读 |
|
||||||
| B | 货号(商品 ID) | 读 |
|
| B | 货号(商品 ID,**可空**) | 读 |
|
||||||
| C | 衣服图路径(本机绝对路径) | 读 |
|
| C | 衣服图路径(本机绝对路径,文件或目录) | 读 |
|
||||||
| D | 生成结果图片路径 | 写回 |
|
| D | 生成结果图片路径 | 写回 |
|
||||||
| E | 完成状态:`完成` / `失败`(空=未处理) | 写回 |
|
| E | 完成状态:`完成` / `失败`(空=未处理) | 写回 |
|
||||||
| F | 失败原因(仅人工查看) | 写回 |
|
| F | 失败原因(仅人工查看) | 写回 |
|
||||||
@@ -65,7 +65,7 @@ services/excel_service:把结果写回 Excel(D 新图路径 / E 状态 / F
|
|||||||
|
|
||||||
- 默认读第一个工作表,第 1 行表头,从第 2 行开始。
|
- 默认读第一个工作表,第 1 行表头,从第 2 行开始。
|
||||||
- **跳过 E=`完成` 的行**;是否处理 E=`失败` 的行由界面「重试失败行」决定(勾选则处理「空白 + 失败」,否则只处理「空白」)。
|
- **跳过 E=`完成` 的行**;是否处理 E=`失败` 的行由界面「重试失败行」决定(勾选则处理「空白 + 失败」,否则只处理「空白」)。
|
||||||
- 标题/货号/衣服图任一为空 → 跳过该行,不写状态、不中断。
|
- **行有效性只看「标题 + 衣服图路径」两者非空**;**货号(B 列)可空**——它不进提示词,仅用于单文件行的输出命名(见 §9),为空时命名回退用源图名,因此不应再因缺货号而跳过整行。标题或衣服图为空 → 跳过该行,不写状态、不中断。
|
||||||
- 字段按原样读取,不清理空格、不回写清理值。
|
- 字段按原样读取,不清理空格、不回写清理值。
|
||||||
- **开始前检测 Excel 是否被占用**(Office 打开会锁文件,openpyxl 无法写回)→ 提示「请关闭 Excel 后再开始」。
|
- **开始前检测 Excel 是否被占用**(Office 打开会锁文件,openpyxl 无法写回)→ 提示「请关闭 Excel 后再开始」。
|
||||||
- **每处理完一行即保存 Excel**(降低崩溃丢结果风险)。
|
- **每处理完一行即保存 Excel**(降低崩溃丢结果风险)。
|
||||||
@@ -174,7 +174,7 @@ Excel 行 → `OutfitTask` 列表的转换由 `excel_service` 完成;核心只
|
|||||||
|
|
||||||
- 格式 JPG,1:1,压缩到 ≤2MB;质量三档(小文件 75 / 均衡 85 / 高清 92)。
|
- 格式 JPG,1:1,压缩到 ≤2MB;质量三档(小文件 75 / 均衡 85 / 高清 92)。
|
||||||
- 默认输出目录沿用 cmbot `get_output_dir()`(程序旁的「合并后的图片」,见 `docs/10` §5),界面可改。
|
- 默认输出目录沿用 cmbot `get_output_dir()`(程序旁的「合并后的图片」,见 `docs/10` §5),界面可改。
|
||||||
- 命名:`货号.jpg`,重名自动 `_1`/`_2`,非法字符替换为 `_`(不改 Excel 原始货号)。
|
- 命名:`货号.jpg`,重名自动 `_1`/`_2`,非法字符替换为 `_`(不改 Excel 原始货号)。**货号为空时命名回退用源图名**(`<源图名>.jpg`,与目录行一致)。
|
||||||
- 成功后把**实际新图绝对路径**写回 Excel D 列。
|
- 成功后把**实际新图绝对路径**写回 Excel D 列。
|
||||||
|
|
||||||
### 9.1 目录行的输出(多图 → 同名子目录)
|
### 9.1 目录行的输出(多图 → 同名子目录)
|
||||||
|
|||||||
@@ -687,8 +687,9 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._sample_combo.clear()
|
self._sample_combo.clear()
|
||||||
if tasks:
|
if tasks:
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
|
pid = t.product_id if (t.product_id and t.product_id.strip()) else "(无货号)"
|
||||||
self._sample_combo.addItem(
|
self._sample_combo.addItem(
|
||||||
"第 {} 行 · {} · {}".format(t.row_index, t.product_id, t.title), t)
|
"第 {} 行 · {} · {}".format(t.row_index, pid, t.title), t)
|
||||||
else:
|
else:
|
||||||
self._sample_combo.addItem("(选 Excel 后显示替换效果)", None)
|
self._sample_combo.addItem("(选 Excel 后显示替换效果)", None)
|
||||||
self._sample_combo.blockSignals(False)
|
self._sample_combo.blockSignals(False)
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ def generate_outfit_image(
|
|||||||
prompt = render_prompt(prompt_template, task, resolution=resolution)
|
prompt = render_prompt(prompt_template, task, resolution=resolution)
|
||||||
client = api_client or ImageApiClient(model_config)
|
client = api_client or ImageApiClient(model_config)
|
||||||
image_bytes = client.generate(prompt, task.garment_path, resolution=resolution)
|
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)
|
save_jpg_under_limit(image_bytes, output_path, quality=quality)
|
||||||
logger.info("Generated outfit row %s -> %s", task.row_index, output_path)
|
logger.info("Generated outfit row %s -> %s", task.row_index, output_path)
|
||||||
return OutfitResult(
|
return OutfitResult(
|
||||||
|
|||||||
@@ -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.
|
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
|
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
|
only when *retry_failed* is True. Row validity needs only 标题 + 衣服图路径;
|
||||||
silently and are not written back.
|
货号 (B) is optional (docs/11 §4). Rows missing title/path are skipped silently
|
||||||
|
and are not written back.
|
||||||
"""
|
"""
|
||||||
path = Path(excel_path)
|
path = Path(excel_path)
|
||||||
workbook = load_workbook(str(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
|
product_id = sheet.cell(row_index, COL_PRODUCT_ID).value
|
||||||
garment_path = sheet.cell(row_index, COL_GARMENT_PATH).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)
|
logger.debug("Skipped incomplete outfit row %s in %s", row_index, path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -117,9 +119,9 @@ def read_all_rows(excel_path):
|
|||||||
"""Return an OutfitTask for every valid data row, ignoring E-column status.
|
"""Return an OutfitTask for every valid data row, ignoring E-column status.
|
||||||
|
|
||||||
Unlike load_outfit_tasks (which filters for *processable* rows), this returns
|
Unlike load_outfit_tasks (which filters for *processable* rows), this returns
|
||||||
all rows whose title/product/garment are filled — including 「完成」/「失败」 —
|
all rows whose 标题 + 衣服图路径 are filled — including 「完成」/「失败」, and
|
||||||
for preview/sample purposes only (docs/11 §10.3). Rows missing any of those
|
regardless of 货号 (which is optional, docs/11 §4) — for preview/sample purposes
|
||||||
fields are skipped.
|
only (docs/11 §10.3). Rows missing title/garment are skipped.
|
||||||
"""
|
"""
|
||||||
path = Path(excel_path)
|
path = Path(excel_path)
|
||||||
workbook = load_workbook(str(path))
|
workbook = load_workbook(str(path))
|
||||||
@@ -130,7 +132,7 @@ def read_all_rows(excel_path):
|
|||||||
title = sheet.cell(row_index, COL_TITLE).value
|
title = sheet.cell(row_index, COL_TITLE).value
|
||||||
product_id = sheet.cell(row_index, COL_PRODUCT_ID).value
|
product_id = sheet.cell(row_index, COL_PRODUCT_ID).value
|
||||||
garment_path = sheet.cell(row_index, COL_GARMENT_PATH).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
|
continue
|
||||||
raw_status = sheet.cell(row_index, COL_STATUS).value
|
raw_status = sheet.cell(row_index, COL_STATUS).value
|
||||||
rows.append(
|
rows.append(
|
||||||
|
|||||||
@@ -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] `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] `tests/test_ai_outfit.py`:扇出/幂等跳过/空目录/部分失败/命名过滤等用例;既有单文件用例保持绿;全套 12 文件 py37 全绿
|
||||||
- [x] 离屏冒烟:临时 Excel 的 C 指向含 3 张图的目录 + mock API,断言 `输出/<目录名>/` 下 3 个 jpg、D=子目录、E=完成、缩略图 3 张
|
- [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 全绿
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ class TestAiOutfitCore(unittest.TestCase):
|
|||||||
self.assertFalse(result.success)
|
self.assertFalse(result.success)
|
||||||
self.assertIn("boom", result.error)
|
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) ---------------------------
|
# -- directory rows (docs/11 §4.1 / §9.1) ---------------------------
|
||||||
|
|
||||||
def _make_image_file(self, path, color=(10, 20, 30)):
|
def _make_image_file(self, path, color=(10, 20, 30)):
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ class TestOutfitExcelService(unittest.TestCase):
|
|||||||
self.assertEqual(by_idx[4].status, "失败")
|
self.assertEqual(by_idx[4].status, "失败")
|
||||||
self.assertEqual(by_idx[6].title, " 保留空格 ") # raw text preserved
|
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):
|
def test_check_excel_writable_true_for_existing_workbook(self):
|
||||||
from services.excel_service import check_excel_writable
|
from services.excel_service import check_excel_writable
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user