diff --git a/negpy/desktop/view/widgets/prescan_dialog.py b/negpy/desktop/view/widgets/prescan_dialog.py index 29abbbb1..e35b3157 100644 --- a/negpy/desktop/view/widgets/prescan_dialog.py +++ b/negpy/desktop/view/widgets/prescan_dialog.py @@ -18,7 +18,7 @@ from negpy.desktop.view.widgets.scan_window_label import ScanWindowLabel from negpy.desktop.workers.scan_worker import PrescanRequest from negpy.infrastructure.scanners.base import ScannerDevice -from negpy.infrastructure.scanners.params import clamp_scan_area +from negpy.infrastructure.scanners.params import crop_to_scan_window from negpy.infrastructure.scanners.result import ScanResult @@ -53,8 +53,10 @@ def __init__( super().__init__(parent) self._controller = controller self._device = device + caps = device.capabilities # TA / backend space (what ScanParams.window stores). self._scan_window: tuple[float, float, float, float] | None = initial_window + self._prescan_mirror_x = bool(caps.prescan_mirror_x) self._busy = False self.setWindowTitle("Prescan — set crop") @@ -152,7 +154,7 @@ def _on_prescan_ready(self, result: object) -> None: if default_crop is not None: self._scan_window = default_crop if self._scan_window is not None: - image_rect = clamp_scan_area(self._scan_window) + image_rect = crop_to_scan_window(self._scan_window, mirror_x=self._prescan_mirror_x) self._label.set_window(image_rect) self._ok_btn.setEnabled(True) self._status.setText("Drag the rectangle to set the scan crop") @@ -177,7 +179,7 @@ def _on_window_changed(self, rect: object) -> None: if rect is None: self._scan_window = None return - self._scan_window = clamp_scan_area(tuple(rect)) # type: ignore[arg-type] + self._scan_window = crop_to_scan_window(tuple(rect), mirror_x=self._prescan_mirror_x) # type: ignore[arg-type] def _on_clear_crop(self) -> None: self._scan_window = None @@ -197,7 +199,7 @@ def accept(self) -> None: # Sync from widget in case the last drag did not emit. rect = self._label.window() if rect is not None: - self._scan_window = clamp_scan_area(rect) + self._scan_window = crop_to_scan_window(rect, mirror_x=self._prescan_mirror_x) self._disconnect_controller() super().accept() diff --git a/negpy/infrastructure/scanners/base.py b/negpy/infrastructure/scanners/base.py index 256089fe..45adb98b 100644 --- a/negpy/infrastructure/scanners/base.py +++ b/negpy/infrastructure/scanners/base.py @@ -34,6 +34,8 @@ class ScannerCapabilities: #: Low-DPI full-window preview then interactive crop (Plustek SE). prescan: bool = False prescan_dpi: int = 0 + #: Left–right mirrored sensor (sensor order flipped in pyopticfilm's assemble()). + prescan_mirror_x: bool = False prescan_default_crop: tuple[float, float, float, float] | None = None multi_exposure: bool = False adapter_frame_capacity: int | None = None # transport capacity bound, not an exposure count diff --git a/negpy/infrastructure/scanners/params.py b/negpy/infrastructure/scanners/params.py index 85aa9679..c77ec16f 100644 --- a/negpy/infrastructure/scanners/params.py +++ b/negpy/infrastructure/scanners/params.py @@ -50,6 +50,22 @@ def clamp_scan_area(area: ScanArea) -> ScanArea: return (x1, y1, x2, y2) +def crop_to_scan_window(crop: ScanArea, *, mirror_x: bool) -> ScanArea: + """Map Prescan widget coords ↔ TA ``area`` for ``ScanParams.window``. + + Self-inverse when ``mirror_x`` is fixed: image-left is sensor-right on mirrored + scanners, so trimming left chrome on the Prescan must crop the opposite TA side. + + Mirrors the pre-#958 behaviour that PR #958 accidentally flattened to a plain + clamp, which displaced Prescan crops on mirror_x Plustek devices. + """ + area = clamp_scan_area(crop) + if mirror_x: + x1, y1, x2, y2 = area + return (1.0 - x2, y1, 1.0 - x1, y2) + return area + + def clamp_frame_offset_mm(offset_mm: float, pitch_mm: float) -> float: """Effective feed-axis offset, floored at 0 and held short of one frame pitch. diff --git a/negpy/infrastructure/scanners/plustek_backend.py b/negpy/infrastructure/scanners/plustek_backend.py index e6a3c1e6..d5bbe77a 100644 --- a/negpy/infrastructure/scanners/plustek_backend.py +++ b/negpy/infrastructure/scanners/plustek_backend.py @@ -57,6 +57,7 @@ def _caps_for(model: Any) -> ScannerCapabilities: autofocus=False, prescan=prescan_ready, prescan_dpi=PRESCAN_DPI if prescan_ready else 0, + prescan_mirror_x=bool(getattr(model, "mirror_x", False)) if prescan_ready else False, prescan_default_crop=default_frame_crop_norm(model) if prescan_ready else None, multi_exposure=bool(getattr(model, "scan_ready", False) and getattr(model, "exposure_long", None)), adapter_frame_capacity=None, diff --git a/tests/scanners/test_params.py b/tests/scanners/test_params.py index fd41edb4..b517fcb3 100644 --- a/tests/scanners/test_params.py +++ b/tests/scanners/test_params.py @@ -1,7 +1,12 @@ """Tests for ScanParams dataclass and ScanMode validation.""" import pytest -from negpy.infrastructure.scanners.params import ScanMode, ScanParams, clamp_scan_area +from negpy.infrastructure.scanners.params import ( + ScanMode, + ScanParams, + clamp_scan_area, + crop_to_scan_window, +) from negpy.infrastructure.scanners.base import ScannerCapabilities @@ -72,3 +77,19 @@ def test_dpi_range_from_caps(self) -> None: class TestPrescanGeometry: def test_clamp_scan_area_enforces_positive_extent(self) -> None: assert clamp_scan_area((0.5, 0.5, 0.5, 0.5)) == (0.5, 0.5, 0.501, 0.501) + + def test_crop_to_scan_window_reflects_x_when_mirrored(self) -> None: + """Mirror_x scanners: display-left is sensor-right, so x must be reflected. + + Regression for PR #958, which flattened this conversion to a plain clamp and + displaced Prescan crops on mirror_x Plustek devices (8200i SE / 8100 V2). + """ + assert crop_to_scan_window((0.1, 0.2, 0.4, 0.8), mirror_x=True) == pytest.approx((0.6, 0.2, 0.9, 0.8)) + + def test_crop_to_scan_window_passthrough_when_not_mirrored(self) -> None: + assert crop_to_scan_window((0.1, 0.2, 0.4, 0.8), mirror_x=False) == pytest.approx((0.1, 0.2, 0.4, 0.8)) + + def test_crop_to_scan_window_is_self_inverse(self) -> None: + crop = (0.1, 0.2, 0.4, 0.8) + reflected = crop_to_scan_window(crop, mirror_x=True) + assert crop_to_scan_window(reflected, mirror_x=True) == pytest.approx(crop) diff --git a/tests/scanners/test_plustek_backend.py b/tests/scanners/test_plustek_backend.py index 95caf78f..06f9d8c1 100644 --- a/tests/scanners/test_plustek_backend.py +++ b/tests/scanners/test_plustek_backend.py @@ -181,6 +181,7 @@ def test_8100_v2_caps_match_pyopticfilm_model(monkeypatch): assert caps.ir_channel is False assert caps.multi_exposure is True assert caps.prescan is True + assert caps.prescan_mirror_x is True # 8100 V2 inherits mirror_x from 8200i SE assert 1200 in caps.supported_dpi