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
158 changes: 158 additions & 0 deletions docs/COOLSCANPY_ROLL_SCANNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Roll scanning with coolscanpy

This page describes NegPy's optional integration with
[coolscanpy](https://github.com/rohanpandula/coolscanpy), a standalone
Python library for whole-roll scanning on a Nikon Coolscan LS-5000 with a
roll feeder. coolscanpy talks to the scanner directly over USB and does not
need SANE installed. NegPy consumes it the same way it consumes
python-sane or gphoto2: as an optional dependency that the rest of the
application never requires.

## What it adds

The plain Scan panel in NegPy scans one frame at a time through SANE. A
roll feeder changes the shape of the problem: you preview the whole roll in
one transport pass, check or nudge each frame's spacing, and then run a
batch fine scan across a list of slots without reloading the film. That
whole-roll workflow, preview, spacing correction, approval of any frame the
transport could not place automatically, and batch scanning with per-frame
receipts, is what coolscanpy implements and what this integration exposes
inside NegPy.

Coverage is narrower than the plain Scan panel. Only color negative film
scans through coolscanpy's roll engine end to end today. Black and white
negative previews and approves normally, but batch fine scanning it raises
`NotImplementedError` inside coolscanpy itself; that limitation is
documented in the coolscanpy README and is unchanged by this integration.

## Install

Nothing here is required to run NegPy. The feature is entirely absent
until coolscanpy is importable, and every entry point checks for that
before doing anything else.

If you use uv, sync the new dependency group the same way you already
sync the `scanner` group for python-sane:

```
uv sync --group coolscan-roll
```

If you manage your own environment instead, install the package directly:

```
pip install coolscanpy
```

coolscanpy has no SANE dependency for the roll workflow. See the
coolscanpy README if you also want its plain single-frame `scan()` path,
which does need SANE.

## Hardware support

coolscanpy, and by extension this integration, has been validated against
one specific setup: a Nikon Super Coolscan 5000 ED (LS-5000) running
firmware 1.03, with an SA-21 roll feeder wired for SA-30 compatibility.
Live hardware validation on 2026-07-18 confirmed USB enumeration, device
open, and a full roll preview with correct spacing and manual-review
flagging. Fine scanning and infrared capture through the roll engine have
not yet been re-run live since coolscanpy was extracted into its own
package; that is the remaining validation step on the coolscanpy side, not
something specific to this NegPy integration.

Every other Coolscan model and every roll feeder other than the SA-21/SA-30
combination above is untested. The transport protocol is not assumed to be
LS-5000-only where it does not have to be, but nothing beyond that one
combination has scanned real film. Windows has never run the roll engine
against hardware.

## What this integration writes

A batch scan produces, per slot, three files in the configured output
folder:

- a 16-bit TIFF holding the scanner-linear RGB image
- a 16-bit TIFF holding the infrared plane, named with an `_IR` suffix
next to the RGB file, matching the suffix NegPy's plain scan writer
already uses for infrared
- a JSON file with an `_receipt` suffix, holding the full scan receipt
coolscanpy returns for that frame: exposure, clipping and focus
telemetry, transport-smear assessment, and the fingerprints the frame was
checked against

The filename pattern is the same Jinja2 template the plain Scan panel
uses, with `date` and `seq` variables. For roll scanning, `seq` is the
frame's physical slot number rather than an incrementing counter. Slot
numbers are already a stable identity for a given roll, so re-scanning one
bad slot overwrites that slot's old files instead of accumulating a second
copy beside them.

The infrared confidence mask coolscanpy also returns per frame is not
written to disk by this integration. Nothing downstream currently reads
it, and adding a file format for it before there is a consumer would be
guessing at a convention rather than following one.

## Error handling

coolscanpy raises a typed exception hierarchy rooted at
`PyCoolscanError`, covering things like a roll that no longer matches its
last preview, a slot that needs manual approval before it can be scanned,
and a requested safe stop. NegPy's existing scanner code has no typed
exception vocabulary of its own; `SaneBackend` reports every failure as a
plain `RuntimeError` with a human-readable message, and that message is
what ends up in the Scan panel's status label. This integration follows
the same convention: every coolscanpy exception is flattened to a
`RuntimeError` at the boundary, with the original exception preserved as
`__cause__` for any caller that wants to branch on it.

One case is worth calling out for whoever builds the interactive panel
described below. `Roll.safe_stop()` lets the frame already in flight
finish and only refuses the next one, by raising `SafeStopRequested`. The
existing plain-scan worker treats a cancellation the same way, checking
its own cancel flag and returning quietly instead of reporting an error.
A roll-scanning worker should do the same for a `RuntimeError` whose
`__cause__` is `SafeStopRequested`: that is an expected stop, not a
failure.

## The integration point

Everything that touches coolscanpy itself lives in one file,
`negpy/infrastructure/scanners/coolscanpy_roll.py`. `open_roll()` in that
file is the single place a device handle gets resolved for the roll
workflow; it currently calls `coolscanpy.open()` directly. Everything else,
including `negpy/services/scanning/roll_service.py`, only ever talks to the
`RollHandle` that function returns.

That module is marked in a comment as the intended target for a future
change. NegPy's maintainer has a generic SANE-based coolscan route planned
upstream, which is expected to add a real backend-selection seam to
`ScannerService` (today `ScannerService._get_backend()` simply hardcodes
`SaneBackend()`, with no selection mechanism at all). When that seam
lands, re-pointing this roll integration at it should only require changing
how `open_roll()` resolves a device. The roll workflow built on top of it,
the exception translation in the same file, and the whole of
`roll_service.py`, should not need to change.

## What is not built yet

This integration ships the backend and service layer: device discovery,
roll preview, spacing correction, approval, batch scanning, and file
writing, all covered by hardware-free tests. It does not ship a contact
sheet panel with rendered thumbnail images in the desktop GUI. Building
that panel from scratch, before the maintainer's own SANE-based route
defines how a Coolscan device is meant to surface in the UI, risked adding
exactly the kind of large, hard-to-review, foreign-feeling change this
integration is meant to avoid.

A future panel can follow the existing Scan panel closely. Look at
`negpy/desktop/view/sidebar/scan.py` for the sidebar structure and its
`_sane_available()` availability gate, `negpy/desktop/workers/scan_worker.py`
for the background-thread pattern (a `QObject` moved to its own `QThread`,
progress and result reported back over Qt signals), and
`negpy/desktop/controller.py` for how that worker's signals get wired into
the rest of the application. A roll panel would follow the same shape,
calling `RollScanningService` instead of `ScannerService`, gated behind
`coolscanpy_roll.available()` the same way the plain panel gates itself
behind SANE. The simplest first version would not need thumbnail images at
all: a plain list of slots with their approval state, backed by
`Roll.preview()`, is enough to let someone pick which slots to scan.
8 changes: 8 additions & 0 deletions negpy/infrastructure/roll/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Coolscan roll-feeder capture layer -- no Qt, no NegPy file model.

Drives a Nikon Coolscan LS-5000 plus SA-21/SA-30 roll feeder through the
optional `coolscanpy` package: whole-roll preview, per-slot spacing
correction and approval, and batch fine-scanning with receipts. Entirely
absent when `coolscanpy` is not installed -- see
`coolscanpy_roll.available()`.
"""
195 changes: 195 additions & 0 deletions negpy/infrastructure/roll/coolscanpy_roll.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Optional coolscanpy-backed roll-scanning adapter for the Nikon Coolscan LS-5000.

coolscanpy (https://github.com/rohanpandula/coolscanpy) is a standalone,
SANE-free library that talks to an LS-5000 plus SA-21/SA-30 roll feeder
directly over USB: whole-roll preview, per-slot spacing correction, and
batch fine-scanning with receipts. NegPy consumes it exactly like
python-sane or gphoto2 elsewhere in this package -- an optional dependency,
imported lazily, entirely absent by default. See `available()`.

This module owns every place coolscanpy itself gets imported or driven for
the roll workflow: opening a device, opening its roll extension, and
translating coolscanpy's exceptions. `negpy.services.roll.service` builds
the file-writing workflow on top of it but never imports coolscanpy
directly, matching how `negpy.services.capture.service` never imports
`gphoto2` directly either.

--------------------------------------------------------------------------
INTEGRATION POINT for a future re-point
--------------------------------------------------------------------------
`open_roll()` below is the one place a device handle gets resolved for the
roll workflow. It currently goes straight to `coolscanpy.open()`. NegPy's
maintainer has a generic SANE-based coolscan route planned upstream, which
is expected to add a real backend-selection seam to
`negpy.services.scanning.service.ScannerService` (today `_get_backend()`
just hardcodes `SaneBackend()`). When that seam lands, re-pointing this
adapter at it should only mean changing how `open_roll()` resolves a
device -- everything built on top of the returned `RollHandle` (this
module's exception translation, and all of
`negpy/services/roll/service.py`) is unaffected, since it never talks to
coolscanpy except through this one function and the `RollHandle` it
returns.
--------------------------------------------------------------------------
"""

from __future__ import annotations

import importlib.util
from typing import TYPE_CHECKING, Iterable, Iterator

if TYPE_CHECKING:
import coolscanpy


def available() -> bool:
"""True if the optional `coolscanpy` dependency is importable.

A cheap, side-effect-free presence check -- mirrors the
`ScanSidebar._sane_available()` check the plain Scan panel already uses
for python-sane, so callers (a future roll panel, a service, a test) can
degrade gracefully without ever importing coolscanpy just to ask whether
it exists.
"""
return importlib.util.find_spec("coolscanpy") is not None


class RollHandle:
"""One open coolscanpy `Device` + `Roll`, released together on `close()`.

Construct via `open_roll()`, not directly. Every method here is a thin
forward to the underlying `coolscanpy.Roll`, translating coolscanpy's
typed exceptions (rooted at `PyCoolscanError`) to plain `RuntimeError`s
-- matching how `SaneBackend.scan()` already reports failures elsewhere
in this package (a message string the scan worker forwards verbatim to
the GUI's status label). NegPy's scanner layer has no typed exception
vocabulary of its own for a caller to catch selectively; a caller that
wants coolscanpy's own richer hierarchy can still recover it from
`__cause__`, since every translation chains `from error`.
"""

def __init__(self, device: "coolscanpy.Device", roll: "coolscanpy.Roll") -> None:
self._device = device
self._roll = roll

def preview(
self, slots: Iterable[int] | None = None, *, on_progress: "coolscanpy.ProgressCallback | None" = None
) -> "list[coolscanpy.Thumbnail]":
"""One whole-roll transport read. See `coolscanpy.Roll.preview`."""
try:
return self._roll.preview(slots, on_progress=on_progress)
except Exception as error:
raise _translate(error) from error

def set_spacing_offset(self, slot: int, offset_rows: int) -> None:
try:
self._roll.set_spacing_offset(slot, offset_rows)
except Exception as error:
raise _translate(error) from error

def approve(self, slot: int) -> None:
try:
self._roll.approve(slot)
except Exception as error:
raise _translate(error) from error

def needs_approval(self, slot: int) -> bool:
try:
return self._roll.needs_approval(slot)
except Exception as error:
raise _translate(error) from error

def scan_many(
self, slots: Iterable[int], *, on_progress: "coolscanpy.ProgressCallback | None" = None
) -> Iterator["coolscanpy.Frame"]:
"""Batch fine-scan `slots` in one transport reservation.

A `RuntimeError` raised here whose `__cause__` is a
`coolscanpy.SafeStopRequested` means `safe_stop()` was already
called deliberately and should be treated as a clean stop, not
surfaced as a failure -- see `is_safe_stop()`.
"""
try:
yield from self._roll.scan_many(slots, on_progress=on_progress)
except Exception as error:
raise _translate(error) from error

def safe_stop(self) -> None:
"""Request a graceful stop; the frame already in flight still finishes."""
self._roll.safe_stop()

def close(self) -> None:
"""Idempotent. Releases the roll extension, then the device."""
try:
self._roll.close()
finally:
self._device.close()

def __enter__(self) -> "RollHandle":
return self

def __exit__(self, *exc: object) -> None:
self.close()


def list_devices() -> "list[coolscanpy.DeviceInfo]":
"""Enumerate attached LS-5000 units. Empty list, never raises, if none found."""
import coolscanpy

return coolscanpy.get_devices()


def open_roll(device_id: str | None = None, *, material: "coolscanpy.Material | None" = None) -> RollHandle:
"""Open one LS-5000 and its roll-feeder extension.

`device_id` follows `coolscanpy.open()`: omitted (`None`) picks "the one
attached unit", an explicit id (from `list_devices()`) disambiguates
when more than one is attached. `material` defaults to
`coolscanpy.Material.COLOR_NEGATIVE`, the only material coolscanpy's
roll engine fine-scans end to end today; black-and-white preview works
but its batch scan raises `NotImplementedError` (unchanged, surfaced as
a plain `RuntimeError` here like everything else).
"""
import coolscanpy

resolved_material = material if material is not None else coolscanpy.Material.COLOR_NEGATIVE
try:
device = coolscanpy.open(device_id or "ls5000")
except Exception as error:
raise _translate(error) from error

try:
roll = device.roll(material=resolved_material)
except Exception as error:
device.close()
raise _translate(error) from error

return RollHandle(device, roll)


def _translate(error: BaseException) -> RuntimeError:
"""Flatten any coolscanpy failure to a plain `RuntimeError`.

Covers the typed `PyCoolscanError` hierarchy (`RollMismatch`,
`FingerprintRefused`, `ManualReviewRequired`, `SafeStopRequested`, ...)
and the handful of plain `ValueError`/`RuntimeError` coolscanpy itself
raises (e.g. "no roll adapter is attached"), so nothing coolscanpy-shaped
ever crosses out of this module. `raise _translate(error) from error`
always preserves the original as `__cause__` -- see `is_safe_stop()`.
"""
return RuntimeError(str(error))


def is_safe_stop(error: BaseException) -> bool:
"""True if `error` is this module's translation of a deliberate
`RollHandle.safe_stop()` outcome, not a genuine failure.

`error` is normally the `RuntimeError` a caller of `scan_many()` just
caught; every translation in this module chains `from error`, so the
original `coolscanpy.SafeStopRequested` (when that is what happened)
is always sitting in `__cause__`. Imports coolscanpy lazily like the
rest of this module -- safe here because an error this module raised
already means coolscanpy was reachable.
"""
import coolscanpy

return isinstance(getattr(error, "__cause__", None), coolscanpy.SafeStopRequested)
26 changes: 26 additions & 0 deletions negpy/infrastructure/roll/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Persisted Roll Scanning panel settings (stored as a global setting dict)."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class RollScanSettings:
"""Sticky settings for the Roll Scanning sidebar.

Persisted via the session repo under the `roll_scan_settings` key,
mirroring `ScannerSettings` and `ScanlightSettings`. `last_device_id`
empty means no device remembered yet; per-slot spacing offsets and
approvals are not persisted here at all -- they live on the open
coolscanpy `Roll` for as long as it stays open, and `preview()` resets
them on its own the moment it re-reads the transport.
"""

last_device_id: str = ""
output_folder: str = ""
filename_pattern: str = '{{ date }}_{{ "%03d" % seq }}'

@classmethod
def defaults(cls) -> "RollScanSettings":
return cls()
Loading
Loading