Skip to main content

CoolscanPy

CoolscanPy 0.7.7 is a standalone Python acquisition library for the Nikon Super Coolscan 5000 ED (LS-5000). Its main path previews a strip or roll, binds reviewed frame positions to that insertion, and captures color-negative frames over direct USB. It returns scanner-linear pixels and acquisition evidence for an application to save, repair, and render.

There is no GUI. The package does not require ScanStudio or NegPy, and its public API does not depend on either application.

Scope and support

Area Contract in 0.7.7
Scanner LS-5000. Other recognized Coolscan models can appear in discovery with supported=False; open() refuses them.
Roll adapter Strip-feeder identities 6Strip and 36Strip are accepted. A positively identified mount adapter is refused by the roll path. This allowlist is not a hardware-validation matrix.
Color negatives Material.COLOR_NEGATIVE: direct-USB preview, review, batch fine capture, infrared, meter pass, and receipts.
Black-and-white negatives Preview and approval are available. Roll fine capture is not wired; Roll.scan() and scan_many() raise NotImplementedError.
Fine-capture format LS-5000 single-pass 4000 dpi, 16-bit RGBI acquisition. This is a fixed capture contract, not arbitrary resolution or scanner support.
Plain Device.scan() A separate, SANE-backed array API requiring the optional scanner extra and host SANE support.
Platforms Hardware-free CI runs on Ubuntu and macOS with Python 3.13 and 3.14. Windows has no CI or validated transport claim; concurrency tests assume POSIX locking.

Tests use synthetic devices and protocol replays. They do not establish compatibility with every firmware or feeder modification. No live-scanner validation is claimed for 0.7.7; CHANGELOG.md records earlier changes.

Version 0.7.7 adds samples_per_scan (1 or 4, default 4) to Roll.scan, Roll.scan_many, and the batch job. The 4-sample capture is byte-for-byte unchanged; single-sample mode is lab-only until a verified live trace exists (see CHANGELOG.md). Version 0.7.6 fixed held-session cleanup before batch iteration and retains ownership when child shutdown is uncertain; see cleanup contracts.

Install

Use Python 3.13 or later in a virtual environment:

python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install 'coolscanpy==0.7.7'

Direct USB requires a host libusb 1.0 runtime that PyUSB can load. Typical package-manager commands are:

# macOS, with Homebrew
brew install libusb

# Debian / Ubuntu
sudo apt install libusb-1.0-0

The process needs USB access permission and exclusive interface ownership. Close competing scanner applications and resolve surviving workers before opening a replacement session. Frozen applications must bundle libusb; see the loader contract.

Discovery, the color roll workflow, presence probing, and Device.eject() use the base installation. SANE is not required for those paths.

Optional SANE path

Install the extra only when using the plain Device.scan() API or the SANE-based diagnostic workflows:

python -m pip install 'coolscanpy[scanner]==0.7.7'

Building python-sane requires host SANE headers. On macOS:

brew install sane-backends
CPPFLAGS="-I$(brew --prefix)/include" \
LDFLAGS="-L$(brew --prefix)/lib" \
python -m pip install 'coolscanpy[scanner]==0.7.7'

On Debian / Ubuntu, install libsane-dev before installing the extra. The extra does not expand scanner support or enable black-and-white roll capture.

Discover and open a device

import coolscanpy

for info in coolscanpy.get_devices():
    print(info.id, info.model, info.supported, info.capabilities)

with coolscanpy.open("ls5000") as device:
    print(device.capabilities)
    print(device.option_names)

get_devices() returns DeviceInfo objects. It tries SANE enumeration and falls back to direct USB when SANE is unavailable, fails, or finds no Coolscan. There is no network transport. The "ls5000" alias requires one supported attached scanner; use an exact discovery ID to disambiguate. open() rechecks discovery and support.

Read identity from the discovery result and capabilities from device.capabilities; there is no public device.info property. USB-only capabilities are conservative: an unknown adapter_frame_capacity can be None, and can_eject=False is not a gate on the direct-USB eject() method.

Preview, review, and scan color negatives

The following interactive example writes preview TIFFs for review, asks for an explicit slot selection, and saves the selected frames. It performs real scanner I/O when run with a connected scanner. Use a new output directory for each insertion; the retained attempt directory contains the acquisition journals and intermediate evidence.

from contextlib import closing
from pathlib import Path

import coolscanpy
import tifffile

output = Path("roll-capture").resolve()
output.mkdir()  # Refuse to overwrite an earlier capture directory.

with coolscanpy.open("ls5000") as device:
    with device.roll(
        material=coolscanpy.Material.COLOR_NEGATIVE,
        attempts_root=output / "attempts",
    ) as roll:
        thumbnails = roll.preview()
        for thumbnail in thumbnails:
            tifffile.imwrite(
                output / f"preview-{thumbnail.slot:02d}.tif", thumbnail.image
            )
            print(thumbnail.slot, thumbnail.needs_approval, thumbnail.warnings)

        selected = [
            int(slot) for slot in input(
                "Inspect the saved previews, then enter approved slots (e.g. 1,2): "
            ).split(",")
        ]
        for slot in selected:
            if roll.attended_binding_available:
                roll.approve(slot, attended=True)
            elif roll.needs_approval(slot):
                roll.approve(slot)

        try:
            with closing(roll.scan_many(selected, eject_after=True)) as frames:
                for frame in frames:
                    tifffile.imwrite(output / f"frame-{frame.slot:02d}-rgb.tif", frame.rgb)
                    if frame.ir is not None:
                        tifffile.imwrite(output / f"frame-{frame.slot:02d}-ir.tif", frame.ir)
                    print(frame.slot, frame.receipt.transport_smear.verdict)
        except coolscanpy.SafeStopRequested:
            print("Stopped after completing the frame already in flight.")

preview() performs a whole-roll transport read at approximately 97 dpi. Its optional slots argument filters thumbnails, not the hardware read. Up to 40 slots can be represented; inspect warnings and partial crops rather than assuming all are complete, scanner-addressable frames.

A new preview replaces the fingerprint and clears approvals. To adjust a boundary, use set_spacing_offset(slot, offset_rows) in native preview rows; it returns a re-cropped thumbnail and invalidates that slot's approval. spacing_offset(slot) reads the offset. Review the changed crop before approving again.

When attended_binding_available is true, automatic detection has medium confidence: use approve(slot, attended=True) for every requested slot, not just flagged slots. Low-confidence detection is not rescued by this approval. Approvals bind to the reviewed content and geometry, not to a permanent slot number.

scan_many() reserves its lazy iterator before returning it. It freezes the selected slots, approvals, and reviewed session against concurrent mutation. Fully consume the iterator, or explicitly close it with contextlib.closing as above. scan(slot) is the one-slot convenience API.

Frames and evidence

A color-roll Frame contains scanner-linear rgb, an ir plane and ir_validity mask, the settled 285 dpi meter_rgbi pass, and a receipt with exposure, clipping, focus, transport-smear, and artifact evidence. Optional evidence fields must be checked before use. RGB and infrared use the package's Nikon Scan storage orientation; the meter has its own acquisition geometry. frame.prepare_digital_ice() validates and returns the scanner-native inputs for a repair call, or refuses incomplete evidence.

Pixels are not inverted or rendered for display. Clipping is informational; stopped-transport smear can refuse a frame. The guarded color-negative exposure solve records its commanded authority rather than promising color equivalence with another scanner application.

Both scan methods accept exposure_override_10ns=(red, green, blue): validated per-channel fine-exposure ticks, each 10 ns, independent of the meter measurements. Advanced contracts live alongside the implementation:

Contract Reference
Frame arrays, receipts, density ownership and repair inputs Public types
Validated manual placement and restored preview state Preview sessions, Roll API
Short-strip geometry, terminal slots and fingerprint validation Transport index
Capture resource and implementation hashes Bundle integrity
Strict EBDE padding validation and streaming/offline parity Packed decoder
Streaming artifact validation and raw-capture fallback Capture finalization

Concurrent decoding is advisory: invalid or unavailable derived data falls back to the retained raw capture. COOLSCANPY_CAPTURE_STREAMING=0 disables that optimization. It does not disable acquisition integrity checks.

Reservation, stop, and cleanup contracts

A successful preview leaves a child holding the scanner reservation. A batch resumes it without another frame-table traversal. A resumed batch that completes without stop or eject_after=True keeps the session held for the next batch. Without a held session, a batch starts a fresh reservation and releases it at completion. Repeating preview() supersedes the old hold and registration.

Use these operations according to the state you own:

Operation Meaning
roll.safe_stop() Stop the current batch between frames. It does not abort a frame already being captured. The consumer receives completed frames followed by SafeStopRequested.
Iterator close() Stop and drain an active batch, or clean up an unstarted reservation, before relinquishing its ownership.
roll.release() Give up a still-held session between batches. This is not an eject.
roll.eject() Replay the held-session eject sequence within the existing reservation. Raises EjectNotAvailable when there is no held session.
scan_many(..., eject_after=True) Eject after the final requested frame. A safe stop takes priority and releases without ejecting.
roll.close() Close owned work and release the Roll reservation. Unconfirmed worker shutdown raises DeviceBusy and retains ownership.
device.eject() Direct-USB unload when the caller has no capture child owning the interface. It confirms absence before returning success; an already-empty transport is a no-op success.

Stop state is batch-scoped: creating a new batch resets the Roll stop event. An application that acknowledges cancellation for a larger job must keep its own job stop state across preparation, reservation, and retries. It must serialize that state with reservation and avoid starting another batch after acknowledging the stop. A pre-reservation call to safe_stop() alone does not cancel a future batch.

In 0.7.6, stopping before the first next(), closing an unstarted iterator, closing its Roll, or abandoning that iterator tears down its pending held child. A reservation failure leaves the original hold available for cleanup. If child exit cannot be confirmed, another batch or device close must not silently acquire ownership. The retained DeviceBusy state is a recovery condition, not an instruction to retry in a loop. Closing from an active progress callback is also refused; request a stop and let the owning caller complete cleanup instead.

attempts_root should be an absolute caller-owned directory when evidence must survive. Without it, CoolscanPy uses temporary storage that normal close removes; uncertain cleanup and other evidence-preserving failures retain it. Keep attempt journals and raw data until a failure is understood.

Recovery

Result What the caller should do
ManualReviewRequired Review the current crop and geometry, then approve the requested slot.
FingerprintRefused / RollMismatch Do not capture using stale geometry; establish the media state and registration.
RefeedRequired The held reservation is unavailable. Treat a physical refeed as a new registration and preview again.
TransportIndexRefused / MeterControllerRefused Retain the typed failure evidence and investigate before retrying.
DeviceBusy during shutdown Keep ownership and evidence intact until the worker's state is resolved.
FeederParked / EjectFailed Establish whether film is still gripped; follow the reported physical remedy rather than automatically repeating unload.

device.film_present() returns True, False, or None. Unknown is not absent, and present is not proof that motion is safe. Device.eject() already confirms absence; a second presence probe should not turn its successful return into an error. A transport-index stall is a stop condition: preserve the attempt and do not repeatedly retry the same insertion.

For exact exception payloads and held-child teardown behavior, see exceptions and the capture process adapter.

Plain scan and diagnostic commands

With the optional SANE installation, the python-sane-shaped path returns an array directly:

import coolscanpy

with coolscanpy.open("ls5000") as device:
    print(device["resolution"].constraint)
    device.resolution = 4000
    device.depth = 16
    rgb = device.scan()
    print(rgb.shape, rgb.dtype)

Plain scan options describe that path. They do not change the color-roll engine's fixed acquisition contract. Device.cancel() is the plain-scan cancellation API; use Roll.safe_stop() for a roll batch.

Installed command entry points and diagnostic modules expose their own help:

coolscanpy-roll-scan --help
coolscanpy-practical-parity --help
python -m coolscanpy.cli.vpd_dump --help
python -m coolscanpy.cli.perforation_probe --help
python -m coolscanpy.cli.unload_timer --help
python -m coolscanpy.cli.wedge_recovery --help

coolscanpy-roll-scan is a separate plan/registration workflow, with prepare, status, approve, fine, full-negative, and forward-roll commands; it is not a CLI wrapper for the Roll example above. Its live commands require --live and NEGPY_ROLL_LIVE=YES. coolscanpy-practical-parity uses SANE and requires --live for capture; its default probe can still contact hardware. The diagnostic modules also have different motion contracts: VPD, perforation, and unload-timer probes are read-only; wedge recovery can command motion. Read their help before invoking them on a scanner.

Develop and test

The canonical release branch is port/cross-platform, even though the GitHub default branch is main. For work on this release line:

git clone --branch port/cross-platform https://github.com/rohanpandula/coolscanpy.git
cd coolscanpy
uv sync --frozen --dev --python 3.13
uv run --frozen pytest -q
uv run --frozen ruff check .

Use uv 0.11.30, as required by pyproject.toml and both CI workflows. If your installed uv is a different version, prefix the uv command with uv tool run --from uv==0.11.30; for example:

uv tool run --from uv==0.11.30 uv sync --frozen --dev --python 3.13

For a regular editable install without the locked development environment:

python -m pip install -e .

The test suite uses synthetic devices, replay fixtures, and mocked transport boundaries. Some tests need optional packages or external archived captures and skip when those inputs are absent. CI runs the full suite and Ruff on Ubuntu/macOS with Python 3.13/3.14; it does not install the optional SANE extra or validate attached hardware. The count of passing tests is not a scanner compatibility claim.

Build with the same locked backend used for publication:

uv sync --frozen --dev
uv build --no-sources --no-build-isolation --out-dir dist

Change capture-bundle hashes only after the affected regression checks pass. _roll.py is outside that manifest and does not require a bundle reseal.

Release through the existing CI

  1. Update the project version in pyproject.toml, the coolscanpy entry in uv.lock, and the changelog together. Keep runtime and distribution metadata consistent.
  2. Review the change and merge a green PR into port/cross-platform after the full tests and Ruff pass on the CI matrix.
  3. Tag the reviewed, merged release commit with the matching version and push that tag. For this release, the tag is v0.7.7.
  4. Monitor the publishing workflow, then verify both PyPI artifacts, metadata, source contents, and attestations.

Pushing a v* tag triggers publication. The workflow first requires the tag commit to be an ancestor of port/cross-platform and passes the test suite. It then checks the tag against the project version, builds with pinned uv and setuptools, and publishes through PyPI Trusted Publishing (GitHub OIDC). No local upload token is needed. A GitHub release page is not the trigger. Do not reuse or move an already-published version tag.

Downstream processing and license

Applications can consume CoolscanPy as an acquisition dependency: digital-fauxice handles infrared-guided repair, cool-colors handles color-negative rendering, and NegPy provides a desktop processing application. These are separate projects; installing CoolscanPy does not install them or assert equivalence between their rendered output and a particular scanner application.

CoolscanPy originated in a NegPy fork and is distributed independently under GPL-3.0-only.

Metadata

Release files for coolscanpy 0.7.7

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for coolscanpy 0.7.7
File Size Uploaded
coolscanpy-0.7.7.tar.gz 557.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for coolscanpy 0.7.7
File Interpreter ABI Platform
coolscanpy-0.7.7-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / coolscanpy-0.7.7.tar.gz

Download URL coolscanpy-0.7.7.tar.gz
Size 557.7 kB
Tags Source
SHA-256 checksum
How to use checksums
85b37217487b0f9dc6b301073be6dc05964774b1d749ef86231a292726e0cf4a
BLAKE2b-256 checksum
How to use checksums
fb4533bc7caa91ec8f1723047d2cb85d266eb156286075185680543d3a9188b8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.

Transparency log

Release files / coolscanpy-0.7.7-py3-none-any.whl

Download URL coolscanpy-0.7.7-py3-none-any.whl
Size 532.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7ec0415431b0827e2e729d74a4c6d5ee011fe43286b6985b92eda23ae5764e8f
BLAKE2b-256 checksum
How to use checksums
0609d949b01e92684adb0047af531efc545147deb92f83fb6e2b067ae81775f2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.9

2 release files

0.7.8

2 release files

This release

0.7.7 This release

2 release files

0.7.6

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page