Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,7 @@ Capture film directly into NegPy. Two collapsible sections.

Drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices), **Nikon Coolscan (nkscan)** (a direct driver for Nikon Coolscans on Linux, Windows and macOS) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Controls are grouped in the order you decide them: **Film** (what is on the film), **Quality** (resolution, depth, extra passes), **Framing** (which frames, and the window) and and **Output** (format, folder, filename template). A group's header disappears with the whole group when the device has nothing in it. **Frames** takes the frames to scan as a list: `1-6`, `1,2,5`, or empty for every frame on the film. The strip preview writes its picks there, so a selection can be changed without previewing again. The line above **Scan** says what pressing it will do: how many frames, at what resolution, which extra passes and roughly how much disk it takes. **Depth** appears only when the device offers more than one bit depth, so it is hidden for the OpticFilm 8200i SE, which is 16-bit only. **Autofocus** and hardware **Auto-exposure** appear only when the connected device reports them, so typically on Coolscans and not on the OpticFilm 8200i SE. **Prescan** appears for devices that support a low-DPI full-window preview, such as the OpticFilm 8200i SE: run the preview, drag a crop rectangle, and the next Scan uses that hardware ROI. When the scanner exposes a `scan-exposure-time` option, as some genesys devices do, an **Exposure** slider appears; set it to override the scanner's default exposure time, and the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.

**pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then leave with **Apply Crop** or **Scan Frame**. Either way the next scan reads that hardware ROI at the chosen DPI, not a software crop. **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long color passes for more highlight and shadow detail; the long pass exposure is chosen per frame, and the scan takes longer than a normal pass. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters.
**pyOpticfilm (Plustek)** notes: the **OpticFilm 8200i SE** (`07b3:1825`) and the **8100 V2** (`07b3:1824`) are scan-ready. Other OpticFilm models may appear in the device list but cannot scan until pyopticfilm marks them ready; on Linux and macOS, switch Backend to **SANE** if that backend lists the scanner. Use **Prescan** to grab a 1200 dpi full-window preview, set a crop, then leave with **Apply Crop** or **Scan Frame**. Either way the next scan reads that hardware ROI at the chosen DPI, not a software crop. **Scan mode** (8200i SE, 8100 V2; Single-Pass by default) chooses among **Single-Pass** (one exposure), **Multi-Pass** (repeats the exposure and stacks the results to reduce noise, with a **Passes** slider from 2 to 9), **Adaptive Multi-Exposure** (fuses a short and long exposure for extended dynamic range) and **Adaptive Multi-Pass** (both together); every mode past Single-Pass takes longer, and Multi-Pass cannot combine with IR. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters.

With **IR** checked, color and infrared come back in one scan pass; pyopticfilm aligns the IR plane to the color frame. Color scans apply ASIC shading measured at home before the film feed, the same order as SilverFast, so the strip may stay loaded. The table is cached per DPI, so later scans only re-upload it.

Expand Down
375 changes: 298 additions & 77 deletions negpy/desktop/view/sidebar/scan.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions negpy/infrastructure/scanners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class ScannerCapabilities:
prescan_mirror_x: bool = False
prescan_default_crop: tuple[float, float, float, float] | None = None
multi_exposure: bool = False
#: Highest n_passes the device accepts; 1 means Multi-Pass (same-exposure repeat stacking)
#: is unavailable. Independent of `multi_exposure` — repeating a single exposure needs no
#: long-exposure capability, so this is not gated on the same condition.
max_n_passes: int = 1
adapter_frame_capacity: int | None = None # transport capacity bound, not an exposure count
adapter_frame_control: bool = False
can_eject: bool = False
Expand Down
31 changes: 30 additions & 1 deletion negpy/infrastructure/scanners/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,41 @@ class ScanMode(StrEnum):
TRANSPARENCY = "Transparency"


class MultiExposureMode(StrEnum):
"""Whether a short+long colour pass pair is merged for extended dynamic range.

OFF: one exposure, the fast path. ADAPTIVE: short+long merged, long exposure picked per
frame from image content — pyopticfilm's only multi-exposure behavior.

Orthogonal to ``ScanParams.n_passes``: this picks *whether* to merge a second exposure,
while ``n_passes`` repeats whichever exposure(s) are chosen for a same-exposure SNR stack —
the two compose (pyopticfilm's "Adaptive Multi-Pass") rather than being alternatives.
"""

OFF = "off"
ADAPTIVE = "adaptive"


#: Repeats of the same exposure to stack for an SNR gain (pyopticfilm's `n_passes`); 1 = no
#: stacking. This module must not import pyopticfilm directly (only plustek_backend.py may,
#: see test_only_adapter_imports_plustek_driver), so this mirrors — rather than imports —
#: pyopticfilm's own `Scanner.scan()` bound; plustek_backend.py's own MAX_N_PASSES import
#: keeps the two in sync at the one place that already reaches into pyopticfilm.
MIN_N_PASSES = 1
MAX_N_PASSES = 9
#: Starting point when a user first turns Passes above the off position — comfortably past
#: the floor without defaulting to the slow end.
DEFAULT_N_PASSES = 3


@dataclass(frozen=True)
class ScanParams:
dpi: int
depth: int
capture_ir: bool
multi_exposure: bool = False
multi_exposure_mode: MultiExposureMode = MultiExposureMode.OFF
# Same-exposure repeats to stack for an SNR gain (1-9); independent of multi_exposure_mode.
n_passes: int = MIN_N_PASSES
# Normalized (x1,y1,x2,y2) window 0..1; backend maps to device units (coolscan3 int px).
window: tuple[float, float, float, float] | None = None
# coolscan3 `subframe` (mm), applied to every frame. 0 = scanner default.
Expand Down
47 changes: 39 additions & 8 deletions negpy/infrastructure/scanners/plustek_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
ScannerUnavailable,
TransientScanError,
)
from negpy.infrastructure.scanners.params import ScanMode, ScanParams
from negpy.infrastructure.scanners.params import (
MAX_N_PASSES,
MIN_N_PASSES,
MultiExposureMode,
ScanMode,
ScanParams,
)
from pyopticfilm.asic.gl128 import DEFAULT_IMAGE_USB_PACE_S
from pyopticfilm.device.select import model_for_device, model_is_scan_ready
from pyopticfilm.exceptions import (
Expand Down Expand Up @@ -60,6 +66,9 @@ def _caps_for(model: Any) -> ScannerCapabilities:
prescan_mirror_x=bool(getattr(model, "mirror_x", False)) if prescan_ready else False,
prescan_default_crop=default_frame_crop_norm(model) if prescan_ready else None,
multi_exposure=bool(getattr(model, "scan_ready", False) and getattr(model, "exposure_long", None)),
# Multi-Pass (repeating the existing single exposure) needs no long-exposure register —
# every scan-ready GL128 model supports it, independent of ME's exposure_long gating.
max_n_passes=MAX_N_PASSES if getattr(model, "scan_ready", False) else MIN_N_PASSES,
adapter_frame_capacity=None,
adapter_frame_control=False,
can_eject=False,
Expand Down Expand Up @@ -89,20 +98,28 @@ def _safe_progress(
progress(max(0.0, min(1.0, float(value))), phase)


def _gl128_me_pass_layout(*, capture_ir: bool, multi_exposure: bool) -> tuple[int, int] | None:
def _gl128_me_pass_layout(*, capture_ir: bool, multi_exposure: bool, n_passes: int = 1) -> tuple[int, int] | None:
"""(n_early, n_pass): physical passes before, and total physical passes across, the single
short→long exposure-change boundary. Every repeat within a slot shares one exposure — no
pyopticfilm-side "preparing" moment between repeats — so there is exactly one boundary
regardless of ``n_passes``: all short-slot repeats (+ the optional IR pass) happen first,
then all long-slot repeats. Collapses to the pre-Multi-Pass ``(2,3)``/``(1,2)`` layout at
``n_passes=1``."""
if not multi_exposure:
return None
n_early = 2 if capture_ir else 1
return n_early, n_early + 1
ir_extra = 1 if capture_ir else 0
n_early = ir_extra + n_passes
return n_early, n_early + n_passes


def _make_scan_progress(
progress: Callable[[float, str], None] | None,
*,
multi_exposure: bool,
capture_ir: bool,
n_passes: int = 1,
) -> Callable[[float], None]:
layout = _gl128_me_pass_layout(capture_ir=capture_ir, multi_exposure=multi_exposure)
layout = _gl128_me_pass_layout(capture_ir=capture_ir, multi_exposure=multi_exposure, n_passes=n_passes)
if layout is None:

def scan_progress(p: float) -> None:
Expand Down Expand Up @@ -154,8 +171,18 @@ def _validate_params(params: ScanParams, *, model: Any | None = None) -> None:
raise RuntimeError("Autofocus requested but the device has no autofocus option")
if params.capture_ir and model is not None and getattr(model, "supports_infrared", None) is False:
raise RuntimeError(f"{getattr(model, 'model', 'device')} does not support infrared")
if params.multi_exposure and model is not None and not getattr(model, "exposure_long", None):
mode = params.multi_exposure_mode
if mode != MultiExposureMode.OFF and model is not None and not getattr(model, "exposure_long", None):
raise RuntimeError(f"{getattr(model, 'model', 'device')} does not support multi-exposure")
if not (MIN_N_PASSES <= params.n_passes <= MAX_N_PASSES):
raise RuntimeError(f"n_passes={params.n_passes} out of range ({MIN_N_PASSES}-{MAX_N_PASSES})")
if params.n_passes > 1:
if model is not None and not getattr(model, "scan_ready", False):
raise RuntimeError(f"{getattr(model, 'model', 'device')} does not support Multi-Pass")
if params.capture_ir:
raise RuntimeError(
"IR and Multi-Pass cannot be combined yet — scan IR separately, or set Passes to 1."
)


class PlustekSession:
Expand Down Expand Up @@ -317,7 +344,9 @@ def _scan_on_scanner(
_validate_params(params, model=scanner.model)
dpi = int(params.dpi)
capture_ir = bool(params.capture_ir)
multi_exposure = bool(params.multi_exposure)
me_mode = params.multi_exposure_mode
multi_exposure = me_mode != MultiExposureMode.OFF
n_passes = int(params.n_passes)
window = params.window
geometry = self._default_scan_geometry(scanner, dpi=dpi, window=window)

Expand All @@ -339,6 +368,7 @@ def _scan_on_scanner(
progress,
multi_exposure=multi_exposure,
capture_ir=capture_ir,
n_passes=n_passes,
)

def on_status(status: str) -> None:
Expand All @@ -359,7 +389,8 @@ def on_status(status: str) -> None:
on_status=on_status,
multi_exposure=multi_exposure,
infrared=capture_ir,
me_exposure_mode="adaptive",
align_passes=True,
n_passes=n_passes,
)
ir_plane = np.asarray(rgb_image.ir) if capture_ir and rgb_image.ir is not None else None
except ScanCancelled as exc:
Expand Down
16 changes: 15 additions & 1 deletion negpy/infrastructure/scanners/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections.abc import Iterable
from dataclasses import dataclass, field, fields

from negpy.infrastructure.scanners.params import MAX_N_PASSES, MIN_N_PASSES, MultiExposureMode
from negpy.infrastructure.scanners.registry import DEFAULT_BACKEND_ID

Rect = tuple[float, float, float, float]
Expand All @@ -16,7 +17,9 @@ class ScannerSettings:
dpi: int = 3600
depth: int = 16
capture_ir: bool = False
multi_exposure: bool = False
multi_exposure_mode: str = MultiExposureMode.OFF.value
# Same-exposure repeats to stack for an SNR gain (1-9); independent of multi_exposure_mode.
n_passes: int = MIN_N_PASSES
autofocus: bool = True
auto_exposure: bool = False
# Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). None is the
Expand Down Expand Up @@ -68,6 +71,17 @@ def from_dict(cls, data: dict) -> "ScannerSettings":
every unrelated preference with it.
"""
data = dict(data)
# Pre-mode blobs only ever had one multi-exposure behavior (today's "adaptive"): a
# checked box meant exactly that, unchecked meant none.
if "multi_exposure_mode" not in data and "multi_exposure" in data:
data["multi_exposure_mode"] = (
MultiExposureMode.ADAPTIVE.value if data.pop("multi_exposure") else MultiExposureMode.OFF.value
)
if data.get("multi_exposure_mode") not in set(MultiExposureMode):
data["multi_exposure_mode"] = MultiExposureMode.OFF.value
n_passes = data.get("n_passes")
if isinstance(n_passes, int) and not (MIN_N_PASSES <= n_passes <= MAX_N_PASSES):
data["n_passes"] = min(max(n_passes, MIN_N_PASSES), MAX_N_PASSES)
first, last = data.pop("frame_from", None), data.pop("frame_to", None)
if not data.get("selected_frames") and isinstance(first, int) and isinstance(last, int) and (first, last) != (1, 1):
data["selected_frames"] = tuple(range(first, last + 1))
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ classifiers = [

[project.optional-dependencies]
nkscan = ["nkscan>=0.9"]
plustek = ["pyopticfilm>=1.3.3"]
plustek = ["pyopticfilm>=1.3.4"]
sane = ["python-sane>=2.9"]
camera = ["gphoto2>=2.5 ; sys_platform != 'win32'"]

Expand All @@ -57,7 +57,7 @@ sane = [
"python-sane>=2.9",
]
plustek = [
"pyopticfilm>=1.3.3",
"pyopticfilm>=1.3.4",
]
camera = [
# Tethered camera scanning. libgphoto2 has no Windows build, so the wheels — and the
Expand Down
Loading
Loading