diff --git a/src/app/main_window.py b/src/app/main_window.py index b43b1e2..4cbd22c 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -257,8 +257,14 @@ class MainWindow(QMainWindow): # Template selection updates canvas, param panel, and queue template self.template_panel.template_applied.connect(self.image_canvas.set_transform) self.template_panel.template_applied.connect(self.transform_panel.set_transform) - self.template_panel.template_applied.connect(self.queue_panel.set_transform) self.template_panel.template_applied.connect(self.export_panel.set_transform) + # Give the queue the template itself (ratio-based) so each item is laid + # out from its own garment/print size, not one shared pixel transform. + # template_changed fires on selection even before a garment is loaded; + # template_applied covers re-applies once sizes are known. + self.template_panel.template_changed.connect(self._sync_queue_template) + self.template_panel.template_applied.connect(self._sync_queue_template) + self.queue_panel.set_template(self.template_panel.current_template()) # initial sync # Output settings (dir/format/quality) flow to the queue for batch export self.export_panel.export_options_changed.connect(self.queue_panel.set_export_options) @@ -346,6 +352,11 @@ class MainWindow(QMainWindow): self._loading_queue_item = False self._update_reset_availability() + def _sync_queue_template(self, _state=None): + """Push the currently selected template (ratio-based) to the queue so + batch export recomputes each item from its own garment/print size.""" + self.queue_panel.set_template(self.template_panel.current_template()) + def _on_transform_changed(self, state): """Route transform changes to panels and mark the active queue item fine-tuned.""" # Keep template panel and export panel up to date for save/export actions diff --git a/src/app/widgets/queue_panel.py b/src/app/widgets/queue_panel.py index 14d3861..665a1e1 100644 --- a/src/app/widgets/queue_panel.py +++ b/src/app/widgets/queue_panel.py @@ -21,8 +21,8 @@ from PySide6.QtWidgets import ( QWidget, ) -from core.composer import compose -from core.models import BatchMode, ExportOptions, ImageAsset, TransformState +from core.composer import compose, image_size, resolve_transform +from core.models import BatchMode, ExportOptions, ImageAsset, Template, TransformState from services.file_service import ( get_output_dir, make_safe_output_path, @@ -182,7 +182,8 @@ class QueuePanel(QWidget): super().__init__(parent) self._garments: List[ImageAsset] = [] self._prints: List[ImageAsset] = [] - self._template_transform: Optional[TransformState] = None + self._template: Optional[Template] = None + self._size_cache: dict = {} # path -> (w, h), reused within a batch run self._export_options: ExportOptions = ExportOptions() self._running = False self._setup_ui() @@ -277,9 +278,14 @@ class QueuePanel(QWidget): self._prints = prints self._rebuild_queue() - def set_transform(self, state: TransformState): - """Store the template transform applied to items that have not been fine-tuned.""" - self._template_transform = state + def set_template(self, template: Optional[Template]): + """Store the selected template (ratio-based) for items not fine-tuned. + + Each item's pixel transform is recomputed from its own garment and + print sizes at export time, so differently sized images in one batch + are placed correctly (see _resolve_transform). + """ + self._template = template def set_export_options(self, options: ExportOptions): self._export_options = options @@ -366,6 +372,7 @@ class QueuePanel(QWidget): def _new_run_dir(self): """Timestamped folder shared by all images in one export run.""" + self._size_cache.clear() # fresh sizes per run base = self._export_options.output_dir or str(get_output_dir()) return timestamped_run_dir(base) @@ -387,11 +394,33 @@ class QueuePanel(QWidget): self._running = False self._batch_btn.setText("开始批量导出") + def _image_size(self, path): + """Return (w, h) for path, caching within a run. None if unreadable.""" + key = str(path) + if key not in self._size_cache: + try: + self._size_cache[key] = image_size(path) + except (OSError, ValueError) as exc: + logger.error("Cannot read image size %s: %s", key, exc) + self._size_cache[key] = None + return self._size_cache[key] + + def _resolve_transform(self, item: QueueItem) -> Optional[TransformState]: + """Per-item transform: the item's own fine-tune, else the selected + template recomputed for this item's garment and print sizes.""" + if item.transform: + return item.transform + return resolve_transform( + self._template, None, + self._image_size(item.garment.path), + self._image_size(item.print_asset.path), + ) + def _export_item(self, row: int, item: QueueItem, run_dir): - transform = item.transform or self._template_transform + transform = self._resolve_transform(item) if not transform: item.status = "失败" - item.error = "无印花参数(请先在画布中调整印花位置)" + item.error = "无印花参数(请先选择模板或在画布中调整印花位置)" self._model.update_row(row) return diff --git a/src/app/widgets/template_panel.py b/src/app/widgets/template_panel.py index dc5f7ee..800e4c4 100644 --- a/src/app/widgets/template_panel.py +++ b/src/app/widgets/template_panel.py @@ -165,6 +165,14 @@ class TemplatePanel(QWidget): return self._state_for_template(t) return None + def current_template(self) -> Optional[Template]: + """Return the currently selected template (ratio-based), or None. + + The queue uses this to recompute each item's placement from its own + garment and print sizes, rather than reusing one pixel transform. + """ + return self._current_template() + def set_transform(self, state: TransformState): """Store current print transform (used when saving a template).""" self._current_state = state diff --git a/src/core/composer.py b/src/core/composer.py index b5bf8c0..944f526 100644 --- a/src/core/composer.py +++ b/src/core/composer.py @@ -9,6 +9,36 @@ from core.models import ComposeResult, ExportOptions, TransformState logger = logging.getLogger(__name__) +def image_size(path: Union[str, Path]): + """Return (width, height) of an image in pixels. + + Reads only the image header (PIL is lazy), so it is cheap to call per + queue item during batch export. Raises on unreadable/invalid files. + """ + with Image.open(str(path)) as img: + return img.size + + +def resolve_transform(template, item_transform, garment_size, print_size): + """Pick the pixel transform for one batch item. + + A fine-tuned per-item transform wins. Otherwise the template is recomputed + from THIS item's garment and print sizes, so differently sized images in a + single batch are each placed correctly (the previous code reused one shared + pixel transform and mis-placed items of other sizes). Returns None when no + transform can be determined. + """ + if item_transform is not None: + return item_transform + if template is None or not garment_size or not print_size: + return None + gw, gh = garment_size + pw, ph = print_size + if gw <= 0 or gh <= 0: + return None + return template.to_transform_state(gw, gh, pw or None, ph or None) + + def compose( garment_path: Union[str, Path], print_path: Union[str, Path], diff --git a/tasks.md b/tasks.md index f66e0df..fabeb49 100644 --- a/tasks.md +++ b/tasks.md @@ -820,6 +820,32 @@ - [x] 点击未微调队列项,预览印花为选中模板要求的大小/位置 - [x] 已微调项仍显示其自身参数,不被覆盖 +### 17.15 修复:批量导出对未微调项复用单个像素变换 + +前置阅读: + +- `docs/02-prd.md`(6.6 模板选择) +- `docs/08-image-editor-design.md`(变换状态) + +问题: + +- `QueuePanel` 用单个像素级 `TransformState`(`_template_transform`,来自 `template_applied`,按「当时画布那张衣服/印花尺寸」算出)作为所有未微调项的回退变换。 +- `TransformState` 是「衣服原图像素坐标」,对尺寸不同的衣服/印花原样复用会错位、错尺寸(如 1000×1000 算出的 x=400/w=250 套到 2000×3000 上,印花跑到左上、尺寸只剩一半)。 +- 影响批量导出与「导出选中」中所有未微调项。 + +任务: + +- [x] 队列改存模板对象(比例坐标 `Template`)而非单个像素变换:`set_template()` 取代 `set_transform()` +- [x] 导出时按每项自身衣服/印花尺寸现算:抽纯函数 `core.composer.resolve_transform()`(已微调项用自身变换,否则 `template.to_transform_state(gw,gh,pw,ph)`) +- [x] widget 仅负责读图尺寸(`core.composer.image_size()`,只读 header)+ 单次运行内缓存 +- [x] `main_window` 在 `template_changed`/`template_applied` 时把模板推给队列,并在启动时初始同步 +- [x] 新增 4 个纯函数单测覆盖:已微调优先、不同尺寸各自换算、空模板/空尺寸返回 None + +验收: + +- [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位 +- [ ] GUI 实测:混合尺寸批量导出,结果图印花位置/大小均正确 + ## 18. 后续暂缓任务 以下任务第一阶段暂不做,后续需要时再新增设计文档: diff --git a/tests/test_composer.py b/tests/test_composer.py index fef214b..8e80463 100644 --- a/tests/test_composer.py +++ b/tests/test_composer.py @@ -9,8 +9,8 @@ from PIL import Image sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from core.composer import compose -from core.models import ExportOptions, TransformState +from core.composer import compose, resolve_transform +from core.models import ExportOptions, Template, TransformState # --------------------------------------------------------------------------- # helpers @@ -207,5 +207,45 @@ class TestJpgOutput(_TmpDir, unittest.TestCase): self.assertTrue(result.success, result.error) +class TestResolveTransform(unittest.TestCase): + """resolve_transform: per-item placement for batch export.""" + + def _square_template(self): + # 25% box centred at (0.40,0.40)-(0.65,0.65); square print fills the box. + return Template(name="sq", x_ratio=0.40, y_ratio=0.40, + width_ratio=0.25, height_ratio=0.25) + + def test_fine_tuned_transform_wins(self): + own = TransformState(x=1, y=2, width=3, height=4) + out = resolve_transform(self._square_template(), own, (1000, 1000), (50, 50)) + self.assertIs(out, own) + + def test_differently_sized_garments_get_different_pixels(self): + """The core bug: one shared pixel transform mis-places other sizes. + + Each garment must be laid out from its own dimensions.""" + tpl = self._square_template() + small = resolve_transform(tpl, None, (1000, 1000), (100, 100)) + large = resolve_transform(tpl, None, (2000, 3000), (100, 100)) + + # 1000px garment: 25% box = 250px, square print fills it, centred at 0.525. + self.assertAlmostEqual(small.width, 250.0) + self.assertAlmostEqual(small.x, 400.0) + # 2000x3000 garment: box is 500x750, square print contain-fits to 500, + # and x stays proportional — proving per-item recomputation. + self.assertAlmostEqual(large.width, 500.0) + self.assertAlmostEqual(large.x, 800.0) + self.assertNotAlmostEqual(small.x, large.x) + + def test_none_template_returns_none(self): + self.assertIsNone(resolve_transform(None, None, (100, 100), (10, 10))) + + def test_missing_sizes_return_none(self): + tpl = self._square_template() + self.assertIsNone(resolve_transform(tpl, None, None, (10, 10))) + self.assertIsNone(resolve_transform(tpl, None, (100, 100), None)) + self.assertIsNone(resolve_transform(tpl, None, (0, 0), (10, 10))) + + if __name__ == "__main__": unittest.main()