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
13 changes: 11 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,11 +466,20 @@ When you set capture gear, it's written to standard EXIF and the digitizing rig

## 12. Scan tab

Capture film directly into NegPy (Linux and macOS; unavailable on Windows). Two collapsible sections:
Capture film directly into NegPy. Two collapsible sections:

* **Scanner (SANE)**: drive a supported flatbed/film scanner over SANE.
* **Scanner**: drive a scanner over one of two backends, picked from the **Backend** dropdown:
* **SANE**: any supported flatbed/film scanner via SANE. Linux and macOS only.
* **nkscan**: a Nikon Coolscan directly over USB or SCSI (FireWire). Linux, macOS and Windows (macOS is USB-only for now). Needs the optional `nkscan` dependency (`uv sync --group nkscan`).
* **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.

The Scanner panel's controls are mostly backend-agnostic (DPI, bit depth, IR, autofocus, auto-exposure, frame range), but a few only appear for devices that support them:

* **Quality** (multisample / single-line): nkscan-specific. Multisample averages multiple passes to trade scan time for lower noise; single-line reads the sensor one line at a time, which is slower but can reduce banding. Hidden on devices that report neither.
* **Lock white balance** / **Lock exposure across batch**: nkscan-specific, shown once a frame range exists, and independent of each other. **Lock white balance** holds white balance during every frame's own metering, so the film's cast isn't scanned out. **Lock exposure across batch** meters only the first frame, then holds that exact exposure for every remaining frame. Combine both for roll analysis (consistent color and brightness across the whole strip); either can be used alone. Both off by default.

A batch scan holds the scanner open for the whole frame range rather than reopening per frame, regardless of backend.

Camera scanning needs the optional `python-gphoto2` dependency (`pip install gphoto2`; no Windows build). See [CAMERA_SCANNING.md](CAMERA_SCANNING.md).

---
Expand Down
11 changes: 5 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 qtawesome as qta
Expand Down Expand Up @@ -94,12 +93,12 @@ 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)
# nkscan supports Windows for some devices, so always build the real sidebar;
# a backend that isn't available on this platform surfaces through
# ScannerUnavailable's install hint instead.
self.scan_sidebar = ScanSidebar(self.controller)

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

Expand Down
113 changes: 94 additions & 19 deletions negpy/desktop/view/sidebar/scan.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import qtawesome as qta
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
Expand Down Expand Up @@ -128,6 +128,23 @@ def _init_ui(self) -> None:
depth_row.addWidget(self.ir_check)
self.form.addRow("Depth", depth_row)

# Multisample averaging / single-line CCD (nkscan-specific — hidden entirely
# on devices that don't report either capability).
quality_row = QHBoxLayout()
quality_row.setContentsMargins(0, 0, 0, 0)
self.multisample_combo = QComboBox()
self.multisample_combo.setToolTip("Multisample averaging (trades scan time for noise)")
self.single_line_check = QCheckBox("Single-line")
self.single_line_check.setToolTip("Single-line CCD mode (slower, may reduce banding)")
quality_row.addWidget(self.multisample_combo, 1)
quality_row.addWidget(self.single_line_check)
self.quality_row_widget = QWidget()
self.quality_row_widget.setLayout(quality_row)
self.quality_row_label = QLabel("Quality")
self.form.addRow(self.quality_row_label, self.quality_row_widget)
self.quality_row_label.setVisible(False)
self.quality_row_widget.setVisible(False)

# Spanning rows (no label column) so the checkboxes sit at the left edge.
self.autofocus_check = QCheckBox("Autofocus")
self.autofocus_check.setChecked(True)
Expand All @@ -138,6 +155,19 @@ def _init_ui(self) -> None:
self.ae_check.setToolTip("Meter exposure in hardware before the scan")
self.form.addRow(self.ae_check)

# nkscan-specific, independent controls — only meaningful once a frame
# range exists. Lock WB affects every frame's own metering; lock exposure
# freezes the numeric gain frame 1 settles on for the rest of the batch.
self.lock_white_balance_check = QCheckBox("Lock white balance")
self.lock_white_balance_check.setToolTip("Hold white balance during metering, so film keeps its cast instead of scanning neutral")
self.form.addRow(self.lock_white_balance_check)
self.lock_white_balance_check.setVisible(False)

self.lock_exposure_check = QCheckBox("Lock exposure across batch")
self.lock_exposure_check.setToolTip("Meter the first frame, then hold that exact exposure for the rest of the batch")
self.form.addRow(self.lock_exposure_check)
self.lock_exposure_check.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 @@ -225,6 +255,9 @@ def _init_ui(self) -> None:
self.pattern_edit.setText(self._settings.filename_pattern)
self.autofocus_check.setChecked(self._settings.autofocus)
self.ae_check.setChecked(self._settings.auto_exposure)
self.single_line_check.setChecked(self._settings.single_line)
self.lock_white_balance_check.setChecked(self._settings.lock_white_balance)
self.lock_exposure_check.setChecked(self._settings.lock_exposure)

def _connect_signals(self) -> None:
self.refresh_btn.clicked.connect(self._on_refresh)
Expand All @@ -241,6 +274,10 @@ 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.multisample_combo.currentTextChanged.connect(lambda: self._update_settings_from_ui())
self.single_line_check.toggled.connect(lambda: self._update_settings_from_ui())
self.lock_white_balance_check.toggled.connect(lambda: self._update_settings_from_ui())
self.lock_exposure_check.toggled.connect(lambda: self._update_settings_from_ui())
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 @@ -343,6 +380,10 @@ def _update_device_caps(self) -> None:
self.dpi_combo.setEnabled(False)
self.depth_combo.setEnabled(False)
self.ir_check.setEnabled(False)
self.quality_row_label.setVisible(False)
self.quality_row_widget.setVisible(False)
self.lock_white_balance_check.setVisible(False)
self.lock_exposure_check.setVisible(False)
self.eject_btn.setVisible(False)
self.frame_range_label.setVisible(False)
self.frame_range_widget.setVisible(False)
Expand All @@ -352,6 +393,8 @@ def _update_device_caps(self) -> None:
self.dpi_combo.setEnabled(True)
self.depth_combo.setEnabled(True)
self.ir_check.setEnabled(True)
self.multisample_combo.setEnabled(True)
self.single_line_check.setEnabled(True)
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")
Expand All @@ -371,6 +414,10 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.depth_combo.blockSignals(True)
self.ir_check.blockSignals(True)
self.ae_check.blockSignals(True)
self.multisample_combo.blockSignals(True)
self.single_line_check.blockSignals(True)
self.lock_white_balance_check.blockSignals(True)
self.lock_exposure_check.blockSignals(True)
self.frame_from_spin.blockSignals(True)
self.frame_to_spin.blockSignals(True)

Expand Down Expand Up @@ -417,6 +464,25 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.ae_check.setChecked(False)
self.ae_check.setToolTip("Auto-exposure not supported by this device")

# Multisample / single-line (nkscan-specific quality knobs; hidden entirely
# on devices that report neither).
has_multisample = len(caps.multisample) > 1
self.quality_row_label.setVisible(has_multisample or caps.single_line)
self.quality_row_widget.setVisible(has_multisample or caps.single_line)

self.multisample_combo.clear()
self.multisample_combo.setVisible(has_multisample)
self.multisample_combo.setEnabled(has_multisample)
if has_multisample:
for m in caps.multisample:
self.multisample_combo.addItem(f"{m}×", m)
idx = self.multisample_combo.findData(self._settings.multisample)
self.multisample_combo.setCurrentIndex(idx if idx >= 0 else 0)

self.single_line_check.setVisible(caps.single_line)
self.single_line_check.setEnabled(caps.single_line)
self.single_line_check.setChecked(caps.single_line and self._settings.single_line)

# Frame range — only a roll/strip feeder reporting a live capacity
capacity = caps.adapter_frame_capacity
has_frames = capacity is not None
Expand All @@ -433,6 +499,17 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.frame_from_spin.setValue(frm)
self.frame_to_spin.setValue(to)

# Lock white balance / lock exposure across batch (nkscan-specific,
# independent controls; only meaningful once there's a batch to hold
# exposure across).
show_lock = caps.supports_exposure_lock and has_frames
self.lock_white_balance_check.setVisible(show_lock)
self.lock_white_balance_check.setEnabled(show_lock)
self.lock_white_balance_check.setChecked(show_lock and self._settings.lock_white_balance)
self.lock_exposure_check.setVisible(show_lock)
self.lock_exposure_check.setEnabled(show_lock)
self.lock_exposure_check.setChecked(show_lock and self._settings.lock_exposure)

self.scan_window_row_label.setVisible(has_frames)
self.scan_window_widget.setVisible(has_frames)
self.scan_window_status.setVisible(has_frames)
Expand All @@ -445,6 +522,10 @@ def _populate_form(self, caps: ScannerCapabilities) -> None:
self.depth_combo.blockSignals(False)
self.ir_check.blockSignals(False)
self.ae_check.blockSignals(False)
self.multisample_combo.blockSignals(False)
self.single_line_check.blockSignals(False)
self.lock_white_balance_check.blockSignals(False)
self.lock_exposure_check.blockSignals(False)
self.frame_from_spin.blockSignals(False)
self.frame_to_spin.blockSignals(False)

Expand Down Expand Up @@ -548,6 +629,10 @@ def _on_scan(self) -> None:
capture_ir = self.ir_check.isEnabled() and self.ir_check.isChecked()
autofocus = self.autofocus_check.isChecked()
auto_exposure = self.ae_check.isEnabled() and self.ae_check.isChecked()
multisample = int(self.multisample_combo.currentData() or 1) if self.multisample_combo.isEnabled() else 1
single_line = self.single_line_check.isEnabled() and self.single_line_check.isChecked()
lock_white_balance = self.lock_white_balance_check.isEnabled() and self.lock_white_balance_check.isChecked()
lock_exposure = self.lock_exposure_check.isEnabled() and self.lock_exposure_check.isChecked()
pattern = self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}'
fmt = self.fmt_combo.currentText()

Expand All @@ -562,6 +647,8 @@ def _on_scan(self) -> None:
auto_exposure=auto_exposure,
window=base_window,
frame_offset_mm=self._settings.frame_offset_mm,
multisample=multisample,
single_line=single_line,
)

self._update_settings_from_ui()
Expand All @@ -580,6 +667,8 @@ def _on_scan(self) -> None:
frames=frames,
frame_windows=frame_windows,
frame_offset_modifier_mm=self._settings.frame_offset_modifier_mm,
lock_white_balance=lock_white_balance,
lock_exposure=lock_exposure,
)
)
else:
Expand Down Expand Up @@ -685,27 +774,13 @@ 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(),
multisample=int(self.multisample_combo.currentData() or 1) if self.multisample_combo.isEnabled() else 1,
single_line=self.single_line_check.isChecked() and self.single_line_check.isEnabled(),
lock_white_balance=self.lock_white_balance_check.isChecked() and self.lock_white_balance_check.isEnabled(),
lock_exposure=self.lock_exposure_check.isChecked() and self.lock_exposure_check.isEnabled(),
frame_from=self.frame_from_spin.value(),
frame_to=self.frame_to_spin.value(),
output_folder=self.folder_edit.text().strip(),
output_format=self.fmt_combo.currentText(),
filename_pattern=self.pattern_edit.text().strip() or '{{ date }}_{{ "%03d" % seq }}',
)


class _ScanUnsupportedPlaceholder(QWidget):
"""Shown on Windows where SANE is not available."""

def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
# No layout alignment and no QSS padding: either one breaks the wrapped
# QLabel's height-for-width negotiation and clips the text — the label
# must be stretched to full width so it can report its wrapped height.
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)

label = QLabel("Scanner support not yet available on Windows.")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
label.setWordWrap(True)
label.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_base}px;")
layout.addWidget(label)
Loading
Loading