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
6 changes: 3 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Right-click a card header for **Copy Card Settings** and **Undo Change to Severa

### What carries to the next frame

A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries.
A new frame gets the roll's film type, and the crop, print stock and Lab settings of the last frame you edited. The look (brightness, color, dodge and burn, toning) starts clean. **Preferences → Session & Storage → Persistent Settings…** sets what carries. Optics and Recovery carry to camera RAW frames only.

**Reset Settings** returns a frame to the defaults. It does not change the scanning choices on the **Setup** card.

Expand Down Expand Up @@ -485,7 +485,7 @@ How a camera RAW file is turned into pixels. Shown for a camera.

* **Camera WB / Linear RAW**: Camera WB uses the camera's white balance. Linear RAW decodes neutral, for narrowband light. Setup sets it for you.
* **Preview** / **Export**: the demosaic method, which fills in the colors each sensor pixel cannot see. **Auto** is fast on screen and uses **AHD** for export. **VNG** is smooth and **PPG** fast. **DCB** and **DHT** keep more fine detail but can turn grain into patterns. **AAHD** softens edges. Only the methods in your LibRaw build are listed.
* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong.
* **Recovery** (slides): rebuilds blown highlights in a camera RAW. **Blend** suits sun and sky. **Reconstruct** is stronger but can get the color wrong. A new film scanner frame starts with Recovery off.

<!-- panel:optics -->
### 10.3 Optics
Expand All @@ -505,7 +505,7 @@ Removes uneven light, such as darker corners, with a shot of the bare light sour
* **Apply Flat Field**: applies the selected profile to this roll.
* **Check Flat Field**: shows how well the profile evens out its own shot. Even gray is good; a band or patch shows where it is off.

A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new frame, or a frame whose profile is missing, takes the last profile you chose.
A frame keeps its own profile unless it follows its roll, so each light and film format can have one. A new camera frame, or a camera frame whose profile is missing, takes the last profile you chose on a camera frame. A new film scanner frame starts with no Optics corrections. With Film Scanner set, this card shows only on a frame that holds one.

<!-- panel:sensor -->
### 10.4 Calibration: colors from your scanning setup
Expand Down
16 changes: 9 additions & 7 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6906,19 +6906,21 @@ def reanalyze_current_file(self) -> None:
self.session.update_config(replace(self.state.config, process=new_process), record_history=False)
self.request_render()

def set_active_flatfield_profile(self, profile_id: str) -> None:
def set_active_flatfield_profile(self, profile_id: str, remember: bool = False) -> None:
"""
Sets the Flat Field card's profile on the current frame like any other roll card (an
empty ``profile_id`` clears it), and makes it the profile a frame that names none takes.
empty ``profile_id`` clears it), and makes it the profile a camera frame that names none
takes. A film scanner frame leaves that one as it is, unless *remember* is set.
"""
from negpy.services.assets.flatfield import FlatFieldProfiles

self.session.repo.save_global_setting("flatfield_active_profile", profile_id or "")
if remember or not self.session.film_scanner_file(self.state.current_file_path or ""):
self.session.repo.save_global_setting("flatfield_active_profile", profile_id or "")
prof = FlatFieldProfiles.get(profile_id) if profile_id else None
pid = prof.id if prof else ""
self.set_roll_default("flatfield", profile_id=pid, apply=bool(pid))

def save_flatfield_profile(self, name: str, path: str) -> None:
def save_flatfield_profile(self, name: str, path: str, remember: bool = False) -> None:
"""
Bakes a reference image into a named flat-field profile and makes it active.
"""
Expand All @@ -6931,7 +6933,7 @@ def save_flatfield_profile(self, name: str, path: str) -> None:
self.set_status(tr("Flat Field: could not read that reference image"), 3000, kind="error")
return
profile_id, check = baked
self.set_active_flatfield_profile(profile_id)
self.set_active_flatfield_profile(profile_id, remember=remember)
if check.clipped:
self.set_status(
tr("Flat Field profile '{name}' saved, but the reference is clipped: shoot it darker to correct the falloff", name=name),
Expand Down Expand Up @@ -6965,7 +6967,7 @@ def delete_flatfield_profile(self, profile_id: str) -> None:
invalidate_gain(profile_id)
active = self.session.repo.get_global_setting("flatfield_active_profile") or ""
if active == profile_id:
self.set_active_flatfield_profile("")
self.set_active_flatfield_profile("", remember=True)
elif self.state.config.flatfield.profile_id == profile_id:
self.set_roll_default("flatfield", profile_id=active if active and FlatFieldProfiles.get(active) else "")

Expand Down Expand Up @@ -7862,7 +7864,7 @@ def _on_capture_flat(self, path: str) -> None:
"""A strip's bare-light reference: save it as the roll's Flat Field profile, which its frames take."""
req = getattr(self, "_last_capture_req", None)
roll = req.roll_name if req is not None else os.path.splitext(os.path.basename(path))[0]
self.save_flatfield_profile(f"{roll} (Lento Dock)", path)
self.save_flatfield_profile(f"{roll} (Lento Dock)", path, remember=True)

def _on_capture_finished(self, paths: list) -> None:
"""Feed the captured frame(s) into NegPy. An RGB triplet → RGB-Scan, C-41; a single-capture
Expand Down
68 changes: 43 additions & 25 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass, field, replace
from enum import Enum, auto
from contextlib import contextmanager
from typing import Any, Callable, Collection, Dict, Iterator, List, Optional, Set, Tuple
from typing import Any, Callable, Collection, Dict, Iterable, Iterator, List, Optional, Set, Tuple

import numpy as np
from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal
Expand All @@ -34,8 +34,8 @@
from negpy.features.exposure.models import apply_targets
from negpy.features.process.capture_color import is_source_profile
from negpy.features.geometry.logic import flip_geometry_and_analysis, has_manual_crop, rotate_geometry_and_analysis
from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, with_process_mode
from negpy.infrastructure.loaders.source_kind import with_scanner_positive_default
from negpy.features.process.models import invalidate_local_bounds, mode_aware_exposure_reset, scan_setup_rig, with_process_mode
from negpy.infrastructure.loaders.source_kind import is_scanned_positive_source, with_scanner_positive_default
from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet
from negpy.features.hdr.logic import resolve_anchor, seed_shadow_density
from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig, hdr_frame_paths
Expand Down Expand Up @@ -86,6 +86,9 @@ class ToolMode(Enum):
REVERTIBLE_TOOLS = frozenset({ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW})

# Zone Overlay looks, by stored key and menu label.
# Settings that describe a camera, which carry to camera frames only.
_CAMERA_ONLY_FIELDS = frozenset(rolls.card_fields("lens") + rolls.card_fields("flatfield") + ("highlight_reconstruction",))

ZONE_STYLES = {"false_color": tr("Color"), "zone_map": tr("Grayscale"), "grid": tr("Grid")}


Expand Down Expand Up @@ -1269,7 +1272,7 @@ def save_flat_output_prefs(self) -> None:
self.repo.save_global_setting("linear_format", self.state.linear_format)
self.repo.save_global_setting("linear_jxl_effort", self.state.linear_jxl_effort)

def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False) -> WorkspaceConfig:
def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False, path: str = "") -> WorkspaceConfig:
"""
Overlays globally persisted settings onto the config.

Expand All @@ -1285,6 +1288,8 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa
the last chosen flat-field profile, the Kelvin roll-locks, the export fields with no
catalog row, and the scan-setup preferences. They apply regardless of
`sticky_settings_enabled`, which only gates the catalog-row overlay above.

A film scanner *path* (film_scanner_file) takes no _CAMERA_ONLY_FIELDS and no last chosen profile.
"""
from negpy.features.metadata.models import resolve_description_fields

Expand All @@ -1295,22 +1300,28 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa
if remainder:
config = replace(config, export=replace(config.export, **remainder))

if only_global:
rows = load_sticky_rows(self.repo)
rows = [r for r in rows if r.section in GLOBAL_TIER_SECTIONS]
elif self.state.sticky_settings_enabled:
rows = load_sticky_rows(self.repo)
else:
rows = []

active_ff = self.repo.get_global_setting("flatfield_active_profile")
ff_id = active_ff if active_ff and FlatFieldProfiles.get(active_ff) is not None else ""
camera_rows = [r for r in rows if _CAMERA_ONLY_FIELDS.intersection(r.fields)]
# Telling a Coolscan NEF from a camera NEF opens the file, so ask only when a camera setting would carry.
if (ff_id or camera_rows) and self.film_scanner_file(path):
ff_id = ""
rows = [r for r in rows if r not in camera_rows]
config = self._with_known_flatfield_profile(config, ff_id)
# Distortion left the profile for the per-image geometry; adopt a legacy value once,
# from the frame's own profile, on frames that carry none of their own.
own_ff = FlatFieldProfiles.get(config.flatfield.profile_id) if config.flatfield.profile_id else None
if config.geometry.distortion_k1 == 0.0 and own_ff is not None and own_ff.k1 != 0.0:
config = replace(config, geometry=replace(config.geometry, distortion_k1=own_ff.k1))

if only_global:
rows = load_sticky_rows(self.repo)
rows = [r for r in rows if r.section in GLOBAL_TIER_SECTIONS]
elif self.state.sticky_settings_enabled:
rows = load_sticky_rows(self.repo)
else:
rows = []
# Description fields carry on their own key, so the last Description… confirm wins
# for the roll rather than whichever frame was saved last.
wants_desc = any("description_fields" in r.fields for r in rows)
Expand Down Expand Up @@ -1394,25 +1405,25 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None:
from dataclasses import asdict

snapshot = sticky_snapshot(config)
if PresetStore(self.repo).owns_sensor_profile(config.process.sensor_profile):
# A Scanlight preset's profile reaches a roll through that preset's scans. Carried
# from here it would land on rolls scanned another way.

def keep_stored(keys: Iterable[str]) -> None:
stored = self.repo.get_global_setting(STICKY_CONFIG_KEY)
stored = stored if isinstance(stored, dict) else {}
for key in ("sensor_profile", "sensor_matrix"):
for key in keys:
if key in stored:
snapshot[key] = stored[key]
else:
snapshot.pop(key, None)

if PresetStore(self.repo).owns_sensor_profile(config.process.sensor_profile):
# A Scanlight preset's profile reaches a roll through that preset's scans. Carried
# from here it would land on rolls scanned another way.
keep_stored(("sensor_profile", "sensor_matrix"))
if has_manual_crop(config.geometry) or self._keep_carried_crop:
# A hand-drawn crop, or a reset of one, is one frame's placement: the carried crop stays as it was, whole.
stored = self.repo.get_global_setting(STICKY_CONFIG_KEY)
stored = stored if isinstance(stored, dict) else {}
for key in ("crop_from_auto", "crop_rect", "crop_detect_key"):
if key in stored:
snapshot[key] = stored[key]
else:
snapshot.pop(key, None)
keep_stored(("crop_from_auto", "crop_rect", "crop_detect_key"))
if self.film_scanner_file(self.state.current_file_path or ""):
keep_stored(_CAMERA_ONLY_FIELDS)
self.repo.save_global_settings(
{
STICKY_CONFIG_KEY: snapshot,
Expand Down Expand Up @@ -1484,7 +1495,7 @@ def load_edit_from_sidecar(self, path: str) -> bool:
if saved is None:
return False
asset = self.state.uploaded_files[idx]
config = self._apply_sticky_settings(saved, only_global=True)
config = self._apply_sticky_settings(saved, only_global=True, path=asset["path"])
before = self.state.config
self.update_config(resolve_asset_hdr(resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), asset), persist=True)
self.relock_diverged_cards(before)
Expand All @@ -1503,17 +1514,24 @@ def _hydrate_asset_config(self, asset: dict) -> WorkspaceConfig:
if saved_config is not None:
# A saved edit keeps its own process mode and shadow lift, which are the user's
# now, so only the wiring overlays apply.
config = self._overlay_roll_defaults(self._apply_sticky_settings(saved_config, only_global=True), asset)
config = self._overlay_roll_defaults(self._apply_sticky_settings(saved_config, only_global=True, path=asset["path"]), asset)
return resolve_asset_hdr(resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), asset)
# Sticky settings include the global process mode, which a composite must not take over
# the mode of the frames it was built from. _asset_defaults applies after.
#
# Not a bare WorkspaceConfig(): only this config holds the shipped autocrop fields, and the
# print/transfer curves are an identity at it (transfer_grade_ref, test_transparency_transfer.py).
config = self._overlay_roll_defaults(self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False), asset, fresh=True)
config = self._overlay_roll_defaults(
self._apply_sticky_settings(DEFAULT_WORKSPACE_CONFIG, only_global=False, path=asset["path"]), asset, fresh=True
)
config = self._asset_defaults(config, asset)
return config if self._roll_sets_positive(asset) else with_scanner_positive_default(config, asset["path"])

def film_scanner_file(self, path: str) -> bool:
"""The Scanning Setup says Film Scanner, or *path* is not a camera RAW. A pick of Camera
does not cover a TIFF, because the Scan tab's source can differ from the Setup card."""
return scan_setup_rig(self.repo.get_global_setting("scan_setup"))[0] == "scanner" or is_scanned_positive_source(path)

def _roll_sets_positive(self, asset: dict) -> bool:
"""The frame's roll holds a Positive value on its Film card, which beats the scanner default."""
roll_id = rolls.frame_roll(self.repo, self.state.active_roll_id, asset)
Expand Down
23 changes: 20 additions & 3 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from negpy.kernel.system.i18n import tr
from typing import Optional

import qtawesome as qta
from PyQt6.QtWidgets import (
QMenu,
Expand Down Expand Up @@ -1054,6 +1056,9 @@ def _sync_all_sidebars(self) -> None:
self._sync_scope_buttons()
self._sync_modified_dots()

_raw_decode_held_by: Optional[str] = None
_optics_held_by: Optional[str] = None

def _sync_scan_setup(self) -> None:
"""The scanning setup decides which rig cards show: Raw Decode and Optics are a camera's,
Trichrome a narrowband camera's. A card or row that holds a setting stays in view."""
Expand All @@ -1065,9 +1070,21 @@ def _sync_scan_setup(self) -> None:
(camera and light == "narrowband") or bool(self.controller.rgb_scan_mode_for_roll(state.active_roll_id))
)
self.sensor_section.setVisible(self.sensor_sidebar.has_controls())
self.demosaic_section.setVisible(camera or cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction)
optics_edited = any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS) or cfg.flatfield.apply
self.optics_section.setVisible(camera or optics_edited)
raw_decode_edited = cfg.process.highlight_reconstruction != _DEFAULT_PROCESS.highlight_reconstruction
locked_cards = self.controller.locked_roll_cards()
optics_edited = (
any(getattr(cfg.geometry, f) != getattr(_DEFAULT_GEOMETRY, f) for f in _LENS_FIELDS)
or cfg.flatfield.apply
or any(card in locked_cards for card in _SECTION_CARDS["optics"])
)
file_hash = state.current_file_hash
if raw_decode_edited:
self._raw_decode_held_by = file_hash
if optics_edited:
self._optics_held_by = file_hash
# A setting turned off stays in reach until another frame opens.
self.demosaic_section.setVisible(camera or raw_decode_edited or (bool(file_hash) and file_hash == self._raw_decode_held_by))
self.optics_section.setVisible(camera or optics_edited or (bool(file_hash) and file_hash == self._optics_held_by))

_ROLL_CARD_LABELS = AppController._ROLL_CARD_LABELS

Expand Down
2 changes: 1 addition & 1 deletion negpy/services/assets/rolls.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ def virtual_rolls(repo: Any) -> List[tuple]:
# spread (a metering run, not an edit).
"baseline": ("process", ("use_luma_average", "use_color_average", "use_cast_average")),
"lens": ("geometry", ("distortion_k1", "lens_distortion_from_metadata", "lens_ca_from_metadata")),
# A frame that names no profile_id takes the last chosen one, upstream of roll defaults.
# A camera frame that names no profile_id takes the last chosen one, upstream of roll defaults.
"flatfield": ("flatfield", ("apply", "profile_id")),
# Metadata describes the roll almost by definition: one camera, one stock, one
# development, one scanning rig. capture_frame is unique to one frame, and
Expand Down
Loading
Loading