Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ This is not a pipeline stage. Every repair is baked into the **linear source bef

B&W silver and Kodachrome block IR like dust does. Such frames are auto-detected, because the IR plane mirrors the image, and skipped.

* **ICE at scan time** (Scan tab, nkscan backend only):
The nkscan driver carries its own openICE port and can run it during the scan, on the scanner's own data. What it writes is already repaired, so no NegPy retouch stage sees the defects: the repair is baked into the file, not into an edit. Use it for speed on a batch; use the paths above where the repair has to stay editable.

* **IR removal, the OpenICE method** (`ir_method = "openice"`, `negpy/features/retouch/openice.py`):
A second reconstruction for the same IR plane, ported from openICE (see `NOTICE.md`), a reverse-engineering of Nikon Scan's Digital ICE verified byte-exact against the original engine. It replaces steps 1–4 above wholesale, shares no code with them, and keeps the routed inpaint. It sits at the same point in the pipeline: the linear source before normalization.

Expand Down
29 changes: 27 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -874,23 +874,48 @@ When you set capture gear, it is written to standard EXIF, and the digitizing ri

---

<!-- panel:scan_sane -->
## 13. Scan tab

Capture film directly into NegPy. Two collapsible sections:

* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Common controls are device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.
* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them — **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and **Output** (format, folder, filename template) — and a group's header disappears with the whole group when the device has nothing in it. **Frames** takes the frames to scan as a list — `1-6`, `1,2,5`, or empty for every frame on the film — and the strip preview writes its picks there, so a selection can be changed without previewing again. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.

**pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters.
**pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then leave with **Apply crop** or **Scan frame** — either way the next scan reads that hardware ROI at the chosen DPI, not a software crop. **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters.

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

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

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

* **ICE**: remove dust and scratches with the infrared channel while scanning. Permanent — it is baked into the file, unlike the Retouch panel's IR Restore, which stays editable. Color film only: silver grain blocks infrared, so the mask on a black-and-white negative is the picture again. **ICE** and **IR** exclude each other, because they read the same pass: ticking one unticks the other. Tick **IR** to keep the plane and clean the file later in Retouch, **ICE** to have the scanner do it now.
* **Samples**: reads per line the scanner averages (1–16). Higher settings cut shadow noise and cost proportionally more time.
* **Superfine**: read one line per pass. Slower, and free of the line registration the faster three-line mode owes the host.
* **Film**: what is on the film — Color negative, B&W negative, Slide or Kodachrome. It decides three things: which way the frame boundaries read when the strip is measured, whether IR and ICE are offered at all (B&W and Kodachrome stop infrared with silver and dyes, so the mask comes back as the picture rather than the dust on it), and how the frame is metered. A color negative is metered one channel at a time, which takes the orange mask off before the converter instead of quantizing the blue record through it; every other film keeps its factory balance, because there the cast is the picture.
* **Film format**: the frame length on the loaded film (135, 66, 645 and so on). Leave it on **Auto** where the holder narrows it, and set it for loose film in a masked carrier. It appears only where the transport measures the film to find its frames — a holder with its own frame table fixes the format, so there is nothing to choose.

Every control here follows what the unit reports. An LS-50 shows neither Samples nor Superfine: it reads one CCD line at a time whatever you ask, and it ignores repeated reads of a line, so both stay hidden and a setting saved from another scanner is never sent to it.

The driver is the optional **nkscan** package (0.9 or newer), which ships as a wheel: install it with `uv sync --group nkscan` or `pip install negpy[nkscan]`. On Linux a Coolscan on USB needs a udev rule for Nikon (vendor `04b0`), and one on FireWire/SCSI needs the `sg` kernel module.

**SANE scan window**: on a roll/strip feeder (a live frame count reported), **Preview strip…** previews every frame, sets a per-frame window, and picks which frames to scan. On a SANE device with a single manual holder and no feeder, the button reads **Preview…** instead: it previews just the current holder position and lets you drag one crop window, reused for the next scan (the pyOpticfilm backend's equivalent is **Prescan**, above). Either way, the window narrows the scanner's own hardware scan area — the real scan only reads that region, rather than reading the full frame (holder margins and film rebate included) and cropping in software afterward.

A preview holds the scanner for the whole pass, so while one runs a progress bar tracks it, **Cancel** reads **Stop preview** — it abandons the pass and keeps the tiles already in hand — and the **Apply** and **Scan** exits stay dark until the pass ends. Previews read the way the **Film** setting says: negative stock is inverted, Slide and Kodachrome are not.
* **Camera Scanning**: DSLR or mirrorless copy-stand capture (macOS/Linux). It auto-connects the camera over USB in PC-Remote mode. With a NegPy **Scanlight** connected it captures narrowband R/G/B triplets from saved film-stock presets; without one it does a single white-light exposure. A **Live View** window helps you frame and focus. Captured frames land in the hot folder and flow straight into Trichrome Scan mode.

Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md).

<!-- panel:scan_strip -->
### Strip preview

Every preview dialog ends the same way: **Cancel**, then **Apply** (keep the framing and go back to the panel) and **Scan** (start the scan from here). The Apply button names what it keeps — **Apply framing** on a strip, **Apply window** on a single holder, **Apply crop** after a Prescan.

* **Cropping**: drag on a previewed frame — a corner resizes, inside moves. Each frame keeps its own window, and **Clear crops** drops the lot.
* **Offset**: slides every frame along the film to clear the inter-frame gap. Frames shift left as it grows, live. The shaded band on the right is film past the frame boundary the transport cannot deliver, so offset past the gap costs frame tail. A feeder cannot back up, so there it only goes one way.
* **Drift**: adds progressively more (or less) offset per frame position, for a strip whose gaps creep along its length. Re-preview to refresh the pixels.
* **Which frames**: each tile carries its own tick; **All** and **None** move the lot, and the count says how many will be scanned. On a measured strip the ticks and crops describe the piece of film in the transport, so ejecting clears them; Offset and Drift survive, because they register the transport.

---

## 14. Preferences
Expand Down
6 changes: 5 additions & 1 deletion negpy/desktop/view/sidebar/right_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,16 @@ def _build_scan_page(self) -> QWidget:
SANE flatbed/film scanner on top, the RGB-Scan trichromatic capture below."""
repo = self.controller.session.repo

from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog, has_guide

def make(title: str, key: str, icon_name: str, content: QWidget, default_expanded: bool) -> CollapsibleSection:
persisted = repo.get_global_setting(f"section_expanded_{key}")
expanded = bool(persisted) if persisted is not None else default_expanded
section = CollapsibleSection(title, expanded=expanded, icon=qta.icon(icon_name, color="#aaa"))
section = CollapsibleSection(title, expanded=expanded, icon=qta.icon(icon_name, color="#aaa"), info=has_guide(key))
section.set_content(content)
section.expanded_changed.connect(lambda checked, k=key: repo.save_global_setting(f"section_expanded_{k}", checked))
if section.info_btn:
section.info_requested.connect(lambda k=key, tt=title, s=section: SectionHelpDialog(k, tt, s).exec())
return section

self.scan_sane_section = make("Film Scanner", "scan_sane", "fa5s.camera-retro", self.scan_sidebar, False)
Expand Down
Loading
Loading