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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ It is built with **Python**, running natively on Linux, macOS, and Windows.
* **Film Physics**: Models the **H&D Characteristic Curve** in density space — an asymmetric toe-linear-shoulder response with independent softplus toe/shoulder knees and ISO-R paper grades — instead of a linear inversion.
* **Smart Auto Conversion**: Per-frame **Auto Density** and **Auto Grade** meter each negative for sensible brightness/contrast — usable out of the box, easy to fine-tune.
* **Darkroom Paper Profiles**: Per-paper curve shaping (tone, per-channel gamma, base tint) mapped from Ilford/Kodak/Foma/Fuji datasheets, selectable per roll.
* **Positive/Slide Support**: Dedicated **E-6 mode** with optional normalization to save expired or faded film.
* **Positive/Slide Support**: Dedicated **Transparency mode** with optional normalization to save expired or faded film.

**Capture & Input**
* **Camera Scanning**: Capture negatives with a tethered camera straight into NegPy — a single RAW, or automated red/green/blue narrowband triplets driven by an RGB [Scanlight](https://github.com/jackw01/scanlight) that feed the RGB Scan merge. macOS/Linux, optional dependency. [Camera Scanning guide](docs/CAMERA_SCANNING.md)
Expand Down
14 changes: 7 additions & 7 deletions docs/CROSSTALK.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,30 @@ own — calibrated for your film *and your scanning setup* — without touching

## Which film a matrix is for

A matrix describes one dye set, and C-41 and E-6 do not share one. Each profile therefore
A matrix describes one dye set, and Color Negative and Transparency do not share one. Each profile therefore
declares its film process:

```toml
process = "E-6" # or "C41" (the default when the key is absent)
process = "Transparency" # or "Color Negative" (the default when the key is absent)
```

The dropdown only offers matrices for the film you are processing, and the render gates on
the same value, so a mismatched profile resolves to identity rather than mixing the wrong
correction in. Every profile written before this key existed is a colour negative stock,
which is why the default is `C41`.
which is why the default is `Color Negative`. Files written before the modes were renamed (`C41`, `B&W`, `E-6`) still load.

No E-6 matrix ships with NegPy today, so on slides the Matrix dropdown starts empty and it
No Transparency matrix ships with NegPy today, so on slides the Matrix dropdown starts empty and it
and the Strength slider are disabled. The editor button stays live: open it, press **+** for
a new matrix, and it is created for the process you are working in. The editor's **Process**
control sets that value on any profile you save, so a matrix built for a slide rig is one you
can then select. (Crosstalk is hidden outright only in B&W, which has one emulsion and so
can then select. (Crosstalk is hidden outright only in B&W Negative, which has one emulsion and so
nothing to unmix.)

> **Reversal film reads differently.** On a negative the dyes' unwanted absorptions are an
> error to remove before inversion, so unmixing moves the render *toward* the scene. A
> transparency **is** the finished image — what you see on a lightbox already includes those
> absorptions — so unmixing moves it *away* from the slide's own appearance and toward the
> dye-amount image behind it. On E-6 treat Crosstalk as a colour-separation control, not a
> dye-amount image behind it. In Transparency treat Crosstalk as a colour-separation control, not a
> fidelity correction, and leave Strength at 0 when the goal is to match the slide.

## What it does
Expand Down Expand Up @@ -201,7 +201,7 @@ have to hand-edit TOML:
folder profiles are read from — so it shows up in the dropdown.
- **Apply & Close** keeps what you were previewing; **Cancel** reverts.

> Crosstalk is a color operation and is hidden in B&W mode. Because it changes what
> Crosstalk is a color operation and is hidden in B&W Negative mode. Because it changes what
> the normalization meters read, re-run **Batch Analysis** (and re-save locked bounds)
> after changing the profile or strength.

Expand Down
24 changes: 12 additions & 12 deletions docs/PIPELINE.md

Large diffs are not rendered by default.

40 changes: 20 additions & 20 deletions docs/USER_GUIDE.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
from negpy.features.retouch.logic import downsample_ir, trace_scratch
from negpy.features.retouch.models import RetouchConfig
from negpy.features.toning.models import ToningConfig
from negpy.infrastructure.capture.settings import WhiteCaptureMode
from negpy.infrastructure.display.color_spaces import ColorSpaceRegistry
from negpy.infrastructure.filesystem.watcher import FolderWatchService
from negpy.infrastructure.gpu.device import GPUDevice
Expand Down Expand Up @@ -3370,9 +3371,9 @@ def _on_capture_finished(self, paths: list) -> None:
self.session.repo.save_global_setting("rgbscan_mode", rgb and not white)
capture_roll = getattr(req, "roll_name", "") if req is not None else ""
capture_frame = getattr(req, "frame_number", None) if req is not None else None
if white: # slides/B&W force a positive process
mode = (req.white_process_mode or "auto").lower()
target = {"e-6": ProcessMode.E6, "b&w": ProcessMode.BW}.get(mode)
if white: # slides / B&W negatives force a positive process
mode = WhiteCaptureMode(req.white_process_mode)
target = {WhiteCaptureMode.E6: ProcessMode.E6, WhiteCaptureMode.BW: ProcessMode.BW}.get(mode)
self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport(
process_mode=target,
detect_mode=target is None,
Expand Down
2 changes: 1 addition & 1 deletion negpy/desktop/view/sidebar/colour.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def _init_ui(self) -> None:
self.cast_removal_slider.setToolTip(
"Cast Removal: neutralizes the colour cast a negative leaves in the print — balances each "
"colour layer so greys stay neutral from deep shadows through highlights. 0 = off, 1 = full."
"<br><br>C-41 only, and hidden elsewhere: it defeats the orange mask, a manufactured cast "
"<br><br>Color Negative only, and hidden elsewhere: it defeats the orange mask, a manufactured cast "
"that is not part of the picture. A slide has no mask — its cast IS the photograph — so "
"solving for a neutral axis there would remove the light you shot in. For a slide use "
"Temperature and the CMY sliders, or Hue Trim for an odd light source."
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,14 @@ def apply_shortcut_tooltips(self) -> None:

ton.selenium_slider.setToolTip(
tooltip_with_shortcut(
"Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows. B&W mode only",
"Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows. B&W Negative mode only",
["selenium_inc", "selenium_dec"],
)
)
ton.sepia_slider.setToolTip(
tooltip_with_shortcut(
"Simulates sepia bleach-redevelop toning — warms the highlights first while shadows hold; "
"partial strength gives the classic split-sepia look. B&W mode only",
"partial strength gives the classic split-sepia look. B&W Negative mode only",
["sepia_inc", "sepia_dec"],
)
)
Expand Down
8 changes: 5 additions & 3 deletions negpy/desktop/view/sidebar/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -1140,17 +1140,19 @@ def _add_hdr_merge_action(self, menu, state) -> None:
black-and-white negative nearer 4 — both inside a single capture, so a bracket buys
nothing. A transparency runs to 10-12, which is what the merge exists for.

Hidden on C-41, disabled with a reason on B&W: reversal-processed monochrome
Hidden on Color Negative, disabled with a reason on B&W Negative: reversal-processed monochrome
(Scala, dr5, Fomapan R) *is* a transparency and does have the range, it is simply
not wired yet, and a missing menu entry would leave nobody anything to ask about.
"""
from negpy.features.process.models import ProcessMode

idx = state.selected_file_idx
assets = state.uploaded_files
mode = str(self.controller.state.config.process.process_mode)
mode = self.controller.state.config.process.process_mode
if 0 <= idx < len(assets):
mode = str(assets[idx].get("process_mode") or mode)
# Coerced, not compared raw: a session blob written before the mode rename
# still carries the old names.
mode = ProcessMode(assets[idx].get("process_mode") or mode)
if mode == ProcessMode.C41:
return
act = menu.addAction("Merge exposures (HDR)")
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/sidebar/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ def _init_ui(self) -> None:
self.mode_combo = QComboBox()
self.mode_combo.addItems([m.value for m in ProcessMode])
self.mode_combo.setCurrentText(conf.process_mode)
self.mode_combo.setToolTip("Film process mode: C41 (colour negative), B&W (panchromatic), E-6 (slide/reversal)")
self.autodetect_btn = self._small_toggle("mdi6.auto-fix", "", False, "Auto-detect film process (C41/B&W/E-6) on load")
self.mode_combo.setToolTip("Film process mode: Color Negative (C-41), B&W Negative (panchromatic), Transparency (slide/reversal)")
self.autodetect_btn = self._small_toggle("mdi6.auto-fix", "", False, "Auto-detect the film process on load")
self.autodetect_btn.setFixedWidth(28)
mode_row.addWidget(field_label("Mode"))
mode_row.addWidget(self.mode_combo, stretch=1)
Expand Down
8 changes: 4 additions & 4 deletions negpy/desktop/view/sidebar/scanlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from negpy.desktop.view.styles.templates import section_subheader
from negpy.desktop.view.styles.theme import THEME
from negpy.infrastructure.capture.gphoto import default_settings_path
from negpy.infrastructure.capture.settings import ScanlightSettings
from negpy.infrastructure.capture.settings import ScanlightSettings, WhiteCaptureMode
from negpy.services.capture.calibration import REFERENCE_LEVELS, SHUTTER_CANDIDATES, normalize_start_point, shutter_seconds, usable_ladder
from negpy.services.capture.presets import PresetStore, ScanlightPreset, framing_levels

Expand All @@ -61,10 +61,10 @@
}

# Built-in white-light preset (no calibration needed): name → process mode.
# Selecting it switches the panel to a single white-light exposure. B&W and slide/E-6
# Selecting it switches the panel to a single white-light exposure. B&W and slide film
# share the *same* light (plain white), so they're one preset; which process to run is
# left to NegPy's autodetect ("auto") — the user can still force it in NegPy if needed.
_BUILTIN_WHITE_PRESETS = {"White Light (B&W or Slide Film)": "auto"}
# left to NegPy's autodetect — the user can still force it in NegPy if needed.
_BUILTIN_WHITE_PRESETS = {"White Light (B&W or Slide Film)": WhiteCaptureMode.AUTO}

# A dropdown sentinel (not a real preset name — user names are stripped, so a NUL can't collide):
# picking it unlocks the sliders + exposure steppers to build a preset by hand, then Save bakes it.
Expand Down
8 changes: 4 additions & 4 deletions negpy/desktop/view/sidebar/toning.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,19 @@ def _init_ui(self) -> None:
self.sepia_slider = CompactSlider("Sepia", 0.0, 2.0, conf.sepia_strength)
self.gold_slider = CompactSlider("Gold", 0.0, 2.0, conf.gold_strength)
self.gold_slider.setToolTip(
"Simulates gold toning — cool blue-black on untoned silver, slight Dmax boost; over sepia it shifts the highlights orange-red (B&W only)"
"Simulates gold toning — cool blue-black on untoned silver, slight Dmax boost; over sepia it shifts the highlights orange-red (B&W Negative only)"
)
self.blue_slider = CompactSlider("Iron Blue", 0.0, 2.0, conf.blue_strength)
self.blue_slider.setToolTip(
"Simulates iron blue (Prussian blue) toning — blues the image shadows-first and intensifies: deeper navy blacks (B&W only)"
"Simulates iron blue (Prussian blue) toning — blues the image shadows-first and intensifies: deeper navy blacks (B&W Negative only)"
)
self.copper_slider = CompactSlider("Copper", 0.0, 2.0, conf.copper_strength)
self.copper_slider.setToolTip(
"Simulates copper toning — pink to brick-red shift with the classic Dmax loss: blacks weaken as the bath bleaches (B&W only)"
"Simulates copper toning — pink to brick-red shift with the classic Dmax loss: blacks weaken as the bath bleaches (B&W Negative only)"
)
self.vanadium_slider = CompactSlider("Vanadium", 0.0, 2.0, conf.vanadium_strength)
self.vanadium_slider.setToolTip(
"Simulates vanadium green toning — bleach-then-tone greens the mids and highlights while deep shadows keep their black silver (B&W only)"
"Simulates vanadium green toning — bleach-then-tone greens the mids and highlights while deep shadows keep their black silver (B&W Negative only)"
)
for left, right in (
(self.selenium_slider, self.sepia_slider),
Expand Down
25 changes: 14 additions & 11 deletions negpy/desktop/view/widgets/crosstalk_editor_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,23 @@
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.sliders import CompactSlider
from negpy.features.process.models import DEFAULT_CROSSTALK_MATRIX, ProcessMode
from negpy.services.assets.crosstalk import TYPE_MEASURED, TYPE_SPECSHEET, TYPE_TUNED, CrosstalkProfiles
from negpy.services.assets.crosstalk import CrosstalkProfiles, CrosstalkType

#: Selectable provenances, in dropdown group order. "Other" is not offered: it exists to
#: keep a hand-written type loadable, not as something to choose.
#: Plain str values, like _PROCESS_CHOICES below: combo item data round-trips through
#: QVariant, which does not match an enum member against the equal string from disk.
_TYPE_CHOICES: tuple[tuple[str, str], ...] = (
(TYPE_TUNED, "Tuned on a rig"),
(TYPE_MEASURED, "Measured"),
(TYPE_SPECSHEET, "From spec sheets (approx)"),
(str(CrosstalkType.TUNED), "Tuned on a rig"),
(str(CrosstalkType.MEASURED), "Measured"),
(str(CrosstalkType.SPECSHEET), "From spec sheets (approx)"),
)

#: Film processes a matrix can describe. B&W has one emulsion, so there is nothing to unmix.
#: Film processes a matrix can describe. A B&W negative has one emulsion, so there is
#: nothing to unmix.
_PROCESS_CHOICES: tuple[tuple[str, str], ...] = (
(str(ProcessMode.C41), "C41 — colour negative"),
(str(ProcessMode.E6), "E-6 — slide / reversal"),
(str(ProcessMode.C41), "Color Negative (C-41)"),
(str(ProcessMode.E6), "Transparency (E-6)"),
)


Expand Down Expand Up @@ -205,7 +208,7 @@ def _init_ui(self) -> None:
"<table width='300'><tr><td>"
"The film process these numbers describe. A matrix only reaches the render — and only "
"appears in the sidebar's Matrix dropdown — while NegPy is in this mode.<br><br>"
"Dye sets do not carry across: a C41 matrix does not describe E-6's dyes, so applying "
"Dye sets do not carry across: a colour negative matrix does not describe a slide's dyes, so applying "
"one to a slide corrects a leak that is not there. Note also that on a positive an unmix "
"moves the render <i>away</i> from the slide's own colour — use it as a separation "
"control, not for fidelity."
Expand Down Expand Up @@ -359,7 +362,7 @@ def _all_names(self) -> list:
return CrosstalkProfiles.list_profiles()

def selected_type(self) -> str:
return self.type_combo.currentData() or TYPE_TUNED
return self.type_combo.currentData() or CrosstalkType.TUNED

def selected_process(self) -> str:
return self.process_combo.currentData() or str(ProcessMode.C41)
Expand All @@ -372,8 +375,8 @@ def _set_type(self, value: str) -> None:
"""Select `value`, falling back to Tuned for a built-in or hand-written type.

Not the first entry: saving must not relabel an unknown type as a spec-sheet claim."""
idx = self.type_combo.findData(value)
self.type_combo.setCurrentIndex(idx if idx >= 0 else self.type_combo.findData(TYPE_TUNED))
idx = self.type_combo.findData(str(value))
self.type_combo.setCurrentIndex(idx if idx >= 0 else self.type_combo.findData(str(CrosstalkType.TUNED)))

def _set_grid(self, flat: List[float]) -> None:
grid = flat_to_grid(flat)
Expand Down
2 changes: 1 addition & 1 deletion negpy/desktop/view/widgets/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class DensitometerRow(QWidget):
"Spot densitometer — hover the image to read the pixel: per-channel density above film base "
"(ΔD, relative to this scan's normalization, not absolute), the displayed tone's reflection "
"print density, and its print zone (0 = paper black, V = 18% mid-gray, X = paper white). "
"In B&W mode the ΔD channels read the pre-conversion colour record."
"In B&W Negative mode the ΔD channels read the pre-conversion colour record."
)

def __init__(self, parent=None):
Expand Down
Loading
Loading