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..1f0923a14 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -874,23 +874,48 @@ 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). 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.
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, 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** 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.
+ * **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.
+
+ 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 (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.
+
+ 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/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..f50a057f6 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,
@@ -9,23 +10,50 @@
QLabel,
QLineEdit,
QMessageBox,
- QProgressBar,
QPushButton,
QSlider,
- QSpinBox,
QVBoxLayout,
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 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
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
+
+
+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."""
@@ -38,6 +66,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)
@@ -50,7 +84,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:
@@ -73,6 +107,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 ───────────────────────────────────────────────
@@ -123,34 +161,78 @@ 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)
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 (no label column) so the checkboxes sit at the left edge.
+ # 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.clean_check = QCheckBox("ICE")
+ self.clean_check.setToolTip(
+ "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)
+ 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)
+
self.autofocus_check = QCheckBox("Autofocus")
self.autofocus_check.setChecked(True)
self.autofocus_check.setToolTip("Autofocus before scanning (film is rarely perfectly flat)")
@@ -180,24 +262,17 @@ def _init_ui(self) -> None:
self.exposure_label.setVisible(False)
self.exposure_row_widget.setVisible(False)
- # 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)
+ self.framing_header = section_subheader("Framing")
+ self.form.addRow(self.framing_header)
+
+ # 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()
@@ -237,6 +312,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")
@@ -259,23 +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)
+ # ── 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)
- # ── SCAN BUTTON ─────────────────────────────────────
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()
@@ -300,13 +373,18 @@ 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 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())
+ 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)
@@ -338,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:
@@ -359,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)
@@ -372,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
@@ -410,11 +488,13 @@ 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)
- 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)
@@ -425,8 +505,24 @@ 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
+ self._sync_group_headers()
+ self._update_summary()
return
caps = device.capabilities
@@ -437,18 +533,18 @@ 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:
- 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)
+ self._sync_group_headers()
+ self._update_summary()
def _populate_form(self, caps: ScannerCapabilities) -> None:
self.dpi_combo.blockSignals(True)
@@ -456,20 +552,27 @@ 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()
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
@@ -488,6 +591,8 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.depth_label.setVisible(show_depth)
# 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)
@@ -497,10 +602,11 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.ir_check.setToolTip("IR scanning not supported by this device")
# 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")
@@ -525,6 +631,64 @@ 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)
+ if self.clean_check.isChecked():
+ self.ir_check.setChecked(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)
+
+ # 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()
+ 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,31 +709,23 @@ 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
- capacity = caps.adapter_frame_capacity
- has_frames = capacity is not None
- 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)
-
- # 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:
+ # 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_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.
+ 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:
@@ -589,8 +745,47 @@ 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
+ 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 _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
+ 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())
@@ -609,15 +804,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
@@ -626,7 +833,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 +843,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():
@@ -653,7 +862,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()
@@ -684,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
@@ -714,14 +931,12 @@ 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))
+ device = self._current_device()
+ if device is not None and _reaches_a_strip(device.capabilities):
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}")
+ win_txt = count_of(n_windows, "window") if n_windows else "Full frame"
+ self.scan_window_status.setText(f"{win_txt}{offset_txt}")
return
- device = self._current_device()
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}")
@@ -729,6 +944,77 @@ 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_spec_edit.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 dataclasses import replace
+
+ from negpy.infrastructure.scanners.settings import resolve_batch_selection
+
+ device = self._current_device()
+ if device is None:
+ self.status_strip.set_summary("")
+ return
+ caps = device.capabilities
+ spec = self._frame_spec()
+ if spec is None:
+ self.status_strip.set_summary("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(
+ replace(self._settings, selected_frames=spec),
+ capacity=caps.adapter_frame_capacity,
+ 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")
+ # 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")
if folder:
@@ -757,7 +1043,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()
@@ -766,8 +1052,13 @@ 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()
+ self._settings,
+ capacity=device.capabilities.adapter_frame_capacity,
+ whole_strip=device.capabilities.roll_discovery,
)
exposure_time_us = (
self._settings.exposure_time_us
@@ -784,6 +1075,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 +1087,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,
@@ -816,58 +1112,68 @@ 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)
@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_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.
+ 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_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 ─────────────────────────────────────────────────
@@ -878,24 +1184,27 @@ 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:
- 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
+ # 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(depth_text)
+ depth = int(self.depth_combo.currentData() or 16)
except (ValueError, TypeError):
depth = 16
@@ -915,9 +1224,14 @@ 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),
- frame_from=self.frame_from_spin.value(),
- frame_to=self.frame_to_spin.value(),
+ 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(),
+ 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 }}',
)
+ self._update_summary()
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/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/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 8be5445c2..94db41bf9 100644
--- a/negpy/desktop/view/widgets/quick_scan_preview_dialog.py
+++ b/negpy/desktop/view/widgets/quick_scan_preview_dialog.py
@@ -11,6 +11,7 @@
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
@@ -24,10 +25,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"
@@ -66,25 +68,25 @@ def __init__(self, controller, device: ScannerDevice, initial_window=None, paren
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)
+ # 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)
btns.addStretch()
- cancel_btn = QPushButton("Cancel")
- cancel_btn.clicked.connect(self.reject)
- btns.addWidget(cancel_btn)
- self.ok_btn = QPushButton("Use")
- self.ok_btn.setDefault(True)
+ self.cancel_btn = QPushButton("Cancel")
+ self.cancel_btn.clicked.connect(self._on_cancel_clicked)
+ btns.addWidget(self.cancel_btn)
+ 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)
@@ -107,6 +109,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")
+ if busy:
+ 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)
@@ -122,46 +142,46 @@ 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.preview_btn.setEnabled(False)
- self.status.setText("Previewing…")
+ self._set_previewing(True)
+ 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)
+ 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)
@pyqtSlot()
def _on_preview_finished(self) -> None:
self._previewing = False
- self.preview_btn.setEnabled(True)
- if not self.status.text().startswith("Preview failed") and not self.status.text().startswith("Could not display"):
- self.status.clear()
+ self._set_previewing(False)
+ 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:
if not self._previewing:
return
self._previewing = False
- self.preview_btn.setEnabled(True)
- self.status.setText(f"Preview failed: {msg}")
+ self._set_previewing(False)
+ self.status_strip.set_message(f"Preview failed: {msg}")
@pyqtSlot()
def _on_cancelled(self) -> None:
if not self._previewing:
return
self._previewing = False
- self.preview_btn.setEnabled(True)
- self.status.setText("Preview cancelled.")
+ self._set_previewing(False)
+ 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 7b7c6fd5a..6ef05a787 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.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
+ 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,57 @@ 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.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
+ `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 6811e112e..e8689a796 100644
--- a/negpy/desktop/view/widgets/strip_preview_dialog.py
+++ b/negpy/desktop/view/widgets/strip_preview_dialog.py
@@ -23,26 +23,33 @@
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.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
# (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
+# 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 +81,19 @@ def _display_to_scan_rect(rect):
return (_clamp01(sx1), _clamp01(sy1), _clamp01(sx2), _clamp01(sy2))
+# 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."
+
+_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):
"""Horizontal QSlider that resets to a default on double-click (matches BaseSlider UX)."""
@@ -108,13 +128,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,65 +155,84 @@ 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.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()
- self.offset_slider.setRange(0, 100) # tenths of a mm → 0..10.0 mm
+ # 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)))
- self.offset_slider.setToolTip("Feed-axis offset applied to every frame (the transport cannot back up)")
- top.addWidget(self.offset_slider)
+ 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)))
+ self.offset_slider.setToolTip(_OFFSET_TIP if self._discovers else f"{_OFFSET_TIP} This transport cannot back up.")
self.offset_label = QLabel()
- 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.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)
+ self.drift_slider.setToolTip(_DRIFT_TIP)
self.drift_label = QLabel()
- top.addWidget(self.drift_label)
- top.addSpacing(16)
- top.addWidget(QLabel("Preview DPI"))
+
+ # 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()
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,33 +246,53 @@ 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)
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)
+ # 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 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)
btns.addStretch()
- cancel_btn = QPushButton("Cancel")
- cancel_btn.clicked.connect(self.reject)
- btns.addWidget(cancel_btn)
- self.ok_btn = QPushButton("Use")
- self.ok_btn.setDefault(True)
+ self.cancel_btn = QPushButton("Cancel")
+ self.cancel_btn.clicked.connect(self._on_cancel_clicked)
+ btns.addWidget(self.cancel_btn)
+ 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")
@@ -235,9 +302,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 +313,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 +337,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 +373,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 +398,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."""
@@ -319,10 +417,29 @@ 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:
- 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())
+ 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:
+ for tile in self._tiles.values():
+ tile.checkbox.setChecked(checked)
def _on_clear_all(self) -> None:
for tile in self._tiles.values():
@@ -332,6 +449,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")
+ if busy:
+ 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:
self.offset_label.setText(f"{self.frame_offset():.1f} mm")
@@ -341,13 +465,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]] = []
@@ -373,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) ────────────────────────────
@@ -393,7 +537,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,34 +553,42 @@ 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)
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(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_strip.set_message("Measuring the strip…")
+ else:
+ 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:
"""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:
# 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)
- pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive)).transformed(QTransform().rotate(_DISPLAY_ROTATION_DEG))
+ 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))
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
@@ -447,11 +601,19 @@ 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_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."
+ )
+ 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:
@@ -459,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:
@@ -467,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/negpy/desktop/workers/scan_worker.py b/negpy/desktop/workers/scan_worker.py
index 42658aaf5..52ec8a144 100644
--- a/negpy/desktop/workers/scan_worker.py
+++ b/negpy/desktop/workers/scan_worker.py
@@ -31,11 +31,15 @@ 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)
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
@@ -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,23 +210,29 @@ 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
- 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)
@@ -267,6 +279,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 +306,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)
@@ -314,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/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..87ffa5707
--- /dev/null
+++ b/negpy/infrastructure/scanners/nkscan_backend.py
@@ -0,0 +1,558 @@
+# 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 # 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.
+_DEFAULT_AREA_MM = (24.0, 36.0)
+
+_PHASES = {"discover": "Detecting frames", "meter": "Metering", "scan": "Scanning"}
+
+_MM_PER_INCH = 25.4
+
+# 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}))
+ 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,
+ # 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=bool(caps.eject),
+ exposure_time_us=None,
+ hw_clean=True,
+ roll_discovery=True,
+ film_formats=FILM_FORMATS if str(caps.framing) in _MEASURED_FRAMING else (),
+ film_types=tuple(FILM_TYPES),
+ 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),
+ )
+
+
+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] = {}
+ 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
+ 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,
+ 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,
+ )
+ 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,
+ rect: tuple[int, int, int, int],
+ *,
+ superfine: bool = False,
+ **options: Any,
+ ) -> Any:
+ """One pass over `rect`. Every scan goes through here, previews included.
+
+ 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 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
+ 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..80713457b
--- /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,
+ 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),
+ )
+ 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..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,13 @@ 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
+ 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 }}'
@@ -54,17 +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
+ 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 fall back to the
- sidebar spinbox 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
- return tuple(range(frame_from, frame_to + 1)), {}, settings.scan_window
+ if whole_strip or capacity is None:
+ return (), {}, 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/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/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/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/pyproject.toml b/pyproject.toml
index 5e6b3544a..933755f89 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ classifiers = [
]
[project.optional-dependencies]
+nkscan = ["nkscan>=0.9"]
plustek = ["pyopticfilm>=1.2.0"]
sane = ["python-sane>=2.9"]
camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"]
@@ -66,6 +67,10 @@ camera = [
pieusb = [
"pieusb>=0.3.7",
]
+nkscan = [
+ # Nikon Coolscan over SCSI/USB. A Rust extension, shipped as wheels.
+ "nkscan>=0.9",
+]
[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..352932def
--- /dev/null
+++ b/tests/scanners/fake_nkscan.py
@@ -0,0 +1,294 @@
+"""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
+
+
+# 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"
+ 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
+ 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)
+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
+ 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
+ self.Capabilities = FakeCapabilities
+ 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
+ # 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",
+ 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..d583d162c
--- /dev/null
+++ b/tests/scanners/test_nkscan_backend.py
@@ -0,0 +1,517 @@
+"""NkscanBackend: capability projection, frame resolution, option plumbing, error typing."""
+
+from __future__ import annotations
+
+import dataclasses
+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 FILM_TYPES, 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)
+
+
+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 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 [s["superfine"] for s in module.opened[-1].scans] == [True]
+
+
+def test_a_unit_with_a_fast_read_is_asked_for_it() -> None:
+ backend, module = make_backend()
+ _scan(backend)
+ assert module.opened[-1].scans[-1]["superfine"] is False
+
+
+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
+
+
+# ── 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(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(max_samples=1))
+ caps = backend.list_devices()[0].capabilities
+ assert caps.max_samples == 1
+
+
+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
+
+
+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_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
+
+ 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 ───────────────────────────────────────────────────
+
+
+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"))
+
+
+# ── 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/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..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,15 +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(), 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, 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/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/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)]
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 aeb668cce..2c6b31421 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")
@@ -89,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():
@@ -100,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():
@@ -109,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
@@ -156,3 +161,60 @@ 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.status_strip._bar.value() == 25
+ assert dialog.status_strip._bar.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"
+
+
+def test_the_exits_name_their_object_and_enter_scans() -> None:
+ """One grammar across the three preview dialogs: reset · Cancel · Apply