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
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
+37 -8
View File
@@ -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
+8
View File
@@ -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
+30
View File
@@ -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],