Skip to content
Draft
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 negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ class AppController(QObject):
scan_backend_requested = pyqtSignal(str)
scan_requested = pyqtSignal(ScanRequest)
scan_devices_ready = pyqtSignal(list)
scan_progress = pyqtSignal(float)
scan_progress = pyqtSignal(float, str) # progress, phase name
scan_finished = pyqtSignal(str)
scan_error = pyqtSignal(str)
scan_started = pyqtSignal()
Expand Down
8 changes: 2 additions & 6 deletions negpy/desktop/view/sidebar/right_panel.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import sys
from typing import Any, Dict

import numpy as np
Expand Down Expand Up @@ -99,12 +98,9 @@ def wrap_scroll(widget: QWidget) -> QScrollArea:
self.metadata_sidebar = MetadataSidebar(self.controller)
self.history_panel = HistoryPanel(self.controller)

from negpy.desktop.view.sidebar.scan import ScanSidebar, _ScanUnsupportedPlaceholder
from negpy.desktop.view.sidebar.scan import ScanSidebar

if sys.platform == "win32":
self.scan_sidebar = _ScanUnsupportedPlaceholder()
else:
self.scan_sidebar = ScanSidebar(self.controller)
self.scan_sidebar = ScanSidebar(self.controller)

from negpy.desktop.view.sidebar.scanlight import ScanlightSidebar

Expand Down
13 changes: 10 additions & 3 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ def _connect_signals(self) -> None:
self.depth_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui())
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.ae_check.toggled.connect(lambda: self._on_ae_toggled())
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)
Expand Down Expand Up @@ -378,6 +378,8 @@ def _update_device_caps(self) -> None:
self.eject_btn.setVisible(caps.can_eject)
self.eject_btn.setEnabled(caps.can_eject and not self._scanning)
self.frame_label.setText(f"Frame: {caps.max_area_mm[0]:.0f} × {caps.max_area_mm[1]:.0f} mm")
self.autofocus_check.setChecked(caps.autofocus)
self.autofocus_check.setVisible(caps.autofocus)

# If no film sources, show banner
if not caps.sources:
Expand Down Expand Up @@ -491,6 +493,10 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.frame_from_spin.blockSignals(False)
self.frame_to_spin.blockSignals(False)

def _on_ae_toggled(self) -> None:
self.exposure_slider.setEnabled(not self.ae_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 @@ -658,9 +664,10 @@ def _on_scan(self) -> None:
self.set_scanning(False)
self.status_label.setText(f"Scanner busy: {e}")

@pyqtSlot(float)
def _on_scan_progress(self, progress: float) -> None:
@pyqtSlot(float, str)
def _on_scan_progress(self, progress: float, phase_name: str = 'Scanning') -> None:
self.progress_bar.setVisible(True)
self.progress_bar.setFormat(f"{phase_name}… %p%")
self.progress_bar.setValue(int(progress * 100))

@pyqtSlot(str)
Expand Down
10 changes: 6 additions & 4 deletions negpy/desktop/workers/scan_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class ScanWorker(QObject):
"""Background worker for scanner operations. Mirrors RenderWorker pattern."""

devices_ready = pyqtSignal(list) # list[ScannerDevice]
progress = pyqtSignal(float) # 0.0..1.0
progress = pyqtSignal(float, str) # 0.0..1.0, phase name
finished = pyqtSignal(str) # output rgb file path
frame_done = pyqtSignal(int, str) # batch: frame number, rgb file path
batch_finished = pyqtSignal(list) # batch: all written rgb paths (also on stop/error)
Expand Down Expand Up @@ -128,7 +128,9 @@ def run_scan(self, req: ScanRequest) -> None:
result = service.run_scan(
device_id=req.device_id,
params=req.params,
progress=self.progress.emit,
# A one-phase backend calls progress(fraction), which a
# two-argument signal's emit rejects on its own.
progress=lambda fraction, phase="Scanning": self.progress.emit(fraction, phase),
cancel=self._cancel_event,
)
except Exception as error:
Expand Down Expand Up @@ -204,8 +206,8 @@ def run_batch(self, req: BatchRequest) -> None:
frame_params = dataclasses.replace(req.params, frame=frame, window=window, frame_offset_mm=offset)
base = index / total

def _progress(fraction: float, _base: float = base) -> None:
self.progress.emit(_base + min(1.0, max(0.0, fraction)) / total)
def _progress(fraction: float, phase: str = "Scanning", _base: float = base) -> None:
self.progress.emit(_base + min(1.0, max(0.0, fraction)) / total, phase)

try:
result = service.run_scan(req.device_id, frame_params, _progress, self._cancel_event)
Expand Down
10 changes: 8 additions & 2 deletions negpy/infrastructure/scanners/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class ScannerCapabilities:
supported_depths: tuple[int, ...]
sources: tuple[ScanMode, ...]
max_area_mm: tuple[float, float] # (width, height)
autofocus: bool = True
auto_exposure: bool = False
adapter_frame_capacity: int | None = None # transport capacity bound, not an exposure count
adapter_frame_control: bool = False
Expand Down Expand Up @@ -56,7 +57,7 @@ class ScannerSession(Protocol):
def scan(
self,
params: ScanParams,
progress: Callable[[float], None],
progress: Callable[[float, str], None],
cancel: threading.Event,
) -> ScanResult: ...
def eject(self) -> bool: ...
Expand All @@ -74,6 +75,11 @@ class ScannerBackend(Protocol):
scanner with no selectable source must still populate it or it never appears.
- `scan` raises `TransientScanError` for retryable transport failures and a plain
exception for everything else — that choice is the backend's alone.
- `scan` reports progress as `progress(fraction)`, or `progress(fraction, phase)`
when it has more than one phase to distinguish. The fraction is relative to
the phase, not the scan, so a backend reporting several rewinds to 0.0 at each
one and the label is what makes that legible. A caller supplying `progress`
must therefore accept the phase as optional, defaulting it to "Scanning".
- `eject` returns False for a device with no eject action; it raises only when a
present eject genuinely fails.
- The constructor raises `ScannerUnavailable` when the driver is missing, with an
Expand All @@ -89,7 +95,7 @@ def scan(
self,
device_id: str,
params: ScanParams,
progress: Callable[[float], None],
progress: Callable[[float, str], None],
cancel: threading.Event,
) -> ScanResult: ...
def open_session(self, device_id: str) -> ScannerSession: ...
Expand Down
2 changes: 1 addition & 1 deletion negpy/infrastructure/scanners/per_frame_roll.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def preview(self, slots: Iterable[int], *, cancel: threading.Event) -> Iterator[
frame=slot,
)
try:
result = self._backend.scan(self._device.id, params, lambda _fraction: None, cancel)
result = self._backend.scan(self._device.id, params, lambda _fraction, _phase="": None, cancel)
except Exception as error:
if cancel.is_set():
return
Expand Down
Loading