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:
+12
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user