Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -962,15 +962,15 @@ Capture film directly into NegPy. Two collapsible sections.
<!-- panel:scan_sane -->
### 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.

With **IR** checked, color and infrared come back in one scan pass; pyopticfilm aligns the IR plane to the color frame. Color scans apply ASIC shading measured at home before the film feed, the same order as SilverFast, so the strip may stay loaded. The table is cached per DPI, so later scans only re-upload it.

The default Full window includes a little holder chrome top and bottom; host-path scans clamp those near-white margins to the film highlight so auto exposure is not skewed. Raise **Analysis Buffer** or crop if a frame still looks off. Autofocus and hardware Auto-exposure controls stay hidden, because the SE does not report those capabilities. On Windows, bind the device to **WinUSB** with Zadig before use, since the stock vendor or SilverFast driver conflicts. The driver is the optional **pyopticfilm** package: install it with `uv sync --group plustek` or `pip install negpy[plustek]`; Windows release builds bundle it. See [PLUSTEK_WINDOWS.md](PLUSTEK_WINDOWS.md).

**Nikon Coolscan (nkscan)** notes: the driver talks to the scanner directly, so it needs no SANE backend. It measures the loaded film instead of counting frames: **Preview strip…** reads the whole strip in one pass, finds every frame on it, and cuts every tile out of that same pass. The tiles appear as the frames turn up, and there is no preview resolution to choose. Check the framing before scanning; a measured boundary can be nudged with **Offset** (±2.5 mm, either way, since the frame is re-addressed rather than fed past) and **Drift**, and because the tile comes out of the strip pass, a nudge re-frames without going back to the scanner. **Scan** with nothing picked scans every frame on the strip, measuring it first if no preview has. To scan a subset, type it in **Frames**, or untick frames in **Preview strip…**. Each tile carries its own tick, **All** and **None** move the lot, and the count next to them says how many will be scanned. Either way the selection shows in **Frames**, and ejecting the film clears it, 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.
Expand All @@ -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.

Expand All @@ -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.

---

Expand Down
15 changes: 10 additions & 5 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion negpy/desktop/view/widgets/scan_window_label.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Loading
Loading