fix: per-item transform in batch export (correct placement across sizes)

QueuePanel reused one pixel TransformState for all non-fine-tuned items,
mis-placing garments/prints of other sizes. Store the ratio-based template
instead and recompute each item from its own garment/print size.

- add core.composer.image_size() and resolve_transform() (pure, tested)
- queue: set_template() replaces set_transform(); per-item resolve + size cache
- template_panel: expose current_template(); main_window pushes it to the queue
- tests: +4 covering fine-tune precedence, per-size recompute, empty inputs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:42:20 +08:00
co-authored by Claude Opus 4.8
parent 76f2c6dd5b
commit f2b08fecb3
6 changed files with 155 additions and 11 deletions
+12 -1
View File
@@ -257,8 +257,14 @@ class MainWindow(QMainWindow):
# Template selection updates canvas, param panel, and queue template # 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.image_canvas.set_transform)
self.template_panel.template_applied.connect(self.transform_panel.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) 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 # 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) 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._loading_queue_item = False
self._update_reset_availability() 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): def _on_transform_changed(self, state):
"""Route transform changes to panels and mark the active queue item fine-tuned.""" """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 # Keep template panel and export panel up to date for save/export actions
+37 -8
View File
@@ -21,8 +21,8 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from core.composer import compose from core.composer import compose, image_size, resolve_transform
from core.models import BatchMode, ExportOptions, ImageAsset, TransformState from core.models import BatchMode, ExportOptions, ImageAsset, Template, TransformState
from services.file_service import ( from services.file_service import (
get_output_dir, get_output_dir,
make_safe_output_path, make_safe_output_path,
@@ -182,7 +182,8 @@ class QueuePanel(QWidget):
super().__init__(parent) super().__init__(parent)
self._garments: List[ImageAsset] = [] self._garments: List[ImageAsset] = []
self._prints: 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._export_options: ExportOptions = ExportOptions()
self._running = False self._running = False
self._setup_ui() self._setup_ui()
@@ -277,9 +278,14 @@ class QueuePanel(QWidget):
self._prints = prints self._prints = prints
self._rebuild_queue() self._rebuild_queue()
def set_transform(self, state: TransformState): def set_template(self, template: Optional[Template]):
"""Store the template transform applied to items that have not been fine-tuned.""" """Store the selected template (ratio-based) for items not fine-tuned.
self._template_transform = state
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): def set_export_options(self, options: ExportOptions):
self._export_options = options self._export_options = options
@@ -366,6 +372,7 @@ class QueuePanel(QWidget):
def _new_run_dir(self): def _new_run_dir(self):
"""Timestamped folder shared by all images in one export run.""" """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()) base = self._export_options.output_dir or str(get_output_dir())
return timestamped_run_dir(base) return timestamped_run_dir(base)
@@ -387,11 +394,33 @@ class QueuePanel(QWidget):
self._running = False self._running = False
self._batch_btn.setText("开始批量导出") 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): 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: if not transform:
item.status = "失败" item.status = "失败"
item.error = "无印花参数(请先在画布中调整印花位置)" item.error = "无印花参数(请先选择模板或在画布中调整印花位置)"
self._model.update_row(row) self._model.update_row(row)
return return
+8
View File
@@ -165,6 +165,14 @@ class TemplatePanel(QWidget):
return self._state_for_template(t) return self._state_for_template(t)
return None 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): def set_transform(self, state: TransformState):
"""Store current print transform (used when saving a template).""" """Store current print transform (used when saving a template)."""
self._current_state = state self._current_state = state
+30
View File
@@ -9,6 +9,36 @@ from core.models import ComposeResult, ExportOptions, TransformState
logger = logging.getLogger(__name__) 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( def compose(
garment_path: Union[str, Path], garment_path: Union[str, Path],
print_path: Union[str, Path], print_path: Union[str, Path],
+26
View File
@@ -820,6 +820,32 @@
- [x] 点击未微调队列项,预览印花为选中模板要求的大小/位置 - [x] 点击未微调队列项,预览印花为选中模板要求的大小/位置
- [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. 后续暂缓任务 ## 18. 后续暂缓任务
以下任务第一阶段暂不做,后续需要时再新增设计文档: 以下任务第一阶段暂不做,后续需要时再新增设计文档:
+42 -2
View File
@@ -9,8 +9,8 @@ from PIL import Image
sys.path.insert(0, str(Path(__file__).parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from core.composer import compose from core.composer import compose, resolve_transform
from core.models import ExportOptions, TransformState from core.models import ExportOptions, Template, TransformState
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# helpers # helpers
@@ -207,5 +207,45 @@ class TestJpgOutput(_TmpDir, unittest.TestCase):
self.assertTrue(result.success, result.error) 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__": if __name__ == "__main__":
unittest.main() unittest.main()