Conversation
…ge UI Port the Nikon Coolscan wire grammar from nkscan (LS-5000/LS-9000 spec, identical to the LS-50) into a new coolscanpy protocol package so the LS-50 ED can be driven over raw USB without SANE: - protocol/ls50/capture.py: session + RGBI capture (RESERVE, MODE_SELECT, SET_BOUNDARY, SET_WINDOW x4, SCAN with RGBI mask, chunked image READ). The READ uses DTC/DTQ (data type code + qualifier) in bytes 2/4-5 per the spec and treats a short read / sense 05-2C (ILI, OutOfSequence) as the normal end of the image stream -- this replaces the per-line READs that desynchronized the LS-50. - protocol/ls50/artifacts.py: write raw linear 16-bit RGB + -ir.tif sidecar + receipt (settingsFingerprint 4000:16:1:rgbi). - protocol/ls50/workflow.py: Ls50Roll -- preview (per-slot streaming, end of strip detection), set_spacing_offset (frame adjust), scan_many. - protocol/ls50/pass_runner.py: CLI for the calibration passes. - tests/protocol/ls50/: hardware-free coverage of the geometry, read CDB grammar, and end-of-strip preview logic. Bridge wiring: - CoolscanPyTransport.preview streams LS-50 thumbnails as they capture (the engine's 600s stream-silence deadline is never hit), and preview_stop() lets the operator finish a preview early. - service.py: roll.previewStop RPC. App: - ContentView: 'Done Previews' button while a preview is in progress. Live-verified against a physical LS-50 ED with a color-negative strip: preview completes with roll.previewComplete and full-res RGBI captures are correct raw negatives.
Five review blockers from the LS-50 PR review are resolved in the working tree, plus the last open hardware bug (preview looping 1..N, N..1): - bridge: define _is_ls50_roll (was NameError in preview_stop routing) - Device.roll(): select Ls50Roll for recognized-but-unverified models so the bridge drives the physical LS-50 instead of the LS-5000 replay engine - pass_runner: rewrite to roll.preview()/scan_many() and write the decoded arrays (also fix missing numpy import that would NameError on --preview, drop the orphaned _synthetic_frame helper) - workflow: clear the stale stop flag before a fresh scan_many (a SafeStopRequested on the very next scan), persist set_spacing_offset into preview.frames, and apply the operator offset to the capture window - workflow: end the preview transport home-loop. Past the last real frame the LS-50 homes back to frame 1 and rescans it (no blank/error slot), so the near-black detector never fired and the pass looped forever. A slot that is near pixel-identical to an already-captured slot now discards the duplicate and stops the pass (new tests for the detector and the loop). coolscanpy + bridge suites pass.
…om installed app
The Sept-9 live session's fixes existed only in the installed app, not the
repo (part of the 'incomplete transfer' Rohan flagged). Port the LS-50-
relevant subset into the standalone package, keeping both suites green:
- _device.py: enumerate via pyusb FIRST (SANE after). SANE's coolscan3 ids
carry SEPARATE bus/address numbering from pyusb's, so a SANE-first id sent
to the capture subprocess could never match the physically attached LS-50
('0 recognized Nikon Coolscan devices'). Also thread allow_unverified
through _sane_model_string_and_supported + the ScannerService/SaneBackend
factory chain so an opted-in LS-50 is not refused by the SANE identity
gate, and add Device.scan()'s direct-USB RGB path for the LS-50.
- bridge: _device_info_from_coolscanpy now honors the coolscan3 id prefix
for unverified-hardware opt-in, and the LS-50 preview streams thumbnails
per-slot via on_thumbnail so a long strip never hits the engine's 600s
stream-silence deadline.
- ls50/__main__.py: one-shot CLI capture -> calibration artifacts.
Deliberately NOT ported: the app's LS-5000 held-preview/metering drift
(_roll schema v4 + live-USB topology probe, solve_exposure, meter-refusal
skips, ExposureSolution) -- that subsystem's own live-edited tests are not
green and it is outside the LS-50 PR scope; reconcile separately.
coolscanpy + bridge suites pass.
set_spacing_offset previously returned a half-height row sliver (h//2), which is what made "Move Left" zoom/crop the preview instead of shifting it. It now re-renders the full slot height at the shifted offset with blank padding at the raster edges, matching the LS-5000 Roll.set_spacing_offset behavior. Also wires the operator-facing "Done Previews" stop end to end (Swift finishPreviewsEarly -> scanner.previewStop RPC -> engine preview_stop() on the backend trait -> bridge roll.previewStop -> Ls50Roll.preview_stop()), so a preview walking the LS-50's blind 40-slot list can be stopped by the operator before it seeks past the real last frame and the transport auto-ejects the strip. coolscanpy ls50 suite (incl. 3 new crop-fix tests), bridge suite, and engine end_to_end_sim all green.
Live 2026-09-11 fixes from testing the physical LS-50 end to end. Eject / frame-count: - Acquire Previews sheet now takes an optional 'Frames on this roll/strip'. The LS-50 driver cannot safely read its own real frame count (the 0x8f read risks a wedge), and blind-walking up to 40 slots could seek past the true last frame, which the transport answers by auto-ejecting the strip. The operator-supplied count bounds the walk (SessionModel.knownFrameCount -> AcquireThumbnailsParams.frames -> engine forwards roll.preview slots -> preview() iterates 1..N only). - Done Previews button moved to RollLoadingWorkspaceView (the view actually shown during acquisition; the gate view was never visible then), wired to the existing scanner.previewStop path. - preview()/scan_many() now treat a clean-but-short/empty read (film ejected mid-seek) as end-of-strip via a typed guard instead of a raw ValueError that crashed the whole preview. Silent scan-evidence bugs (root-caused via temporary engine debug log): - Ls50FrameReceiptMinimal.fresh_fingerprint_sha256: None serialized to JSON null but the engine's BridgeScanReceipt requires a String, so scan.frameCompleted failed Rust deserialization and every frame silently vanished from evidence. Now a real 64-char value. - clipping.fractions empty tuple -> BridgeClippingTelemetry requires a (f64,f64,f64); now (0.0,0.0,0.0). Same silent-loss failure. - Receipt storage_transform now declared (swapaxes01 parity v2) so the derivative renderer renders archive masters instead of refusing. Orientation: - Preview tiles mirror via flipud for the LS-50 400dpi path (operator confirmed plain swap gives a mirrored preview; 4000dpi capture path is already correct without it). set_spacing_offset re-render path matches. Includes 2 new ls50 tests (short-read end-of-strip, empty-read guard). coolscanpy + bridge suites green; engine end_to_end_sim green.
LS-50 review and implementation handoff
This is a self-contained Markdown handoff you can paste into an implementation LLM. Please verify each finding against the branch before editing, fix the root causes, and report the tests and remaining hardware uncertainties. Reviewed this PR at The existing suites passing is useful, but the boundary reproductions below expose gaps. I recommend addressing these before merging. Keep fixes small, reuse the existing transaction/artifact machinery where its contract fits, and preserve LS-5000 behavior. Do not make tests green by replacing unknown measurements with plausible constants or by disabling identity checks. Findings and fix direction1. P2 — USB-first discovery breaks the existing LS-5000 plain scanIn #123, Discovery change, plain scan dispatch. 2. P2 — The new READ loop does not satisfy its transport contractBoth PRs reuse 3. P1 — Streaming thumbnails does not prevent the preview timeout#123 adds per-frame thumbnail events to avoid the 600-second timeout, but the existing New streaming callback, fixed deadline. 4. P1 — Requested multisampling is silently discardedThe bridge passes 5. P1 — A stop request can be cleared after it was accepted
6. P1 — Repeat captures overwrite earlier outputsIn #123, 7. P1 — Receipts report measurements that were never madeThe receipt supplies zero clipping, an out-of-vocabulary focus verdict The zero receipt hashes are placeholders, but this review has not established that those fields currently enforce a film-identity check. Separately, the preview fingerprint Receipt defaults and telemetry. 8. P1 — LS-50 eject still selects an LS-5000The new roll has no eject method, so the bridge falls back to Device integration, adapter connection helper. 9. P2 — The pass CLI cannot perform its default invocationIn #123, omitting optional 10. P2 — CLI controls and output metadata disagree with captureThe new single-frame CLI parses exposure arguments but constructs Single-frame options, pass writer. 11. P2 — Temporary scan-event logging is unbounded
Suggested implementation order and acceptance checks
Checks already run on the reviewed ScanStudio snapshot
Runnable Python defect reproductionsSave the following as PYTHONPATH="$PWD/coolscanpy/src:$PWD/bridge/src" python review_reproduce.pyThese assertions confirm current defects. They are diagnostic probes, not desired-behavior regression tests. After fixing a defect, invert/replace its assertion with the intended behavior. The endpoint doubles describe input cases; they do not establish which byte sequence a real scanner emits at EOF. The repeat-output test bypasses the separately broken CLI preview prerequisite to isolate overwrite behavior. The single-frame probe records Python reproductions (copy the complete block)"""Hardware-free reproductions for ScanStudio PR 123 at 768ddcd.
Run with the PR's coolscanpy/src and bridge/src on PYTHONPATH.
Assertions confirm the reported defects, rather than claiming correct behavior.
"""
import contextlib
import io
import json
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import tifffile
from coolscanpy.protocol.ls50 import capture, pass_runner
from coolscanpy.protocol.ls50 import __main__ as one_frame
from coolscanpy.protocol.ls50.workflow import Ls50Roll
from coolscanpy.protocol.ls5000_single_pass.worker import DesynchronizedProtocolError
class FakeSession:
def __enter__(self): return self
def __exit__(self, *args): pass
def open(self): pass
def close(self): pass
def capture(self, opt, **kwargs): return bytes([80]) * opt.stream_bytes
def run():
findings = {}
class EndpointOut:
def __init__(self): self.writes = []
def write(self, data, **kwargs):
self.writes.append(data)
return len(data)
class EndpointIn:
def __init__(self, responses): self.responses = iter(responses)
def read(self, size, **kwargs): return next(self.responses)
opt = capture.Ls50ScanOptions(dpi=4000, x_max=1, y_max=0)
session = capture.Ls50Session()
session._ep_out = EndpointOut()
session._ep_in = EndpointIn([b'\x03', bytes(opt.stream_bytes - 10), b''])
try:
session._read_lines(opt)
except DesynchronizedProtocolError as exc:
assert b'\x06' not in session._ep_out.writes
findings['short_read'] = str(exc)
else:
raise AssertionError('short-read reproduction changed')
session._ep_in = EndpointIn([b'\x03', bytes(opt.stream_bytes), bytes.fromhex('0203110000000000')])
assert len(session._read_lines(opt)) == opt.stream_bytes
findings['read_error_ignored'] = 'sense 031100 accepted with a full payload'
with tempfile.TemporaryDirectory() as directory:
argv = ['pass_runner', '--stock', 'test', '--pass', 'A1', '--out', directory, '--frames', '1']
with patch.object(pass_runner, 'Ls50Session', FakeSession), patch.object(sys, 'argv', argv):
try:
pass_runner.main()
except RuntimeError as exc:
assert str(exc) == 'slot 1 produced no capture'
findings['cli_without_preview'] = str(exc)
else:
raise AssertionError('CLI reproduction changed')
roll = Ls50Roll(session=FakeSession())
roll.preview(slots=[1])
roll.preview_stop()
class InspectCapture(Exception): pass
def inspect_capture(opt, **kwargs):
findings['actual_samples_per_scan'] = opt.samples_per_scan
raise InspectCapture
roll.session.capture = inspect_capture
try: next(roll.scan_many([1], samples_per_scan=4))
except InspectCapture: pass
assert findings['actual_samples_per_scan'] == 1
iterator = roll.scan_many([1])
roll.safe_stop()
try: next(iterator)
except InspectCapture: findings['cancel_before_iteration'] = 'capture still entered'
assert findings['cancel_before_iteration'] == 'capture still entered'
class StopBeforeUSB(FakeSession):
def capture(self, opt, **kwargs):
findings['single_cli_actual'] = {'y_min': opt.y_min, 'exposure_r': opt.exposure_r_raw_10ns}
raise InspectCapture
with tempfile.TemporaryDirectory() as directory:
argv = ['ls50', '--out', directory, '--stem', 'frame5', '--frame', '5', '--exp-r', '2000000']
with patch.object(one_frame, 'Ls50Session', StopBeforeUSB), patch.object(sys, 'argv', argv):
try: one_frame.main()
except InspectCapture: pass
assert findings['single_cli_actual'] == {'y_min': 0, 'exposure_r': 1000000}
class FakeRoll:
calls = []
def __init__(self, **kwargs): pass
def close(self): pass
def scan_many(self, slots, **kwargs):
self.calls.append(slots)
yield SimpleNamespace(slot=slots[0], rgb=np.full((2, 2, 3), len(self.calls), np.uint16), ir=np.ones((2, 2), np.uint16))
with tempfile.TemporaryDirectory() as directory:
argv = ['pass_runner', '--stock', 'test', '--pass', 'Arep02', '--page-frame', '7', '--out', directory]
with patch.object(pass_runner, 'Ls50Session', FakeSession), patch.object(pass_runner, 'Ls50Roll', FakeRoll), patch.object(sys, 'argv', argv):
pass_runner.main()
files = sorted(p.name for p in (Path(directory) / 'test' / 'coolscan').iterdir())
assert FakeRoll.calls == [[20], [20]]
assert files == ['pass-summary.txt', 'test_20_Arep02-ir.tif', 'test_20_Arep02.tif']
assert tifffile.imread(Path(directory) / 'test/coolscan/test_20_Arep02.tif')[0, 0, 0] == 2
findings['repeat_outputs'] = {'slots': FakeRoll.calls, 'files': files, 'last_capture_overwrote_first': True}
from coolscanpy.transport import adapter_status
calls = []
with patch('coolscanpy.protocol.ls5000_single_pass.worker._connect_device', side_effect=lambda **kwargs: calls.append(kwargs)):
adapter_status._connect_device(device_id='usb:1:2')
assert 'expected_scanner_product' not in calls[0]
findings['presence_and_eject_selector'] = 'uses default LS-5000 ED rather than selected LS-50 ED'
return findings
if __name__ == '__main__':
with contextlib.redirect_stdout(io.StringIO()):
result = run()
print(json.dumps(result, indent=2))Existing LS-5000 plain-scan regression reproductionRun with the same environment. This uses actual discovery, facade, service and SANE scan code, replacing enumeration and initialization only. Expected current error: from types import SimpleNamespace
from unittest.mock import patch
from coolscanpy import _device
from coolscanpy.types import DeviceInfo, Capabilities
from coolscanpy.session.service import ScannerService
from coolscanpy.transport.sane import SaneBackend
info = DeviceInfo('usb:1:2', 'Nikon', 'LS-5000 ED',
Capabilities(True, (4000,), (16,), True, 40, True, True, True, True))
backend = SaneBackend.__new__(SaneBackend)
backend._sane = SimpleNamespace(get_devices=lambda: [
('coolscan3:usb:libusb:001:002', 'Nikon', 'LS-5000 ED', 'scanner')])
service = ScannerService()
service._backend = backend
with patch.object(_device, '_usb_fallback_device_infos', return_value=[info]), \
patch.object(backend, '_ensure_initialized'):
selected = _device.get_devices()[0]
try:
_device.Device(selected, service).scan()
except RuntimeError as error:
assert 'disappeared from fresh SANE' in str(error)
print(error)
else:
raise AssertionError('Expected current identity mismatch')Rust preview-deadline reproductionIn an isolated checkout, add a test-only delay inside the existing if std::env::var_os("REVIEW_PREVIEW_TICK").is_some() {
std::thread::sleep(std::time::Duration::from_millis(60));
}Save the following as use std::{sync::{Arc,mpsc},time::Duration};
use scanstudio_engine::{domain::{ScannerBackend,FilmProcess},protocol::ConnectOptions,real_backend::RealLs5000};
#[test]
fn streamed_thumbnails_do_not_extend_the_current_deadline() {
let backend=Arc::new(RealLs5000::new_with_env(env!("CARGO_BIN_EXE_mock_bridge"),Duration::from_secs(3),&[("REVIEW_PREVIEW_TICK","1"),("MOCK_BRIDGE_PREVIEW_DELAY_MS","0")]).unwrap().with_preview_silence_deadline(Duration::from_millis(150)));
backend.connect("bridge-ls5000-0",&ConnectOptions::default()).unwrap();
let (tx,rx)=mpsc::channel();
RealLs5000::acquire_thumbnails(&backend,None,FilmProcess::default(),Some("review".into()),tx).unwrap();
let mut delivered=0; let mut stalled=false;
loop {let value:serde_json::Value=serde_json::from_str(&rx.recv_timeout(Duration::from_secs(3)).unwrap()).unwrap();
if value["event"]=="scanner.thumbnail" {delivered+=1;}
if value["event"]=="scanner.thumbnailsFailed" {stalled=value["payload"]["code"]=="BRIDGE_STREAM_STALLED";}
if value["event"]=="scanner.thumbnailsComplete" {break;}
}
assert_eq!(delivered,2); assert!(stalled,"expected reproduction of absolute deadline despite 60ms heartbeat");
std::thread::sleep(Duration::from_millis(150));
}Please finish with a finding-by-finding disposition: fixed with code/test reference, already resolved with evidence, or still blocked on a specific hardware observation. Do not treat passing the old suites alone as closure. |
Summary
Adds direct-USB capture support for the Nikon LS-50 ED (Coolscan V) so it can be driven over raw USB (no SANE), producing the calibration workflow's artifacts.
What's included
coolscanpy (protocol/ls50):
capture.py: session + RGBI capture. The image READ carries DTC (data type code) in byte 2 and DTQ (color<<8|element-width) in bytes 4-5 per the Nikon spec, reads in whole-granule chunks, and treats a short read / sense 05-2C (ILI, OutOfSequence) as the normal end of the image stream (fixes the LS-50 desync).artifacts.py: raw linear 16-bit RGB TIFF +-ir.tifsidecar + receipt (4000:16:1:rgbi).workflow.py: Ls50Roll — preview (per-slot streaming, end-of-strip detection), set_spacing_offset (frame adjust), scan_many.pass_runner.py: CLI for the calibration passes.tests/protocol/ls50/.bridge:
CoolscanPyTransport.previewstreams LS-50 thumbnails as they capture (avoids the engine's 600s stream-silence deadline).preview_stop()+roll.previewStopRPC so the operator can finish a preview early.app:
Verification
Live-tested against a physical LS-50 ED with a color-negative strip in an SA-30 adapter: preview completes with
roll.previewCompleteand end-of-strip detection; full-res RGBI captures are correct raw negatives.The wire grammar is adapted from nkscan (activexray/nkscan, MIT/Apache-2.0), which documents the LS-5000/LS-9000 ED protocol and lists the LS-50 as supported.