diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 4d533d80..b752fce6 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -10,6 +10,7 @@ QLineEdit, QProgressBar, QPushButton, + QSlider, QSpinBox, QVBoxLayout, QWidget, @@ -138,6 +139,25 @@ def _init_ui(self) -> None: self.ae_check.setToolTip("Meter exposure in hardware before the scan") self.form.addRow(self.ae_check) + # Scan exposure time (SANE `scan-exposure-time`), shown only when the + # device reports a usable range. Slider in microseconds; label shows + # the value in a human-readable form. + self.exposure_row_widget = QWidget() + exposure_layout = QHBoxLayout(self.exposure_row_widget) + exposure_layout.setContentsMargins(0, 0, 0, 0) + exposure_layout.setSpacing(6) + self.exposure_slider = QSlider(Qt.Orientation.Horizontal) + self.exposure_slider.setSingleStep(1) + self.exposure_slider.setToolTip("Scan exposure time (microseconds)") + self.exposure_value_label = QLabel() + self.exposure_value_label.setMinimumWidth(64) + exposure_layout.addWidget(self.exposure_slider, 1) + exposure_layout.addWidget(self.exposure_value_label) + self.exposure_label = QLabel("Exposure") + self.form.addRow(self.exposure_label, self.exposure_row_widget) + self.exposure_label.setVisible(False) + self.exposure_row_widget.setVisible(False) + # Frame range (roll/strip feeders only — shown when a live capacity is known). self.frame_range_widget = QWidget() frame_row = QHBoxLayout(self.frame_range_widget) @@ -241,6 +261,7 @@ def _connect_signals(self) -> None: self.ir_check.toggled.connect(lambda: self._update_settings_from_ui()) self.autofocus_check.toggled.connect(lambda: self._update_settings_from_ui()) self.ae_check.toggled.connect(lambda: self._update_settings_from_ui()) + self.exposure_slider.valueChanged.connect(self._on_exposure_changed) self.frame_from_spin.valueChanged.connect(self._on_frame_from_changed) self.frame_to_spin.valueChanged.connect(self._on_frame_to_changed) self.scan_window_btn.clicked.connect(self._on_set_scan_window) @@ -346,6 +367,8 @@ def _update_device_caps(self) -> None: self.eject_btn.setVisible(False) self.frame_range_label.setVisible(False) self.frame_range_widget.setVisible(False) + self.exposure_label.setVisible(False) + self.exposure_row_widget.setVisible(False) return caps = device.capabilities @@ -417,6 +440,26 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.ae_check.setChecked(False) self.ae_check.setToolTip("Auto-exposure not supported by this device") + # Scan exposure time — shown only when the device reports a usable range. + self.exposure_slider.blockSignals(True) + et_range = caps.exposure_time_us + if et_range is not None: + lo_us, hi_us = et_range + self.exposure_slider.setRange(int(lo_us), int(hi_us)) + current = self._settings.exposure_time_us + if current is None or current < lo_us or current > hi_us: + current = lo_us + self.exposure_slider.setValue(int(current)) + self.exposure_label.setVisible(True) + self.exposure_row_widget.setVisible(True) + else: + self.exposure_slider.setRange(0, 1) + self.exposure_slider.setValue(0) + self.exposure_label.setVisible(False) + self.exposure_row_widget.setVisible(False) + self.exposure_slider.blockSignals(False) + self._update_exposure_value_label() + # Frame range — only a roll/strip feeder reporting a live capacity capacity = caps.adapter_frame_capacity has_frames = capacity is not None @@ -448,6 +491,19 @@ def _populate_form(self, caps: ScannerCapabilities) -> None: self.frame_from_spin.blockSignals(False) self.frame_to_spin.blockSignals(False) + def _on_exposure_changed(self, _value: int) -> None: + self._update_exposure_value_label() + self._update_settings_from_ui() + + def _update_exposure_value_label(self) -> None: + us = self.exposure_slider.value() + if us >= 1_000_000: + self.exposure_value_label.setText(f"{us / 1_000_000:.2f} s") + elif us >= 1_000: + self.exposure_value_label.setText(f"{us / 1_000:.1f} ms") + else: + self.exposure_value_label.setText(f"{us} us") + def _on_frame_from_changed(self, _value: int) -> None: if self.frame_to_spin.value() < self.frame_from_spin.value(): self.frame_to_spin.setValue(self.frame_from_spin.value()) @@ -554,12 +610,18 @@ def _on_scan(self) -> None: frames, frame_windows, base_window = resolve_batch_selection( self._settings, self.frame_from_spin.value(), self.frame_to_spin.value() ) + exposure_time_us = ( + self._settings.exposure_time_us + if self._settings.exposure_time_us is not None and self.exposure_row_widget.isVisible() + else None + ) base_params = ScanParams( dpi=dpi, depth=depth, capture_ir=capture_ir, autofocus=autofocus, auto_exposure=auto_exposure, + exposure_time_us=exposure_time_us, window=base_window, frame_offset_mm=self._settings.frame_offset_mm, ) @@ -685,6 +747,7 @@ def _update_settings_from_ui(self) -> None: capture_ir=self.ir_check.isChecked() and self.ir_check.isEnabled(), autofocus=self.autofocus_check.isChecked(), auto_exposure=self.ae_check.isChecked() and self.ae_check.isEnabled(), + exposure_time_us=(self.exposure_slider.value() if self.exposure_row_widget.isVisible() else None), frame_from=self.frame_from_spin.value(), frame_to=self.frame_to_spin.value(), output_folder=self.folder_edit.text().strip(), diff --git a/negpy/infrastructure/scanners/base.py b/negpy/infrastructure/scanners/base.py index fbea4051..a9294215 100644 --- a/negpy/infrastructure/scanners/base.py +++ b/negpy/infrastructure/scanners/base.py @@ -33,6 +33,7 @@ class ScannerCapabilities: adapter_frame_control: bool = False can_eject: bool = False frame_pitch_mm: float = 0.0 # feed-axis distance between frame positions; 0.0 = unknown + exposure_time_us: tuple[int, int] | None = None # (min, max) in microseconds @dataclass(frozen=True) diff --git a/negpy/infrastructure/scanners/params.py b/negpy/infrastructure/scanners/params.py index bb0de0e6..107cce99 100644 --- a/negpy/infrastructure/scanners/params.py +++ b/negpy/infrastructure/scanners/params.py @@ -25,6 +25,10 @@ class ScanParams: # Hardware auto-exposure (SANE `ae`), distinct from NegPy's rendering # auto-exposure. An explicit request fails if the option is unavailable. auto_exposure: bool = False + # Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). + # None = let the scanner use its default; ignored when the device has no + # such option. + exposure_time_us: int | None = None MIN_FRAME_EXTENT_MM = 1.0 # below this a capped scan is a useless sliver diff --git a/negpy/infrastructure/scanners/sane_backend.py b/negpy/infrastructure/scanners/sane_backend.py index c631f6f0..f569575f 100644 --- a/negpy/infrastructure/scanners/sane_backend.py +++ b/negpy/infrastructure/scanners/sane_backend.py @@ -306,6 +306,14 @@ def _find_ir_option(opt) -> str | None: return None +def _find_scan_exposure_time_option(opt) -> str | None: + """Return the device's `scan-exposure-time` option key, if present.""" + for key in opt: + if str(key).lower().replace("-", "_") == "scan_exposure_time": + return str(key) + return None + + def _find_eject_option(opt) -> str | None: """Return the device's vendor eject/unload action option, if any.""" for key in opt: @@ -407,6 +415,42 @@ def _detect_auto_exposure(opt) -> bool: return _has_usable_option(opt, "ae") +def _detect_scan_exposure_time(opt) -> tuple[int, int] | None: + """Return (min_us, max_us) when the device has a usable ``scan-exposure-time`` option. + + python-sane may expose it as ``scan_exposure_time`` or ``scan-exposure-time``; + handles both conventions. Values are in microseconds — genesys reports plain ints, + other backends that use SANE_FIXED carry the same numeric range when accessed via + python-sane. + """ + name: str | None = None + for key in opt: + if str(key).lower().replace("-", "_") == "scan_exposure_time": + name = str(key) + break + if not name or name not in opt: + return None + option = opt[name] + if not _option_is_usable(option): + return None + constraint = getattr(option, "constraint", None) + + values: list[float | int] | None = None + if isinstance(constraint, tuple) and len(constraint) >= 2: + values = [constraint[0], constraint[1]] + elif isinstance(constraint, list) and len(constraint) == 2: + values = [constraint[0], constraint[1]] + if values is not None: + try: + lo_ = int(float(values[0])) + hi_ = int(float(values[1])) + if 0 < lo_ <= hi_ and hi_ < 1_200_000_000: + return (lo_, hi_) + except (ValueError, TypeError, OverflowError): + return None + return None + + def _detect_eject(opt) -> bool: """True when the device exposes a usable eject/unload action.""" option_name = _find_eject_option(opt) @@ -478,6 +522,7 @@ def _caps_from_options(opt, device_id: str = "") -> ScannerCapabilities: adapter_frame_control=_detect_adapter_frame_control(opt), can_eject=_detect_eject(opt), frame_pitch_mm=_feed_pitch_mm(opt), + exposure_time_us=_detect_scan_exposure_time(opt), ) @@ -902,6 +947,17 @@ def _scan_on_device( except Exception as e: raise RuntimeError(f"Could not enable auto-exposure: {e}") from e + # Manual scan exposure time (SANE `scan-exposure-time`). Applied only + # when the device exposes the option; a device without it ignores the + # request silently so a saved setting never breaks a different scanner. + if params.exposure_time_us is not None: + _et_name = _find_scan_exposure_time_option(option_map) + if _et_name is not None: + try: + setattr(dev, _et_name, int(params.exposure_time_us)) + except Exception as e: + raise RuntimeError(f"Could not set scan-exposure-time={params.exposure_time_us}: {e}") from e + # Inline IR via a boolean option (coolscan3 `infrared`): the 4th # sample rides in the same frame, no mode/source change. IR was # explicitly requested — fail loud rather than silently drop it. diff --git a/negpy/infrastructure/scanners/settings.py b/negpy/infrastructure/scanners/settings.py index 93eb6840..345e1710 100644 --- a/negpy/infrastructure/scanners/settings.py +++ b/negpy/infrastructure/scanners/settings.py @@ -14,6 +14,9 @@ class ScannerSettings: capture_ir: bool = False autofocus: bool = True auto_exposure: bool = False + # Hardware scan exposure time in microseconds (SANE `scan-exposure-time`). + # None = scanner default. Only meaningful when the device exposes the option. + exposure_time_us: int | None = None frame_from: int = 1 frame_to: int = 1 output_folder: str = "" diff --git a/tests/scanners/test_capabilities.py b/tests/scanners/test_capabilities.py index 8b9dc571..f4933501 100644 --- a/tests/scanners/test_capabilities.py +++ b/tests/scanners/test_capabilities.py @@ -283,3 +283,47 @@ def test_splits_four_channels(self) -> None: assert ir.shape == (2, 3) assert np.array_equal(rgb, arr[:, :, :3]) assert np.array_equal(ir, arr[:, :, 3]) + + +class TestScanExposureTimeCapability: + """Detection of the SANE `scan-exposure-time` option (e.g. genesys).""" + + def test_range_tuple_detected(self) -> None: + from negpy.infrastructure.scanners.sane_backend import _detect_scan_exposure_time + + opt = {"scan_exposure_time": FakeOption(constraint=(11000, 65535, 1))} + assert _detect_scan_exposure_time(opt) == (11000, 65535) + + def test_hyphenated_key_detected(self) -> None: + from negpy.infrastructure.scanners.sane_backend import _detect_scan_exposure_time + + opt = {"scan-exposure-time": FakeOption(constraint=(11000, 65535, 1))} + assert _detect_scan_exposure_time(opt) == (11000, 65535) + + def test_absent_returns_none(self) -> None: + from negpy.infrastructure.scanners.sane_backend import _detect_scan_exposure_time + + assert _detect_scan_exposure_time({"source": FakeOption()}) is None + + def test_caps_from_options_wires_exposure_time(self) -> None: + caps = _caps_from_options( + { + "source": FakeOption(constraint=["Negative", "Positive", "Transparency"]), + "resolution": FakeOption(constraint=[300, 600, 1200, 2400, 3600]), + "depth": FakeOption(constraint=[8, 16]), + "scan_exposure_time": FakeOption(constraint=(11000, 65535, 1)), + }, + "genesys:libusb:003:005", + ) + assert caps.exposure_time_us == (11000, 65535) + + def test_caps_from_options_defaults_exposure_time_none(self) -> None: + caps = _caps_from_options( + { + "source": FakeOption(constraint=["Negative", "Positive", "Transparency"]), + "resolution": FakeOption(constraint=[300, 600, 1200, 2400, 3600]), + "depth": FakeOption(constraint=[8, 16]), + }, + "plustek:libusb:001:008", + ) + assert caps.exposure_time_us is None