From 3096980e0cf0de194ccdbc3344e3e05673c0b879 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 22 Aug 2026 21:29:15 +0200 Subject: [PATCH 1/8] feat(scan): nkscan backend for Nikon Coolscans Drives a Coolscan through the nkscan Rust driver instead of SANE's coolscan3: frame rectangles measured off the film, a native IR pass, a native eject, and the driver's own openICE dust removal. The strip is measured in one pass and every preview tile is cut out of that pass, so previewing five frames takes one read of the film rather than five scans. A boundary nudged afterwards re-frames without touching the scanner. Frame rects and the pass they came from are cached per device and dropped on eject. The first native RollSession, so ScannerService.open_roll grows the branch its docstring anticipated. An empty BatchRequest.frames now means every frame on the film, resolved through ScannerService.detect_frames: a transport that measures the film has no frame range to fall back on, and the hidden spinboxes silently meant frame 1. New controls, each gated on what the unit advertises: ICE, Samples, Superfine, Film (color negative, B&W, slide, Kodachrome) and Film format. Film type decides which way frame boundaries read on a strip, and disables IR and ICE on stock whose silver or dyes stop infrared. An LS-50 reads one CCD line at a time and ignores repeated reads, so Superfine and Samples stay hidden there; where the bindings cannot say, the backend learns it from the first refusal. Also fixes a pre-existing wipe: _update_device_caps checked the autofocus box before the capability flags were refreshed, persisting every gated preference at its unsupported value. It was already losing a saved hardware auto-exposure. --- docs/PIPELINE.md | 3 + docs/USER_GUIDE.md | 15 +- negpy/desktop/view/sidebar/right_panel.py | 6 +- negpy/desktop/view/sidebar/scan.py | 231 +++++++- .../view/widgets/strip_preview_dialog.py | 207 +++++-- negpy/desktop/workers/scan_worker.py | 23 +- negpy/infrastructure/scanners/base.py | 14 +- .../infrastructure/scanners/nkscan_backend.py | 560 ++++++++++++++++++ negpy/infrastructure/scanners/nkscan_roll.py | 199 +++++++ negpy/infrastructure/scanners/params.py | 57 ++ negpy/infrastructure/scanners/registry.py | 7 + negpy/infrastructure/scanners/sane_backend.py | 13 +- negpy/infrastructure/scanners/settings.py | 17 +- negpy/services/scanning/service.py | 28 +- pyproject.toml | 10 + tests/scanners/fake_nkscan.py | 272 +++++++++ tests/scanners/test_backend_contract.py | 30 +- tests/scanners/test_nkscan_backend.py | 484 +++++++++++++++ tests/scanners/test_nkscan_roll.py | 232 ++++++++ tests/scanners/test_scanner_settings.py | 12 + tests/scanners/test_service.py | 55 ++ tests/test_scan_sidebar.py | 183 +++++- tests/test_scan_worker.py | 60 +- tests/test_section_help_dialog.py | 3 +- tests/test_strip_preview_dialog.py | 202 +++++++ uv.lock | 14 +- 26 files changed, 2836 insertions(+), 101 deletions(-) create mode 100644 negpy/infrastructure/scanners/nkscan_backend.py create mode 100644 negpy/infrastructure/scanners/nkscan_roll.py create mode 100644 tests/scanners/fake_nkscan.py create mode 100644 tests/scanners/test_nkscan_backend.py create mode 100644 tests/scanners/test_nkscan_roll.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 0d6b10af8..fad3a094a 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -359,6 +359,9 @@ This is not a pipeline stage. Every repair is baked into the **linear source bef B&W silver and Kodachrome block IR like dust does. Such frames are auto-detected, because the IR plane mirrors the image, and skipped. +* **ICE at scan time** (Scan tab, nkscan backend only): + The nkscan driver carries its own openICE port and can run it during the scan, on the scanner's own data. What it writes is already repaired, so no NegPy retouch stage sees the defects: the repair is baked into the file, not into an edit. Use it for speed on a batch; use the paths above where the repair has to stay editable. + * **IR removal, the OpenICE method** (`ir_method = "openice"`, `negpy/features/retouch/openice.py`): A second reconstruction for the same IR plane, ported from openICE (see `NOTICE.md`), a reverse-engineering of Nikon Scan's Digital ICE verified byte-exact against the original engine. It replaces steps 1–4 above wholesale, shares no code with them, and keeps the routed inpaint. It sits at the same point in the pipeline: the linear source before normalization. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index cfb572844..a1772ee32 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -874,11 +874,12 @@ When you set capture gear, it is written to standard EXIF, and the digitizing ri --- + ## 13. Scan tab Capture film directly into NegPy. Two collapsible sections: -* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Common controls are device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. +* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Common controls are device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. **pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. @@ -886,6 +887,18 @@ Capture film directly into NegPy. Two collapsible sections: 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). + **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, there is no frame range to set, and no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. The selection sticks until you change it, and the Scan panel lists it under the strip button. Four controls appear only on this backend: + + * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. + * **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time. + * **Superfine**: read one line per pass. Slower, and free of the line registration the faster three-line mode owes the host. + * **Film**: what is on the film — Color negative, B&W negative, Slide or Kodachrome. It decides which way the frame boundaries read when the strip is measured, and it disables IR and ICE on B&W and Kodachrome, whose silver and dyes stop infrared so the mask comes back as the picture rather than the dust on it. + * **Film format**: the frame length on the loaded film (135, 66, 645 and so on). Leave it on **Auto** where the holder fixes the format, and set it for loose film in a masked carrier. + + Samples and Superfine appear only on units that advertise them. An LS-50 offers neither: it reads one line at a time whatever you ask, and it ignores repeated reads of a line, so both controls stay hidden there and a setting saved from another scanner is never sent to it. + + The driver is the optional **nkscan** package: install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module. + **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 Trichrome Scan mode. diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 4dacfd4e2..cd0371a0a 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -226,12 +226,16 @@ def _build_scan_page(self) -> QWidget: SANE flatbed/film scanner on top, the RGB-Scan trichromatic capture below.""" repo = self.controller.session.repo + from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog, has_guide + def make(title: str, key: str, icon_name: str, content: QWidget, default_expanded: bool) -> CollapsibleSection: persisted = repo.get_global_setting(f"section_expanded_{key}") expanded = bool(persisted) if persisted is not None else default_expanded - section = CollapsibleSection(title, expanded=expanded, icon=qta.icon(icon_name, color="#aaa")) + section = CollapsibleSection(title, expanded=expanded, icon=qta.icon(icon_name, color="#aaa"), info=has_guide(key)) section.set_content(content) section.expanded_changed.connect(lambda checked, k=key: repo.save_global_setting(f"section_expanded_{k}", checked)) + if section.info_btn: + section.info_requested.connect(lambda k=key, tt=title, s=section: SectionHelpDialog(k, tt, s).exec()) return section self.scan_sane_section = make("Film Scanner", "scan_sane", "fa5s.camera-retro", self.scan_sidebar, False) diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 85aa9929b..76ae06b5f 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -22,10 +22,19 @@ from negpy.desktop.view.styles.templates import hint_label from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice +from negpy.infrastructure.scanners.params import FILM_TYPES, FilmType, film_passes_infrared from negpy.infrastructure.scanners.registry import DEFAULT_BACKEND_ID, backend_choices from negpy.infrastructure.scanners.settings import ScannerSettings +_SAMPLE_COUNTS = (1, 2, 4, 8, 16) + + +def _reaches_a_strip(caps: ScannerCapabilities) -> bool: + """Whether a device holds several frames: a feeder with a capacity, or a measured strip.""" + return caps.adapter_frame_capacity is not None or caps.roll_discovery + + class ScanSidebar(QWidget): """Scanner control panel — replaces the originally planned modal ScanDialog.""" @@ -38,6 +47,12 @@ def __init__(self, controller) -> None: self._devices_loaded = False self._caps_autofocus = False self._caps_auto_exposure = False + self._caps_clean = False + self._caps_superfine = False + self._caps_max_samples = 1 + self._caps_film_formats: tuple[str, ...] = () + self._caps_film_types: tuple[str, ...] = () + self._device_ir = False self._init_ui() self._connect_signals() install_wheel_guards(self) @@ -131,25 +146,23 @@ def _init_ui(self) -> None: self.dpi_combo.setEditable(True) self.form.addRow("DPI", self.dpi_combo) - self.ir_check = QCheckBox("IR") - self.ir_check.setToolTip("Scan a separate infrared channel for dust detection") - - self.me_check = QCheckBox("Multi-exposure") - self.me_check.setToolTip("Merge short and long colour passes for more highlight and shadow detail. Takes longer.") - - self.depth_row_widget = QWidget() - depth_row = QHBoxLayout(self.depth_row_widget) - depth_row.setContentsMargins(0, 0, 0, 0) self.depth_combo = QComboBox() self.depth_combo.setToolTip("Bit depth") - depth_row.addWidget(self.depth_combo, 1) - depth_row.addWidget(self.ir_check) - depth_row.addWidget(self.me_check) self.depth_label = QLabel("Depth") - self.form.addRow(self.depth_label, self.depth_row_widget) + self.form.addRow(self.depth_label, self.depth_combo) self.depth_combo.setVisible(False) self.depth_label.setVisible(False) + # Spanning rows, like every other toggle: sharing the Depth row left these indented into + # the field column on a single-depth device, where the combo beside them is hidden. + self.ir_check = QCheckBox("IR") + self.ir_check.setToolTip("Scan a separate infrared channel for dust detection") + self.form.addRow(self.ir_check) + + self.me_check = QCheckBox("Multi-exposure") + self.me_check.setToolTip("Merge short and long color passes for more highlight and shadow detail. Takes longer.") + self.form.addRow(self.me_check) + # Spanning rows (no label column) so the checkboxes sit at the left edge. self.autofocus_check = QCheckBox("Autofocus") self.autofocus_check.setChecked(True) @@ -162,6 +175,44 @@ def _init_ui(self) -> None: self.form.addRow(self.ae_check) self.ae_check.setVisible(False) + self.clean_check = QCheckBox("ICE") + self.clean_check.setToolTip( + "Remove dust and scratches with the infrared channel while scanning.\nBaked into the file — colour film only." + ) + self.form.addRow(self.clean_check) + self.clean_check.setVisible(False) + + self.superfine_check = QCheckBox("Superfine") + self.superfine_check.setToolTip("Read one line per pass: slower, and free of line registration") + self.form.addRow(self.superfine_check) + self.superfine_check.setVisible(False) + + # Multi-sample: repeated reads of one line the scanner averages, for shadow noise. + self.samples_combo = QComboBox() + self.samples_combo.setToolTip("Reads per line the scanner averages") + self.samples_label = QLabel("Samples") + self.form.addRow(self.samples_label, self.samples_combo) + self.samples_label.setVisible(False) + self.samples_combo.setVisible(False) + + # What is on the film: it decides which way the frame boundaries read on a strip, and + # whether an IR pass has anything to see. + self.film_type_combo = QComboBox() + self.film_type_combo.setToolTip("What is on the film. Silver and Kodachrome block infrared") + self.film_type_label = QLabel("Film") + self.form.addRow(self.film_type_label, self.film_type_combo) + self.film_type_label.setVisible(False) + self.film_type_combo.setVisible(False) + + # Frame length, for a transport that measures the strip and cannot infer it. + self.format_combo = QComboBox() + self.format_combo.setToolTip("Frame length on the loaded film; Auto where the holder fixes it") + # Not "Format": the output file format owns that label further down the panel. + self.film_format_label = QLabel("Film format") + self.form.addRow(self.film_format_label, self.format_combo) + self.film_format_label.setVisible(False) + self.format_combo.setVisible(False) + # Scan exposure time (SANE `scan-exposure-time`), shown only when the device reports a # usable range. The slider is in microseconds and the label shows a readable value. self.exposure_row_widget = QWidget() @@ -304,6 +355,11 @@ def _connect_signals(self) -> None: self.me_check.toggled.connect(lambda: self._update_settings_from_ui()) self.autofocus_check.toggled.connect(lambda: self._update_settings_from_ui()) self.ae_check.toggled.connect(lambda: self._on_ae_toggled()) + self.clean_check.toggled.connect(lambda: self._update_settings_from_ui()) + self.superfine_check.toggled.connect(lambda: self._update_settings_from_ui()) + self.samples_combo.currentIndexChanged.connect(lambda: self._update_settings_from_ui()) + self.format_combo.currentIndexChanged.connect(lambda: self._update_settings_from_ui()) + self.film_type_combo.currentIndexChanged.connect(lambda: self._on_film_type_changed()) self.exposure_slider.valueChanged.connect(self._on_exposure_changed) self.frame_from_spin.valueChanged.connect(self._on_frame_from_changed) self.frame_to_spin.valueChanged.connect(self._on_frame_to_changed) @@ -425,8 +481,22 @@ def _update_device_caps(self) -> None: self.prescan_label.setVisible(False) self.prescan_widget.setVisible(False) self.prescan_status.setVisible(False) + self.clean_check.setVisible(False) + self.superfine_check.setVisible(False) + self.samples_label.setVisible(False) + self.samples_combo.setVisible(False) + self.film_format_label.setVisible(False) + self.format_combo.setVisible(False) + self.film_type_label.setVisible(False) + self.film_type_combo.setVisible(False) self._caps_autofocus = False self._caps_auto_exposure = False + self._caps_clean = False + self._caps_superfine = False + self._caps_max_samples = 1 + self._caps_film_formats = () + self._caps_film_types = () + self._device_ir = False return caps = device.capabilities @@ -437,8 +507,6 @@ def _update_device_caps(self) -> None: self.eject_btn.setVisible(caps.can_eject) self.eject_btn.setEnabled(caps.can_eject and not self._scanning) self.frame_label.setText(f"Frame: {caps.max_area_mm[0]:.0f} × {caps.max_area_mm[1]:.0f} mm") - self.autofocus_check.setChecked(caps.autofocus) - self.autofocus_check.setVisible(caps.autofocus) # If no film sources, show banner if not caps.sources: @@ -488,6 +556,7 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.depth_label.setVisible(show_depth) # IR + self._device_ir = bool(caps.ir_channel) self.ir_check.setEnabled(caps.ir_channel) if caps.ir_channel: self.ir_check.setChecked(self._settings.capture_ir) @@ -495,6 +564,7 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: else: self.ir_check.setChecked(False) self.ir_check.setToolTip("IR scanning not supported by this device") + self._apply_film_type_to_ir() # Multi-exposure (Plustek SE only today) self.me_check.setEnabled(caps.multi_exposure) @@ -525,6 +595,58 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.ae_check.setChecked(False) self.ae_check.setToolTip("Auto-exposure not supported by this device") + # Dust removal, multi-sample and superfine: only where the transport does them itself. + self._caps_clean = bool(caps.hw_clean) + self._caps_superfine = bool(caps.superfine) + self.clean_check.blockSignals(True) + self.clean_check.setVisible(self._caps_clean) + self.clean_check.setChecked(self._caps_clean and self._settings.clean) + self.clean_check.blockSignals(False) + + self.superfine_check.blockSignals(True) + self.superfine_check.setVisible(self._caps_superfine) + self.superfine_check.setChecked(self._caps_superfine and self._settings.superfine) + self.superfine_check.blockSignals(False) + + self._caps_max_samples = int(caps.max_samples) + self.samples_combo.blockSignals(True) + self.samples_combo.clear() + show_samples = caps.max_samples > 1 + if show_samples: + for count in _SAMPLE_COUNTS: + if count <= caps.max_samples: + self.samples_combo.addItem(str(count), count) + idx = self.samples_combo.findData(self._settings.samples) + self.samples_combo.setCurrentIndex(max(idx, 0)) + self.samples_label.setVisible(show_samples) + self.samples_combo.setVisible(show_samples) + self.samples_combo.blockSignals(False) + + self._caps_film_types = tuple(caps.film_types) + self.film_type_combo.blockSignals(True) + self.film_type_combo.clear() + for film_type in self._caps_film_types: + self.film_type_combo.addItem(FILM_TYPES[film_type][0], film_type) + idx = self.film_type_combo.findData(self._settings.film_type) + self.film_type_combo.setCurrentIndex(max(idx, 0)) + self.film_type_label.setVisible(bool(self._caps_film_types)) + self.film_type_combo.setVisible(bool(self._caps_film_types)) + self.film_type_combo.blockSignals(False) + + self._caps_film_formats = tuple(caps.film_formats) + self.format_combo.blockSignals(True) + self.format_combo.clear() + show_formats = bool(caps.film_formats) + if show_formats: + self.format_combo.addItem("Auto", None) + for film_format in caps.film_formats: + self.format_combo.addItem(film_format, film_format) + idx = self.format_combo.findData(self._settings.film_format) + self.format_combo.setCurrentIndex(max(idx, 0)) + self.film_format_label.setVisible(show_formats) + self.format_combo.setVisible(show_formats) + self.format_combo.blockSignals(False) + # Scan exposure time, shown only when the device reports a usable range. self.exposure_slider.blockSignals(True) et_range = caps.exposure_time_us @@ -545,9 +667,12 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.exposure_slider.blockSignals(False) self._update_exposure_value_label() - # Frame range, only for a roll or strip feeder reporting a live capacity + # Frame range, only for a roll or strip feeder reporting a live capacity. A transport + # that measures the strip has no capacity to range over: its frames come from the + # strip dialog. capacity = caps.adapter_frame_capacity has_frames = capacity is not None + is_strip = _reaches_a_strip(caps) self.frame_range_label.setVisible(has_frames) self.frame_range_widget.setVisible(has_frames) if has_frames: @@ -561,15 +686,15 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.frame_from_spin.setValue(frm) self.frame_to_spin.setValue(to) - # Scan window: SANE-only crop UI (strip feeder or QuickScanPreviewDialog). - # pyOpticfilm uses Prescan instead; both wrote the same scan_window setting. - use_sane_window = self._current_backend_id() != "plustek" - self.scan_window_row_label.setVisible(use_sane_window) - self.scan_window_widget.setVisible(use_sane_window) - self.scan_window_status.setVisible(use_sane_window) - if use_sane_window: - self.scan_window_row_label.setText("Batch" if has_frames else "Window") - if has_frames: + # Scan window: crop UI for every backend but pyOpticfilm, which uses Prescan instead; + # both wrote the same scan_window setting. + use_window = self._current_backend_id() != "plustek" + self.scan_window_row_label.setVisible(use_window) + self.scan_window_widget.setVisible(use_window) + self.scan_window_status.setVisible(use_window) + if use_window: + self.scan_window_row_label.setText("Batch" if is_strip else "Window") + if is_strip: 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") else: @@ -592,6 +717,37 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.frame_from_spin.blockSignals(False) self.frame_to_spin.blockSignals(False) + def _film_type(self) -> str: + default = FilmType.NEGATIVE.value + return str(self.film_type_combo.currentData() or default) if self._caps_film_types else default + + def _on_film_type_changed(self) -> None: + self._apply_film_type_to_ir() + self._update_settings_from_ui() + + def _apply_film_type_to_ir(self) -> None: + """Silver grain and Kodachrome's dyes stop infrared, so its mask comes back as the + picture rather than the dust on it. Both controls go with the film, not the scanner.""" + passes = film_passes_infrared(self._film_type()) + for control, supported in ((self.ir_check, self._device_ir), (self.clean_check, self._caps_clean)): + control.blockSignals(True) + control.setEnabled(supported and passes) + if not passes: + control.setChecked(False) + control.blockSignals(False) + if not passes and (self._device_ir or self._caps_clean): + reason = f"{FILM_TYPES[self._film_type()][0]} blocks infrared" + self.ir_check.setToolTip(reason) + self.clean_check.setToolTip(reason) + + def _samples(self) -> int: + if self._caps_max_samples <= 1: + return 1 + return int(self.samples_combo.currentData() or 1) + + def _film_format(self) -> str | None: + return self.format_combo.currentData() if self._caps_film_formats else None + def _on_ae_toggled(self) -> None: self.exposure_slider.setEnabled(not self.ae_check.isChecked()) self._update_settings_from_ui() @@ -626,7 +782,7 @@ def _on_set_scan_window(self) -> None: if device is None: return - if device.capabilities.adapter_frame_capacity is not None: + if _reaches_a_strip(device.capabilities): from negpy.desktop.view.widgets.strip_preview_dialog import StripPreviewDialog dialog = StripPreviewDialog( @@ -636,6 +792,8 @@ def _on_set_scan_window(self) -> None: initial_selected=self._settings.selected_frames, initial_offset=self._settings.frame_offset_mm, initial_offset_modifier=self._settings.frame_offset_modifier_mm, + film_format=self._film_format(), + film_type=self._film_type(), parent=self, ) if dialog.exec(): @@ -722,6 +880,10 @@ def _update_scan_window_status(self) -> None: self.scan_window_status.setText(f"Frames {frames_txt}{win_txt}{offset_txt}") return device = self._current_device() + if device is not None and device.capabilities.roll_discovery: + # Nothing picked yet, and a measured strip has no frame range to fall back on. + self.scan_window_status.setText("Whole strip — Preview strip to pick frames instead") + return area = scan_window_to_area(self._settings.scan_window, device.capabilities.max_area_mm) if device else None if area is None: self.scan_window_status.setText(f"Full frame{offset_txt}") @@ -767,7 +929,10 @@ def _on_scan(self) -> None: fmt = self.fmt_combo.currentText() frames, frame_windows, base_window = resolve_batch_selection( - self._settings, self.frame_from_spin.value(), self.frame_to_spin.value() + self._settings, + self.frame_from_spin.value(), + self.frame_to_spin.value(), + whole_strip=device.capabilities.roll_discovery, ) exposure_time_us = ( self._settings.exposure_time_us @@ -784,6 +949,11 @@ def _on_scan(self) -> None: exposure_time_us=exposure_time_us, window=base_window, frame_offset_mm=self._settings.frame_offset_mm, + clean=self._caps_clean and self.clean_check.isChecked(), + samples=self._samples(), + superfine=self._caps_superfine and self.superfine_check.isChecked(), + film_format=self._film_format(), + film_type=self._film_type(), ) self._update_settings_from_ui() @@ -791,7 +961,7 @@ def _on_scan(self) -> None: self.set_scanning(True) try: - if device.capabilities.adapter_frame_capacity is not None: + if _reaches_a_strip(device.capabilities): self.controller.start_batch( BatchRequest( device_id=device.id, @@ -915,6 +1085,11 @@ def _update_settings_from_ui(self) -> None: autofocus=self._caps_autofocus and self.autofocus_check.isChecked(), auto_exposure=self._caps_auto_exposure and self.ae_check.isChecked(), exposure_time_us=(self.exposure_slider.value() if self.exposure_row_widget.isVisible() else None), + clean=self._caps_clean and self.clean_check.isChecked(), + samples=self._samples(), + superfine=self._caps_superfine and self.superfine_check.isChecked(), + film_format=self._film_format(), + film_type=self._film_type(), frame_from=self.frame_from_spin.value(), frame_to=self.frame_to_spin.value(), output_folder=self.folder_edit.text().strip(), diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 6811e112e..cd4815860 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -23,7 +23,7 @@ QWidget, ) -from negpy.kernel.system.text import plural +from negpy.kernel.system.text import count_of, 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 @@ -41,8 +41,11 @@ _PREVIEW_FALLBACK_DPI = 500 # only when the device reports no DPI list at all _TILE_H = 140 # constant tile height; width follows the device aspect _TILES_PER_ROW = 6 # one SA-21 strip per row; roll adapters (up to 40 frames) wrap below +# A transport that measures the strip reports its frame count only as previews arrive, so ask +# for a roll's worth and keep the tiles it answers with. +_DISCOVERY_SLOTS = 40 -# The LS-50 raster is portrait, with the feed axis vertical, so rotate each preview -90° +# A coolscan3 raster is portrait, with the feed axis vertical, so rotate each preview -90° # and the frame reads landscape. QTransform().rotate(-90) maps a scan point (fx, fy) to # display (fy, 1 - fx), pinned against Qt, so crop rects round-trip exactly and the # feed-axis start lands on the display's LEFT edge. Tiles 1..N laid left to right then @@ -74,6 +77,25 @@ def _display_to_scan_rect(rect): return (_clamp01(sx1), _clamp01(sy1), _clamp01(sx2), _clamp01(sy2)) +_FEEDER_HELP = ( + "Preview each frame (the eye button on a tile, or Preview all). Drag on a previewed " + "frame to crop it — a corner to resize, inside to move; each frame keeps its own window. " + "Offset slides every frame along the film to clear the inter-frame gap — frames shift " + "left as it grows, live; the shaded band on the right is film past the frame boundary " + "the transport cannot deliver (offset past the gap costs frame tail). Drift adds " + "progressively more (or less) offset per frame position; re-preview to refresh the pixels. " + "Tick the frames to scan, then Use (apply and return) " + "or Scan (start scanning now)." +) + +_DISCOVERY_HELP = ( + "Detect frames measures the whole strip in one pass and previews every frame it found. " + "Untick any frame you do not want scanned. Drag on a frame to crop it — a corner to " + "resize, inside to move; each frame keeps its own window. Offset and Drift slide the frames " + "along the film. Then Use (apply and return) or Scan (start scanning now)." +) + + class _ResetSlider(QSlider): """Horizontal QSlider that resets to a default on double-click (matches BaseSlider UX).""" @@ -108,13 +130,21 @@ def __init__( initial_selected=None, initial_offset: float = 0.0, initial_offset_modifier: float = 0.0, + film_format: str | None = None, + film_type: str = "negative", parent=None, ) -> None: super().__init__(parent) self._controller = controller self._device = device + self._film_format = film_format + self._film_type = film_type self._caps = device.capabilities - self._capacity = max(1, self._caps.adapter_frame_capacity or 1) + # A measured strip has no capacity: tiles grow from what the preview finds. + self._discovers = self._caps.adapter_frame_capacity is None and self._caps.roll_discovery + # A measured strip arrives feed-axis-horizontal already, so only a portrait raster turns. + self._rotation = 0 if self._discovers else _DISPLAY_ROTATION_DEG + self._capacity = 0 if self._discovers else max(1, self._caps.adapter_frame_capacity or 1) # Landscape tile aspect (W/H) from the rotated raster: the feed axis (max_area_mm[1]) # becomes horizontal. Tiles are constant-size at this aspect. mm = self._caps.max_area_mm @@ -127,22 +157,13 @@ def __init__( self.setWindowTitle("Preview strip — set a window per frame") self.setModal(True) tile_w, tile_h = self._tile_size() - cols = min(self._capacity, _TILES_PER_ROW) - rows = -(-self._capacity // _TILES_PER_ROW) + cols = min(self._capacity or _TILES_PER_ROW, _TILES_PER_ROW) + rows = max(1, -(-self._capacity // _TILES_PER_ROW)) self.resize(cols * (tile_w + 4) + 36, min(rows, 3) * (tile_h + 4) + 260) layout = QVBoxLayout(self) - help_lbl = QLabel( - "Preview each frame (the eye button on a tile, or Preview all). Drag on a previewed " - "frame to crop it — a corner to resize, inside to move; each frame keeps its own window. " - "Offset slides every frame along the film to clear the inter-frame gap — frames shift " - "left as it grows, live; the shaded band on the right is film past the frame boundary " - "the transport cannot deliver (offset past the gap costs frame tail). Drift adds " - "progressively more (or less) offset per frame position; re-preview to refresh the pixels. " - "Tick the frames to scan, then Use (apply and return) " - "or Scan (start scanning now)." - ) + help_lbl = QLabel(_DISCOVERY_HELP if self._discovers else _FEEDER_HELP) help_lbl.setWordWrap(True) help_lbl.setStyleSheet( f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;" @@ -153,12 +174,18 @@ def __init__( top = QHBoxLayout() top.addWidget(QLabel("Offset")) self.offset_slider = _ResetSlider() - self.offset_slider.setRange(0, 100) # tenths of a mm → 0..10.0 mm + # A measured strip re-addresses the frame, so the offset may run a whole frame either + # way; a feeder cannot back up and blacks out one pitch past the frame start. + self.offset_slider.setRange(-380 if self._discovers else 0, 380 if self._discovers else 100) self.offset_slider.setSingleStep(1) self.offset_slider.setPageStep(5) self.offset_slider.setFixedWidth(160) self.offset_slider.setValue(int(round(max(0.0, float(initial_offset)) * 10))) - self.offset_slider.setToolTip("Feed-axis offset applied to every frame (the transport cannot back up)") + self.offset_slider.setToolTip( + "Feed-axis offset applied to every frame" + if self._discovers + else "Feed-axis offset applied to every frame (the transport cannot back up)" + ) top.addWidget(self.offset_slider) self.offset_label = QLabel() top.addWidget(self.offset_label) @@ -177,15 +204,20 @@ def __init__( self.drift_label = QLabel() top.addWidget(self.drift_label) top.addSpacing(16) - top.addWidget(QLabel("Preview DPI")) + # A measured strip previews out of its own pass, whose resolution nothing chooses. + self.preview_dpi_label = 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 scans") + self.preview_dpi_label.setVisible(not self._discovers) + self.preview_dpi_combo.setVisible(not self._discovers) + top.addWidget(self.preview_dpi_label) top.addWidget(self.preview_dpi_combo) top.addStretch() - self.preview_all_btn = QPushButton(qta.icon("fa5s.eye", color=THEME.text_primary), " Preview all") + label = " Detect frames" if self._discovers else " Preview all" + self.preview_all_btn = QPushButton(qta.icon("fa5s.eye", color=THEME.text_primary), label) self.preview_all_btn.clicked.connect(self._on_preview_all) top.addWidget(self.preview_all_btn) layout.addLayout(top) @@ -199,11 +231,16 @@ def __init__( strip.setContentsMargins(2, 2, 2, 2) strip.setSpacing(4) self._tiles: dict[int, _Tile] = {} + self._tiles_wired = False + self._strip = strip + self._empty_hint = QLabel("Press Detect frames to measure the strip" if self._discovers else "Preview a frame to set its window") + self._empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_hint.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_lg}px; padding: 48px;") + strip.addWidget(self._empty_hint, 0, 0, 1, _TILES_PER_ROW) + self._initial_windows = initial_windows + self._initial_selected = initial_selected for frame in range(1, self._capacity + 1): - checked = (frame in initial_selected) if initial_selected else True - tile = self._build_tile(frame, initial_windows.get(frame), checked) - self._tiles[frame] = tile - strip.addWidget(tile.widget, (frame - 1) // _TILES_PER_ROW, (frame - 1) % _TILES_PER_ROW) + self._ensure_tile(frame) # Pin the grid top-left so a partial last row doesn't spread across the viewport. strip.setColumnStretch(cols, 1) strip.setRowStretch(rows, 1) @@ -216,7 +253,23 @@ def __init__( layout.addWidget(self.status) btns = QHBoxLayout() - self.clear_btn = QPushButton("Clear all") + self.select_all_btn = QPushButton("All") + self.select_all_btn.setFixedWidth(48) + self.select_all_btn.setToolTip("Scan every frame on the strip") + self.select_all_btn.clicked.connect(lambda: self._set_all_checked(True)) + self.select_none_btn = QPushButton("None") + self.select_none_btn.setFixedWidth(56) + self.select_none_btn.setToolTip("Untick every frame") + self.select_none_btn.clicked.connect(lambda: self._set_all_checked(False)) + self.selection_label = QLabel() + self.selection_label.setStyleSheet(f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;") + btns.addWidget(QLabel("Frames to scan")) + btns.addWidget(self.select_all_btn) + btns.addWidget(self.select_none_btn) + btns.addWidget(self.selection_label) + btns.addSpacing(16) + # Not "Clear all": it clears crops, and it sits next to the selection buttons. + self.clear_btn = QPushButton("Clear crops") self.clear_btn.setToolTip("Remove every window (scan full frames)") self.clear_btn.clicked.connect(self._on_clear_all) btns.addWidget(self.clear_btn) @@ -235,9 +288,10 @@ def __init__( layout.addLayout(btns) # Connect after ok_btn exists: setChecked during the tile build must not fire the - # enable-check before the button is there. + # enable-check before the button is there. Tiles built later connect themselves. for tile in self._tiles.values(): tile.checkbox.toggled.connect(self._update_ok_enabled) + self._tiles_wired = True self.offset_slider.valueChanged.connect(self._on_offset_changed) self.drift_slider.valueChanged.connect(self._on_offset_changed) self._on_offset_changed(self.offset_slider.value()) @@ -245,6 +299,22 @@ def __init__( self._connect_preview_signals() + def _ensure_tile(self, frame: int) -> _Tile: + """The tile for a strip position, built and placed on first sight of it.""" + tile = self._tiles.get(frame) + if tile is not None: + return tile + checked = (frame in self._initial_selected) if self._initial_selected else True + tile = self._build_tile(frame, self._initial_windows.get(frame), checked) + self._tiles[frame] = tile + self._capacity = max(self._capacity, frame) + self._empty_hint.setVisible(False) + self._strip.addWidget(tile.widget, (frame - 1) // _TILES_PER_ROW, (frame - 1) % _TILES_PER_ROW) + if self._tiles_wired: + tile.checkbox.toggled.connect(self._update_ok_enabled) + self._update_ok_enabled() + return tile + def _build_tile(self, frame: int, initial_window, checked: bool) -> _Tile: """A big landscape preview with a subtle overlay box (frame checkbox + preview).""" widget = QWidget() @@ -253,17 +323,21 @@ def _build_tile(self, frame: int, initial_window, checked: bool) -> _Tile: label = ScanWindowLabel() label.setFixedSize(*self._tile_size()) - label.set_window(_scan_to_display_rect(initial_window) if initial_window else None) + label.set_window(self._to_display(initial_window) if initial_window else None) grid.addWidget(label, 0, 0) overlay = QFrame() overlay.setObjectName("frameOverlay") + # Opaque, not a wash: the tick sits over the picture and has to read on a bright frame + # as well as a dark one. overlay.setStyleSheet( - "#frameOverlay { background: rgba(13, 13, 15, 0.55); border-radius: 6px; }" - f"#frameOverlay QCheckBox {{ color: {THEME.text_primary}; font-size: {THEME.font_size_small}px; }}" + "#frameOverlay { background: rgba(13, 13, 15, 0.82); border-radius: 6px; }" + f"#frameOverlay QCheckBox {{ color: {THEME.text_primary}; font-size: {THEME.font_size_base}px;" + " font-weight: 600; spacing: 6px; }" + "#frameOverlay QCheckBox::indicator { width: 16px; height: 16px; }" ) oh = QHBoxLayout(overlay) - oh.setContentsMargins(6, 3, 6, 3) + oh.setContentsMargins(7, 4, 7, 4) oh.setSpacing(6) checkbox = QCheckBox(str(frame)) checkbox.setChecked(checked) @@ -285,10 +359,16 @@ def _tile_size(self) -> tuple[int, int]: # ── result getters ──────────────────────────────────────────────── def selected_frames(self) -> tuple[int, ...]: - return tuple(f for f in range(1, self._capacity + 1) if self._tiles[f].checkbox.isChecked()) + return tuple(sorted(f for f, t in self._tiles.items() if t.checkbox.isChecked())) def frame_windows(self) -> dict: - return {f: _display_to_scan_rect(t.label.window()) for f, t in self._tiles.items() if t.label.window() is not None} + return {f: self._to_scan(t.label.window()) for f, t in self._tiles.items() if t.label.window() is not None} + + def _to_display(self, rect): + return _scan_to_display_rect(rect) if self._rotation else rect + + def _to_scan(self, rect): + return _display_to_scan_rect(rect) if self._rotation else rect def frame_offset(self) -> float: return self.offset_slider.value() / 10.0 @@ -304,10 +384,14 @@ def _raw_offset_for_frame(self, frame: int) -> float: return self.frame_offset() + (frame - 1) * self.frame_offset_modifier() def _offset_for_frame(self, frame: int) -> float: - """Effective offset for a frame position: base + (N-1)·drift, floored at 0 and - held short of one pitch (the scan blacks out at the frame boundary — below 0 is - unreachable, past one pitch there is nothing left to scan).""" - return clamp_frame_offset_mm(self._raw_offset_for_frame(frame), self._frame_pitch()) + """Effective offset for a frame position: base + (N-1)·drift. + + A feeder is floored at 0 and held short of one pitch: it cannot back up, and the scan + blacks out at the frame boundary. A measured strip re-addresses the frame instead, so + its offset stands as asked, either way. + """ + raw = self._raw_offset_for_frame(frame) + return raw if self._discovers else clamp_frame_offset_mm(raw, self._frame_pitch()) def scan_requested(self) -> bool: """True when the dialog was accepted via Scan (start now), not Use.""" @@ -320,9 +404,14 @@ def _on_scan_clicked(self) -> None: self.accept() def _update_ok_enabled(self, *_args) -> None: - enabled = any(t.checkbox.isChecked() for t in self._tiles.values()) - self.ok_btn.setEnabled(enabled) - self.scan_btn.setEnabled(enabled) + picked = sum(1 for t in self._tiles.values() if t.checkbox.isChecked()) + self.ok_btn.setEnabled(bool(picked)) + self.scan_btn.setEnabled(bool(picked)) + self.selection_label.setText(f"{picked} of {count_of(len(self._tiles), 'frame')}" if self._tiles else "none yet") + + def _set_all_checked(self, checked: bool) -> None: + for tile in self._tiles.values(): + tile.checkbox.setChecked(checked) def _on_clear_all(self) -> None: for tile in self._tiles.values(): @@ -341,13 +430,33 @@ def _on_offset_changed(self, _value: int) -> None: def _tile_coverage(self, tile: _Tile) -> tuple[float, float]: """Span a raster previewed at x occupies when the slider reads y: (x − y, 1 − y). Tile coords are the next scan's raster, so content slides left as the offset - grows and every raster ends at the blackout boundary (a fixed film position).""" + grows and every raster ends at the blackout boundary (a fixed film position). + + A measured strip re-addresses the whole frame instead of losing its tail, so its raster + keeps full length and simply slides: the gap that opens at one edge is the film the next + scan takes in. + """ pitch = self._frame_pitch() + if self._discovers: + y = (self._offset_for_frame(tile.frame) / pitch) if pitch else 0.0 + x = tile.previewed_offset or 0.0 + return (x - y, x - y + 1.0) y = (self._offset_for_frame(tile.frame) / pitch) if pitch else 0.0 x = tile.previewed_offset or 0.0 return (x - y, 1.0 - y) def _refresh_offset_indicators(self) -> None: + if self._discovers: + # Nothing is cut off, so there is no band to shade: the frame boundary itself is the + # line, drawn where the raster the operator is looking at now ends. + for tile in self._tiles.values(): + start, end = self._tile_coverage(tile) + edge = [(end, "right")] if tile.label.has_frame() and end < 1.0 else [] + edge += [(start, "left")] if tile.label.has_frame() and start > 0.0 else [] + tile.label.set_offset_indicators(edge) + if tile.label.has_frame(): + tile.label.set_coverage((start, end)) + return pitch = self._frame_pitch() clamped: list[int] = [] cut: list[tuple[int, float]] = [] @@ -393,7 +502,9 @@ def _on_preview_one(self, frame: int) -> None: self._start_preview((frame,)) def _on_preview_all(self) -> None: - self._start_preview(tuple(range(1, self._capacity + 1))) + # A measured strip answers with the frames it found and ignores the rest. + slots = _DISCOVERY_SLOTS if self._discovers else self._capacity + self._start_preview(tuple(range(1, slots + 1))) def _start_preview(self, slots: tuple[int, ...]) -> None: if self._previewing: @@ -407,6 +518,8 @@ def _start_preview(self, slots: tuple[int, ...]) -> None: # Raw, not clamped: the session holds the transport's own limits and reports back the # offset it actually reached. offsets={f: (self._raw_offset_for_frame(f) / pitch if pitch else 0.0) for f in slots}, + film_format=self._film_format, + film_type=self._film_type, ) try: self._controller.start_roll_preview(req) @@ -421,7 +534,7 @@ def _start_preview(self, slots: tuple[int, ...]) -> None: def _on_preview_ready(self, preview) -> None: """One slot landed. Slot number and effective offset ride on the preview, so results need no in-flight bookkeeping and may arrive in any order.""" - tile = self._tiles.get(preview.slot) + tile = self._ensure_tile(preview.slot) if self._discovers else self._tiles.get(preview.slot) if tile is None: return if preview.error is not None: @@ -432,7 +545,9 @@ def _on_preview_ready(self, preview) -> None: return try: positive = preview_positive(preview.rgb) - pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)).transformed(QTransform().rotate(_DISPLAY_ROTATION_DEG)) + pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)) + if self._rotation: + pixmap = pixmap.transformed(QTransform().rotate(self._rotation)) except Exception as e: self.status.setText(f"Could not display frame {preview.slot}: {e}") return @@ -447,6 +562,14 @@ def _on_preview_ready(self, preview) -> None: def _on_preview_finished(self) -> None: self._previewing = False self._set_previewing(False) + if self._discovers and not self._failed_frames: + found = len(self._tiles) + self.status.setText( + f"{count_of(found, 'frame')} detected — check the framing before scanning." + if found + else "No frames were detected on the loaded film." + ) + return if self._failed_frames: failed = ", ".join(str(f) for f in self._failed_frames) self.status.setText(f"Preview done. Failed {plural(len(self._failed_frames), 'frame')}: {failed}") diff --git a/negpy/desktop/workers/scan_worker.py b/negpy/desktop/workers/scan_worker.py index 42658aaf5..42ef2f81d 100644 --- a/negpy/desktop/workers/scan_worker.py +++ b/negpy/desktop/workers/scan_worker.py @@ -31,6 +31,10 @@ class RollPreviewRequest: slots: tuple[int, ...] dpi: int offsets: dict[int, float] = field(default_factory=dict) + # Frame length for a transport that measures the strip and cannot infer the format, and + # what is on the film, which decides which way its frame boundaries read. + film_format: str | None = None + film_type: str = "negative" @dataclass(frozen=True) @@ -50,6 +54,8 @@ class BatchRequest: output_folder: str filename_pattern: str output_format: str + # Empty means every frame the transport finds on the loaded film, for one that measures it + # rather than counting slots. frames: tuple[int, ...] frame_windows: dict[int, tuple[float, float, float, float]] = field(default_factory=dict) # Feed-axis drift (mm/frame): frame N scans at frame_offset_mm + (N-1) * modifier, @@ -204,18 +210,20 @@ def run_batch(self, req: BatchRequest) -> None: self._scanning = True assertion = acquire_unattended_power_assertion("NegPy film scan batch") - frames = list(req.frames) - total = max(1, len(frames)) paths: list[str] = [] outcome: tuple[str, str | None] = ("finished", None) try: service = self._ensure_service() + frames = list(req.frames) or self._whole_strip(service, req) + total = max(1, len(frames)) for index, frame in enumerate(frames): if self._cancel_event.is_set(): outcome = ("cancelled", None) break window = req.frame_windows.get(frame, req.params.window) - offset = max(0.0, req.params.frame_offset_mm + (frame - 1) * req.frame_offset_modifier_mm) + # No floor here: a transport that cannot back up clamps in its own backend, and + # one that re-addresses an absolute frame may legitimately go negative. + offset = req.params.frame_offset_mm + (frame - 1) * req.frame_offset_modifier_mm frame_params = dataclasses.replace(req.params, frame=frame, window=window, frame_offset_mm=offset) base = index / total @@ -267,6 +275,13 @@ def _progress(fraction: float, phase: str = "Scanning", _base: float = base) -> # no-op on devices without an eject option. self.eject(req.device_id) + def _whole_strip(self, service: ScannerService, req: BatchRequest) -> list[int]: + """Every frame on the loaded film, for a request that named none.""" + count = service.detect_frames(req.device_id, film_format=req.params.film_format, film_type=req.params.film_type) + if count <= 0: + raise RuntimeError("No frames were detected on the loaded film") + return list(range(1, count + 1)) + @pyqtSlot(RollPreviewRequest) def run_roll_preview(self, req: RollPreviewRequest) -> None: """Preview strip slots, emitting one RollPreview per slot as it lands. @@ -287,7 +302,7 @@ def run_roll_preview(self, req: RollPreviewRequest) -> None: outcome = ("cancelled", None) else: service = self._ensure_service() - session = service.open_roll(req.device, dpi=req.dpi) + session = service.open_roll(req.device, dpi=req.dpi, film_format=req.film_format, film_type=req.film_type) try: for slot, offset in req.offsets.items(): session.set_offset(slot, offset) diff --git a/negpy/infrastructure/scanners/base.py b/negpy/infrastructure/scanners/base.py index 45adb98b0..af9ac74d7 100644 --- a/negpy/infrastructure/scanners/base.py +++ b/negpy/infrastructure/scanners/base.py @@ -28,7 +28,6 @@ class ScannerCapabilities: supported_depths: tuple[int, ...] sources: tuple[ScanMode, ...] max_area_mm: tuple[float, float] # (width, height) - autofocus: bool = True auto_exposure: bool = False autofocus: bool = False #: Low-DPI full-window preview then interactive crop (Plustek SE). @@ -43,6 +42,19 @@ class ScannerCapabilities: can_eject: bool = False frame_pitch_mm: float = 0.0 # feed-axis distance between frame positions; 0.0 = unknown exposure_time_us: tuple[int, int] | None = None # (min, max) in microseconds + #: The transport removes dust itself, baked into what it returns. + hw_clean: bool = False + #: Frames are detected per strip, not addressed by index: the count is unknown until a + #: strip is measured, so the UI must grow its slots from what the preview reports. + roll_discovery: bool = False + #: Film formats the transport must be told, because it cannot measure the frame length + #: itself. Empty when the holder fixes it. + film_formats: tuple[str, ...] = () + #: Film types the transport takes, as `params.FILM_TYPES` keys. Empty when it is told + #: nothing about the film and reads whatever is loaded. + film_types: tuple[str, ...] = () + max_samples: int = 1 # per-line multi-sample bound; 1 = single read + superfine: bool = False # one line per pass, slower, owes the host no registration @dataclass(frozen=True) diff --git a/negpy/infrastructure/scanners/nkscan_backend.py b/negpy/infrastructure/scanners/nkscan_backend.py new file mode 100644 index 000000000..82cc4832e --- /dev/null +++ b/negpy/infrastructure/scanners/nkscan_backend.py @@ -0,0 +1,560 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""NegPy ``ScannerBackend`` adapter for the nkscan Nikon Coolscan driver. + +nkscan addresses a frame by its rectangle on the film, measured per strip, where the rest of +NegPy addresses one by index. The index is resolved against the rectangles the last discovery +found, cached per device: they are absolute stage addresses, so they stay valid across session +closes and until the film moves. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from contextlib import contextmanager, suppress +from typing import Any, Iterator + +import numpy as np + +from negpy.infrastructure.scanners.base import ( + ScannerCapabilities, + ScannerDevice, + ScannerSession, + ScannerUnavailable, + TransientScanError, +) +from negpy.infrastructure.scanners.params import ( + FILM_TYPES, + ScanMode, + ScanParams, + dpi_stops_in_range, + film_passes_infrared, + film_reads_positive, +) +from negpy.infrastructure.scanners.result import ScanResult +from negpy.kernel.system.logging import get_logger + +logger = get_logger(__name__) + +_INSTALL_HINT = ( + "nkscan is not installed. Install it with: uv sync --group nkscan. On Linux a Coolscan on " + "USB needs a udev rule for Nikon (04b0); on FireWire/SCSI it needs the sg kernel module." +) + +# Frame lengths nkscan cannot measure for itself, as it spells them. +FILM_FORMATS = ("135", "half", "IX240", "16", "645", "66", "67", "68", "69") + +_MAX_SAMPLES = 16 + +# 135, across the film by along the feed: the raster is portrait. Only the tile aspect and the +# window mm readout use it, and no adapter reports its opening through the bindings. +_DEFAULT_AREA_MM = (24.0, 36.0) + +_PHASES = {"discover": "Detecting frames", "meter": "Metering", "scan": "Scanning"} + +_MM_PER_INCH = 25.4 + +# nkscan's own name for the refusal a unit with no three-row CCD read raises. +_INTERLEAVING_OP = "color interleaving" + + +def _caps_for(caps: Any) -> ScannerCapabilities: + lo, hi = (int(v) for v in caps.x_dpi_range) + optical = int(caps.optical_dpi) + dpi = tuple(sorted({*dpi_stops_in_range(lo, hi), optical})) + # A unit with one read mode has no Superfine to choose, and one that ignores repeated + # reads has no Samples. An nkscan too old to say assumes both, and the first refusal + # teaches the backend the read mode anyway. + superfine = bool(getattr(caps, "multiline_read", True)) + max_samples = _MAX_SAMPLES if getattr(caps, "multi_reading", True) else 1 + return ScannerCapabilities( + ir_channel=True, + supported_dpi=dpi, + # Every plane comes back stretched to a 16-bit ceiling, whatever the unit read at. + supported_depths=(16,), + sources=(ScanMode.NEGATIVE, ScanMode.POSITIVE), + max_area_mm=_DEFAULT_AREA_MM, + auto_exposure=False, + autofocus=False, + adapter_frame_capacity=None, + can_eject=True, + exposure_time_us=None, + hw_clean=True, + roll_discovery=True, + film_formats=FILM_FORMATS, + film_types=tuple(FILM_TYPES), + max_samples=max_samples, + superfine=superfine, + ) + + +def _safe_progress( + progress: Callable[..., None] | None, + value: float, + phase: str = "Scanning", +) -> None: + if progress is None: + return + with suppress(Exception): + progress(max(0.0, min(1.0, float(value))), phase) + + +def _progress_bridge(progress: Callable[..., None] | None, cancel: threading.Event) -> Callable[..., bool]: + """nkscan's (phase, pass, done, total) callback over NegPy's (fraction, phase) one. + + Returning False is how nkscan is cancelled, so the event is polled here rather than + between passes. + """ + + def report(phase: str, _pass: int, done: int, total: int) -> bool: + _safe_progress(progress, (done / total) if total else 0.0, _PHASES.get(phase, "Scanning")) + return not cancel.is_set() + + return report + + +def _validate_params(params: ScanParams) -> None: + samples = int(params.samples) + if not 1 <= samples <= _MAX_SAMPLES: + raise RuntimeError(f"Samples must be 1..{_MAX_SAMPLES}, not {samples}") + if params.auto_exposure: + raise RuntimeError("Auto-exposure requested but nkscan meters every frame itself") + if params.film_format is not None and params.film_format not in FILM_FORMATS: + raise RuntimeError(f"Unknown film format {params.film_format!r}; expected one of {', '.join(FILM_FORMATS)}") + if params.film_type not in FILM_TYPES: + raise RuntimeError(f"Unknown film type {params.film_type!r}; expected one of {', '.join(FILM_TYPES)}") + if (params.capture_ir or params.clean) and not film_passes_infrared(params.film_type): + label = FILM_TYPES[params.film_type][0] + raise RuntimeError(f"{label} blocks infrared, so IR and ICE have nothing to read on it") + # `depth` and `autofocus` carry no request here: every scan comes back 16-bit and focuses + # itself, so honouring the defaults silently is the truth, not a skipped option. + + +def _shift_frame(rect: tuple[int, int, int, int], units: int) -> tuple[int, int, int, int]: + """Slide a frame along the feed axis, floored at the start of the stage's range.""" + top, left, bottom, right = rect + units = max(units, -top) + return (top + units, left, bottom + units, right) + + +def _offset_units(offset_mm: float, optical_dpi: int) -> int: + """Feed-axis mm as stage addresses: one address is one pixel at the optical resolution.""" + if not offset_mm or optical_dpi <= 0: + return 0 + return int(round(offset_mm * optical_dpi / _MM_PER_INCH)) + + +def _crop_frame( + rect: tuple[int, int, int, int], + window: tuple[float, float, float, float] | None, +) -> tuple[int, int, int, int]: + """Apply a normalized window inside a frame rect. x is across the film, y along the feed.""" + if window is None: + return rect + top, left, bottom, right = rect + x1, y1, x2, y2 = window + height = bottom - top + width = right - left + new_top = top + int(round(min(y1, y2) * height)) + new_bottom = top + int(round(max(y1, y2) * height)) + new_left = left + int(round(min(x1, x2) * width)) + new_right = left + int(round(max(x1, x2) * width)) + return (new_top, new_left, max(new_bottom, new_top + 1), max(new_right, new_left + 1)) + + +def _stack_rgb(colors: dict[str, np.ndarray]) -> np.ndarray: + """One (rows, cols, 3) array from nkscan's per-channel planes. + + A unit or adapter with no colour components delivers one plane, which every downstream + stage still expects three of. + """ + planes = [colors[name] for name in ("red", "green", "blue") if name in colors] + if len(planes) != 3: + if not colors: + raise RuntimeError("The scan returned no image planes") + planes = [next(iter(colors.values()))] * 3 + return np.stack(planes, axis=-1) + + +class NkscanSession: + """Exclusive hold on one Coolscan: one nkscan session, N scans, one release.""" + + def __init__(self, backend: NkscanBackend, device_id: str, session: Any, model: str) -> None: + self.device_id = device_id + self._backend = backend + self._session = session + self._model = model + self._closed = False + + def scan( + self, + params: ScanParams, + progress: Callable[..., None], + cancel: threading.Event, + ) -> ScanResult: + self._require_open() + return self._backend._scan_on_session(self._session, self.device_id, self._model, params, progress, cancel) + + def eject(self) -> bool: + self._require_open() + with self._backend._mapped_errors(): + ejected = bool(self._session.eject()) + self._backend.forget_frames(self.device_id) + return ejected + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + self._session.close() + finally: + self._backend._release_session(self) + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError(f"Scanner session for {self.device_id} is closed") + + def __enter__(self) -> NkscanSession: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + +class NkscanBackend: + """ScannerBackend for Nikon Coolscans over nkscan.""" + + def __init__(self) -> None: + try: + import nkscan + except ImportError as exc: + raise ScannerUnavailable(_INSTALL_HINT) from exc + self._nk = nkscan + self._devices_cache: list[ScannerDevice] | None = None + self._sessions: dict[str, NkscanSession] = {} + # Frame rects from the last discovery, per device. Absolute stage addresses, so they + # outlive the session that measured them, and so does the pass they were measured on: + # re-previewing a strip after a nudge must not cost another read of the film. + self._frames: dict[str, list[tuple[int, int, int, int]]] = {} + self._strips: dict[str, np.ndarray] = {} + # Units that read one CCD line at a time, learned from the first refusal. + self._line_ordering_only: dict[str, bool] = {} + self._lock = threading.Lock() + + # ── enumeration ─────────────────────────────────────────────────── + + def list_devices(self) -> list[ScannerDevice]: + if self._devices_cache is None: + self._devices_cache = self._probe_devices() + return [d for d in self._devices_cache if d.capabilities.sources] + + def refresh_devices(self) -> list[ScannerDevice]: + self._devices_cache = None + return self.list_devices() + + def _probe_devices(self) -> list[ScannerDevice]: + with self._mapped_errors(): + found = list(self._nk.list_devices()) + cached = {d.id: d for d in self._devices_cache or []} + devices: list[ScannerDevice] = [] + for device in found: + location = str(device.location) + # Probing opens the unit, and a held one refuses. Reuse what the hold was built from. + if location in self._sessions and location in cached: + devices.append(cached[location]) + continue + probed = self._probe_one(device, location) + if probed is not None: + devices.append(probed) + return devices + + def _probe_one(self, device: Any, location: str) -> ScannerDevice | None: + try: + session = self._nk.Session.open(device) + except Exception as exc: + logger.warning("Could not probe %s: %s", location, exc) + return None + try: + caps = session.capabilities + if getattr(caps, "multiline_read", True) is False: + # Said outright, so no scan has to learn it from a refusal. + self._line_ordering_only[location] = True + return ScannerDevice( + id=location, + vendor=str(caps.vendor).strip(), + model=str(caps.model or caps.product).strip(), + capabilities=_caps_for(caps), + ) + except Exception as exc: + logger.warning("Could not read capabilities of %s: %s", location, exc) + return None + finally: + with suppress(Exception): + session.close() + + # ── sessions ────────────────────────────────────────────────────── + + def open_session(self, device_id: str) -> ScannerSession: + with self._lock: + if device_id in self._sessions: + raise RuntimeError(f"Device already held in a session: {device_id}") + session, model = self._open(device_id) + held = NkscanSession(self, device_id, session, model) + with self._lock: + self._sessions[device_id] = held + return held + + def _release_session(self, session: NkscanSession) -> None: + with self._lock: + self._sessions.pop(session.device_id, None) + + def _open(self, device_id: str) -> tuple[Any, str]: + """Open the unit at `device_id` and stage it for a scan.""" + model = next((d.model for d in self.list_devices() if d.id == device_id), "") + with self._mapped_errors(): + session = self._nk.Session(device_id) + try: + with self._mapped_errors(): + if not session.media_loaded(): + session.load() + session.stage() + except Exception: + with suppress(Exception): + session.close() + raise + return session, model + + # ── scanning ────────────────────────────────────────────────────── + + def scan( + self, + device_id: str, + params: ScanParams, + progress: Callable[..., None], + cancel: threading.Event, + ) -> ScanResult: + with self._lock: + if device_id in self._sessions: + raise RuntimeError(f"Device {device_id} is held by an open session; use session.scan()") + # Validate before opening: a refused option must never leave the unit staged. + _validate_params(params) + session, model = self._open(device_id) + try: + return self._scan_on_session(session, device_id, model, params, progress, cancel) + finally: + with suppress(Exception): + session.close() + + def _scan_on_session( + self, + session: Any, + device_id: str, + model: str, + params: ScanParams, + progress: Callable[..., None], + cancel: threading.Event, + *, + exposures: dict[str, int] | None = None, + ) -> ScanResult: + _validate_params(params) + if cancel.is_set(): + raise RuntimeError("Scan cancelled before start") + report = _progress_bridge(progress, cancel) + rect = self._resolve_frame(session, device_id, params, report) + optical = int(session.capabilities.optical_dpi) + rect = _shift_frame(rect, _offset_units(params.frame_offset_mm, optical)) + rect = _crop_frame(rect, params.window) + with self._mapped_errors(): + result = self.scan_frame( + session, + device_id, + rect, + dpi=int(params.dpi), + samples=int(params.samples), + superfine=bool(params.superfine), + infrared=bool(params.capture_ir), + clean=bool(params.clean), + exposures=exposures, + progress=report, + ) + if cancel.is_set(): + raise RuntimeError("Scan cancelled") + if result.cleaned: + logger.info("Dust removal rebuilt %d pixels", result.cleaned) + return self._to_result(result, model) + + def scan_frame( + self, + session: Any, + device_id: str, + rect: tuple[int, int, int, int], + *, + superfine: bool = False, + **options: Any, + ) -> Any: + """One pass over `rect`. Every scan goes through here, previews included. + + Some units read one CCD line at a time and nothing else — the LS-50 does — and the + bindings cannot be asked in advance. The refusal comes from the recipe check before the + stage moves, so the fast read is tried once, then remembered as unavailable. + """ + want = bool(superfine) or self._line_ordering_only.get(device_id, False) + try: + return session.scan_frame(rect, superfine=want, **options) + except self._nk.UnsupportedError as exc: + if want or getattr(exc, "op", "") != _INTERLEAVING_OP: + raise + logger.info("%s reads one CCD line at a time; every scan on it is superfine", device_id) + self._line_ordering_only[device_id] = True + return session.scan_frame(rect, superfine=True, **options) + + def _to_result(self, result: Any, model: str) -> ScanResult: + ir = result.ir + return ScanResult( + rgb=_stack_rgb(result.colors), + ir=ir, + dpi=int(result.dpi), + device_model=model, + ir_valid_mask=np.ones(ir.shape[:2], dtype=np.bool_) if ir is not None else None, + ) + + # ── frames ──────────────────────────────────────────────────────── + + def discover_frames( + self, + session: Any, + device_id: str, + *, + film_format: str | None, + film_type: str = "negative", + progress: Callable[..., bool] | None = None, + ) -> Any: + """Measure the loaded film, cache the rects, and return nkscan's Discovery.""" + with self._mapped_errors(): + discovery = session.discover_frames( + format=film_format, + positive=film_reads_positive(film_type), + progress=progress, + ) + self._frames[device_id] = [tuple(int(v) for v in rect) for rect in discovery.frames] + thumbnail = getattr(discovery, "thumbnail", None) + if thumbnail: + self._strips[device_id] = _stack_rgb(thumbnail) + logger.info("Detected %d frames on %s", len(self._frames[device_id]), device_id) + return discovery + + def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: + """How many frames the loaded film carries, measuring it only if that is not known. + + A strip previewed a moment ago is already measured, so this usually costs nothing. + """ + known = self._frames.get(device_id) + if known: + return len(known) + with self._lock: + held = self._sessions.get(device_id) + if held is not None: + with self._mapped_errors(): + self.discover_frames(held._session, device_id, film_format=film_format, film_type=film_type) + return len(self._frames.get(device_id, ())) + session, _model = self._open(device_id) + try: + with self._mapped_errors(): + self.discover_frames(session, device_id, film_format=film_format, film_type=film_type) + finally: + with suppress(Exception): + session.close() + return len(self._frames.get(device_id, ())) + + def frames(self, device_id: str) -> list[tuple[int, int, int, int]]: + return list(self._frames.get(device_id, ())) + + def strip_pass(self, device_id: str) -> np.ndarray | None: + """The whole-strip read the frames were measured on, where the mechanism took one.""" + return self._strips.get(device_id) + + def set_frame(self, device_id: str, slot: int, rect: tuple[int, int, int, int]) -> None: + """Replace one detected rect, so a nudge in the preview reaches the fine scan.""" + frames = self._frames.get(device_id) + if frames and 1 <= slot <= len(frames): + frames[slot - 1] = rect + + def forget_frames(self, device_id: str) -> None: + """Drop the cached rects and the pass they came from: that film has moved.""" + self._frames.pop(device_id, None) + self._strips.pop(device_id, None) + + def _resolve_frame( + self, + session: Any, + device_id: str, + params: ScanParams, + report: Callable[..., bool], + ) -> tuple[int, int, int, int]: + frames = self._frames.get(device_id) + if not frames: + self.discover_frames( + session, + device_id, + film_format=params.film_format, + film_type=params.film_type, + progress=report, + ) + frames = self._frames.get(device_id) + if not frames: + raise RuntimeError("No frames were detected on the loaded film") + # No frame requested means the first one on the strip, never whatever the stage happens + # to be over. + index = 1 if params.frame is None else int(params.frame) + if not 1 <= index <= len(frames): + raise RuntimeError(f"Frame {index} was not detected on this strip ({len(frames)} found)") + return frames[index - 1] + + # ── strip preview ───────────────────────────────────────────────── + + def open_roll( + self, + device: ScannerDevice, + *, + dpi: int, + film_format: str | None = None, + film_type: str = "negative", + ) -> Any: + from negpy.infrastructure.scanners.nkscan_roll import NkscanRollSession + + session, model = self._open(device.id) + return NkscanRollSession(self, device, session, model, dpi=dpi, film_format=film_format, film_type=film_type) + + # ── eject ───────────────────────────────────────────────────────── + + def eject(self, device_id: str) -> bool: + with self._lock: + held = self._sessions.get(device_id) + if held is not None: + return held.eject() + session, _model = self._open(device_id) + try: + with self._mapped_errors(): + return bool(session.eject()) + finally: + self.forget_frames(device_id) + with suppress(Exception): + session.close() + + # ── errors ──────────────────────────────────────────────────────── + + @contextmanager + def _mapped_errors(self) -> Iterator[None]: + """nkscan's exception tree onto NegPy's, by type — never by message.""" + nk = self._nk + try: + yield + except nk.ScanCancelled as exc: + raise RuntimeError("Scan cancelled") from exc + except nk.TransientError as exc: + raise TransientScanError(str(exc)) from exc + except nk.UnsupportedError as exc: + raise RuntimeError(f"{getattr(exc, 'op', 'operation')}: {getattr(exc, 'reason', exc)}") from exc + except nk.ScannerError as exc: + raise RuntimeError(str(exc)) from exc diff --git a/negpy/infrastructure/scanners/nkscan_roll.py b/negpy/infrastructure/scanners/nkscan_roll.py new file mode 100644 index 000000000..2d3b8d2c8 --- /dev/null +++ b/negpy/infrastructure/scanners/nkscan_roll.py @@ -0,0 +1,199 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""RollSession over nkscan: measure the strip once, then cut the previews out of that pass. + +The first native RollSession. Where PerFrameRollSession guesses a frame's position from an +index and an offset the operator tunes by eye, nkscan measures every boundary on the loaded +film in one pass and hands that pass back, so a preview costs no scanning at all — a nudged +boundary re-previews without touching the scanner. +""" + +from __future__ import annotations + +import threading +from collections.abc import Iterable, Iterator +from contextlib import suppress +from typing import Any + +import numpy as np + +from negpy.infrastructure.scanners.base import ScannerDevice +from negpy.infrastructure.scanners.nkscan_backend import _offset_units, _progress_bridge, _shift_frame, _stack_rgb +from negpy.infrastructure.scanners.roll import RollPreview, effective_pitch_mm +from negpy.kernel.system.logging import get_logger + +logger = get_logger(__name__) + +_PREVIEW_DEPTH_DPI = 0 # the strip pass has its own resolution; nothing chooses it + + +def thumbnail_scale(rect: tuple[int, int, int, int], rows: int) -> float: + """Stage addresses per thumbnail pixel. + + The strip pass covers the adapter's opening across the film and the whole feed axis along + it, at one resolution on both axes, and every measured rect spans that same opening. So the + frame's width over the pass's row count is the scale, and a column is a feed address. + """ + _top, left, _bottom, right = rect + return (right - left) / rows if rows else 0.0 + + +def slice_frame(strip: np.ndarray, rect: tuple[int, int, int, int], scale: float) -> np.ndarray | None: + """The frame's own pixels out of the strip pass, or None when it falls outside. + + Columns are feed addresses from the axis start, so a rect maps straight onto them. + """ + if scale <= 0: + return None + top, _left, bottom, _right = rect + first = int(round(top / scale)) + last = int(round(bottom / scale)) + if first < 0 or last > strip.shape[1] or last - first < 2: + return None + return strip[:, first:last] + + +class NkscanRollSession: + """One measured strip: preview its frames, nudge their boundaries, release the unit.""" + + def __init__( + self, + backend: Any, + device: ScannerDevice, + session: Any, + model: str, + *, + dpi: int = _PREVIEW_DEPTH_DPI, + film_format: str | None = None, + film_type: str = "negative", + ) -> None: + self._backend = backend + self._device = device + self._session = session + self._model = model + self._dpi = int(dpi) + self._film_format = film_format + self._film_type = film_type + self._offsets: dict[int, float] = {} + self._approved: set[int] = set() + # Only the fallback meters, and one strip is one exposure decision. + self._exposures: dict[str, int] | None = None + self._closed = False + self.slot_count = len(backend.frames(device.id)) + # An absolute rect can be re-addressed backwards, unlike a within-frame offset. + self.offset_range = (-1.0, 1.0) + self.supports_single_slot_preview = True + self._pitch_mm = effective_pitch_mm(device.capabilities) + + # ── preview ─────────────────────────────────────────────────────── + + def preview(self, slots: Iterable[int], *, cancel: threading.Event) -> Iterator[RollPreview]: + self._require_open() + if cancel.is_set(): + return + frames = self._ensure_frames(cancel) + if not frames: + return + for slot in slots: + if cancel.is_set(): + return + if not 1 <= slot <= len(frames): + # The dialog may ask for more slots than this strip turned out to hold. + continue + offset = self._offsets.get(slot, 0.0) + try: + rgb = self._preview_one(slot, cancel) + except Exception as error: + if cancel.is_set(): + return + logger.warning("Preview of slot %s failed: %s", slot, error) + yield RollPreview(slot=slot, error=str(error), offset=offset, needs_approval=self._needs_approval(slot)) + continue + yield RollPreview(slot=slot, rgb=rgb, offset=offset, needs_approval=self._needs_approval(slot)) + + @property + def thumbnail(self) -> np.ndarray | None: + """The strip pass every preview is cut from, where the mechanism took one.""" + return self._backend.strip_pass(self._device.id) + + def _preview_one(self, slot: int, cancel: threading.Event) -> np.ndarray: + rect = self._rect(slot) + strip = self.thumbnail + if strip is not None: + tile = slice_frame(strip, rect, self._scale(strip)) + if tile is not None: + return tile + logger.info("Slot %s falls outside the strip pass; scanning it instead", slot) + return self._scan_preview(rect, cancel) + + def _scale(self, strip: np.ndarray) -> float: + frames = self._backend.frames(self._device.id) + return thumbnail_scale(frames[0], strip.shape[0]) if frames else 0.0 + + def _scan_preview(self, rect: tuple[int, int, int, int], cancel: threading.Event) -> np.ndarray: + """A pass of one frame, for a mechanism that measured the film without a strip pass.""" + with self._backend._mapped_errors(): + result = self._backend.scan_frame( + self._session, + self._device.id, + rect, + dpi=self._dpi or None, + samples=1, + infrared=False, + clean=False, + exposures=self._exposures, + progress=_progress_bridge(None, cancel), + ) + if self._exposures is None: + self._exposures = dict(result.exposures) + return _stack_rgb(result.colors) + + def _ensure_frames(self, cancel: threading.Event) -> list[tuple[int, int, int, int]]: + frames = self._backend.frames(self._device.id) + if not frames: + self._backend.discover_frames( + self._session, + self._device.id, + film_format=self._film_format, + film_type=self._film_type, + progress=_progress_bridge(None, cancel), + ) + frames = self._backend.frames(self._device.id) + self.slot_count = len(frames) + return frames + + # ── boundaries ──────────────────────────────────────────────────── + + def set_offset(self, slot: int, offset: float) -> None: + lo, hi = self.offset_range + self._offsets[slot] = max(lo, min(hi, float(offset))) + + def approve(self, slot: int) -> None: + self._approved.add(slot) + + def _needs_approval(self, slot: int) -> bool: + """Every boundary here was measured rather than addressed, so it needs a look.""" + return slot not in self._approved + + def _rect(self, slot: int) -> tuple[int, int, int, int]: + """The slot's rect, slid by its offset. + + The offset is a fraction of one frame pitch, the same units the fine scan receives as + millimetres, so preview and scan land on the same film. + """ + rect = self._backend.frames(self._device.id)[slot - 1] + offset_mm = self._offsets.get(slot, 0.0) * self._pitch_mm + optical = int(self._session.capabilities.optical_dpi) + return _shift_frame(rect, _offset_units(offset_mm, optical)) + + # ── lifetime ────────────────────────────────────────────────────── + + def close(self) -> None: + if self._closed: + return + self._closed = True + with suppress(Exception): + self._session.close() + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError(f"Strip session for {self._device.id} is closed") diff --git a/negpy/infrastructure/scanners/params.py b/negpy/infrastructure/scanners/params.py index c77ec16f8..ab176169b 100644 --- a/negpy/infrastructure/scanners/params.py +++ b/negpy/infrastructure/scanners/params.py @@ -29,10 +29,67 @@ class ScanParams: # Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). None lets the # scanner use its default, and it is ignored when the device has no such option. exposure_time_us: int | None = None + # Let the transport remove dust itself, baked into what it returns. Requires + # ScannerCapabilities.hw_clean; a request against a device without it fails. + clean: bool = False + # Repeated reads of one line for the transport to average, not binning. 1 = single read. + samples: int = 1 + # One line per pass: slower, and it owes the host no line registration. + superfine: bool = False + # Film format the transport cannot measure itself ("135", "66", ...). None = let it decide. + film_format: str | None = None + # What is on the film. Reversal stock reads the opposite way against the unexposed film + # between frames, which is what finding the frames on a strip goes by, and silver blocks + # infrared, so this decides whether an IR pass means anything. + film_type: str = "negative" + + +class FilmType(StrEnum): + """What is on the film, as the transports spell it.""" + + NEGATIVE = "negative" + MONO = "mono" + POSITIVE = "positive" + KODACHROME = "kodachrome" + + +#: Label for each film type, and whether infrared can see dust through it. Silver grain stops +#: infrared as it stops light, and Kodachrome's dyes do the same, so the mask comes back as the +#: picture rather than the dust on it. +#: Plain `str` keys, not the enum: these cross into Qt as combo data, and a QVariant holding a +#: StrEnum does not compare equal to the string a caller looks it up with. +FILM_TYPES: dict[str, tuple[str, bool]] = { + FilmType.NEGATIVE.value: ("Color negative", True), + FilmType.MONO.value: ("B&W negative", False), + FilmType.POSITIVE.value: ("Slide", True), + FilmType.KODACHROME.value: ("Kodachrome", False), +} + + +def film_reads_positive(film_type: str) -> bool: + """Reversal stock develops its unexposed film to maximum density, negatives to their base.""" + return film_type in (FilmType.POSITIVE.value, FilmType.KODACHROME.value) + + +def film_passes_infrared(film_type: str) -> bool: + return FILM_TYPES.get(film_type, ("", True))[1] MIN_FRAME_EXTENT_MM = 1.0 # below this a capped scan is a useless sliver +CANONICAL_DPI_STOPS = (75, 150, 300, 600, 1200, 2400, 3600, 4800, 6400, 7200, 9600) + + +def dpi_stops_in_range(lo: float, hi: float) -> tuple[int, ...]: + """Canonical stops a (min, max) hardware range covers, or every stop when it covers none. + + A transport that reports a continuous range offers no ladder of its own, and a UI needs + one — the scanner rounds an off-ladder request rather than refusing it. + """ + stops = tuple(s for s in CANONICAL_DPI_STOPS if lo <= s <= hi) + return stops or CANONICAL_DPI_STOPS + + ScanArea = tuple[float, float, float, float] diff --git a/negpy/infrastructure/scanners/registry.py b/negpy/infrastructure/scanners/registry.py index c14bb524c..f7b7e0bc4 100644 --- a/negpy/infrastructure/scanners/registry.py +++ b/negpy/infrastructure/scanners/registry.py @@ -27,6 +27,12 @@ def _make_plustek() -> ScannerBackend: return PlustekBackend(calib_cache=calib_cache) +def _make_nkscan() -> ScannerBackend: + from negpy.infrastructure.scanners.nkscan_backend import NkscanBackend + + return NkscanBackend() + + def _make_pieusb() -> ScannerBackend: from negpy.infrastructure.scanners.pieusb_backend import PieusbBackend @@ -38,6 +44,7 @@ def _make_pieusb() -> ScannerBackend: # id -> (display label, factory). Insertion order drives the sidebar dropdown. SANE is # Unix-only (python-sane); Windows ships Plustek USB and PIEUSB. BACKENDS: dict[str, tuple[str, Callable[[], ScannerBackend]]] = { + "nkscan": ("Nikon Coolscan (nkscan)", _make_nkscan), "plustek": ("pyOpticfilm (Plustek)", _make_plustek), "pieusb": ("PIEUSB", _make_pieusb), } diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index e1caf893c..a6540c2fb 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -15,7 +15,12 @@ ScannerUnavailable, TransientScanError, ) -from negpy.infrastructure.scanners.params import ScanParams, clamp_frame_offset_mm +from negpy.infrastructure.scanners.params import ( + CANONICAL_DPI_STOPS, + ScanParams, + clamp_frame_offset_mm, + dpi_stops_in_range, +) from negpy.infrastructure.scanners.result import ScanResult from negpy.kernel.system.logging import get_logger @@ -35,8 +40,6 @@ "film": ScanMode.TRANSPARENCY, } -CANONICAL_DPI_STOPS = (75, 150, 300, 600, 1200, 2400, 3600, 4800, 6400, 7200, 9600) - # Legacy SANE option py_names that expose a dedicated infrared channel/scan. # Their presence-only capability behavior predates Coolscan support. _IR_OPTION_NAMES = ("ir", "preview_ir") @@ -158,9 +161,7 @@ def _detect_dpi(opt) -> tuple[int, ...]: if isinstance(constraint, list): return tuple(sorted(int(c) for c in constraint)) if isinstance(constraint, tuple) and len(constraint) >= 2: - lo, hi = constraint[0], constraint[1] - dpi = tuple(s for s in CANONICAL_DPI_STOPS if lo <= s <= hi) - return dpi or tuple(CANONICAL_DPI_STOPS) + return dpi_stops_in_range(constraint[0], constraint[1]) return CANONICAL_DPI_STOPS diff --git a/negpy/infrastructure/scanners/settings.py b/negpy/infrastructure/scanners/settings.py index 506cb26ce..3c6c6762b 100644 --- a/negpy/infrastructure/scanners/settings.py +++ b/negpy/infrastructure/scanners/settings.py @@ -23,6 +23,13 @@ class ScannerSettings: exposure_time_us: int | None = None frame_from: int = 1 frame_to: int = 1 + # Let the transport remove dust itself, baked into the file it writes. + clean: bool = False + samples: int = 1 + superfine: bool = False + # Frame length for a transport that measures the strip; None lets it decide. + film_format: str | None = None + film_type: str = "negative" output_folder: str = "" output_format: str = "TIFF" filename_pattern: str = '{{ date }}_{{ "%03d" % seq }}' @@ -56,15 +63,19 @@ def defaults(cls) -> "ScannerSettings": def resolve_batch_selection( - settings: ScannerSettings, frame_from: int, frame_to: int + settings: ScannerSettings, frame_from: int, frame_to: int, *, whole_strip: bool = False ) -> tuple[tuple[int, ...], dict[int, Rect], Rect | None]: """(frames, per-frame windows, base window) for a BatchRequest. - The strip-dialog selection wins when present; otherwise fall back to the - sidebar spinbox range with the single reused scan_window. + The strip-dialog selection wins when present. Otherwise a transport that measures the film + gets an empty tuple, meaning every frame it finds — it has no frame range to fall back on, + so the spinbox default would silently mean frame 1. The rest fall back to that range with + the single reused scan_window. """ if settings.selected_frames: frames = tuple(sorted(settings.selected_frames)) windows = {f: settings.frame_windows[f] for f in frames if f in settings.frame_windows} return frames, windows, None + if whole_strip: + return (), {}, settings.scan_window return tuple(range(frame_from, frame_to + 1)), {}, settings.scan_window diff --git a/negpy/services/scanning/service.py b/negpy/services/scanning/service.py index 721dcc6de..5c9cd039e 100644 --- a/negpy/services/scanning/service.py +++ b/negpy/services/scanning/service.py @@ -62,14 +62,32 @@ def eject(self, device_id: str) -> bool: """ return self._get_backend().eject(device_id) - def open_roll(self, device: ScannerDevice, *, dpi: int) -> RollSession: + def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: + """How many frames the loaded film carries, 0 where the transport cannot measure it. + + A feeder counts slots instead, and the caller has that from the device capabilities. + """ + detect = getattr(self._get_backend(), "detect_frames", None) + return 0 if detect is None else int(detect(device_id, film_format=film_format, film_type=film_type)) + + def open_roll( + self, + device: ScannerDevice, + *, + dpi: int, + film_format: str | None = None, + film_type: str = "negative", + ) -> RollSession: """Open a strip for whole-roll preview. - Every backend reaches a strip one frame at a time today, so this always - wraps. A backend with a native whole-roll traversal supplies its own - RollSession instead, and this grows a branch then — not before. + A backend that reaches the whole strip natively supplies its own RollSession + through `open_roll`; the rest are wrapped one frame at a time. """ - return PerFrameRollSession(self._get_backend(), device, dpi=dpi) + backend = self._get_backend() + native = getattr(backend, "open_roll", None) + if native is not None: + return native(device, dpi=dpi, film_format=film_format, film_type=film_type) + return PerFrameRollSession(backend, device, dpi=dpi) def run_scan( self, diff --git a/pyproject.toml b/pyproject.toml index 5e6b3544a..b87c857ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ classifiers = [ ] [project.optional-dependencies] +nkscan = ["nkscan>=0.7"] plustek = ["pyopticfilm>=1.2.0"] sane = ["python-sane>=2.9"] camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] @@ -66,6 +67,15 @@ camera = [ pieusb = [ "pieusb>=0.3.7", ] +nkscan = [ + # Nikon Coolscan over SCSI/USB. A Rust extension, so installing this group needs the + # maturin build backend and a Rust toolchain until the wheels are on PyPI. + "nkscan>=0.7", +] + +[tool.uv.sources] +# Local build while nkscan is unreleased. Delete this once its wheels are on PyPI. +nkscan = { path = "../nkscan", editable = true } [project.urls] Homepage = "https://github.com/marcinz606/NegPy" diff --git a/tests/scanners/fake_nkscan.py b/tests/scanners/fake_nkscan.py new file mode 100644 index 000000000..cb0133f15 --- /dev/null +++ b/tests/scanners/fake_nkscan.py @@ -0,0 +1,272 @@ +"""A stand-in for the nkscan extension module, so the backend is testable without hardware. + +The real module is a compiled PyO3 extension. `NkscanBackend` keeps it on `self._nk` for +exactly this reason: `make_backend` builds the backend without running its import. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import Any, Callable +from unittest import mock + +import numpy as np + +from negpy.infrastructure.scanners.nkscan_backend import NkscanBackend + +DEVICE_ID = "usb:1-3.2" +# Rects an LS-50 measured on a real 135 strip: 4000 addresses to the inch, so a frame is +# 5668 tall by 3945 across, and the pitch between them is a little over its height. +FRAMES = ((4387, 0, 10055, 3945), (10742, 0, 16410, 3945), (17753, 0, 23421, 3945)) + + +class ScannerError(RuntimeError): ... + + +class TransientError(ScannerError): ... + + +class TransportError(TransientError): ... + + +class DeviceBusy(TransientError): ... + + +class DeviceNotFound(ScannerError): ... + + +class MediaError(ScannerError): ... + + +class ScanCancelled(ScannerError): ... + + +class UnsupportedError(ScannerError): + def __init__(self, message: str, op: str = "scan", reason: str = "not offered") -> None: + super().__init__(message) + self.op = op + self.reason = reason + + +@dataclass(frozen=True) +class FakeCapabilities: + vendor: str = "Nikon" + product: str = "LS-50 ED" + revision: str = "1.00" + model: str | None = "LS-50" + x_dpi_range: tuple[int, int] = (500, 4000) + y_dpi_range: tuple[int, int] = (500, 4000) + optical_dpi: int = 4000 + multiline_read: bool = True + multi_reading: bool = True + + +@dataclass(frozen=True) +class FakeDevice: + location: str + name: str = "LS-50" + + +@dataclass(frozen=True) +class FakeDiscovery: + frames: list[tuple[int, int, int, int]] + thumbnail: dict[str, np.ndarray] | None = None + + +@dataclass(frozen=True) +class FakeScanResult: + colors: dict[str, np.ndarray] + ir: np.ndarray | None + dpi: int + rows: int + cols: int + exposures: dict[str, int] + cleaned: int | None + + +@dataclass +class FakeNkscanModule: + """Every knob a scenario needs, and a record of what the backend asked for.""" + + locations: tuple[str, ...] = (DEVICE_ID,) + frames: tuple[tuple[int, int, int, int], ...] = FRAMES + caps: FakeCapabilities = field(default_factory=FakeCapabilities) + media_loaded_at_open: bool = True + with_eject: bool = False + progress_steps: int = 0 + rows: int = 8 + cols: int = 6 + thumbnail: bool = True + strip_slack: int = 4 # columns past the last frame; negative pushes frames off the pass + # Some units (the LS-50) never read three CCD rows at once, and refuse the fast mode. + line_ordering_only: bool = False + scan_error: Exception | None = None + discover_error: Exception | None = None + open_error: Exception | None = None + probe_error: Exception | None = None + opened: list[Any] = field(default_factory=list) + + def __post_init__(self) -> None: + module = self + + class Session(FakeSession): + _module = module + + self.Session = Session + for name in ( + "ScannerError", + "TransientError", + "TransportError", + "DeviceBusy", + "DeviceNotFound", + "MediaError", + "ScanCancelled", + "UnsupportedError", + ): + setattr(self, name, globals()[name]) + + def list_devices(self) -> list[FakeDevice]: + return [FakeDevice(location=loc) for loc in self.locations] + + def strip_pass(self) -> dict[str, np.ndarray] | None: + """The whole-strip pass, laid out the way the unit delivers one. + + Columns are feed addresses from the axis start, at the same resolution as the rows, + which span the adapter opening. Each frame's band carries its own slot number, so a + test can tell which part of the strip a tile was cut from. + """ + if not self.thumbnail or not self.frames: + return None + top, left, _bottom, right = self.frames[0] + scale = (right - left) / self.rows + cols = int(max(f[2] for f in self.frames) / scale) + self.strip_slack + plane = np.zeros((self.rows, cols), np.uint16) + for slot, (top, _l, bottom, _r) in enumerate(self.frames, 1): + plane[:, int(top / scale) : int(bottom / scale)] = slot + return {c: plane.copy() for c in ("red", "green", "blue")} + + @property + def sessions(self) -> list[Any]: + return self.opened + + +class FakeSession: + _module: FakeNkscanModule + + def __init__(self, location: str) -> None: + module = self._module + if location not in module.locations: + raise DeviceNotFound(f"no scanner at {location}") + if module.open_error is not None: + raise module.open_error + self.location = location + self.closed = False + self.staged = 0 + self.loads = 0 + self.ejects = 0 + self.discoveries: list[str | None] = [] + self.polarities: list[bool] = [] + self.scans: list[dict[str, Any]] = [] + module.opened.append(self) + + @classmethod + def open(cls, device: FakeDevice) -> FakeSession: + if cls._module.probe_error is not None: + raise cls._module.probe_error + return cls(device.location) + + @property + def capabilities(self) -> FakeCapabilities: + return self._module.caps + + def media_loaded(self) -> bool: + return self._module.media_loaded_at_open + + def load(self) -> bool: + self.loads += 1 + return True + + def stage(self) -> None: + self.staged += 1 + + def eject(self) -> bool: + self.ejects += 1 + return self._module.with_eject + + def discover_frames( + self, + format: str | None = None, # noqa: A002 - the binding's own name + positive: bool = False, + progress: Callable[..., Any] | None = None, + ) -> FakeDiscovery: + module = self._module + self.discoveries.append(format) + self.polarities.append(positive) + if progress is not None: + progress("discover", 0, 1, 1) + if module.discover_error is not None: + raise module.discover_error + return FakeDiscovery(frames=list(module.frames), thumbnail=module.strip_pass()) + + def scan_frame( + self, + frame: tuple[int, int, int, int], + dpi: int | None = None, + samples: int = 1, + superfine: bool = False, + infrared: bool = False, + clean: bool = False, + lock_white_balance: bool = True, + exposures: dict[str, int] | None = None, + progress: Callable[..., Any] | None = None, + ) -> FakeScanResult: + module = self._module + if module.line_ordering_only and not superfine: + raise UnsupportedError( + "color interleaving: this unit does not read the CCD three rows at once", + op="color interleaving", + reason="only ColorInterleaving(LINE_WITHOUT_DISTANCE)", + ) + self.scans.append( + { + "frame": frame, + "dpi": dpi, + "samples": samples, + "superfine": superfine, + "infrared": infrared, + "clean": clean, + "lock_white_balance": lock_white_balance, + "exposures": exposures, + } + ) + for step in range(1, module.progress_steps + 1): + if progress is not None and progress("scan", 0, step, module.progress_steps) is False: + raise ScanCancelled("cancelled by the progress callback") + if module.scan_error is not None: + raise module.scan_error + rows, cols = module.rows, module.cols + colors = {name: np.full((rows, cols), value, np.uint16) for name, value in (("red", 10), ("green", 20), ("blue", 30))} + return FakeScanResult( + colors=colors, + ir=np.full((rows, cols), 40, np.uint16) if infrared else None, + dpi=int(dpi or module.caps.optical_dpi), + rows=rows, + cols=cols, + exposures={"red": 1, "green": 2, "blue": 3}, + cleaned=7 if clean else None, + ) + + def close(self) -> None: + self.closed = True + + +def make_backend(module: FakeNkscanModule | None = None, **kwargs: Any) -> tuple[NkscanBackend, FakeNkscanModule]: + """A backend wired to a fake module. + + Constructed through the real `__init__` with the fake standing in for the extension, so the + backend's own state cannot drift from what a test set up by hand. + """ + module = module or FakeNkscanModule(**kwargs) + with mock.patch.dict(sys.modules, {"nkscan": module}): + return NkscanBackend(), module diff --git a/tests/scanners/test_backend_contract.py b/tests/scanners/test_backend_contract.py index 543c09e09..e8cbd4ec4 100644 --- a/tests/scanners/test_backend_contract.py +++ b/tests/scanners/test_backend_contract.py @@ -85,7 +85,35 @@ def _sane_backend( return _make_backend(_ModuleWithDevice(dev, device_id)), device_id -BACKENDS: list[tuple[str, _Factory]] = [("sane", _sane_backend)] +# ── the nkscan entry ────────────────────────────────────────────────────── + + +def _nkscan_backend( + *, + scan_error: Exception | None = None, + with_eject: bool = False, + film: bool = True, + progress_steps: int = 0, +) -> tuple[Any, str]: + from tests.scanners import fake_nkscan + + # nkscan drives film scanners only, so "no film source" is a unit it cannot open. + probe_error = None if film else fake_nkscan.DeviceNotFound("not a Coolscan") + translated = None + if scan_error is not None: + marker = "i/o" in str(scan_error).lower() + cls = fake_nkscan.TransportError if marker else fake_nkscan.MediaError + translated = cls(str(scan_error)) + backend, _module = fake_nkscan.make_backend( + scan_error=translated, + with_eject=with_eject, + probe_error=probe_error, + progress_steps=progress_steps, + ) + return backend, fake_nkscan.DEVICE_ID + + +BACKENDS: list[tuple[str, _Factory]] = [("sane", _sane_backend), ("nkscan", _nkscan_backend)] pytestmark = pytest.mark.parametrize("name,make_backend", BACKENDS) diff --git a/tests/scanners/test_nkscan_backend.py b/tests/scanners/test_nkscan_backend.py new file mode 100644 index 000000000..5bcf5a125 --- /dev/null +++ b/tests/scanners/test_nkscan_backend.py @@ -0,0 +1,484 @@ +"""NkscanBackend: capability projection, frame resolution, option plumbing, error typing.""" + +from __future__ import annotations + +import threading + +import numpy as np +import pytest + +from negpy.infrastructure.scanners.base import TransientScanError +from negpy.infrastructure.scanners.nkscan_backend import _crop_frame, _offset_units, _shift_frame, _stack_rgb +from negpy.infrastructure.scanners.params import ScanMode, ScanParams +from tests.scanners import fake_nkscan +from tests.scanners.fake_nkscan import DEVICE_ID, FRAMES, FakeCapabilities, make_backend + +_PARAMS = ScanParams(dpi=1000, depth=16, capture_ir=False) + + +def _scan(backend, params=_PARAMS, progress=None): + return backend.scan(DEVICE_ID, params, progress or (lambda *_: None), threading.Event()) + + +# ── capabilities ────────────────────────────────────────────────────────── + + +def test_capabilities_project_the_dpi_ladder_and_the_optical_stop() -> None: + backend, _ = make_backend() + caps = backend.list_devices()[0].capabilities + + assert caps.supported_dpi == (600, 1200, 2400, 3600, 4000) + assert caps.supported_depths == (16,) + assert caps.sources == (ScanMode.NEGATIVE, ScanMode.POSITIVE) + + +def test_capabilities_announce_the_nkscan_only_controls() -> None: + backend, _ = make_backend() + caps = backend.list_devices()[0].capabilities + + assert caps.hw_clean and caps.roll_discovery and caps.superfine + assert caps.max_samples == 16 + assert "135" in caps.film_formats + assert caps.ir_channel and caps.can_eject + # Neither is controllable through the bindings, so neither gets a control. + assert not caps.autofocus and not caps.auto_exposure + assert caps.exposure_time_us is None + # The frame count is unknown until a strip is measured. + assert caps.adapter_frame_capacity is None + + +def test_a_continuous_range_outside_every_stop_still_offers_a_ladder() -> None: + backend, _ = make_backend(caps=FakeCapabilities(x_dpi_range=(20, 40), optical_dpi=40)) + assert backend.list_devices()[0].capabilities.supported_dpi[0] == 40 + + +def test_devices_are_named_from_the_unit() -> None: + backend, _ = make_backend() + device = backend.list_devices()[0] + assert device.id == DEVICE_ID + assert (device.vendor, device.model) == ("Nikon", "LS-50") + + +def test_list_devices_caches_and_refresh_re_probes() -> None: + backend, module = make_backend() + backend.list_devices() + probes = len(module.opened) + backend.list_devices() + assert len(module.opened) == probes + + backend.refresh_devices() + assert len(module.opened) > probes + + +def test_a_held_device_is_not_re_probed() -> None: + """Probing opens the unit, and nkscan reserves it: a held one would refuse.""" + backend, module = make_backend() + backend.list_devices() + with backend.open_session(DEVICE_ID): + probes = len(module.opened) + assert [d.id for d in backend.refresh_devices()] == [DEVICE_ID] + assert len(module.opened) == probes + 1 # the session itself, not a probe + + +# ── frames ──────────────────────────────────────────────────────────────── + + +def test_a_scan_with_no_frame_takes_the_first_detected_one() -> None: + backend, module = make_backend() + _scan(backend) + assert module.opened[-1].scans[0]["frame"] == FRAMES[0] + + +def test_a_frame_index_resolves_against_the_detected_rects() -> None: + backend, module = make_backend() + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, frame=3)) + assert module.opened[-1].scans[0]["frame"] == FRAMES[2] + + +def test_a_frame_past_the_detected_count_fails_rather_than_scanning_something_else() -> None: + backend, _ = make_backend() + with pytest.raises(RuntimeError, match="Frame 9 was not detected"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, frame=9)) + + +def test_discovery_runs_once_and_is_reused_across_scans() -> None: + backend, module = make_backend() + _scan(backend) + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, frame=2)) + assert sum(len(s.discoveries) for s in module.opened) == 1 + + +def test_an_eject_forgets_the_rects_because_the_film_has_moved() -> None: + backend, _ = make_backend(with_eject=True) + _scan(backend) + assert backend.frames(DEVICE_ID) + + assert backend.eject(DEVICE_ID) is True + assert backend.frames(DEVICE_ID) == [] + + +def test_no_detected_frames_is_a_plain_failure() -> None: + backend, _ = make_backend(frames=()) + with pytest.raises(RuntimeError, match="No frames were detected"): + _scan(backend) + + +def test_the_film_format_reaches_discovery() -> None: + backend, module = make_backend() + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, film_format="66")) + assert module.opened[-1].discoveries == ["66"] + + +def test_an_unknown_film_format_is_refused_before_the_unit_moves() -> None: + backend, module = make_backend() + with pytest.raises(RuntimeError, match="Unknown film format"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, film_format="120")) + assert module.opened == [] + + +# ── geometry ────────────────────────────────────────────────────────────── + + +def test_offset_millimetres_become_stage_addresses() -> None: + assert _offset_units(25.4, 4000) == 4000 + assert _offset_units(0.0, 4000) == 0 + + +def test_a_frame_slides_along_the_feed_axis_only() -> None: + assert _shift_frame((100, 10, 1100, 810), 50) == (150, 10, 1150, 810) + + +def test_a_frame_cannot_slide_before_the_stage_range() -> None: + assert _shift_frame((100, 10, 1100, 810), -400) == (0, 10, 1000, 810) + + +def test_a_window_crops_inside_the_frame() -> None: + assert _crop_frame((100, 10, 1100, 810), (0.0, 0.5, 0.5, 1.0)) == (600, 10, 1100, 410) + + +def test_a_degenerate_window_keeps_a_pixel() -> None: + top, left, bottom, right = _crop_frame((100, 10, 1100, 810), (0.5, 0.5, 0.5, 0.5)) + assert bottom > top and right > left + + +def test_the_offset_and_the_window_both_reach_the_scan() -> None: + backend, module = make_backend() + _scan( + backend, + ScanParams(dpi=1000, depth=16, capture_ir=False, frame=1, frame_offset_mm=25.4, window=(0.0, 0.0, 1.0, 0.5)), + ) + top, _left, bottom, _right = module.opened[-1].scans[0]["frame"] + assert top == FRAMES[0][0] + 4000 + assert bottom - top == (FRAMES[0][2] - FRAMES[0][0]) // 2 + + +# ── result ──────────────────────────────────────────────────────────────── + + +def test_the_planes_come_back_as_one_rgb_array() -> None: + backend, _ = make_backend() + result = _scan(backend) + assert result.rgb.shape == (8, 6, 3) + assert result.rgb.dtype == np.uint16 + assert result.ir is None and result.ir_valid_mask is None + + +def test_an_ir_scan_carries_the_plane_and_an_all_valid_mask() -> None: + backend, _ = make_backend() + result = _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=True)) + assert result.ir is not None and result.ir.shape == (8, 6) + assert result.ir_valid_mask is not None and result.ir_valid_mask.all() + + +def test_a_single_plane_unit_still_yields_three_channels() -> None: + mono = {"default": np.full((4, 3), 7, np.uint16)} + rgb = _stack_rgb(mono) + assert rgb.shape == (4, 3, 3) + assert (rgb[..., 0] == rgb[..., 2]).all() + + +def test_a_scan_with_no_planes_at_all_is_an_error() -> None: + with pytest.raises(RuntimeError, match="no image planes"): + _stack_rgb({}) + + +# ── options ─────────────────────────────────────────────────────────────── + + +def test_ice_samples_and_superfine_reach_the_scan() -> None: + backend, module = make_backend() + _scan(backend, ScanParams(dpi=2400, depth=16, capture_ir=True, clean=True, samples=4, superfine=True)) + asked = module.opened[-1].scans[0] + assert asked["clean"] and asked["infrared"] and asked["superfine"] + assert (asked["samples"], asked["dpi"]) == (4, 2400) + # White balance stays locked: a negative is normalized downstream, not by the scanner. + assert asked["lock_white_balance"] is True + + +def test_samples_outside_the_bound_are_refused() -> None: + backend, module = make_backend() + with pytest.raises(RuntimeError, match="Samples must be 1..16"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, samples=32)) + assert module.opened == [] + + +def test_hardware_auto_exposure_is_refused_rather_than_ignored() -> None: + backend, _ = make_backend() + with pytest.raises(RuntimeError, match="meters every frame"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, auto_exposure=True)) + + +def test_the_default_autofocus_request_is_honoured_silently() -> None: + """nkscan focuses every frame itself, so the default True is the truth, not an unmet option.""" + backend, _ = make_backend() + assert _PARAMS.autofocus is True + assert _scan(backend).rgb.shape[2] == 3 + + +# ── lifetime and errors ─────────────────────────────────────────────────── + + +def test_a_scan_stages_the_unit_and_closes_it_again() -> None: + backend, module = make_backend() + _scan(backend) + session = module.opened[-1] + assert session.staged == 1 and session.closed + + +def test_film_is_loaded_when_the_holder_is_empty() -> None: + backend, module = make_backend(media_loaded_at_open=False) + _scan(backend) + assert module.opened[-1].loads == 1 + + +def test_a_held_device_refuses_a_stateless_scan() -> None: + backend, _ = make_backend() + with backend.open_session(DEVICE_ID): + with pytest.raises(RuntimeError, match="held by an open session"): + _scan(backend) + + +def test_a_second_session_on_a_held_device_is_refused() -> None: + backend, _ = make_backend() + with backend.open_session(DEVICE_ID): + with pytest.raises(RuntimeError, match="already held"): + backend.open_session(DEVICE_ID) + + +def test_a_session_scan_reuses_the_one_hold() -> None: + backend, module = make_backend() + with backend.open_session(DEVICE_ID) as session: + session.scan(_PARAMS, lambda *_: None, threading.Event()) + session.scan(_PARAMS, lambda *_: None, threading.Event()) + assert len(module.opened) == 2 # one probe, one session + assert len(module.opened[-1].scans) == 2 + + +def test_a_closed_session_refuses_everything() -> None: + backend, _ = make_backend() + session = backend.open_session(DEVICE_ID) + session.close() + with pytest.raises(RuntimeError, match="is closed"): + session.scan(_PARAMS, lambda *_: None, threading.Event()) + with pytest.raises(RuntimeError, match="is closed"): + session.eject() + + +def test_a_failure_to_stage_still_releases_the_unit() -> None: + backend, module = make_backend() + module.frames = FRAMES + module.discover_error = fake_nkscan.MediaError("no film in the holder") + with pytest.raises(RuntimeError, match="no film"): + _scan(backend) + assert module.opened[-1].closed + + +def test_a_transport_glitch_is_typed_transient() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.TransportError("the link dropped")) + with pytest.raises(TransientScanError): + _scan(backend) + + +def test_a_busy_unit_is_typed_transient() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.DeviceBusy("another process has it")) + with pytest.raises(TransientScanError): + _scan(backend) + + +def test_a_media_fault_is_not_transient() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.MediaError("the holder jammed")) + with pytest.raises(RuntimeError) as excinfo: + _scan(backend) + assert not isinstance(excinfo.value, TransientScanError) + + +def test_an_unsupported_operation_names_it() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.UnsupportedError("nope", op="clean", reason="mono film")) + with pytest.raises(RuntimeError, match="clean: mono film"): + _scan(backend) + + +def test_a_cancel_from_the_progress_callback_reads_as_cancelled() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.ScanCancelled("stopped")) + with pytest.raises(RuntimeError, match="[Cc]ancel"): + _scan(backend) + + +# ── progress ────────────────────────────────────────────────────────────── + + +def test_progress_reports_a_fraction_and_a_phase_name() -> None: + backend, _ = make_backend(progress_steps=4) + seen: list[tuple[float, str]] = [] + _scan(backend, progress=lambda fraction, phase="Scanning": seen.append((fraction, phase))) + + assert ("Detecting frames" in [p for _f, p in seen]) and ("Scanning" in [p for _f, p in seen]) + assert [f for f, p in seen if p == "Scanning"] == [0.25, 0.5, 0.75, 1.0] + + +def test_a_cancel_mid_read_stops_the_pass() -> None: + backend, module = make_backend(progress_steps=4) + cancel = threading.Event() + + def progress(_fraction: float, phase: str = "Scanning") -> None: + if phase == "Scanning": + cancel.set() + + with pytest.raises(RuntimeError, match="[Cc]ancel"): + backend.scan(DEVICE_ID, _PARAMS, progress, cancel) + assert len(module.opened[-1].scans) == 1 + + +# ── units that read one line at a time ──────────────────────────────────── + + +def test_a_unit_with_no_fast_read_still_scans() -> None: + """The LS-50 offers only line ordering, and the bindings cannot be asked in advance.""" + backend, module = make_backend(line_ordering_only=True) + result = _scan(backend) + + assert result.rgb.shape == (8, 6, 3) + assert module.opened[-1].scans[-1]["superfine"] is True + + +def test_the_refusal_is_learned_once_per_device() -> None: + backend, module = make_backend(line_ordering_only=True) + _scan(backend) + _scan(backend) + + # One wasted validation on the first scan, then straight to the read the unit accepts. + asked = [s["superfine"] for session_ in module.opened for s in session_.scans] + assert asked == [True, True] + + +def test_a_different_unsupported_operation_still_fails() -> None: + backend, _ = make_backend(scan_error=fake_nkscan.UnsupportedError("nope", op="clean", reason="mono film")) + with pytest.raises(RuntimeError, match="clean: mono film"): + _scan(backend) + + +# ── how many frames the film carries ────────────────────────────────────── + + +def test_detect_frames_measures_the_loaded_film() -> None: + backend, module = make_backend() + assert backend.detect_frames(DEVICE_ID) == len(FRAMES) + assert module.opened[-1].discoveries == [None] + + +def test_detect_frames_reuses_what_a_preview_already_measured() -> None: + backend, module = make_backend() + backend.detect_frames(DEVICE_ID) + measurements = sum(len(s.discoveries) for s in module.opened) + + assert backend.detect_frames(DEVICE_ID) == len(FRAMES) + assert sum(len(s.discoveries) for s in module.opened) == measurements + + +def test_detect_frames_uses_a_held_session_rather_than_opening_a_second() -> None: + backend, module = make_backend() + with backend.open_session(DEVICE_ID): + held = len(module.opened) + assert backend.detect_frames(DEVICE_ID, film_format="66") == len(FRAMES) + assert len(module.opened) == held + assert module.opened[-1].discoveries == ["66"] + + +def test_bare_film_detects_nothing() -> None: + backend, _ = make_backend(frames=()) + assert backend.detect_frames(DEVICE_ID) == 0 + + +def test_a_unit_that_offers_one_read_mode_offers_no_superfine_control() -> None: + backend, _ = make_backend(caps=FakeCapabilities(multiline_read=False)) + caps = backend.list_devices()[0].capabilities + assert caps.superfine is False + + +def test_a_unit_that_ignores_repeated_reads_offers_no_samples_control() -> None: + backend, _ = make_backend(caps=FakeCapabilities(multi_reading=False)) + caps = backend.list_devices()[0].capabilities + assert caps.max_samples == 1 + + +def test_a_unit_that_says_it_has_no_fast_read_is_never_asked_for_one() -> None: + """The refusal is free but the round trip is not, and the pages already answered.""" + backend, module = make_backend(caps=FakeCapabilities(multiline_read=False), line_ordering_only=True) + backend.list_devices() + _scan(backend) + + assert [s["superfine"] for s in module.opened[-1].scans] == [True] + + +def test_an_nkscan_too_old_to_say_assumes_both() -> None: + """The bits landed after the first bindings; a wheel without them must not lose controls.""" + + class OldCapabilities: + vendor, product, revision, model = "Nikon", "LS-9000 ED", "1.00", "LS-9000" + x_dpi_range = y_dpi_range = (500, 4000) + optical_dpi = 4000 + + backend, _ = make_backend(caps=OldCapabilities()) + caps = backend.list_devices()[0].capabilities + assert caps.superfine is True and caps.max_samples == 16 + + +# ── what is on the film ─────────────────────────────────────────────────── + + +def test_reversal_film_is_measured_the_other_way_round() -> None: + """Unexposed slide film develops to maximum density, a negative to its base.""" + backend, module = make_backend() + session = backend.open_session(DEVICE_ID) + backend.discover_frames(module.opened[-1], DEVICE_ID, film_format=None, film_type="positive") + assert module.opened[-1].polarities == [True] + + backend.forget_frames(DEVICE_ID) + backend.discover_frames(module.opened[-1], DEVICE_ID, film_format=None, film_type="mono") + assert module.opened[-1].polarities == [True, False] + session.close() + + +def test_ir_on_black_and_white_is_refused_before_the_unit_moves() -> None: + backend, module = make_backend() + with pytest.raises(RuntimeError, match="B&W negative blocks infrared"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=True, film_type="mono")) + assert module.opened == [] + + +def test_ice_on_kodachrome_is_refused_too() -> None: + backend, _ = make_backend() + with pytest.raises(RuntimeError, match="Kodachrome blocks infrared"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, clean=True, film_type="kodachrome")) + + +def test_black_and_white_still_scans_without_ir() -> None: + backend, module = make_backend() + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, film_type="mono")) + assert module.opened[-1].scans[0]["infrared"] is False + + +def test_an_unknown_film_type_is_refused() -> None: + backend, _ = make_backend() + with pytest.raises(RuntimeError, match="Unknown film type"): + _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, film_type="tintype")) diff --git a/tests/scanners/test_nkscan_roll.py b/tests/scanners/test_nkscan_roll.py new file mode 100644 index 000000000..2317881d6 --- /dev/null +++ b/tests/scanners/test_nkscan_roll.py @@ -0,0 +1,232 @@ +"""NkscanRollSession: measure the strip once, preview what it found.""" + +from __future__ import annotations + +import threading + +import pytest + +from negpy.infrastructure.scanners.base import ScannerDevice +from negpy.infrastructure.scanners.nkscan_roll import NkscanRollSession +from tests.scanners import fake_nkscan +from tests.scanners.fake_nkscan import DEVICE_ID, FRAMES, make_backend + +_PITCH_MM = 36.0 # max_area_mm[1] on the nkscan caps: the fallback pitch + + +def _roll(**kwargs) -> tuple[NkscanRollSession, object, ScannerDevice]: + backend, module = make_backend(**kwargs) + device = backend.list_devices()[0] + return backend.open_roll(device, dpi=500), module, device + + +def _previews(session, slots=(1, 2, 3), cancel=None): + return list(session.preview(slots, cancel=cancel or threading.Event())) + + +# ── discovery ───────────────────────────────────────────────────────────── + + +def test_the_strip_is_measured_once_for_every_slot() -> None: + session, module, _ = _roll() + previews = _previews(session) + + assert [p.slot for p in previews] == [1, 2, 3] + assert len(module.opened[-1].discoveries) == 1 + assert session.slot_count == 3 + + +def test_the_detected_rects_are_cached_for_the_fine_scan() -> None: + backend, _module = make_backend() + device = backend.list_devices()[0] + session = backend.open_roll(device, dpi=500) + _previews(session) + session.close() + + assert backend.frames(DEVICE_ID) == list(FRAMES) + + +def test_previewing_the_whole_strip_scans_nothing() -> None: + """The measurement already read the film; a preview is a slice of that pass.""" + session, module, _ = _roll() + previews = _previews(session) + + assert all(p.rgb is not None for p in previews) + assert module.opened[-1].scans == [] + + +def test_each_preview_is_cut_from_its_own_part_of_the_strip() -> None: + session, _module, _ = _roll() + previews = _previews(session) + + # The fake marks every frame's band with its slot number. + assert [int(p.rgb[0, 0, 0]) for p in previews] == [1, 2, 3] + assert previews[0].rgb.shape[2] == 3 + + +def test_the_strip_pass_is_kept_for_the_operator() -> None: + session, _module, _ = _roll() + _previews(session, (1,)) + assert session.thumbnail is not None and session.thumbnail.shape[2] == 3 + + +def test_a_mechanism_that_takes_no_thumbnail_scans_each_frame_instead() -> None: + """A masked holder publishes its geometry, so nothing measures a strip pass to cut from.""" + session, module, _ = _roll(thumbnail=False) + previews = _previews(session) + + assert session.thumbnail is None + assert [p.rgb is not None for p in previews] == [True, True, True] + assert [s["frame"] for s in module.opened[-1].scans] == list(FRAMES) + asked = [s["exposures"] for s in module.opened[-1].scans] + assert asked[0] is None and asked[1] == asked[2] == {"red": 1, "green": 2, "blue": 3} + assert all(s["samples"] == 1 and not s["infrared"] and not s["clean"] for s in module.opened[-1].scans) + + +def test_a_frame_past_the_end_of_the_strip_pass_is_scanned_instead() -> None: + """An offset can walk a rect off the pass; the film is still reachable by scanning it.""" + session, module, _ = _roll() + _previews(session, (3,)) + assert module.opened[-1].scans == [] + + session.set_offset(3, 1.0) + preview = _previews(session, (3,))[0] + assert preview.error is None and preview.rgb is not None + assert len(module.opened[-1].scans) == 1 + + +def test_the_film_format_reaches_the_measurement() -> None: + backend, module = make_backend() + device = backend.list_devices()[0] + session = backend.open_roll(device, dpi=500, film_format="66") + _previews(session, (1,)) + assert module.opened[-1].discoveries == ["66"] + + +def test_an_empty_strip_yields_nothing_rather_than_failing() -> None: + session, _module, _ = _roll(frames=()) + assert _previews(session) == [] + + +def test_slots_past_the_detected_count_are_skipped() -> None: + session, _module, _ = _roll() + assert [p.slot for p in _previews(session, (1, 2, 3, 4, 5))] == [1, 2, 3] + + +# ── boundaries ──────────────────────────────────────────────────────────── + + +def test_every_boundary_needs_a_look_until_it_is_approved() -> None: + session, _module, _ = _roll() + assert _previews(session, (1,))[0].needs_approval is True + + session.approve(1) + assert _previews(session, (1,))[0].needs_approval is False + + +def test_an_offset_slides_the_rect_by_the_same_film_distance_the_scan_uses() -> None: + session, module, _ = _roll(thumbnail=False) # the fallback shows the rect it asked for + session.set_offset(1, 25.4 / _PITCH_MM) # one inch of film + _previews(session, (1,)) + + assert module.opened[-1].scans[0]["frame"][0] == FRAMES[0][0] + 4000 + + +def test_an_absolute_rect_can_be_re_addressed_backwards() -> None: + session, _module, _ = _roll() + assert session.offset_range == (-1.0, 1.0) + _previews(session, (2,)) # measure first, so the offset only moves the slice + + session.set_offset(2, -1.0) + moved = _previews(session, (2,))[0] + # One pitch back off frame 2 lands on frame 1's band. + assert int(moved.rgb[0, 0, 0]) == 1 + + +def test_an_offset_beyond_the_range_is_clamped() -> None: + session, _module, _ = _roll() + session.set_offset(1, 4.0) + assert _previews(session, (1,))[0].offset == 1.0 + + +# ── failure and cancellation ────────────────────────────────────────────── + + +def test_one_failed_slot_does_not_cost_the_rest_of_the_strip() -> None: + session, module, _ = _roll(thumbnail=False) + previews = [] + for slot in (1, 2, 3): + module.scan_error = fake_nkscan.MediaError("lost focus") if slot == 2 else None + previews += _previews(session, (slot,)) + + assert [p.slot for p in previews] == [1, 2, 3] + assert previews[1].error == "lost focus" and previews[1].rgb is None + assert previews[2].rgb is not None + + +def test_a_cancel_stops_the_strip_where_it_is() -> None: + session, _module, _ = _roll() + cancel = threading.Event() + seen = [] + for preview in session.preview((1, 2, 3), cancel=cancel): + seen.append(preview.slot) + cancel.set() + + assert seen == [1] + + +def test_a_pre_set_cancel_never_touches_the_unit() -> None: + session, module, _ = _roll() + cancel = threading.Event() + cancel.set() + + assert _previews(session, (1, 2, 3), cancel=cancel) == [] + assert module.opened[-1].scans == [] + + +# ── lifetime ────────────────────────────────────────────────────────────── + + +def test_close_releases_the_unit_and_is_idempotent() -> None: + session, module, _ = _roll() + session.close() + session.close() + assert module.opened[-1].closed + + +def test_a_closed_strip_refuses_a_preview() -> None: + session, _module, _ = _roll() + session.close() + with pytest.raises(RuntimeError, match="is closed"): + _previews(session, (1,)) + + +def test_a_second_strip_session_re_slices_rather_than_reading_the_film_again() -> None: + """Nudging an offset and previewing again must not cost another pass over the strip.""" + backend, module = make_backend() + device = backend.list_devices()[0] + + first = backend.open_roll(device, dpi=500) + _previews(first) + first.close() + reads = sum(len(s.discoveries) for s in module.opened) + scans = sum(len(s.scans) for s in module.opened) + + second = backend.open_roll(device, dpi=500) + second.set_offset(2, 0.05) + previews = _previews(second) + second.close() + + assert [p.slot for p in previews] == [1, 2, 3] + assert sum(len(s.discoveries) for s in module.opened) == reads + assert sum(len(s.scans) for s in module.opened) == scans + + +def test_ejecting_forgets_the_strip_pass_because_that_film_is_gone() -> None: + backend, module = make_backend(with_eject=True) + device = backend.list_devices()[0] + _previews(backend.open_roll(device, dpi=500)) + assert backend.strip_pass(device.id) is not None + + backend.eject(device.id) + assert backend.strip_pass(device.id) is None diff --git a/tests/scanners/test_scanner_settings.py b/tests/scanners/test_scanner_settings.py index 00249d04f..8a88cbd41 100644 --- a/tests/scanners/test_scanner_settings.py +++ b/tests/scanners/test_scanner_settings.py @@ -74,3 +74,15 @@ def test_resolve_batch_selection_falls_back_to_spinbox_range(): assert frames == (2, 3, 4, 5) assert windows == {} assert base == (0.2, 0.2, 0.8, 0.8) + + +def test_a_measured_strip_with_no_selection_means_every_frame() -> None: + """Its frame count is unknown until the film is measured, so a range would mean frame 1.""" + frames, windows, window = resolve_batch_selection(ScannerSettings(), 1, 1, whole_strip=True) + assert frames == () and windows == {} and window is None + + +def test_a_selection_still_wins_on_a_measured_strip() -> None: + settings = ScannerSettings(selected_frames=(2, 5)) + frames, _windows, _window = resolve_batch_selection(settings, 1, 1, whole_strip=True) + assert frames == (2, 5) diff --git a/tests/scanners/test_service.py b/tests/scanners/test_service.py index c23f4e026..e37d5a012 100644 --- a/tests/scanners/test_service.py +++ b/tests/scanners/test_service.py @@ -238,3 +238,58 @@ def test_write_refuses_a_pattern_that_does_not_vary_with_sequence(self) -> None: service._backend = FakeBackend() with pytest.raises(ValueError, match="different basename"): service.write_result(result, tmpdir, "fixed_name", "TIFF") + + +class _NativeRollBackend(FakeBackend): + """A backend that reaches a whole strip itself, so the service must not wrap it.""" + + def __init__(self, devices: list[ScannerDevice]) -> None: + super().__init__(devices=devices) + self.roll_args: dict[str, object] = {} + + def open_roll(self, device, *, dpi, film_format=None, film_type="negative") -> object: + self.roll_args = {"device": device, "dpi": dpi, "film_format": film_format, "film_type": film_type} + return self + + +def test_open_roll_prefers_the_backend_own_strip_session(fake_device: ScannerDevice) -> None: + service = ScannerService() + backend = _NativeRollBackend([fake_device]) + service._backend = backend + + session = service.open_roll(fake_device, dpi=500, film_format="66") + + assert session is backend + assert backend.roll_args == {"device": fake_device, "dpi": 500, "film_format": "66", "film_type": "negative"} + + +def test_open_roll_wraps_a_backend_that_scans_one_frame_at_a_time(fake_device: ScannerDevice) -> None: + from negpy.infrastructure.scanners.per_frame_roll import PerFrameRollSession + + service = ScannerService() + service._backend = FakeBackend(devices=[fake_device]) + + assert isinstance(service.open_roll(fake_device, dpi=500), PerFrameRollSession) + + +class _MeasuringBackend(FakeBackend): + def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: + self.detect_args = (device_id, film_format, film_type) + return 4 + + +def test_detect_frames_asks_the_backend_that_can_measure(fake_device: ScannerDevice) -> None: + service = ScannerService() + backend = _MeasuringBackend([fake_device]) + service._backend = backend + + assert service.detect_frames("fake:001", film_format="135", film_type="positive") == 4 + assert backend.detect_args == ("fake:001", "135", "positive") + + +def test_a_backend_that_counts_slots_measures_nothing(fake_device: ScannerDevice) -> None: + """A feeder's capacity is a capability; the caller already has it.""" + service = ScannerService() + service._backend = FakeBackend(devices=[fake_device]) + + assert service.detect_frames("fake:001") == 0 diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index aa5331c11..f8e86e4bb 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -24,7 +24,7 @@ from negpy.desktop.view.sidebar.scan import ScanSidebar from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice -from negpy.infrastructure.scanners.params import ScanMode +from negpy.infrastructure.scanners.params import FILM_TYPES, ScanMode if not QApplication.instance(): _app = QApplication(sys.argv) @@ -89,6 +89,24 @@ ) +# A Coolscan over nkscan: it measures the strip, cleans dust itself and multi-samples. +NKSCAN_CAPS = ScannerCapabilities( + ir_channel=True, + supported_dpi=(1000, 4000), + supported_depths=(16,), + sources=(ScanMode.NEGATIVE, ScanMode.POSITIVE), + max_area_mm=(24.0, 36.0), + can_eject=True, + hw_clean=True, + roll_discovery=True, + film_formats=("135", "66"), + film_types=tuple(FILM_TYPES), + max_samples=16, + superfine=True, +) +NKSCAN_DEVICE = ScannerDevice(id="usb:1-3.2", vendor="Nikon", model="LS-50", capabilities=NKSCAN_CAPS) + + class _FakeRepo: def __init__(self, settings: dict | None = None) -> None: self._store: dict = {"scanner_settings": settings} if settings else {} @@ -522,3 +540,166 @@ def _fake_warning(parent, title, text, *args, **kwargs): assert f"Error: {msg}" in sidebar.status_label.text() assert popped == [("Scan failed", msg)] + + +# ── nkscan-only controls ────────────────────────────────────────────────── + + +def test_a_measured_strip_device_shows_the_nkscan_controls() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + assert sidebar.clean_check.isVisibleTo(sidebar) is True + assert sidebar.superfine_check.isVisibleTo(sidebar) is True + assert sidebar.samples_combo.isVisibleTo(sidebar) is True + assert sidebar.format_combo.isVisibleTo(sidebar) is True + assert [sidebar.samples_combo.itemData(i) for i in range(sidebar.samples_combo.count())] == [1, 2, 4, 8, 16] + assert [sidebar.format_combo.itemData(i) for i in range(sidebar.format_combo.count())] == [None, "135", "66"] + + +def test_a_sane_device_shows_none_of_them() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + assert sidebar.clean_check.isVisibleTo(sidebar) is False + assert sidebar.superfine_check.isVisibleTo(sidebar) is False + assert sidebar.samples_combo.isVisibleTo(sidebar) is False + assert sidebar.format_combo.isVisibleTo(sidebar) is False + + +def test_a_measured_strip_hides_the_frame_range_and_offers_the_strip_dialog() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + assert sidebar.frame_range_widget.isVisibleTo(sidebar) is False + assert sidebar.scan_window_btn.text() == "Preview strip…" + + +def test_a_measured_strip_scans_as_a_batch() -> None: + sidebar, controller = _sidebar(NKSCAN_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar._on_scan() + assert [kind for kind, _req in controller.started] == ["batch"] + + +def test_the_nkscan_options_reach_the_request() -> None: + sidebar, controller = _sidebar(NKSCAN_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar.clean_check.setChecked(True) + sidebar.superfine_check.setChecked(True) + sidebar.samples_combo.setCurrentIndex(sidebar.samples_combo.findData(4)) + sidebar.format_combo.setCurrentIndex(sidebar.format_combo.findData("66")) + + sidebar._on_scan() + params = controller.started[-1][1].params + assert (params.clean, params.superfine, params.samples, params.film_format) == (True, True, 4, "66") + + +def test_a_saved_clean_never_reaches_a_device_that_cannot_do_it() -> None: + """The same guard as autofocus: a stale saved value must not become a refused option.""" + sidebar, controller = _sidebar(FULL_DEVICE, settings={"clean": True, "superfine": True, "samples": 8}) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar._on_scan() + + params = controller.started[-1][1].params + assert (params.clean, params.superfine, params.samples, params.film_format) == (False, False, 1, None) + + +def test_the_nkscan_options_persist() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.clean_check.setChecked(True) + sidebar.samples_combo.setCurrentIndex(sidebar.samples_combo.findData(2)) + assert (sidebar.settings.clean, sidebar.settings.samples) == (True, 2) + + +def test_a_measured_strip_says_what_scan_would_do_before_a_preview() -> None: + """Its frames come from the strip dialog, and there is no frame range to fall back on.""" + sidebar, _ = _sidebar(NKSCAN_DEVICE) + assert "Preview strip" in sidebar.scan_window_status.text() + + +def test_a_measured_strip_scans_the_frames_the_strip_dialog_picked() -> None: + sidebar, controller = _sidebar(NKSCAN_DEVICE, settings={"selected_frames": [2, 4]}) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar._on_scan() + assert controller.started[-1][1].frames == (2, 4) + + +def test_a_measured_strip_with_nothing_picked_scans_the_whole_strip() -> None: + """Its frame count is unknown until the film is measured, so the batch names no frames.""" + sidebar, controller = _sidebar(NKSCAN_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar._on_scan() + + kind, req = controller.started[-1] + assert kind == "batch" and req.frames == () + + +def test_a_feeder_with_nothing_picked_still_uses_its_frame_range() -> None: + sidebar, controller = _sidebar(LS50_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar._on_scan() + + assert controller.started[-1][1].frames == tuple(range(1, 7)) + + +def test_selecting_a_device_keeps_the_saved_capability_gated_settings() -> None: + """Populating the form must not persist a value read before the caps flags catch up.""" + saved = {"backend": "nkscan", "clean": True, "superfine": True, "samples": 8} + sidebar, _ = _sidebar(NKSCAN_DEVICE, settings=saved) + + assert (sidebar.settings.clean, sidebar.settings.superfine, sidebar.settings.samples) == (True, True, 8) + assert sidebar.clean_check.isChecked() and sidebar.superfine_check.isChecked() + assert sidebar.samples_combo.currentData() == 8 + + +def test_selecting_a_device_keeps_a_saved_auto_exposure() -> None: + sidebar, _ = _sidebar(FULL_DEVICE, settings={"auto_exposure": True, "autofocus": False}) + assert sidebar.settings.auto_exposure is True + assert sidebar.settings.autofocus is False + + +# ── what is on the film ─────────────────────────────────────────────────── + + +def test_the_film_types_the_transport_takes_are_offered() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + labels = [sidebar.film_type_combo.itemText(i) for i in range(sidebar.film_type_combo.count())] + assert labels == ["Color negative", "B&W negative", "Slide", "Kodachrome"] + assert sidebar.film_type_combo.currentData() == "negative" + + +def test_a_device_that_is_told_nothing_about_the_film_hides_the_control() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + assert sidebar.film_type_combo.isVisibleTo(sidebar) is False + + +def test_a_film_that_blocks_infrared_disables_ir_and_ice() -> None: + """Silver grain stops infrared as it stops light, so the mask is the picture again.""" + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.ir_check.setChecked(True) + sidebar.clean_check.setChecked(True) + + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("mono")) + + assert sidebar.ir_check.isEnabled() is False and sidebar.ir_check.isChecked() is False + assert sidebar.clean_check.isEnabled() is False and sidebar.clean_check.isChecked() is False + assert "blocks infrared" in sidebar.ir_check.toolTip() + + +def test_kodachrome_blocks_infrared_too() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("kodachrome")) + assert sidebar.clean_check.isEnabled() is False + + +def test_going_back_to_colour_negative_restores_them() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("mono")) + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("negative")) + + assert sidebar.ir_check.isEnabled() is True and sidebar.clean_check.isEnabled() is True + + +def test_the_film_type_reaches_the_request_and_the_settings() -> None: + sidebar, controller = _sidebar(NKSCAN_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("positive")) + sidebar._on_scan() + + assert controller.started[-1][1].params.film_type == "positive" + assert sidebar.settings.film_type == "positive" diff --git a/tests/test_scan_worker.py b/tests/test_scan_worker.py index d30621c1a..cfd513632 100644 --- a/tests/test_scan_worker.py +++ b/tests/test_scan_worker.py @@ -64,9 +64,11 @@ def write_result(self, **_kwargs) -> str: class _BatchService: """Records per-frame scans; returns a frame-numbered path from write_result.""" - def __init__(self, *, fail_on: int | None = None, cancel_before: int | None = None) -> None: + def __init__(self, *, fail_on: int | None = None, cancel_before: int | None = None, detected: int = 0) -> None: self.fail_on = fail_on self.cancel_before = cancel_before + self.detected = detected + self.detect_calls: list[str | None] = [] self.frames: list[int] = [] self.written_seqs: list[int] = [] self.windows: list = [] @@ -77,6 +79,10 @@ def eject(self, device_id: str) -> bool: self.eject_calls.append(device_id) return True + def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: + self.detect_calls.append((film_format, film_type)) + return self.detected + def run_scan(self, device_id, params, progress, cancel): if self.cancel_before is not None and params.frame == self.cancel_before: cancel.set() @@ -104,10 +110,10 @@ def _scan_request() -> ScanRequest: ) -def _batch_request(frames=(2, 3, 4), frame_windows=None) -> BatchRequest: +def _batch_request(frames=(2, 3, 4), frame_windows=None, film_format=None) -> BatchRequest: return BatchRequest( device_id="coolscan3:test", - params=ScanParams(dpi=4_000, depth=16, capture_ir=False), + params=ScanParams(dpi=4_000, depth=16, capture_ir=False, film_format=film_format), output_folder="/tmp", filename_pattern='scan-{{ "%03d" % seq }}', output_format="TIFF", @@ -146,7 +152,7 @@ def test_batch_applies_progressive_offset_per_frame_position() -> None: assert service.offsets == pytest.approx([1.2, 1.4, 1.6]) -def test_batch_negative_drift_floors_the_offset_at_zero() -> None: +def test_batch_passes_a_negative_drift_through_to_the_backend() -> None: worker = ScanWorker() service = _BatchService() worker._service = service # type: ignore[assignment] @@ -162,7 +168,9 @@ def test_batch_negative_drift_floors_the_offset_at_zero() -> None: worker.run_batch(req) - assert service.offsets == pytest.approx([0.3, 0.05, 0.0]) + # The floor belongs to the transport: a feeder clamps in its own backend + # (clamp_frame_offset_mm), and one that re-addresses an absolute frame may go negative. + assert service.offsets == pytest.approx([0.3, 0.05, -0.2]) def test_list_devices_failure_emits_the_empty_list_before_the_error() -> None: @@ -447,10 +455,11 @@ def __init__(self, *, session: _FakeRollSession | None = None, open_error: Excep self.session = session or _FakeRollSession(slots_seen=[]) self._open_error = open_error - def open_roll(self, device, *, dpi): + def open_roll(self, device, *, dpi, film_format=None, film_type="negative"): if self._open_error is not None: raise self._open_error self.dpi = dpi + self.film_format = film_format return self.session @@ -540,3 +549,42 @@ def preview(self, slots, *, cancel): assert errors == ["transport died"] assert session.closed is True + + +# ── a batch that names no frames ────────────────────────────────────────── + + +def test_a_batch_with_no_frames_scans_every_frame_on_the_film() -> None: + """A transport that measures the film has no frame range, so an empty request means all.""" + worker = ScanWorker() + service = _BatchService(detected=3) + worker._service = service + done: list[list] = [] + worker.batch_finished.connect(done.append) + + worker.run_batch(_batch_request(frames=(), film_format="66")) + + assert service.frames == [1, 2, 3] + assert service.detect_calls == [("66", "negative")] + assert len(done[0]) == 3 + + +def test_a_batch_with_no_frames_on_bare_film_is_an_error() -> None: + worker = ScanWorker() + worker._service = _BatchService(detected=0) + _finished, _cancelled, errors = _terminal_outcomes(worker) + + worker.run_batch(_batch_request(frames=())) + + assert errors and "No frames were detected" in errors[0] + + +def test_a_named_frame_list_never_measures_the_film() -> None: + worker = ScanWorker() + service = _BatchService() + worker._service = service + + worker.run_batch(_batch_request(frames=(2, 4))) + + assert service.frames == [2, 4] + assert service.detect_calls == [] diff --git a/tests/test_section_help_dialog.py b/tests/test_section_help_dialog.py index 4a70bc961..0bdbfc361 100644 --- a/tests/test_section_help_dialog.py +++ b/tests/test_section_help_dialog.py @@ -29,6 +29,7 @@ "toning", "retouch", "finish", + "scan_sane", ) @@ -77,7 +78,7 @@ def test_the_dialog_renders_markdown_for_every_panel(key: str) -> None: def test_the_info_button_is_opt_in() -> None: - """CollapsibleSection also backs the Export, Metadata and Scan sections, which have no guide.""" + """CollapsibleSection also backs the Export and Metadata sections, which have no guide.""" assert CollapsibleSection("Plain").info_btn is None assert CollapsibleSection("Analysis", info=True).info_btn is not None diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index 159920ad8..72a0a614f 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -499,3 +499,205 @@ def test_drift_past_the_pitch_is_clamped_and_flagged() -> None: assert dialog._offset_for_frame(1) == pytest.approx(8.0) assert dialog._offset_for_frame(14) == pytest.approx(36.83) # pitch - 1 mm, not 40.5 assert "14" in dialog.status.text() + + +# ── a transport that measures the strip ─────────────────────────────────── + + +def _discovery_device() -> ScannerDevice: + caps = ScannerCapabilities( + ir_channel=True, + supported_dpi=(500, 4000), + supported_depths=(16,), + sources=(ScanMode.NEGATIVE,), + max_area_mm=(24.0, 36.0), + can_eject=True, + roll_discovery=True, + film_formats=("135", "66"), + ) + return ScannerDevice(id="usb:1-3.2", vendor="Nikon", model="LS-50", capabilities=caps) + + +def test_a_measured_strip_opens_with_no_tiles() -> None: + dialog = StripPreviewDialog(_FakeController(), _discovery_device()) + assert dialog._tiles == {} + assert dialog.preview_all_btn.text().strip() == "Detect frames" + + +def test_tiles_grow_from_the_frames_the_measurement_found() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + + assert sorted(dialog._tiles) == [1, 2, 3] + assert dialog.selected_frames() == (1, 2, 3) + assert "3 frames detected" in dialog.status.text() + + +def test_the_selection_is_read_off_the_tiles_that_appeared() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + + dialog._tiles[2].checkbox.setChecked(False) + assert dialog.selected_frames() == (1, 3) + + +def test_a_measured_strip_asks_for_a_roll_worth_of_slots() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + + # The count is unknown until the strip is measured, so ask wide and keep what arrives. + assert controller.preview_reqs[0].slots[0] == 1 + assert len(controller.preview_reqs[0].slots) > 6 + + +def test_the_film_format_rides_along_with_the_request() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device(), film_format="66") + dialog._on_preview_all() + + assert controller.preview_reqs[0].film_format == "66" + + +def test_a_measured_strip_slides_the_frame_instead_of_losing_its_tail() -> None: + """The whole rect is re-addressed, so the raster keeps full length and just moves.""" + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1,), offset=0.0) + assert dialog._tile_coverage(dialog._tiles[1]) == (0.0, 1.0) + + dialog.offset_slider.setValue(180) # 18 mm, half a frame pitch + start, end = dialog._tile_coverage(dialog._tiles[1]) + assert start < 0.0 and end < 1.0 + assert end - start == pytest.approx(1.0), "a slid frame is still a whole frame" + + +def test_a_measured_strip_draws_the_boundary_it_slid_to() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1,), offset=0.0) + assert dialog._tiles[1].label._offset_indicators == [] + + dialog.offset_slider.setValue(180) + assert [edge for _frac, edge in dialog._tiles[1].label._offset_indicators] == ["right"] + # Nothing is cut off, so neither blackout warning applies. + assert not dialog.status.text().startswith(("Offset held", "Offset cuts")) + + +def test_a_measured_strip_can_slide_a_frame_backwards() -> None: + dialog = StripPreviewDialog(_FakeController(), _discovery_device()) + assert dialog.offset_slider.minimum() < 0 + + dialog.offset_slider.setValue(-180) + assert dialog.frame_offset() < 0 + + +def test_a_feeder_cannot_slide_backwards() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3)) + assert dialog.offset_slider.minimum() == 0 + + +def test_an_empty_strip_says_so() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.scan_roll_preview_finished.emit() + + assert "No frames were detected" in dialog.status.text() + assert dialog.ok_btn.isEnabled() is False + + +def test_a_measured_strip_offers_no_preview_resolution() -> None: + """Its previews are cut from the measurement's own pass; nothing chooses that resolution.""" + dialog = StripPreviewDialog(_FakeController(), _discovery_device()) + assert dialog.preview_dpi_combo.isVisibleTo(dialog) is False + assert dialog.preview_dpi_label.isVisibleTo(dialog) is False + + +def test_a_feeder_still_picks_its_preview_resolution() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(6)) + assert dialog.preview_dpi_combo.isVisibleTo(dialog) is True + + +def test_a_measured_strip_raster_is_not_rotated() -> None: + """nkscan delivers the feed axis along the columns already, unlike a coolscan3 raster.""" + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver(1, rgb=np.zeros((8, 24, 3), dtype=np.uint8)) + + pixmap = dialog._tiles[1].label._pixmap + assert (pixmap.width(), pixmap.height()) == (24, 8) + + +def test_a_feeder_raster_turns_landscape() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(3)) + dialog._on_preview_one(1) + controller.deliver(1, rgb=np.zeros((24, 8, 3), dtype=np.uint8)) + + pixmap = dialog._tiles[1].label._pixmap + assert (pixmap.width(), pixmap.height()) == (24, 8) + + +def test_a_measured_strip_crop_needs_no_axis_swap() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1,)) + dialog._tiles[1].label.set_window((0.1, 0.2, 0.5, 0.6)) + + assert dialog.frame_windows()[1] == (0.1, 0.2, 0.5, 0.6) + + +# ── picking the frames to scan ──────────────────────────────────────────── + + +def test_an_undetected_strip_says_what_to_press() -> None: + dialog = StripPreviewDialog(_FakeController(), _discovery_device()) + assert dialog._empty_hint.isVisibleTo(dialog) is True + assert "Detect frames" in dialog._empty_hint.text() + assert dialog.selection_label.text() == "none yet" + + +def test_the_hint_goes_once_the_strip_has_frames() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + + assert dialog._empty_hint.isVisibleTo(dialog) is False + + +def test_the_selection_is_counted_where_the_buttons_are() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + assert dialog.selection_label.text() == "3 of 3 frames" + + dialog._tiles[2].checkbox.setChecked(False) + assert dialog.selection_label.text() == "2 of 3 frames" + + +def test_none_and_all_move_the_whole_selection() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + + dialog.select_none_btn.click() + assert dialog.selected_frames() == () + # Nothing to scan, so neither button that would act on it is live. + assert dialog.ok_btn.isEnabled() is False and dialog.scan_btn.isEnabled() is False + + dialog.select_all_btn.click() + assert dialog.selected_frames() == (1, 2, 3) + assert dialog.ok_btn.isEnabled() is True diff --git a/uv.lock b/uv.lock index 9389fe9ee..80e6c8913 100644 --- a/uv.lock +++ b/uv.lock @@ -342,6 +342,9 @@ dependencies = [ camera = [ { name = "gphoto2", marker = "sys_platform != 'win32'" }, ] +nkscan = [ + { name = "nkscan" }, +] plustek = [ { name = "pyopticfilm" }, ] @@ -360,6 +363,9 @@ dev = [ { name = "ruff" }, { name = "ty" }, ] +nkscan = [ + { name = "nkscan" }, +] pieusb = [ { name = "pieusb" }, ] @@ -376,6 +382,7 @@ requires-dist = [ { name = "imagecodecs", specifier = "==2026.6.6" }, { name = "imageio", specifier = "==2.37.3" }, { name = "jinja2", specifier = "==3.1.6" }, + { name = "nkscan", marker = "extra == 'nkscan'", editable = "../nkscan" }, { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, { name = "opencv-python-headless", specifier = "==4.13.0.92" }, @@ -391,7 +398,7 @@ requires-dist = [ { name = "tifffile", specifier = "==2026.6.1" }, { name = "wgpu", specifier = "==0.31.1" }, ] -provides-extras = ["plustek", "sane", "camera"] +provides-extras = ["nkscan", "plustek", "sane", "camera"] [package.metadata.requires-dev] camera = [{ name = "gphoto2", marker = "sys_platform != 'win32'", specifier = ">=2.5" }] @@ -402,10 +409,15 @@ dev = [ { name = "ruff", specifier = "==0.14.10" }, { name = "ty", specifier = ">=0.0.26" }, ] +nkscan = [{ name = "nkscan", editable = "../nkscan" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] plustek = [{ name = "pyopticfilm", specifier = ">=1.2.0" }] sane = [{ name = "python-sane", specifier = ">=2.9" }] +[[package]] +name = "nkscan" +source = { editable = "../nkscan" } + [[package]] name = "numba" version = "0.65.1" From a49f47be42b3f1cb780bdeaef161aefeba1c99a5 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 23 Aug 2026 19:24:40 +0200 Subject: [PATCH 2/8] feat(scan): group the scanner panel, and stop a preview that is abandoned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving a preview dialog left the pass running: accept() and reject() never raise a close event, so the mixin's teardown ran only on the window close button, and the abandoned pass held the unit until the next scan came back "Scanner busy". Teardown moves to done(), cancels the request, and Cancel reads "Stop preview" while one runs — it abandons the pass and keeps the tiles in hand. Use and Scan darken meanwhile; pressing either mid-pass hit the same refusal. Both preview dialogs get a progress bar off scan_progress, the pattern the prescan dialog already had. A batch names the frame in its phase string, so the sidebar reads "Frame 3 of 5 — Scanning" rather than a bare percentage. preview_positive takes the film type: reversal stock is already a positive and only gets the level stretch, matching services.assets.thumbnails, which decides the same thing off the stored process mode. A slide previewed as a negative. A successful eject drops the frame selection and the per-frame crops — they describe the piece of film that just came out, and the next strip silently reused them. Offset and Drift stay: they register the transport. The panel's dozen rows become Film / Quality / Framing / Output, each header hiding with its whole group, and the line above Scan says what pressing it does: frame count, resolution, extra passes, estimated size. Falling out of that: a pass the device cannot run now hides instead of sitting greyed (disabled-with-a-reason is reserved for one the film blocks), and a saved DPI the device does not offer snaps to the nearest stop it does — the box showed the unreachable number while the scan used the first entry. A measured strip's Offset spans ±2.5 mm, the same as Drift. It came from the 38 mm hardware blackout distance, which is a whole frame away and addresses the neighbour rather than correcting a boundary. Its saved value also stopped being floored at 0, which lost a negative offset on reopen. The DPI box takes a validator bounded by the device range, three tooltips lose their British spelling, and database_dialog's byte formatter moves to kernel.system.text as human_bytes. --- docs/USER_GUIDE.md | 6 +- negpy/desktop/view/sidebar/scan.py | 225 ++++++++++++++---- negpy/desktop/view/widgets/database_dialog.py | 12 +- .../view/widgets/quick_scan_preview_dialog.py | 45 +++- .../view/widgets/scan_preview_common.py | 61 ++++- .../view/widgets/strip_preview_dialog.py | 55 ++++- negpy/desktop/workers/scan_worker.py | 12 +- negpy/kernel/system/text.py | 10 + tests/test_quick_scan_preview_dialog.py | 52 ++++ tests/test_scan_sidebar.py | 154 +++++++++++- tests/test_scan_worker.py | 17 ++ tests/test_strip_preview_dialog.py | 136 ++++++++++- 12 files changed, 677 insertions(+), 108 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a1772ee32..2efd5d071 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -879,7 +879,7 @@ When you set capture gear, it is written to standard EXIF, and the digitizing ri Capture film directly into NegPy. Two collapsible sections: -* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Common controls are device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. +* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them — **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and **Output** (format, folder, filename template) — and a group's header disappears with the whole group when the device has nothing in it. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. **pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. @@ -887,7 +887,7 @@ Capture film directly into NegPy. Two collapsible sections: 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). - **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, there is no frame range to set, and no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. The selection sticks until you change it, and the Scan panel lists it under the strip button. Four controls appear only on this backend: + **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, there is no frame range to set, and no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** (±2.5 mm, either way, since the frame is re-addressed rather than fed past) and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. The Scan panel lists the selection under the strip button, and ejecting the film clears it — the frames and their crops describe the piece of film that just came out. **Offset** and **Drift** survive an eject, because they register the transport rather than one strip. Four controls appear only on this backend: * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. * **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time. @@ -900,6 +900,8 @@ Capture film directly into NegPy. Two collapsible sections: The driver is the optional **nkscan** package: install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module. **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. + + A preview holds the scanner for the whole pass, so while one runs a progress bar tracks it, **Cancel** reads **Stop preview** — it abandons the pass and keeps the tiles already in hand — and **Use** and **Scan** stay dark until the pass ends. Previews read the way the **Film** setting says: negative stock is inverted, Slide and Kodachrome are not. * **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 Trichrome 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 76ae06b5f..7289a4127 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -1,5 +1,6 @@ import qtawesome as qta from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtGui import QIntValidator from PyQt6.QtWidgets import ( QCheckBox, QComboBox, @@ -17,9 +18,9 @@ QWidget, ) -from negpy.kernel.system.text import count_of +from negpy.kernel.system.text import count_of, human_bytes from negpy.desktop.view.sidebar.base import install_wheel_guards -from negpy.desktop.view.styles.templates import hint_label +from negpy.desktop.view.styles.templates import hint_label, section_subheader from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice from negpy.infrastructure.scanners.params import FILM_TYPES, FilmType, film_passes_infrared @@ -35,6 +36,26 @@ def _reaches_a_strip(caps: ScannerCapabilities) -> bool: return caps.adapter_frame_capacity is not None or caps.roll_discovery +def estimated_frame_bytes( + caps: ScannerCapabilities, + dpi: int, + depth: int, + *, + capture_ir: bool = False, + window: tuple[float, float, float, float] | None = None, +) -> int: + """Uncompressed size of one scanned frame, for the summary the operator reads before + committing a batch. The frame area comes from the adapter, the window shrinks it, and + an IR pass adds a fourth plane.""" + width_mm, height_mm = caps.max_area_mm if caps.max_area_mm else (36.0, 24.0) + pixels = (width_mm / 25.4 * dpi) * (height_mm / 25.4 * dpi) + if window and len(window) == 4: + x1, y1, x2, y2 = window + pixels *= max(0.0, x2 - x1) * max(0.0, y2 - y1) + planes = 4 if capture_ir else 3 + return int(pixels * planes * (2 if depth > 8 else 1)) + + class ScanSidebar(QWidget): """Scanner control panel — replaces the originally planned modal ScanDialog.""" @@ -138,9 +159,36 @@ def _init_ui(self) -> None: layout.addWidget(self.frame_label) # ── SETTINGS ──────────────────────────────────────── + # Four labelled groups in one form, in the order the operator decides them: what is + # on the film, how to read it, which frames, where the files go. Every row keeps its + # own visibility gate; a header hides when its whole group is gated away. self.form = QFormLayout() self.form.setSpacing(6) + self.film_header = section_subheader("Film") + self.form.addRow(self.film_header) + + # What is on the film: it decides which way the frame boundaries read on a strip, and + # whether an IR pass has anything to see. + self.film_type_combo = QComboBox() + self.film_type_combo.setToolTip("What is on the film. Silver and Kodachrome block infrared") + self.film_type_label = QLabel("Film") + self.form.addRow(self.film_type_label, self.film_type_combo) + self.film_type_label.setVisible(False) + self.film_type_combo.setVisible(False) + + # Frame length, for a transport that measures the strip and cannot infer it. + self.format_combo = QComboBox() + self.format_combo.setToolTip("Frame length on the loaded film; Auto where the holder fixes it") + # Not "Format": the output file format owns that label further down the panel. + self.film_format_label = QLabel("Film format") + self.form.addRow(self.film_format_label, self.format_combo) + self.film_format_label.setVisible(False) + self.format_combo.setVisible(False) + + self.quality_header = section_subheader("Quality") + self.form.addRow(self.quality_header) + self.dpi_combo = QComboBox() self.dpi_combo.setToolTip("Resolution (DPI)") self.dpi_combo.setEditable(True) @@ -159,29 +207,17 @@ def _init_ui(self) -> None: self.ir_check.setToolTip("Scan a separate infrared channel for dust detection") self.form.addRow(self.ir_check) - self.me_check = QCheckBox("Multi-exposure") - self.me_check.setToolTip("Merge short and long color passes for more highlight and shadow detail. Takes longer.") - self.form.addRow(self.me_check) - - # Spanning rows (no label column) so the checkboxes sit at the left edge. - self.autofocus_check = QCheckBox("Autofocus") - self.autofocus_check.setChecked(True) - self.autofocus_check.setToolTip("Autofocus before scanning (film is rarely perfectly flat)") - self.form.addRow(self.autofocus_check) - self.autofocus_check.setVisible(False) - - self.ae_check = QCheckBox("Auto-exposure") - self.ae_check.setToolTip("Meter exposure in hardware before the scan") - self.form.addRow(self.ae_check) - self.ae_check.setVisible(False) - self.clean_check = QCheckBox("ICE") self.clean_check.setToolTip( - "Remove dust and scratches with the infrared channel while scanning.\nBaked into the file — colour film only." + "Remove dust and scratches with the infrared channel while scanning.\nBaked into the file — color film only." ) self.form.addRow(self.clean_check) self.clean_check.setVisible(False) + self.me_check = QCheckBox("Multi-exposure") + self.me_check.setToolTip("Merge short and long color passes for more highlight and shadow detail. Takes longer.") + self.form.addRow(self.me_check) + self.superfine_check = QCheckBox("Superfine") self.superfine_check.setToolTip("Read one line per pass: slower, and free of line registration") self.form.addRow(self.superfine_check) @@ -195,23 +231,16 @@ def _init_ui(self) -> None: self.samples_label.setVisible(False) self.samples_combo.setVisible(False) - # What is on the film: it decides which way the frame boundaries read on a strip, and - # whether an IR pass has anything to see. - self.film_type_combo = QComboBox() - self.film_type_combo.setToolTip("What is on the film. Silver and Kodachrome block infrared") - self.film_type_label = QLabel("Film") - self.form.addRow(self.film_type_label, self.film_type_combo) - self.film_type_label.setVisible(False) - self.film_type_combo.setVisible(False) + self.autofocus_check = QCheckBox("Autofocus") + self.autofocus_check.setChecked(True) + self.autofocus_check.setToolTip("Autofocus before scanning (film is rarely perfectly flat)") + self.form.addRow(self.autofocus_check) + self.autofocus_check.setVisible(False) - # Frame length, for a transport that measures the strip and cannot infer it. - self.format_combo = QComboBox() - self.format_combo.setToolTip("Frame length on the loaded film; Auto where the holder fixes it") - # Not "Format": the output file format owns that label further down the panel. - self.film_format_label = QLabel("Film format") - self.form.addRow(self.film_format_label, self.format_combo) - self.film_format_label.setVisible(False) - self.format_combo.setVisible(False) + self.ae_check = QCheckBox("Auto-exposure") + self.ae_check.setToolTip("Meter exposure in hardware before the scan") + self.form.addRow(self.ae_check) + self.ae_check.setVisible(False) # Scan exposure time (SANE `scan-exposure-time`), shown only when the device reports a # usable range. The slider is in microseconds and the label shows a readable value. @@ -231,6 +260,9 @@ def _init_ui(self) -> None: self.exposure_label.setVisible(False) self.exposure_row_widget.setVisible(False) + self.framing_header = section_subheader("Framing") + self.form.addRow(self.framing_header) + # Frame range, for roll and strip feeders only. Shown when a live capacity is known. self.frame_range_widget = QWidget() frame_row = QHBoxLayout(self.frame_range_widget) @@ -288,6 +320,9 @@ def _init_ui(self) -> None: self.prescan_widget.setVisible(False) self.prescan_status.setVisible(False) + self.output_header = section_subheader("Output") + self.form.addRow(self.output_header) + self.fmt_combo = QComboBox() self.fmt_combo.addItems(["TIFF", "DNG"]) self.fmt_combo.setToolTip("Output file format") @@ -322,7 +357,10 @@ def _init_ui(self) -> None: self.status_label = hint_label("") layout.addWidget(self.status_label) - # ── SCAN BUTTON ───────────────────────────────────── + # ── SUMMARY + SCAN BUTTON ─────────────────────────── + self.summary_label = hint_label("") + layout.addWidget(self.summary_label) + self.scan_btn = QPushButton(" Scan") self.scan_btn.setObjectName("scan_btn") self.scan_btn.setFixedHeight(40) @@ -466,6 +504,8 @@ def _update_device_caps(self) -> None: self.depth_combo.setEnabled(False) self.depth_combo.setVisible(False) self.depth_label.setVisible(False) + self.ir_check.setVisible(False) + self.me_check.setVisible(False) self.ir_check.setEnabled(False) self.me_check.setEnabled(False) self.eject_btn.setVisible(False) @@ -497,6 +537,8 @@ def _update_device_caps(self) -> None: self._caps_film_formats = () self._caps_film_types = () self._device_ir = False + self._sync_group_headers() + self._update_summary() return caps = device.capabilities @@ -517,6 +559,8 @@ def _update_device_caps(self) -> None: self.scan_btn.setEnabled(True) self._populate_form(caps) + self._sync_group_headers() + self._update_summary() def _populate_form(self, caps: ScannerCapabilities) -> None: self.dpi_combo.blockSignals(True) @@ -532,12 +576,20 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: if caps.supported_dpi: for d in caps.supported_dpi: self.dpi_combo.addItem(str(d), d) - if self._settings.dpi: + if self._settings.dpi and caps.supported_dpi: idx = self.dpi_combo.findData(self._settings.dpi) + if idx < 0: + # A saved resolution this device does not offer: show the nearest one it does, + # rather than a number the scan silently replaces. + nearest = min(caps.supported_dpi, key=lambda d: abs(d - self._settings.dpi)) + idx = self.dpi_combo.findData(nearest) if idx >= 0: self.dpi_combo.setCurrentIndex(idx) - else: - self.dpi_combo.setCurrentText(str(self._settings.dpi)) + # The combo is editable because the stops are a ladder, not the device's limits. A + # validator keeps a typo off the scan: unparsable text silently fell back to a default. + editor = self.dpi_combo.lineEdit() + if editor is not None and caps.supported_dpi: + editor.setValidator(QIntValidator(min(caps.supported_dpi), max(caps.supported_dpi), self.dpi_combo)) # Depth, shown only when the device offers more than one bit depth. Default to the # deepest supported when the saved value is absent: a saved 16 does not exist on a @@ -557,6 +609,7 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: # IR self._device_ir = bool(caps.ir_channel) + self.ir_check.setVisible(self._device_ir) self.ir_check.setEnabled(caps.ir_channel) if caps.ir_channel: self.ir_check.setChecked(self._settings.capture_ir) @@ -567,10 +620,11 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self._apply_film_type_to_ir() # Multi-exposure (Plustek SE only today) + self.me_check.setVisible(bool(caps.multi_exposure)) self.me_check.setEnabled(caps.multi_exposure) if caps.multi_exposure: self.me_check.setChecked(self._settings.multi_exposure) - self.me_check.setToolTip("Merge short and long colour passes for more highlight and shadow detail. Takes longer.") + self.me_check.setToolTip("Merge short and long color passes for more highlight and shadow detail. Takes longer.") else: self.me_check.setChecked(False) self.me_check.setToolTip("Multi-exposure not supported by this device") @@ -811,7 +865,13 @@ def _on_set_scan_window(self) -> None: from negpy.desktop.view.widgets.quick_scan_preview_dialog import QuickScanPreviewDialog - dialog = QuickScanPreviewDialog(self.controller, device, initial_window=self._settings.scan_window, parent=self) + dialog = QuickScanPreviewDialog( + self.controller, + device, + initial_window=self._settings.scan_window, + film_type=self._film_type(), + parent=self, + ) if dialog.exec(): self.settings = replace(self._settings, scan_window=dialog.window()) self._update_scan_window_status() @@ -882,7 +942,7 @@ def _update_scan_window_status(self) -> None: device = self._current_device() if device is not None and device.capabilities.roll_discovery: # Nothing picked yet, and a measured strip has no frame range to fall back on. - self.scan_window_status.setText("Whole strip — Preview strip to pick frames instead") + self.scan_window_status.setText("Whole strip — pick frames in the preview") return area = scan_window_to_area(self._settings.scan_window, device.capabilities.max_area_mm) if device else None if area is None: @@ -891,6 +951,61 @@ def _update_scan_window_status(self) -> None: tl_x, tl_y, br_x, br_y = area self.scan_window_status.setText(f"{br_x - tl_x:.1f} × {br_y - tl_y:.1f} mm{offset_txt}") + def _sync_group_headers(self) -> None: + """A group header only earns its space when the group has a visible row.""" + self.film_header.setVisible(self.film_type_combo.isVisibleTo(self) or self.format_combo.isVisibleTo(self)) + self.framing_header.setVisible( + self.frame_range_widget.isVisibleTo(self) or self.scan_window_widget.isVisibleTo(self) or self.prescan_widget.isVisibleTo(self) + ) + + def _dpi(self) -> int: + """The resolution the next scan runs at: the picked stop, else the typed value, + else the finest the device offers.""" + device = self._current_device() + supported = device.capabilities.supported_dpi if device else () + fallback = max(supported) if supported else 3600 + try: + return int(self.dpi_combo.currentData() or self.dpi_combo.currentText()) + except (ValueError, TypeError): + return fallback + + def _update_summary(self) -> None: + """One line saying what pressing Scan will do: how much film, at what resolution, + with which passes, and how much disk it takes.""" + from negpy.infrastructure.scanners.settings import resolve_batch_selection + + device = self._current_device() + if device is None: + self.summary_label.setText("") + return + caps = device.capabilities + frames, windows, base_window = resolve_batch_selection( + self._settings, + self.frame_from_spin.value(), + self.frame_to_spin.value(), + whole_strip=caps.roll_discovery, + ) + if not _reaches_a_strip(caps): + frames = (1,) + base_window = self._settings.scan_window + dpi = self._dpi() + depth = int(self.depth_combo.currentData() or 16) + capture_ir = self.ir_check.isEnabled() and self.ir_check.isChecked() + # An unmeasured strip has no frame count yet, so the size is quoted per frame. + per_frame = [ + estimated_frame_bytes(caps, dpi, depth, capture_ir=capture_ir, window=windows.get(f, base_window)) for f in frames + ] or [estimated_frame_bytes(caps, dpi, depth, capture_ir=capture_ir, window=base_window)] + size = f"~{human_bytes(sum(per_frame))}" if frames else f"~{human_bytes(per_frame[0])}/frame" + passes = [name for name, on in (("IR", capture_ir), ("ICE", self._caps_clean and self.clean_check.isChecked())) if on] + if self._caps_superfine and self.superfine_check.isChecked(): + passes.append("Superfine") + if self._samples() > 1: + passes.append(f"{self._samples()}× sampled") + if self.me_check.isEnabled() and self.me_check.isChecked(): + passes.append("Multi-exposure") + parts = [count_of(len(frames), "frame") if frames else "Whole strip", f"{dpi} dpi", *passes, size] + self.summary_label.setText(" · ".join(parts)) + def _on_browse(self) -> None: folder = QFileDialog.getExistingDirectory(self, "Select Output Folder") if folder: @@ -919,7 +1034,7 @@ def _on_scan(self) -> None: from negpy.infrastructure.scanners.params import ScanParams from negpy.infrastructure.scanners.settings import resolve_batch_selection - dpi = int(self.dpi_combo.currentData() or self.dpi_combo.currentText() or 3600) + dpi = self._dpi() depth = int(self.depth_combo.currentData() or 16) capture_ir = self.ir_check.isEnabled() and self.ir_check.isChecked() multi_exposure = self.me_check.isEnabled() and self.me_check.isChecked() @@ -1029,9 +1144,21 @@ def _on_scan_error(self, msg: str) -> None: @pyqtSlot(bool) def _on_ejected(self, triggered: bool) -> None: + from dataclasses import replace + device = self._current_device() self.eject_btn.setEnabled(bool(device and device.capabilities.can_eject) and not self._scanning) - self.status_label.setText("Film ejected" if triggered else "This device has no eject control") + if not triggered: + self.status_label.setText("This device has no eject control") + return + # Frames and their crops describe the piece of film that just came out; the next strip + # is a different one, and silently reusing them scans the wrong frames. + stale = bool(self._settings.selected_frames or self._settings.frame_windows) + if stale: + self.settings = replace(self._settings, selected_frames=(), frame_windows={}) + self._update_scan_window_status() + self._update_summary() + self.status_label.setText("Film ejected — frame selection cleared" if stale else "Film ejected") @pyqtSlot(str) def _on_eject_error(self, msg: str) -> None: @@ -1058,14 +1185,9 @@ def set_scanning(self, active: bool) -> None: self.prescan_btn.setEnabled(True) def _update_settings_from_ui(self) -> None: - dpi_text = self.dpi_combo.currentData() or self.dpi_combo.currentText() - depth_text = self.depth_combo.currentData() or 16 - try: - dpi = int(dpi_text) - except (ValueError, TypeError): - dpi = 3600 + dpi = self._dpi() try: - depth = int(depth_text) + depth = int(self.depth_combo.currentData() or 16) except (ValueError, TypeError): depth = 16 @@ -1096,3 +1218,4 @@ def _update_settings_from_ui(self) -> None: output_format=self.fmt_combo.currentText(), filename_pattern=self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}', ) + self._update_summary() diff --git a/negpy/desktop/view/widgets/database_dialog.py b/negpy/desktop/view/widgets/database_dialog.py index 7ceda1cca..c4b63e8aa 100644 --- a/negpy/desktop/view/widgets/database_dialog.py +++ b/negpy/desktop/view/widgets/database_dialog.py @@ -14,6 +14,7 @@ ) from negpy.desktop.view.styles.theme import THEME +from negpy.kernel.system.text import human_bytes # (stat key, display label). The order is the display order, and a separator sits between # the per-image group and the reusable-tooling group. @@ -31,15 +32,6 @@ ) -def _human_bytes(n: int) -> str: - size = float(n) - for unit in ("B", "KB", "MB", "GB"): - if size < 1024 or unit == "GB": - return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" - size /= 1024 - return f"{size:.1f} GB" - - class DatabaseDialog(QDialog): """View what the app has stored in SQLite and clear it. @@ -175,7 +167,7 @@ def _refresh(self) -> None: for key, lbl in self._value_labels.items(): lbl.setText(f"{stats.get(key, 0):,}") db_bytes = stats.get("edits_db_bytes", 0) + stats.get("settings_db_bytes", 0) - self._size_label.setText(f"On disk: {_human_bytes(db_bytes)} databases + {_human_bytes(thumb_bytes)} thumbnails") + self._size_label.setText(f"On disk: {human_bytes(db_bytes)} databases + {human_bytes(thumb_bytes)} thumbnails") self._update_enabled(stats) def _update_enabled(self, stats: dict) -> None: diff --git a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py index 8be5445c2..5acad9aa1 100644 --- a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py +++ b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py @@ -8,7 +8,7 @@ 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 PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout from negpy.desktop.converters import ImageConverter from negpy.desktop.view.styles.theme import THEME @@ -24,10 +24,11 @@ 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: + def __init__(self, controller, device: ScannerDevice, initial_window=None, film_type: str = "negative", parent=None) -> None: super().__init__(parent) self._controller = controller self._device = device + self._film_type = film_type self._caps = device.capabilities self._previewing = False self._scan_now = False # set when the user chooses "Scan" over "Use" @@ -71,15 +72,21 @@ def __init__(self, controller, device: ScannerDevice, initial_window=None, paren self.status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") layout.addWidget(self.status) + self.preview_progress = QProgressBar() + self.preview_progress.setRange(0, 100) + self.preview_progress.setValue(0) + self.preview_progress.setVisible(False) + layout.addWidget(self.preview_progress) + 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.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self._on_cancel_clicked) + btns.addWidget(self.cancel_btn) self.ok_btn = QPushButton("Use") self.ok_btn.setDefault(True) self.ok_btn.clicked.connect(self.accept) @@ -107,6 +114,24 @@ def _on_scan_clicked(self) -> None: self._scan_now = True self.accept() + def _on_cancel_clicked(self) -> None: + """Stop the pass in flight, or leave when there is none.""" + if self._previewing: + self.stop_preview() + return + self.reject() + + def _set_previewing(self, busy: bool) -> None: + self.preview_btn.setEnabled(not busy) + # Committing mid-pass would hand the scan a unit the preview still holds. + self.ok_btn.setEnabled(not busy) + self.scan_btn.setEnabled(not busy) + self.cancel_btn.setText("Stop preview" if busy else "Cancel") + self.preview_progress.setVisible(busy) + if busy: + self.preview_progress.setValue(0) + self.preview_progress.setFormat("Previewing… %p%") + def _preview_dpi(self) -> int: return int(self.preview_dpi_combo.currentData() or _PREVIEW_FALLBACK_DPI) @@ -125,7 +150,7 @@ def _on_preview(self) -> None: self.status.setText(f"Scanner busy — {e}") return self._previewing = True - self.preview_btn.setEnabled(False) + self._set_previewing(True) self.status.setText("Previewing…") @pyqtSlot(object) @@ -136,7 +161,7 @@ def _on_preview_ready(self, preview) -> None: self.status.setText(f"Preview failed: {preview.error}") return try: - positive = preview_positive(preview.rgb) + positive = preview_positive(preview.rgb, self._film_type) pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)) except Exception as e: self.status.setText(f"Could not display preview: {e}") @@ -146,7 +171,7 @@ def _on_preview_ready(self, preview) -> None: @pyqtSlot() def _on_preview_finished(self) -> None: self._previewing = False - self.preview_btn.setEnabled(True) + self._set_previewing(False) if not self.status.text().startswith("Preview failed") and not self.status.text().startswith("Could not display"): self.status.clear() @@ -155,7 +180,7 @@ def _on_error(self, msg) -> None: if not self._previewing: return self._previewing = False - self.preview_btn.setEnabled(True) + self._set_previewing(False) self.status.setText(f"Preview failed: {msg}") @pyqtSlot() @@ -163,5 +188,5 @@ def _on_cancelled(self) -> None: if not self._previewing: return self._previewing = False - self.preview_btn.setEnabled(True) + self._set_previewing(False) 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 index 7b7c6fd5a..c552102e8 100644 --- a/negpy/desktop/view/widgets/scan_preview_common.py +++ b/negpy/desktop/view/widgets/scan_preview_common.py @@ -2,20 +2,25 @@ import numpy as np +from negpy.infrastructure.scanners.params import FilmType, film_reads_positive + 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. + """Wires a dialog's preview handlers onto its controller, drives its progress bar, + and stops the scanner when the dialog goes away. Both preview dialogs (whole-strip and single-shot) drive the same - RollPreviewRequest/roll-preview signal pair and differ only in what their four + RollPreviewRequest/roll-preview signal pair and differ only in what their 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. + A subclass must set ``self._controller``, ``self._previewing`` and + ``self.preview_progress`` before calling ``_connect_preview_signals()``, and + implement the four result handlers. + + Leaving a dialog mid-preview cancels the request: the transport holds the unit for + the whole pass, so an abandoned preview leaves the next scan refused as busy. """ def _preview_signal_pairs(self): @@ -23,6 +28,7 @@ def _preview_signal_pairs(self): return ( (c.scan_roll_preview_ready, self._on_preview_ready), (c.scan_roll_preview_finished, self._on_preview_finished), + (c.scan_progress, self._on_preview_progress), (c.scan_error, self._on_error), (c.scan_cancelled, self._on_cancelled), ) @@ -31,28 +37,59 @@ def _connect_preview_signals(self) -> None: for signal, slot in self._preview_signal_pairs(): signal.connect(slot) - def closeEvent(self, ev) -> None: + def _on_preview_progress(self, fraction: float, phase: str = "Scanning") -> None: + if not self._previewing: + return + self.preview_progress.setVisible(True) + self.preview_progress.setFormat(f"{phase}… %p%") + self.preview_progress.setValue(int(max(0.0, min(1.0, float(fraction))) * 100)) + + def stop_preview(self) -> None: + """Ask the transport to abandon the pass in flight. The worker answers with + `cancelled`, which the dialog's own handler turns into idle state.""" + if self._previewing: + self._controller.cancel_scan() + + def _teardown_preview(self) -> None: + self.stop_preview() + self._previewing = False for signal, slot in self._preview_signal_pairs(): try: signal.disconnect(slot) except (TypeError, RuntimeError): pass + + def done(self, result: int) -> None: + # accept() and reject() both route through done() and never raise a close event, + # so this is the only hook that catches Use / Scan / Cancel / Esc. + self._teardown_preview() + super().done(result) + + def closeEvent(self, ev) -> None: + self._teardown_preview() super().closeEvent(ev) -def preview_positive(rgb: np.ndarray) -> np.ndarray: - """Cheap negative->positive for a scan preview: per-channel invert + auto-level. +def preview_positive(rgb: np.ndarray, film_type: str = FilmType.NEGATIVE.value) -> np.ndarray: + """Cheap scan preview: per-channel auto-level, inverted for negative stock. 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. + orange mask. Each channel is stretched between its 1st/99th percentiles, which + neutralizes the base cast; reversal stock is already a positive and only gets + the stretch. Mirrors services.assets.thumbnails.preview_positive, which decides + the same thing off the frame's stored process mode. """ a = rgb.astype(np.float32) if a.ndim == 2: a = a[:, :, None] + positive = film_reads_positive(film_type) 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 + if hi <= lo: + out[..., c] = 0.0 + continue + scaled = (ch - lo) if positive else (hi - ch) + out[..., c] = np.clip(scaled / (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 cd4815860..3b5df0dc7 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -16,6 +16,7 @@ QGridLayout, QHBoxLayout, QLabel, + QProgressBar, QPushButton, QScrollArea, QSlider, @@ -39,6 +40,7 @@ # (pitch - frame) discards that much picture off the frame tail. _FRAME_LEN_MM = 36.0 _PREVIEW_FALLBACK_DPI = 500 # only when the device reports no DPI list at all +_MAX_MEASURED_OFFSET_TENTHS = 25 # ±2.5 mm, in the slider's tenths of a millimetre _TILE_H = 140 # constant tile height; width follows the device aspect _TILES_PER_ROW = 6 # one SA-21 strip per row; roll adapters (up to 40 frames) wrap below # A transport that measures the strip reports its frame count only as previews arrive, so ask @@ -174,13 +176,19 @@ def __init__( top = QHBoxLayout() top.addWidget(QLabel("Offset")) self.offset_slider = _ResetSlider() - # A measured strip re-addresses the frame, so the offset may run a whole frame either - # way; a feeder cannot back up and blacks out one pitch past the frame start. - self.offset_slider.setRange(-380 if self._discovers else 0, 380 if self._discovers else 100) + # A measured strip re-addresses the frame, so its offset may go either way; a feeder + # cannot back up and blacks out one pitch past the frame start. Both are a correction to + # a boundary, not a way to reach the next frame, so a measured strip gets the same + # ±2.5 mm span as Drift. + self.offset_slider.setRange( + -_MAX_MEASURED_OFFSET_TENTHS if self._discovers else 0, _MAX_MEASURED_OFFSET_TENTHS if self._discovers else 100 + ) self.offset_slider.setSingleStep(1) self.offset_slider.setPageStep(5) self.offset_slider.setFixedWidth(160) - self.offset_slider.setValue(int(round(max(0.0, float(initial_offset)) * 10))) + # Not floored at 0: a measured strip's saved offset may be negative, and the range + # clamps it either way. + self.offset_slider.setValue(int(round(float(initial_offset) * 10))) self.offset_slider.setToolTip( "Feed-axis offset applied to every frame" if self._discovers @@ -252,6 +260,12 @@ def __init__( self.status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") layout.addWidget(self.status) + self.preview_progress = QProgressBar() + self.preview_progress.setRange(0, 100) + self.preview_progress.setValue(0) + self.preview_progress.setVisible(False) + layout.addWidget(self.preview_progress) + btns = QHBoxLayout() self.select_all_btn = QPushButton("All") self.select_all_btn.setFixedWidth(48) @@ -274,9 +288,9 @@ def __init__( self.clear_btn.clicked.connect(self._on_clear_all) btns.addWidget(self.clear_btn) btns.addStretch() - cancel_btn = QPushButton("Cancel") - cancel_btn.clicked.connect(self.reject) - btns.addWidget(cancel_btn) + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self._on_cancel_clicked) + btns.addWidget(self.cancel_btn) self.ok_btn = QPushButton("Use") self.ok_btn.setDefault(True) self.ok_btn.clicked.connect(self.accept) @@ -403,10 +417,18 @@ def _on_scan_clicked(self) -> None: self._scan_now = True self.accept() + def _on_cancel_clicked(self) -> None: + """Stop the pass in flight, or leave when there is none: the tiles already in + hand are worth keeping, so a stopped preview does not close the dialog.""" + if self._previewing: + self.stop_preview() + return + self.reject() + def _update_ok_enabled(self, *_args) -> None: picked = sum(1 for t in self._tiles.values() if t.checkbox.isChecked()) - self.ok_btn.setEnabled(bool(picked)) - self.scan_btn.setEnabled(bool(picked)) + self.ok_btn.setEnabled(bool(picked) and not self._previewing) + self.scan_btn.setEnabled(bool(picked) and not self._previewing) self.selection_label.setText(f"{picked} of {count_of(len(self._tiles), 'frame')}" if self._tiles else "none yet") def _set_all_checked(self, checked: bool) -> None: @@ -421,6 +443,13 @@ def _set_previewing(self, busy: bool) -> None: self.preview_all_btn.setEnabled(not busy) for tile in self._tiles.values(): tile.preview_btn.setEnabled(not busy) + # Committing mid-pass would hand the batch a unit the preview still holds. + self.cancel_btn.setText("Stop preview" if busy else "Cancel") + self.preview_progress.setVisible(busy) + if busy: + self.preview_progress.setValue(0) + self.preview_progress.setFormat("Previewing… %p%") + self._update_ok_enabled() def _on_offset_changed(self, _value: int) -> None: self.offset_label.setText(f"{self.frame_offset():.1f} mm") @@ -528,7 +557,11 @@ def _start_preview(self, slots: tuple[int, ...]) -> None: return self._previewing = True self._set_previewing(True) - self.status.setText(f"Previewing {'frame ' + str(slots[0]) if len(slots) == 1 else f'{len(slots)} frames'}…") + if self._discovers and len(slots) > 1: + # The slot count asked for is a roll's worth, not what the strip holds. + self.status.setText("Measuring the strip…") + else: + self.status.setText(f"Previewing {'frame ' + str(slots[0]) if len(slots) == 1 else f'{len(slots)} frames'}…") @pyqtSlot(object) def _on_preview_ready(self, preview) -> None: @@ -544,7 +577,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, self._film_type) pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)) if self._rotation: pixmap = pixmap.transformed(QTransform().rotate(self._rotation)) diff --git a/negpy/desktop/workers/scan_worker.py b/negpy/desktop/workers/scan_worker.py index 42ef2f81d..52ec8a144 100644 --- a/negpy/desktop/workers/scan_worker.py +++ b/negpy/desktop/workers/scan_worker.py @@ -39,7 +39,7 @@ class RollPreviewRequest: @dataclass(frozen=True) class PrescanRequest: - """One low-DPI full-window colour preview for crop setup (no file write).""" + """One low-DPI full-window color preview for crop setup (no file write).""" device_id: str prescan_dpi: int @@ -227,8 +227,12 @@ def run_batch(self, req: BatchRequest) -> None: frame_params = dataclasses.replace(req.params, frame=frame, window=window, frame_offset_mm=offset) base = index / total - def _progress(fraction: float, phase: str = "Scanning", _base: float = base) -> None: - self.progress.emit(_base + min(1.0, max(0.0, fraction)) / total, phase) + # The frame's position in the run rides on the phase string: a batch's global + # percentage alone says nothing about how much film is left. + position = f"Frame {index + 1} of {total}" + + def _progress(fraction: float, phase: str = "Scanning", _base: float = base, _at: str = position) -> None: + self.progress.emit(_base + min(1.0, max(0.0, fraction)) / total, f"{_at} — {phase}") try: result = service.run_scan(req.device_id, frame_params, _progress, self._cancel_event) @@ -329,7 +333,7 @@ def run_roll_preview(self, req: RollPreviewRequest) -> None: @pyqtSlot(PrescanRequest) def run_prescan(self, req: PrescanRequest) -> None: - """Full-window colour preview at prescan_dpi; emit RGB without writing a file.""" + """Full-window color preview at prescan_dpi; emit RGB without writing a file.""" if req.prescan_dpi <= 0: self.prescan_error.emit("Device does not support Prescan") return diff --git a/negpy/kernel/system/text.py b/negpy/kernel/system/text.py index 265c36fbc..c2a97482d 100644 --- a/negpy/kernel/system/text.py +++ b/negpy/kernel/system/text.py @@ -18,3 +18,13 @@ def count_of(count: int, singular: str, plural_form: str = "") -> str: to forget. """ return f"{count} {plural(count, singular, plural_form)}" + + +def human_bytes(n: float) -> str: + """A byte count in the largest unit that keeps it under 1024.""" + size = float(n) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" diff --git a/tests/test_quick_scan_preview_dialog.py b/tests/test_quick_scan_preview_dialog.py index aeb668cce..6c11b1b2d 100644 --- a/tests/test_quick_scan_preview_dialog.py +++ b/tests/test_quick_scan_preview_dialog.py @@ -42,14 +42,19 @@ def _device() -> ScannerDevice: class _FakeController(QObject): scan_roll_preview_ready = pyqtSignal(object) scan_roll_preview_finished = pyqtSignal() + scan_progress = pyqtSignal(float, str) scan_error = pyqtSignal(str) scan_cancelled = pyqtSignal() def __init__(self, *, raise_on_preview: bool = False) -> None: super().__init__() self.preview_reqs: list = [] + self.cancels = 0 self._raise = raise_on_preview + def cancel_scan(self) -> None: + self.cancels += 1 + def start_roll_preview(self, req) -> None: if self._raise: raise RuntimeError("A scanner request is already active") @@ -156,3 +161,50 @@ def test_close_disconnects_preview_signals_without_error(): # Disconnected: delivering a result now must not raise or touch the dialog. controller.deliver() + + +def test_leaving_mid_preview_stops_the_transport(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + dialog._on_preview() + + dialog.accept() + + assert controller.cancels == 1 + + +def test_stopping_a_running_preview_keeps_the_dialog_open(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + dialog._on_preview() + assert dialog.cancel_btn.text() == "Stop preview" + assert dialog.ok_btn.isEnabled() is False + + dialog.cancel_btn.click() + + assert controller.cancels == 1 + assert dialog.result() == 0 + + +def test_preview_progress_reaches_the_bar(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device()) + dialog._on_preview() + + controller.scan_progress.emit(0.25, "Metering") + + assert dialog.preview_progress.value() == 25 + assert dialog.preview_progress.format() == "Metering… %p%" + + +def test_reversal_film_previews_without_inversion(): + controller = _FakeController() + dialog = QuickScanPreviewDialog(controller, _device(), film_type="positive") + dialog._on_preview() + rgb = np.zeros((4, 8, 3), dtype=np.uint8) + rgb[:, 4:, :] = 200 + + controller.deliver(rgb=rgb) + + assert dialog.label.has_frame() is True + assert dialog._film_type == "positive" diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index f8e86e4bb..df87165b1 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -20,9 +20,10 @@ import pytest from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtGui import QValidator from PyQt6.QtWidgets import QApplication -from negpy.desktop.view.sidebar.scan import ScanSidebar +from negpy.desktop.view.sidebar.scan import ScanSidebar, estimated_frame_bytes from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice from negpy.infrastructure.scanners.params import FILM_TYPES, ScanMode @@ -272,8 +273,8 @@ def test_sane_backend_scan_window_opens_the_quick_preview_dialog(monkeypatch) -> 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 __init__(self, controller, device, initial_window=None, film_type="negative", parent=None) -> None: + self.seen = (controller, device, initial_window, film_type) def exec(self) -> bool: return True @@ -609,7 +610,7 @@ def test_the_nkscan_options_persist() -> None: def test_a_measured_strip_says_what_scan_would_do_before_a_preview() -> None: """Its frames come from the strip dialog, and there is no frame range to fall back on.""" sidebar, _ = _sidebar(NKSCAN_DEVICE) - assert "Preview strip" in sidebar.scan_window_status.text() + assert sidebar.scan_window_status.text().startswith("Whole strip") def test_a_measured_strip_scans_the_frames_the_strip_dialog_picked() -> None: @@ -703,3 +704,148 @@ def test_the_film_type_reaches_the_request_and_the_settings() -> None: assert controller.started[-1][1].params.film_type == "positive" assert sidebar.settings.film_type == "positive" + + +# ── group headers ───────────────────────────────────────────────────── + + +def test_a_group_header_hides_with_its_whole_group() -> None: + sidebar, _ = _sidebar(MINIMAL_DEVICE) + # Nothing to say about the film, and one manual holder to frame. + assert sidebar.film_header.isVisibleTo(sidebar) is False + assert sidebar.quality_header.isVisibleTo(sidebar) is True + assert sidebar.output_header.isVisibleTo(sidebar) is True + + +def test_the_film_group_appears_where_the_transport_asks_about_the_film() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + assert sidebar.film_header.isVisibleTo(sidebar) is True + assert sidebar.framing_header.isVisibleTo(sidebar) is True + + +def test_no_device_hides_the_optional_headers() -> None: + sidebar, _ = _sidebar() + sidebar._update_device_caps() + assert sidebar.film_header.isVisibleTo(sidebar) is False + assert sidebar.framing_header.isVisibleTo(sidebar) is False + + +# ── the summary above the Scan button ───────────────────────────────── + + +def test_the_summary_counts_the_frames_the_batch_will_scan() -> None: + sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1, 3, 5], "dpi": 4000}) + text = sidebar.summary_label.text() + assert text.startswith("3 frames · 4000 dpi") + assert "GB" in text or "MB" in text + + +def test_the_summary_quotes_a_per_frame_size_for_an_unmeasured_strip() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + text = sidebar.summary_label.text() + assert text.startswith("Whole strip") + assert "/frame" in text + + +def test_the_summary_names_the_extra_passes() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.ir_check.setChecked(True) + sidebar.clean_check.setChecked(True) + sidebar.samples_combo.setCurrentIndex(sidebar.samples_combo.findData(4)) + + text = sidebar.summary_label.text() + + assert "IR" in text and "ICE" in text and "4× sampled" in text + + +def test_a_single_holder_device_summarizes_one_frame() -> None: + # The saved 3600 is not offered here, so the nearest stop the device has is shown and used. + sidebar, _ = _sidebar(MINIMAL_DEVICE) + assert sidebar.summary_label.text().startswith("1 frame · 2400 dpi") + assert sidebar._dpi() == 2400 + + +def test_the_summary_shrinks_with_the_crop() -> None: + full, _ = _sidebar(MINIMAL_DEVICE) + cropped, _ = _sidebar(MINIMAL_DEVICE, settings={"scan_window": [0.0, 0.0, 0.5, 0.5]}) + assert full.summary_label.text() != cropped.summary_label.text() + + +def test_an_ir_pass_costs_a_fourth_plane() -> None: + caps = MINIMAL_CAPS + plain = estimated_frame_bytes(caps, 2400, 16) + with_ir = estimated_frame_bytes(caps, 2400, 16, capture_ir=True) + assert with_ir == pytest.approx(plain * 4 / 3, rel=1e-3) + + +def test_a_window_scales_the_estimate_by_its_area() -> None: + caps = MINIMAL_CAPS + assert estimated_frame_bytes(caps, 2400, 16, window=(0.0, 0.0, 0.5, 0.5)) == pytest.approx( + estimated_frame_bytes(caps, 2400, 16) / 4, rel=1e-3 + ) + + +# ── the film that came out ──────────────────────────────────────────── + + +def test_ejecting_drops_the_frame_selection_of_the_film_that_left() -> None: + sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1, 3], "frame_windows": {"1": [0.1, 0.1, 0.9, 0.9]}}) + assert sidebar.settings.selected_frames == (1, 3) + + sidebar._on_ejected(True) + + assert sidebar.settings.selected_frames == () + assert sidebar.settings.frame_windows == {} + assert "frame selection cleared" in sidebar.status_label.text() + + +def test_ejecting_keeps_the_registration_offsets() -> None: + # Offset and drift belong to the transport's own registration, not to one strip. + sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1], "frame_offset_mm": 1.5, "frame_offset_modifier_mm": 0.2}) + + sidebar._on_ejected(True) + + assert sidebar.settings.frame_offset_mm == 1.5 + assert sidebar.settings.frame_offset_modifier_mm == 0.2 + + +def test_ejecting_with_nothing_picked_says_only_that() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + sidebar._on_ejected(True) + assert sidebar.status_label.text() == "Film ejected" + + +# ── typed DPI ───────────────────────────────────────────────────────── + + +def test_a_typed_dpi_outside_the_device_range_cannot_be_entered() -> None: + sidebar, _ = _sidebar(MINIMAL_DEVICE) # supported_dpi=(1200, 2400) + editor = sidebar.dpi_combo.lineEdit() + assert editor is not None + assert editor.validator() is not None + assert editor.validator().validate("99999", 5)[0] != QValidator.State.Acceptable + + +def test_an_empty_dpi_box_falls_back_to_the_finest_the_device_offers() -> None: + sidebar, _ = _sidebar(MINIMAL_DEVICE) + sidebar.dpi_combo.clear() # nothing selected and nothing typed + assert sidebar._dpi() == 2400 + + +def test_a_pass_the_device_cannot_run_is_not_shown_at_all() -> None: + # Disabled-with-a-reason is for a pass the film blocks; one the transport lacks goes away. + sidebar, _ = _sidebar(MINIMAL_DEVICE) + assert sidebar.ir_check.isVisibleTo(sidebar) is False + assert sidebar.me_check.isVisibleTo(sidebar) is False + + sidebar, _ = _sidebar(SE_DEVICE, settings={"backend": "plustek"}) + assert sidebar.ir_check.isVisibleTo(sidebar) is True + assert sidebar.me_check.isVisibleTo(sidebar) is True + + +def test_a_film_that_blocks_infrared_leaves_the_control_visible_to_explain_itself() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE) + sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("mono")) + + assert sidebar.ir_check.isVisibleTo(sidebar) is True + assert sidebar.ir_check.isEnabled() is False diff --git a/tests/test_scan_worker.py b/tests/test_scan_worker.py index cfd513632..3c0d7ca96 100644 --- a/tests/test_scan_worker.py +++ b/tests/test_scan_worker.py @@ -588,3 +588,20 @@ def test_a_named_frame_list_never_measures_the_film() -> None: assert service.frames == [2, 4] assert service.detect_calls == [] + + +def test_batch_progress_names_the_frame_and_its_position() -> None: + """A global percentage says nothing about how much film is left in the run.""" + worker = ScanWorker() + worker._service = _BatchService() # type: ignore[assignment] + seen: list[tuple[float, str]] = [] + worker.progress.connect(lambda fraction, phase: seen.append((fraction, phase))) + + worker.run_batch(_batch_request(frames=(2, 3, 4))) + + assert [phase for _fraction, phase in seen] == [ + "Frame 1 of 3 — Scanning", + "Frame 2 of 3 — Scanning", + "Frame 3 of 3 — Scanning", + ] + assert [fraction for fraction, _phase in seen] == pytest.approx([1 / 3, 2 / 3, 1.0]) diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index 72a0a614f..5c78c20c3 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -49,14 +49,19 @@ def _device(capacity: int) -> ScannerDevice: class _FakeController(QObject): scan_roll_preview_ready = pyqtSignal(object) scan_roll_preview_finished = pyqtSignal() + scan_progress = pyqtSignal(float, str) scan_error = pyqtSignal(str) scan_cancelled = pyqtSignal() def __init__(self, *, raise_on_preview: bool = False) -> None: super().__init__() self.preview_reqs: list = [] + self.cancels = 0 self._raise = raise_on_preview + def cancel_scan(self) -> None: + self.cancels += 1 + def start_roll_preview(self, req) -> None: if self._raise: raise RuntimeError("A scanner request is already active") @@ -572,7 +577,7 @@ def test_a_measured_strip_slides_the_frame_instead_of_losing_its_tail() -> None: controller.deliver_all((1,), offset=0.0) assert dialog._tile_coverage(dialog._tiles[1]) == (0.0, 1.0) - dialog.offset_slider.setValue(180) # 18 mm, half a frame pitch + dialog.offset_slider.setValue(25) # 2.5 mm, the whole span start, end = dialog._tile_coverage(dialog._tiles[1]) assert start < 0.0 and end < 1.0 assert end - start == pytest.approx(1.0), "a slid frame is still a whole frame" @@ -585,7 +590,7 @@ def test_a_measured_strip_draws_the_boundary_it_slid_to() -> None: controller.deliver_all((1,), offset=0.0) assert dialog._tiles[1].label._offset_indicators == [] - dialog.offset_slider.setValue(180) + dialog.offset_slider.setValue(25) assert [edge for _frac, edge in dialog._tiles[1].label._offset_indicators] == ["right"] # Nothing is cut off, so neither blackout warning applies. assert not dialog.status.text().startswith(("Offset held", "Offset cuts")) @@ -595,8 +600,8 @@ def test_a_measured_strip_can_slide_a_frame_backwards() -> None: dialog = StripPreviewDialog(_FakeController(), _discovery_device()) assert dialog.offset_slider.minimum() < 0 - dialog.offset_slider.setValue(-180) - assert dialog.frame_offset() < 0 + dialog.offset_slider.setValue(-25) + assert dialog.frame_offset() == -2.5 def test_a_feeder_cannot_slide_backwards() -> None: @@ -701,3 +706,126 @@ def test_none_and_all_move_the_whole_selection() -> None: dialog.select_all_btn.click() assert dialog.selected_frames() == (1, 2, 3) assert dialog.ok_btn.isEnabled() is True + + +def test_stopping_a_running_preview_cancels_it_and_keeps_the_dialog_open() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + assert dialog.cancel_btn.text() == "Stop preview" + + dialog.cancel_btn.click() + + assert controller.cancels == 1 + assert dialog.result() == 0 # not rejected: the tiles already in hand stay + + +def test_cancel_closes_the_dialog_when_no_preview_is_running() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + + dialog.cancel_btn.click() + + assert controller.cancels == 0 + assert dialog.isVisible() is False + + +def test_leaving_mid_preview_stops_the_transport() -> None: + # The unit is held for the whole pass, so an abandoned preview would refuse the next scan. + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + + dialog.accept() + + assert controller.cancels == 1 + + +def test_committing_is_refused_while_the_preview_runs() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + controller.deliver_all((1, 2, 3)) + assert dialog.ok_btn.isEnabled() is True + + dialog._on_preview_all() + + assert dialog.ok_btn.isEnabled() is False + assert dialog.scan_btn.isEnabled() is False + + +def test_preview_progress_reaches_the_bar_and_names_the_phase() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + dialog._on_preview_all() + + controller.scan_progress.emit(0.4, "Detecting frames") + + assert dialog.preview_progress.value() == 40 + assert dialog.preview_progress.format() == "Detecting frames… %p%" + + controller.deliver_all((1,)) + assert dialog.preview_progress.isVisibleTo(dialog) is False + + +def test_idle_progress_updates_are_ignored() -> None: + # A batch scan started from the sidebar drives the same signal. + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + + controller.scan_progress.emit(0.9, "Scanning") + + assert dialog.preview_progress.value() == 0 + + +def test_reversal_film_previews_without_inversion() -> None: + slide = np.zeros((4, 4, 3), dtype=np.uint8) + slide[:, :2, :] = 20 # a dark slide stays dark + slide[:, 2:, :] = 200 + pos = preview_positive(slide, "positive") + assert pos[:, :2, :].mean() < pos[:, 2:, :].mean() + + +def test_the_film_type_decides_which_way_a_tile_reads() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device(), film_type="kodachrome") + dialog._on_preview_all() + rgb = np.zeros((4, 8, 3), dtype=np.uint8) + rgb[:, :4, :] = 20 + rgb[:, 4:, :] = 200 + + controller.deliver(1, rgb=rgb) + + assert dialog._tiles[1].label.has_frame() is True + + +def test_a_measured_strip_says_it_is_measuring_not_how_many_slots_it_asked_for() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + + dialog._on_preview_all() + + assert dialog.status.text() == "Measuring the strip…" + + +def test_a_feeder_still_counts_the_frames_it_previews() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(3)) + + dialog._on_preview_all() + + assert dialog.status.text() == "Previewing 3 frames…" + + +def test_the_offset_is_a_boundary_correction_not_a_way_to_the_next_frame() -> None: + measured = StripPreviewDialog(_FakeController(), _discovery_device()) + assert (measured.offset_slider.minimum(), measured.offset_slider.maximum()) == (-25, 25) + + measured.offset_slider.setValue(400) + assert measured.frame_offset() == 2.5 + + +def test_a_saved_negative_offset_comes_back_as_it_was() -> None: + dialog = StripPreviewDialog(_FakeController(), _discovery_device(), initial_offset=-1.5) + assert dialog.frame_offset() == -1.5 + assert dialog.offset_label.text() == "-1.5 mm" From 44de19650bf983191dbc8d8a81bd7c57fc068515 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 24 Aug 2026 07:35:30 +0200 Subject: [PATCH 3/8] feat(scan): install nkscan 0.9 from PyPI and read what the unit reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nkscan ships wheels now, so the local editable path goes and the group needs no Rust toolchain. 0.9's Capabilities answers what the backend used to guess. The fast three-line read was asked for, refused, and remembered per device — a wasted round trip on every unit that has only the one ordering; `multi_line` says so up front, and the learned-refusal state goes with it. `max_samples` is the unit's own ceiling rather than a constant, `eject` decides whether the button appears, and `framing` gates Film format: only the two mechanisms that measure the film can be told a frame length, and a holder with its own frame table fixes it. scan_frame gained lock_white_balance, and its binding default is wrong for negatives: the Python signature holds the channels together while the library's own default does not. A colour negative meters per channel, which takes the orange mask off before the converter instead of quantising the blue record through it, so the film type now decides it through the library's own locks_white_balance. Its film names are NegPy's film-type values. max_frames stays unread on purpose: mapping it to adapter_frame_capacity would put the strip dialog on the index-addressed feeder path, and frames here come from discovery. The fake module follows the 0.9 capability shape, and two new tests hold it to the real extension where that is installed — every fake field a real property, and every film name one the binding accepts. That is the drift that made the fork's two hand-added booleans look fine offline. --- docs/USER_GUIDE.md | 8 +- .../infrastructure/scanners/nkscan_backend.py | 60 ++++++------ negpy/infrastructure/scanners/nkscan_roll.py | 2 +- pyproject.toml | 11 +-- tests/scanners/fake_nkscan.py | 32 ++++++- tests/scanners/test_nkscan_backend.py | 93 +++++++++++++------ uv.lock | 13 ++- 7 files changed, 137 insertions(+), 82 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2efd5d071..9a41b1bda 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -892,12 +892,12 @@ Capture film directly into NegPy. Two collapsible sections: * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. * **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time. * **Superfine**: read one line per pass. Slower, and free of the line registration the faster three-line mode owes the host. - * **Film**: what is on the film — Color negative, B&W negative, Slide or Kodachrome. It decides which way the frame boundaries read when the strip is measured, and it disables IR and ICE on B&W and Kodachrome, whose silver and dyes stop infrared so the mask comes back as the picture rather than the dust on it. - * **Film format**: the frame length on the loaded film (135, 66, 645 and so on). Leave it on **Auto** where the holder fixes the format, and set it for loose film in a masked carrier. + * **Film**: what is on the film — Color negative, B&W negative, Slide or Kodachrome. It decides three things: which way the frame boundaries read when the strip is measured, whether IR and ICE are offered at all (B&W and Kodachrome stop infrared with silver and dyes, so the mask comes back as the picture rather than the dust on it), and how the frame is metered. A color negative is metered one channel at a time, which takes the orange mask off before the converter instead of quantizing the blue record through it; every other film keeps its factory balance, because there the cast is the picture. + * **Film format**: the frame length on the loaded film (135, 66, 645 and so on). Leave it on **Auto** where the holder narrows it, and set it for loose film in a masked carrier. It appears only where the transport measures the film to find its frames — a holder with its own frame table fixes the format, so there is nothing to choose. - Samples and Superfine appear only on units that advertise them. An LS-50 offers neither: it reads one line at a time whatever you ask, and it ignores repeated reads of a line, so both controls stay hidden there and a setting saved from another scanner is never sent to it. + Every control here follows what the unit reports. An LS-50 shows neither Samples nor Superfine: it reads one CCD line at a time whatever you ask, and it ignores repeated reads of a line, so both stay hidden and a setting saved from another scanner is never sent to it. - The driver is the optional **nkscan** package: install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module. + The driver is the optional **nkscan** package (0.9 or newer), which ships as a wheel: install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module. **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. diff --git a/negpy/infrastructure/scanners/nkscan_backend.py b/negpy/infrastructure/scanners/nkscan_backend.py index 82cc4832e..87ffa5707 100644 --- a/negpy/infrastructure/scanners/nkscan_backend.py +++ b/negpy/infrastructure/scanners/nkscan_backend.py @@ -44,7 +44,7 @@ # Frame lengths nkscan cannot measure for itself, as it spells them. FILM_FORMATS = ("135", "half", "IX240", "16", "645", "66", "67", "68", "69") -_MAX_SAMPLES = 16 +_MAX_SAMPLES = 16 # the protocol's own ceiling; a unit's own limit comes from its capabilities # 135, across the film by along the feed: the raster is portrait. Only the tile aspect and the # window mm readout use it, and no adapter reports its opening through the bindings. @@ -54,19 +54,15 @@ _MM_PER_INCH = 25.4 -# nkscan's own name for the refusal a unit with no three-row CCD read raises. -_INTERLEAVING_OP = "color interleaving" +# The two of nkscan's four framing mechanisms that take a thumbnail pass, and so the two that +# can be told a frame length. The others read the holder's own table or an address. +_MEASURED_FRAMING = ("thumbnail", "perforation") def _caps_for(caps: Any) -> ScannerCapabilities: lo, hi = (int(v) for v in caps.x_dpi_range) optical = int(caps.optical_dpi) dpi = tuple(sorted({*dpi_stops_in_range(lo, hi), optical})) - # A unit with one read mode has no Superfine to choose, and one that ignores repeated - # reads has no Samples. An nkscan too old to say assumes both, and the first refusal - # teaches the backend the read mode anyway. - superfine = bool(getattr(caps, "multiline_read", True)) - max_samples = _MAX_SAMPLES if getattr(caps, "multi_reading", True) else 1 return ScannerCapabilities( ir_channel=True, supported_dpi=dpi, @@ -74,17 +70,24 @@ def _caps_for(caps: Any) -> ScannerCapabilities: supported_depths=(16,), sources=(ScanMode.NEGATIVE, ScanMode.POSITIVE), max_area_mm=_DEFAULT_AREA_MM, + # nkscan meters every frame itself and focuses itself, and offers no parameter for + # either, so neither has a control to carry — `hardware_metering` and `autofocus` say + # what the unit does, not what a caller may ask for. auto_exposure=False, autofocus=False, + # Frames come from `discover_frames`, never from an index, so `max_frames` is not a + # capacity to range over: the strip dialog picks from what the film turned out to hold. adapter_frame_capacity=None, - can_eject=True, + can_eject=bool(caps.eject), exposure_time_us=None, hw_clean=True, roll_discovery=True, - film_formats=FILM_FORMATS, + film_formats=FILM_FORMATS if str(caps.framing) in _MEASURED_FRAMING else (), film_types=tuple(FILM_TYPES), - max_samples=max_samples, - superfine=superfine, + max_samples=max(1, int(caps.max_samples)), + # One read mode means no Superfine to switch: every pass on the unit is already one + # line at a time. + superfine=bool(caps.multi_line), ) @@ -238,8 +241,6 @@ def __init__(self) -> None: # re-previewing a strip after a nudge must not cost another read of the film. self._frames: dict[str, list[tuple[int, int, int, int]]] = {} self._strips: dict[str, np.ndarray] = {} - # Units that read one CCD line at a time, learned from the first refusal. - self._line_ordering_only: dict[str, bool] = {} self._lock = threading.Lock() # ── enumeration ─────────────────────────────────────────────────── @@ -277,9 +278,6 @@ def _probe_one(self, device: Any, location: str) -> ScannerDevice | None: return None try: caps = session.capabilities - if getattr(caps, "multiline_read", True) is False: - # Said outright, so no scan has to learn it from a refusal. - self._line_ordering_only[location] = True return ScannerDevice( id=location, vendor=str(caps.vendor).strip(), @@ -368,13 +366,13 @@ def _scan_on_session( with self._mapped_errors(): result = self.scan_frame( session, - device_id, rect, dpi=int(params.dpi), samples=int(params.samples), superfine=bool(params.superfine), infrared=bool(params.capture_ir), clean=bool(params.clean), + lock_white_balance=self.locks_white_balance(params.film_type), exposures=exposures, progress=report, ) @@ -387,7 +385,6 @@ def _scan_on_session( def scan_frame( self, session: Any, - device_id: str, rect: tuple[int, int, int, int], *, superfine: bool = False, @@ -395,19 +392,20 @@ def scan_frame( ) -> Any: """One pass over `rect`. Every scan goes through here, previews included. - Some units read one CCD line at a time and nothing else — the LS-50 does — and the - bindings cannot be asked in advance. The refusal comes from the recipe check before the - stage moves, so the fast read is tried once, then remembered as unavailable. + A unit whose CCD cannot read its lines at once — the LS-50 cannot — has only the + superfine ordering, and asking for the fast one is refused before the stage moves. """ - want = bool(superfine) or self._line_ordering_only.get(device_id, False) - try: - return session.scan_frame(rect, superfine=want, **options) - except self._nk.UnsupportedError as exc: - if want or getattr(exc, "op", "") != _INTERLEAVING_OP: - raise - logger.info("%s reads one CCD line at a time; every scan on it is superfine", device_id) - self._line_ordering_only[device_id] = True - return session.scan_frame(rect, superfine=True, **options) + want = bool(superfine) or not bool(session.capabilities.multi_line) + return session.scan_frame(rect, superfine=want, **options) + + def locks_white_balance(self, film_type: str) -> bool: + """nkscan's own metering default for this film. + + A colour negative is metered per channel, which takes the orange mask off before the + ADC instead of quantising the blue record through it; everything else keeps the factory + balance, because there the cast is the picture. + """ + return bool(self._nk.Capabilities.locks_white_balance(film_type)) def _to_result(self, result: Any, model: str) -> ScanResult: ir = result.ir diff --git a/negpy/infrastructure/scanners/nkscan_roll.py b/negpy/infrastructure/scanners/nkscan_roll.py index 2d3b8d2c8..80713457b 100644 --- a/negpy/infrastructure/scanners/nkscan_roll.py +++ b/negpy/infrastructure/scanners/nkscan_roll.py @@ -134,12 +134,12 @@ def _scan_preview(self, rect: tuple[int, int, int, int], cancel: threading.Event with self._backend._mapped_errors(): result = self._backend.scan_frame( self._session, - self._device.id, rect, dpi=self._dpi or None, samples=1, infrared=False, clean=False, + lock_white_balance=self._backend.locks_white_balance(self._film_type), exposures=self._exposures, progress=_progress_bridge(None, cancel), ) diff --git a/pyproject.toml b/pyproject.toml index b87c857ff..933755f89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ classifiers = [ ] [project.optional-dependencies] -nkscan = ["nkscan>=0.7"] +nkscan = ["nkscan>=0.9"] plustek = ["pyopticfilm>=1.2.0"] sane = ["python-sane>=2.9"] camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] @@ -68,15 +68,10 @@ pieusb = [ "pieusb>=0.3.7", ] nkscan = [ - # Nikon Coolscan over SCSI/USB. A Rust extension, so installing this group needs the - # maturin build backend and a Rust toolchain until the wheels are on PyPI. - "nkscan>=0.7", + # Nikon Coolscan over SCSI/USB. A Rust extension, shipped as wheels. + "nkscan>=0.9", ] -[tool.uv.sources] -# Local build while nkscan is unreleased. Delete this once its wheels are on PyPI. -nkscan = { path = "../nkscan", editable = true } - [project.urls] Homepage = "https://github.com/marcinz606/NegPy" Documentation = "https://github.com/marcinz606/NegPy/docs" diff --git a/tests/scanners/fake_nkscan.py b/tests/scanners/fake_nkscan.py index cb0133f15..352932def 100644 --- a/tests/scanners/fake_nkscan.py +++ b/tests/scanners/fake_nkscan.py @@ -49,6 +49,11 @@ def __init__(self, message: str, op: str = "scan", reason: str = "not offered") self.reason = reason +# What the real Capabilities.locks_white_balance answers: only a colour negative is metered +# per channel, and the binding refuses a film it does not know. +_LOCKED_FILMS = {"negative": False, "mono": True, "monochrome": True, "positive": True, "slide": True, "kodachrome": True} + + @dataclass(frozen=True) class FakeCapabilities: vendor: str = "Nikon" @@ -58,8 +63,24 @@ class FakeCapabilities: x_dpi_range: tuple[int, int] = (500, 4000) y_dpi_range: tuple[int, int] = (500, 4000) optical_dpi: int = 4000 - multiline_read: bool = True - multi_reading: bool = True + max_frames: int = 6 + thumbnail_dpi: tuple[int, int] = (250, 250) + focus_range: tuple[int, int] = (0, 255) + max_samples: int = 16 + framing: str = "thumbnail" + thumbnail: bool = True + multi_line: bool = True + eject: bool = True + autofocus: bool = True + hardware_metering: bool = False + interleavings: tuple[str, ...] = ("LINE_WITHOUT_DISTANCE", "MULTILINE_SIMULTANEOUS") + + @staticmethod + def locks_white_balance(film: str) -> bool: + try: + return _LOCKED_FILMS[film.lower()] + except KeyError: + raise RuntimeError(f"unknown film type {film!r}") from None @dataclass(frozen=True) @@ -99,8 +120,6 @@ class FakeNkscanModule: cols: int = 6 thumbnail: bool = True strip_slack: int = 4 # columns past the last frame; negative pushes frames off the pass - # Some units (the LS-50) never read three CCD rows at once, and refuse the fast mode. - line_ordering_only: bool = False scan_error: Exception | None = None discover_error: Exception | None = None open_error: Exception | None = None @@ -114,6 +133,7 @@ class Session(FakeSession): _module = module self.Session = Session + self.Capabilities = FakeCapabilities for name in ( "ScannerError", "TransientError", @@ -222,7 +242,9 @@ def scan_frame( progress: Callable[..., Any] | None = None, ) -> FakeScanResult: module = self._module - if module.line_ordering_only and not superfine: + # A unit whose CCD reads one line at a time refuses the fast ordering, in the recipe + # check before the stage moves. + if not module.caps.multi_line and not superfine: raise UnsupportedError( "color interleaving: this unit does not read the CCD three rows at once", op="color interleaving", diff --git a/tests/scanners/test_nkscan_backend.py b/tests/scanners/test_nkscan_backend.py index 5bcf5a125..d583d162c 100644 --- a/tests/scanners/test_nkscan_backend.py +++ b/tests/scanners/test_nkscan_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses import threading import numpy as np @@ -9,7 +10,7 @@ from negpy.infrastructure.scanners.base import TransientScanError from negpy.infrastructure.scanners.nkscan_backend import _crop_frame, _offset_units, _shift_frame, _stack_rgb -from negpy.infrastructure.scanners.params import ScanMode, ScanParams +from negpy.infrastructure.scanners.params import FILM_TYPES, ScanMode, ScanParams from tests.scanners import fake_nkscan from tests.scanners.fake_nkscan import DEVICE_ID, FRAMES, FakeCapabilities, make_backend @@ -211,8 +212,6 @@ def test_ice_samples_and_superfine_reach_the_scan() -> None: asked = module.opened[-1].scans[0] assert asked["clean"] and asked["infrared"] and asked["superfine"] assert (asked["samples"], asked["dpi"]) == (4, 2400) - # White balance stays locked: a negative is normalized downstream, not by the scanner. - assert asked["lock_white_balance"] is True def test_samples_outside_the_bound_are_refused() -> None: @@ -353,22 +352,18 @@ def progress(_fraction: float, phase: str = "Scanning") -> None: def test_a_unit_with_no_fast_read_still_scans() -> None: - """The LS-50 offers only line ordering, and the bindings cannot be asked in advance.""" - backend, module = make_backend(line_ordering_only=True) + """The LS-50 offers only line ordering, and says so, so the fast read is never asked for.""" + backend, module = make_backend(caps=FakeCapabilities(multi_line=False)) result = _scan(backend) assert result.rgb.shape == (8, 6, 3) - assert module.opened[-1].scans[-1]["superfine"] is True + assert [s["superfine"] for s in module.opened[-1].scans] == [True] -def test_the_refusal_is_learned_once_per_device() -> None: - backend, module = make_backend(line_ordering_only=True) - _scan(backend) +def test_a_unit_with_a_fast_read_is_asked_for_it() -> None: + backend, module = make_backend() _scan(backend) - - # One wasted validation on the first scan, then straight to the read the unit accepts. - asked = [s["superfine"] for session_ in module.opened for s in session_.scans] - assert asked == [True, True] + assert module.opened[-1].scans[-1]["superfine"] is False def test_a_different_unsupported_operation_still_fails() -> None: @@ -409,38 +404,55 @@ def test_bare_film_detects_nothing() -> None: assert backend.detect_frames(DEVICE_ID) == 0 +# ── what the unit says it can do ────────────────────────────────────────── + + def test_a_unit_that_offers_one_read_mode_offers_no_superfine_control() -> None: - backend, _ = make_backend(caps=FakeCapabilities(multiline_read=False)) + backend, _ = make_backend(caps=FakeCapabilities(multi_line=False)) caps = backend.list_devices()[0].capabilities assert caps.superfine is False def test_a_unit_that_ignores_repeated_reads_offers_no_samples_control() -> None: - backend, _ = make_backend(caps=FakeCapabilities(multi_reading=False)) + backend, _ = make_backend(caps=FakeCapabilities(max_samples=1)) caps = backend.list_devices()[0].capabilities assert caps.max_samples == 1 -def test_a_unit_that_says_it_has_no_fast_read_is_never_asked_for_one() -> None: - """The refusal is free but the round trip is not, and the pages already answered.""" - backend, module = make_backend(caps=FakeCapabilities(multiline_read=False), line_ordering_only=True) - backend.list_devices() - _scan(backend) +def test_the_samples_ceiling_is_the_units_own() -> None: + backend, _ = make_backend(caps=FakeCapabilities(max_samples=4)) + assert backend.list_devices()[0].capabilities.max_samples == 4 - assert [s["superfine"] for s in module.opened[-1].scans] == [True] +def test_a_unit_that_cannot_give_the_film_back_offers_no_eject() -> None: + backend, _ = make_backend(caps=FakeCapabilities(eject=False)) + assert backend.list_devices()[0].capabilities.can_eject is False -def test_an_nkscan_too_old_to_say_assumes_both() -> None: - """The bits landed after the first bindings; a wheel without them must not lose controls.""" - class OldCapabilities: - vendor, product, revision, model = "Nikon", "LS-9000 ED", "1.00", "LS-9000" - x_dpi_range = y_dpi_range = (500, 4000) - optical_dpi = 4000 +def test_only_a_transport_that_measures_the_film_is_told_the_frame_length() -> None: + """A holder with its own frame table fixes the format, so there is nothing to choose.""" + measured, _ = make_backend(caps=FakeCapabilities(framing="perforation")) + assert "135" in measured.list_devices()[0].capabilities.film_formats - backend, _ = make_backend(caps=OldCapabilities()) - caps = backend.list_devices()[0].capabilities - assert caps.superfine is True and caps.max_samples == 16 + published, _ = make_backend(caps=FakeCapabilities(framing="published")) + assert published.list_devices()[0].capabilities.film_formats == () + + +# ── metering ────────────────────────────────────────────────────────────── + + +def test_a_colour_negative_is_metered_per_channel() -> None: + """Held together, the orange mask is quantised through and the blue record loses range.""" + backend, module = make_backend() + _scan(backend, dataclasses.replace(_PARAMS, film_type="negative")) + assert module.opened[-1].scans[-1]["lock_white_balance"] is False + + +def test_every_other_film_keeps_its_factory_balance() -> None: + for film in ("positive", "kodachrome", "mono"): + backend, module = make_backend() + _scan(backend, dataclasses.replace(_PARAMS, film_type=film, capture_ir=False)) + assert module.opened[-1].scans[-1]["lock_white_balance"] is True, film # ── what is on the film ─────────────────────────────────────────────────── @@ -482,3 +494,24 @@ def test_an_unknown_film_type_is_refused() -> None: backend, _ = make_backend() with pytest.raises(RuntimeError, match="Unknown film type"): _scan(backend, ScanParams(dpi=1000, depth=16, capture_ir=False, film_type="tintype")) + + +# ── the fake against the real extension ─────────────────────────────────── + + +def test_the_fake_capabilities_carry_what_the_extension_does() -> None: + """The fake is the whole test suite's idea of the bindings, so it has to keep up with them. + + Every capability the backend reads is a property on the real class; one the fake invents + would pass here and fail on hardware. + """ + nkscan = pytest.importorskip("nkscan") + real = set(dir(nkscan.Capabilities)) + assert {f.name for f in dataclasses.fields(FakeCapabilities)} <= real + + +def test_the_films_the_backend_names_are_films_the_extension_knows() -> None: + nkscan = pytest.importorskip("nkscan") + backend, _ = make_backend() + for film in FILM_TYPES: + assert backend.locks_white_balance(film) == nkscan.Capabilities.locks_white_balance(film) diff --git a/uv.lock b/uv.lock index 80e6c8913..d7bb1caf8 100644 --- a/uv.lock +++ b/uv.lock @@ -382,7 +382,7 @@ requires-dist = [ { name = "imagecodecs", specifier = "==2026.6.6" }, { name = "imageio", specifier = "==2.37.3" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "nkscan", marker = "extra == 'nkscan'", editable = "../nkscan" }, + { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.9" }, { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, { name = "opencv-python-headless", specifier = "==4.13.0.92" }, @@ -409,14 +409,21 @@ dev = [ { name = "ruff", specifier = "==0.14.10" }, { name = "ty", specifier = ">=0.0.26" }, ] -nkscan = [{ name = "nkscan", editable = "../nkscan" }] +nkscan = [{ name = "nkscan", specifier = ">=0.9" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] plustek = [{ name = "pyopticfilm", specifier = ">=1.2.0" }] sane = [{ name = "python-sane", specifier = ">=2.9" }] [[package]] name = "nkscan" -source = { editable = "../nkscan" } +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/4d/8b02d8e3a7ce1604f9d3b1ae94e31fac5850b7daaa484a81b2f8f78ab6e9/nkscan-0.9.0.tar.gz", hash = "sha256:b38d89afc0d6bfe3473294db587f58fa9fe8a9c6a9017561105a633d8cd355a8", size = 2804597, upload-time = "2026-08-24T01:28:37.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/50/483d48da107ac514b8fad9ca5caff4f1d20dc048f2a4e4822259d49c5ae2/nkscan-0.9.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f7be405c2b601cdf942f4e87e8b16eb2a6702ae5c10dc145947c4e4cdaf7a49", size = 735425, upload-time = "2026-08-24T01:28:32.919Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/92008b81db30187f1461d6fbdb8c313713001a40cdad82bbaba0bdbe19e7/nkscan-0.9.0-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4c0550b73e18393461f9073d4ab75ecd9ff8ae2042ebb5c5028271eeeb1f10a", size = 836903, upload-time = "2026-08-24T01:28:34.506Z" }, + { url = "https://files.pythonhosted.org/packages/1c/94/f53841c7168ebea01f16ab758714f70da348ef56b2e0204024babd56d358/nkscan-0.9.0-cp313-abi3-win_amd64.whl", hash = "sha256:dff231a33cbd3164c9d116b5bbf6ca9a7c070f8d386330833f89266a42ed7f08", size = 595336, upload-time = "2026-08-24T01:28:35.973Z" }, +] [[package]] name = "numba" From 27a01bc63385e1ae5ffc3b072eb4a89f4b254d11 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 24 Aug 2026 18:49:28 +0200 Subject: [PATCH 4/8] feat(scan): type the frames to scan, instead of previewing the strip again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which frames a batch takes was the strip dialog's to say, so changing the selection meant another pass over the film. A Frames box takes the list directly — 1-6, 1,2,5, empty for every frame — and the dialog writes its picks there, so the preview stays a way to see the film rather than the only way to address it. It replaces the from-to spinboxes, which only ever appeared on a feeder that reports a slot count and left a measured strip with no frame control at all. One box now serves both: a feeder falls back to every slot it holds, a measured strip to every frame it finds. Text that cannot be read stops the scan and says so above the button, rather than scanning the wrong film quietly. The status line under Batch drops the frame list the box now owns, and keeps the windows, offset and drift. ScannerSettings loads through from_dict, which turns a saved frame_from/frame_to into a selection. It also ignores a key this version dropped: the constructor threw on one, and the whole blob then fell back to defaults, taking the output folder, DPI and filename pattern with it. --- docs/USER_GUIDE.md | 4 +- negpy/desktop/view/sidebar/scan.py | 137 +++++++++++----------- negpy/infrastructure/scanners/settings.py | 67 +++++++++-- tests/scanners/test_scanner_settings.py | 69 +++++++++-- tests/test_scan_sidebar.py | 56 ++++++--- 5 files changed, 224 insertions(+), 109 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9a41b1bda..9d84505ac 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -879,7 +879,7 @@ When you set capture gear, it is written to standard EXIF, and the digitizing ri Capture film directly into NegPy. Two collapsible sections: -* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them — **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and **Output** (format, folder, filename template) — and a group's header disappears with the whole group when the device has nothing in it. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. +* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them — **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and **Output** (format, folder, filename template) — and a group's header disappears with the whole group when the device has nothing in it. **Frames** takes the frames to scan as a list — `1-6`, `1,2,5`, or empty for every frame on the film — and the strip preview writes its picks there, so a selection can be changed without previewing again. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. **pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. @@ -887,7 +887,7 @@ Capture film directly into NegPy. Two collapsible sections: 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). - **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, there is no frame range to set, and no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** (±2.5 mm, either way, since the frame is re-addressed rather than fed past) and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. The Scan panel lists the selection under the strip button, and ejecting the film clears it — the frames and their crops describe the piece of film that just came out. **Offset** and **Drift** survive an eject, because they register the transport rather than one strip. Four controls appear only on this backend: + **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, and there is no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** (±2.5 mm, either way, since the frame is re-addressed rather than fed past) and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, type it in **Frames**, or untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. Either way the selection shows in **Frames**, and ejecting the film clears it — the frames and their crops describe the piece of film that just came out. **Offset** and **Drift** survive an eject, because they register the transport rather than one strip. Four controls appear only on this backend: * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. * **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time. diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 7289a4127..9cfe2d584 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -13,7 +13,6 @@ QProgressBar, QPushButton, QSlider, - QSpinBox, QVBoxLayout, QWidget, ) @@ -86,7 +85,7 @@ def _load_settings(self) -> ScannerSettings: data = self.controller.session.repo.get_global_setting("scanner_settings", default={}) if isinstance(data, dict) and data: try: - settings = ScannerSettings(**data) + settings = ScannerSettings.from_dict(data) except Exception: settings = ScannerSettings.defaults() else: @@ -109,6 +108,10 @@ def settings(self) -> ScannerSettings: def settings(self, value: ScannerSettings) -> None: self._settings = value self._save_settings() + # Every writer routes through here, so the frame box follows the selection wherever it + # was set — the strip dialog, a clear, an eject. A stale box would wipe it on the next + # edit of any other control. + self._sync_frame_spec() # ── UI construction ─────────────────────────────────────────────── @@ -263,24 +266,14 @@ def _init_ui(self) -> None: self.framing_header = section_subheader("Framing") self.form.addRow(self.framing_header) - # Frame range, for roll and strip feeders only. Shown when a live capacity is known. - self.frame_range_widget = QWidget() - frame_row = QHBoxLayout(self.frame_range_widget) - frame_row.setContentsMargins(0, 0, 0, 0) - self.frame_from_spin = QSpinBox() - self.frame_from_spin.setMinimum(1) - self.frame_from_spin.setToolTip("First frame to scan") - self.frame_to_spin = QSpinBox() - self.frame_to_spin.setMinimum(1) - self.frame_to_spin.setToolTip("Last frame to scan") - frame_row.addWidget(self.frame_from_spin) - frame_row.addWidget(QLabel("–")) - frame_row.addWidget(self.frame_to_spin) - frame_row.addStretch() - self.frame_range_label = QLabel("Frames") - self.form.addRow(self.frame_range_label, self.frame_range_widget) - self.frame_range_label.setVisible(False) - self.frame_range_widget.setVisible(False) + # Which frames the batch scans, for roll and strip feeders only. + self.frame_spec_edit = QLineEdit() + self.frame_spec_edit.setPlaceholderText("All frames") + self.frame_spec_edit.setToolTip("Frames to scan: 1-6 or 1,2,5. Empty scans every frame.") + self.frame_spec_label = QLabel("Frames") + self.form.addRow(self.frame_spec_label, self.frame_spec_edit) + self.frame_spec_label.setVisible(False) + self.frame_spec_edit.setVisible(False) # Scan window (strip/roll feeders): set once from a preview, reused per frame. self.scan_window_widget = QWidget() @@ -399,8 +392,8 @@ def _connect_signals(self) -> None: self.format_combo.currentIndexChanged.connect(lambda: self._update_settings_from_ui()) self.film_type_combo.currentIndexChanged.connect(lambda: self._on_film_type_changed()) self.exposure_slider.valueChanged.connect(self._on_exposure_changed) - self.frame_from_spin.valueChanged.connect(self._on_frame_from_changed) - self.frame_to_spin.valueChanged.connect(self._on_frame_to_changed) + self.frame_spec_edit.textChanged.connect(self._on_frame_spec_typed) + self.frame_spec_edit.editingFinished.connect(self._update_settings_from_ui) self.scan_window_btn.clicked.connect(self._on_set_scan_window) self.scan_window_clear_btn.clicked.connect(self._on_clear_scan_window) self.prescan_btn.clicked.connect(self._on_prescan) @@ -509,8 +502,8 @@ def _update_device_caps(self) -> None: self.ir_check.setEnabled(False) self.me_check.setEnabled(False) self.eject_btn.setVisible(False) - self.frame_range_label.setVisible(False) - self.frame_range_widget.setVisible(False) + self.frame_spec_label.setVisible(False) + self.frame_spec_edit.setVisible(False) self.scan_window_row_label.setVisible(False) self.scan_window_widget.setVisible(False) self.scan_window_status.setVisible(False) @@ -568,8 +561,7 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.ir_check.blockSignals(True) self.me_check.blockSignals(True) self.ae_check.blockSignals(True) - self.frame_from_spin.blockSignals(True) - self.frame_to_spin.blockSignals(True) + self.frame_spec_edit.blockSignals(True) # DPI self.dpi_combo.clear() @@ -721,24 +713,13 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.exposure_slider.blockSignals(False) self._update_exposure_value_label() - # Frame range, only for a roll or strip feeder reporting a live capacity. A transport - # that measures the strip has no capacity to range over: its frames come from the - # strip dialog. - capacity = caps.adapter_frame_capacity - has_frames = capacity is not None + # Which frames to scan: every transport that reaches more than one frame, whether it + # counts slots or measures them off the film. is_strip = _reaches_a_strip(caps) - self.frame_range_label.setVisible(has_frames) - self.frame_range_widget.setVisible(has_frames) - if has_frames: - self.frame_from_spin.setMaximum(capacity) - self.frame_to_spin.setMaximum(capacity) - frm = min(max(self._settings.frame_from, 1), capacity) - to = min(max(self._settings.frame_to, frm), capacity) - # A stored (1, 1) is the unset default → offer the whole strip. - if self._settings.frame_from == 1 and self._settings.frame_to == 1: - to = capacity - self.frame_from_spin.setValue(frm) - self.frame_to_spin.setValue(to) + self.frame_spec_label.setVisible(is_strip) + self.frame_spec_edit.setVisible(is_strip) + if is_strip: + self._sync_frame_spec() # Scan window: crop UI for every backend but pyOpticfilm, which uses Prescan instead; # both wrote the same scan_window setting. @@ -768,8 +749,7 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.ir_check.blockSignals(False) self.me_check.blockSignals(False) self.ae_check.blockSignals(False) - self.frame_from_spin.blockSignals(False) - self.frame_to_spin.blockSignals(False) + self.frame_spec_edit.blockSignals(False) def _film_type(self) -> str: default = FilmType.NEGATIVE.value @@ -819,15 +799,27 @@ def _update_exposure_value_label(self) -> None: else: self.exposure_value_label.setText(f"{us} us") - def _on_frame_from_changed(self, _value: int) -> None: - if self.frame_to_spin.value() < self.frame_from_spin.value(): - self.frame_to_spin.setValue(self.frame_from_spin.value()) - self._update_settings_from_ui() + def _frame_spec(self) -> tuple[int, ...] | None: + """The typed frame selection, or None where the text cannot be read.""" + from negpy.infrastructure.scanners.settings import parse_frame_spec - def _on_frame_to_changed(self, _value: int) -> None: - if self.frame_from_spin.value() > self.frame_to_spin.value(): - self.frame_from_spin.setValue(self.frame_to_spin.value()) - self._update_settings_from_ui() + try: + return parse_frame_spec(self.frame_spec_edit.text()) + except ValueError: + return None + + def _sync_frame_spec(self) -> None: + """Write the stored selection into the box, which the strip dialog also sets.""" + from negpy.infrastructure.scanners.settings import format_frame_spec + + text = format_frame_spec(self._settings.selected_frames) + if text != self.frame_spec_edit.text(): + self.frame_spec_edit.setText(text) + self._update_summary() + + def _on_frame_spec_typed(self, _text: str) -> None: + # Only the summary follows every keystroke; the selection is stored on editingFinished. + self._update_summary() def _on_set_scan_window(self) -> None: from dataclasses import replace @@ -932,17 +924,11 @@ def _update_scan_window_status(self) -> None: offset_txt = f" · offset {offset:.1f} mm" if offset else "" drift = self._settings.frame_offset_modifier_mm offset_txt += f" · drift {drift:+.2f} mm/frame" if drift else "" - selected = self._settings.selected_frames - if selected: - frames_txt = ", ".join(str(f) for f in sorted(selected)) - n_windows = len(self._settings.frame_windows) - win_txt = f" · {count_of(n_windows, 'window')}" if n_windows else "" - self.scan_window_status.setText(f"Frames {frames_txt}{win_txt}{offset_txt}") - return device = self._current_device() - if device is not None and device.capabilities.roll_discovery: - # Nothing picked yet, and a measured strip has no frame range to fall back on. - self.scan_window_status.setText("Whole strip — pick frames in the preview") + if device is not None and _reaches_a_strip(device.capabilities): + n_windows = len(self._settings.frame_windows) + win_txt = count_of(n_windows, "window") if n_windows else "Full frame" + self.scan_window_status.setText(f"{win_txt}{offset_txt}") return area = scan_window_to_area(self._settings.scan_window, device.capabilities.max_area_mm) if device else None if area is None: @@ -955,7 +941,7 @@ def _sync_group_headers(self) -> None: """A group header only earns its space when the group has a visible row.""" self.film_header.setVisible(self.film_type_combo.isVisibleTo(self) or self.format_combo.isVisibleTo(self)) self.framing_header.setVisible( - self.frame_range_widget.isVisibleTo(self) or self.scan_window_widget.isVisibleTo(self) or self.prescan_widget.isVisibleTo(self) + self.frame_spec_edit.isVisibleTo(self) or self.scan_window_widget.isVisibleTo(self) or self.prescan_widget.isVisibleTo(self) ) def _dpi(self) -> int: @@ -972,6 +958,8 @@ def _dpi(self) -> int: def _update_summary(self) -> None: """One line saying what pressing Scan will do: how much film, at what resolution, with which passes, and how much disk it takes.""" + from dataclasses import replace + from negpy.infrastructure.scanners.settings import resolve_batch_selection device = self._current_device() @@ -979,10 +967,16 @@ def _update_summary(self) -> None: self.summary_label.setText("") return caps = device.capabilities + spec = self._frame_spec() + if spec is None: + self.summary_label.setText("Frames: cannot read that") + self.scan_btn.setEnabled(False) + return + if not self._scanning: + self.scan_btn.setEnabled(True) frames, windows, base_window = resolve_batch_selection( - self._settings, - self.frame_from_spin.value(), - self.frame_to_spin.value(), + replace(self._settings, selected_frames=spec), + capacity=caps.adapter_frame_capacity, whole_strip=caps.roll_discovery, ) if not _reaches_a_strip(caps): @@ -1043,10 +1037,12 @@ def _on_scan(self) -> None: pattern = self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}' fmt = self.fmt_combo.currentText() + if self._frame_spec() is None: + return + self._update_settings_from_ui() frames, frame_windows, base_window = resolve_batch_selection( self._settings, - self.frame_from_spin.value(), - self.frame_to_spin.value(), + capacity=device.capabilities.adapter_frame_capacity, whole_strip=device.capabilities.roll_discovery, ) exposure_time_us = ( @@ -1212,8 +1208,7 @@ def _update_settings_from_ui(self) -> None: superfine=self._caps_superfine and self.superfine_check.isChecked(), film_format=self._film_format(), film_type=self._film_type(), - frame_from=self.frame_from_spin.value(), - frame_to=self.frame_to_spin.value(), + selected_frames=(spec if (spec := self._frame_spec()) is not None else self._settings.selected_frames), output_folder=self.folder_edit.text().strip(), output_format=self.fmt_combo.currentText(), filename_pattern=self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}', diff --git a/negpy/infrastructure/scanners/settings.py b/negpy/infrastructure/scanners/settings.py index 3c6c6762b..7eb1bf2a8 100644 --- a/negpy/infrastructure/scanners/settings.py +++ b/negpy/infrastructure/scanners/settings.py @@ -1,4 +1,5 @@ -from dataclasses import dataclass, field +from collections.abc import Iterable +from dataclasses import dataclass, field, fields from negpy.infrastructure.scanners.registry import DEFAULT_BACKEND_ID @@ -21,8 +22,6 @@ class ScannerSettings: # Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). None is the # scanner default. Only meaningful when the device exposes the option. exposure_time_us: int | None = None - frame_from: int = 1 - frame_to: int = 1 # Let the transport remove dust itself, baked into the file it writes. clean: bool = False samples: int = 1 @@ -61,21 +60,69 @@ def __post_init__(self) -> None: def defaults(cls) -> "ScannerSettings": return cls() + @classmethod + def from_dict(cls, data: dict) -> "ScannerSettings": + """Build from a persisted blob, migrating and dropping keys this version dropped. + + An unknown key must not throw: the whole blob would fall back to defaults and take + every unrelated preference with it. + """ + data = dict(data) + first, last = data.pop("frame_from", None), data.pop("frame_to", None) + if not data.get("selected_frames") and isinstance(first, int) and isinstance(last, int) and (first, last) != (1, 1): + data["selected_frames"] = tuple(range(first, last + 1)) + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in data.items() if k in known}) + def resolve_batch_selection( - settings: ScannerSettings, frame_from: int, frame_to: int, *, whole_strip: bool = False + settings: ScannerSettings, *, capacity: int | None = None, whole_strip: bool = False ) -> tuple[tuple[int, ...], dict[int, Rect], Rect | None]: """(frames, per-frame windows, base window) for a BatchRequest. - The strip-dialog selection wins when present. Otherwise a transport that measures the film - gets an empty tuple, meaning every frame it finds — it has no frame range to fall back on, - so the spinbox default would silently mean frame 1. The rest fall back to that range with - the single reused scan_window. + A named selection wins. With none, a transport that measures the film gets an empty tuple, + meaning every frame it finds; a feeder gets every slot it holds, because its own frame + count reads 0 and an empty tuple there would scan nothing. """ if settings.selected_frames: frames = tuple(sorted(settings.selected_frames)) windows = {f: settings.frame_windows[f] for f in frames if f in settings.frame_windows} return frames, windows, None - if whole_strip: + if whole_strip or capacity is None: return (), {}, settings.scan_window - return tuple(range(frame_from, frame_to + 1)), {}, settings.scan_window + return tuple(range(1, capacity + 1)), {}, settings.scan_window + + +def parse_frame_spec(text: str) -> tuple[int, ...]: + """Frame numbers from an operator's list: "1-6", "1,2,5", "1-3, 5". Empty means every frame. + + Raises ValueError on anything else, so a typo cannot quietly scan the wrong film. + """ + frames: set[int] = set() + for part in text.replace(" ", "").split(","): + if not part: + continue + first, sep, last = part.partition("-") + try: + lo = int(first) + hi = int(last) if sep else lo + except ValueError: + raise ValueError(f"Cannot read {part!r} as a frame number") from None + if lo < 1 or hi < lo: + raise ValueError(f"{part!r} is not a frame range") + frames.update(range(lo, hi + 1)) + return tuple(sorted(frames)) + + +def format_frame_spec(frames: Iterable[int]) -> str: + """The compact form of a frame selection, contiguous runs collapsed: (1,2,3,5) → "1-3,5".""" + ordered = sorted(set(frames)) + if not ordered: + return "" + runs: list[list[int]] = [[ordered[0], ordered[0]]] + for frame in ordered[1:]: + if frame == runs[-1][1] + 1: + runs[-1][1] = frame + else: + runs.append([frame, frame]) + return ",".join(str(lo) if lo == hi else f"{lo}-{hi}" for lo, hi in runs) diff --git a/tests/scanners/test_scanner_settings.py b/tests/scanners/test_scanner_settings.py index 8a88cbd41..a2c30df4a 100644 --- a/tests/scanners/test_scanner_settings.py +++ b/tests/scanners/test_scanner_settings.py @@ -1,7 +1,12 @@ import json from dataclasses import asdict -from negpy.infrastructure.scanners.settings import ScannerSettings, resolve_batch_selection +from negpy.infrastructure.scanners.settings import ( + ScannerSettings, + format_frame_spec, + parse_frame_spec, + resolve_batch_selection, +) def test_scan_window_default_is_none(): @@ -54,7 +59,7 @@ def test_resolve_batch_selection_uses_dialog_selection_sorted(): selected_frames=(4, 1, 2), frame_windows={1: (0.0, 0.0, 1.0, 1.0), 4: (0.1, 0.1, 0.5, 0.5)}, ) - frames, windows, base = resolve_batch_selection(settings, 2, 3) + frames, windows, base = resolve_batch_selection(settings, capacity=6) assert frames == (1, 2, 4) assert windows == {1: (0.0, 0.0, 1.0, 1.0), 4: (0.1, 0.1, 0.5, 0.5)} assert base is None @@ -62,27 +67,75 @@ def test_resolve_batch_selection_uses_dialog_selection_sorted(): def test_resolve_batch_selection_omits_selected_frame_without_a_window(): settings = ScannerSettings(selected_frames=(1, 2), frame_windows={2: (0.1, 0.1, 0.5, 0.5)}) - frames, windows, base = resolve_batch_selection(settings, 1, 1) + frames, windows, base = resolve_batch_selection(settings, capacity=6) assert frames == (1, 2) assert windows == {2: (0.1, 0.1, 0.5, 0.5)} assert base is None -def test_resolve_batch_selection_falls_back_to_spinbox_range(): +def test_resolve_batch_selection_falls_back_to_every_slot_the_feeder_holds(): settings = ScannerSettings(scan_window=(0.2, 0.2, 0.8, 0.8)) - frames, windows, base = resolve_batch_selection(settings, 2, 5) - assert frames == (2, 3, 4, 5) + frames, windows, base = resolve_batch_selection(settings, capacity=4) + assert frames == (1, 2, 3, 4) assert windows == {} assert base == (0.2, 0.2, 0.8, 0.8) def test_a_measured_strip_with_no_selection_means_every_frame() -> None: """Its frame count is unknown until the film is measured, so a range would mean frame 1.""" - frames, windows, window = resolve_batch_selection(ScannerSettings(), 1, 1, whole_strip=True) + frames, windows, window = resolve_batch_selection(ScannerSettings(), whole_strip=True) assert frames == () and windows == {} and window is None def test_a_selection_still_wins_on_a_measured_strip() -> None: settings = ScannerSettings(selected_frames=(2, 5)) - frames, _windows, _window = resolve_batch_selection(settings, 1, 1, whole_strip=True) + frames, _windows, _window = resolve_batch_selection(settings, whole_strip=True) assert frames == (2, 5) + + +# ── the frame list an operator types ────────────────────────────────────── + + +def test_parse_frame_spec_reads_ranges_lists_and_both(): + assert parse_frame_spec("1-6") == (1, 2, 3, 4, 5, 6) + assert parse_frame_spec("1,2,5") == (1, 2, 5) + assert parse_frame_spec(" 5, 1-3 ") == (1, 2, 3, 5) + + +def test_parse_frame_spec_reads_no_frames_as_every_frame(): + assert parse_frame_spec("") == () + assert parse_frame_spec(" ") == () + + +def test_parse_frame_spec_refuses_what_it_cannot_read(): + import pytest + + for text in ("x", "2-", "-3", "0", "5-2", "1,,x"): + with pytest.raises(ValueError): + parse_frame_spec(text) + + +def test_format_frame_spec_collapses_runs(): + assert format_frame_spec(()) == "" + assert format_frame_spec((1, 2, 3, 6)) == "1-3,6" + assert format_frame_spec((4, 1, 2)) == "1-2,4" + + +def test_a_frame_spec_round_trips(): + for text in ("1-6", "1,3,5", "1-3,7-9"): + assert format_frame_spec(parse_frame_spec(text)) == text + + +def test_a_saved_frame_range_becomes_a_selection(): + restored = ScannerSettings.from_dict({"frame_from": 2, "frame_to": 4, "dpi": 4000}) + assert restored.selected_frames == (2, 3, 4) + assert restored.dpi == 4000 + + +def test_an_unset_saved_frame_range_selects_nothing(): + assert ScannerSettings.from_dict({"frame_from": 1, "frame_to": 1}).selected_frames == () + + +def test_a_key_this_version_dropped_keeps_the_rest_of_the_blob(): + restored = ScannerSettings.from_dict({"gone_in_this_version": True, "output_folder": "/scans"}) + assert restored.output_folder == "/scans" diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index df87165b1..7ca870899 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -177,7 +177,7 @@ def test_no_device_disables_controls() -> None: sidebar._update_device_caps() # no device selected 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.frame_spec_edit.isVisibleTo(sidebar) is False assert sidebar.ae_check.isVisibleTo(sidebar) is False assert sidebar.autofocus_check.isVisibleTo(sidebar) is False assert sidebar.depth_combo.isVisibleTo(sidebar) is False @@ -191,9 +191,7 @@ def test_full_capability_device_enables_coolscan_controls() -> None: assert sidebar.ae_check.isVisibleTo(sidebar) is True assert sidebar.autofocus_check.isVisibleTo(sidebar) is True assert sidebar.eject_btn.isVisibleTo(sidebar) is True - assert sidebar.frame_range_widget.isVisibleTo(sidebar) is True - assert sidebar.frame_from_spin.maximum() == 40 - assert sidebar.frame_to_spin.maximum() == 40 + assert sidebar.frame_spec_edit.isVisibleTo(sidebar) is True assert sidebar.depth_combo.isVisibleTo(sidebar) is True assert sidebar.depth_label.isVisibleTo(sidebar) is True @@ -206,7 +204,7 @@ def test_minimal_device_hides_coolscan_controls() -> None: assert sidebar.ae_check.isVisibleTo(sidebar) is False assert sidebar.autofocus_check.isVisibleTo(sidebar) is False assert sidebar.eject_btn.isVisibleTo(sidebar) is False - assert sidebar.frame_range_widget.isVisibleTo(sidebar) is False + assert sidebar.frame_spec_edit.isVisibleTo(sidebar) is False assert sidebar.depth_combo.isVisibleTo(sidebar) is False assert sidebar.depth_label.isVisibleTo(sidebar) is False assert sidebar.depth_combo.currentData() == 16 @@ -221,7 +219,7 @@ def test_se_device_shows_prescan() -> None: assert sidebar.scan_window_widget.isVisibleTo(sidebar) is False assert sidebar.ir_check.isEnabled() is True assert sidebar.me_check.isEnabled() is True - assert sidebar.frame_range_widget.isVisibleTo(sidebar) is False + assert sidebar.frame_spec_edit.isVisibleTo(sidebar) is False def test_plustek_backend_hides_sane_window_control() -> None: @@ -325,19 +323,23 @@ def test_saved_depth_wins_when_the_device_offers_it() -> None: assert sidebar.depth_combo.currentData() == 8 -def test_frame_range_keeps_from_not_after_to() -> None: - sidebar, _ = _sidebar(FULL_DEVICE) - sidebar.frame_from_spin.setValue(5) - sidebar.frame_to_spin.setValue(3) - assert sidebar.frame_from_spin.value() == 3 - assert sidebar.frame_to_spin.value() == 3 +def test_an_unreadable_frame_list_refuses_the_scan() -> None: + sidebar, controller = _sidebar(FULL_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-scan-out") + sidebar.frame_spec_edit.setText("2-") + + assert sidebar.scan_btn.isEnabled() is False + sidebar._on_scan() + assert controller.started == [] + + sidebar.frame_spec_edit.setText("2-4") + assert sidebar.scan_btn.isEnabled() is True def test_scan_on_capacity_device_routes_to_batch() -> None: sidebar, controller = _sidebar(FULL_DEVICE) sidebar.folder_edit.setText("/tmp/negpy-scan-out") - sidebar.frame_from_spin.setValue(2) - sidebar.frame_to_spin.setValue(4) + sidebar.frame_spec_edit.setText("2-4") sidebar._on_scan() @@ -564,9 +566,9 @@ def test_a_sane_device_shows_none_of_them() -> None: assert sidebar.format_combo.isVisibleTo(sidebar) is False -def test_a_measured_strip_hides_the_frame_range_and_offers_the_strip_dialog() -> None: +def test_a_measured_strip_offers_a_frame_list_and_the_strip_dialog() -> None: sidebar, _ = _sidebar(NKSCAN_DEVICE) - assert sidebar.frame_range_widget.isVisibleTo(sidebar) is False + assert sidebar.frame_spec_edit.isVisibleTo(sidebar) is True assert sidebar.scan_window_btn.text() == "Preview strip…" @@ -608,13 +610,31 @@ def test_the_nkscan_options_persist() -> None: def test_a_measured_strip_says_what_scan_would_do_before_a_preview() -> None: - """Its frames come from the strip dialog, and there is no frame range to fall back on.""" + """Nothing is cropped yet, and the summary quotes the size of one frame.""" sidebar, _ = _sidebar(NKSCAN_DEVICE) - assert sidebar.scan_window_status.text().startswith("Whole strip") + assert sidebar.scan_window_status.text().startswith("Full frame") + assert sidebar.summary_label.text().startswith("Whole strip") + + +def test_a_typed_frame_list_reaches_the_batch_without_a_preview() -> None: + sidebar, controller = _sidebar(NKSCAN_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-test") + sidebar.frame_spec_edit.setText("1,3-5") + + sidebar._on_scan() + + assert controller.started[-1][1].frames == (1, 3, 4, 5) + assert sidebar.settings.selected_frames == (1, 3, 4, 5) + + +def test_a_selection_from_the_strip_dialog_shows_in_the_frame_box() -> None: + sidebar, _ = _sidebar(NKSCAN_DEVICE, settings={"selected_frames": [1, 2, 3, 6]}) + assert sidebar.frame_spec_edit.text() == "1-3,6" def test_a_measured_strip_scans_the_frames_the_strip_dialog_picked() -> None: sidebar, controller = _sidebar(NKSCAN_DEVICE, settings={"selected_frames": [2, 4]}) + assert sidebar.frame_spec_edit.text() == "2,4" sidebar.folder_edit.setText("/tmp/negpy-test") sidebar._on_scan() assert controller.started[-1][1].frames == (2, 4) From 82b962d80bddf3a2f09869d72fa18f78df1a98df Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 24 Aug 2026 19:35:59 +0200 Subject: [PATCH 5/8] fix(scan): keep a half-written scan out of a watched hot folder Every scan is written to a temp file in the output folder and renamed into place, and the temp file carried the final extension. With that folder open as a hot folder, the watcher indexes on extension alone, so it picked the file up mid-write, then the rename took it away: a dead tile and a Thumbnail Error in the log for every frame of a batch. The part file takes the .part suffix export and the sidecar writer already use, which no loader accepts. Nothing needs to know about scanning, and the other discovery paths are covered by the same change. --- negpy/services/scanning/writer.py | 16 +++++++--- tests/scanners/test_writer.py | 51 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/negpy/services/scanning/writer.py b/negpy/services/scanning/writer.py index 268cd42f0..b9a62f481 100644 --- a/negpy/services/scanning/writer.py +++ b/negpy/services/scanning/writer.py @@ -11,6 +11,12 @@ logger = get_logger(__name__) +# Extension for the file an atomic write goes to before its rename, the same one export and +# the sidecar writer use. Deliberately not one a loader accepts: the output folder may be a +# watched hot folder, which indexes on extension and would take a half-written scan for a +# new asset. +_PART_SUFFIX = ".part" + def _to_uint16(arr: np.ndarray) -> np.ndarray: """Convert array to uint16. For uint8, replicate byte (x<<8 | x) so 8-bit @@ -26,7 +32,7 @@ def _to_uint16(arr: np.ndarray) -> np.ndarray: def write_tiff_16bit(result: ScanResult, path: str) -> str: """Write ScanResult to 16-bit TIFF. IR written as sidecar `_IR.tif`. - Uses atomic write (write to .tmp then rename) to avoid partial files. + Uses atomic write (write to a part file, then rename) to avoid partial files. Returns final RGB path. """ if not path.lower().endswith((".tif", ".tiff")): @@ -34,7 +40,7 @@ def write_tiff_16bit(result: ScanResult, path: str) -> str: rgb = _to_uint16(result.rgb) - fd, tmp_path = tempfile.mkstemp(suffix=".tif", dir=os.path.dirname(path) or ".") + fd, tmp_path = tempfile.mkstemp(suffix=_PART_SUFFIX, dir=os.path.dirname(path) or ".") os.close(fd) try: tifffile.imwrite(tmp_path, rgb, photometric="rgb", compression="zlib", predictor=True) @@ -48,7 +54,7 @@ def write_tiff_16bit(result: ScanResult, path: str) -> str: base = os.path.splitext(path)[0] ir_path = f"{base}_IR.tif" ir_data = _to_uint16(result.ir) - fd_ir, tmp_ir = tempfile.mkstemp(suffix=".tif", dir=os.path.dirname(ir_path) or ".") + fd_ir, tmp_ir = tempfile.mkstemp(suffix=_PART_SUFFIX, dir=os.path.dirname(ir_path) or ".") os.close(fd_ir) try: tifffile.imwrite(tmp_ir, ir_data, photometric="minisblack", compression="zlib", predictor=True) @@ -64,7 +70,7 @@ def write_tiff_16bit(result: ScanResult, path: str) -> str: base = os.path.splitext(path)[0] valid_path = f"{base}_IR_VALID.tif" valid_data = np.asarray(result.ir_valid_mask).astype(np.uint8) * 255 - fd_v, tmp_v = tempfile.mkstemp(suffix=".tif", dir=os.path.dirname(valid_path) or ".") + fd_v, tmp_v = tempfile.mkstemp(suffix=_PART_SUFFIX, dir=os.path.dirname(valid_path) or ".") os.close(fd_v) try: tifffile.imwrite(tmp_v, valid_data, photometric="minisblack", compression="zlib", predictor=True) @@ -109,7 +115,7 @@ def write_dng_linear(result: ScanResult, path: str) -> str: ] payload = _encode_dng(full_array, extratags) - fd, tmp_path = tempfile.mkstemp(suffix=".dng", dir=os.path.dirname(path) or ".") + fd, tmp_path = tempfile.mkstemp(suffix=_PART_SUFFIX, dir=os.path.dirname(path) or ".") os.close(fd) try: with open(tmp_path, "wb") as fh: diff --git a/tests/scanners/test_writer.py b/tests/scanners/test_writer.py index d43266191..e25513476 100644 --- a/tests/scanners/test_writer.py +++ b/tests/scanners/test_writer.py @@ -130,3 +130,54 @@ def test_adds_dng_extension(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = write_dng_linear(result, os.path.join(tmpdir, "noext")) assert path.endswith(".dng") + + +class TestHotFolderSeesOnlyFinishedScans: + """The output folder may be watched, and it indexes on extension alone.""" + + def test_the_tiff_being_written_is_not_offered_to_the_watcher(self, monkeypatch) -> None: + from negpy.infrastructure.filesystem.watcher import FolderWatchService + + rgb = np.random.randint(0, 65535, (60, 80, 3), dtype=np.uint16) + ir = np.random.randint(0, 65535, (60, 80), dtype=np.uint16) + result = ScanResult(rgb=rgb, ir=ir, dpi=3600, device_model="TestScanner") + offered: list[str] = [] + real_imwrite = tifffile.imwrite + + with tempfile.TemporaryDirectory() as tmpdir: + + def watching_imwrite(file, data, **kwargs): + # Probe with the half-written file still on disk, before its rename. + real_imwrite(file, data, **kwargs) + found = FolderWatchService.scan_for_new_files(tmpdir, set()) + assert os.path.abspath(str(file)) not in found + offered.extend(found) + + monkeypatch.setattr(tifffile, "imwrite", watching_imwrite) + path = write_tiff_16bit(result, os.path.join(tmpdir, "scan_001")) + + assert offered # the probe ran, and only ever saw finished scans + assert FolderWatchService.scan_for_new_files(tmpdir, set()) == [os.path.abspath(path)] + + def test_the_dng_being_written_is_not_offered_to_the_watcher(self, monkeypatch) -> None: + from negpy.infrastructure.filesystem.watcher import FolderWatchService + + rgb = np.random.randint(0, 65535, (60, 80, 3), dtype=np.uint16) + result = ScanResult(rgb=rgb, ir=None, dpi=3600, device_model="TestScanner") + probes = 0 + real_replace = os.replace + + with tempfile.TemporaryDirectory() as tmpdir: + + def watching_replace(src, dst): + # The written file is complete here, but still under its part name. + nonlocal probes + probes += 1 + assert FolderWatchService.scan_for_new_files(tmpdir, set()) == [] + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", watching_replace) + path = write_dng_linear(result, os.path.join(tmpdir, "scan_002")) + + assert probes == 1 + assert FolderWatchService.scan_for_new_files(tmpdir, set()) == [os.path.abspath(path)] From 37cbf170443888370276cff5bd500ec859e8d112 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 25 Aug 2026 16:31:07 +0200 Subject: [PATCH 6/8] fix(scan): let only one of IR and ICE be asked for, and gate both on the film IR and ICE read the same infrared pass, and ICE bakes its repair into the file, so a raw IR plane beside it has only cleaned pixels to find dust in. Ticking one now unticks the other, and a saved pair comes back as ICE alone. The film gate that greys both out on B&W and Kodachrome ran before the ICE capability and the device's film list were read, so it judged the previous device: switching to a Coolscan with B&W saved left IR enabled, and switching to one with colour negative left ICE greyed out. It runs after both now. Claude-Session: https://claude.ai/code/session_01H1QXANZKX1HEEQKu4oeipf --- docs/USER_GUIDE.md | 2 +- negpy/desktop/view/sidebar/scan.py | 20 +++++++++++-- tests/test_scan_sidebar.py | 47 ++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9d84505ac..e0077922d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -889,7 +889,7 @@ Capture film directly into NegPy. Two collapsible sections: **Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass — the tiles appear as the frames turn up, and there is no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** (±2.5 mm, either way, since the frame is re-addressed rather than fed past) and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, type it in **Frames**, or untick frames in **Preview strip…** — each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. Either way the selection shows in **Frames**, and ejecting the film clears it — the frames and their crops describe the piece of film that just came out. **Offset** and **Drift** survive an eject, because they register the transport rather than one strip. Four controls appear only on this backend: - * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. + * **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. **ICE** and **IR** exclude each other, because they read the same pass: ticking one unticks the other. Tick **IR** to keep the plane and clean the file later in Retouch, **ICE** to have the scanner do it now. * **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time. * **Superfine**: read one line per pass. Slower, and free of the line registration the faster three-line mode owes the host. * **Film**: what is on the film — Color negative, B&W negative, Slide or Kodachrome. It decides three things: which way the frame boundaries read when the strip is measured, whether IR and ICE are offered at all (B&W and Kodachrome stop infrared with silver and dyes, so the mask comes back as the picture rather than the dust on it), and how the frame is metered. A color negative is metered one channel at a time, which takes the orange mask off before the converter instead of quantizing the blue record through it; every other film keeps its factory balance, because there the cast is the picture. diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 9cfe2d584..7fbeccafe 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -382,11 +382,11 @@ def _connect_signals(self) -> None: self.fmt_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui()) self.dpi_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui()) self.depth_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui()) - self.ir_check.toggled.connect(lambda: self._update_settings_from_ui()) + self.ir_check.toggled.connect(lambda on: self._on_ir_pass_toggled(self.clean_check, on)) self.me_check.toggled.connect(lambda: self._update_settings_from_ui()) self.autofocus_check.toggled.connect(lambda: self._update_settings_from_ui()) self.ae_check.toggled.connect(lambda: self._on_ae_toggled()) - self.clean_check.toggled.connect(lambda: self._update_settings_from_ui()) + self.clean_check.toggled.connect(lambda on: self._on_ir_pass_toggled(self.ir_check, on)) self.superfine_check.toggled.connect(lambda: self._update_settings_from_ui()) self.samples_combo.currentIndexChanged.connect(lambda: self._update_settings_from_ui()) self.format_combo.currentIndexChanged.connect(lambda: self._update_settings_from_ui()) @@ -609,7 +609,6 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: else: self.ir_check.setChecked(False) self.ir_check.setToolTip("IR scanning not supported by this device") - self._apply_film_type_to_ir() # Multi-exposure (Plustek SE only today) self.me_check.setVisible(bool(caps.multi_exposure)) @@ -648,6 +647,8 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.clean_check.setVisible(self._caps_clean) self.clean_check.setChecked(self._caps_clean and self._settings.clean) self.clean_check.blockSignals(False) + if self.clean_check.isChecked(): + self.ir_check.setChecked(False) self.superfine_check.blockSignals(True) self.superfine_check.setVisible(self._caps_superfine) @@ -679,6 +680,10 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.film_type_combo.setVisible(bool(self._caps_film_types)) self.film_type_combo.blockSignals(False) + # Last: it gates IR and ICE on the film, so it needs both the ICE capability and the + # film list this device offers, which are read further up. + self._apply_film_type_to_ir() + self._caps_film_formats = tuple(caps.film_formats) self.format_combo.blockSignals(True) self.format_combo.clear() @@ -774,6 +779,15 @@ def _apply_film_type_to_ir(self) -> None: self.ir_check.setToolTip(reason) self.clean_check.setToolTip(reason) + def _on_ir_pass_toggled(self, other: QCheckBox, checked: bool) -> None: + """IR and ICE read the same pass, and ICE bakes its repair into the file, so a raw IR + plane beside it would only have cleaned pixels to detect dust in.""" + if checked and other.isChecked(): + other.blockSignals(True) + other.setChecked(False) + other.blockSignals(False) + self._update_settings_from_ui() + def _samples(self) -> int: if self._caps_max_samples <= 1: return 1 diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index 7ca870899..f9ca105e8 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -702,12 +702,52 @@ def test_a_film_that_blocks_infrared_disables_ir_and_ice() -> None: assert "blocks infrared" in sidebar.ir_check.toolTip() +def test_ir_and_ice_untick_each_other() -> None: + """Both read the same pass, and ICE bakes its repair in, so only one can be asked for.""" + sidebar, _ = _sidebar(NKSCAN_DEVICE) + + sidebar.ir_check.setChecked(True) + sidebar.clean_check.setChecked(True) + assert (sidebar.ir_check.isChecked(), sidebar.clean_check.isChecked()) == (False, True) + assert (sidebar.settings.capture_ir, sidebar.settings.clean) == (False, True) + + sidebar.ir_check.setChecked(True) + assert (sidebar.ir_check.isChecked(), sidebar.clean_check.isChecked()) == (True, False) + assert (sidebar.settings.capture_ir, sidebar.settings.clean) == (True, False) + + +def test_a_saved_ir_and_ice_pair_comes_back_as_ice_alone() -> None: + saved = {"backend": "nkscan", "capture_ir": True, "clean": True} + sidebar, _ = _sidebar(NKSCAN_DEVICE, settings=saved) + assert (sidebar.ir_check.isChecked(), sidebar.clean_check.isChecked()) == (False, True) + + def test_kodachrome_blocks_infrared_too() -> None: sidebar, _ = _sidebar(NKSCAN_DEVICE) sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("kodachrome")) assert sidebar.clean_check.isEnabled() is False +def test_a_saved_bw_film_greys_ir_and_ice_out_on_the_device_it_is_switched_to() -> None: + """The film gate reads the ICE capability and the film list, so it runs after both.""" + sidebar, _ = _sidebar(settings={"backend": "nkscan", "film_type": "mono"}) + sidebar._on_devices_ready([FULL_DEVICE, NKSCAN_DEVICE]) + sidebar.device_combo.setCurrentIndex(1) + + assert sidebar.film_type_combo.currentData() == "mono" + assert sidebar.ir_check.isEnabled() is False and sidebar.ir_check.isChecked() is False + assert sidebar.clean_check.isEnabled() is False and sidebar.clean_check.isChecked() is False + + +def test_colour_negative_leaves_ice_usable_on_the_device_it_is_switched_to() -> None: + sidebar, _ = _sidebar(settings={"backend": "nkscan", "film_type": "negative"}) + sidebar._on_devices_ready([SE_DEVICE, NKSCAN_DEVICE]) + sidebar.device_combo.setCurrentIndex(1) + + assert sidebar.ir_check.isEnabled() is True + assert sidebar.clean_check.isEnabled() is True + + def test_going_back_to_colour_negative_restores_them() -> None: sidebar, _ = _sidebar(NKSCAN_DEVICE) sidebar.film_type_combo.setCurrentIndex(sidebar.film_type_combo.findData("mono")) @@ -770,12 +810,13 @@ def test_the_summary_quotes_a_per_frame_size_for_an_unmeasured_strip() -> None: def test_the_summary_names_the_extra_passes() -> None: sidebar, _ = _sidebar(NKSCAN_DEVICE) sidebar.ir_check.setChecked(True) - sidebar.clean_check.setChecked(True) sidebar.samples_combo.setCurrentIndex(sidebar.samples_combo.findData(4)) - text = sidebar.summary_label.text() + assert "IR" in sidebar.summary_label.text() and "4× sampled" in sidebar.summary_label.text() + + sidebar.clean_check.setChecked(True) - assert "IR" in text and "ICE" in text and "4× sampled" in text + assert "ICE" in sidebar.summary_label.text() and "IR" not in sidebar.summary_label.text() def test_a_single_holder_device_summarizes_one_frame() -> None: From fac40285c05b786a7417f614703658be91f0cad0 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 25 Aug 2026 18:43:31 +0200 Subject: [PATCH 7/8] fix(scan): give Offset and Drift the width the strip dialog row has left Both sliders were pinned to 160 px, so they took a small part of a wide dialog while a stretch ate the rest. They carry the framing, so they now split the free width and the Detect frames button keeps its own. The value labels are pinned to their widest reading: a label that grows with the value takes the width out of the slider it belongs to, and the handle steps sideways under the cursor mid-drag. Claude-Session: https://claude.ai/code/session_01TS769PZf9ifizfBTvFgHYx --- .../view/widgets/strip_preview_dialog.py | 13 ++++++---- tests/test_strip_preview_dialog.py | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 3b5df0dc7..7faad2285 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -185,7 +185,7 @@ def __init__( ) self.offset_slider.setSingleStep(1) self.offset_slider.setPageStep(5) - self.offset_slider.setFixedWidth(160) + self.offset_slider.setMinimumWidth(160) # Not floored at 0: a measured strip's saved offset may be negative, and the range # clamps it either way. self.offset_slider.setValue(int(round(float(initial_offset) * 10))) @@ -194,8 +194,11 @@ def __init__( if self._discovers else "Feed-axis offset applied to every frame (the transport cannot back up)" ) - top.addWidget(self.offset_slider) + top.addWidget(self.offset_slider, 1) self.offset_label = QLabel() + # Pinned to its widest reading: a label that grows with the value would take the width + # out of the slider it belongs to, and the handle would step sideways under the cursor. + self.offset_label.setFixedWidth(self.offset_label.fontMetrics().horizontalAdvance("-99.9 mm")) top.addWidget(self.offset_label) top.addSpacing(16) top.addWidget(QLabel("Drift")) @@ -203,13 +206,14 @@ def __init__( self.drift_slider.setRange(-250, 250) # hundredths of a mm → ±2.50 mm/frame self.drift_slider.setSingleStep(1) self.drift_slider.setPageStep(10) - self.drift_slider.setFixedWidth(160) + self.drift_slider.setMinimumWidth(160) self.drift_slider.setValue(int(round(float(initial_offset_modifier) * 100))) self.drift_slider.setToolTip( "Extra offset added per frame position (mm/frame) — corrects progressive frame-gap drift along the strip" ) - top.addWidget(self.drift_slider) + top.addWidget(self.drift_slider, 1) self.drift_label = QLabel() + self.drift_label.setFixedWidth(self.drift_label.fontMetrics().horizontalAdvance("-9.99 mm/frame")) top.addWidget(self.drift_label) top.addSpacing(16) # A measured strip previews out of its own pass, whose resolution nothing chooses. @@ -223,7 +227,6 @@ def __init__( self.preview_dpi_combo.setVisible(not self._discovers) top.addWidget(self.preview_dpi_label) top.addWidget(self.preview_dpi_combo) - top.addStretch() label = " Detect frames" if self._discovers else " Preview all" self.preview_all_btn = QPushButton(qta.icon("fa5s.eye", color=THEME.text_primary), label) self.preview_all_btn.clicked.connect(self._on_preview_all) diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index 5c78c20c3..bd24ca51f 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -202,6 +202,31 @@ def test_offset_and_drift_sliders_reset_to_zero_on_double_click() -> None: assert dialog.drift_label.text() == "+0.00 mm/frame" +def test_the_offset_and_drift_sliders_share_the_width_the_row_has_left() -> None: + """They carry the framing, so they take the row rather than a fixed 160 px of it. The + button keeps its own width, and the value labels are pinned so a wide reading cannot + shrink the slider it belongs to.""" + dialog = StripPreviewDialog(_FakeController(), _device(3)) + dialog.show() + + widths = [] + for width in (800, 1400): + dialog.resize(width, 700) + dialog.layout().activate() + widths.append((dialog.offset_slider.width(), dialog.drift_slider.width())) + assert abs(dialog.offset_slider.width() - dialog.drift_slider.width()) <= 1 + assert dialog.preview_all_btn.width() == dialog.preview_all_btn.sizeHint().width() + + assert widths[1][0] > widths[0][0] + + before = dialog.offset_slider.width() + dialog.offset_slider.setValue(dialog.offset_slider.maximum()) + dialog.drift_slider.setValue(dialog.drift_slider.minimum()) + dialog.layout().activate() + + assert dialog.offset_slider.width() == before + + def test_preview_dpi_dropdown_defaults_to_lowest_and_flows_into_requests() -> None: controller = _FakeController() dialog = StripPreviewDialog(controller, _device(3)) # supported_dpi=(1000, 4000) From cd5ce2aca322a6a9f7076ddd1712b6600404b574 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 25 Aug 2026 19:29:43 +0200 Subject: [PATCH 8/8] feat(scan): make the scan surfaces say what they will do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass over the Scan panel and the three preview dialogs. The commit button asked for an object name that no rule matched, so the one control that moves the transport and writes files rendered like Browse. It is filled now, and goes hollow while it reads Stop. The line that says what a scan will cost was drawn in the placeholder grey, which is under AA at that size. It carries secondary weight, with the frame count and the byte figure in primary. The three dialogs ended in three different sentences. Every footer now reads reset · Cancel · Apply · Scan : the middle exit names what it keeps, and Enter scans once frames are ticked rather than walking back to the panel. Prescan gains the Scan exit it never had and loses Rescan from among its exits, since acquisition belongs at the top with the other two dialogs. Each surface stacked a progress bar, a status line and a summary that came and went independently, so the button underneath moved as a scan started or failed. StatusStrip reserves one row and shows the running pass, the message it left, or the resting summary. The strip dialog opened with ninety words above the tiles. One line stays; Offset and Drift explain themselves on their own sliders, and the ⓘ opens the rest from the user guide. Both sliders take the width the row has left, with the reading above the groove like the panel sliders — beside it, mm/frame clipped. Claude-Session: https://claude.ai/code/session_01TS769PZf9ifizfBTvFgHYx --- docs/USER_GUIDE.md | 14 +- negpy/desktop/view/sidebar/scan.py | 101 +++++------ negpy/desktop/view/styles/modern_dark.qss | 38 +++++ negpy/desktop/view/styles/templates.py | 80 ++++++++- negpy/desktop/view/widgets/prescan_dialog.py | 70 +++++--- .../view/widgets/quick_scan_preview_dialog.py | 47 +++--- .../view/widgets/scan_preview_common.py | 6 +- .../view/widgets/strip_preview_dialog.py | 151 ++++++++--------- tests/test_prescan_dialog.py | 157 ++++++++++++++++++ tests/test_quick_scan_preview_dialog.py | 20 ++- tests/test_scan_sidebar.py | 105 ++++++++++-- tests/test_section_help_dialog.py | 6 +- tests/test_strip_preview_dialog.py | 77 ++++++--- 13 files changed, 652 insertions(+), 220 deletions(-) create mode 100644 tests/test_prescan_dialog.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e0077922d..1f0923a14 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -881,7 +881,7 @@ Capture film directly into NegPy. Two collapsible sections: * **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them — **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and **Output** (format, folder, filename template) — and a group's header disappears with the whole group when the device has nothing in it. **Frames** takes the frames to scan as a list — `1-6`, `1,2,5`, or empty for every frame on the film — and the strip preview writes its picks there, so a selection can be changed without previewing again. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and 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. - **pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. + **pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then leave with **Apply crop** or **Scan frame** — either way the next scan reads that hardware ROI at the chosen DPI, not a software crop. **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. With **IR** checked, colour and infrared come back in one scan pass; pyopticfilm aligns the IR plane to the colour frame. 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. @@ -901,11 +901,21 @@ Capture film directly into NegPy. Two collapsible sections: **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. - A preview holds the scanner for the whole pass, so while one runs a progress bar tracks it, **Cancel** reads **Stop preview** — it abandons the pass and keeps the tiles already in hand — and **Use** and **Scan** stay dark until the pass ends. Previews read the way the **Film** setting says: negative stock is inverted, Slide and Kodachrome are not. + A preview holds the scanner for the whole pass, so while one runs a progress bar tracks it, **Cancel** reads **Stop preview** — it abandons the pass and keeps the tiles already in hand — and the **Apply** and **Scan** exits stay dark until the pass ends. Previews read the way the **Film** setting says: negative stock is inverted, Slide and Kodachrome are not. * **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 Trichrome Scan mode. Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md). + +### Strip preview + +Every preview dialog ends the same way: **Cancel**, then **Apply** (keep the framing and go back to the panel) and **Scan** (start the scan from here). The Apply button names what it keeps — **Apply framing** on a strip, **Apply window** on a single holder, **Apply crop** after a Prescan. + +* **Cropping**: drag on a previewed frame — a corner resizes, inside moves. Each frame keeps its own window, and **Clear crops** drops the lot. +* **Offset**: slides every frame along the film to clear the inter-frame gap. Frames shift left as it grows, live. The shaded band on the right is film past the frame boundary the transport cannot deliver, so offset past the gap costs frame tail. A feeder cannot back up, so there it only goes one way. +* **Drift**: adds progressively more (or less) offset per frame position, for a strip whose gaps creep along its length. Re-preview to refresh the pixels. +* **Which frames**: each tile carries its own tick; **All** and **None** move the lot, and the count says how many will be scanned. On a measured strip the ticks and crops describe the piece of film in the transport, so ejecting clears them; Offset and Drift survive, because they register the transport. + --- ## 14. Preferences diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 7fbeccafe..f50a057f6 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -10,7 +10,6 @@ QLabel, QLineEdit, QMessageBox, - QProgressBar, QPushButton, QSlider, QVBoxLayout, @@ -19,7 +18,7 @@ from negpy.kernel.system.text import count_of, human_bytes from negpy.desktop.view.sidebar.base import install_wheel_guards -from negpy.desktop.view.styles.templates import hint_label, section_subheader +from negpy.desktop.view.styles.templates import StatusStrip, hint_label, section_subheader from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice from negpy.infrastructure.scanners.params import FILM_TYPES, FilmType, film_passes_infrared @@ -338,26 +337,18 @@ def _init_ui(self) -> None: layout.addLayout(self.form) - # ── PROGRESS ──────────────────────────────────────── - self.progress_bar = QProgressBar() - self.progress_bar.setVisible(False) - self.progress_bar.setRange(0, 100) - self.progress_bar.setValue(0) - self.progress_bar.setFormat("Scanning… %p%") - layout.addWidget(self.progress_bar) - - # ── STATUS ────────────────────────────────────────── - self.status_label = hint_label("") - layout.addWidget(self.status_label) - - # ── SUMMARY + SCAN BUTTON ─────────────────────────── - self.summary_label = hint_label("") - layout.addWidget(self.summary_label) + # ── STATUS + SCAN BUTTON ──────────────────────────── + # One reserved row for all three: the pass that is running, the message it left, and + # the resting summary of what Scan will cost. Three rows that come and go move the + # button under them, which is where the cursor already is. + self.status_strip = StatusStrip() + layout.addWidget(self.status_strip) self.scan_btn = QPushButton(" Scan") self.scan_btn.setObjectName("scan_btn") self.scan_btn.setFixedHeight(40) - self.scan_btn.setIcon(qta.icon("fa5s.camera-retro", color=THEME.text_primary)) + self.scan_btn.setProperty("scanning", "false") + self.scan_btn.setIcon(qta.icon("fa5s.camera-retro", color="#FFFFFF")) layout.addWidget(self.scan_btn) layout.addStretch() @@ -425,7 +416,7 @@ def _request_devices(self) -> None: self.device_combo.clear() self.device_combo.addItem("Detecting scanners…", None) self.device_combo.setEnabled(False) - self.status_label.setText("Detecting scanners…") + self.status_strip.set_message("Detecting scanners…") self.controller.request_scan_devices() def _on_refresh(self) -> None: @@ -446,7 +437,7 @@ def _on_eject(self) -> None: if device is None: return self.eject_btn.setEnabled(False) - self.status_label.setText("Ejecting film…") + self.status_strip.set_message("Ejecting film…") self.controller.eject_scanner(device.id) @pyqtSlot(list) @@ -459,7 +450,7 @@ def _on_devices_ready(self, devices: list) -> None: if not devices: self.device_combo.addItem("No scanners detected", None) self.device_combo.setEnabled(False) - self.status_label.setText("No scanners detected. Plug in your scanner and click Refresh.") + self.status_strip.set_message("No scanners detected. Plug in your scanner and click Refresh.") self.scan_btn.setEnabled(False) return @@ -545,10 +536,10 @@ def _update_device_caps(self) -> None: # If no film sources, show banner if not caps.sources: - self.status_label.setText("This scanner reports no film/transparency sources. NegPy v1 supports film scanning only.") + self.status_strip.set_message("This scanner reports no film/transparency sources. NegPy v1 supports film scanning only.") self.scan_btn.setEnabled(False) else: - self.status_label.setText("") + self.status_strip.set_message("") self.scan_btn.setEnabled(True) self._populate_form(caps) @@ -908,6 +899,8 @@ def _on_prescan(self) -> None: self.settings = replace(self._settings, scan_window=dialog.scan_window()) self._update_prescan_status() self._save_settings() + if dialog.scan_requested(): + self._on_scan() def _on_clear_prescan_crop(self) -> None: from dataclasses import replace @@ -978,12 +971,12 @@ def _update_summary(self) -> None: device = self._current_device() if device is None: - self.summary_label.setText("") + self.status_strip.set_summary("") return caps = device.capabilities spec = self._frame_spec() if spec is None: - self.summary_label.setText("Frames: cannot read that") + self.status_strip.set_summary("Frames: cannot read that") self.scan_btn.setEnabled(False) return if not self._scanning: @@ -1011,8 +1004,16 @@ def _update_summary(self) -> None: passes.append(f"{self._samples()}× sampled") if self.me_check.isEnabled() and self.me_check.isChecked(): passes.append("Multi-exposure") - parts = [count_of(len(frames), "frame") if frames else "Whole strip", f"{dpi} dpi", *passes, size] - self.summary_label.setText(" · ".join(parts)) + # The count and the size are what the operator checks before committing, so they carry + # primary weight; the rest of the line stays secondary. + strong = f'{{}}' + parts = [ + strong.format(count_of(len(frames), "frame") if frames else "Whole strip"), + f"{dpi} dpi", + *passes, + strong.format(size), + ] + self.status_strip.set_summary(" · ".join(parts)) def _on_browse(self) -> None: folder = QFileDialog.getExistingDirectory(self, "Select Output Folder") @@ -1111,43 +1112,41 @@ def _on_scan(self) -> None: ) except RuntimeError as e: self.set_scanning(False) - self.status_label.setText(f"Scanner busy: {e}") + self.status_strip.set_message(f"Scanner busy: {e}") @pyqtSlot(float, str) def _on_scan_progress(self, progress: float, phase_name: str = "Scanning") -> None: - self.progress_bar.setVisible(True) - self.progress_bar.setFormat(f"{phase_name}… %p%") - self.progress_bar.setValue(int(progress * 100)) + self.status_strip.set_progress(f"{phase_name}… %p%", progress) @pyqtSlot(str) def _on_scan_finished(self, path: str) -> None: self.set_scanning(False) - self.progress_bar.setVisible(False) - self.status_label.setText(f"Scanned: {path}") + self.status_strip.stop_progress() + self.status_strip.set_message(f"Scanned: {path}") @pyqtSlot(int, str) def _on_scan_frame_done(self, frame: int, path: str) -> None: - self.status_label.setText(f"Scanned frame {frame}: {path}") + self.status_strip.set_message(f"Scanned frame {frame}: {path}") @pyqtSlot(list) def _on_scan_batch_finished(self, paths: list) -> None: self.set_scanning(False) - self.progress_bar.setVisible(False) + self.status_strip.stop_progress() if paths: - self.status_label.setText(f"Batch complete: {count_of(len(paths), 'frame')}") + self.status_strip.set_message(f"Batch complete: {count_of(len(paths), 'frame')}") @pyqtSlot() def _on_scan_cancelled(self) -> None: self.set_scanning(False) - self.progress_bar.setVisible(False) - self.status_label.setText("Scan stopped") + self.status_strip.stop_progress() + self.status_strip.set_message("Scan stopped") @pyqtSlot(str) def _on_scan_error(self, msg: str) -> None: self.set_scanning(False) - self.progress_bar.setVisible(False) + self.status_strip.stop_progress() text = msg or "Unknown scan error" - self.status_label.setText(f"Error: {text}") + self.status_strip.set_message(f"Error: {text}") # Unsupported pyOpticfilm models: status alone is easy to miss. if "cannot scan with pyOpticfilm" in text: QMessageBox.warning(self, "Scan failed", text) @@ -1159,7 +1158,7 @@ def _on_ejected(self, triggered: bool) -> None: device = self._current_device() self.eject_btn.setEnabled(bool(device and device.capabilities.can_eject) and not self._scanning) if not triggered: - self.status_label.setText("This device has no eject control") + self.status_strip.set_message("This device has no eject control") return # Frames and their crops describe the piece of film that just came out; the next strip # is a different one, and silently reusing them scans the wrong frames. @@ -1168,13 +1167,13 @@ def _on_ejected(self, triggered: bool) -> None: self.settings = replace(self._settings, selected_frames=(), frame_windows={}) self._update_scan_window_status() self._update_summary() - self.status_label.setText("Film ejected — frame selection cleared" if stale else "Film ejected") + self.status_strip.set_message("Film ejected — frame selection cleared" if stale else "Film ejected") @pyqtSlot(str) def _on_eject_error(self, msg: str) -> None: device = self._current_device() self.eject_btn.setEnabled(bool(device and device.capabilities.can_eject) and not self._scanning) - self.status_label.setText(f"Eject failed: {msg}") + self.status_strip.set_message(f"Eject failed: {msg}") # ── state helpers ───────────────────────────────────────────────── @@ -1185,16 +1184,24 @@ def set_scanning(self, active: bool) -> None: self.eject_btn.setEnabled(bool(device and device.capabilities.can_eject) and not active) if active: self.scan_btn.setText(" Stop") - self.scan_btn.setIcon(qta.icon("fa5s.stop", color=THEME.text_primary)) - self.progress_bar.setVisible(True) - self.progress_bar.setValue(0) + self.scan_btn.setIcon(qta.icon("fa5s.stop", color=THEME.accent_secondary)) + self.status_strip.start_progress("Scanning… %p%") self.prescan_btn.setEnabled(False) else: self.scan_btn.setText(" Scan") - self.scan_btn.setIcon(qta.icon("fa5s.camera-retro", color=THEME.text_primary)) + self.scan_btn.setIcon(qta.icon("fa5s.camera-retro", color="#FFFFFF")) self.prescan_btn.setEnabled(True) + self.status_strip.stop_progress() + # The filled/hollow swap is a QSS property selector, and Qt only re-reads those on a + # repolish. + self.scan_btn.setProperty("scanning", "true" if active else "false") + style = self.scan_btn.style() + style.unpolish(self.scan_btn) + style.polish(self.scan_btn) def _update_settings_from_ui(self) -> None: + # Editing anything means the last pass's message has been read: let the summary back. + self.status_strip.set_message("") dpi = self._dpi() try: depth = int(self.depth_combo.currentData() or 16) diff --git a/negpy/desktop/view/styles/modern_dark.qss b/negpy/desktop/view/styles/modern_dark.qss index f27a7ae7e..2b3463450 100644 --- a/negpy/desktop/view/styles/modern_dark.qss +++ b/negpy/desktop/view/styles/modern_dark.qss @@ -78,6 +78,44 @@ QPushButton:checked:hover { border: 1px solid @accent_secondary; } +/* The Scan panel's commit button: the only control that moves the transport and + writes files, so it is the one filled button in the app. While a scan runs it + reads "Stop" and goes hollow — the same accent, but not an invitation. */ +QPushButton#scan_btn { + background-color: @accent_primary; + color: #FFFFFF; + border: 1px solid @accent_secondary; + border-radius: 4px; + font-weight: 600; +} + +QPushButton#scan_btn:hover { + background-color: @accent_secondary; + border: 1px solid @accent_secondary; +} + +QPushButton#scan_btn:pressed { + background-color: #8E1616; + color: #FFFFFF; +} + +QPushButton#scan_btn:disabled { + background-color: #1A1414; + color: #666666; + border: 1px solid #2A1D1D; +} + +QPushButton#scan_btn[scanning="true"] { + background-color: transparent; + color: @accent_secondary; + border: 1px solid @accent_primary; +} + +QPushButton#scan_btn[scanning="true"]:hover { + background-color: #2A1D1D; + color: #FFFFFF; +} + QToolButton { background-color: transparent; border: 1px solid transparent; diff --git a/negpy/desktop/view/styles/templates.py b/negpy/desktop/view/styles/templates.py index 8cc1fb262..0f5c9853b 100644 --- a/negpy/desktop/view/styles/templates.py +++ b/negpy/desktop/view/styles/templates.py @@ -2,8 +2,8 @@ import html import qtawesome as qta -from PyQt6.QtCore import QEvent -from PyQt6.QtWidgets import QLabel, QPushButton, QWidget +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtWidgets import QLabel, QProgressBar, QPushButton, QStackedLayout, QWidget from negpy.desktop.view.styles.fonts import ui_font_family from negpy.desktop.view.styles.theme import THEME @@ -118,6 +118,82 @@ def _reposition(self) -> None: self.move(parent.width() - self.width() - self._margin, self._margin) +class StatusStrip(QWidget): + """One fixed-height row for a surface's transient state: the pass that is running, the + message it left behind, or the resting summary — whichever is current, in that order. + + The height is reserved once, at construction. That is the whole point: a scan surface + that shows each of these in its own appearing row moves the button underneath them. + """ + + def __init__(self, parent: QWidget | None = None, lines: int = 2) -> None: + super().__init__(parent) + self._stack = QStackedLayout(self) + self._stack.setContentsMargins(0, 0, 0, 0) + + self._summary = QLabel("") + self._summary.setWordWrap(True) + self._summary.setStyleSheet(f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;") + + self._message = QLabel("") + self._message.setWordWrap(True) + self._message.setStyleSheet(f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;") + + self._bar = QProgressBar() + self._bar.setRange(0, 100) + self._bar.setValue(0) + + for w in (self._summary, self._message, self._bar): + self._stack.addWidget(w) + self._stack.setAlignment(Qt.AlignmentFlag.AlignVCenter) + + self.setFixedHeight(self._summary.fontMetrics().lineSpacing() * lines + THEME.space_lg) + self._running = False + self._show_current() + + def set_summary(self, markup: str) -> None: + """The resting line. Rich text: callers weight the parts that matter.""" + self._summary.setText(markup) + self._show_current() + + def set_message(self, text: str) -> None: + """A result, a warning or an error. Empty falls back to the summary.""" + self._message.setText(text) + self._message.setToolTip(text) # the strip clips rather than grows; nothing is lost + self._show_current() + + def message(self) -> str: + return self._message.text() + + def showing(self) -> str: + """Which role the row currently carries: "progress" | "message" | "summary".""" + return {self._bar: "progress", self._message: "message"}.get(self._stack.currentWidget(), "summary") + + def start_progress(self, fmt: str) -> None: + self._bar.setFormat(fmt) + self._bar.setValue(0) + self._running = True + self._show_current() + + def set_progress(self, fmt: str, fraction: float) -> None: + self._bar.setFormat(fmt) + self._bar.setValue(int(max(0.0, min(1.0, fraction)) * 100)) + self._running = True + self._show_current() + + def stop_progress(self) -> None: + self._running = False + self._show_current() + + def _show_current(self) -> None: + if self._running: + self._stack.setCurrentWidget(self._bar) + elif self._message.text(): + self._stack.setCurrentWidget(self._message) + else: + self._stack.setCurrentWidget(self._summary) + + def section_subheader(text: str) -> QLabel: """Small all-caps label for section grouping in sidebars.""" lbl = QLabel(text.upper()) diff --git a/negpy/desktop/view/widgets/prescan_dialog.py b/negpy/desktop/view/widgets/prescan_dialog.py index e35b3157c..38f998db3 100644 --- a/negpy/desktop/view/widgets/prescan_dialog.py +++ b/negpy/desktop/view/widgets/prescan_dialog.py @@ -9,12 +9,12 @@ QDialog, QHBoxLayout, QLabel, - QProgressBar, QPushButton, QVBoxLayout, ) from negpy.desktop.converters import ImageConverter +from negpy.desktop.view.styles.templates import StatusStrip 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 @@ -58,43 +58,55 @@ def __init__( self._scan_window: tuple[float, float, float, float] | None = initial_window self._prescan_mirror_x = bool(caps.prescan_mirror_x) self._busy = False + self._scan_now = False # set when the user chooses Scan over Apply crop self.setWindowTitle("Prescan — set crop") self.setModal(True) self.resize(720, 560) root = QVBoxLayout(self) - self._status = QLabel("Starting Prescan…") - root.addWidget(self._status) - self._progress = QProgressBar() - self._progress.setRange(0, 100) - self._progress.setValue(0) - root.addWidget(self._progress) + # Acquisition sits at the top, as in the other two preview dialogs; the footer is for + # leaving. + top = QHBoxLayout() + top.addWidget(QLabel("Prescan")) + top.addStretch() + self._retry_btn = QPushButton("Rescan") + self._retry_btn.setToolTip("Run the preview pass again") + self._retry_btn.setEnabled(False) + top.addWidget(self._retry_btn) + root.addLayout(top) + + # One reserved row: the pass that is running, or the message it left behind. + self._strip = StatusStrip(lines=1) + root.addWidget(self._strip) self._label = ScanWindowLabel() root.addWidget(self._label, 1) row = QHBoxLayout() - self._retry_btn = QPushButton("Rescan") - self._retry_btn.setEnabled(False) self._clear_btn = QPushButton("Clear crop") + self._clear_btn.setToolTip("Scan the full window instead of a crop") self._clear_btn.setEnabled(False) self._cancel_btn = QPushButton("Cancel") - self._ok_btn = QPushButton("Use crop") + self._ok_btn = QPushButton("Apply crop") + self._ok_btn.setToolTip("Keep this crop and return to the Scan panel") self._ok_btn.setEnabled(False) - self._ok_btn.setDefault(True) - row.addWidget(self._retry_btn) + self._scan_btn = QPushButton("Scan frame") + self._scan_btn.setToolTip("Scan now with the current settings") + self._scan_btn.setEnabled(False) row.addWidget(self._clear_btn) row.addStretch() row.addWidget(self._cancel_btn) row.addWidget(self._ok_btn) + row.addWidget(self._scan_btn) root.addLayout(row) self._retry_btn.clicked.connect(self._start_prescan) self._clear_btn.clicked.connect(self._on_clear_crop) self._cancel_btn.clicked.connect(self._on_cancel) self._ok_btn.clicked.connect(self.accept) + self._scan_btn.clicked.connect(self._on_scan_clicked) self._label.windowChanged.connect(self._on_window_changed) self._controller.scan_prescan_ready.connect(self._on_prescan_ready) @@ -108,15 +120,23 @@ def scan_window(self) -> tuple[float, float, float, float] | None: """TA-normalized window for ScanParams, or None for full frame.""" return self._scan_window + def scan_requested(self) -> bool: + """True when the dialog was accepted via Scan (start now), not Apply crop.""" + return self._scan_now + + def _on_scan_clicked(self) -> None: + self._scan_now = True + self.accept() + def _start_prescan(self) -> None: if self._busy: return self._busy = True - self._status.setText("Scanning preview at 1200 dpi…") - self._progress.setValue(0) - self._progress.setVisible(True) + self._strip.set_message("Scanning preview at 1200 dpi…") + self._strip.start_progress("Prescanning at 1200 dpi… %p%") self._retry_btn.setEnabled(False) self._ok_btn.setEnabled(False) + self._scan_btn.setEnabled(False) self._clear_btn.setEnabled(False) self._label.set_frame(QPixmap()) try: @@ -128,23 +148,23 @@ def _start_prescan(self) -> None: ) except Exception as exc: self._busy = False - self._status.setText(str(exc)) + self._strip.set_message(str(exc)) self._retry_btn.setEnabled(True) def _on_progress(self, value: float) -> None: if not self._busy: return - self._progress.setValue(int(max(0.0, min(1.0, float(value))) * 100)) + self._strip.set_progress("Prescanning at 1200 dpi… %p%", float(value)) def _on_prescan_ready(self, result: object) -> None: if not self._busy: return self._busy = False - self._progress.setVisible(False) + self._strip.stop_progress() self._retry_btn.setEnabled(True) self._clear_btn.setEnabled(True) if not isinstance(result, ScanResult): - self._status.setText("Prescan returned no image") + self._strip.set_message("Prescan returned no image") return u8 = _preview_u8(result.rgb) qimg = ImageConverter.to_qimage(u8) @@ -157,23 +177,25 @@ def _on_prescan_ready(self, result: object) -> None: 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") + self._scan_btn.setEnabled(True) + self._scan_btn.setDefault(True) + self._strip.set_message("Drag the rectangle to set the scan crop") def _on_prescan_error(self, message: str) -> None: if not self._busy: return self._busy = False - self._progress.setVisible(False) + self._strip.stop_progress() self._retry_btn.setEnabled(True) - self._status.setText(message or "Prescan failed") + self._strip.set_message(message or "Prescan failed") def _on_prescan_cancelled(self) -> None: if not self._busy: return self._busy = False - self._progress.setVisible(False) + self._strip.stop_progress() self._retry_btn.setEnabled(True) - self._status.setText("Prescan cancelled") + self._strip.set_message("Prescan cancelled") def _on_window_changed(self, rect: object) -> None: if rect is None: diff --git a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py index 5acad9aa1..94db41bf9 100644 --- a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py +++ b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py @@ -8,9 +8,10 @@ import qtawesome as qta from PyQt6.QtCore import pyqtSlot from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QProgressBar, QPushButton, QVBoxLayout +from PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout from negpy.desktop.converters import ImageConverter +from negpy.desktop.view.styles.templates import StatusStrip 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 @@ -67,19 +68,12 @@ def __init__(self, controller, device: ScannerDevice, initial_window=None, film_ 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) - - self.preview_progress = QProgressBar() - self.preview_progress.setRange(0, 100) - self.preview_progress.setValue(0) - self.preview_progress.setVisible(False) - layout.addWidget(self.preview_progress) + # One reserved row: the pass that is running, or the message it left behind. + self.status_strip = StatusStrip(lines=1) + layout.addWidget(self.status_strip) btns = QHBoxLayout() - self.clear_btn = QPushButton("Clear") + self.clear_btn = QPushButton("Clear crop") self.clear_btn.setToolTip("Scan the whole frame instead") self.clear_btn.clicked.connect(self.label.clear_window) btns.addWidget(self.clear_btn) @@ -87,11 +81,12 @@ def __init__(self, controller, device: ScannerDevice, initial_window=None, film_ self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self._on_cancel_clicked) btns.addWidget(self.cancel_btn) - self.ok_btn = QPushButton("Use") - self.ok_btn.setDefault(True) + self.ok_btn = QPushButton("Apply window") + self.ok_btn.setToolTip("Keep this window and return to the Scan panel") 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 = QPushButton(qta.icon("fa5s.play", color=THEME.text_primary), " Scan frame") + self.scan_btn.setDefault(True) self.scan_btn.setToolTip("Scan now with the current settings") self.scan_btn.clicked.connect(self._on_scan_clicked) btns.addWidget(self.scan_btn) @@ -127,10 +122,10 @@ def _set_previewing(self, busy: bool) -> None: self.ok_btn.setEnabled(not busy) self.scan_btn.setEnabled(not busy) self.cancel_btn.setText("Stop preview" if busy else "Cancel") - self.preview_progress.setVisible(busy) if busy: - self.preview_progress.setValue(0) - self.preview_progress.setFormat("Previewing… %p%") + self.status_strip.start_progress("Previewing… %p%") + else: + self.status_strip.stop_progress() def _preview_dpi(self) -> int: return int(self.preview_dpi_combo.currentData() or _PREVIEW_FALLBACK_DPI) @@ -147,24 +142,24 @@ def _on_preview(self) -> None: try: self._controller.start_roll_preview(req) except Exception as e: - self.status.setText(f"Scanner busy — {e}") + self.status_strip.set_message(f"Scanner busy — {e}") return self._previewing = True self._set_previewing(True) - self.status.setText("Previewing…") + self.status_strip.set_message("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}") + self.status_strip.set_message(f"Preview failed: {preview.error}") return try: positive = preview_positive(preview.rgb, self._film_type) pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)) except Exception as e: - self.status.setText(f"Could not display preview: {e}") + self.status_strip.set_message(f"Could not display preview: {e}") return self.label.set_frame(pixmap) @@ -172,8 +167,8 @@ def _on_preview_ready(self, preview) -> None: def _on_preview_finished(self) -> None: self._previewing = False self._set_previewing(False) - if not self.status.text().startswith("Preview failed") and not self.status.text().startswith("Could not display"): - self.status.clear() + if not self.status_strip.message().startswith("Preview failed") and not self.status_strip.message().startswith("Could not display"): + self.status_strip.set_message("") @pyqtSlot(str) def _on_error(self, msg) -> None: @@ -181,7 +176,7 @@ def _on_error(self, msg) -> None: return self._previewing = False self._set_previewing(False) - self.status.setText(f"Preview failed: {msg}") + self.status_strip.set_message(f"Preview failed: {msg}") @pyqtSlot() def _on_cancelled(self) -> None: @@ -189,4 +184,4 @@ def _on_cancelled(self) -> None: return self._previewing = False self._set_previewing(False) - self.status.setText("Preview cancelled.") + self.status_strip.set_message("Preview cancelled.") diff --git a/negpy/desktop/view/widgets/scan_preview_common.py b/negpy/desktop/view/widgets/scan_preview_common.py index c552102e8..6ef05a787 100644 --- a/negpy/desktop/view/widgets/scan_preview_common.py +++ b/negpy/desktop/view/widgets/scan_preview_common.py @@ -16,7 +16,7 @@ class RollPreviewSignalsMixin: doesn't vary, so it lives here once rather than being copy-pasted per dialog. A subclass must set ``self._controller``, ``self._previewing`` and - ``self.preview_progress`` before calling ``_connect_preview_signals()``, and + ``self.status_strip`` before calling ``_connect_preview_signals()``, and implement the four result handlers. Leaving a dialog mid-preview cancels the request: the transport holds the unit for @@ -40,9 +40,7 @@ def _connect_preview_signals(self) -> None: def _on_preview_progress(self, fraction: float, phase: str = "Scanning") -> None: if not self._previewing: return - self.preview_progress.setVisible(True) - self.preview_progress.setFormat(f"{phase}… %p%") - self.preview_progress.setValue(int(max(0.0, min(1.0, float(fraction))) * 100)) + self.status_strip.set_progress(f"{phase}… %p%", float(fraction)) def stop_preview(self) -> None: """Ask the transport to abandon the pass in flight. The worker answers with diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 7faad2285..e8689a796 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -16,7 +16,6 @@ QGridLayout, QHBoxLayout, QLabel, - QProgressBar, QPushButton, QScrollArea, QSlider, @@ -26,14 +25,17 @@ from negpy.kernel.system.text import count_of, plural from negpy.desktop.converters import ImageConverter +from negpy.desktop.view.styles.templates import StatusStrip 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.section_help_dialog import SectionHelpDialog, has_guide 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 from negpy.infrastructure.scanners.params import clamp_frame_offset_mm from negpy.infrastructure.scanners.roll import effective_pitch_mm +_GUIDE_KEY = "scan_strip" # the marker its ⓘ reads out of the user guide _CLAMP_NOTICE = "Offset held at the frame pitch" _CUT_NOTICE = "Offset cuts into the frame" # 135 full frame. Delivery ends one pitch past the frame start, so an offset beyond @@ -79,23 +81,17 @@ def _display_to_scan_rect(rect): return (_clamp01(sx1), _clamp01(sy1), _clamp01(sx2), _clamp01(sy2)) -_FEEDER_HELP = ( - "Preview each frame (the eye button on a tile, or Preview all). Drag on a previewed " - "frame to crop it — a corner to resize, inside to move; each frame keeps its own window. " - "Offset slides every frame along the film to clear the inter-frame gap — frames shift " - "left as it grows, live; the shaded band on the right is film past the frame boundary " - "the transport cannot deliver (offset past the gap costs frame tail). Drift adds " - "progressively more (or less) offset per frame position; re-preview to refresh the pixels. " - "Tick the frames to scan, then Use (apply and return) " - "or Scan (start scanning now)." -) +# One line of orientation. Offset and Drift explain themselves on their own sliders, where +# the hand already is, and the ⓘ carries the rest. +_FEEDER_HELP = "Preview a frame, drag on it to crop, and tick the frames to scan." +_DISCOVERY_HELP = "Detect the frames, untick what you do not want, drag on a tile to crop it." -_DISCOVERY_HELP = ( - "Detect frames measures the whole strip in one pass and previews every frame it found. " - "Untick any frame you do not want scanned. Drag on a frame to crop it — a corner to " - "resize, inside to move; each frame keeps its own window. Offset and Drift slide the frames " - "along the film. Then Use (apply and return) or Scan (start scanning now)." +_OFFSET_TIP = ( + "Slides every frame along the film to clear the inter-frame gap. Frames shift left as it " + "grows; the shaded band is film past the frame boundary the transport cannot deliver, so " + "offset past the gap costs frame tail." ) +_DRIFT_TIP = "Adds progressively more (or less) offset per frame position, for a strip whose gaps creep. Re-preview to refresh the pixels." class _ResetSlider(QSlider): @@ -165,16 +161,24 @@ def __init__( layout = QVBoxLayout(self) - help_lbl = QLabel(_DISCOVERY_HELP if self._discovers else _FEEDER_HELP) - 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) + help_row = QHBoxLayout() + self.help_lbl = QLabel(_DISCOVERY_HELP if self._discovers else _FEEDER_HELP) + self.help_lbl.setWordWrap(True) + self.help_lbl.setStyleSheet(f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;") + help_row.addWidget(self.help_lbl) + help_row.addStretch() + self.help_btn = QPushButton(qta.icon("fa5s.info-circle", color=THEME.text_muted), "") + self.help_btn.setToolTip("Offset, Drift and cropping, in full") + self.help_btn.setFlat(True) + self.help_btn.setFixedSize(24, 22) + self.help_btn.setVisible(has_guide(_GUIDE_KEY)) + self.help_btn.clicked.connect(lambda: SectionHelpDialog(_GUIDE_KEY, "Strip preview", self).exec()) + help_row.addWidget(self.help_btn) + layout.addLayout(help_row) top = QHBoxLayout() - top.addWidget(QLabel("Offset")) + top.setSpacing(THEME.space_2xl) + self.offset_slider = _ResetSlider() # A measured strip re-addresses the frame, so its offset may go either way; a feeder # cannot back up and blacks out one pitch past the frame start. Both are a correction to @@ -189,33 +193,33 @@ def __init__( # Not floored at 0: a measured strip's saved offset may be negative, and the range # clamps it either way. self.offset_slider.setValue(int(round(float(initial_offset) * 10))) - self.offset_slider.setToolTip( - "Feed-axis offset applied to every frame" - if self._discovers - else "Feed-axis offset applied to every frame (the transport cannot back up)" - ) - top.addWidget(self.offset_slider, 1) + self.offset_slider.setToolTip(_OFFSET_TIP if self._discovers else f"{_OFFSET_TIP} This transport cannot back up.") self.offset_label = QLabel() - # Pinned to its widest reading: a label that grows with the value would take the width - # out of the slider it belongs to, and the handle would step sideways under the cursor. - self.offset_label.setFixedWidth(self.offset_label.fontMetrics().horizontalAdvance("-99.9 mm")) - top.addWidget(self.offset_label) - top.addSpacing(16) - top.addWidget(QLabel("Drift")) + self.drift_slider = _ResetSlider() self.drift_slider.setRange(-250, 250) # hundredths of a mm → ±2.50 mm/frame self.drift_slider.setSingleStep(1) self.drift_slider.setPageStep(10) self.drift_slider.setMinimumWidth(160) self.drift_slider.setValue(int(round(float(initial_offset_modifier) * 100))) - self.drift_slider.setToolTip( - "Extra offset added per frame position (mm/frame) — corrects progressive frame-gap drift along the strip" - ) - top.addWidget(self.drift_slider, 1) + self.drift_slider.setToolTip(_DRIFT_TIP) self.drift_label = QLabel() - self.drift_label.setFixedWidth(self.drift_label.fontMetrics().horizontalAdvance("-9.99 mm/frame")) - top.addWidget(self.drift_label) - top.addSpacing(16) + + # Name left, reading right, groove underneath — the panel sliders' shape. Beside the + # groove the reading either clips or steals the width it is measuring. + for name, slider, value in (("Offset", self.offset_slider, self.offset_label), ("Drift", self.drift_slider, self.drift_label)): + block = QVBoxLayout() + block.setSpacing(0) + head = QHBoxLayout() + head.setSpacing(THEME.space_md) + head.addWidget(QLabel(name)) + head.addStretch() + value.setStyleSheet(f"color: {THEME.text_secondary};") + head.addWidget(value) + block.addLayout(head) + block.addWidget(slider) + top.addLayout(block, 1) + # A measured strip previews out of its own pass, whose resolution nothing chooses. self.preview_dpi_label = QLabel("Preview DPI") self.preview_dpi_combo = QComboBox() @@ -258,16 +262,9 @@ def __init__( self._scroll.setWidget(container) layout.addWidget(self._scroll, 1) - self.status = QLabel("") # live status only (previewing / errors); help moved to the top box - self.status.setWordWrap(True) - self.status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;") - layout.addWidget(self.status) - - self.preview_progress = QProgressBar() - self.preview_progress.setRange(0, 100) - self.preview_progress.setValue(0) - self.preview_progress.setVisible(False) - layout.addWidget(self.preview_progress) + # One reserved row: the pass that is running, or the message it left behind. + self.status_strip = StatusStrip(lines=1) + layout.addWidget(self.status_strip) btns = QHBoxLayout() self.select_all_btn = QPushButton("All") @@ -294,8 +291,8 @@ def __init__( self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self._on_cancel_clicked) btns.addWidget(self.cancel_btn) - self.ok_btn = QPushButton("Use") - self.ok_btn.setDefault(True) + self.ok_btn = QPushButton("Apply framing") + self.ok_btn.setToolTip("Keep this framing and selection, and return to the Scan panel") 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") @@ -430,8 +427,14 @@ def _on_cancel_clicked(self) -> None: def _update_ok_enabled(self, *_args) -> None: picked = sum(1 for t in self._tiles.values() if t.checkbox.isChecked()) - self.ok_btn.setEnabled(bool(picked) and not self._previewing) - self.scan_btn.setEnabled(bool(picked) and not self._previewing) + ready = bool(picked) and not self._previewing + self.ok_btn.setEnabled(ready) + self.scan_btn.setEnabled(ready) + self.scan_btn.setText(f" Scan {count_of(picked, 'frame')}" if picked else " Scan") + # Enter follows the intent: once frames are measured and ticked, that is scanning them, + # not walking back to the panel. + self.scan_btn.setDefault(ready) + self.ok_btn.setDefault(not ready) self.selection_label.setText(f"{picked} of {count_of(len(self._tiles), 'frame')}" if self._tiles else "none yet") def _set_all_checked(self, checked: bool) -> None: @@ -448,10 +451,10 @@ def _set_previewing(self, busy: bool) -> None: tile.preview_btn.setEnabled(not busy) # Committing mid-pass would hand the batch a unit the preview still holds. self.cancel_btn.setText("Stop preview" if busy else "Cancel") - self.preview_progress.setVisible(busy) if busy: - self.preview_progress.setValue(0) - self.preview_progress.setFormat("Previewing… %p%") + self.status_strip.start_progress("Previewing… %p%") + else: + self.status_strip.stop_progress() self._update_ok_enabled() def _on_offset_changed(self, _value: int) -> None: @@ -514,16 +517,16 @@ def _refresh_offset_indicators(self) -> None: tile.label.set_coverage(self._tile_coverage(tile)) if clamped: frames = ", ".join(str(f) for f in clamped) - self.status.setText(f"{_CLAMP_NOTICE} on {plural(len(clamped), 'frame')} {frames} — reduce Offset or Drift.") + self.status_strip.set_message(f"{_CLAMP_NOTICE} on {plural(len(clamped), 'frame')} {frames} — reduce Offset or Drift.") elif cut: frames = ", ".join(str(f) for f, _ in cut) worst = max(loss for _, loss in cut) - self.status.setText( + self.status_strip.set_message( f"{_CUT_NOTICE} on {plural(len(cut), 'frame')} {frames} — up to {worst:.1f} mm of picture lost off the " f"frame tail; reduce Offset, or re-feed the strip for a better registration." ) - elif self.status.text().startswith((_CLAMP_NOTICE, _CUT_NOTICE)): - self.status.clear() + elif self.status_strip.message().startswith((_CLAMP_NOTICE, _CUT_NOTICE)): + self.status_strip.set_message("") # ── preview flow (single-flight chain) ──────────────────────────── @@ -556,15 +559,15 @@ def _start_preview(self, slots: tuple[int, ...]) -> None: try: self._controller.start_roll_preview(req) except Exception as e: - self.status.setText(f"Scanner busy — {e}") + self.status_strip.set_message(f"Scanner busy — {e}") return self._previewing = True self._set_previewing(True) if self._discovers and len(slots) > 1: # The slot count asked for is a roll's worth, not what the strip holds. - self.status.setText("Measuring the strip…") + self.status_strip.set_message("Measuring the strip…") else: - self.status.setText(f"Previewing {'frame ' + str(slots[0]) if len(slots) == 1 else f'{len(slots)} frames'}…") + self.status_strip.set_message(f"Previewing {'frame ' + str(slots[0]) if len(slots) == 1 else f'{len(slots)} frames'}…") @pyqtSlot(object) def _on_preview_ready(self, preview) -> None: @@ -577,7 +580,7 @@ def _on_preview_ready(self, preview) -> None: # One frame glitched, and the backend already retried it. The rest of the strip is still # coming. self._failed_frames.append(preview.slot) - self.status.setText(f"Frame {preview.slot} failed — continuing…") + self.status_strip.set_message(f"Frame {preview.slot} failed — continuing…") return try: positive = preview_positive(preview.rgb, self._film_type) @@ -585,7 +588,7 @@ def _on_preview_ready(self, preview) -> None: if self._rotation: pixmap = pixmap.transformed(QTransform().rotate(self._rotation)) except Exception as e: - self.status.setText(f"Could not display frame {preview.slot}: {e}") + self.status_strip.set_message(f"Could not display frame {preview.slot}: {e}") return tile.previewed_offset = preview.offset # Anchor the tile to the next scan: a current raster sits flush left and ends at the @@ -600,7 +603,7 @@ def _on_preview_finished(self) -> None: self._set_previewing(False) if self._discovers and not self._failed_frames: found = len(self._tiles) - self.status.setText( + self.status_strip.set_message( f"{count_of(found, 'frame')} detected — check the framing before scanning." if found else "No frames were detected on the loaded film." @@ -608,9 +611,9 @@ def _on_preview_finished(self) -> None: return if self._failed_frames: failed = ", ".join(str(f) for f in self._failed_frames) - self.status.setText(f"Preview done. Failed {plural(len(self._failed_frames), 'frame')}: {failed}") + self.status_strip.set_message(f"Preview done. Failed {plural(len(self._failed_frames), 'frame')}: {failed}") else: - self.status.clear() + self.status_strip.set_message("") @pyqtSlot(str) def _on_error(self, msg) -> None: @@ -618,7 +621,7 @@ def _on_error(self, msg) -> None: return self._previewing = False self._set_previewing(False) - self.status.setText(f"Preview failed: {msg}") + self.status_strip.set_message(f"Preview failed: {msg}") @pyqtSlot() def _on_cancelled(self) -> None: @@ -626,4 +629,4 @@ def _on_cancelled(self) -> None: return self._previewing = False self._set_previewing(False) - self.status.setText("Preview cancelled.") + self.status_strip.set_message("Preview cancelled.") diff --git a/tests/test_prescan_dialog.py b/tests/test_prescan_dialog.py new file mode 100644 index 000000000..068a7faaf --- /dev/null +++ b/tests/test_prescan_dialog.py @@ -0,0 +1,157 @@ +"""Offline tests for the Prescan crop dialog. + +Constructs the real PrescanCropDialog against a light fake controller under an +offscreen Qt platform. Proves the footer grammar it shares with the other two +preview dialogs, and the Scan exit it did not use to have. +""" + +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.prescan_dialog import PrescanCropDialog +from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice +from negpy.infrastructure.scanners.params import ScanMode +from negpy.infrastructure.scanners.result import ScanResult + +if not QApplication.instance(): + _app = QApplication(sys.argv) + + +def _device() -> ScannerDevice: + caps = ScannerCapabilities( + ir_channel=True, + supported_dpi=(1200, 3600), + supported_depths=(16,), + sources=(ScanMode.TRANSPARENCY,), + max_area_mm=(36.33, 25.0), + prescan=True, + prescan_dpi=1200, + prescan_default_crop=(0.0, 0.35, 1.0, 0.65), + ) + return ScannerDevice(id="plustek:usb:07b3:1825:002:006", vendor="PLUSTEK", model="OpticFilm 8200i SE", capabilities=caps) + + +class _FakeController(QObject): + scan_prescan_ready = pyqtSignal(object) + scan_prescan_error = pyqtSignal(str) + scan_progress = pyqtSignal(float, str) + scan_cancelled = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + self.prescan_reqs: list = [] + self.cancels = 0 + + def start_prescan(self, req) -> None: + self.prescan_reqs.append(req) + + def cancel_scan(self) -> None: + self.cancels += 1 + + +def _result() -> ScanResult: + rgb = np.linspace(0, 65535, 32 * 48 * 3, dtype=np.float32).reshape(32, 48, 3).astype(np.uint16) + return ScanResult(rgb=rgb, ir=None, dpi=1200, device_model="OpticFilm 8200i SE") + + +def _ready(controller: _FakeController, dialog: PrescanCropDialog) -> None: + controller.scan_prescan_ready.emit(_result()) + assert dialog._label.pixmap() is not None + + +def test_the_footer_reads_like_the_other_preview_dialogs() -> None: + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + _ready(controller, dialog) + + assert dialog._clear_btn.text() == "Clear crop" + assert dialog._ok_btn.text() == "Apply crop" + assert dialog._scan_btn.text() == "Scan frame" + + +def test_re_acquisition_is_not_one_of_the_exits() -> None: + """Rescan sat among Cancel and Use; the other two dialogs keep acquisition up top.""" + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + + footer = dialog.layout().itemAt(dialog.layout().count() - 1).layout() + in_footer = {footer.itemAt(i).widget() for i in range(footer.count())} + + assert dialog._retry_btn not in in_footer + assert {dialog._clear_btn, dialog._cancel_btn, dialog._ok_btn, dialog._scan_btn} <= in_footer + + +def test_scan_is_the_exit_the_dialog_never_had() -> None: + """Framing a crop used to end at the panel, and a second trip to the Scan button.""" + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + _ready(controller, dialog) + + assert dialog.scan_requested() is False + assert dialog._scan_btn.isDefault() is True + + dialog._scan_btn.click() + + assert dialog.scan_requested() is True + assert dialog.result() == PrescanCropDialog.DialogCode.Accepted.value + + +def test_apply_crop_leaves_without_asking_for_a_scan() -> None: + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + _ready(controller, dialog) + + dialog._ok_btn.click() + + assert dialog.scan_requested() is False + assert dialog.scan_window() == (0.0, 0.35, 1.0, 0.65) + + +def test_neither_exit_is_offered_before_a_preview_arrives() -> None: + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + + assert dialog._ok_btn.isEnabled() is False + assert dialog._scan_btn.isEnabled() is False + + _ready(controller, dialog) + + assert dialog._ok_btn.isEnabled() is True + assert dialog._scan_btn.isEnabled() is True + + +def test_the_pass_and_its_message_share_one_reserved_row() -> None: + """Status and progress were two rows that came and went above the crop.""" + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + + assert dialog._strip.showing() == "progress" + + controller.scan_progress.emit(0.5, "Prescanning") + assert dialog._strip._bar.value() == 50 + + _ready(controller, dialog) + assert dialog._strip.showing() == "message" + assert "Drag the rectangle" in dialog._strip.message() + + +def test_a_failed_prescan_says_so_in_the_same_row() -> None: + controller = _FakeController() + dialog = PrescanCropDialog(controller, _device()) + reserved = dialog._strip.height() + + controller.scan_prescan_error.emit("lamp did not warm up") + + assert dialog._strip.showing() == "message" + assert "lamp did not warm up" in dialog._strip.message() + assert dialog._strip.height() == reserved + assert dialog._ok_btn.isEnabled() is False diff --git a/tests/test_quick_scan_preview_dialog.py b/tests/test_quick_scan_preview_dialog.py index 6c11b1b2d..2c6b31421 100644 --- a/tests/test_quick_scan_preview_dialog.py +++ b/tests/test_quick_scan_preview_dialog.py @@ -94,7 +94,7 @@ def test_preview_result_shows_the_frame_and_clears_busy_state(): assert dialog.preview_btn.isEnabled() is True assert dialog.label.has_frame() - assert dialog.status.text() == "" + assert dialog.status_strip.message() == "" def test_preview_failure_reports_status_and_clears_busy_state(): @@ -105,7 +105,7 @@ def test_preview_failure_reports_status_and_clears_busy_state(): controller.deliver(error="carriage jammed") assert dialog.preview_btn.isEnabled() is True - assert "carriage jammed" in dialog.status.text() + assert "carriage jammed" in dialog.status_strip.message() def test_busy_scanner_reports_status_without_starting_preview(): @@ -114,7 +114,7 @@ def test_busy_scanner_reports_status_without_starting_preview(): dialog._on_preview() - assert "busy" in dialog.status.text().lower() + assert "busy" in dialog.status_strip.message().lower() assert dialog.preview_btn.isEnabled() is True @@ -193,8 +193,8 @@ def test_preview_progress_reaches_the_bar(): controller.scan_progress.emit(0.25, "Metering") - assert dialog.preview_progress.value() == 25 - assert dialog.preview_progress.format() == "Metering… %p%" + assert dialog.status_strip._bar.value() == 25 + assert dialog.status_strip._bar.format() == "Metering… %p%" def test_reversal_film_previews_without_inversion(): @@ -208,3 +208,13 @@ def test_reversal_film_previews_without_inversion(): assert dialog.label.has_frame() is True assert dialog._film_type == "positive" + + +def test_the_exits_name_their_object_and_enter_scans() -> None: + """One grammar across the three preview dialogs: reset · Cancel · Apply · Scan.""" + dialog = QuickScanPreviewDialog(_FakeController(), _device()) + + assert dialog.clear_btn.text() == "Clear crop" + assert dialog.ok_btn.text() == "Apply window" + assert dialog.scan_btn.text().strip() == "Scan frame" + assert dialog.scan_btn.isDefault() is True diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index f9ca105e8..57c323596 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -11,6 +11,7 @@ from __future__ import annotations import os +import re os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -24,6 +25,7 @@ from PyQt6.QtWidgets import QApplication from negpy.desktop.view.sidebar.scan import ScanSidebar, estimated_frame_bytes +from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.scanners.base import ScannerCapabilities, ScannerDevice from negpy.infrastructure.scanners.params import FILM_TYPES, ScanMode @@ -166,6 +168,11 @@ def _sidebar(device: ScannerDevice | None = None, settings: dict | None = None) return sidebar, controller +def _summary(sidebar: ScanSidebar) -> str: + """The summary line as the eye reads it: the count and size carry markup for weight.""" + return re.sub(r"<[^>]+>", "", sidebar.status_strip._summary.text()) + + def test_controller_signals_connect_without_error() -> None: # Construction wires every scan_* signal; a missing one would raise here. sidebar, _ = _sidebar() @@ -511,7 +518,6 @@ def test_unsupported_ae_af_forced_off_in_scan_params() -> None: def test_scan_error_resets_ui_and_shows_status(monkeypatch) -> None: sidebar, _ = _sidebar(SE_DEVICE) sidebar.set_scanning(True) - sidebar.progress_bar.setVisible(True) popped: list[tuple[str, str]] = [] def _fake_warning(parent, title, text, *args, **kwargs): @@ -523,8 +529,8 @@ def _fake_warning(parent, title, text, *args, **kwargs): sidebar._on_scan_error("USB I/O failed") assert sidebar._scanning is False - assert sidebar.progress_bar.isVisible() is False - assert "Error: USB I/O failed" in sidebar.status_label.text() + assert sidebar.status_strip.showing() == "message" + assert "Error: USB I/O failed" in sidebar.status_strip.message() assert popped == [] @@ -541,7 +547,7 @@ def _fake_warning(parent, title, text, *args, **kwargs): msg = "OpticFilm 8100 (GL845) cannot scan with pyOpticfilm in this release — only OpticFilm 8200i SE is validated." sidebar._on_scan_error(msg) - assert f"Error: {msg}" in sidebar.status_label.text() + assert f"Error: {msg}" in sidebar.status_strip.message() assert popped == [("Scan failed", msg)] @@ -613,7 +619,7 @@ def test_a_measured_strip_says_what_scan_would_do_before_a_preview() -> None: """Nothing is cropped yet, and the summary quotes the size of one frame.""" sidebar, _ = _sidebar(NKSCAN_DEVICE) assert sidebar.scan_window_status.text().startswith("Full frame") - assert sidebar.summary_label.text().startswith("Whole strip") + assert _summary(sidebar).startswith("Whole strip") def test_a_typed_frame_list_reaches_the_batch_without_a_preview() -> None: @@ -790,19 +796,82 @@ def test_no_device_hides_the_optional_headers() -> None: assert sidebar.framing_header.isVisibleTo(sidebar) is False +# ── the status strip ────────────────────────────────────────────────── + + +def test_the_status_row_keeps_its_height_through_every_role() -> None: + """Three rows that come and go moved the Scan button under the cursor.""" + sidebar, _ = _sidebar(FULL_DEVICE) + sidebar.show() + sidebar.layout().activate() + reserved = sidebar.status_strip.height() + top_of_button = sidebar.scan_btn.pos().y() + + for step in ( + lambda: sidebar.set_scanning(True), + lambda: sidebar._on_scan_progress(0.4, "Scanning"), + lambda: sidebar._on_scan_error("USB I/O failed on a long path that would wrap a label"), + ): + step() + sidebar.layout().activate() + assert sidebar.status_strip.height() == reserved + assert sidebar.scan_btn.pos().y() == top_of_button + + +def test_the_row_prefers_the_pass_then_the_message_then_the_summary() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + assert sidebar.status_strip.showing() == "summary" + + sidebar.status_strip.set_message("Scanned: /tmp/a.tiff") + assert sidebar.status_strip.showing() == "message" + + sidebar.status_strip.set_progress("Scanning… %p%", 0.5) + assert sidebar.status_strip.showing() == "progress" # a running pass outranks the message + + sidebar.status_strip.stop_progress() + assert sidebar.status_strip.showing() == "message" + + sidebar._update_settings_from_ui() # the operator edits: the summary is due again + assert sidebar.status_strip.showing() == "summary" + + +# ── the commit button ───────────────────────────────────────────────── + + +def test_the_scan_button_goes_hollow_while_a_scan_runs() -> None: + """The fill says "this starts a scan"; Stop must not wear it. QSS reads the property.""" + sidebar, _ = _sidebar(FULL_DEVICE) + assert sidebar.scan_btn.property("scanning") == "false" + + sidebar.set_scanning(True) + assert sidebar.scan_btn.property("scanning") == "true" + assert sidebar.scan_btn.text().strip() == "Stop" + + sidebar.set_scanning(False) + assert sidebar.scan_btn.property("scanning") == "false" + assert sidebar.scan_btn.text().strip() == "Scan" + + +def test_the_scan_button_has_a_rule_to_fill_it() -> None: + """objectName without a matching rule is how it stayed transparent.""" + from negpy.desktop.view.styles.templates import load_stylesheet + + assert "QPushButton#scan_btn" in load_stylesheet() + + # ── the summary above the Scan button ───────────────────────────────── def test_the_summary_counts_the_frames_the_batch_will_scan() -> None: sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1, 3, 5], "dpi": 4000}) - text = sidebar.summary_label.text() + text = _summary(sidebar) assert text.startswith("3 frames · 4000 dpi") assert "GB" in text or "MB" in text def test_the_summary_quotes_a_per_frame_size_for_an_unmeasured_strip() -> None: sidebar, _ = _sidebar(NKSCAN_DEVICE) - text = sidebar.summary_label.text() + text = _summary(sidebar) assert text.startswith("Whole strip") assert "/frame" in text @@ -812,24 +881,34 @@ def test_the_summary_names_the_extra_passes() -> None: sidebar.ir_check.setChecked(True) sidebar.samples_combo.setCurrentIndex(sidebar.samples_combo.findData(4)) - assert "IR" in sidebar.summary_label.text() and "4× sampled" in sidebar.summary_label.text() + assert "IR" in _summary(sidebar) and "4× sampled" in _summary(sidebar) sidebar.clean_check.setChecked(True) - assert "ICE" in sidebar.summary_label.text() and "IR" not in sidebar.summary_label.text() + assert "ICE" in _summary(sidebar) and "IR" not in _summary(sidebar) + + +def test_the_count_and_the_size_carry_the_weight_in_the_summary() -> None: + """The two numbers the operator checks before committing are the two that stand out.""" + sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1, 3, 5], "dpi": 4000}) + markup = sidebar.status_strip._summary.text() + + assert f'3 frames' in markup + assert markup.count(" None: # The saved 3600 is not offered here, so the nearest stop the device has is shown and used. sidebar, _ = _sidebar(MINIMAL_DEVICE) - assert sidebar.summary_label.text().startswith("1 frame · 2400 dpi") + assert _summary(sidebar).startswith("1 frame · 2400 dpi") assert sidebar._dpi() == 2400 def test_the_summary_shrinks_with_the_crop() -> None: full, _ = _sidebar(MINIMAL_DEVICE) cropped, _ = _sidebar(MINIMAL_DEVICE, settings={"scan_window": [0.0, 0.0, 0.5, 0.5]}) - assert full.summary_label.text() != cropped.summary_label.text() + assert _summary(full) != _summary(cropped) def test_an_ir_pass_costs_a_fourth_plane() -> None: @@ -857,7 +936,7 @@ def test_ejecting_drops_the_frame_selection_of_the_film_that_left() -> None: assert sidebar.settings.selected_frames == () assert sidebar.settings.frame_windows == {} - assert "frame selection cleared" in sidebar.status_label.text() + assert "frame selection cleared" in sidebar.status_strip.message() def test_ejecting_keeps_the_registration_offsets() -> None: @@ -873,7 +952,7 @@ def test_ejecting_keeps_the_registration_offsets() -> None: def test_ejecting_with_nothing_picked_says_only_that() -> None: sidebar, _ = _sidebar(FULL_DEVICE) sidebar._on_ejected(True) - assert sidebar.status_label.text() == "Film ejected" + assert sidebar.status_strip.message() == "Film ejected" # ── typed DPI ───────────────────────────────────────────────────────── diff --git a/tests/test_section_help_dialog.py b/tests/test_section_help_dialog.py index 0bdbfc361..54ffdac5d 100644 --- a/tests/test_section_help_dialog.py +++ b/tests/test_section_help_dialog.py @@ -11,8 +11,9 @@ from negpy.desktop.view.widgets.collapsible import CollapsibleSection from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog, _guides, guide_markdown, has_guide -# The Analysis read-out plus every section ControlsPanel builds. A key here with no -# marker in the doc silently drops that panel's ⓘ, which nothing else would catch. +# The Analysis read-out plus every section ControlsPanel builds, and the dialogs that carry +# their own ⓘ. A key here with no marker in the doc silently drops that ⓘ, which nothing else +# would catch. GUIDED_KEYS = ( "analysis", "presets", @@ -30,6 +31,7 @@ "retouch", "finish", "scan_sane", + "scan_strip", ) diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index bd24ca51f..021ad108e 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -203,9 +203,8 @@ def test_offset_and_drift_sliders_reset_to_zero_on_double_click() -> None: def test_the_offset_and_drift_sliders_share_the_width_the_row_has_left() -> None: - """They carry the framing, so they take the row rather than a fixed 160 px of it. The - button keeps its own width, and the value labels are pinned so a wide reading cannot - shrink the slider it belongs to.""" + """They carry the framing, so they take the row rather than a fixed 160 px of it, and the + button keeps its own width.""" dialog = StripPreviewDialog(_FakeController(), _device(3)) dialog.show() @@ -219,12 +218,48 @@ def test_the_offset_and_drift_sliders_share_the_width_the_row_has_left() -> None assert widths[1][0] > widths[0][0] - before = dialog.offset_slider.width() - dialog.offset_slider.setValue(dialog.offset_slider.maximum()) - dialog.drift_slider.setValue(dialog.drift_slider.minimum()) + +def test_each_reading_sits_above_its_groove_with_room_for_its_widest_value() -> None: + """Beside the groove the reading clipped ("+0.10 mm/fram") — the panel sliders stack.""" + dialog = StripPreviewDialog(_FakeController(), _device(3)) + dialog.resize(1200, 700) + dialog.show() + dialog.offset_slider.setValue(dialog.offset_slider.minimum()) + dialog.drift_slider.setValue(dialog.drift_slider.maximum()) dialog.layout().activate() - assert dialog.offset_slider.width() == before + for label, slider in ((dialog.offset_label, dialog.offset_slider), (dialog.drift_label, dialog.drift_slider)): + assert label.mapTo(dialog, label.rect().topLeft()).y() < slider.mapTo(dialog, slider.rect().topLeft()).y() + assert label.fontMetrics().horizontalAdvance(label.text()) <= label.width() + + +def test_both_exits_name_what_they_do_and_enter_scans_once_frames_are_ticked() -> None: + """ "Use" named nothing, and held the default — so Enter walked back after framing.""" + dialog = StripPreviewDialog(_FakeController(), _device(3)) + + assert dialog.ok_btn.text() == "Apply framing" + assert dialog.scan_btn.text().strip() == "Scan 3 frames" + assert dialog.scan_btn.isDefault() is True + assert dialog.ok_btn.isDefault() is False + + for tile in dialog._tiles.values(): + tile.checkbox.setChecked(False) + + assert dialog.scan_btn.text().strip() == "Scan" + assert dialog.scan_btn.isDefault() is False + assert dialog.ok_btn.isDefault() is True # nothing to scan: Enter is the way back + + +def test_the_help_is_one_line_and_the_detail_lives_where_the_hand_is() -> None: + """Ninety words above the tiles were read once and skipped after.""" + from negpy.desktop.view.widgets.section_help_dialog import has_guide + + dialog = StripPreviewDialog(_FakeController(), _device(3)) + + assert len(dialog.help_lbl.text().split()) < 20 + assert "gap" in dialog.offset_slider.toolTip() + assert "per frame position" in dialog.drift_slider.toolTip() + assert has_guide("scan_strip") and dialog.help_btn.isVisibleTo(dialog) is True def test_preview_dpi_dropdown_defaults_to_lowest_and_flows_into_requests() -> None: @@ -390,7 +425,7 @@ def test_a_failed_slot_does_not_abort_the_strip() -> None: assert dialog._tiles[1].label.has_frame() is False assert all(dialog._tiles[f].label.has_frame() for f in (2, 3)) - assert "1" in dialog.status.text() # the failed slot is named + assert "1" in dialog.status_strip.message() # the failed slot is named assert dialog.preview_all_btn.isEnabled() is True @@ -402,7 +437,7 @@ def test_a_terminal_error_unlocks_the_dialog() -> None: controller.scan_error.emit("scanner went away") assert dialog.preview_all_btn.isEnabled() is True - assert "scanner went away" in dialog.status.text() + assert "scanner went away" in dialog.status_strip.message() def test_a_second_preview_is_refused_while_one_is_running() -> None: @@ -508,7 +543,7 @@ def test_offset_past_the_frame_budget_warns_about_picture_loss() -> None: dialog = StripPreviewDialog(_FakeController(), _pitch_device(3), initial_offset=5.5) dialog._refresh_offset_indicators() - text = dialog.status.text() + text = dialog.status_strip.message() assert "cuts into the frame" in text assert "3.7 mm" in text # 5.5 - (37.83 - 36.0) assert "1, 2, 3" in text @@ -520,7 +555,7 @@ def test_offset_within_the_frame_budget_does_not_warn() -> None: dialog.offset_slider.setValue(14) # 1.4 mm — inside pitch - 36 mm budget - assert dialog.status.text() == "" # warning cleared once the offset is safe + assert dialog.status_strip.message() == "" # warning cleared once the offset is safe def test_drift_past_the_pitch_is_clamped_and_flagged() -> None: @@ -528,7 +563,7 @@ def test_drift_past_the_pitch_is_clamped_and_flagged() -> None: assert dialog._offset_for_frame(1) == pytest.approx(8.0) assert dialog._offset_for_frame(14) == pytest.approx(36.83) # pitch - 1 mm, not 40.5 - assert "14" in dialog.status.text() + assert "14" in dialog.status_strip.message() # ── a transport that measures the strip ─────────────────────────────────── @@ -563,7 +598,7 @@ def test_tiles_grow_from_the_frames_the_measurement_found() -> None: assert sorted(dialog._tiles) == [1, 2, 3] assert dialog.selected_frames() == (1, 2, 3) - assert "3 frames detected" in dialog.status.text() + assert "3 frames detected" in dialog.status_strip.message() def test_the_selection_is_read_off_the_tiles_that_appeared() -> None: @@ -618,7 +653,7 @@ def test_a_measured_strip_draws_the_boundary_it_slid_to() -> None: dialog.offset_slider.setValue(25) assert [edge for _frac, edge in dialog._tiles[1].label._offset_indicators] == ["right"] # Nothing is cut off, so neither blackout warning applies. - assert not dialog.status.text().startswith(("Offset held", "Offset cuts")) + assert not dialog.status_strip.message().startswith(("Offset held", "Offset cuts")) def test_a_measured_strip_can_slide_a_frame_backwards() -> None: @@ -640,7 +675,7 @@ def test_an_empty_strip_says_so() -> None: dialog._on_preview_all() controller.scan_roll_preview_finished.emit() - assert "No frames were detected" in dialog.status.text() + assert "No frames were detected" in dialog.status_strip.message() assert dialog.ok_btn.isEnabled() is False @@ -786,11 +821,11 @@ def test_preview_progress_reaches_the_bar_and_names_the_phase() -> None: controller.scan_progress.emit(0.4, "Detecting frames") - assert dialog.preview_progress.value() == 40 - assert dialog.preview_progress.format() == "Detecting frames… %p%" + assert dialog.status_strip._bar.value() == 40 + assert dialog.status_strip._bar.format() == "Detecting frames… %p%" controller.deliver_all((1,)) - assert dialog.preview_progress.isVisibleTo(dialog) is False + assert dialog.status_strip.showing() != "progress" def test_idle_progress_updates_are_ignored() -> None: @@ -800,7 +835,7 @@ def test_idle_progress_updates_are_ignored() -> None: controller.scan_progress.emit(0.9, "Scanning") - assert dialog.preview_progress.value() == 0 + assert dialog.status_strip._bar.value() == 0 def test_reversal_film_previews_without_inversion() -> None: @@ -830,7 +865,7 @@ def test_a_measured_strip_says_it_is_measuring_not_how_many_slots_it_asked_for() dialog._on_preview_all() - assert dialog.status.text() == "Measuring the strip…" + assert dialog.status_strip.message() == "Measuring the strip…" def test_a_feeder_still_counts_the_frames_it_previews() -> None: @@ -839,7 +874,7 @@ def test_a_feeder_still_counts_the_frames_it_previews() -> None: dialog._on_preview_all() - assert dialog.status.text() == "Previewing 3 frames…" + assert dialog.status_strip.message() == "Previewing 3 frames…" def test_the_offset_is_a_boundary_correction_not_a_way_to_the_next_frame() -> None: