Skip to content
Merged
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
63 changes: 63 additions & 0 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
QLineEdit,
QProgressBar,
QPushButton,
QSlider,
QSpinBox,
QVBoxLayout,
QWidget,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions negpy/infrastructure/scanners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions negpy/infrastructure/scanners/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions negpy/infrastructure/scanners/sane_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
)


Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions negpy/infrastructure/scanners/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
44 changes: 44 additions & 0 deletions tests/scanners/test_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading