diff --git a/docs/PLUSTEK_WINDOWS.md b/docs/PLUSTEK_WINDOWS.md index f3ac0453..58692dc8 100644 --- a/docs/PLUSTEK_WINDOWS.md +++ b/docs/PLUSTEK_WINDOWS.md @@ -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 diff --git a/negpy/desktop/view/sidebar/scan.py b/negpy/desktop/view/sidebar/scan.py index 8472f14d..47f955d4 100644 --- a/negpy/desktop/view/sidebar/scan.py +++ b/negpy/desktop/view/sidebar/scan.py @@ -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 @@ -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: diff --git a/negpy/desktop/view/styles/templates.py b/negpy/desktop/view/styles/templates.py index 0f5c9853..478adb67 100644 --- a/negpy/desktop/view/styles/templates.py +++ b/negpy/desktop/view/styles/templates.py @@ -170,12 +170,21 @@ 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 @@ -183,6 +192,8 @@ def set_progress(self, fmt: str, fraction: float) -> None: 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: diff --git a/negpy/infrastructure/scanners/plustek_backend.py b/negpy/infrastructure/scanners/plustek_backend.py index 9759d135..3bf0a3cc 100644 --- a/negpy/infrastructure/scanners/plustek_backend.py +++ b/negpy/infrastructure/scanners/plustek_backend.py @@ -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 @@ -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( @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 0ea20c07..a83fa35a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'"] @@ -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 diff --git a/tests/scanners/test_plustek_backend.py b/tests/scanners/test_plustek_backend.py index ee97eb26..b7922aa0 100644 --- a/tests/scanners/test_plustek_backend.py +++ b/tests/scanners/test_plustek_backend.py @@ -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: @@ -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() @@ -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() diff --git a/tests/scanners/test_plustek_ir_alignment.py b/tests/scanners/test_plustek_ir_alignment.py index 30f69fea..9dc59ac6 100644 --- a/tests/scanners/test_plustek_ir_alignment.py +++ b/tests/scanners/test_plustek_ir_alignment.py @@ -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(): diff --git a/tests/test_scan_sidebar.py b/tests/test_scan_sidebar.py index 97ed8b82..4c1d738e 100644 --- a/tests/test_scan_sidebar.py +++ b/tests/test_scan_sidebar.py @@ -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 ────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 4107e1d2..f1c4800b 100644 --- a/uv.lock +++ b/uv.lock @@ -388,7 +388,7 @@ requires-dist = [ { name = "opencv-python-headless", specifier = "==4.13.0.92" }, { name = "piexif", specifier = "==1.1.3" }, { name = "pillow", specifier = "==12.2.0" }, - { name = "pyopticfilm", marker = "extra == 'plustek'", specifier = ">=1.3.0" }, + { name = "pyopticfilm", marker = "extra == 'plustek'", specifier = ">=1.3.1" }, { name = "pyqt6", specifier = "==6.11.0" }, { name = "pyqt6-charts", specifier = "==6.11.0" }, { name = "pyserial", specifier = ">=3.5" }, @@ -411,7 +411,7 @@ dev = [ ] nkscan = [{ name = "nkscan", specifier = ">=0.9" }] pieusb = [{ name = "pieusb", specifier = ">=0.3.7" }] -plustek = [{ name = "pyopticfilm", specifier = ">=1.3.0" }] +plustek = [{ name = "pyopticfilm", specifier = ">=1.3.1" }] sane = [{ name = "python-sane", specifier = ">=2.9" }] [[package]] @@ -420,6 +420,7 @@ version = "0.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/4d/8b02d8e3a7ce1604f9d3b1ae94e31fac5850b7daaa484a81b2f8f78ab6e9/nkscan-0.9.0.tar.gz", hash = "sha256:b38d89afc0d6bfe3473294db587f58fa9fe8a9c6a9017561105a633d8cd355a8", size = 2804597, upload-time = "2026-08-24T01:28:37.411Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/c3/29574ec5e7dea0aac7f9fa65e4573711f0f0099bd8843146973fb09d151b/nkscan-0.9.0-cp313-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0cf978009af4f0ca2c59aa97926ba8e9d7291bf57fdef0247c9224c5587520d", size = 743459, upload-time = "2026-08-26T00:41:14.069Z" }, { url = "https://files.pythonhosted.org/packages/b6/50/483d48da107ac514b8fad9ca5caff4f1d20dc048f2a4e4822259d49c5ae2/nkscan-0.9.0-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f7be405c2b601cdf942f4e87e8b16eb2a6702ae5c10dc145947c4e4cdaf7a49", size = 735425, upload-time = "2026-08-24T01:28:32.919Z" }, { url = "https://files.pythonhosted.org/packages/43/b7/92008b81db30187f1461d6fbdb8c313713001a40cdad82bbaba0bdbe19e7/nkscan-0.9.0-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4c0550b73e18393461f9073d4ab75ecd9ff8ae2042ebb5c5028271eeeb1f10a", size = 836903, upload-time = "2026-08-24T01:28:34.506Z" }, { url = "https://files.pythonhosted.org/packages/1c/94/f53841c7168ebea01f16ab758714f70da348ef56b2e0204024babd56d358/nkscan-0.9.0-cp313-abi3-win_amd64.whl", hash = "sha256:dff231a33cbd3164c9d116b5bbf6ca9a7c070f8d386330833f89266a42ed7f08", size = 595336, upload-time = "2026-08-24T01:28:35.973Z" }, @@ -686,16 +687,16 @@ wheels = [ [[package]] name = "pyopticfilm" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "libusb-package", marker = "sys_platform == 'win32'" }, { name = "numpy" }, { name = "pyusb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/0e/99cc5110555e6694b564cd8c7b680410a84d82aba16bd3007d0c44f8692a/pyopticfilm-1.3.0.tar.gz", hash = "sha256:00fc5f2d6e21977fc76f5d150c0c12e17ca2945208548ae89bcaecd76e3755d6", size = 122006, upload-time = "2026-08-27T17:28:09.122Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/1f/fbc132e93aba99f9c68294d2a21d49b097aaca0f97a29529277d97da5726/pyopticfilm-1.3.1.tar.gz", hash = "sha256:dd82ef5d019abbbce91841621c4238fbb439919099f13934d5eb92f7dab73df0", size = 130402, upload-time = "2026-08-29T17:46:25.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/eb/6363eb865ef2b69ffee5f52467bebe7a40ddd63b5ba81c9fab31bcca9ffb/pyopticfilm-1.3.0-py3-none-any.whl", hash = "sha256:8d9d3a857666677a9e4f0e0d41b79de60d599969040e68e7960648766a67f558", size = 154519, upload-time = "2026-08-27T17:28:07.797Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/861ffbf133e0c03c247330ba2d13288b940b038221091d11e963a3dc9193/pyopticfilm-1.3.1-py3-none-any.whl", hash = "sha256:cd7e2a81550886deb2aaafb96faae0bcd15305461c85767a568c93bd02d563ad", size = 163353, upload-time = "2026-08-29T17:46:23.954Z" }, ] [[package]]