diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d09185f10..d09ab6167 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -869,6 +869,8 @@ Capture film directly into NegPy. Two collapsible sections: IR is a second USB pass, color then infrared; NegPy registers that IR plane to the color frame with a whole-pixel shift, so dust removal stays aligned after the carriage re-homes. Color scans apply ASIC shading measured at home before the film feed, the same order as SilverFast, so the strip may stay loaded. The table is cached per DPI, so later scans only re-upload it. The default Full window includes a little holder chrome top and bottom; host-path scans clamp those near-white margins to the film highlight so auto exposure is not skewed. Raise **Analysis Buffer** or crop if a frame still looks off. Autofocus and hardware Auto-exposure controls stay hidden, because the SE does not report those capabilities. On Windows, bind the device to **WinUSB** with Zadig before use, since the stock vendor or SilverFast driver conflicts. The driver is the optional **pyopticfilm** package: install it with `uv sync --group plustek` or `pip install negpy[plustek]`; Windows release builds bundle it. See [PLUSTEK_WINDOWS.md](PLUSTEK_WINDOWS.md). + + **SANE scan window**: on a roll/strip feeder (a live frame count reported), **Preview strip…** previews every frame, sets a per-frame window, and picks which frames to scan. On a SANE device with a single manual holder and no feeder, the button reads **Preview…** instead: it previews just the current holder position and lets you drag one crop window, reused for the next scan (the pyOpticfilm backend's equivalent is **Prescan**, above). Either way, the window narrows the scanner's own hardware scan area — the real scan only reads that region, rather than reading the full frame (holder margins and film rebate included) and cropping in software afterward. * **Camera Scanning**: DSLR or mirrorless copy-stand capture (macOS/Linux). It auto-connects the camera over USB in PC-Remote mode. With a NegPy **Scanlight** connected it captures narrowband R/G/B triplets from saved film-stock presets; without one it does a single white-light exposure. A **Live View** window helps you frame and focus. Captured frames land in the hot folder and flow straight into RGB-Scan mode. Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md). diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index c79a06263..e469e82d2 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -409,6 +409,9 @@ def _update_device_caps(self) -> None: self.eject_btn.setVisible(False) self.frame_range_label.setVisible(False) self.frame_range_widget.setVisible(False) + self.scan_window_row_label.setVisible(False) + self.scan_window_widget.setVisible(False) + self.scan_window_status.setVisible(False) self.exposure_label.setVisible(False) self.exposure_row_widget.setVisible(False) self.autofocus_check.setVisible(False) @@ -541,13 +544,20 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.frame_from_spin.setValue(frm) self.frame_to_spin.setValue(to) - self.scan_window_row_label.setVisible(has_frames) - self.scan_window_widget.setVisible(has_frames) - self.scan_window_status.setVisible(has_frames) + # Scan window: a strip/roll feeder previews per-frame windows (StripPreviewDialog); + # any other device still gets one quick low-res preview of its single holder + # position to set one crop window (QuickScanPreviewDialog). + self.scan_window_row_label.setVisible(True) + self.scan_window_widget.setVisible(True) + self.scan_window_status.setVisible(True) + self.scan_window_row_label.setText("Batch" if has_frames else "Window") if has_frames: self.scan_window_btn.setText("Preview strip…") self.scan_window_btn.setToolTip("Preview each frame, set a window per frame, and pick which frames to scan") - self._update_scan_window_status() + else: + self.scan_window_btn.setText("Preview…") + self.scan_window_btn.setToolTip("Preview the current holder position and set a crop window for the scan") + self._update_scan_window_status() show_prescan = bool(caps.prescan) self.prescan_label.setVisible(show_prescan) @@ -593,28 +603,40 @@ def _on_frame_to_changed(self, _value: int) -> None: def _on_set_scan_window(self) -> None: from dataclasses import replace - from negpy.desktop.view.widgets.strip_preview_dialog import StripPreviewDialog - device = self._current_device() if device is None: return - dialog = StripPreviewDialog( - self.controller, - device, - initial_windows=self._settings.frame_windows, - initial_selected=self._settings.selected_frames, - initial_offset=self._settings.frame_offset_mm, - initial_offset_modifier=self._settings.frame_offset_modifier_mm, - parent=self, - ) - if dialog.exec(): - self.settings = replace( - self._settings, - frame_windows=dialog.frame_windows(), - selected_frames=dialog.selected_frames(), - frame_offset_mm=dialog.frame_offset(), - frame_offset_modifier_mm=dialog.frame_offset_modifier(), + + if device.capabilities.adapter_frame_capacity is not None: + from negpy.desktop.view.widgets.strip_preview_dialog import StripPreviewDialog + + dialog = StripPreviewDialog( + self.controller, + device, + initial_windows=self._settings.frame_windows, + initial_selected=self._settings.selected_frames, + initial_offset=self._settings.frame_offset_mm, + initial_offset_modifier=self._settings.frame_offset_modifier_mm, + parent=self, ) + if dialog.exec(): + self.settings = replace( + self._settings, + frame_windows=dialog.frame_windows(), + selected_frames=dialog.selected_frames(), + frame_offset_mm=dialog.frame_offset(), + frame_offset_modifier_mm=dialog.frame_offset_modifier(), + ) + self._update_scan_window_status() + if dialog.scan_requested(): + self._on_scan() + return + + from negpy.desktop.view.widgets.quick_scan_preview_dialog import QuickScanPreviewDialog + + dialog = QuickScanPreviewDialog(self.controller, device, initial_window=self._settings.scan_window, parent=self) + if dialog.exec(): + self.settings = replace(self._settings, scan_window=dialog.window()) self._update_scan_window_status() if dialog.scan_requested(): self._on_scan() diff --git a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py new file mode 100644 index 000000000..8be5445c2 --- /dev/null +++ b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py @@ -0,0 +1,167 @@ +"""Modal pop-up: a single low-res preview scan and a crop window, for devices with +no addressable frame adapter (Plustek: one manual holder, not a strip/roll feeder — +see StripPreviewDialog for that case). + +Read after ``exec()`` via ``window()``. +""" + +import qtawesome as qta +from PyQt6.QtCore import pyqtSlot +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from negpy.desktop.converters import ImageConverter +from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.scan_preview_common import RollPreviewSignalsMixin, preview_positive +from negpy.desktop.view.widgets.scan_window_label import ScanWindowLabel +from negpy.desktop.workers.scan_worker import RollPreviewRequest +from negpy.infrastructure.scanners.base import ScannerDevice + +_PREVIEW_FALLBACK_DPI = 500 # only when the device reports no DPI list at all +_PREVIEW_SLOT = 1 # PerFrameRollSession's only slot on a frame-less device + + +class QuickScanPreviewDialog(RollPreviewSignalsMixin, QDialog): + """Preview the current holder position at low res; set a crop window for the real scan.""" + + def __init__(self, controller, device: ScannerDevice, initial_window=None, parent=None) -> None: + super().__init__(parent) + self._controller = controller + self._device = device + self._caps = device.capabilities + self._previewing = False + self._scan_now = False # set when the user chooses "Scan" over "Use" + self.setWindowTitle("Preview — set the scan window") + self.setModal(True) + self.resize(560, 480) + + layout = QVBoxLayout(self) + + help_lbl = QLabel( + "Preview the current holder position, then drag to crop — a corner to resize, " + "inside to move. Use (apply and return) or Scan (start scanning now)." + ) + help_lbl.setWordWrap(True) + help_lbl.setStyleSheet( + f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;" + f" background: rgba(255,255,255,0.04); border-radius: 6px; padding: 6px 8px;" + ) + layout.addWidget(help_lbl) + + top = QHBoxLayout() + top.addWidget(QLabel("Preview DPI")) + self.preview_dpi_combo = QComboBox() + for dpi in sorted(self._caps.supported_dpi) or [_PREVIEW_FALLBACK_DPI]: + self.preview_dpi_combo.addItem(str(dpi), dpi) + self.preview_dpi_combo.setCurrentIndex(0) # lowest: fastest, framing only + self.preview_dpi_combo.setToolTip("Resolution used for the preview scan") + top.addWidget(self.preview_dpi_combo) + top.addStretch() + self.preview_btn = QPushButton(qta.icon("fa5s.eye", color=THEME.text_primary), " Preview") + self.preview_btn.clicked.connect(self._on_preview) + top.addWidget(self.preview_btn) + layout.addLayout(top) + + self.label = ScanWindowLabel() + self.label.set_window(tuple(initial_window) if initial_window else None) + layout.addWidget(self.label, 1) + + self.status = QLabel("") + self.status.setWordWrap(True) + self.status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") + layout.addWidget(self.status) + + btns = QHBoxLayout() + self.clear_btn = QPushButton("Clear") + self.clear_btn.setToolTip("Scan the whole frame instead") + self.clear_btn.clicked.connect(self.label.clear_window) + btns.addWidget(self.clear_btn) + btns.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btns.addWidget(cancel_btn) + self.ok_btn = QPushButton("Use") + self.ok_btn.setDefault(True) + self.ok_btn.clicked.connect(self.accept) + btns.addWidget(self.ok_btn) + self.scan_btn = QPushButton(qta.icon("fa5s.play", color=THEME.text_primary), " Scan") + self.scan_btn.setToolTip("Scan now with the current settings") + self.scan_btn.clicked.connect(self._on_scan_clicked) + btns.addWidget(self.scan_btn) + layout.addLayout(btns) + + self._connect_preview_signals() + + # ── result getters ──────────────────────────────────────────────── + + def window(self): + return self.label.window() + + def scan_requested(self) -> bool: + """True when the dialog was accepted via Scan (start now), not Use.""" + return self._scan_now + + # ── ui state ────────────────────────────────────────────────────── + + def _on_scan_clicked(self) -> None: + self._scan_now = True + self.accept() + + def _preview_dpi(self) -> int: + return int(self.preview_dpi_combo.currentData() or _PREVIEW_FALLBACK_DPI) + + def _on_preview(self) -> None: + if self._previewing: + return + req = RollPreviewRequest( + device=self._device, + slots=(_PREVIEW_SLOT,), + dpi=self._preview_dpi(), + offsets={}, + ) + try: + self._controller.start_roll_preview(req) + except Exception as e: + self.status.setText(f"Scanner busy — {e}") + return + self._previewing = True + self.preview_btn.setEnabled(False) + self.status.setText("Previewing…") + + @pyqtSlot(object) + def _on_preview_ready(self, preview) -> None: + if preview.slot != _PREVIEW_SLOT: + return + if preview.error is not None: + self.status.setText(f"Preview failed: {preview.error}") + return + try: + positive = preview_positive(preview.rgb) + pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)) + except Exception as e: + self.status.setText(f"Could not display preview: {e}") + return + self.label.set_frame(pixmap) + + @pyqtSlot() + def _on_preview_finished(self) -> None: + self._previewing = False + self.preview_btn.setEnabled(True) + if not self.status.text().startswith("Preview failed") and not self.status.text().startswith("Could not display"): + self.status.clear() + + @pyqtSlot(str) + def _on_error(self, msg) -> None: + if not self._previewing: + return + self._previewing = False + self.preview_btn.setEnabled(True) + self.status.setText(f"Preview failed: {msg}") + + @pyqtSlot() + def _on_cancelled(self) -> None: + if not self._previewing: + return + self._previewing = False + self.preview_btn.setEnabled(True) + self.status.setText("Preview cancelled.") diff --git a/negpy/desktop/view/widgets/scan_preview_common.py b/negpy/desktop/view/widgets/scan_preview_common.py new file mode 100644 index 000000000..7b7c6fd5a --- /dev/null +++ b/negpy/desktop/view/widgets/scan_preview_common.py @@ -0,0 +1,58 @@ +"""Shared display helpers for scan-preview dialogs (StripPreviewDialog, QuickScanPreviewDialog).""" + +import numpy as np + + +class RollPreviewSignalsMixin: + """Wires a dialog's four scan_roll_preview_ready/scan_roll_preview_finished/ + scan_error/scan_cancelled handlers onto its controller, and tears the connections + down on close. + + Both preview dialogs (whole-strip and single-shot) drive the same + RollPreviewRequest/roll-preview signal pair and differ only in what their four + handlers do with a result — StripPreviewDialog updates one of N tiles and tracks + a batch selection, QuickScanPreviewDialog has just one frame. The wiring itself + doesn't vary, so it lives here once rather than being copy-pasted per dialog. + + A subclass must set ``self._controller`` before calling ``_connect_preview_signals()`` + (typically the first thing __init__ does) and implement the four handlers. + """ + + def _preview_signal_pairs(self): + c = self._controller + return ( + (c.scan_roll_preview_ready, self._on_preview_ready), + (c.scan_roll_preview_finished, self._on_preview_finished), + (c.scan_error, self._on_error), + (c.scan_cancelled, self._on_cancelled), + ) + + def _connect_preview_signals(self) -> None: + for signal, slot in self._preview_signal_pairs(): + signal.connect(slot) + + def closeEvent(self, ev) -> None: + for signal, slot in self._preview_signal_pairs(): + try: + signal.disconnect(slot) + except (TypeError, RuntimeError): + pass + super().closeEvent(ev) + + +def preview_positive(rgb: np.ndarray) -> np.ndarray: + """Cheap negative->positive for a scan preview: per-channel invert + auto-level. + + Not the real develop pipeline — just enough to read the scene through the + orange mask. Each channel is inverted and stretched between its 1st/99th + percentiles, which both flips the negative and neutralizes the base cast. + """ + a = rgb.astype(np.float32) + if a.ndim == 2: + a = a[:, :, None] + out = np.empty_like(a) + for c in range(a.shape[2]): + ch = a[..., c] + lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) + out[..., c] = 0.0 if hi <= lo else np.clip((hi - ch) / (hi - lo), 0.0, 1.0) * 255.0 + return out.astype(np.uint8) diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 7ab40f682..6811e112e 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -5,7 +5,6 @@ ``frame_offset()``. """ -import numpy as np import qtawesome as qta from PyQt6.QtCore import Qt, pyqtSlot from PyQt6.QtGui import QPixmap, QTransform @@ -27,6 +26,7 @@ from negpy.kernel.system.text import plural from negpy.desktop.converters import ImageConverter from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.scan_preview_common import RollPreviewSignalsMixin, preview_positive from negpy.desktop.view.widgets.scan_window_label import ScanWindowLabel from negpy.desktop.workers.scan_worker import RollPreviewRequest from negpy.infrastructure.scanners.base import ScannerDevice @@ -50,24 +50,6 @@ _DISPLAY_ROTATION_DEG = -90 -def _preview_positive(rgb: np.ndarray) -> np.ndarray: - """Cheap negative→positive for the strip preview: per-channel invert + auto-level. - - Not the real develop pipeline — just enough to read the scene through the - orange mask. Each channel is inverted and stretched between its 1st/99th - percentiles, which both flips the negative and neutralizes the base cast. - """ - a = rgb.astype(np.float32) - if a.ndim == 2: - a = a[:, :, None] - out = np.empty_like(a) - for c in range(a.shape[2]): - ch = a[..., c] - lo, hi = np.percentile(ch, 1), np.percentile(ch, 99) - out[..., c] = 0.0 if hi <= lo else np.clip((hi - ch) / (hi - lo), 0.0, 1.0) * 255.0 - return out.astype(np.uint8) - - def _clamp01(v: float) -> float: return max(0.0, min(1.0, v)) @@ -115,7 +97,7 @@ def __init__(self, frame: int, label: ScanWindowLabel, checkbox: QCheckBox, prev self.widget = widget -class StripPreviewDialog(QDialog): +class StripPreviewDialog(RollPreviewSignalsMixin, QDialog): """Preview each frame of a strip; set a per-frame window and frame selection.""" def __init__( @@ -261,10 +243,7 @@ def __init__( self._on_offset_changed(self.offset_slider.value()) self._update_ok_enabled() - controller.scan_roll_preview_ready.connect(self._on_preview_ready) - controller.scan_roll_preview_finished.connect(self._on_preview_finished) - controller.scan_error.connect(self._on_error) - controller.scan_cancelled.connect(self._on_cancelled) + self._connect_preview_signals() def _build_tile(self, frame: int, initial_window, checked: bool) -> _Tile: """A big landscape preview with a subtle overlay box (frame checkbox + preview).""" @@ -452,7 +431,7 @@ def _on_preview_ready(self, preview) -> None: self.status.setText(f"Frame {preview.slot} failed — continuing…") return try: - positive = _preview_positive(preview.rgb) + positive = preview_positive(preview.rgb) pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)).transformed(QTransform().rotate(_DISPLAY_ROTATION_DEG)) except Exception as e: self.status.setText(f"Could not display frame {preview.slot}: {e}") @@ -489,16 +468,3 @@ def _on_cancelled(self) -> None: self._previewing = False self._set_previewing(False) self.status.setText("Preview cancelled.") - - def closeEvent(self, ev) -> None: - for signal, slot in ( - (self._controller.scan_roll_preview_ready, self._on_preview_ready), - (self._controller.scan_roll_preview_finished, self._on_preview_finished), - (self._controller.scan_error, self._on_error), - (self._controller.scan_cancelled, self._on_cancelled), - ): - try: - signal.disconnect(slot) - except (TypeError, RuntimeError): - pass - super().closeEvent(ev) diff --git a/negpy/infrastructure/scanners/per_frame_roll.py b/negpy/infrastructure/scanners/per_frame_roll.py index 4298ef0af..1bfe1f52c 100644 --- a/negpy/infrastructure/scanners/per_frame_roll.py +++ b/negpy/infrastructure/scanners/per_frame_roll.py @@ -42,6 +42,11 @@ def approve(self, slot: int) -> None: """No-op: a per-frame transport addresses frames directly, never infers a boundary.""" def preview(self, slots: Iterable[int], *, cancel: threading.Event) -> Iterator[RollPreview]: + # A device with no adapter (Plustek: single manual holder, no SANE `frame` + # option) has one implicit "current position" — requesting frame=1 on it + # fails loud (see sane_backend._require_writable_option). slot_count is 1 + # there anyway, so omitting `frame` costs nothing on a real adapter. + has_adapter = self._device.capabilities.adapter_frame_capacity is not None for slot in slots: if cancel.is_set(): return @@ -54,7 +59,7 @@ def preview(self, slots: Iterable[int], *, cancel: threading.Event) -> Iterator[ frame_offset_mm=offset_mm, autofocus=False, auto_exposure=False, - frame=slot, + frame=slot if has_adapter else None, ) try: result = self._backend.scan(self._device.id, params, lambda _fraction, _phase="": None, cancel) diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index 0716dea33..e1caf893c 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -298,6 +298,21 @@ def _apply_frame_offset(dev, offset_mm: float) -> None: raise RuntimeError(f"Could not set frame offset (subframe)={offset_mm}: {e}") from e +def _apply_scan_window(dev, option_map, window: tuple[float, float, float, float] | None) -> None: + """Set tl_x/tl_y/br_x/br_y from a normalized window, if any. No-op on None. + + Must be called before every dev.start() a scan makes, not just the first: a + device that does a second full pass under a different `source` (the 'source' + IR strategy) can reset or re-range its geometry options on that switch, and a + silent per-pass window mismatch there yields two differently-shaped arrays. + """ + if window is None: + return + for name, value in _window_to_option_values(option_map, window).items(): + if hasattr(dev, name): + setattr(dev, name, value) + + def _find_ir_option(opt) -> str | None: """Return a legacy dedicated-IR option, preserving presence-only behavior.""" for key in opt: @@ -1060,10 +1075,7 @@ def _scan_on_device( x1, y1, x2, y2 = window if window is not None else (0.0, 0.0, 1.0, 1.0) y2 = min(y2, extent_cap) window = (x1, min(y1, y2), x2, y2) - if window is not None: - for name, value in _window_to_option_values(option_map, window).items(): - if hasattr(dev, name): - setattr(dev, name, value) + _apply_scan_window(dev, option_map, window) if progress: try: @@ -1143,6 +1155,12 @@ def _scan_on_device( ir_source = self._get_ir_source(dev) if ir_source: dev.source = ir_source + # A source switch can reset/re-range geometry options on some + # backends — re-apply so the IR pass covers the same window as + # RGB. A silent mismatch here means two differently-shaped + # arrays, and preview_manager's loader-side guard then drops + # the IR plane outright as "belongs to another frame." + _apply_scan_window(dev, option_map, window) if progress: try: progress(0.0) diff --git a/tests/scanners/test_ir_progress.py b/tests/scanners/test_ir_progress.py index 4751a251f..cbd379590 100644 --- a/tests/scanners/test_ir_progress.py +++ b/tests/scanners/test_ir_progress.py @@ -9,6 +9,7 @@ from typing import Any import numpy as np +import pytest from negpy.infrastructure.scanners.params import ScanParams from negpy.infrastructure.scanners.sane_backend import SaneBackend @@ -32,21 +33,47 @@ def is_settable(self) -> bool: "source": FakeOption(constraint=["Negative", "Negative (IR)"]), "depth": FakeOption(constraint=[8, 16]), "resolution": FakeOption(constraint=[300, 600, 1200]), + "tl_x": FakeOption(constraint=(0.0, 36.33, 0.0)), + "tl_y": FakeOption(constraint=(0.0, 25.0, 0.0)), + "br_x": FakeOption(constraint=(0.0, 36.33, 0.0)), + "br_y": FakeOption(constraint=(0.0, 25.0, 0.0)), } +_GEOMETRY_NAMES = ("tl_x", "tl_y", "br_x", "br_y") -class TwoPassFakeSaneDev: - """Plustek/genesys-style: switches `source` for a second full arr_snap() pass.""" - _INTERNAL = ("recorded", "true_frame", "closed", "cancelled", "rgb_frame", "ir_frame") +class TwoPassFakeSaneDev: + """Plustek/genesys-style: switches `source` for a second full arr_snap() pass. + + Also models a real backend quirk: switching `source` can reset/re-range the + device's geometry options, so a scan window applied only before the RGB pass + would silently vanish for the IR pass. reset_geometry_on_source_switch=True + reproduces that, to prove the window gets re-applied for both passes. + """ + + _INTERNAL = ( + "recorded", + "true_frame", + "closed", + "cancelled", + "rgb_frame", + "ir_frame", + "reset_geometry_on_source_switch", + "geometry_at_snap", + ) - def __init__(self, rgb_frame: np.ndarray, ir_frame: np.ndarray) -> None: + def __init__(self, rgb_frame: np.ndarray, ir_frame: np.ndarray, *, reset_geometry_on_source_switch: bool = False) -> None: object.__setattr__(self, "recorded", {"source": "Negative"}) object.__setattr__(self, "rgb_frame", rgb_frame) object.__setattr__(self, "ir_frame", ir_frame) object.__setattr__(self, "true_frame", rgb_frame) object.__setattr__(self, "closed", False) object.__setattr__(self, "cancelled", False) + object.__setattr__(self, "reset_geometry_on_source_switch", reset_geometry_on_source_switch) + # One geometry snapshot per arr_snap() call — what a real backend would + # actually have scanned with for that pass, independent of what a later + # source-switch-back (RGB->IR->RGB) subsequently wipes. + object.__setattr__(self, "geometry_at_snap", []) @property def opt(self): @@ -58,6 +85,9 @@ def __setattr__(self, name: str, value: Any) -> None: return if name not in _SOURCE_OPT: raise AttributeError(f"No such SANE option: {name}") + if name == "source" and self.reset_geometry_on_source_switch: + for geo in _GEOMETRY_NAMES: + self.recorded.pop(geo, None) self.recorded[name] = value def __getattr__(self, name: str) -> Any: @@ -73,6 +103,7 @@ def get_parameters(self): return ("color", 1, (w, h), 16, w * 3 * 2) def arr_snap(self, progress=None) -> np.ndarray: + self.geometry_at_snap.append({geo: self.recorded.get(geo) for geo in _GEOMETRY_NAMES}) frame = self.ir_frame if self.recorded.get("source") == "Negative (IR)" else self.rgb_frame if progress is not None: h = frame.shape[0] @@ -157,3 +188,30 @@ def test_no_ir_scan_still_reaches_100_percent(): assert calls[-1] == 1.0 assert calls.count(0.0) == 1 # single pass — never resets back to 0 mid-scan assert calls == sorted(calls) + + +def test_scan_window_survives_a_source_switch_that_resets_geometry(): + """A device that clears tl_x/tl_y/br_x/br_y on `source =` (plausible on real + hardware — different sources can report different scannable areas) must not + silently lose the crop window for the IR pass: RGB and IR would then come + back different pixel sizes, and the loader drops the "mismatched" IR plane.""" + h, w = 10, 6 + rgb = np.zeros((h, w, 3), dtype=np.uint16) + ir = np.zeros((h, w), dtype=np.uint16) + dev = TwoPassFakeSaneDev(rgb, ir, reset_geometry_on_source_switch=True) + backend = _make_backend(dev) + + backend.scan( + "plustek:libusb:001:008", + ScanParams(dpi=300, depth=16, capture_ir=True, window=(0.1, 0.2, 0.9, 0.8)), + None, + threading.Event(), + ) + + # Two arr_snap() calls: RGB, then IR after the source switch wiped geometry. + # The IR-pass snapshot must show the window re-applied, not the wiped state. + rgb_snap, ir_snap = dev.geometry_at_snap + expected = {"tl_x": 0.1 * 36.33, "tl_y": 0.2 * 25.0, "br_x": 0.9 * 36.33, "br_y": 0.8 * 25.0} + for geo, value in expected.items(): + assert rgb_snap[geo] == pytest.approx(value) + assert ir_snap[geo] == pytest.approx(value) diff --git a/tests/scanners/test_per_frame_roll.py b/tests/scanners/test_per_frame_roll.py index 46af6e122..45a2f383a 100644 --- a/tests/scanners/test_per_frame_roll.py +++ b/tests/scanners/test_per_frame_roll.py @@ -35,7 +35,7 @@ def __init__(self, *, fail_on: set[int] | None = None, cancel_on: int | None = N def scan(self, device_id, params, progress, cancel) -> ScanResult: self.params_seen.append(params) - if self._cancel_on == params.frame: + if self._cancel_on is not None and self._cancel_on == params.frame: cancel.set() raise RuntimeError("cancelled mid-scan") if params.frame in self._fail_on: @@ -154,6 +154,28 @@ def test_slot_count_follows_the_adapter_capacity() -> None: assert _session(_FakeBackend(), device=_device(capacity=40)).slot_count == 40 +def test_frame_less_device_omits_frame_rather_than_requesting_frame_one() -> None: + """Plustek etc.: one manual holder, no SANE `frame` option — requesting frame=1 + on it fails loud (sane_backend._require_writable_option), so it must be omitted.""" + caps = ScannerCapabilities( + ir_channel=False, + supported_dpi=(1200,), + supported_depths=(8, 16), + sources=(ScanMode.NEGATIVE,), + max_area_mm=(36.0, 24.0), + adapter_frame_capacity=None, + ) + device = ScannerDevice(id="plustek:libusb:001:008", vendor="Plustek", model="OpticFilm", capabilities=caps) + backend = _FakeBackend() + session = PerFrameRollSession(backend, device, dpi=300) + assert session.slot_count == 1 + + (preview,) = list(session.preview((1,), cancel=threading.Event())) + + assert backend.params_seen[0].frame is None + assert preview.rgb is not None + + def test_approve_and_close_are_no_ops() -> None: session = _session(_FakeBackend()) session.approve(1) diff --git a/tests/test_quick_scan_preview_dialog.py b/tests/test_quick_scan_preview_dialog.py new file mode 100644 index 000000000..aeb668cce --- /dev/null +++ b/tests/test_quick_scan_preview_dialog.py @@ -0,0 +1,158 @@ +"""Offline tests for the single-shot preview dialog (frame-less devices, e.g. Plustek). + +Constructs the real QuickScanPreviewDialog against a light fake controller under an +offscreen Qt platform. Proves the preview/result flow and — since both preview +dialogs now share their signal wiring via RollPreviewSignalsMixin — that this +dialog's connect/disconnect actually works, not just StripPreviewDialog's. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import sys + +import numpy as np +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtWidgets import QApplication + +from negpy.desktop.view.widgets.quick_scan_preview_dialog import QuickScanPreviewDialog +from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice +from negpy.infrastructure.scanners.params import ScanMode +from negpy.infrastructure.scanners.roll import RollPreview + +if not QApplication.instance(): + _app = QApplication(sys.argv) + + +def _device() -> ScannerDevice: + caps = ScannerCapabilities( + ir_channel=False, + supported_dpi=(1200, 2400, 7200), + supported_depths=(8, 16), + sources=(ScanMode.NEGATIVE,), + max_area_mm=(36.0, 24.0), + adapter_frame_capacity=None, # the whole point: no feeder + ) + return ScannerDevice(id="plustek:libusb:001:008", vendor="Plustek", model="OpticFilm", capabilities=caps) + + +class _FakeController(QObject): + scan_roll_preview_ready = pyqtSignal(object) + scan_roll_preview_finished = pyqtSignal() + scan_error = pyqtSignal(str) + scan_cancelled = pyqtSignal() + + def __init__(self, *, raise_on_preview: bool = False) -> None: + super().__init__() + self.preview_reqs: list = [] + self._raise = raise_on_preview + + def start_roll_preview(self, req) -> None: + if self._raise: + raise RuntimeError("A scanner request is already active") + self.preview_reqs.append(req) + + def deliver(self, slot: int = 1, *, rgb=None, error: str | None = None) -> None: + rgb = np.zeros((8, 8, 3), dtype=np.uint8) if rgb is None and error is None else rgb + self.scan_roll_preview_ready.emit(RollPreview(slot=slot, rgb=rgb, error=error)) + self.scan_roll_preview_finished.emit() + + +def test_preview_requests_the_single_implicit_slot(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog._on_preview() + + assert controller.preview_reqs[0].slots == (1,) + + +def test_preview_uses_lowest_supported_dpi(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog._on_preview() + + assert controller.preview_reqs[0].dpi == 1200 + + +def test_preview_result_shows_the_frame_and_clears_busy_state(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog._on_preview() + assert dialog.preview_btn.isEnabled() is False + controller.deliver() + + assert dialog.preview_btn.isEnabled() is True + assert dialog.label.has_frame() + assert dialog.status.text() == "" + + +def test_preview_failure_reports_status_and_clears_busy_state(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog._on_preview() + controller.deliver(error="carriage jammed") + + assert dialog.preview_btn.isEnabled() is True + assert "carriage jammed" in dialog.status.text() + + +def test_busy_scanner_reports_status_without_starting_preview(): + controller = _FakeController(raise_on_preview=True) + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog._on_preview() + + assert "busy" in dialog.status.text().lower() + assert dialog.preview_btn.isEnabled() is True + + +def test_initial_window_is_restored_but_no_image_until_previewed(): + rect = (0.1, 0.1, 0.5, 0.5) + dialog = QuickScanPreviewDialog(_FakeController(), _device(), initial_window=rect) + + assert dialog.window() == rect + assert dialog.label.has_frame() is False + + +def test_clear_removes_the_window(): + rect = (0.1, 0.1, 0.5, 0.5) + dialog = QuickScanPreviewDialog(_FakeController(), _device(), initial_window=rect) + + dialog.clear_btn.click() + + assert dialog.window() is None + + +def test_scan_button_sets_scan_requested_and_accepts(): + dialog = QuickScanPreviewDialog(_FakeController(), _device()) + + dialog._on_scan_clicked() + + assert dialog.scan_requested() is True + assert dialog.result() == dialog.Accepted + + +def test_use_button_does_not_set_scan_requested(): + dialog = QuickScanPreviewDialog(_FakeController(), _device()) + + dialog.ok_btn.click() + + assert dialog.scan_requested() is False + assert dialog.result() == dialog.Accepted + + +def test_close_disconnects_preview_signals_without_error(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + + dialog.close() + + # Disconnected: delivering a result now must not raise or touch the dialog. + controller.deliver() diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index e0ccb26ba..c3579c214 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -163,6 +163,7 @@ def test_no_device_disables_controls() -> None: assert sidebar.autofocus_check.isVisibleTo(sidebar) is False assert sidebar.depth_combo.isVisibleTo(sidebar) is False assert sidebar.depth_label.isVisibleTo(sidebar) is False + assert sidebar.scan_window_widget.isVisibleTo(sidebar) is False def test_full_capability_device_enables_coolscan_controls() -> None: @@ -211,6 +212,64 @@ def test_scan_params_include_prescan_crop() -> None: assert req.params.window == (0.1, 0.2, 0.9, 0.8) +def test_minimal_device_still_gets_a_single_shot_preview_window_control() -> None: + # No frame adapter to page through, but a single manual holder still gets a + # quick low-res preview to set one crop window before the real scan. + sidebar, _ = _sidebar(MINIMAL_DEVICE) + assert sidebar.scan_window_widget.isVisibleTo(sidebar) is True + assert sidebar.scan_window_btn.text() == "Preview…" + assert sidebar.scan_window_row_label.text() == "Window" + + +def test_full_capability_device_gets_the_strip_preview_window_control() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + assert sidebar.scan_window_widget.isVisibleTo(sidebar) is True + assert sidebar.scan_window_btn.text() == "Preview strip…" + assert sidebar.scan_window_row_label.text() == "Batch" + + +def test_minimal_device_scan_window_opens_the_quick_preview_dialog(monkeypatch) -> None: + sidebar, _ = _sidebar(MINIMAL_DEVICE) + rect = (0.2, 0.2, 0.8, 0.8) + + class _FakeDialog: + def __init__(self, controller, device, initial_window=None, parent=None) -> None: + self.seen = (controller, device, initial_window) + + def exec(self) -> bool: + return True + + def window(self): + return rect + + def scan_requested(self) -> bool: + return False + + monkeypatch.setattr("negpy.desktop.view.widgets.quick_scan_preview_dialog.QuickScanPreviewDialog", _FakeDialog) + + sidebar._on_set_scan_window() + + assert sidebar._settings.scan_window == rect + + +def test_full_capability_device_scan_window_still_opens_the_strip_dialog(monkeypatch) -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + opened: list = [] + + class _FakeDialog: + def __init__(self, controller, device, **kwargs) -> None: + opened.append(device) + + def exec(self) -> bool: + return False # cancelled — settings must stay untouched + + monkeypatch.setattr("negpy.desktop.view.widgets.strip_preview_dialog.StripPreviewDialog", _FakeDialog) + + sidebar._on_set_scan_window() + + assert opened == [FULL_DEVICE] + + def test_14_bit_device_defaults_to_14_not_8() -> None: sidebar, _ = _sidebar(LS50_DEVICE) # Saved default depth 16 is not offered on an (8, 14) scanner; the combo must diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index 05721bb68..159920ad8 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -19,10 +19,10 @@ from PyQt6.QtCore import QObject, pyqtSignal from PyQt6.QtWidgets import QApplication +from negpy.desktop.view.widgets.scan_preview_common import preview_positive from negpy.desktop.view.widgets.strip_preview_dialog import ( StripPreviewDialog, _display_to_scan_rect, - _preview_positive, _scan_to_display_rect, ) from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice @@ -212,7 +212,7 @@ def test_preview_positive_inverts_and_levels() -> None: neg = np.zeros((4, 4, 3), dtype=np.uint8) neg[:, :2, :] = 20 # low negative value = scene shadow → should become bright neg[:, 2:, :] = 200 # high negative value = scene highlight → should become dark - pos = _preview_positive(neg) + pos = preview_positive(neg) assert pos.dtype == np.uint8 assert pos.shape == neg.shape assert pos[:, :2, :].mean() > pos[:, 2:, :].mean() # inverted