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 @@ -880,7 +880,7 @@ Capture film directly into NegPy. Two collapsible sections:

* **Scanner**: drive a film scanner. Choose a **Backend**: **SANE** (Linux/macOS; Coolscans and other SANE devices) or **pyOpticfilm (Plustek)** (OpticFilm 8200i SE and 8100 V2; Windows, macOS and Linux). Common controls are device selection, DPI, IR channel, frame range (roll feeders), scan window, output format, folder and filename template. **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 Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. 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 Scan at the chosen DPI (a hardware ROI, not a software crop). **Multi-exposure** (8200i SE, 8100 V2; off by default) merges short and long colour passes for more highlight and shadow detail; it takes longer than a normal scan. Scans from pyopticfilm 1.1.2 onward match SilverFast orientation; rescans older files if left-right matters. When **Multi-exposure** is on, a **Passes** counter appears (2–9) asking how many exposure pairs to stack: **2** is the classic short+long pair, and a higher count repeats it N/2 times for greater shadow/highlight SNR at the cost of scan time. The multi-pass counter requires the pyopticfilm release that supports `Scanner.scan(passes=…)`; on earlier builds the control is shown but a scan with N>2 is rejected with a clear message rather than silently downgrading.

With **IR** checked, colour and infrared come back in one scan pass; pyopticfilm aligns the IR plane to the colour 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
31 changes: 29 additions & 2 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ def _init_ui(self) -> None:
self.me_check = QCheckBox("Multi-exposure")
self.me_check.setToolTip("Merge short and long colour passes for more highlight and shadow detail. Takes longer.")

self.passes_spin = QSpinBox()
self.passes_spin.setRange(2, 9)
self.passes_spin.setToolTip(
"How many exposure pairs to stack for multi-pass (2 = classic short+long). Requires pyopticfilm multi-pass support."
)
# Pre-seed from persisted settings so any early _update_settings_from_ui
# (e.g. an autofocus toggle firing during device setup) keeps the count.
self.passes_spin.setValue(self._settings.multi_pass)
self.passes_spin.setVisible(False)

self.depth_row_widget = QWidget()
depth_row = QHBoxLayout(self.depth_row_widget)
depth_row.setContentsMargins(0, 0, 0, 0)
Expand All @@ -145,6 +155,7 @@ def _init_ui(self) -> None:
depth_row.addWidget(self.depth_combo, 1)
depth_row.addWidget(self.ir_check)
depth_row.addWidget(self.me_check)
depth_row.addWidget(self.passes_spin)
self.depth_label = QLabel("Depth")
self.form.addRow(self.depth_label, self.depth_row_widget)
self.depth_combo.setVisible(False)
Expand Down Expand Up @@ -301,7 +312,8 @@ def _connect_signals(self) -> None:
self.dpi_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui())
self.depth_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui())
self.ir_check.toggled.connect(lambda: self._update_settings_from_ui())
self.me_check.toggled.connect(lambda: self._update_settings_from_ui())
self.me_check.toggled.connect(lambda: self._on_me_toggled())
self.passes_spin.valueChanged.connect(lambda _v: self._update_settings_from_ui())
self.autofocus_check.toggled.connect(lambda: self._update_settings_from_ui())
self.ae_check.toggled.connect(lambda: self._on_ae_toggled())
self.exposure_slider.valueChanged.connect(self._on_exposure_changed)
Expand Down Expand Up @@ -412,6 +424,8 @@ def _update_device_caps(self) -> None:
self.depth_label.setVisible(False)
self.ir_check.setEnabled(False)
self.me_check.setEnabled(False)
self.passes_spin.setVisible(False)
self.passes_spin.setEnabled(False)
self.eject_btn.setVisible(False)
self.frame_range_label.setVisible(False)
self.frame_range_widget.setVisible(False)
Expand Down Expand Up @@ -496,14 +510,19 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.ir_check.setChecked(False)
self.ir_check.setToolTip("IR scanning not supported by this device")

# Multi-exposure (Plustek SE only today)
# Multi-exposure (Plustek SE / 8100 V2), the multi-pass activation,
# plus the Passes count spin that only shows on an ME-capable device.
self.me_check.setEnabled(caps.multi_exposure)
self.passes_spin.setVisible(caps.multi_exposure)
if caps.multi_exposure:
self.me_check.setChecked(self._settings.multi_exposure)
self.me_check.setToolTip("Merge short and long colour passes for more highlight and shadow detail. Takes longer.")
self.passes_spin.setValue(self._settings.multi_pass)
self.passes_spin.setEnabled(self.me_check.isChecked())
else:
self.me_check.setChecked(False)
self.me_check.setToolTip("Multi-exposure not supported by this device")
self.passes_spin.setVisible(False)

# Autofocus and auto-exposure, shown only when the device reports them.
self._caps_autofocus = bool(caps.autofocus)
Expand Down Expand Up @@ -596,6 +615,11 @@ def _on_ae_toggled(self) -> None:
self.exposure_slider.setEnabled(not self.ae_check.isChecked())
self._update_settings_from_ui()

def _on_me_toggled(self) -> None:
# Multi-exposure is the multi-pass activation: enable/disable the Passes count.
self.passes_spin.setEnabled(self.me_check.isEnabled() and self.me_check.isChecked())
self._update_settings_from_ui()

def _on_exposure_changed(self, _value: int) -> None:
self._update_exposure_value_label()
self._update_settings_from_ui()
Expand Down Expand Up @@ -761,6 +785,7 @@ def _on_scan(self) -> None:
depth = int(self.depth_combo.currentData() or 16)
capture_ir = self.ir_check.isEnabled() and self.ir_check.isChecked()
multi_exposure = self.me_check.isEnabled() and self.me_check.isChecked()
multi_pass = self._settings.multi_pass if multi_exposure else None
autofocus = self._caps_autofocus and self.autofocus_check.isChecked()
auto_exposure = self._caps_auto_exposure and self.ae_check.isChecked()
pattern = self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}'
Expand All @@ -779,6 +804,7 @@ def _on_scan(self) -> None:
depth=depth,
capture_ir=capture_ir,
multi_exposure=multi_exposure,
passes=multi_pass,
autofocus=autofocus,
auto_exposure=auto_exposure,
exposure_time_us=exposure_time_us,
Expand Down Expand Up @@ -912,6 +938,7 @@ def _update_settings_from_ui(self) -> None:
depth=depth,
capture_ir=self.ir_check.isChecked() and self.ir_check.isEnabled(),
multi_exposure=self.me_check.isChecked() and self.me_check.isEnabled(),
multi_pass=self.passes_spin.value(),
autofocus=self._caps_autofocus and self.autofocus_check.isChecked(),
auto_exposure=self._caps_auto_exposure and self.ae_check.isChecked(),
exposure_time_us=(self.exposure_slider.value() if self.exposure_row_widget.isVisible() else None),
Expand Down
10 changes: 6 additions & 4 deletions negpy/desktop/view/widgets/prescan_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from negpy.desktop.view.widgets.scan_window_label import ScanWindowLabel
from negpy.desktop.workers.scan_worker import PrescanRequest
from negpy.infrastructure.scanners.base import ScannerDevice
from negpy.infrastructure.scanners.params import clamp_scan_area
from negpy.infrastructure.scanners.params import crop_to_scan_window
from negpy.infrastructure.scanners.result import ScanResult


Expand Down Expand Up @@ -53,8 +53,10 @@ def __init__(
super().__init__(parent)
self._controller = controller
self._device = device
caps = device.capabilities
# TA / backend space (what ScanParams.window stores).
self._scan_window: tuple[float, float, float, float] | None = initial_window
self._prescan_mirror_x = bool(caps.prescan_mirror_x)
self._busy = False

self.setWindowTitle("Prescan — set crop")
Expand Down Expand Up @@ -152,7 +154,7 @@ def _on_prescan_ready(self, result: object) -> None:
if default_crop is not None:
self._scan_window = default_crop
if self._scan_window is not None:
image_rect = clamp_scan_area(self._scan_window)
image_rect = crop_to_scan_window(self._scan_window, mirror_x=self._prescan_mirror_x)
self._label.set_window(image_rect)
self._ok_btn.setEnabled(True)
self._status.setText("Drag the rectangle to set the scan crop")
Expand All @@ -177,7 +179,7 @@ def _on_window_changed(self, rect: object) -> None:
if rect is None:
self._scan_window = None
return
self._scan_window = clamp_scan_area(tuple(rect)) # type: ignore[arg-type]
self._scan_window = crop_to_scan_window(tuple(rect), mirror_x=self._prescan_mirror_x) # type: ignore[arg-type]

def _on_clear_crop(self) -> None:
self._scan_window = None
Expand All @@ -197,7 +199,7 @@ def accept(self) -> None:
# Sync from widget in case the last drag did not emit.
rect = self._label.window()
if rect is not None:
self._scan_window = clamp_scan_area(rect)
self._scan_window = crop_to_scan_window(rect, mirror_x=self._prescan_mirror_x)
self._disconnect_controller()
super().accept()

Expand Down
2 changes: 2 additions & 0 deletions negpy/infrastructure/scanners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class ScannerCapabilities:
#: Low-DPI full-window preview then interactive crop (Plustek SE).
prescan: bool = False
prescan_dpi: int = 0
#: Left–right mirrored sensor (sensor order flipped in pyopticfilm's assemble()).
prescan_mirror_x: bool = False
prescan_default_crop: tuple[float, float, float, float] | None = None
multi_exposure: bool = False
adapter_frame_capacity: int | None = None # transport capacity bound, not an exposure count
Expand Down
22 changes: 22 additions & 0 deletions negpy/infrastructure/scanners/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class ScanParams:
depth: int
capture_ir: bool
multi_exposure: bool = False
# Multi-pass (N) bracketing (pyopticfilm with multi-pass support). None =
# classic 2-pass ME when multi_exposure=True. passes >= 3 reproduces the
# short/long pair N/2 times for SNR. exposures is an explicit per-pass
# exposure list and takes precedence over passes.
passes: int | None = None
exposures: tuple[int, ...] | None = None
# 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 Expand Up @@ -50,6 +56,22 @@ def clamp_scan_area(area: ScanArea) -> ScanArea:
return (x1, y1, x2, y2)


def crop_to_scan_window(crop: ScanArea, *, mirror_x: bool) -> ScanArea:
"""Map Prescan widget coords ↔ TA ``area`` for ``ScanParams.window``.

Self-inverse when ``mirror_x`` is fixed: image-left is sensor-right on mirrored
scanners, so trimming left chrome on the Prescan must crop the opposite TA side.

Mirrors the pre-#958 behaviour that PR #958 accidentally flattened to a plain
clamp, which displaced Prescan crops on mirror_x Plustek devices.
"""
area = clamp_scan_area(crop)
if mirror_x:
x1, y1, x2, y2 = area
return (1.0 - x2, y1, 1.0 - x1, y2)
return area


def clamp_frame_offset_mm(offset_mm: float, pitch_mm: float) -> float:
"""Effective feed-axis offset, floored at 0 and held short of one frame pitch.

Expand Down
34 changes: 32 additions & 2 deletions negpy/infrastructure/scanners/plustek_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@
logger = get_logger(__name__)


def _scan_supports_multi_pass(scanner: "Scanner") -> bool:
"""True when the installed pyopticfilm's Scanner.scan accepts the `passes` kwarg."""
try:
import inspect

return "passes" in inspect.signature(scanner.scan).parameters
except (TypeError, ValueError):
return False


def _ensure_quiet_usb_drain(scanner: Scanner) -> None:
asic = scanner.asic
if hasattr(asic, "image_usb_pace_s"):
Expand All @@ -57,6 +67,7 @@ def _caps_for(model: Any) -> ScannerCapabilities:
autofocus=False,
prescan=prescan_ready,
prescan_dpi=PRESCAN_DPI if prescan_ready else 0,
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)),
adapter_frame_capacity=None,
Expand Down Expand Up @@ -114,6 +125,12 @@ def _validate_params(params: ScanParams, *, model: Any | None = None) -> None:
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):
raise RuntimeError(f"{getattr(model, 'model', 'device')} does not support multi-exposure")
if params.passes is not None and params.passes < 2:
raise RuntimeError(f"Invalid multi-pass N={params.passes}; N must be >= 2")
if params.exposures and len(params.exposures) < 2:
raise RuntimeError("Multi-pass exposures must contain at least 2 values")
if (params.passes is not None or params.exposures is not None) and model is not None and not getattr(model, "exposure_long", None):
raise RuntimeError(f"{getattr(model, 'model', 'device')} does not support multi-pass")


class PlustekSession:
Expand Down Expand Up @@ -297,16 +314,29 @@ def scan_progress(p: float) -> None:
_safe_progress(progress, 0.1 + 0.9 * p)

scan_area = None if geometry is not None else window
rgb_image = scanner.scan(
multi_pass = params.passes is not None or params.exposures is not None
scan_kwargs: dict[str, Any] = dict(
resolution=dpi,
mode="color",
area=scan_area,
geometry=geometry,
progress=scan_progress,
cancel=cancel,
multi_exposure=multi_exposure,
infrared=capture_ir,
)
if multi_pass:
if not _scan_supports_multi_pass(scanner):
raise RuntimeError(
f"Multi-pass (N={params.passes or 'exposures'}) requires a pyopticfilm "
"build that supports Scanner.scan(passes=...); please update pyopticfilm."
)
scan_kwargs["passes"] = params.passes
if params.exposures is not None:
scan_kwargs["exposures"] = params.exposures
else:
# Classic 2-pass multi-exposure, backported to any pyopticfilm release.
scan_kwargs["multi_exposure"] = multi_exposure
rgb_image = scanner.scan(**scan_kwargs)
ir_plane = np.asarray(rgb_image.ir) if capture_ir and rgb_image.ir is not None else None
except ScanCancelled as exc:
raise RuntimeError("Scan cancelled") from exc
Expand Down
2 changes: 2 additions & 0 deletions negpy/infrastructure/scanners/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class ScannerSettings:
depth: int = 16
capture_ir: bool = False
multi_exposure: bool = False
# Multi-pass count (pyopticfilm multi-pass release). 2 = classic short/long pair.
multi_pass: int = 2
autofocus: bool = True
auto_exposure: bool = False
# Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). None is the
Expand Down
23 changes: 22 additions & 1 deletion tests/scanners/test_params.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Tests for ScanParams dataclass and ScanMode validation."""

import pytest
from negpy.infrastructure.scanners.params import ScanMode, ScanParams, clamp_scan_area
from negpy.infrastructure.scanners.params import (
ScanMode,
ScanParams,
clamp_scan_area,
crop_to_scan_window,
)
from negpy.infrastructure.scanners.base import ScannerCapabilities


Expand Down Expand Up @@ -72,3 +77,19 @@ def test_dpi_range_from_caps(self) -> None:
class TestPrescanGeometry:
def test_clamp_scan_area_enforces_positive_extent(self) -> None:
assert clamp_scan_area((0.5, 0.5, 0.5, 0.5)) == (0.5, 0.5, 0.501, 0.501)

def test_crop_to_scan_window_reflects_x_when_mirrored(self) -> None:
"""Mirror_x scanners: display-left is sensor-right, so x must be reflected.

Regression for PR #958, which flattened this conversion to a plain clamp and
displaced Prescan crops on mirror_x Plustek devices (8200i SE / 8100 V2).
"""
assert crop_to_scan_window((0.1, 0.2, 0.4, 0.8), mirror_x=True) == pytest.approx((0.6, 0.2, 0.9, 0.8))

def test_crop_to_scan_window_passthrough_when_not_mirrored(self) -> None:
assert crop_to_scan_window((0.1, 0.2, 0.4, 0.8), mirror_x=False) == pytest.approx((0.1, 0.2, 0.4, 0.8))

def test_crop_to_scan_window_is_self_inverse(self) -> None:
crop = (0.1, 0.2, 0.4, 0.8)
reflected = crop_to_scan_window(crop, mirror_x=True)
assert crop_to_scan_window(reflected, mirror_x=True) == pytest.approx(crop)
Loading