Skip to content

Integrate CoolscanPy LS-5000 whole-roll scanning#613

Closed
rohanpandula wants to merge 51 commits into
marcinz606:mainfrom
rohanpandula:feat/coolscanpy-roll-adapter
Closed

Integrate CoolscanPy LS-5000 whole-roll scanning#613
rohanpandula wants to merge 51 commits into
marcinz606:mainfrom
rohanpandula:feat/coolscanpy-roll-adapter

Conversation

@rohanpandula

@rohanpandula rohanpandula commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

This integrates the standalone CoolscanPy LS-5000/SA-21 whole-roll backend into NegPy as an optional dependency and desktop workflow.

The integration provides:

  • Direct-USB whole-roll preview, frame review/spacing approval, selected-frame batch capture, Safe Stop, and Eject Roll.
  • A central contact-sheet preview with raw-negative and auto-toned-positive display modes. Preview toning is display-only and does not alter saved TIFFs.
  • Tier 1 unrepaired RGB/IR, Tier 2 Exact or configurable Hybrid Digital ICE repair, and Tier 3 verified Nikon-exact positives.
  • Hybrid recommendations and a fail-closed configurable synthesis ceiling, accepted up to 10% only when the retained receipt and disclosure masks verify.
  • Retained acquisition, Hybrid, native-builder, and Nikon color evidence with transactional publication and offline deep acceptance.
  • Existing roll-aware auto-crop and orientation behavior retained in the integrated workflow.
  • Fail-closed eject behavior: a confirmation timeout is reported as physically uncertain because the action may already have been dispatched, and a blind retry is forbidden.

Hardware validation

Validated live on 2026-07-23 with a Nikon Super Coolscan 5000 ED (firmware 1.03) and converted SA-21/SA-30 feeder:

  • 36-frame roll preview and registration.
  • 36 fine captures at 4,000 dpi, 16-bit RGBI.
  • Every capture completed 2,980 reads and 619,458,560 bytes.
  • All 36 outputs passed retained-acquisition replay, Tier 1 pixel comparison, Hybrid receipt/mask verification, Nikon builder/CMS replay, and positive TIFF/ICC verification.
  • Hybrid synthesis ranged from 7.0193% to 8.0410%, below the configured 10% ceiling; zero degraded frames.
  • All 36 positives were exact Nikon-color publications.

Software validation

  • Latest upstream main is merged; the PR is conflict-free and mergeable.
  • Ruff passes.
  • Scoped ty check for the touched SANE eject backend passes.
  • Post-upstream-merge suite: 3,001 passed, 5 skipped, 7 deselected.
  • The Apple-signed frozen macOS app passes code-sign verification, frozen import/assets smoke, and a scanner-free 619,458,560-byte capture-plan smoke.
  • CoolscanPy is pinned to the live-validated immutable commit 970d18e4b092737a633bd20141d3eaa62b25dcc6; its integration PR is merged in rohanpandula/coolscanpy and CI passes on macOS/Ubuntu, Python 3.13/3.14.

Review note

This is intentionally opened as a draft because the branch is broad and the maintainer may prefer the desktop integration, evidence contracts, and packaging changes split into smaller review units. The implementation and live validation are complete; feedback on how to slice it for upstream review is welcome.

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.
Mirrors CaptureWorker's shape: a QObject moved to its own QThread, request
dataclasses for the operations with a progress/status lifecycle
(RollPreviewRequest, RollBatchScanRequest), and one open hardware session
held across calls rather than reopened per operation.

_ensure_open() lazily opens the roll for whichever device a preview or
batch-scan request names, closing a previous reservation on a device
switch, mirroring _acquire_camera()'s idempotent-session pattern.

Cancellation has no local threading.Event the way CaptureWorker's does:
a fine-scan in flight can't be aborted mid-pull, so safe_stop() (thread-
safe, called directly like CaptureWorker.cancel()) is the only stop, and
it always lets the in-flight frame finish. run_batch_scan tells that clean
stop apart from a genuine failure via coolscanpy_roll.is_safe_stop().

Extends the tests/roll/ fake_coolscanpy fixture's FakeRoll to actually
call on_progress during preview()/scan_many(), matching coolscanpy's real
Progress-object shape, so the worker's progress-forwarding has real
coverage instead of being untestable.
Structured like ScanlightSidebar: a pinned primary action, a gate hint
that names what's still missing, sections for device and output settings,
one status line plus progress bar, and settings persisted the same way
(RollScanSettings under a global-setting key, loaded/saved like
ScanlightSettings). _apply_gating() mirrors _missing_requirements() /
_apply_gating() there, split into a narrower preview gate and a fuller
scan gate.

The contact sheet, per-slot spacing/approval controls and the safe-stop
button have no camera-route analogue -- coolscanpy's preview/approve/
batch-scan workflow doesn't exist on that side. Built the simplest way
that fits the surrounding code: a plain QListWidget in icon mode (not the
file browser's heavier ThumbnailGridView, which assumes NegPy's own asset
model) with a small local numpy-to-QPixmap conversion, since a scanner
preview frame isn't a color-managed pipeline buffer.

Safe Stop is its own control, not a Scan/Stop button toggle: unlike a
camera exposure, a fine-scan in flight can't be aborted mid-pull, so the
button explicitly says what it does -- finish the current frame, then
stop -- rather than implying an instant cancel.
AppController gets a roll_thread + RollWorker pair, lazily started like
the capture thread (_ensure_roll_thread mirrors _ensure_capture_thread),
request/result signal pairs for every RollWorker operation, and public
methods the sidebar calls (request_roll_devices, start_roll_preview,
set_roll_spacing_offset, approve_roll_slot, start_roll_scan,
roll_safe_stop). roll_safe_stop() calls the worker directly, same as
cancel_capture()/cancel_scan() -- no thread needs to be running for a
thread-safe Event.set() to be worth queuing.

_on_roll_scan_finished feeds written frames into NegPy's file list via
request_asset_discovery(), the same seam the plain Scan and Camera
Scanning routes already use for their own output. Unlike
_on_capture_finished it does no rgbscan_mode/process_mode bookkeeping:
each roll frame is already one complete per-slot RGB TIFF, not an R/G/B
triplet needing a merge flag.

RightPanel's Scan tab gains a third collapsible section, "Roll Scanning",
alongside the existing "Scanner (SANE)" and "Camera Scanning" sections,
and on_activated() now reaches all three sidebars on tab switch.
Mirrors test_capture_controller.py: exercises
AppController._on_roll_scan_finished directly against a mock controller,
so a future rename of roll_finished/request_asset_discovery fails a fast
unit test instead of only showing up as scanned frames silently not
appearing in the file browser.
Rewrites the module layout section around infrastructure/roll and
services/roll instead of the old scanners/scanning locations, documents
RollScanningError and is_safe_stop() alongside the existing exception-
translation section, points the integration-point cross-reference at the
new file paths, and replaces the old "not built yet" note (written before
this branch had a UI at all) with a description of what the panel
actually does and the three deliberate simplifications in it: no material
picker, no separate close-roll button, and Safe Stop as its own control
rather than a Scan/Stop toggle.

Also comments the coolscan-roll dependency group in pyproject.toml,
matching the existing camera group's comment.
Types _thumbnails and the worker-signal handler parameters against
coolscanpy's real Thumbnail/DeviceInfo (TYPE_CHECKING-only, matching the
lazy-import discipline everywhere else) and RollFrameOutput, instead of
bare object/list. Guards contact_sheet.item(i) against the None a
QListWidgetItem lookup can return per the PyQt6 stubs, even though the
loop's own range() never actually produces an out-of-range index.
The previous wording implied the camera route's threading.Event cancel is
"instant"; it isn't, it's checked between channels the same way safe_stop
is checked between frames here. The real reason RollWorker carries no
local cancel flag of its own is that coolscanpy already exposes
Roll.safe_stop() for this, where gphoto2 exposes nothing equivalent and
CaptureWorker had to build its own.
RollScanningService will write a frame's dust/scratch-repaired RGBI as
its own output tier, but no repair engine ships in this package. This
adds the plug point: available()/register_engine()/repair(), with
nothing registered by default so a caller degrades instead of writing
data mislabeled as repaired.
render_positive() inverts a scanner-linear RGB buffer to a positive by
calling ImageProcessor.run_pipeline directly on the in-memory array --
the same DarkroomEngine entry point a freshly imported negative reaches
on first open, and the same one NegPy's own export pipeline calls after
decoding a file. A stock WorkspaceConfig is used (C41, print intent,
auto exposure on): the ordinary default conversion, not a custom one,
since this tier is a regenerable view rather than an edit.

Each call gets a fresh source_hash rather than one derived from the
frame, deliberately: the engine's stage cache reuses a prior render
when both the source hash and the settings hash match, and every
Tier-3 call here shares the same stock settings, so a stable hash
reused across two different frames would silently hand back a
previous frame's pixels.
write_unrepaired, write_repaired and write_positive select which of
the three output tiers a batch scan writes, independently of one
another rather than as a single three-way choice. write_unrepaired
defaults on: it is the archival master and the only tier the scanner
itself can reproduce. repair_mode carries the Tier-2 variant (exact or
hybrid) once a repair engine is registered.
write_frame() now writes up to three output tiers per frame instead
of always writing the one it used to: unrepaired (Tier 1, the archival
master), repaired (Tier 2, via the repair seam), and positive (Tier 3,
Tier 2 inverted). Any combination is valid.

Tier 3 always derives from Tier 2's in-memory result, computing repair
whenever either flag needs it even if Tier 2 itself isn't written to
disk, and never from a Tier 1 or Tier 2 file already on disk. Tier 2
and Tier 3 degrade with a status in the receipt instead of raising
when they can't be produced -- no repair engine registered, no
infrared plane to guide one, the engine or the render failing -- so a
problem in a derived tier never costs the archival capture. Naming
follows the existing _IR-sidecar convention: _repaired.tif plus
_repaired_IR.tif for Tier 2 (the infrared sidecar is Tier 1's own,
retained unchanged since repair consumes it rather than producing a
repaired version of it), _positive.tif for Tier 3.

The receipt's existing top-level fields (coolscanpy's own per-frame
receipt) are unchanged; a new "outputs" key records what was written
per tier, and for Tier 2/3 the provenance needed to audit or
regenerate them -- engine name, version and mode for Tier 2, and
render intent, process mode, auto-exposure setting and the repair
that fed it for Tier 3.

RollFrameOutput's rgb_path is now optional to match: Tier 1 can be
turned off same as the other two.
RollBatchScanRequest gains write_unrepaired/write_repaired/
write_positive/repair_mode, defaulting to RollScanSettings.defaults()
(Tier 1 only) so a request built without naming them behaves exactly
as before. run_batch_scan forwards them to write_frame().
Three checkboxes (Unrepaired/Repaired/Positive) and a repair-mode
dropdown in the Output section, seeded from and persisted through
RollScanSettings the same way the folder and filename-pattern fields
already are. Scanning is gated on at least one tier being selected,
matching the existing device/slots/folder gates.

Unchecking Unrepaired shows a warning label: it's the only tier the
scanner itself can reproduce, so turning it off is a real tradeoff the
UI should say out loud rather than a setting like any other.
rgb_path can now be None (Tier 1 turned off in favor of Tier 2/3
alone), which _on_roll_scan_finished's discovery seam didn't account
for. Filters those out instead of handing NegPy's asset discovery a
None path; a batch with at least one real Tier-1 output still
discovers those normally.
Explains Tier 1 (unrepaired, the archival master), Tier 2 (repaired,
cached because it's expensive but deterministic), and Tier 3
(positive, cheap and treated as a regenerable view since NegPy's
color rendering is still being tuned), the capture-then-repair-then-
invert ordering, what each tier's receipt entry records, how the
degrade paths behave, and the storage cost of writing every tier.
@rohanpandula

Copy link
Copy Markdown
Contributor Author

Closing this in favor of #615 — a thin slice of just the glue (optional dependency group + the coolscanpy_roll adapter + the file-writing service + docs + faked-hardware tests, ~1,135 lines across 12 files), matching the "plug it in like python-sane/gphoto2" seam we agreed on in #497.

The broader pieces that were in this PR — the desktop sidebar, background worker, repair tiers, Nikon-exact color builders, and live-evidence contracts — will come back as separate stacked PRs on top of #615, so each can be reviewed on its own. Nothing is lost; the full branch is preserved locally.

@rohanpandula
rohanpandula deleted the feat/coolscanpy-roll-adapter branch July 23, 2026 19:04
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