diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2d4142169..4633d75f6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -962,7 +962,7 @@ Capture film directly into NegPy. Two collapsible sections. ### Film 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 and **Output** (format, folder, filename template). 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. 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. +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 and **Output** (format, folder, filename template). A group's header disappears with the whole group when the device has nothing in it. **Format** writes `TIFF` or `TIFF (mono)`, which is one 16-bit grey plane for film with a single record, such as a black-and-white negative. **Frames** takes the frames to scan as a list: `1-6`, `1,2,5`, or empty for every frame on the film. 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 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 color passes for more highlight and shadow detail; the long pass exposure is chosen per frame, and the scan takes longer than a normal pass. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. @@ -970,7 +970,7 @@ With **IR** checked, color and infrared come back in one scan pass; pyopticfilm 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, since the frames and their crops describe the piece of film that just came out. **Offset** and **Drift** survive an eject, because they register the transport rather than one strip. Four controls appear only on this backend: +**Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass. The search starts when the dialog opens, and its result is kept until the film is ejected. **Detect frames** runs it again when the film has moved. 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** (±10 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, since 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, because 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. @@ -980,7 +980,7 @@ The default Full window includes a little holder chrome top and bottom; host-pat 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: If running from source install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module. +The driver is the optional **nkscan** package (0.11 or newer), which ships as a wheel: If running from source 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, so the real scan only reads that region, rather than reading the full frame (holder margins and film rebate included) and cropping in software afterward. @@ -1003,9 +1003,12 @@ Camera scanning needs the optional `python-gphoto2` dependency (`pip install gph 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. +* **Frame outline**: a red box marks the detected frame on each tile. Offset, Drift and the per-frame slider are measured from it. +* **Offset**: slides every frame along the film to clear the inter-frame gap. Frames shift left as it grows, live. On a measured strip the tiles are cut again from the strip pass when the slider stops, with no new scan. 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. +* **Per-frame offset**: the slider under each tile corrects that frame alone, on top of Offset and Drift. The tooltip shows its value; double-click resets it. +* **Size**: the tile size. The grid reflows to fit the dialog, with no new scan, and the size is remembered. Double-click resets it. +* **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, per-frame offsets included; Offset and Drift survive, because they register the transport. --- diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 14d83dcef..7632444f5 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -30,7 +30,7 @@ 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 +from negpy.infrastructure.scanners.settings import OUTPUT_FORMATS, ScannerSettings _SAMPLE_COUNTS = (1, 2, 4, 8, 16) @@ -312,8 +312,8 @@ def _init_ui(self) -> None: self.form.addRow(self.output_header) self.fmt_combo = QComboBox() - self.fmt_combo.addItems(["TIFF", "DNG"]) - self.fmt_combo.setToolTip("Output file format") + self.fmt_combo.addItems(list(OUTPUT_FORMATS)) + self.fmt_combo.setToolTip("Output file format. Mono writes one grey plane, for film with a single record.") self.form.addRow("Format", self.fmt_combo) folder_row = QHBoxLayout() @@ -840,6 +840,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, + initial_frame_offsets=self._settings.frame_offsets, + initial_tile_height=self._settings.strip_tile_height, film_format=self._film_format(), film_type=self._film_type(), parent=self, @@ -851,6 +853,8 @@ def _on_set_scan_window(self) -> None: selected_frames=dialog.selected_frames(), frame_offset_mm=dialog.frame_offset(), frame_offset_modifier_mm=dialog.frame_offset_modifier(), + frame_offsets=dialog.frame_offsets(), + strip_tile_height=dialog.tile_height(), ) self._update_scan_window_status() if dialog.scan_requested(): @@ -1095,6 +1099,7 @@ def _on_scan(self) -> None: frames=frames, frame_windows=frame_windows, frame_offset_modifier_mm=self._settings.frame_offset_modifier_mm, + frame_offsets=self._settings.frame_offsets, ) ) else: @@ -1162,9 +1167,9 @@ def _on_ejected(self, triggered: bool) -> None: 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) + stale = bool(self._settings.selected_frames or self._settings.frame_windows or self._settings.frame_offsets) if stale: - self.settings = replace(self._settings, selected_frames=(), frame_windows={}) + self.settings = replace(self._settings, selected_frames=(), frame_windows={}, frame_offsets={}) self._update_scan_window_status() self._update_summary() self.status_strip.set_message("Film ejected — frame selection cleared" if stale else "Film ejected") diff --git a/negpy/desktop/view/widgets/scan_window_label.py b/negpy/desktop/view/widgets/scan_window_label.py index d43520003..e5cd97820 100644 --- a/negpy/desktop/view/widgets/scan_window_label.py +++ b/negpy/desktop/view/widgets/scan_window_label.py @@ -8,8 +8,8 @@ from PyQt6.QtCore import QPoint, QRect, Qt, pyqtSignal from PyQt6.QtGui import QColor, QMouseEvent, QPainter, QPen, QPixmap from PyQt6.QtWidgets import QLabel, QSizePolicy -from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.scan_window_geometry import ( Rect, hit_corner, @@ -207,6 +207,10 @@ def paintEvent(self, _ev) -> None: painter.drawRect(QRect(x, draw_rect.top(), max(0, draw_rect.right() - x), draw_rect.height())) painter.setPen(pen) painter.drawLine(x, draw_rect.top(), x, draw_rect.bottom()) + # Drawn last, so the offset band and a crop cannot hide the frame boundary. + painter.setPen(QPen(QColor(THEME.accent_primary), 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(draw_rect.adjusted(0, 0, -1, -1)) else: painter.fillRect(self.rect(), QColor(THEME.bg_dark)) painter.end() diff --git a/negpy/desktop/view/widgets/strip_preview_dialog.py b/negpy/desktop/view/widgets/strip_preview_dialog.py index 2742e1cad..2e02f6255 100644 --- a/negpy/desktop/view/widgets/strip_preview_dialog.py +++ b/negpy/desktop/view/widgets/strip_preview_dialog.py @@ -6,7 +6,7 @@ """ import qtawesome as qta -from PyQt6.QtCore import Qt, pyqtSlot +from PyQt6.QtCore import Qt, QTimer, pyqtSlot from PyQt6.QtGui import QPixmap, QTransform from PyQt6.QtWidgets import ( QCheckBox, @@ -42,12 +42,18 @@ # (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 +# ±10 mm in tenths: a measured boundary can sit several millimetres off the picture. +_MAX_MEASURED_OFFSET_TENTHS = 100 +_TILE_H = 140 # default tile height; width follows the device aspect +_TILE_H_MIN, _TILE_H_MAX = 90, 340 # what the size slider spans +_GRID_MARGIN = 36 # dialog width the strip grid does not get: frame, scrollbar, padding +_TILE_SLIDER_H = 18 # the per-frame offset slider under each tile +_TILES_PER_ROW = 6 # columns assumed before the grid has a width to measure # 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 +# Pause after an offset moves before its tiles are re-cut, so one drag makes one request. +_RECUT_DELAY_MS = 250 # 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 @@ -84,14 +90,16 @@ def _display_to_scan_rect(rect): # One line of orientation. Offset and Drift explain themselves on their own sliders, where # the hand already is, and the ⓘ carries the rest. _FEEDER_HELP = "Preview a frame, drag on it to crop, and tick the frames to scan." -_DISCOVERY_HELP = "Detect the frames, untick what you do not want, drag on a tile to crop it." +_DISCOVERY_HELP = "Untick what you do not want, drag on a tile to crop it, slide under a tile to shift 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." +_DRIFT_TIP = "Adds progressively more (or less) offset per frame position, for a strip whose gaps creep." +_TILE_OFFSET_TIP = "Corrects this frame alone, on top of Offset and Drift. Double-click to reset." +_SIZE_TIP = "Tile size. The grid reflows to whatever fits the dialog. Double-click to reset." class _ResetSlider(QSlider): @@ -108,12 +116,21 @@ def mouseDoubleClickEvent(self, _event) -> None: class _Tile: """One strip position: its preview label and include box.""" - def __init__(self, frame: int, label: ScanWindowLabel, checkbox: QCheckBox, preview_btn: QPushButton, widget: QWidget) -> None: + def __init__( + self, + frame: int, + label: ScanWindowLabel, + checkbox: QCheckBox, + preview_btn: QPushButton, + offset_slider: "_ResetSlider", + widget: QWidget, + ) -> None: self.frame = frame self.previewed_offset: float | None = None # offset the shown preview was scanned at self.label = label self.checkbox = checkbox self.preview_btn = preview_btn + self.offset_slider = offset_slider self.widget = widget @@ -128,6 +145,8 @@ def __init__( initial_selected=None, initial_offset: float = 0.0, initial_offset_modifier: float = 0.0, + initial_frame_offsets: dict[int, float] | None = None, + initial_tile_height: int = _TILE_H, film_format: str | None = None, film_type: str = "negative", parent=None, @@ -144,20 +163,32 @@ def __init__( 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. + # becomes horizontal. Every tile keeps this aspect; the size slider drives the height. mm = self._caps.max_area_mm self._tile_aspect = (mm[1] / mm[0]) if (mm and len(mm) > 1 and mm[0]) else 1.5 self._previewing = False + self._detected_on_open = False + # On a measured strip a moved offset re-cuts the tile from the strip pass, not slides its pixels. + self._recutting = False + self._recut = QTimer(self) + self._recut.setSingleShot(True) + self._recut.setInterval(_RECUT_DELAY_MS) + self._recut.timeout.connect(self._recut_moved_tiles) self._failed_frames: list[int] = [] self._scan_now = False # set when the user chooses "Scan" over "Use" initial_windows = initial_windows or {} initial_selected = tuple(initial_selected or ()) + self._initial_frame_offsets = dict(initial_frame_offsets or {}) + self._tile_h = max(_TILE_H_MIN, min(_TILE_H_MAX, int(initial_tile_height))) + # Columns the grid is currently laid out in, and the stretch cells pinning it top-left. + self._cols = _TILES_PER_ROW + self._stretch: tuple[int, int] | None = None self.setWindowTitle("Preview Strip — Set a Window per Frame") self.setModal(True) tile_w, tile_h = self._tile_size() 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) + self.resize(cols * (tile_w + 4) + _GRID_MARGIN, min(rows, 3) * (tile_h + _TILE_SLIDER_H + 4) + 260) layout = QVBoxLayout(self) @@ -248,7 +279,7 @@ def __init__( 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 = QLabel("Finding the frames on 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_hint}; font-size: {THEME.font_size_base}px; padding: 48px;") strip.addWidget(self._empty_hint, 0, 0, 1, _TILES_PER_ROW) @@ -256,9 +287,7 @@ def __init__( self._initial_selected = initial_selected for frame in range(1, self._capacity + 1): 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._relayout(force=True) self._scroll.setWidget(container) layout.addWidget(self._scroll, 1) @@ -288,6 +317,16 @@ def __init__( self.clear_btn.clicked.connect(self._on_clear_all) btns.addWidget(self.clear_btn) btns.addStretch() + self.size_slider = _ResetSlider(_TILE_H) + self.size_slider.setRange(_TILE_H_MIN, _TILE_H_MAX) + self.size_slider.setSingleStep(10) + self.size_slider.setPageStep(40) + self.size_slider.setFixedWidth(120) + self.size_slider.setValue(self._tile_h) + self.size_slider.setToolTip(_SIZE_TIP) + btns.addWidget(QLabel("Size")) + btns.addWidget(self.size_slider) + btns.addSpacing(16) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self._on_cancel_clicked) btns.addWidget(self.cancel_btn) @@ -310,6 +349,7 @@ def __init__( self._tiles_wired = True self.offset_slider.valueChanged.connect(self._on_offset_changed) self.drift_slider.valueChanged.connect(self._on_offset_changed) + self.size_slider.valueChanged.connect(self._on_tile_size_changed) self._on_offset_changed(self.offset_slider.value()) self._update_ok_enabled() @@ -325,10 +365,10 @@ def _ensure_tile(self, frame: int) -> _Tile: 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() + self._relayout(force=True) return tile def _build_tile(self, frame: int, initial_window, checked: bool) -> _Tile: @@ -367,24 +407,106 @@ def _build_tile(self, frame: int, initial_window, checked: bool) -> _Tile: oh.addWidget(preview_btn) grid.addWidget(overlay, 0, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) - return _Tile(frame, label, checkbox, preview_btn, widget) + offset_slider = _ResetSlider() + offset_slider.setRange(-_MAX_MEASURED_OFFSET_TENTHS, _MAX_MEASURED_OFFSET_TENTHS) + offset_slider.setFixedSize(self._tile_size()[0], _TILE_SLIDER_H) + # Set before connecting, so building a tile does not refresh a half-built dialog. + offset_slider.setValue(int(round(self._initial_frame_offsets.get(frame, 0.0) * 10))) + offset_slider.valueChanged.connect(lambda _v, f=frame: self._on_tile_offset_changed(f)) + grid.addWidget(offset_slider, 1, 0) + + tile = _Tile(frame, label, checkbox, preview_btn, offset_slider, widget) + self._set_tile_offset_tooltip(tile) + return tile + + def _fitting_columns(self) -> int: + """Tiles that fit across the strip area, at least one.""" + tile_w = self._tile_size()[0] + # Before show, the viewport has a Qt default width, so measure the dialog instead. + viewport = self._scroll.viewport() + width = viewport.width() if (self.isVisible() and viewport is not None) else self.width() - _GRID_MARGIN + available = width - 4 # the grid's own left/right margins + if available < tile_w: + return 1 + return max(1, (available + 4) // (tile_w + 4)) # 4 = grid spacing + + def _relayout(self, *, force: bool = False) -> None: + """Re-place every tile at the current column count. A no-op while it has not changed.""" + cols = self._fitting_columns() + if cols == self._cols and not force: + return + self._cols = cols + for frame in sorted(self._tiles): + tile = self._tiles[frame] + self._strip.removeWidget(tile.widget) + self._strip.addWidget(tile.widget, (frame - 1) // cols, (frame - 1) % cols) + # Remove first: re-adding a widget the grid holds leaves a second, stale item. + self._strip.removeWidget(self._empty_hint) + self._strip.addWidget(self._empty_hint, 0, 0, 1, cols) + # Pin the grid top-left. Release the previous pin, or an occupied cell keeps stretching. + if self._stretch is not None: + self._strip.setColumnStretch(self._stretch[0], 0) + self._strip.setRowStretch(self._stretch[1], 0) + rows = max(1, -(-len(self._tiles) // cols)) + self._strip.setColumnStretch(cols, 1) + self._strip.setRowStretch(rows, 1) + self._stretch = (cols, rows) + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + self._relayout() + + def showEvent(self, event) -> None: + """Find the frames once, as the dialog opens: every per-frame control lives on a tile.""" + super().showEvent(event) + self._relayout() + if self._discovers and not self._detected_on_open: + self._detected_on_open = True + self._on_preview_all() def _tile_size(self) -> tuple[int, int]: - return int(_TILE_H * self._tile_aspect), _TILE_H + return int(self._tile_h * self._tile_aspect), self._tile_h # ── result getters ──────────────────────────────────────────────── def selected_frames(self) -> tuple[int, ...]: + """Ticked frames, or the saved selection while there are no tiles.""" + if not self._tiles: + return self._initial_selected return tuple(sorted(f for f, t in self._tiles.items() if t.checkbox.isChecked())) def frame_windows(self) -> dict: - return {f: self._to_scan(t.label.window()) for f, t in self._tiles.items() if t.label.window() is not None} - + """Per-frame crops. A frame with no tile keeps its saved crop.""" + merged = dict(self._initial_windows) + for frame, tile in self._tiles.items(): + window = tile.label.window() + if window is None: + merged.pop(frame, None) + else: + merged[frame] = self._to_scan(window) + return merged + + # Both rasters show the feed along display x and high sensor addresses at the top, so one + # transform serves both. def _to_display(self, rect): - return _scan_to_display_rect(rect) if self._rotation else rect + return _scan_to_display_rect(rect) def _to_scan(self, rect): - return _display_to_scan_rect(rect) if self._rotation else rect + return _display_to_scan_rect(rect) + + def tile_height(self) -> int: + return int(self.size_slider.value()) + + def frame_offsets(self) -> dict[int, float]: + """Non-zero per-frame corrections. A frame with no tile keeps its saved correction.""" + merged = dict(self._initial_frame_offsets) + for frame, tile in self._tiles.items(): + value = tile.offset_slider.value() / 10.0 + if value: + merged[frame] = value + else: + merged.pop(frame, None) + return merged def frame_offset(self) -> float: return self.offset_slider.value() / 10.0 @@ -396,11 +518,16 @@ def _frame_pitch(self) -> float: """Feed-axis frame pitch (mm) — the length a tile represents. 0.0 when unknown.""" return effective_pitch_mm(self._caps) + def _frame_delta(self, frame: int) -> float: + """This frame's own correction. A slot with no tile yet contributes nothing.""" + tile = self._tiles.get(frame) + return tile.offset_slider.value() / 10.0 if tile else self._initial_frame_offsets.get(frame, 0.0) + def _raw_offset_for_frame(self, frame: int) -> float: - return self.frame_offset() + (frame - 1) * self.frame_offset_modifier() + return self.frame_offset() + (frame - 1) * self.frame_offset_modifier() + self._frame_delta(frame) def _offset_for_frame(self, frame: int) -> float: - """Effective offset for a frame position: base + (N-1)·drift. + """Effective offset for a frame position: base + (N-1)·drift + the frame's own correction. 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 @@ -459,10 +586,30 @@ def _set_previewing(self, busy: bool) -> None: self.status_strip.stop_progress() self._update_ok_enabled() + def _set_tile_offset_tooltip(self, tile: _Tile) -> None: + tile.offset_slider.setToolTip(f"Frame {tile.frame}: {tile.offset_slider.value() / 10.0:+.1f} mm. {_TILE_OFFSET_TIP}") + + def _on_tile_offset_changed(self, frame: int) -> None: + tile = self._tiles.get(frame) + if tile is not None: + self._set_tile_offset_tooltip(tile) + self._on_offset_changed(0) + + def _on_tile_size_changed(self, value: int) -> None: + """Resize every tile in place. The label letterboxes its kept pixmap, so nothing rescans.""" + self._tile_h = int(value) + size = self._tile_size() + for tile in self._tiles.values(): + tile.label.setFixedSize(*size) + tile.offset_slider.setFixedWidth(size[0]) + self._relayout(force=True) + def _on_offset_changed(self, _value: int) -> None: self.offset_label.setText(f"{self.frame_offset():.1f} mm") self.drift_label.setText(f"{self.frame_offset_modifier():+.2f} mm/frame") self._refresh_offset_indicators() + if self._discovers and self._tiles: + self._recut.start() 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). @@ -519,13 +666,15 @@ 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_strip.set_message(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, Drift or that frame's own slider." + ) elif cut: frames = ", ".join(str(f) for f, _ in cut) worst = max(loss for _, loss in cut) 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." + f"frame tail; reduce Offset or that frame's own slider, or re-feed the strip for a better registration." ) elif self.status_strip.message().startswith((_CLAMP_NOTICE, _CUT_NOTICE)): self.status_strip.set_message("") @@ -543,7 +692,28 @@ def _on_preview_all(self) -> None: 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: + def done(self, result: int) -> None: + """Stop a pending re-cut: its timer holds this dialog.""" + self._recut.stop() + super().done(result) + + def _recut_moved_tiles(self) -> None: + """Re-cut, from the strip pass, the tiles whose offset no longer matches their pixels.""" + if self._previewing: + self._recut.start() + return + pitch = self._frame_pitch() + if not pitch: + return + moved = tuple( + frame + for frame, tile in sorted(self._tiles.items()) + if abs(self._raw_offset_for_frame(frame) / pitch - (tile.previewed_offset or 0.0)) > 1e-4 + ) + if moved: + self._start_preview(moved, message=f"Re-cutting {count_of(len(moved), 'frame')}…") + + def _start_preview(self, slots: tuple[int, ...], *, message: str | None = None) -> None: if self._previewing: return self._failed_frames = [] @@ -564,8 +734,11 @@ def _start_preview(self, slots: tuple[int, ...]) -> None: self.status_strip.set_message(f"Scanner busy — {e}") return self._previewing = True + self._recutting = message is not None self._set_previewing(True) - if self._discovers and len(slots) > 1: + if message is not None: + self.status_strip.set_message(message) + elif 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: @@ -603,7 +776,8 @@ 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: + recut, self._recutting = self._recutting, False + if self._discovers and not self._failed_frames and not recut: found = len(self._tiles) self.status_strip.set_message( f"{count_of(found, 'frame')} detected — check the framing before scanning." diff --git a/negpy/desktop/workers/scan_worker.py b/negpy/desktop/workers/scan_worker.py index a36821315..f614f0d7c 100644 --- a/negpy/desktop/workers/scan_worker.py +++ b/negpy/desktop/workers/scan_worker.py @@ -19,7 +19,7 @@ class ScanRequest: params: ScanParams output_folder: str filename_pattern: str - output_format: str # "TIFF" or "DNG" + output_format: str # one of settings.OUTPUT_FORMATS @dataclass(frozen=True) @@ -61,6 +61,8 @@ class BatchRequest: # Feed-axis drift (mm/frame): frame N scans at frame_offset_mm + (N-1) * modifier, # floored at 0. frame_offset_modifier_mm: float = 0.0 + # Per-frame correction (mm) on top of that ramp; an absent key means none. + frame_offsets: dict[int, float] = field(default_factory=dict) class ScanWorker(QObject): @@ -223,8 +225,9 @@ def run_batch(self, req: BatchRequest) -> None: window = req.frame_windows.get(frame, req.params.window) # 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 + offset = req.params.frame_offset_mm + (frame - 1) * req.frame_offset_modifier_mm + req.frame_offsets.get(frame, 0.0) frame_params = dataclasses.replace(req.params, frame=frame, window=window, frame_offset_mm=offset) + logger.info("Batch frame %d at %+.2f mm on the feed axis", frame, offset) base = index / total # The frame's position in the run rides on the phase string: a batch's global @@ -281,7 +284,7 @@ def _progress(fraction: float, phase: str = "Scanning", _base: float = base, _at 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) + count = service.detect_frames(req.device_id, film_format=req.params.film_format) if count <= 0: raise RuntimeError("No frames were detected on the loaded film") return list(range(1, count + 1)) diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py index 1a6006213..a1b10563e 100644 --- a/negpy/infrastructure/loaders/rawpy_loader.py +++ b/negpy/infrastructure/loaders/rawpy_loader.py @@ -137,9 +137,8 @@ def tag(name: str) -> Optional[Any]: def _peek_linearraw_4ch(file_path: str) -> Optional[Tuple[np.ndarray, np.ndarray]]: """Inspect a DNG. If it carries 4 linear samples (RGB + IR), return (rgb, ir) as float32 [0,1]. - NegPy's own `write_dng_linear` produces a single-IFD DNG; VueScan and Adobe-style DNGs - put the full-res data in a SubIFD behind a reduced-resolution thumbnail IFD0 — both are - checked. Returns None for camera DNGs (Bayer, 3-channel, etc.) so rawpy can handle them. + Checks IFD0 and, for VueScan and Adobe-style DNGs, a SubIFD behind a thumbnail IFD0. + Returns None for camera DNGs (Bayer, 3-channel, etc.) so rawpy can handle them. """ if not _is_dng(file_path): return None diff --git a/negpy/infrastructure/scanners/nkscan_backend.py b/negpy/infrastructure/scanners/nkscan_backend.py index 87ffa5707..1553ef5d8 100644 --- a/negpy/infrastructure/scanners/nkscan_backend.py +++ b/negpy/infrastructure/scanners/nkscan_backend.py @@ -29,7 +29,6 @@ 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 @@ -241,6 +240,7 @@ def __init__(self) -> None: # re-previewing a strip after a nudge must not cost another read of the film. self._frames: dict[str, list[tuple[int, int, int, int]]] = {} self._strips: dict[str, np.ndarray] = {} + self._columns: dict[str, float] = {} self._lock = threading.Lock() # ── enumeration ─────────────────────────────────────────────────── @@ -361,8 +361,10 @@ def _scan_on_session( report = _progress_bridge(progress, cancel) rect = self._resolve_frame(session, device_id, params, report) optical = int(session.capabilities.optical_dpi) + detected = rect rect = _shift_frame(rect, _offset_units(params.frame_offset_mm, optical)) rect = _crop_frame(rect, params.window) + logger.info("Frame %s detected %s, scanning %s (%+0.2f mm)", params.frame, detected, rect, params.frame_offset_mm) with self._mapped_errors(): result = self.scan_frame( session, @@ -425,24 +427,20 @@ def discover_frames( 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, - ) + discovery = session.discover_frames(format=film_format, 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) + self._columns[device_id] = float(discovery.addresses_per_column) 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: + def detect_frames(self, device_id: str, *, film_format: str | None = None) -> 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. @@ -454,12 +452,12 @@ def detect_frames(self, device_id: str, *, film_format: str | None = None, film_ 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) + self.discover_frames(held._session, device_id, film_format=film_format) 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) + self.discover_frames(session, device_id, film_format=film_format) finally: with suppress(Exception): session.close() @@ -472,6 +470,10 @@ 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 addresses_per_column(self, device_id: str) -> float | None: + """Feed addresses one column of the strip pass spans, as that pass measured it.""" + return self._columns.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) @@ -482,6 +484,7 @@ 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) + self._columns.pop(device_id, None) def _resolve_frame( self, @@ -496,7 +499,6 @@ def _resolve_frame( session, device_id, film_format=params.film_format, - film_type=params.film_type, progress=report, ) frames = self._frames.get(device_id) diff --git a/negpy/infrastructure/scanners/nkscan_roll.py b/negpy/infrastructure/scanners/nkscan_roll.py index 80713457b..903846e22 100644 --- a/negpy/infrastructure/scanners/nkscan_roll.py +++ b/negpy/infrastructure/scanners/nkscan_roll.py @@ -26,17 +26,6 @@ _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. @@ -119,16 +108,12 @@ 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)) + tile = slice_frame(strip, rect, self._backend.addresses_per_column(self._device.id) or 0.0) 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(): @@ -154,7 +139,6 @@ def _ensure_frames(self, cancel: threading.Event) -> list[tuple[int, int, int, i 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) diff --git a/negpy/infrastructure/scanners/settings.py b/negpy/infrastructure/scanners/settings.py index 7eb1bf2a8..a8d145def 100644 --- a/negpy/infrastructure/scanners/settings.py +++ b/negpy/infrastructure/scanners/settings.py @@ -5,6 +5,11 @@ Rect = tuple[float, float, float, float] +#: One 16-bit grey plane, for film with a single record such as a B&W negative. +MONO_TIFF = "TIFF (mono)" +#: What the Format combo offers, in order. +OUTPUT_FORMATS = ("TIFF", MONO_TIFF) + @dataclass(frozen=True) class ScannerSettings: @@ -42,6 +47,10 @@ class ScannerSettings: # switch to a sorted tuple of pairs if that ever changes. frame_windows: dict[int, Rect] = field(default_factory=dict) selected_frames: tuple[int, ...] = () + # Per-frame feed-axis correction (mm) on top of frame_offset_mm + drift. + frame_offsets: dict[int, float] = field(default_factory=dict) + # Strip preview tile height (px); the width follows the device aspect. + strip_tile_height: int = 140 def __post_init__(self) -> None: # JSON round-trips tuples as lists and dict keys as strings; coerce back. @@ -55,6 +64,8 @@ def __post_init__(self) -> None: ) if isinstance(self.selected_frames, list): object.__setattr__(self, "selected_frames", tuple(self.selected_frames)) + if isinstance(self.frame_offsets, dict): + object.__setattr__(self, "frame_offsets", {int(k): float(v) for k, v in self.frame_offsets.items()}) @classmethod def defaults(cls) -> "ScannerSettings": @@ -68,6 +79,9 @@ def from_dict(cls, data: dict) -> "ScannerSettings": every unrelated preference with it. """ data = dict(data) + # A saved DNG output format loads as TIFF. + if str(data.get("output_format", "")).upper() == "DNG": + data["output_format"] = "TIFF" 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)) diff --git a/negpy/services/scanning/__init__.py b/negpy/services/scanning/__init__.py index bdc2f09be..881471989 100644 --- a/negpy/services/scanning/__init__.py +++ b/negpy/services/scanning/__init__.py @@ -1,10 +1,9 @@ """Scanner orchestration — no Qt dependencies.""" from negpy.services.scanning.service import ScannerService -from negpy.services.scanning.writer import write_dng_linear, write_tiff_16bit +from negpy.services.scanning.writer import write_tiff_16bit __all__ = [ "ScannerService", - "write_dng_linear", "write_tiff_16bit", ] diff --git a/negpy/services/scanning/service.py b/negpy/services/scanning/service.py index 5c9cd039e..23f0f47a2 100644 --- a/negpy/services/scanning/service.py +++ b/negpy/services/scanning/service.py @@ -62,13 +62,13 @@ def eject(self, device_id: str) -> bool: """ return self._get_backend().eject(device_id) - def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: + def detect_frames(self, device_id: str, *, film_format: str | None = None) -> 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)) + return 0 if detect is None else int(detect(device_id, film_format=film_format)) def open_roll( self, @@ -134,12 +134,14 @@ def write_result( """ from datetime import date as dt_date - from negpy.services.scanning.writer import write_dng_linear, write_tiff_16bit + from negpy.infrastructure.scanners.settings import MONO_TIFF + from negpy.services.scanning.writer import write_tiff_16bit os.makedirs(output_folder, exist_ok=True) + fmt = output_format.upper() date_str = dt_date.today().strftime("%Y%m%d") - ext = ".dng" if output_format.upper() == "DNG" else ".tif" + ext = ".tif" require_sequence_varying_scan_filename(filename_pattern, date_str) @@ -151,9 +153,6 @@ def write_result( break current += 1 - if output_format.upper() == "DNG": - rgb_path = write_dng_linear(result, rgb_path) - else: - rgb_path = write_tiff_16bit(result, rgb_path) + rgb_path = write_tiff_16bit(result, rgb_path, mono=fmt == MONO_TIFF.upper()) return rgb_path diff --git a/negpy/services/scanning/writer.py b/negpy/services/scanning/writer.py index b9a62f481..cc58019bb 100644 --- a/negpy/services/scanning/writer.py +++ b/negpy/services/scanning/writer.py @@ -1,6 +1,4 @@ -import io import os -import struct import tempfile import numpy as np @@ -29,21 +27,36 @@ def _to_uint16(arr: np.ndarray) -> np.ndarray: return arr.astype(np.uint16) -def write_tiff_16bit(result: ScanResult, path: str) -> str: +def _to_grey(rgb: np.ndarray) -> np.ndarray: + """The rounded mean of the three planes. + + Single-record film is metered with locked channels, so the planes are one density read three + times. The sum is uint32 so a uint16 source cannot overflow. + """ + if rgb.ndim == 2: + return rgb + return ((np.sum(rgb, axis=-1, dtype=np.uint32) + 1) // 3).astype(rgb.dtype) + + +def write_tiff_16bit(result: ScanResult, path: str, *, mono: bool = False) -> str: """Write ScanResult to 16-bit TIFF. IR written as sidecar `_IR.tif`. - Uses atomic write (write to a part file, then rename) to avoid partial files. - Returns final RGB path. + `mono` writes one grey plane instead of three. 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")): path = path + ".tif" rgb = _to_uint16(result.rgb) + photometric = "rgb" + if mono: + rgb = _to_grey(rgb) + photometric = "minisblack" 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) + tifffile.imwrite(tmp_path, rgb, photometric=photometric, compression="zlib", predictor=True) os.replace(tmp_path, path) except Exception: if os.path.exists(tmp_path): @@ -81,85 +94,3 @@ def write_tiff_16bit(result: ScanResult, path: str) -> str: raise return path - - -def write_dng_linear(result: ScanResult, path: str) -> str: - """Write ScanResult to an uncompressed 16-bit LinearRaw DNG via tifffile. - - A LinearRaw DNG is a single-IFD TIFF plus a few DNG tags. If result.ir is - present it is stacked as an extra sample. Atomic write; returns final path. - """ - if not path.lower().endswith(".dng"): - path = path + ".dng" - - rgb = _to_uint16(result.rgb) - - if result.ir is not None: - ir = result.ir - if ir.ndim == 2: - ir = ir[:, :, np.newaxis] - ir = _to_uint16(ir) - full_array = np.dstack([rgb, ir]) - else: - full_array = np.ascontiguousarray(rgb) - - model = result.device_model - # (code, dtype, count, value, writeonce); NewSubfileType=0 is required or LibRaw rejects the DNG. - extratags = [ - (254, 4, 1, 0, True), # NewSubfileType - (50706, 1, 4, (1, 4, 0, 0), True), # DNGVersion - (50707, 1, 4, (1, 0, 0, 0), True), # DNGBackwardVersion - (274, 3, 1, 1, True), # Orientation - (271, 2, len(model) + 1, model, True), # Make - (272, 2, len(model) + 1, model, True), # Model - ] - payload = _encode_dng(full_array, extratags) - - 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: - fh.write(payload) - os.replace(tmp_path, path) - except Exception: - if os.path.exists(tmp_path): - os.unlink(tmp_path) - raise - - return path - - -def _encode_dng(full_array: np.ndarray, extratags: list) -> bytes: - """Encode an RGB(+IR) uint16 array as LinearRaw DNG bytes. - - RGB is written with the RGB photometric so tifffile emits a clean 3 *color* - samples with no ExtraSamples (matching pidng); the PhotometricInterpretation - tag is then patched to LinearRaw (34892), which DNG requires. Marking color - planes as ExtraSamples instead makes some raw processors treat the file as a - 1-channel sensor + aux planes and mis-demosaic it. - - The IR (4-sample) case keeps the LINEAR_RAW photometric with the extra planes - declared as extra samples — there the 4th plane genuinely is infrared, and - tifffile has no clean 4-color-sample form. - """ - buf = io.BytesIO() - if full_array.shape[-1] == 3: - tifffile.imwrite(buf, full_array, photometric=tifffile.PHOTOMETRIC.RGB, compression=None, metadata=None, extratags=extratags) - data = bytearray(buf.getvalue()) - with tifffile.TiffFile(io.BytesIO(bytes(data))) as tf: - offset = tf.pages[0].tags["PhotometricInterpretation"].valueoffset - byteorder = tf.byteorder - struct.pack_into(byteorder + "H", data, offset, 34892) # RGB(2) → LinearRaw(34892) - return bytes(data) - - extrasamples = (0,) * (full_array.shape[-1] - 1) - tifffile.imwrite( - buf, - full_array, - photometric=tifffile.PHOTOMETRIC.LINEAR_RAW, - compression=None, - metadata=None, - extrasamples=extrasamples, - extratags=extratags, - ) - return buf.getvalue() diff --git a/pyproject.toml b/pyproject.toml index e5410d300..501bc06d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ classifiers = [ ] [project.optional-dependencies] -nkscan = ["nkscan>=0.9"] +nkscan = ["nkscan>=0.11"] plustek = ["pyopticfilm>=1.3.3"] sane = ["python-sane>=2.9"] camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"] @@ -69,7 +69,7 @@ pieusb = [ ] nkscan = [ # Nikon Coolscan over SCSI/USB. A Rust extension, shipped as wheels. - "nkscan>=0.9", + "nkscan>=0.11", ] [project.urls] diff --git a/tests/scanners/fake_nkscan.py b/tests/scanners/fake_nkscan.py index 352932def..781ec6cef 100644 --- a/tests/scanners/fake_nkscan.py +++ b/tests/scanners/fake_nkscan.py @@ -93,6 +93,7 @@ class FakeDevice: class FakeDiscovery: frames: list[tuple[int, int, int, int]] thumbnail: dict[str, np.ndarray] | None = None + addresses_per_column: float | None = None @dataclass(frozen=True) @@ -120,6 +121,8 @@ class FakeNkscanModule: cols: int = 6 thumbnail: bool = True strip_slack: int = 4 # columns past the last frame; negative pushes frames off the pass + # Not the caps' 4000 / 250, as on a real pass. + addresses_per_column: float = 16.3 scan_error: Exception | None = None discover_error: Exception | None = None open_error: Exception | None = None @@ -150,20 +153,14 @@ 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. - """ + """The whole-strip pass. Each frame's band holds its slot number, so a tile shows where it was cut.""" 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 + scale = self.addresses_per_column + cols = round(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 + plane[:, round(top / scale) : round(bottom / scale)] = slot return {c: plane.copy() for c in ("red", "green", "blue")} @property @@ -186,7 +183,6 @@ def __init__(self, location: str) -> None: 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) @@ -217,17 +213,20 @@ def eject(self) -> bool: 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()) + thumbnail = module.strip_pass() + return FakeDiscovery( + frames=list(module.frames), + thumbnail=thumbnail, + addresses_per_column=module.addresses_per_column if thumbnail else None, + ) def scan_frame( self, diff --git a/tests/scanners/test_nkscan_backend.py b/tests/scanners/test_nkscan_backend.py index d583d162c..e97bf7467 100644 --- a/tests/scanners/test_nkscan_backend.py +++ b/tests/scanners/test_nkscan_backend.py @@ -458,19 +458,6 @@ def test_every_other_film_keeps_its_factory_balance() -> None: # ── 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"): @@ -515,3 +502,30 @@ def test_the_films_the_backend_names_are_films_the_extension_knows() -> None: backend, _ = make_backend() for film in FILM_TYPES: assert backend.locks_white_balance(film) == nkscan.Capabilities.locks_white_balance(film) + + +def test_a_per_frame_offset_slides_only_the_feed_axis_of_the_frame_asked_for() -> None: + """The rect moves by the dialled distance on the feed axis only.""" + shift = round(0.7 * 4000 / 25.4) # 0.7 mm at the fake's optical dpi + for frame, offset_mm, expected in ((3, 0.0, 0), (3, 0.7, shift), (3, -0.7, -shift), (1, 0.7, shift)): + backend, module = make_backend() + _scan(backend, dataclasses.replace(_PARAMS, frame=frame, frame_offset_mm=offset_mm)) + + rect = module.opened[-1].scans[-1]["frame"] + detected = FRAMES[frame - 1] + assert rect[0] - detected[0] == expected + assert rect[2] - detected[2] == expected + assert (rect[1], rect[3]) == (detected[1], detected[3]) # across-film edges untouched + assert rect[2] - rect[0] == detected[2] - detected[0] # the frame keeps its length + + +def test_the_scan_logs_the_detected_and_the_shifted_rect(caplog) -> None: + """Which rect a frame was actually scanned at has to be readable without the file.""" + import logging + + backend, _module = make_backend() + with caplog.at_level(logging.INFO): + _scan(backend, dataclasses.replace(_PARAMS, frame=2, frame_offset_mm=1.0)) + + assert "detected (10742, 0, 16410, 3945)" in caplog.text + assert "+1.00 mm" in caplog.text diff --git a/tests/scanners/test_nkscan_roll.py b/tests/scanners/test_nkscan_roll.py index 2317881d6..529d1a7b9 100644 --- a/tests/scanners/test_nkscan_roll.py +++ b/tests/scanners/test_nkscan_roll.py @@ -4,6 +4,7 @@ import threading +import numpy as np import pytest from negpy.infrastructure.scanners.base import ScannerDevice @@ -230,3 +231,24 @@ def test_ejecting_forgets_the_strip_pass_because_that_film_is_gone() -> None: backend.eject(device.id) assert backend.strip_pass(device.id) is None + + +def test_a_tile_is_cut_at_the_address_the_scan_will_use() -> None: + """The tile uses the column pitch the pass measured, not optical over thumbnail dpi.""" + backend, module = make_backend() + device = backend.list_devices()[0] + session = backend.open_roll(device, dpi=500) + try: + previews = _previews(session) + finally: + session.close() + + scale = module.addresses_per_column + strip = backend.strip_pass(device.id) + assert strip is not None + for preview, (slot, rect) in zip(previews, enumerate(module.frames, 1)): + expected = strip[:, round(rect[0] / scale) : round(rect[2] / scale)] + assert preview.rgb.shape[1] == expected.shape[1] + # Every band carries its own slot, so a tile cut at the wrong column opens on the gap. + assert set(np.unique(preview.rgb[:, 0])) == {slot} + assert set(np.unique(preview.rgb)) <= {0, slot} diff --git a/tests/scanners/test_scanner_settings.py b/tests/scanners/test_scanner_settings.py index a2c30df4a..9822ce6fe 100644 --- a/tests/scanners/test_scanner_settings.py +++ b/tests/scanners/test_scanner_settings.py @@ -139,3 +139,14 @@ def test_an_unset_saved_frame_range_selects_nothing(): 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" + + +def test_per_frame_offsets_round_trip_through_json_string_keys(): + restored = ScannerSettings.from_dict({"frame_offsets": {"2": 0.4, "5": -0.3}}) + assert restored.frame_offsets == {2: 0.4, 5: -0.3} + + +def test_a_saved_dng_output_format_lands_on_tiff(): + # DNG output is retired; the saved preference must not survive as an unknown format. + assert ScannerSettings.from_dict({"output_format": "DNG"}).output_format == "TIFF" + assert ScannerSettings.from_dict({"output_format": "TIFF (mono)"}).output_format == "TIFF (mono)" diff --git a/tests/scanners/test_service.py b/tests/scanners/test_service.py index e37d5a012..9ef65283d 100644 --- a/tests/scanners/test_service.py +++ b/tests/scanners/test_service.py @@ -224,6 +224,25 @@ def test_no_overwrite_increments(self) -> None: assert os.path.exists(path2) assert path1 != path2 + def test_the_mono_format_writes_one_plane_and_keeps_the_tif_extension(self) -> None: + import tempfile + + import numpy as np + import tifffile + + from negpy.infrastructure.scanners.result import ScanResult + from negpy.infrastructure.scanners.settings import MONO_TIFF + + result = ScanResult(rgb=np.zeros((8, 8, 3), dtype=np.uint16), ir=None, dpi=300, device_model="Test") + + with tempfile.TemporaryDirectory() as tmpdir: + service = ScannerService() + service._backend = FakeBackend() + path = service.write_result(result, tmpdir, '{{ date }}_{{ "%03d" % seq }}', MONO_TIFF) + + assert path.endswith(".tif") + assert tifffile.imread(path).shape == (8, 8) + def test_write_refuses_a_pattern_that_does_not_vary_with_sequence(self) -> None: import tempfile @@ -273,8 +292,8 @@ def test_open_roll_wraps_a_backend_that_scans_one_frame_at_a_time(fake_device: S 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) + def detect_frames(self, device_id: str, *, film_format: str | None = None) -> int: + self.detect_args = (device_id, film_format) return 4 @@ -283,8 +302,8 @@ def test_detect_frames_asks_the_backend_that_can_measure(fake_device: ScannerDev 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") + assert service.detect_frames("fake:001", film_format="135") == 4 + assert backend.detect_args == ("fake:001", "135") def test_a_backend_that_counts_slots_measures_nothing(fake_device: ScannerDevice) -> None: diff --git a/tests/scanners/test_writer.py b/tests/scanners/test_writer.py index e25513476..1a8515bab 100644 --- a/tests/scanners/test_writer.py +++ b/tests/scanners/test_writer.py @@ -1,4 +1,4 @@ -"""Tests for TIFF and DNG output writers.""" +"""Tests for the TIFF output writer.""" import os import tempfile @@ -7,7 +7,7 @@ import tifffile from negpy.infrastructure.scanners.result import ScanResult -from negpy.services.scanning.writer import write_dng_linear, write_tiff_16bit +from negpy.services.scanning.writer import write_tiff_16bit class TestTiffWriter: @@ -83,55 +83,6 @@ def test_converts_non_uint16(self) -> None: assert readback.dtype == np.uint16 -class TestDngWriter: - def test_writes_linear_dng(self) -> None: - rgb = np.random.randint(0, 65535, (200, 300, 3), dtype=np.uint16) - result = ScanResult(rgb=rgb, ir=None, dpi=3600, device_model="TestScanner") - - with tempfile.TemporaryDirectory() as tmpdir: - path = write_dng_linear(result, os.path.join(tmpdir, "test_scan")) - assert os.path.exists(path) - assert path.endswith(".dng") - - readback = tifffile.imread(path) - assert readback.shape == (200, 300, 3) - assert readback.dtype == np.uint16 - np.testing.assert_array_equal(readback, rgb) - - with tifffile.TiffFile(path) as tf: - tags = tf.pages[0].tags - assert int(tags["PhotometricInterpretation"].value) == 34892 # LinearRaw - assert tuple(tags["DNGVersion"].value) == (1, 4, 0, 0) - assert int(tags["SamplesPerPixel"].value) == 3 - # 3 plain color samples, no ExtraSamples (matches pidng); marking color - # planes as extra makes some raw processors mis-demosaic the file. - assert tags.get("ExtraSamples") is None - - def test_writes_dng_with_ir(self) -> None: - rgb = np.random.randint(0, 65535, (100, 150, 3), dtype=np.uint16) - ir = np.random.randint(0, 65535, (100, 150), dtype=np.uint16) - result = ScanResult(rgb=rgb, ir=ir, dpi=3600, device_model="TestScanner") - - with tempfile.TemporaryDirectory() as tmpdir: - path = write_dng_linear(result, os.path.join(tmpdir, "test_ir")) - assert os.path.exists(path) - - readback = tifffile.imread(path) - assert readback.shape == (100, 150, 4) - np.testing.assert_array_equal(readback[:, :, :3], rgb) - np.testing.assert_array_equal(readback[:, :, 3], ir) - with tifffile.TiffFile(path) as tf: - assert int(tf.pages[0].tags["SamplesPerPixel"].value) == 4 - - def test_adds_dng_extension(self) -> None: - rgb = np.random.randint(0, 65535, (50, 50, 3), dtype=np.uint16) - result = ScanResult(rgb=rgb, ir=None, dpi=300, device_model="T") - - 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.""" @@ -159,25 +110,37 @@ def watching_imwrite(file, data, **kwargs): 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) +class TestMonoTiff: + def test_writes_one_grey_plane(self) -> None: + rgb = np.random.randint(0, 65535, (40, 60, 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: + path = write_tiff_16bit(result, os.path.join(tmpdir, "mono"), mono=True) - 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) + readback = tifffile.imread(path) + assert readback.shape == (40, 60) + assert readback.dtype == np.uint16 + with tifffile.TiffFile(path) as tf: + assert int(tf.pages[0].tags["PhotometricInterpretation"].value) == 1 # minisblack - monkeypatch.setattr(os, "replace", watching_replace) - path = write_dng_linear(result, os.path.join(tmpdir, "scan_002")) + def test_the_grey_plane_is_the_mean_of_the_three(self) -> None: + rgb = np.array([[[0, 0, 0], [10, 11, 12], [65535, 65535, 65535], [1, 2, 2]]], dtype=np.uint16) + result = ScanResult(rgb=rgb, ir=None, dpi=3600, device_model="TestScanner") - assert probes == 1 - assert FolderWatchService.scan_for_new_files(tmpdir, set()) == [os.path.abspath(path)] + with tempfile.TemporaryDirectory() as tmpdir: + path = write_tiff_16bit(result, os.path.join(tmpdir, "mean"), mono=True) + readback = tifffile.imread(path) + + # Rounded, not floored: 5/3 reads 2, and the top of the range does not wrap. + assert readback.tolist() == [[0, 11, 65535, 2]] + + def test_the_ir_sidecar_still_comes_out_beside_it(self) -> None: + rgb = np.random.randint(0, 65535, (20, 30, 3), dtype=np.uint16) + ir = np.random.randint(0, 65535, (20, 30), dtype=np.uint16) + result = ScanResult(rgb=rgb, ir=ir, dpi=3600, device_model="TestScanner") + + with tempfile.TemporaryDirectory() as tmpdir: + path = write_tiff_16bit(result, os.path.join(tmpdir, "mono_ir"), mono=True) + assert os.path.exists(path.replace(".tif", "_IR.tif")) diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index d0fb08df1..3964a876b 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -483,6 +483,17 @@ def test_scan_carries_offset_and_drift_into_the_batch_request() -> None: assert req.frame_offset_modifier_mm == 0.2 +def test_scan_carries_the_per_frame_corrections_into_the_batch_request() -> None: + sidebar, controller = _sidebar(LS50_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-scan-out") + sidebar.settings = replace(sidebar._settings, frame_offsets={2: -0.4}) + + sidebar._on_scan() + + _kind, req = controller.started[0] + assert req.frame_offsets == {2: -0.4} + + def test_eject_button_calls_controller() -> None: sidebar, controller = _sidebar(FULL_DEVICE) sidebar._on_eject() @@ -948,6 +959,15 @@ def test_ejecting_drops_the_frame_selection_of_the_film_that_left() -> None: assert "frame selection cleared" in sidebar.status_strip.message() +def test_ejecting_drops_the_per_frame_corrections_of_the_film_that_left() -> None: + # A per-frame correction registers one strip's own boundaries; the next strip has its own. + sidebar, _ = _sidebar(FULL_DEVICE, settings={"frame_offsets": {"2": 0.4}}) + + sidebar._on_ejected(True) + + assert sidebar.settings.frame_offsets == {} + + def test_ejecting_keeps_the_registration_offsets() -> None: # Offset and drift belong to the transport's own registration, not to one strip. sidebar, _ = _sidebar(FULL_DEVICE, settings={"selected_frames": [1], "frame_offset_mm": 1.5, "frame_offset_modifier_mm": 0.2}) @@ -998,3 +1018,25 @@ def test_a_film_that_blocks_infrared_leaves_the_control_visible_to_explain_itsel assert sidebar.ir_check.isVisibleTo(sidebar) is True assert sidebar.ir_check.isEnabled() is False + + +def test_the_format_combo_offers_the_mono_tiff() -> None: + sidebar, _ = _sidebar(FULL_DEVICE) + offered = [sidebar.fmt_combo.itemText(i) for i in range(sidebar.fmt_combo.count())] + assert offered == ["TIFF", "TIFF (mono)"] + + +def test_the_chosen_format_reaches_the_batch_request() -> None: + sidebar, controller = _sidebar(LS50_DEVICE) + sidebar.folder_edit.setText("/tmp/negpy-scan-out") + sidebar.fmt_combo.setCurrentText("TIFF (mono)") + + sidebar._on_scan() + + _kind, req = controller.started[0] + assert req.output_format == "TIFF (mono)" + + +def test_the_strip_tile_height_survives_the_dialog() -> None: + sidebar, _ = _sidebar(FULL_DEVICE, settings={"strip_tile_height": 260}) + assert sidebar.settings.strip_tile_height == 260 diff --git a/tests/test_scan_window_label.py b/tests/test_scan_window_label.py index 4b5fb0a4c..a857a6e22 100644 --- a/tests/test_scan_window_label.py +++ b/tests/test_scan_window_label.py @@ -110,3 +110,48 @@ def test_crop_rect_round_trips_through_widget_pixels_under_an_offset() -> None: back = label._rect_in_widget((fx, fy, fx, fy), draw) assert abs(back.left() - point.x()) <= 1 + + +def _render(label: ScanWindowLabel): + from PyQt6.QtGui import QImage, QPainter + + shot = QImage(label.width(), label.height(), QImage.Format.Format_RGB32) + shot.fill(0) + painter = QPainter(shot) + label.render(painter) + painter.end() + return shot + + +def test_the_frame_boundary_is_outlined_once_there_is_a_frame() -> None: + """The offset is measured from the frame edge, so that edge has to be visible.""" + from PyQt6.QtGui import QColor + + from negpy.desktop.view.styles.theme import THEME + + label = ScanWindowLabel() + label.setFixedSize(120, 80) + black = QPixmap(120, 80) + black.fill(QColor("#000000")) + label.set_frame(black) + + shot = _render(label) + rect = label._display() + assert rect is not None + + on_edge = shot.pixelColor(rect.left(), rect.top() + rect.height() // 2) + inside = shot.pixelColor(rect.left() + rect.width() // 2, rect.top() + rect.height() // 2) + # The house red, undiluted, with the picture itself left alone. + assert on_edge.name() == QColor(THEME.accent_primary).name() + assert inside.red() == 0 + + +def test_an_empty_tile_is_not_outlined() -> None: + """Nothing was detected there, so there is no boundary to mark.""" + label = ScanWindowLabel() + label.setFixedSize(120, 80) + + shot = _render(label) + + assert label._display() is None + assert shot.pixelColor(0, 40).red() == shot.pixelColor(60, 40).red() diff --git a/tests/test_scan_worker.py b/tests/test_scan_worker.py index 3c0d7ca96..cb91a3952 100644 --- a/tests/test_scan_worker.py +++ b/tests/test_scan_worker.py @@ -79,8 +79,8 @@ def eject(self, device_id: str) -> bool: self.eject_calls.append(device_id) return True - def detect_frames(self, device_id: str, *, film_format: str | None = None, film_type: str = "negative") -> int: - self.detect_calls.append((film_format, film_type)) + def detect_frames(self, device_id: str, *, film_format: str | None = None) -> int: + self.detect_calls.append(film_format) return self.detected def run_scan(self, device_id, params, progress, cancel): @@ -152,6 +152,27 @@ def test_batch_applies_progressive_offset_per_frame_position() -> None: assert service.offsets == pytest.approx([1.2, 1.4, 1.6]) +def test_batch_adds_a_per_frame_correction_on_top_of_the_ramp() -> None: + worker = ScanWorker() + service = _BatchService() + worker._service = service # type: ignore[assignment] + req = BatchRequest( + device_id="coolscan3:test", + params=ScanParams(dpi=4_000, depth=16, capture_ir=False, frame_offset_mm=1.0), + output_folder="/tmp", + filename_pattern='scan-{{ "%03d" % seq }}', + output_format="TIFF", + frames=(2, 3, 4), + frame_offset_modifier_mm=0.2, + frame_offsets={3: -0.5}, + ) + + worker.run_batch(req) + + # Only frame 3 moves; a frame with no entry keeps base + drift. + assert service.offsets == pytest.approx([1.2, 0.9, 1.6]) + + def test_batch_passes_a_negative_drift_through_to_the_backend() -> None: worker = ScanWorker() service = _BatchService() @@ -565,7 +586,7 @@ def test_a_batch_with_no_frames_scans_every_frame_on_the_film() -> None: worker.run_batch(_batch_request(frames=(), film_format="66")) assert service.frames == [1, 2, 3] - assert service.detect_calls == [("66", "negative")] + assert service.detect_calls == ["66"] assert len(done[0]) == 3 @@ -605,3 +626,27 @@ def test_batch_progress_names_the_frame_and_its_position() -> None: "Frame 3 of 3 — Scanning", ] assert [fraction for fraction, _phase in seen] == pytest.approx([1 / 3, 2 / 3, 1.0]) + + +def test_the_batch_logs_the_offset_each_frame_was_scanned_at(caplog) -> None: + """Whether a per-frame correction reached the scan must be answerable from the log.""" + import logging + + worker = ScanWorker() + service = _BatchService() + worker._service = service # type: ignore[assignment] + req = BatchRequest( + device_id="coolscan3:test", + params=ScanParams(dpi=4_000, depth=16, capture_ir=False, frame_offset_mm=1.0), + output_folder="/tmp", + filename_pattern='scan-{{ "%03d" % seq }}', + output_format="TIFF", + frames=(1, 2), + frame_offsets={2: -0.5}, + ) + + with caplog.at_level(logging.INFO): + worker.run_batch(req) + + assert "Batch frame 1 at +1.00 mm" in caplog.text + assert "Batch frame 2 at +0.50 mm" in caplog.text diff --git a/tests/test_strip_preview_dialog.py b/tests/test_strip_preview_dialog.py index 5e4240efa..f54cbe876 100644 --- a/tests/test_strip_preview_dialog.py +++ b/tests/test_strip_preview_dialog.py @@ -21,6 +21,7 @@ from negpy.desktop.view.widgets.scan_preview_common import preview_positive from negpy.desktop.view.widgets.strip_preview_dialog import ( + _TILE_H_MAX, StripPreviewDialog, _display_to_scan_rect, _scan_to_display_rect, @@ -46,6 +47,21 @@ def _device(capacity: int) -> ScannerDevice: return ScannerDevice(id="coolscan3:usb:libusb:001:050", vendor="Nikon", model="LS-50", capabilities=caps) +def _discovery_device() -> ScannerDevice: + """A transport that measures the strip: no capacity, tiles grow from the previews.""" + caps = ScannerCapabilities( + ir_channel=False, + supported_dpi=(4000,), + supported_depths=(16,), + sources=(ScanMode.NEGATIVE,), + max_area_mm=(25.0, 38.0), + adapter_frame_capacity=None, + roll_discovery=True, + can_eject=True, + ) + return ScannerDevice(id="nkscan:usb:001", vendor="Nikon", model="LS-50", capabilities=caps) + + class _FakeController(QObject): scan_roll_preview_ready = pyqtSignal(object) scan_roll_preview_finished = pyqtSignal() @@ -712,23 +728,25 @@ def test_a_feeder_raster_turns_landscape() -> None: assert (pixmap.width(), pixmap.height()) == (24, 8) -def test_a_measured_strip_crop_needs_no_axis_swap() -> None: +def test_a_measured_strip_crop_maps_display_x_to_the_feed_and_display_top_to_the_sensor_end() -> None: + # The tile is not rotated, but the backend window has y along the feed and x along the + # sensor, high addresses at the top: the same transform a feeder tile needs. controller = _FakeController() dialog = StripPreviewDialog(controller, _discovery_device()) dialog._on_preview_all() controller.deliver_all((1,)) - dialog._tiles[1].label.set_window((0.1, 0.2, 0.5, 0.6)) + dialog._tiles[1].label.set_window((0.0, 0.0, 0.5, 0.3)) # top-left: first half of the feed - assert dialog.frame_windows()[1] == (0.1, 0.2, 0.5, 0.6) + assert dialog.frame_windows()[1] == pytest.approx((0.7, 0.0, 1.0, 0.5)) # ── picking the frames to scan ──────────────────────────────────────────── -def test_an_undetected_strip_says_what_to_press() -> None: +def test_an_unmeasured_strip_says_it_is_working_rather_than_what_to_press() -> None: dialog = StripPreviewDialog(_FakeController(), _discovery_device()) assert dialog._empty_hint.isVisibleTo(dialog) is True - assert "Detect frames" in dialog._empty_hint.text() + assert "Finding the frames" in dialog._empty_hint.text() assert dialog.selection_label.text() == "none yet" @@ -878,14 +896,412 @@ def test_a_feeder_still_counts_the_frames_it_previews() -> None: def test_the_offset_is_a_boundary_correction_not_a_way_to_the_next_frame() -> None: + # 10 mm reaches a badly placed boundary and still stops far short of the ~36 mm pitch. measured = StripPreviewDialog(_FakeController(), _discovery_device()) - assert (measured.offset_slider.minimum(), measured.offset_slider.maximum()) == (-25, 25) + assert (measured.offset_slider.minimum(), measured.offset_slider.maximum()) == (-100, 100) - measured.offset_slider.setValue(400) - assert measured.frame_offset() == 2.5 + measured.offset_slider.setValue(4000) + assert measured.frame_offset() == 10.0 + assert measured.frame_offset() < measured._frame_pitch() def test_a_saved_negative_offset_comes_back_as_it_was() -> None: dialog = StripPreviewDialog(_FakeController(), _discovery_device(), initial_offset=-1.5) assert dialog.frame_offset() == -1.5 assert dialog.offset_label.text() == "-1.5 mm" + + +# ── the per-frame offset slider ─────────────────────────────────────────── + + +def test_a_tile_slider_corrects_its_own_frame_only() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3), initial_offset=2.0) + + dialog._tiles[2].offset_slider.setValue(-5) # tenths of a mm + + assert dialog._raw_offset_for_frame(1) == pytest.approx(2.0) + assert dialog._raw_offset_for_frame(2) == pytest.approx(1.5) + assert dialog._raw_offset_for_frame(3) == pytest.approx(2.0) + + +def test_a_tile_slider_moves_that_tiles_band_and_no_other() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3), initial_offset=4.0) + + dialog._tiles[2].offset_slider.setValue(19) # +1.9 mm + + ((first, _),) = dialog._tiles[1].label._offset_indicators + ((second, _),) = dialog._tiles[2].label._offset_indicators + assert first == pytest.approx(4.0 / 38.0, abs=1e-3) + assert second == pytest.approx(5.9 / 38.0, abs=1e-3) + + +def test_frame_offsets_reports_the_corrected_frames_only() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3)) + + dialog._tiles[3].offset_slider.setValue(7) + + assert dialog.frame_offsets() == {3: 0.7} + + +def test_a_saved_correction_comes_back_on_its_tile() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3), initial_frame_offsets={2: -0.8}) + + assert dialog._tiles[2].offset_slider.value() == -8 + assert dialog.frame_offsets() == {2: -0.8} + + +def test_double_clicking_a_tile_slider_clears_its_correction() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3), initial_frame_offsets={1: 1.2}) + + dialog._tiles[1].offset_slider.mouseDoubleClickEvent(None) + + assert dialog.frame_offsets() == {} + + +def test_the_preview_request_carries_the_per_frame_correction() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(3), initial_offset=1.0, initial_frame_offsets={2: 0.9}) + + dialog._on_preview_all() + + assert controller.preview_reqs[0].offsets[2] == pytest.approx(1.9 / 38.0, abs=1e-4) + + +# ── the tile size slider ────────────────────────────────────────────────── + + +def test_tiles_start_at_the_saved_size() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3), initial_tile_height=220) + + assert dialog.size_slider.value() == 220 + assert dialog._tiles[1].label.height() == 220 + assert dialog.tile_height() == 220 + + +def test_a_saved_size_outside_the_slider_is_clamped() -> None: + assert StripPreviewDialog(_FakeController(), _device(2), initial_tile_height=10_000).tile_height() == 340 + assert StripPreviewDialog(_FakeController(), _device(2), initial_tile_height=1).tile_height() == 90 + + +def test_moving_the_slider_resizes_every_tile_and_its_offset_slider() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3)) + + dialog.size_slider.setValue(300) + + for tile in dialog._tiles.values(): + assert tile.label.height() == 300 + assert tile.label.width() == tile.offset_slider.width() + + +def _cell(dialog, frame: int) -> tuple[int, int]: + grid = dialog._strip + return grid.getItemPosition(grid.indexOf(dialog._tiles[frame].widget))[:2] + + +def test_the_grid_reflows_to_the_columns_that_fit() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(12)) + tile_w = dialog._tile_size()[0] + + dialog.resize(6 * (tile_w + 4) + 36, 600) + dialog._relayout(force=True) + assert dialog._cols == 6 + assert _cell(dialog, 7) == (1, 0) + + dialog.resize(2 * (tile_w + 4) + 36, 600) + dialog._relayout(force=True) + assert dialog._cols == 2 + assert _cell(dialog, 7) == (3, 0) + + +def test_a_strip_area_narrower_than_one_tile_still_shows_a_column() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(3)) + + dialog.resize(60, 600) + dialog._relayout(force=True) + + assert dialog._cols == 1 + + +def test_bigger_tiles_reflow_into_fewer_columns_at_a_fixed_width() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(12)) + dialog.resize(6 * (dialog._tile_size()[0] + 4) + 36, 600) + dialog._relayout(force=True) + assert dialog._cols == 6 + + dialog.size_slider.setValue(_TILE_H_MAX) + + assert dialog._cols < 6 + + +def test_resizing_the_tiles_does_not_rescan() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(3)) + + dialog.size_slider.setValue(320) + + assert controller.preview_reqs == [] + + +def test_reflowing_does_not_accumulate_layout_items() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(6)) + before = dialog._strip.count() + + for width in (400, 900, 400, 1200): + dialog.resize(width, 600) + dialog._relayout(force=True) + + assert dialog._strip.count() == before + + +# ── the per-frame offset, end to end ────────────────────────────────────── + + +def test_the_preview_and_the_scan_land_on_the_same_millimetres() -> None: + """Preview (fractions of a pitch) and scan (mm) compose base, drift and correction alike.""" + + from negpy.desktop.workers.scan_worker import BatchRequest, ScanWorker + from negpy.infrastructure.scanners.params import ScanParams + + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(5), initial_offset=1.0, initial_offset_modifier=0.2) + dialog._tiles[3].offset_slider.setValue(-7) # -0.7 mm on frame 3 alone + + dialog._on_preview_all() + pitch = 38.0 # _device's max_area_mm[1] + previewed = [controller.preview_reqs[0].offsets[f] * pitch for f in (1, 2, 3, 4, 5)] + + scanned: list[float] = [] + + class _Service: + def run_scan(self, device_id, params, progress, cancel): + scanned.append(params.frame_offset_mm) + return object() + + def write_result(self, **_kwargs): + return "/tmp/frame.tif" + + def eject(self, *_args, **_kwargs): + return True + + worker = ScanWorker() + worker._service = _Service() # type: ignore[assignment] + worker.run_batch( + BatchRequest( + device_id="coolscan3:test", + params=ScanParams(dpi=4000, depth=16, capture_ir=False, frame_offset_mm=dialog.frame_offset()), + output_folder="/tmp", + filename_pattern='{{ date }}_{{ "%03d" % seq }}', + output_format="TIFF", + frames=(1, 2, 3, 4, 5), + frame_offset_modifier_mm=dialog.frame_offset_modifier(), + frame_offsets=dialog.frame_offsets(), + ) + ) + + assert scanned == pytest.approx([1.0, 1.2, 0.7, 1.6, 1.8]) + assert scanned == pytest.approx(previewed) + + +def test_a_feeder_says_which_control_it_clamped() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(6), initial_offset=0.5) + + dialog._tiles[2].offset_slider.setValue(-20) # drives frame 2 below the transport's floor + dialog._refresh_offset_indicators() + + assert dialog._offset_for_frame(2) == 0.0 # the feeder cannot back up + assert "frame 2" in dialog.status_strip.message() + assert "own slider" in dialog.status_strip.message() + + +def test_accepting_without_detecting_keeps_the_saved_corrections() -> None: + """A measured strip opens with no tiles, and must not erase what was saved for them.""" + dialog = StripPreviewDialog(_FakeController(), _discovery_device(), initial_frame_offsets={3: -0.7}) + + assert dialog._tiles == {} + assert dialog.frame_offsets() == {3: -0.7} + + +def test_a_tile_the_operator_reset_drops_its_saved_correction() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(4), initial_frame_offsets={2: 0.5, 3: -0.4}) + + dialog._tiles[2].offset_slider.setValue(0) # deliberately cleared, with the tile in view + + assert dialog.frame_offsets() == {3: -0.4} + + +def test_a_detected_strip_overrides_the_saved_correction() -> None: + dialog = StripPreviewDialog(_FakeController(), _device(4), initial_frame_offsets={2: 0.5}) + + dialog._tiles[2].offset_slider.setValue(-9) + + assert dialog.frame_offsets() == {2: -0.9} + + +# ── finding the frames as the dialog opens ──────────────────────────────── + + +def test_a_measured_strip_finds_its_frames_as_it_opens() -> None: + """Every per-frame control lives on a tile, so an empty dialog offers nothing to adjust.""" + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + assert controller.preview_reqs == [] # nothing before it is on screen + + dialog.show() + + assert len(controller.preview_reqs) == 1 + + +def test_it_only_finds_them_once_however_often_the_dialog_is_shown() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _discovery_device()) + + dialog.show() + dialog.hide() + dialog.show() + + assert len(controller.preview_reqs) == 1 + + +def test_a_feeder_is_left_alone_because_previewing_it_costs_a_pass_per_frame() -> None: + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(6)) + + dialog.show() + + assert controller.preview_reqs == [] + + +def test_the_offset_sliders_reach_a_boundary_several_millimetres_out() -> None: + """A measured boundary can sit several millimetres off the picture.""" + dialog = StripPreviewDialog(_FakeController(), _discovery_device()) + ctl = _FakeController() + grown = StripPreviewDialog(ctl, _device(3)) + + assert dialog.offset_slider.minimum() == -100 and dialog.offset_slider.maximum() == 100 + # A feeder cannot back up, and its forward reach is unchanged. + assert grown.offset_slider.minimum() == 0 and grown.offset_slider.maximum() == 100 + tile = grown._tiles[1].offset_slider + tile.setValue(85) + assert grown.frame_offsets() == {1: 8.5} + + +# ── re-cutting a nudged frame out of the strip pass ──────────────────────── + + +def _measured(controller, slots=(1, 2, 3), **kwargs): + """A dialog over a measured strip with its tiles already cut at offset 0.""" + dialog = StripPreviewDialog(controller, _discovery_device(), **kwargs) + controller.deliver_all(slots) + controller.preview_reqs.clear() + return dialog + + +def _dispose(dialog) -> None: + """Close and destroy a dialog: a timer left armed on a collected one crashes teardown.""" + dialog.reject() + dialog.deleteLater() + QApplication.processEvents() + + +def test_a_moved_frame_offset_re_cuts_that_frame_alone() -> None: + controller = _FakeController() + dialog = _measured(controller) + + dialog._tiles[2].offset_slider.setValue(15) + dialog._recut_moved_tiles() + _dispose(dialog) + + assert [r.slots for r in controller.preview_reqs] == [(2,)] + assert controller.preview_reqs[0].offsets[2] == pytest.approx(1.5 / 36.0, abs=1e-4) + + +def test_the_global_offset_re_cuts_every_tile() -> None: + controller = _FakeController() + dialog = _measured(controller) + + dialog.offset_slider.setValue(20) + dialog._recut_moved_tiles() + _dispose(dialog) + + assert [r.slots for r in controller.preview_reqs] == [(1, 2, 3)] + + +def test_an_offset_the_tiles_were_already_cut_at_re_cuts_nothing() -> None: + controller = _FakeController() + dialog = _measured(controller) + + dialog._recut_moved_tiles() + _dispose(dialog) + + assert controller.preview_reqs == [] + + +def test_moving_an_offset_arms_the_re_cut() -> None: + controller = _FakeController() + dialog = _measured(controller) + + dialog._tiles[1].offset_slider.setValue(4) + armed = dialog._recut.isActive() + _dispose(dialog) + + assert armed + assert not dialog._recut.isActive() # closing takes the pending re-cut with it + + +def test_a_feeder_never_re_cuts_its_tiles() -> None: + """Only a measured strip has the pass in memory; a feeder would have to scan again.""" + controller = _FakeController() + dialog = StripPreviewDialog(controller, _device(3)) + controller.deliver_all((1, 2, 3)) + controller.preview_reqs.clear() + + dialog._tiles[1].offset_slider.setValue(6) + armed = dialog._recut.isActive() + _dispose(dialog) + + assert not armed + + +def test_a_re_cut_does_not_report_the_strip_as_freshly_detected() -> None: + controller = _FakeController() + dialog = _measured(controller) + + dialog._tiles[3].offset_slider.setValue(-9) + dialog._recut_moved_tiles() + assert dialog.status_strip.message().startswith("Re-cutting") + controller.deliver_all((3,)) + _dispose(dialog) + + assert dialog.status_strip.message() == "" + + +def test_a_stopped_detection_does_not_erase_the_saved_framing() -> None: + """Accepting before any tile exists must keep what a previous pass set, not wipe it.""" + controller = _FakeController() + dialog = StripPreviewDialog( + controller, + _discovery_device(), + initial_windows={2: (0.1, 0.1, 0.9, 0.9)}, + initial_selected=(2, 3), + initial_frame_offsets={2: 0.4}, + ) + assert dialog._tiles == {} + + windows, selected, offsets = dialog.frame_windows(), dialog.selected_frames(), dialog.frame_offsets() + _dispose(dialog) + + assert windows == {2: (0.1, 0.1, 0.9, 0.9)} + assert selected == (2, 3) + assert offsets == {2: 0.4} + + +def test_clearing_a_crop_on_a_tile_drops_the_saved_one() -> None: + controller = _FakeController() + dialog = _measured(controller, initial_windows={2: (0.1, 0.1, 0.9, 0.9)}) + assert dialog.frame_windows()[2] == pytest.approx((0.1, 0.1, 0.9, 0.9)) + + dialog._tiles[2].label.clear_window() + windows = dialog.frame_windows() + _dispose(dialog) + + assert windows == {} diff --git a/tests/test_tiff_loader_encoding.py b/tests/test_tiff_loader_encoding.py index fda5c1ea1..b72311d1d 100644 --- a/tests/test_tiff_loader_encoding.py +++ b/tests/test_tiff_loader_encoding.py @@ -1,4 +1,4 @@ -"""Scanner TIFFs must decode identically to their LinearRaw DNG twins.""" +"""How a scanner TIFF is encoded, and what the loader makes of it.""" import base64 import os @@ -12,11 +12,9 @@ from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper from negpy.infrastructure.loaders.tiff_loader import TiffLoader -from negpy.infrastructure.scanners.result import ScanResult from negpy.kernel.image.logic import srgb_to_linear, working_oetf_decode from negpy.features.process.logic import effective_linear_raw from negpy.features.process.models import ProcessConfig, ProcessMode -from negpy.services.scanning.writer import write_dng_linear, write_tiff_16bit # The standard Adobe RGB (1998) ICC profile — real-world scanner/export software tags # TIFFs with exactly this, so the loader's Adobe RGB branch is tested against what @@ -182,34 +180,6 @@ def test_without_positive_source_the_tag_is_still_ignored(self) -> None: assert metadata["color_space"] is None -class TestScanRoundTripParity: - def test_tiff_and_dng_decode_identically(self) -> None: - from negpy.services.rendering.image_processor import ImageProcessor - - result = ScanResult(rgb=_rgb16(), ir=None, dpi=3600, device_model="TestScanner") - proc = ImageProcessor() - with tempfile.TemporaryDirectory() as tmpdir: - tif_path = write_tiff_16bit(result, os.path.join(tmpdir, "pair")) - dng_path = write_dng_linear(result, os.path.join(tmpdir, "pair")) - tif_rgb, _ = proc._decode_sensor_rgb(tif_path, linear_raw=True) - dng_rgb, _ = proc._decode_sensor_rgb(dng_path, linear_raw=True) - np.testing.assert_array_equal(tif_rgb, dng_rgb) - - def test_tiff_and_dng_agree_on_same_as_source_target(self) -> None: - """The twins must also export alike, not just decode alike.""" - from negpy.services.rendering.image_processor import ImageProcessor - - result = ScanResult(rgb=_rgb16(), ir=None, dpi=3600, device_model="TestScanner") - proc = ImageProcessor() - with tempfile.TemporaryDirectory() as tmpdir: - tif_path = write_tiff_16bit(result, os.path.join(tmpdir, "pair")) - dng_path = write_dng_linear(result, os.path.join(tmpdir, "pair")) - _, tif_meta = proc._decode_sensor_rgb(tif_path, linear_raw=True) - _, dng_meta = proc._decode_sensor_rgb(dng_path, linear_raw=True) - assert tif_meta.get("color_space") is None - assert dng_meta.get("color_space") is None - - class TestUncharacterisedSourceResolvesToWorkingSpace: """A source with no embedded profile is already in the working space: "Same as Source" must export it without a needless conversion into a narrower gamut.""" diff --git a/uv.lock b/uv.lock index 91b19f00f..dd3b273ed 100644 --- a/uv.lock +++ b/uv.lock @@ -382,7 +382,7 @@ requires-dist = [ { name = "imagecodecs", specifier = "==2026.6.6" }, { name = "imageio", specifier = "==2.37.3" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.9" }, + { name = "nkscan", marker = "extra == 'nkscan'", specifier = ">=0.11" }, { name = "numba", specifier = "==0.65.1" }, { name = "numpy", specifier = "==2.4.4" }, { name = "opencv-python-headless", specifier = "==4.13.0.92" }, @@ -409,21 +409,21 @@ dev = [ { name = "ruff", specifier = "==0.14.10" }, { name = "ty", specifier = ">=0.0.26" }, ] -nkscan = [{ name = "nkscan", specifier = ">=0.9" }] +nkscan = [{ name = "nkscan", specifier = ">=0.11" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] plustek = [{ name = "pyopticfilm", specifier = ">=1.3.3" }] sane = [{ name = "python-sane", specifier = ">=2.9" }] [[package]] name = "nkscan" -version = "0.9.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/4d/8b02d8e3a7ce1604f9d3b1ae94e31fac5850b7daaa484a81b2f8f78ab6e9/nkscan-0.9.0.tar.gz", hash = "sha256:b38d89afc0d6bfe3473294db587f58fa9fe8a9c6a9017561105a633d8cd355a8", size = 2804597, upload-time = "2026-08-24T01:28:37.411Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/f1/fc1201744ac24ab5f0fbd7f79ff4d1d8a112b6b3e65d0970b906e8e1e03b/nkscan-0.11.0.tar.gz", hash = "sha256:2bf020eb7d4e74723a4f0a89c82f887a29a3514832fad0abfa088d66ec09f274", size = 2809850, upload-time = "2026-09-13T19:57:32.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/c3/29574ec5e7dea0aac7f9fa65e4573711f0f0099bd8843146973fb09d151b/nkscan-0.9.0-cp313-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0cf978009af4f0ca2c59aa97926ba8e9d7291bf57fdef0247c9224c5587520d", size = 743459, upload-time = "2026-08-26T00:41:14.069Z" }, - { url = "https://files.pythonhosted.org/packages/b6/50/483d48da107ac514b8fad9ca5caff4f1d20dc048f2a4e4822259d49c5ae2/nkscan-0.9.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f7be405c2b601cdf942f4e87e8b16eb2a6702ae5c10dc145947c4e4cdaf7a49", size = 735425, upload-time = "2026-08-24T01:28:32.919Z" }, - { url = "https://files.pythonhosted.org/packages/43/b7/92008b81db30187f1461d6fbdb8c313713001a40cdad82bbaba0bdbe19e7/nkscan-0.9.0-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4c0550b73e18393461f9073d4ab75ecd9ff8ae2042ebb5c5028271eeeb1f10a", size = 836903, upload-time = "2026-08-24T01:28:34.506Z" }, - { url = "https://files.pythonhosted.org/packages/1c/94/f53841c7168ebea01f16ab758714f70da348ef56b2e0204024babd56d358/nkscan-0.9.0-cp313-abi3-win_amd64.whl", hash = "sha256:dff231a33cbd3164c9d116b5bbf6ca9a7c070f8d386330833f89266a42ed7f08", size = 595336, upload-time = "2026-08-24T01:28:35.973Z" }, + { url = "https://files.pythonhosted.org/packages/21/0a/b48d696b32f2b628e1ad224b62d512a457538375e16a726aebc3710d1f88/nkscan-0.11.0-cp313-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0089cd04b9cf0c9f4112d2c7f29a2eacaa3ce1b81da44a1bd3e8d343e8fd4758", size = 1129273, upload-time = "2026-09-13T19:57:25.596Z" }, + { url = "https://files.pythonhosted.org/packages/df/d7/f273b36dfba1bea62f6f41f107d6e804e510b117ad3018f3dc6aa8b33dfb/nkscan-0.11.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:0082693f94a4c76b222a8dcac485e00bc4d69fa4e744f3fcb5c4924228a43b80", size = 1106963, upload-time = "2026-09-13T19:57:27.245Z" }, + { url = "https://files.pythonhosted.org/packages/83/68/56b54bd270d41fcab843ed7fc9fc6955a8a0a37109853835065b9fe763b0/nkscan-0.11.0-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b3270da6585ac0251127fb29c423576822f100116b4ae8f95e744ec9c2f0531", size = 1256726, upload-time = "2026-09-13T19:57:28.698Z" }, + { url = "https://files.pythonhosted.org/packages/e2/50/80503f55c53e4fa917c7e5c45f6ba3b0097b35a8d9971b10ec4041012bc6/nkscan-0.11.0-cp313-abi3-win_amd64.whl", hash = "sha256:6c83f7558cccc5dfaf0cbc0b6774385a4cc2bd08898aeaed0eb5550b4bb3986c", size = 966245, upload-time = "2026-09-13T19:57:30.502Z" }, ] [[package]]