diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0c49ae95..82a9221b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -634,6 +634,7 @@ When you set capture gear, it's written to standard EXIF and the digitizing rig Capture film directly into NegPy (Linux and macOS; unavailable on Windows). Two collapsible sections: * **Scanner (SANE)**: drive a supported flatbed/film scanner over SANE. Common controls: backend/device selection, DPI, bit depth, IR channel, autofocus, hardware auto-exposure, frame range (roll feeders), scan window, output format, folder and filename template. When the connected scanner exposes a SANE `scan-exposure-time` option (e.g. some genesys devices), an **Exposure** slider appears below Auto-exposure — set it to override the scanner's default exposure time; the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner. +* **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 scanner with a single manual holder and no feeder (Plustek and similar), the button reads **Preview…** instead: it previews just the current holder position and lets you drag one crop window, reused for the next scan. 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/mirrorless copy-stand capture. Auto-connects the camera over USB (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 b752fce6..c091aa2f 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -367,6 +367,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) return @@ -476,13 +479,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() self.dpi_combo.blockSignals(False) self.depth_combo.blockSignals(False) @@ -517,28 +527,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 00000000..8be5445c --- /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 00000000..7b7c6fd5 --- /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 1d23edd6..7db6d2a6 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 @@ -26,6 +25,7 @@ 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 @@ -49,24 +49,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)) @@ -114,7 +96,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__( @@ -260,10 +242,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 16c76530..97a20ddf 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: None, cancel) diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index d417c260..484e497b 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -299,6 +299,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: @@ -1054,10 +1069,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: @@ -1137,6 +1149,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 4751a251..cbd37959 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 46af6e12..45a2f383 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 00000000..aeb668cc --- /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 949f6b94..e9bbe2a3 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -138,6 +138,7 @@ def test_no_device_disables_controls() -> None: assert sidebar.scan_btn.isEnabled() is False assert sidebar.eject_btn.isVisibleTo(sidebar) is False assert sidebar.frame_range_widget.isVisibleTo(sidebar) is False + assert sidebar.scan_window_widget.isVisibleTo(sidebar) is False def test_full_capability_device_enables_coolscan_controls() -> None: @@ -161,6 +162,64 @@ def test_minimal_device_hides_coolscan_controls() -> None: assert sidebar.scan_btn.isEnabled() is True +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 05721bb6..159920ad 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