Skip to content

Nikon Coolscan scanning through nkscan - #970

Draft
marcinz606 wants to merge 8 commits into
mainfrom
feat/nkscan
Draft

Nikon Coolscan scanning through nkscan#970
marcinz606 wants to merge 8 commits into
mainfrom
feat/nkscan

Conversation

@marcinz606

@marcinz606 marcinz606 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Adds a Nikon Coolscan backend built on the nkscan driver, next to the existing SANE one. It gets what coolscan3 cannot give us: a native IR pass, hardware eject, openICE dust removal, and frame rectangles measured off the film instead of guessed from a fixed pitch.

The scanner panel is now grouped (Film / Quality / Framing / Output) and the line above Scan says what pressing it will do: frame count, resolution, extra passes, estimated size. Leaving a preview dialog now stops the pass instead of leaving the unit busy until the next scan fails.

Tested on a Coolscan LS-50 with the SA-21 strip feeder: measured strips, IR, ICE (clean) full strip scans, eject

@marcinz606

Copy link
Copy Markdown
Owner Author

needs more testing

Drives a Coolscan through the nkscan Rust driver instead of SANE's coolscan3:
frame rectangles measured off the film, a native IR pass, a native eject, and
the driver's own openICE dust removal.

The strip is measured in one pass and every preview tile is cut out of that
pass, so previewing five frames takes one read of the film rather than five
scans. A boundary nudged afterwards re-frames without touching the scanner.
Frame rects and the pass they came from are cached per device and dropped on
eject.

The first native RollSession, so ScannerService.open_roll grows the branch its
docstring anticipated. An empty BatchRequest.frames now means every frame on
the film, resolved through ScannerService.detect_frames: a transport that
measures the film has no frame range to fall back on, and the hidden spinboxes
silently meant frame 1.

New controls, each gated on what the unit advertises: ICE, Samples, Superfine,
Film (color negative, B&W, slide, Kodachrome) and Film format. Film type
decides which way frame boundaries read on a strip, and disables IR and ICE on
stock whose silver or dyes stop infrared. An LS-50 reads one CCD line at a time
and ignores repeated reads, so Superfine and Samples stay hidden there; where
the bindings cannot say, the backend learns it from the first refusal.

Also fixes a pre-existing wipe: _update_device_caps checked the autofocus box
before the capability flags were refreshed, persisting every gated preference
at its unsupported value. It was already losing a saved hardware auto-exposure.
…oned

Leaving a preview dialog left the pass running: accept() and reject() never
raise a close event, so the mixin's teardown ran only on the window close
button, and the abandoned pass held the unit until the next scan came back
"Scanner busy". Teardown moves to done(), cancels the request, and Cancel reads
"Stop preview" while one runs — it abandons the pass and keeps the tiles in
hand. Use and Scan darken meanwhile; pressing either mid-pass hit the same
refusal.

Both preview dialogs get a progress bar off scan_progress, the pattern the
prescan dialog already had. A batch names the frame in its phase string, so the
sidebar reads "Frame 3 of 5 — Scanning" rather than a bare percentage.

preview_positive takes the film type: reversal stock is already a positive and
only gets the level stretch, matching services.assets.thumbnails, which decides
the same thing off the stored process mode. A slide previewed as a negative.

A successful eject drops the frame selection and the per-frame crops — they
describe the piece of film that just came out, and the next strip silently
reused them. Offset and Drift stay: they register the transport.

The panel's dozen rows become Film / Quality / Framing / Output, each header
hiding with its whole group, and the line above Scan says what pressing it
does: frame count, resolution, extra passes, estimated size. Falling out of
that: a pass the device cannot run now hides instead of sitting greyed
(disabled-with-a-reason is reserved for one the film blocks), and a saved DPI
the device does not offer snaps to the nearest stop it does — the box showed
the unreachable number while the scan used the first entry.

A measured strip's Offset spans ±2.5 mm, the same as Drift. It came from the
38 mm hardware blackout distance, which is a whole frame away and addresses the
neighbour rather than correcting a boundary. Its saved value also stopped being
floored at 0, which lost a negative offset on reopen.

The DPI box takes a validator bounded by the device range, three tooltips lose
their British spelling, and database_dialog's byte formatter moves to
kernel.system.text as human_bytes.
nkscan ships wheels now, so the local editable path goes and the group needs
no Rust toolchain.

0.9's Capabilities answers what the backend used to guess. The fast three-line
read was asked for, refused, and remembered per device — a wasted round trip on
every unit that has only the one ordering; `multi_line` says so up front, and
the learned-refusal state goes with it. `max_samples` is the unit's own ceiling
rather than a constant, `eject` decides whether the button appears, and
`framing` gates Film format: only the two mechanisms that measure the film can
be told a frame length, and a holder with its own frame table fixes it.

scan_frame gained lock_white_balance, and its binding default is wrong for
negatives: the Python signature holds the channels together while the library's
own default does not. A colour negative meters per channel, which takes the
orange mask off before the converter instead of quantising the blue record
through it, so the film type now decides it through the library's own
locks_white_balance. Its film names are NegPy's film-type values.

max_frames stays unread on purpose: mapping it to adapter_frame_capacity would
put the strip dialog on the index-addressed feeder path, and frames here come
from discovery.

The fake module follows the 0.9 capability shape, and two new tests hold it to
the real extension where that is installed — every fake field a real property,
and every film name one the binding accepts. That is the drift that made the
fork's two hand-added booleans look fine offline.
…again

Which frames a batch takes was the strip dialog's to say, so changing the
selection meant another pass over the film. A Frames box takes the list
directly — 1-6, 1,2,5, empty for every frame — and the dialog writes its picks
there, so the preview stays a way to see the film rather than the only way to
address it.

It replaces the from-to spinboxes, which only ever appeared on a feeder that
reports a slot count and left a measured strip with no frame control at all.
One box now serves both: a feeder falls back to every slot it holds, a measured
strip to every frame it finds. Text that cannot be read stops the scan and says
so above the button, rather than scanning the wrong film quietly.

The status line under Batch drops the frame list the box now owns, and keeps
the windows, offset and drift.

ScannerSettings loads through from_dict, which turns a saved frame_from/frame_to
into a selection. It also ignores a key this version dropped: the constructor
threw on one, and the whole blob then fell back to defaults, taking the output
folder, DPI and filename pattern with it.
Every scan is written to a temp file in the output folder and renamed into
place, and the temp file carried the final extension. With that folder open as
a hot folder, the watcher indexes on extension alone, so it picked the file up
mid-write, then the rename took it away: a dead tile and a Thumbnail Error in
the log for every frame of a batch.

The part file takes the .part suffix export and the sidecar writer already use,
which no loader accepts. Nothing needs to know about scanning, and the other
discovery paths are covered by the same change.
…the film

IR and ICE read the same infrared pass, and ICE bakes its repair into the
file, so a raw IR plane beside it has only cleaned pixels to find dust in.
Ticking one now unticks the other, and a saved pair comes back as ICE alone.

The film gate that greys both out on B&W and Kodachrome ran before the ICE
capability and the device's film list were read, so it judged the previous
device: switching to a Coolscan with B&W saved left IR enabled, and switching
to one with colour negative left ICE greyed out. It runs after both now.

Claude-Session: https://claude.ai/code/session_01H1QXANZKX1HEEQKu4oeipf
Both sliders were pinned to 160 px, so they took a small part of a wide
dialog while a stretch ate the rest. They carry the framing, so they now
split the free width and the Detect frames button keeps its own.

The value labels are pinned to their widest reading: a label that grows with
the value takes the width out of the slider it belongs to, and the handle
steps sideways under the cursor mid-drag.

Claude-Session: https://claude.ai/code/session_01TS769PZf9ifizfBTvFgHYx
A pass over the Scan panel and the three preview dialogs.

The commit button asked for an object name that no rule matched, so the one
control that moves the transport and writes files rendered like Browse. It is
filled now, and goes hollow while it reads Stop.

The line that says what a scan will cost was drawn in the placeholder grey,
which is under AA at that size. It carries secondary weight, with the frame
count and the byte figure in primary.

The three dialogs ended in three different sentences. Every footer now reads
reset · Cancel · Apply <object> · Scan <what>: the middle exit names what it
keeps, and Enter scans once frames are ticked rather than walking back to the
panel. Prescan gains the Scan exit it never had and loses Rescan from among
its exits, since acquisition belongs at the top with the other two dialogs.

Each surface stacked a progress bar, a status line and a summary that came
and went independently, so the button underneath moved as a scan started or
failed. StatusStrip reserves one row and shows the running pass, the message
it left, or the resting summary.

The strip dialog opened with ninety words above the tiles. One line stays;
Offset and Drift explain themselves on their own sliders, and the ⓘ opens the
rest from the user guide. Both sliders take the width the row has left, with
the reading above the groove like the panel sliders — beside it, mm/frame
clipped.

Claude-Session: https://claude.ai/code/session_01TS769PZf9ifizfBTvFgHYx
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