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
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ When you set capture gear, it's written to standard EXIF and the digitizing rig
Capture film directly into NegPy (Linux and macOS; unavailable on Windows). Two collapsible sections:

* **Scanner (SANE)**: drive a supported flatbed/film scanner over SANE. Common controls: backend/device selection, DPI, bit depth, IR channel, autofocus, hardware auto-exposure, frame range (roll feeders), scan window, output format, folder and filename template. When the connected scanner exposes a SANE `scan-exposure-time` option (e.g. some genesys devices), an **Exposure** slider appears below Auto-exposure — set it to override the scanner's default exposure time; the value shows in µs, ms or s as appropriate. A device without the option hides the slider, so a saved value never breaks a different scanner.
* **Scan window**: on a roll/strip feeder (a live frame count reported), **Preview strip…** previews every frame, sets a per-frame window, and picks which frames to scan. On a scanner with a single manual holder and no feeder (Plustek and similar), the button reads **Preview…** instead: it previews just the current holder position and lets you drag one crop window, reused for the next scan. Either way, the window narrows the scanner's own hardware scan area — the real scan only reads that region, rather than reading the full frame (holder margins and film rebate included) and cropping in software afterward.
* **Camera Scanning**: DSLR/mirrorless copy-stand capture. Auto-connects the camera over USB (PC-Remote mode). With a NegPy **Scanlight** connected it captures narrowband R/G/B triplets from saved film-stock presets; without one it does a single white-light exposure. A **Live View** window helps you frame and focus; captured frames land in the hot folder and flow straight into RGB-Scan mode.

Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md).
Expand Down
66 changes: 44 additions & 22 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ def _update_device_caps(self) -> None:
self.eject_btn.setVisible(False)
self.frame_range_label.setVisible(False)
self.frame_range_widget.setVisible(False)
self.scan_window_row_label.setVisible(False)
self.scan_window_widget.setVisible(False)
self.scan_window_status.setVisible(False)
self.exposure_label.setVisible(False)
self.exposure_row_widget.setVisible(False)
return
Expand Down Expand Up @@ -476,13 +479,20 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.frame_from_spin.setValue(frm)
self.frame_to_spin.setValue(to)

self.scan_window_row_label.setVisible(has_frames)
self.scan_window_widget.setVisible(has_frames)
self.scan_window_status.setVisible(has_frames)
# Scan window: a strip/roll feeder previews per-frame windows (StripPreviewDialog);
# any other device still gets one quick low-res preview of its single holder
# position to set one crop window (QuickScanPreviewDialog).
self.scan_window_row_label.setVisible(True)
self.scan_window_widget.setVisible(True)
self.scan_window_status.setVisible(True)
self.scan_window_row_label.setText("Batch" if has_frames else "Window")
if has_frames:
self.scan_window_btn.setText("Preview strip…")
self.scan_window_btn.setToolTip("Preview each frame, set a window per frame, and pick which frames to scan")
self._update_scan_window_status()
else:
self.scan_window_btn.setText("Preview…")
self.scan_window_btn.setToolTip("Preview the current holder position and set a crop window for the scan")
self._update_scan_window_status()

self.dpi_combo.blockSignals(False)
self.depth_combo.blockSignals(False)
Expand Down Expand Up @@ -517,28 +527,40 @@ def _on_frame_to_changed(self, _value: int) -> None:
def _on_set_scan_window(self) -> None:
from dataclasses import replace

from negpy.desktop.view.widgets.strip_preview_dialog import StripPreviewDialog

device = self._current_device()
if device is None:
return
dialog = StripPreviewDialog(
self.controller,
device,
initial_windows=self._settings.frame_windows,
initial_selected=self._settings.selected_frames,
initial_offset=self._settings.frame_offset_mm,
initial_offset_modifier=self._settings.frame_offset_modifier_mm,
parent=self,
)
if dialog.exec():
self.settings = replace(
self._settings,
frame_windows=dialog.frame_windows(),
selected_frames=dialog.selected_frames(),
frame_offset_mm=dialog.frame_offset(),
frame_offset_modifier_mm=dialog.frame_offset_modifier(),

if device.capabilities.adapter_frame_capacity is not None:
from negpy.desktop.view.widgets.strip_preview_dialog import StripPreviewDialog

dialog = StripPreviewDialog(
self.controller,
device,
initial_windows=self._settings.frame_windows,
initial_selected=self._settings.selected_frames,
initial_offset=self._settings.frame_offset_mm,
initial_offset_modifier=self._settings.frame_offset_modifier_mm,
parent=self,
)
if dialog.exec():
self.settings = replace(
self._settings,
frame_windows=dialog.frame_windows(),
selected_frames=dialog.selected_frames(),
frame_offset_mm=dialog.frame_offset(),
frame_offset_modifier_mm=dialog.frame_offset_modifier(),
)
self._update_scan_window_status()
if dialog.scan_requested():
self._on_scan()
return

from negpy.desktop.view.widgets.quick_scan_preview_dialog import QuickScanPreviewDialog

dialog = QuickScanPreviewDialog(self.controller, device, initial_window=self._settings.scan_window, parent=self)
if dialog.exec():
self.settings = replace(self._settings, scan_window=dialog.window())
self._update_scan_window_status()
if dialog.scan_requested():
self._on_scan()
Expand Down
167 changes: 167 additions & 0 deletions negpy/desktop/view/widgets/quick_scan_preview_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Modal pop-up: a single low-res preview scan and a crop window, for devices with

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would reuse strip preview dialog (limiting it to 1 frame when applicable) rather than building completely separate dialog

no addressable frame adapter (Plustek: one manual holder, not a strip/roll feeder —
see StripPreviewDialog for that case).

Read after ``exec()`` via ``window()``.
"""

import qtawesome as qta
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout

from negpy.desktop.converters import ImageConverter
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.scan_preview_common import RollPreviewSignalsMixin, preview_positive
from negpy.desktop.view.widgets.scan_window_label import ScanWindowLabel
from negpy.desktop.workers.scan_worker import RollPreviewRequest
from negpy.infrastructure.scanners.base import ScannerDevice

_PREVIEW_FALLBACK_DPI = 500 # only when the device reports no DPI list at all
_PREVIEW_SLOT = 1 # PerFrameRollSession's only slot on a frame-less device


class QuickScanPreviewDialog(RollPreviewSignalsMixin, QDialog):
"""Preview the current holder position at low res; set a crop window for the real scan."""

def __init__(self, controller, device: ScannerDevice, initial_window=None, parent=None) -> None:
super().__init__(parent)
self._controller = controller
self._device = device
self._caps = device.capabilities
self._previewing = False
self._scan_now = False # set when the user chooses "Scan" over "Use"
self.setWindowTitle("Preview — set the scan window")
self.setModal(True)
self.resize(560, 480)

layout = QVBoxLayout(self)

help_lbl = QLabel(
"Preview the current holder position, then drag to crop — a corner to resize, "
"inside to move. Use (apply and return) or Scan (start scanning now)."
)
help_lbl.setWordWrap(True)
help_lbl.setStyleSheet(
f"color: {THEME.text_secondary}; font-size: {THEME.font_size_small}px;"
f" background: rgba(255,255,255,0.04); border-radius: 6px; padding: 6px 8px;"
)
layout.addWidget(help_lbl)

top = QHBoxLayout()
top.addWidget(QLabel("Preview DPI"))
self.preview_dpi_combo = QComboBox()
for dpi in sorted(self._caps.supported_dpi) or [_PREVIEW_FALLBACK_DPI]:
self.preview_dpi_combo.addItem(str(dpi), dpi)
self.preview_dpi_combo.setCurrentIndex(0) # lowest: fastest, framing only
self.preview_dpi_combo.setToolTip("Resolution used for the preview scan")
top.addWidget(self.preview_dpi_combo)
top.addStretch()
self.preview_btn = QPushButton(qta.icon("fa5s.eye", color=THEME.text_primary), " Preview")
self.preview_btn.clicked.connect(self._on_preview)
top.addWidget(self.preview_btn)
layout.addLayout(top)

self.label = ScanWindowLabel()
self.label.set_window(tuple(initial_window) if initial_window else None)
layout.addWidget(self.label, 1)

self.status = QLabel("")
self.status.setWordWrap(True)
self.status.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_small}px;")
layout.addWidget(self.status)

btns = QHBoxLayout()
self.clear_btn = QPushButton("Clear")
self.clear_btn.setToolTip("Scan the whole frame instead")
self.clear_btn.clicked.connect(self.label.clear_window)
btns.addWidget(self.clear_btn)
btns.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
btns.addWidget(cancel_btn)
self.ok_btn = QPushButton("Use")
self.ok_btn.setDefault(True)
self.ok_btn.clicked.connect(self.accept)
btns.addWidget(self.ok_btn)
self.scan_btn = QPushButton(qta.icon("fa5s.play", color=THEME.text_primary), " Scan")
self.scan_btn.setToolTip("Scan now with the current settings")
self.scan_btn.clicked.connect(self._on_scan_clicked)
btns.addWidget(self.scan_btn)
layout.addLayout(btns)

self._connect_preview_signals()

# ── result getters ────────────────────────────────────────────────

def window(self):
return self.label.window()

def scan_requested(self) -> bool:
"""True when the dialog was accepted via Scan (start now), not Use."""
return self._scan_now

# ── ui state ──────────────────────────────────────────────────────

def _on_scan_clicked(self) -> None:
self._scan_now = True
self.accept()

def _preview_dpi(self) -> int:
return int(self.preview_dpi_combo.currentData() or _PREVIEW_FALLBACK_DPI)

def _on_preview(self) -> None:
if self._previewing:
return
req = RollPreviewRequest(
device=self._device,
slots=(_PREVIEW_SLOT,),
dpi=self._preview_dpi(),
offsets={},
)
try:
self._controller.start_roll_preview(req)
except Exception as e:
self.status.setText(f"Scanner busy — {e}")
return
self._previewing = True
self.preview_btn.setEnabled(False)
self.status.setText("Previewing…")

@pyqtSlot(object)
def _on_preview_ready(self, preview) -> None:
if preview.slot != _PREVIEW_SLOT:
return
if preview.error is not None:
self.status.setText(f"Preview failed: {preview.error}")
return
try:
positive = preview_positive(preview.rgb)
pixmap = QPixmap.fromImage(ImageConverter.to_qimage(positive))
except Exception as e:
self.status.setText(f"Could not display preview: {e}")
return
self.label.set_frame(pixmap)

@pyqtSlot()
def _on_preview_finished(self) -> None:
self._previewing = False
self.preview_btn.setEnabled(True)
if not self.status.text().startswith("Preview failed") and not self.status.text().startswith("Could not display"):
self.status.clear()

@pyqtSlot(str)
def _on_error(self, msg) -> None:
if not self._previewing:
return
self._previewing = False
self.preview_btn.setEnabled(True)
self.status.setText(f"Preview failed: {msg}")

@pyqtSlot()
def _on_cancelled(self) -> None:
if not self._previewing:
return
self._previewing = False
self.preview_btn.setEnabled(True)
self.status.setText("Preview cancelled.")
58 changes: 58 additions & 0 deletions negpy/desktop/view/widgets/scan_preview_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Shared display helpers for scan-preview dialogs (StripPreviewDialog, QuickScanPreviewDialog)."""

import numpy as np


class RollPreviewSignalsMixin:
"""Wires a dialog's four scan_roll_preview_ready/scan_roll_preview_finished/
scan_error/scan_cancelled handlers onto its controller, and tears the connections
down on close.

Both preview dialogs (whole-strip and single-shot) drive the same
RollPreviewRequest/roll-preview signal pair and differ only in what their four
handlers do with a result — StripPreviewDialog updates one of N tiles and tracks
a batch selection, QuickScanPreviewDialog has just one frame. The wiring itself
doesn't vary, so it lives here once rather than being copy-pasted per dialog.

A subclass must set ``self._controller`` before calling ``_connect_preview_signals()``
(typically the first thing __init__ does) and implement the four handlers.
"""

def _preview_signal_pairs(self):
c = self._controller
return (
(c.scan_roll_preview_ready, self._on_preview_ready),
(c.scan_roll_preview_finished, self._on_preview_finished),
(c.scan_error, self._on_error),
(c.scan_cancelled, self._on_cancelled),
)

def _connect_preview_signals(self) -> None:
for signal, slot in self._preview_signal_pairs():
signal.connect(slot)

def closeEvent(self, ev) -> None:
for signal, slot in self._preview_signal_pairs():
try:
signal.disconnect(slot)
except (TypeError, RuntimeError):
pass
super().closeEvent(ev)


def preview_positive(rgb: np.ndarray) -> np.ndarray:
"""Cheap negative->positive for a scan preview: per-channel invert + auto-level.

Not the real develop pipeline — just enough to read the scene through the
orange mask. Each channel is inverted and stretched between its 1st/99th
percentiles, which both flips the negative and neutralizes the base cast.
"""
a = rgb.astype(np.float32)
if a.ndim == 2:
a = a[:, :, None]
out = np.empty_like(a)
for c in range(a.shape[2]):
ch = a[..., c]
lo, hi = np.percentile(ch, 1), np.percentile(ch, 99)
out[..., c] = 0.0 if hi <= lo else np.clip((hi - ch) / (hi - lo), 0.0, 1.0) * 255.0
return out.astype(np.uint8)
Loading
Loading