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
2 changes: 1 addition & 1 deletion docs/PLUSTEK_WINDOWS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Windows USB setup (Plustek OpticFilm)

NegPy's Plustek USB backend uses the external [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (libusb through PyUSB). Requires pyopticfilm **1.3.0** or later. The stock Plustek Windows driver must not own the device.
NegPy's Plustek USB backend uses the external [pyopticfilm](https://github.com/jboneng/pyopticfilm) driver (libusb through PyUSB). Requires pyopticfilm **1.3.1** or later. The stock Plustek Windows driver must not own the device.

## Requirements

Expand Down
7 changes: 6 additions & 1 deletion negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ def estimated_frame_bytes(
class ScanSidebar(QWidget):
"""Scanner control panel — replaces the originally planned modal ScanDialog."""

_INDETERMINATE_SCAN_PHASES = frozenset({"Preparing long exposure", "Merging exposures"})

def __init__(self, controller) -> None:
super().__init__()
self.controller = controller
Expand Down Expand Up @@ -1119,7 +1121,10 @@ def _on_scan(self) -> None:

@pyqtSlot(float, str)
def _on_scan_progress(self, progress: float, phase_name: str = "Scanning") -> None:
self.status_strip.set_progress(f"{phase_name}… %p%", progress)
if phase_name in self._INDETERMINATE_SCAN_PHASES:
self.status_strip.set_progress_indeterminate(f"{phase_name}…")
else:
self.status_strip.set_progress(f"{phase_name}… %p%", progress)

@pyqtSlot(str)
def _on_scan_finished(self, path: str) -> None:
Expand Down
11 changes: 11 additions & 0 deletions negpy/desktop/view/styles/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,30 @@ def showing(self) -> str:
return {self._bar: "progress", self._message: "message"}.get(self._stack.currentWidget(), "summary")

def start_progress(self, fmt: str) -> None:
self._bar.setRange(0, 100)
self._bar.setFormat(fmt)
self._bar.setValue(0)
self._running = True
self._show_current()

def set_progress_indeterminate(self, fmt: str) -> None:
self._bar.setRange(0, 0)
self._bar.setFormat(fmt)
self._running = True
self._show_current()

def set_progress(self, fmt: str, fraction: float) -> None:
if self._bar.maximum() == 0:
self._bar.setRange(0, 100)
self._bar.setFormat(fmt)
self._bar.setValue(int(max(0.0, min(1.0, fraction)) * 100))
self._running = True
self._show_current()

def stop_progress(self) -> None:
self._running = False
self._bar.setRange(0, 100)
self._bar.setValue(0)
self._show_current()

def _show_current(self) -> None:
Expand Down
52 changes: 49 additions & 3 deletions negpy/infrastructure/scanners/plustek_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,47 @@ 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:
if not multi_exposure:
return None
n_early = 2 if capture_ir else 1
return n_early, n_early + 1


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

def scan_progress(p: float) -> None:
_safe_progress(progress, 0.1 + 0.9 * p)

return scan_progress

n_early, n_pass = layout
plateau = n_early / n_pass
state = {"long_started": False}

def scan_progress(p: float) -> None:
frac = min(1.0, max(0.0, float(p)))
if frac >= 1.0 - 1e-9:
_safe_progress(progress, 0.85, "Merging exposures")
return
if frac > plateau + 1e-6:
state["long_started"] = True
_safe_progress(progress, 0.10 + 0.72 * frac, "Scanning")
elif abs(frac - plateau) < 1e-6 and not state["long_started"]:
_safe_progress(progress, 0.83, "Preparing long exposure")
else:
_safe_progress(progress, 0.10 + 0.72 * frac, "Scanning")

return scan_progress


def _validate_params(params: ScanParams, *, model: Any | None = None) -> None:
from pyopticfilm.device.model_8200i import MODEL_8200I

Expand Down Expand Up @@ -294,14 +335,18 @@ def _scan_on_scanner(
cancel=cancel,
)

def scan_progress(p: float) -> None:
_safe_progress(progress, 0.1 + 0.9 * p)
scan_progress = _make_scan_progress(
progress,
multi_exposure=multi_exposure,
capture_ir=capture_ir,
)

def on_status(status: str) -> None:
if status == "priming":
_safe_progress(progress, 0.05, "Priming")
elif status == "scanning":
_safe_progress(progress, 0.1, "Scanning")
# "prime_skipped" only when gl128_prime=False (debug); NegPy keeps default priming.

scan_area = None if geometry is not None else window
rgb_image = scanner.scan(
Expand All @@ -324,7 +369,8 @@ def on_status(status: str) -> None:
except PlustekError as exc:
raise RuntimeError(str(exc)) from exc

_safe_progress(progress, 1.0)
if not multi_exposure:
_safe_progress(progress, 1.0)
return ScanResult(
rgb=np.asarray(rgb_image.rgb),
ir=ir_plane,
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.0"]
plustek = ["pyopticfilm>=1.3.1"]
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.0",
"pyopticfilm>=1.3.1",
]
camera = [
# Tethered camera scanning. libgphoto2 has no Windows build, so the wheels — and the
Expand Down
70 changes: 67 additions & 3 deletions tests/scanners/test_plustek_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ def _patch_enum(monkeypatch, devices: list[UsbDeviceInfo] | None = None) -> None
monkeypatch.setattr(f"{_BACKEND}.list_devices", lambda: list(devices))


def _fake_scanner(*, progress_steps: int = 0, scan_error: Exception | None = None, rgb: np.ndarray | None = None):
def _fake_scanner(
*,
progress_steps: int = 0,
me_fractions: list[float] | None = None,
scan_error: Exception | None = None,
rgb: np.ndarray | None = None,
):
from pyopticfilm.device.model_8200i_se import MODEL_8200I_SE

if rgb is None:
Expand All @@ -120,8 +126,12 @@ def scan(**kwargs):
on_status("scanning")
progress = kwargs.get("progress")
if progress is not None:
for i in range(1, progress_steps + 1):
progress(i / progress_steps)
if me_fractions is not None:
for frac in me_fractions:
progress(frac)
else:
for i in range(1, progress_steps + 1):
progress(i / progress_steps)
if scan_error is not None:
raise scan_error
out_rgb = rgb.copy()
Expand Down Expand Up @@ -364,6 +374,60 @@ def progress(fraction: float, phase: str = "Scanning") -> None:
assert "Scanning" in phases[priming_i + 1 :]


def test_me_scan_reports_preparing_then_long_pass(monkeypatch):
_patch_enum(monkeypatch)
scanner = _fake_scanner(me_fractions=[0.25, 0.5, 0.5, 0.75, 1.0])
monkeypatch.setattr(f"{_BACKEND}.Scanner.open", _FakeOpen(scanner))
seen: list[tuple[float, str]] = []

def progress(fraction: float, phase: str = "Scanning") -> None:
seen.append((fraction, phase))

PlustekBackend().scan(
_DEVICE_ID,
_params(multi_exposure=True),
progress,
threading.Event(),
)
phases = [phase for _, phase in seen]
assert "Preparing long exposure" in phases
assert phases.index("Preparing long exposure") < phases.index("Merging exposures")
assert phases.count("Scanning") >= 2


def test_me_scan_reports_merging_at_completion(monkeypatch):
_patch_enum(monkeypatch)
scanner = _fake_scanner(me_fractions=[1.0])
monkeypatch.setattr(f"{_BACKEND}.Scanner.open", _FakeOpen(scanner))
phases: list[str] = []

def progress(_fraction: float, phase: str = "Scanning") -> None:
phases.append(phase)

PlustekBackend().scan(
_DEVICE_ID,
_params(multi_exposure=True),
progress,
threading.Event(),
)
assert "Merging exposures" in phases
assert "Preparing long exposure" not in phases


def test_non_me_scan_skips_me_progress_phases(monkeypatch):
_patch_enum(monkeypatch)
scanner = _fake_scanner(progress_steps=4)
monkeypatch.setattr(f"{_BACKEND}.Scanner.open", _FakeOpen(scanner))
phases: list[str] = []

def progress(_fraction: float, phase: str = "Scanning") -> None:
phases.append(phase)

PlustekBackend().scan(_DEVICE_ID, _params(), progress, threading.Event())
assert "Preparing long exposure" not in phases
assert "Merging exposures" not in phases


def test_open_applies_quiet_usb_drain(monkeypatch):
_patch_enum(monkeypatch)
scanner = _fake_scanner()
Expand Down
4 changes: 3 additions & 1 deletion tests/scanners/test_plustek_ir_alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ def test_plustek_align_zero_shift_is_near_identity():
base = _texture()
rgb = np.stack([base, base, base], axis=-1)
aligned = align_ir_to_rgb(rgb, base)
np.testing.assert_allclose(aligned, base, atol=1e-5)
# Sub-pixel phase noise can still run the warp path; border fill only touches edge strips.
sl = (slice(8, -8), slice(8, -8))
np.testing.assert_allclose(aligned[sl], base[sl], atol=1e-5)


def test_plustek_align_recovers_vertical_only_offset():
Expand Down
9 changes: 9 additions & 0 deletions tests/test_scan_sidebar.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,15 @@ def _fake_warning(parent, title, text, *args, **kwargs):
assert popped == [("Scan failed", msg)]


def test_indeterminate_scan_phase_uses_busy_progress_bar() -> None:
sidebar, _ = _sidebar(SE_DEVICE, settings={"backend": "plustek"})
sidebar._on_scan_progress(0.85, "Merging exposures")
bar = sidebar.status_strip._bar
assert bar.minimum() == 0
assert bar.maximum() == 0
assert bar.format() == "Merging exposures…"


# ── nkscan-only controls ──────────────────────────────────────────────────


Expand Down
11 changes: 6 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading