Skip to content

feat(roll): add coolscanpy roll-scanning backend adapter (optional dependency)#615

Draft
rohanpandula wants to merge 5 commits into
marcinz606:mainfrom
rohanpandula:feat/coolscanpy-roll-glue
Draft

feat(roll): add coolscanpy roll-scanning backend adapter (optional dependency)#615
rohanpandula wants to merge 5 commits into
marcinz606:mainfrom
rohanpandula:feat/coolscanpy-roll-glue

Conversation

@rohanpandula

Copy link
Copy Markdown
Contributor

Summary

This is the thin connector from the standalone coolscanpy package into NegPy — the follow-through on the agreement in #497. coolscanpy owns all the device-specific LS-5000/SA-21 USB transport; NegPy consumes it exactly the way it already consumes python-sane (plain Scan panel) and gphoto2 (Camera Scanning): as an optional dependency, imported lazily, entirely absent by default.

It is intentionally scoped to only the glue. No desktop sidebar, no background worker, no repair engine, no output tiers, no Nikon-exact color builders, no live-evidence contracts — all of that is meant to land as separate review units stacked on top of this one. This PR adds nothing a reviewer has to maintain beyond a small adapter and the file-writing service behind it.

What it adds

  • coolscan-roll optional dependency group in pyproject.toml, mirroring the existing scanner (python-sane) and camera (gphoto2) groups. Pins coolscanpy to immutable commit 970d18e. uv sync --group coolscan-roll opts in; the rest of the app never sees it.
  • negpy/infrastructure/roll/coolscanpy_roll.py — the only module that imports coolscanpy. Wraps coolscanpy.Device + Roll behind a small RollHandle (preview, spacing correction, approve, batch scan_many, safe_stop, close), and translates coolscanpy's typed PyCoolscanError hierarchy to plain RuntimeErrors the same way SaneBackend already reports failures — original preserved as __cause__. available() is a cheap importlib.util.find_spec() check, mirroring ScanSidebar._sane_available(), so the feature degrades to fully absent when coolscanpy isn't installed.
  • negpy/services/roll/service.pyRollScanningService orchestrates open/close (one reservation at a time, matching coolscanpy's own lock), preview, approve, spacing correction, batch scan_many, and safe_stop. write_frame() writes each frame in NegPy's existing scan-output shape: a 16-bit TIFF master, an _IR.tif sidecar when the frame carries infrared (matching writer.write_tiff_16bit's own suffix), and a _receipt.json sidecar. The filename pattern is the same Jinja2 template the plain Scan panel uses, but seq is seeded from the frame's physical slot number so re-scanning one bad slot overwrites instead of accumulating a second copy.
  • negpy/infrastructure/roll/settings.pyRollScanSettings, mirroring ScannerSettings / ScanlightSettings.
  • docs/COOLSCANPY_ROLL_SCANNING.md — install, the LS-5000/SA-30 hardware caveat, on-disk output convention, error handling, and where the single integration point lives.
  • tests/roll/conftest.py injects a fake coolscanpy module, so test_coolscanpy_roll.py and test_service.py need no real hardware and no real package installed.

The integration point

open_roll() in coolscanpy_roll.py is the one place a device handle gets resolved. Everything built on top of the returned RollHandle — this module's exception translation, all of service.py — never talks to coolscanpy except through that one function. It's marked in-code as the intended re-point target: once your planned generic SANE-based coolscan route adds a real backend-selection seam to ScannerService (today _get_backend() just hardcodes SaneBackend()), re-pointing this adapter should only mean changing how open_roll() resolves a device. Nothing above it moves.

Hardware & validation

coolscanpy (and therefore this adapter) is validated against one setup: Nikon Super Coolscan 5000 ED, firmware 1.03, SA-21 wired for SA-30 compatibility. Live acceptance on 2026-07-23 covered a 36-frame preview and 36 fine captures at 4,000 dpi / 16-bit RGBI. Every other Coolscan model and feeder is untested. This glue PR itself carries no hardware coupling beyond the lazy coolscanpy.open() call; the device-specific behavior lives in coolscanpy.

Verification

  • ruff check on the new Python files: passes.
  • tests/roll/ passes against the fake-coolscanpy fixture with no hardware and no coolscanpy installed (33 tests; one presence-absence test is environment-dependent and passes on CI where the coolscan-roll group is not synced).
  • Feature is fully inert when coolscanpy is absent — available() gates every entry point.

Review note

Opened as a draft to keep the surface small and reviewable. The desktop panel, repair tiers, and Nikon-exact color path are ready as follow-on PRs stacked on this one. Feedback on the adapter boundary and the open_roll() re-point contract is what I'm most looking for.


Relationship to prior PRs: #497 (the original monolithic LS-5000 PR, declined → split into coolscanpy + this adapter) · #613 (the full integration this glue is sliced out of, now closed in favor of smaller stacked PRs).

coolscanpy is a standalone, SANE-free library for whole-roll scanning
on a Nikon Coolscan LS-5000 with a roll feeder. Add it as a new
dependency-group, following the same pattern as the existing
"scanner" group for python-sane: opt-in, not a hard dependency of the
rest of the application.
CoolscanRoll wraps coolscanpy's Device/Roll workflow (preview, spacing
correction, approval, batch fine-scan, safe stop) behind a small
RollHandle, translating coolscanpy's typed PyCoolscanError hierarchy
to plain RuntimeErrors the same way SaneBackend already reports
failures elsewhere in this package.

available() is a cheap importlib.util.find_spec() check, mirroring
ScanSidebar's existing _sane_available() gate, so the feature degrades
to fully absent when coolscanpy isn't installed.

open_roll() is the single place this adapter resolves a device handle,
marked as the intended re-point target once the maintainer's own
generic SANE-based coolscan route defines a real backend-selection
seam on ScannerService.

Tests inject a fake coolscanpy module (tests/scanners/conftest.py) so
none of this requires real hardware or the real package installed.
RollScanningService orchestrates the coolscanpy_roll adapter: open/
close a roll (one at a time, matching coolscanpy's own reservation
lock), preview, approve, spacing correction, batch scan_many, and
safe_stop.

write_frame() writes each scanned Frame to disk in NegPy's existing
scan-output shape: a 16-bit TIFF master, an "_IR" TIFF sidecar when
the frame carries an infrared plane (matching writer.write_tiff_16bit's
own suffix), and an "_receipt.json" sidecar with the full Receipt.
The filename pattern is the same Jinja2 template the plain Scan panel
uses, but seq is seeded from the frame's physical slot number rather
than probed for a free name, so redoing one bad slot overwrites that
slot's files instead of accumulating a second copy.

ir_validity is intentionally not persisted; nothing downstream reads
it yet.
Explains what the integration adds, install steps, the LS-5000/SA-30
hardware caveat, the on-disk output convention, error handling, and
where the single integration point lives for a future re-point onto
the maintainer's own generic SANE-based coolscan route. Also documents
the follow-up: no contact-sheet GUI panel yet, and where one would
plug in.
The camera capture route lives beside scanners/ and scanning/ as its own
infrastructure/capture + services/capture pair, gated on an optional
dependency, rather than inside the single-frame ScannerBackend protocol.
Give roll scanning the same shape: infrastructure/roll/coolscanpy_roll.py
and services/roll/service.py, instead of sitting inside the plain-SANE
scanners/scanning packages where it doesn't implement that protocol and
never will.

Also adds a RollScanningError for this service's own lifecycle misuse
(mirrors CaptureError), an is_safe_stop() helper next to the existing
exception translation so a future worker can tell a deliberate safe_stop()
outcome from a real failure, and precise on_progress/return typing sourced
from coolscanpy's actual Progress/Thumbnail/Frame types.

No behavior change to the preview/approve/scan/write workflow itself.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant