feat(roll): add coolscanpy roll-scanning backend adapter (optional dependency)#615
Draft
rohanpandula wants to merge 5 commits into
Draft
feat(roll): add coolscanpy roll-scanning backend adapter (optional dependency)#615rohanpandula wants to merge 5 commits into
rohanpandula wants to merge 5 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This is the thin connector from the standalone
coolscanpypackage 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 consumespython-sane(plain Scan panel) andgphoto2(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-rolloptional dependency group inpyproject.toml, mirroring the existingscanner(python-sane) andcamera(gphoto2) groups. Pins coolscanpy to immutable commit970d18e.uv sync --group coolscan-rollopts in; the rest of the app never sees it.negpy/infrastructure/roll/coolscanpy_roll.py— the only module that imports coolscanpy. Wrapscoolscanpy.Device+Rollbehind a smallRollHandle(preview, spacing correction, approve, batchscan_many,safe_stop,close), and translates coolscanpy's typedPyCoolscanErrorhierarchy to plainRuntimeErrors the same waySaneBackendalready reports failures — original preserved as__cause__.available()is a cheapimportlib.util.find_spec()check, mirroringScanSidebar._sane_available(), so the feature degrades to fully absent when coolscanpy isn't installed.negpy/services/roll/service.py—RollScanningServiceorchestrates open/close (one reservation at a time, matching coolscanpy's own lock), preview, approve, spacing correction, batchscan_many, andsafe_stop.write_frame()writes each frame in NegPy's existing scan-output shape: a 16-bit TIFF master, an_IR.tifsidecar when the frame carries infrared (matchingwriter.write_tiff_16bit's own suffix), and a_receipt.jsonsidecar. The filename pattern is the same Jinja2 template the plain Scan panel uses, butseqis 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.py—RollScanSettings, mirroringScannerSettings/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.pyinjects a fakecoolscanpymodule, sotest_coolscanpy_roll.pyandtest_service.pyneed no real hardware and no real package installed.The integration point
open_roll()incoolscanpy_roll.pyis the one place a device handle gets resolved. Everything built on top of the returnedRollHandle— this module's exception translation, all ofservice.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 toScannerService(today_get_backend()just hardcodesSaneBackend()), re-pointing this adapter should only mean changing howopen_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 checkon 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 thecoolscan-rollgroup is not synced).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).