diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e8e98f61a..62e427db2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. @@ -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. ### 10.3 Optics @@ -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. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 710de9d37..7a98cf119 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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. """ @@ -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), @@ -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 "") @@ -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 diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 517f32c48..758b3f7a1 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -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 @@ -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 @@ -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")} @@ -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. @@ -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 @@ -1295,8 +1300,21 @@ 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. @@ -1304,13 +1322,6 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa 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) @@ -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, @@ -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) @@ -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) diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index ff496aa9b..08190e867 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -1,4 +1,6 @@ from negpy.kernel.system.i18n import tr +from typing import Optional + import qtawesome as qta from PyQt6.QtWidgets import ( QMenu, @@ -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.""" @@ -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 diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py index 64a077a76..7023ec85a 100644 --- a/negpy/services/assets/rolls.py +++ b/negpy/services/assets/rolls.py @@ -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 diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index d6a0fbd76..ed62079d5 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -395,16 +395,16 @@ Misst die ganze Rolle einmal und teilt das Ergebnis, damit die Bilder eines Film * **Szenen**: Gruppen von Bildern mit eigener Basislinie, zum Beispiel ein Motiv unter einem Licht ([Szenen](#scenes)). **Analysieren** misst die Szene, **Auswählen** wählt ihre Bilder aus und **Löschen** entfernt die Gruppe. * **Szenen erkennen**: findet die Gruppen von Bildern unter anderem Licht als die Rolle, in beliebiger Bildreihenfolge, und zeigt sie auf dem Leuchttisch. Jede Gruppe wartet unter **Gefunden**. Um eine Gruppe zu ändern, ziehe Bilder auf eine andere Gruppe oder auf ein Bild ohne Szene, um sie auszuschließen. Rechtsklick › **Szene** macht dasselbe. Gib ihr einen Namen und **Übernehme** sie als Szene mit eigener Basislinie, oder **Verwirf** sie. - + ### 10.6 Raw-Dekodierung Wie eine Kamera-RAW-Datei in Pixel umgewandelt wird. Wird bei einer Kamera angezeigt. * **Kamera-WB / Lineares RAW**: Kamera-WB nutzt den Weißabgleich der Kamera. Lineares RAW dekodiert neutral, für schmalbandiges Licht. Die Einrichtung stellt es für dich ein. * **Vorschau** / **Export**: die Demosaicing-Methode, die die Farben ergänzt, die ein Sensorpixel nicht sieht. **Auto** ist am Bildschirm schnell und nutzt für den Export **AHD**. **VNG** ist glatt und **PPG** schnell. **DCB** und **DHT** erhalten mehr feine Details, können aber Korn in Muster verwandeln. **AAHD** macht Kanten weicher. Es werden nur die Methoden deines LibRaw-Builds aufgeführt. -* **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen. +* **Wiederherstellung** (Dias): rekonstruiert ausgebrannte Lichter in einer Kamera-RAW-Datei. **Mischen** passt zu Sonne und Himmel. **Rekonstruieren** ist stärker, kann aber die Farbe falsch treffen. Ein neues Bild vom Filmscanner beginnt mit ausgeschalteter Wiederherstellung. - + ### 10.7 Optik Korrekturen für Objektiv und Licht, gleich für jedes Bild, das du mit diesem Aufbau scannst. Wird bei einer Kamera angezeigt. @@ -422,7 +422,7 @@ Entfernt ungleichmäßiges Licht, zum Beispiel dunklere Ecken, mit einer Aufnahm * **Flat Field anwenden**: wendet das gewählte Profil auf diese Rolle an. * **Flat Field prüfen**: zeigt, wie gut das Profil seine eigene Aufnahme ausgleicht. Gleichmäßiges Grau ist gut. Ein Streifen oder Fleck zeigt, wo es abweicht. -Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Bild oder ein Bild mit fehlendem Profil nimmt das zuletzt gewählte Profil. +Ein Bild behält sein eigenes Profil, solange es nicht seiner Rolle folgt. So kann jedes Licht und jedes Filmformat eines haben. Ein neues Kamerabild oder ein Kamerabild mit fehlendem Profil nimmt das Profil, das du zuletzt bei einem Kamerabild gewählt hast. Ein neues Bild vom Filmscanner beginnt ohne Optik-Korrekturen. Mit der Einstellung Filmscanner erscheint diese Karte nur bei einem Bild, das eine Korrektur hat. ### Analoge Ausrüstung diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index e8e98f61a..62e427db2 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -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. @@ -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. ### 10.3 Optics @@ -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. ### 10.4 Calibration: colors from your scanning setup diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index f51be0f76..b461c3efb 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -317,16 +317,16 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 カメラではRAW デコードと光学のカードが表示されます。ナローバンド光のカメラでは、さらにトリクロームとキャリブレーションも表示されます。変更すると開いているすべてのフレームが更新されます(Ctrl+Z でフレームごとに戻せます)が、自分で選んだプリント素材はそのままです。カメラのスキャンでは、フレームごとに撮影時の光源からデコードとプリント素材を決めます。 - + ## RAW デコード カメラの RAW ファイルを画素に変える方法です。カメラのときに表示されます。 - **カメラWB/リニアRAW**:カメラWB はカメラのホワイトバランスを使います。リニアRAW はニュートラルにデコードし、ナローバンド光に向きます。セットアップが自動で設定します。 - **プレビュー**/**書き出し**:デモザイクの方法。センサーの各画素が捉えない色を補います。**自動**は画面表示で速く、書き出しでは **AHD** を使います。**VNG** はなめらか、**PPG** は高速です。**DCB** と **DHT** は細部を多く残しますが、粒子が模様のようになることがあります。**AAHD** は輪郭を柔らかくします。お使いの LibRaw にある方法だけが表示されます。 -- **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。 +- **回復**(スライド):カメラ RAW の白飛びしたハイライトを再構成します。**ブレンド**は太陽や空に向きます。**再構成**はより強力ですが、色を誤ることがあります。フィルムスキャナーの新しいフレームは回復がオフの状態で始まります。 - + ## 光学補正 レンズと光源の補正です。同じ環境でスキャンするすべてのフレームに共通です。カメラのときに表示されます。 @@ -344,7 +344,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **フラットフィールドを適用**:選択したプロファイルをこのロールに適用します。 - **フラットフィールドを確認**:プロファイルが自身の画像をどれだけ均一にするかを表示します。均一なグレーなら良好です。帯やむらはずれている場所を示します。 -フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいフレームと、プロファイルが見つからないフレームは、最後に選んだプロファイルを使います。 +フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいカメラのフレームと、プロファイルが見つからないカメラのフレームは、カメラのフレームで最後に選んだプロファイルを使います。フィルムスキャナーの新しいフレームは光学補正なしで始まります。フィルムスキャナーを設定しているときは、補正を持つフレームでのみこのカードを表示します。 ## キャリブレーション:スキャン環境による色 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index a8d09d682..0a6eb4bd0 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -317,16 +317,16 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 카메라에서는 RAW 디코딩과 광학 카드가 보입니다. 협대역 광의 카메라에서는 삼색과 보정 카드도 보입니다. 바꾸면 열려 있는 모든 프레임이 갱신되지만(Ctrl+Z로 프레임마다 되돌림), 직접 고른 프린트 소재는 그대로입니다. 카메라 스캔은 프레임마다 촬영한 광원에 따라 디코딩과 프린트 소재를 정합니다. - + ## RAW 디코딩 카메라 RAW 파일을 픽셀로 바꾸는 방식입니다. 카메라일 때 보입니다. - **카메라 WB / 선형 RAW**: 카메라 WB는 카메라의 화이트 밸런스를 씁니다. 선형 RAW는 중립으로 디코딩하며 협대역 광에 알맞습니다. 설정이 자동으로 정합니다. - **미리보기** / **내보내기**: 디모자이크 방식으로, 센서의 각 픽셀이 보지 못하는 색을 채웁니다. **자동**은 화면에서 빠르고 내보내기에는 **AHD**를 씁니다. **VNG**는 부드럽고 **PPG**는 빠릅니다. **DCB**와 **DHT**는 세부를 더 살리지만 입자가 무늬처럼 보일 수 있습니다. **AAHD**는 가장자리를 부드럽게 합니다. 사용 중인 LibRaw에 있는 방식만 표시됩니다. -- **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다. +- **복구**(슬라이드): 카메라 RAW의 날아간 밝은 영역을 다시 만듭니다. **블렌드**는 해와 하늘에 알맞습니다. **재구성**은 더 강하지만 색이 틀릴 수 있습니다. 필름 스캐너의 새 프레임은 복구가 꺼진 상태로 시작합니다. - + ## 광학 보정 렌즈와 광원 보정으로, 같은 환경에서 스캔하는 모든 프레임에 공통입니다. 카메라일 때 보입니다. @@ -344,7 +344,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **플랫 필드 적용**: 선택한 프로필을 이 롤에 적용합니다. - **플랫 필드 확인**: 프로필이 자기 이미지를 얼마나 고르게 하는지 보여줍니다. 고른 회색이면 좋고, 띠나 얼룩은 어긋난 곳을 보여줍니다. -프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 프레임과 프로필이 없는 프레임은 마지막으로 고른 프로필을 사용합니다. +프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 카메라 프레임과 프로필이 없는 카메라 프레임은 카메라 프레임에서 마지막으로 고른 프로필을 사용합니다. 필름 스캐너의 새 프레임은 광학 보정 없이 시작합니다. 필름 스캐너로 설정하면 보정이 있는 프레임에서만 이 카드를 보여줍니다. ## 보정: 스캔 환경에 따른 색 diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index bd9ee60a1..482199b91 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -317,16 +317,16 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 相机会显示 RAW 解码和光学卡片。使用窄带光的相机还会显示三色扫描和校准卡片。更改后所有打开的帧都会更新(Ctrl+Z 可逐帧撤销),但你自己选择的印相材料保持不变。相机扫描按每帧的光源设置解码和印相材料。 - + ## RAW 解码 把相机 RAW 文件变成像素的方式。使用相机时显示。 - **相机白平衡 / 线性 RAW**:相机白平衡使用相机的白平衡。线性 RAW 以中性方式解码,适合窄带光。设置会自动选择。 - **预览** / **导出**:去马赛克方法,补出每个传感器像素看不到的颜色。**自动**在屏幕上更快,导出时使用 **AHD**。**VNG** 平滑,**PPG** 快速。**DCB** 和 **DHT** 保留更多细节,但可能把颗粒变成花纹。**AAHD** 让边缘更柔和。只列出你的 LibRaw 版本中有的方法。 -- **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。 +- **恢复**(反转片):重建相机 RAW 中过曝的高光。**混合**适合太阳和天空。**重建**更强,但颜色可能出错。新的胶片扫描仪帧开始时恢复为关闭。 - + ## 光学 镜头和光源的校正,对你用此设备扫描的每一帧都相同。使用相机时显示。 @@ -344,7 +344,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **应用平场**:把所选配置应用到此卷。 - **检查平场**:显示配置对其自身照片的均匀效果。均匀的灰色表示良好;条带或斑块表示有偏差的位置。 -除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新帧或配置已缺失的帧使用你最后选择的配置。 +除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新的相机帧或配置已缺失的相机帧使用你在相机帧上最后选择的配置。新的胶片扫描仪帧开始时没有光学校正。设置为胶片扫描仪时,只有带校正的帧才显示此卡片。 ## 校准:扫描设备带来的颜色 diff --git a/tests/test_composite_inheritance.py b/tests/test_composite_inheritance.py index c07abec03..3065de84a 100644 --- a/tests/test_composite_inheritance.py +++ b/tests/test_composite_inheritance.py @@ -39,7 +39,7 @@ def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: {"last_process_mode": sticky_mode}.get(k, default) - session._apply_sticky_settings = lambda c, only_global=False: ( + session._apply_sticky_settings = lambda c, only_global=False, path="": ( c if only_global else replace(c, process=replace(c.process, process_mode=sticky_mode)) ) import negpy.desktop.session as mod @@ -247,7 +247,7 @@ def _session(self, saved_config=None): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: default - session._apply_sticky_settings = lambda c, only_global=False: c + session._apply_sticky_settings = lambda c, only_global=False, path="": c import negpy.desktop.session as mod orig = mod.load_or_promote @@ -353,7 +353,7 @@ def test_reset_matches_what_opening_the_asset_fresh_gives(self): orig = mod.load_or_promote mod.load_or_promote = lambda *a, **k: None try: - session._apply_sticky_settings = lambda c, only_global=False: c + session._apply_sticky_settings = lambda c, only_global=False, path="": c fresh = session._hydrate_asset_config(asset) finally: mod.load_or_promote = orig @@ -432,7 +432,7 @@ def _session(self, saved_config=None, sticky_mode=ProcessMode.C41): session.state = AppState() session.repo = MagicMock() session.repo.get_global_setting.side_effect = lambda k, default=None: default - session._apply_sticky_settings = lambda c, only_global=False: ( + session._apply_sticky_settings = lambda c, only_global=False, path="": ( c if only_global else replace(c, process=replace(c.process, process_mode=sticky_mode)) ) import negpy.desktop.session as mod diff --git a/tests/test_controller.py b/tests/test_controller.py index f57f19ded..122ce2b9a 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2,7 +2,7 @@ import sys import tempfile import unittest -from unittest.mock import ANY, MagicMock, patch +from unittest.mock import ANY, MagicMock, patch, call from dataclasses import replace from types import SimpleNamespace @@ -6095,6 +6095,27 @@ def test_a_source_with_no_preview_says_so_and_stays_off(self): self.assertFalse(self.controller.state.embedded_peek) self.assertEqual(seen, [False], "the menu item must not stay checked on a file with no preview") + def _pick_profile(self, film_scanner, **kwargs): + from negpy.services.assets.flatfield import FlatFieldProfile, FlatFieldProfiles + + repo = self.mock_session_manager.repo + repo.save_global_setting.reset_mock() + self.mock_session_manager.film_scanner_file.return_value = film_scanner + self.controller.set_roll_default = MagicMock() + with patch.object(FlatFieldProfiles, "get", return_value=FlatFieldProfile("ff-120", "120", 0.0, "")): + self.controller.set_active_flatfield_profile("ff-120", **kwargs) + self.controller.set_roll_default.assert_called_once_with("flatfield", profile_id="ff-120", apply=True) + return repo.save_global_setting.call_args_list + + def test_a_camera_frame_sets_the_last_chosen_profile(self): + self.assertEqual(self._pick_profile(False), [call("flatfield_active_profile", "ff-120")]) + + def test_a_film_scanner_frame_leaves_the_last_chosen_profile(self): + self.assertEqual(self._pick_profile(True), []) + + def test_a_capture_sets_the_last_chosen_profile_from_any_frame(self): + self.assertEqual(self._pick_profile(True, remember=True), [call("flatfield_active_profile", "ff-120")]) + def test_the_peeks_are_mutually_exclusive(self): with patch("negpy.desktop.controller.PreviewManager.try_splash_preview", return_value=(self._preview(), (8, 6))): self.controller.state.negative_peek = True diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 7fab5ebed..447e43187 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -671,6 +671,83 @@ def test_no_chosen_flat_field_profile_leaves_a_new_file_uncorrected(self): ff = self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False).flatfield self.assertEqual((ff.profile_id, ff.apply), ("", False)) + def _fresh_config(self, path, setup=None, roll_defaults=None): + """A first open of *path* with a camera rig's flat field and distortion carried.""" + settings = { + "flatfield_active_profile": "ff-35", + "sticky_config": {"distortion_k1": 0.05, "highlight_reconstruction": 2}, + "scan_setup": setup, + "rolls_by_id": {"r1": {"kind": "virtual", "name": "Portra", "defaults": roll_defaults}} if roll_defaults else {}, + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: settings.get(key, default) + self.session.state.active_roll_id = "r1" if roll_defaults else None + with ( + patch("negpy.desktop.session.load_or_promote", return_value=None), + patch("negpy.desktop.session.FlatFieldProfiles.get", return_value=SimpleNamespace(id="ff-35", k1=0.0)), + patch( + "negpy.desktop.session.load_sticky_rows", + return_value=[r for r in all_rows() if {"distortion_k1", "highlight_reconstruction"} & set(r.fields)], + ), + patch("negpy.desktop.session.is_scanned_positive_source", side_effect=lambda p: not p.endswith(".cr3")), + ): + return self.session.config_for_asset({"name": path, "path": path, "hash": "h"}) + + def test_a_new_camera_frame_takes_the_carried_optics(self): + config = self._fresh_config("/roll/a.cr3") + self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) + self.assertEqual(config.geometry.distortion_k1, 0.05) + self.assertEqual(config.process.highlight_reconstruction, 2) + + def test_a_new_film_scanner_frame_takes_no_carried_camera_settings(self): + for path, setup in (("/roll/a.tif", None), ("/roll/a.tif", {"capture": "camera"}), ("/roll/a.cr3", {"capture": "scanner"})): + with self.subTest(path=path, setup=setup): + config = self._fresh_config(path, setup) + self.assertEqual(config.flatfield, WorkspaceConfig().flatfield) + self.assertEqual(config.geometry.distortion_k1, 0.0) + self.assertEqual(config.process.highlight_reconstruction, 0) + + def _reopened_tiff(self, path="/roll/a.tif"): + """A saved frame with no profile, reopened while a legacy profile with a k1 is the last chosen.""" + settings = {"flatfield_active_profile": "ff-35"} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: settings.get(key, default) + self.session.state.active_roll_id = None + with ( + patch("negpy.desktop.session.load_or_promote", return_value=WorkspaceConfig()), + patch("negpy.desktop.session.FlatFieldProfiles.get", return_value=SimpleNamespace(id="ff-35", k1=-0.05)), + patch("negpy.desktop.session.is_scanned_positive_source", side_effect=lambda p: not p.endswith(".cr3")), + ): + return self.session.config_for_asset({"name": path, "path": path, "hash": "h"}) + + def test_a_saved_film_scanner_frame_takes_no_last_chosen_profile(self): + config = self._reopened_tiff() + self.assertEqual(config.flatfield.profile_id, "") + self.assertEqual(config.geometry.distortion_k1, 0.0) + + def test_a_saved_camera_frame_takes_the_last_chosen_profile(self): + self.assertEqual(self._reopened_tiff("/roll/a.cr3").flatfield.profile_id, "ff-35") + + def test_the_file_is_not_read_when_no_camera_setting_carries(self): + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: default + with ( + patch("negpy.desktop.session.is_scanned_positive_source") as scanned, + patch("negpy.desktop.session.load_sticky_rows", return_value=[]), + ): + self.session._apply_sticky_settings(WorkspaceConfig(), only_global=False, path="/roll/a.nef") + scanned.assert_not_called() + + def test_a_film_scanner_frame_takes_optics_its_roll_holds(self): + config = self._fresh_config("/roll/a.tif", roll_defaults={"profile_id": "ff-35", "apply": True}) + self.assertEqual((config.flatfield.profile_id, config.flatfield.apply), ("ff-35", True)) + + def test_a_film_scanner_frame_leaves_the_carried_values_for_the_next_camera_frame(self): + stored = {"distortion_k1": 0.05, "apply": True, "profile_id": "ff-35", "highlight_reconstruction": 2} + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: stored if key == "sticky_config" else default + self.session.state.current_file_path = "/roll/a.tif" + with patch("negpy.desktop.session.is_scanned_positive_source", return_value=True): + self.session._persist_sticky_settings(WorkspaceConfig()) + snapshot = self.mock_repo.save_global_settings.call_args.args[0]["sticky_config"] + self.assertEqual({k: snapshot[k] for k in stored}, stored) + def test_paper_black_carries_to_new_files(self): """Sticky must carry an explicit value over the file's base.""" sticky = {"sticky_config": {"paper_black": False}} diff --git a/tests/test_scan_setup.py b/tests/test_scan_setup.py index 3b8370a19..dac4f7c0d 100644 --- a/tests/test_scan_setup.py +++ b/tests/test_scan_setup.py @@ -9,6 +9,7 @@ from negpy.desktop.session import AppState, DesktopSessionManager from negpy.desktop.view.sidebar.scan_setup import ScanSetupSidebar, resolved_rig from negpy.domain.models import WorkspaceConfig +from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.process.models import ProcessMode, scan_setup_rig, scan_setup_values, with_process_mode from negpy.services.assets import rolls from negpy.services.rendering.preview_manager import PreviewManager @@ -169,6 +170,42 @@ def test_a_setting_keeps_its_control_in_view(self): panel = self._panel({"capture": "scanner", "light": "white"}, config=config) self.assertFalse(panel.sensor_sidebar.light_fold.content.isHidden()) + def test_turning_off_flat_field_keeps_optics_in_view_on_that_frame(self): + on = WorkspaceConfig(flatfield=FlatFieldConfig(apply=True, profile_id="ff-35")) + panel = self._panel({"capture": "scanner", "light": "white"}, config=on, current_file_hash="a") + self.assertFalse(panel.optics_section.isHidden()) + + state = panel.controller.state + state.config = replace(on, flatfield=replace(on.flatfield, apply=False)) + panel._sync_scan_setup() + self.assertFalse(panel.optics_section.isHidden()) + + state.current_file_hash = "b" + panel._sync_scan_setup() + self.assertTrue(panel.optics_section.isHidden()) + + def test_a_frame_locked_on_flat_field_keeps_optics_in_view(self): + panel = self._panel({"capture": "scanner", "light": "white"}, current_file_hash="a") + self.assertTrue(panel.optics_section.isHidden()) + panel.controller.locked_roll_cards.return_value = {"flatfield"} + panel._sync_scan_setup() + self.assertFalse(panel.optics_section.isHidden()) + + def test_turning_off_recovery_keeps_raw_decode_in_view_on_that_frame(self): + base = WorkspaceConfig() + on = replace(base, process=replace(base.process, highlight_reconstruction=2)) + panel = self._panel({"capture": "scanner", "light": "white"}, config=on, current_file_hash="a") + self.assertFalse(panel.demosaic_section.isHidden()) + + state = panel.controller.state + state.config = base + panel._sync_scan_setup() + self.assertFalse(panel.demosaic_section.isHidden()) + + state.current_file_hash = "b" + panel._sync_scan_setup() + self.assertTrue(panel.demosaic_section.isHidden()) + def test_bw_on_a_white_light_scanner_leaves_calibration_empty(self): config = with_process_mode(WorkspaceConfig(), ProcessMode.BW) panel = self._panel({"capture": "scanner", "light": "white"}, config=config) diff --git a/tests/test_source_encoding.py b/tests/test_source_encoding.py index 126330749..c91116163 100644 --- a/tests/test_source_encoding.py +++ b/tests/test_source_encoding.py @@ -275,7 +275,7 @@ def _hydrating_session(monkeypatch, tmp_path, saved=None): session = DesktopSessionManager.__new__(DesktopSessionManager) session.state = AppState() session.repo = repo - session._apply_sticky_settings = lambda c, only_global=False: c if only_global else with_process_mode(c, ProcessMode.E6) + session._apply_sticky_settings = lambda c, only_global=False, path="": c if only_global else with_process_mode(c, ProcessMode.E6) monkeypatch.setattr(session_mod, "load_or_promote", lambda *a, **k: saved) return session