From 79362208c190b5a506acb1562a81802879a05332 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Sun, 6 Sep 2026 14:31:14 +0200 Subject: [PATCH 1/8] Add embedded Sony ARW and DNG lens correction --- CLAUDE.md | 1 + docs/PIPELINE.md | 1 + docs/USER_GUIDE.md | 1 + negpy/desktop/controller.py | 15 +- negpy/desktop/session.py | 4 + negpy/desktop/settings_catalog.py | 2 +- negpy/desktop/view/keyboard_shortcuts.py | 1 + negpy/desktop/view/shortcut_registry.py | 1 + negpy/desktop/view/sidebar/controls_panel.py | 7 + negpy/desktop/view/sidebar/geometry.py | 41 +- negpy/desktop/workers/render.py | 42 +- negpy/features/geometry/models.py | 3 + negpy/features/lens/__init__.py | 1 + negpy/features/lens/logic.py | 73 ++++ negpy/features/lens/models.py | 54 +++ negpy/infrastructure/loaders/lens_metadata.py | 171 ++++++++ negpy/infrastructure/loaders/rawpy_loader.py | 12 +- negpy/services/rendering/image_processor.py | 13 +- negpy/services/rendering/lens.py | 33 ++ negpy/services/rendering/preview_cache.py | 2 + negpy/services/rendering/preview_manager.py | 20 +- negpy/services/rendering/source_identity.py | 8 +- tests/test_batch_autocrop_worker.py | 27 +- tests/test_batch_norm_wb.py | 2 + tests/test_metadata_lens.py | 382 ++++++++++++++++++ 25 files changed, 878 insertions(+), 39 deletions(-) create mode 100644 negpy/features/lens/__init__.py create mode 100644 negpy/features/lens/logic.py create mode 100644 negpy/features/lens/models.py create mode 100644 negpy/infrastructure/loaders/lens_metadata.py create mode 100644 negpy/services/rendering/lens.py create mode 100644 tests/test_metadata_lens.py diff --git a/CLAUDE.md b/CLAUDE.md index f3910691e..812b3b096 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ Migrations that rewrite *rows* rather than a config payload need a repository, s - **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. - **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. - **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages. +- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its mode and flat-field token belong to the source identity. Composite and RGB+IR sources do not use it. - **Source bakes** run before either engine, on the linear source: flat-field, sensor unmix and every defect repair (IR, detected specks, painted heal strokes). Both engines re-upload that source per frame, so a bake reaches them parity-free and needs no shader. Each bake folds a token into `source_hash` to invalidate the engine cache. - **Working space**: scene-linear internally; the working OETF (Adobe RGB 1998 TRC — a pure 563/256 power, no linear segment) is the final engine step. Lab/toning compute CIELAB directly from linear, D65. Adobe RGB rather than a wide gamut because ProPhoto's imaginary primaries inflate chroma in the saturation and toning stages. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 674684ab0..e47b75daa 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -49,6 +49,7 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Physical model**: we treat the input as a **radiometric measurement**. Pixel values are linear transmittance captured by the sensor. * **Source corrections** (linear domain, before the log conversion): + * **Embedded lens correction** (`features/lens`): when enabled, single-file preview and export decode apply flat-field first, then cubic RGB inverse warps, before sensor unmix, user geometry and crop. Sony radial tables use their recorded knots; DNG WarpRectilinear uses the active-image optical center and per-plane radial/tangential coefficients. EXIF orientation and decoder crop offsets are accounted for. Edges replicate; unsupported instructions disable the warp. Only the mode is serialized. The decode cache includes the mode and flat-field content token. * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff using a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The reference is decoded and the gain **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so the render never touches the original reference file. Moving or deleting it is harmless. The per-image edit stores only the profile id. The render path resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py` at startup) and caches it, and the profile id plus a content token of the gain are folded into the render's source hash. * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, the camera's CFA passbands overlap the light source's bands, so a pure R/G/B exposure leaks into the other channels. That is a fixed property of the sensor and light pair, independent of film. It is calibrated once from three bare-light exposures, with response columns normalized to a unit diagonal so per-capture exposure cancels, then inverted. It is applied as a 3×3 unmix of the **linear** capture, ahead of the log and inversion where the film-dye crosstalk below lives. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 90855e6c7..72b24cf86 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -511,6 +511,7 @@ Where the frame gets its final shape: what is inside the print, and whether it s Both replicate a wedge along the squeezed edge, as Fine Rotation does; crop it off. Crop before correcting if you can, because the meters read the corrected frame: on an uncropped scan a big correction pulls rebate and surround into the metered area and the print darkens. * **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight-edge reference. Corrected before Tilt and Swing. +* **From metadata**: use the source file's embedded scanning-lens correction; replaces manual distortion. The readout shows available/active distortion and lateral chromatic aberration, or why correction is unavailable. Supports Sony ARW coefficients and DNG WarpRectilinear (including DNG 1.7 containers); other DNG warp types, composites and RGB+IR are unsupported. Lens names and camera on/off flags alone do not enable it. Choose this before cropping or retouching; crop off replicated edge pixels if needed. The mode is saved with the edit, while each file supplies its own coefficients. ### 5.2 Flat Field: even out the light diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 47dd89951..f7578e986 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -152,6 +152,7 @@ from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES from negpy.services.rendering.preview_manager import PreviewManager from negpy.services.rendering.source_identity import source_token +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled from negpy.services.view.coordinate_mapping import CoordinateMapping logger = get_logger(__name__) @@ -236,6 +237,7 @@ def _autocrop_fingerprint(config: WorkspaceConfig, workspace_color_space: str) - int(geometry.autocrop_offset), round(float(geometry.autocrop_rebate_trim), 4), round(float(geometry.distortion_k1), 9), + bool(geometry.lens_from_metadata), bool(flatfield.apply), str(flatfield.profile_id), bool(config.process.linear_raw), @@ -1969,6 +1971,8 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b flatfield_profile_id=flatfield.profile_id if (stitch.stitch_enabled and flatfield.apply) else "", half_slice=half_info, demosaic=self.state.config.process.demosaic_preview, + lens_from_metadata=metadata_lens_enabled(self.state.config), + lens_flatfield=self.state.config.flatfield, ) ) @@ -2037,7 +2041,10 @@ def _on_preview_loaded( if ir_preview is not None: ir_preview, _ = self._split_active_half(ir_preview, None) self.state.preview_raw = raw - self.state.preview_cam_xyz, self.state.preview_camera_wb = cam_matrix or (None, None) + self.state.preview_cam_xyz, self.state.preview_camera_wb = cam_matrix[:2] if cam_matrix else (None, None) + self.state.preview_lens = cam_matrix[2] if cam_matrix and len(cam_matrix) > 2 else None + self.state.preview_lens_path = file_path + self.state.preview_lens_token = cam_matrix[3] if cam_matrix and len(cam_matrix) > 3 else "" self.state.preview_proxy = _interactive_proxy(raw) self.state.preview_ir = ir_preview self.state.preview_ir_proxy = _interactive_ir_proxy(ir_preview, self.state.preview_proxy) @@ -2118,6 +2125,8 @@ def _neighbor_prefetch_task( protected_file_hashes=protected_file_hashes, half_slice=self._half_slice_for_asset(asset["path"], file_hash), demosaic=saved.process.demosaic_preview if saved else self.state.config.process.demosaic_preview, + lens_from_metadata=metadata_lens_enabled(saved or self.state.config), + lens_flatfield=(saved or self.state.config).flatfield, ) def _start_next_neighbor_prefetch(self) -> None: @@ -4480,6 +4489,10 @@ def request_render( before/after split instead of being displayed. """ self._render_debounce.stop() + lens_token = lens_decode_token(metadata_lens_enabled(self.state.config), self.state.config.flatfield) + if not ephemeral and self.state.current_file_path and lens_token != self.state.preview_lens_token: + self.load_file(self.state.current_file_path, preserve_zoom=True) + return # Any direct render exits the flat preview-peek. if config_override is None and self.state.flat_peek: diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 23c671cb1..c3fd51a86 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -27,6 +27,7 @@ from negpy.features.hdr.logic import resolve_anchor, seed_shadow_density from negpy.features.hdr.models import ANCHOR_EV_UNSET, HdrConfig, hdr_frame_paths from negpy.features.stitch.models import StitchConfig +from negpy.features.lens.models import LensMetadata from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.config import APP_CONFIG @@ -85,6 +86,9 @@ class AppState: # None for sources that carry no camera matrix (scanner TIFF, JPEG). preview_cam_xyz: Optional[list] = None preview_camera_wb: Optional[list] = None + preview_lens: Optional[LensMetadata] = None + preview_lens_path: str = "" + preview_lens_token: str = "" # Preview-resolution stand-in for preview_raw while HQ is on. Interactive frames render # against it. None when preview_raw is already small enough. preview_proxy: Optional[Any] = None diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index f03bf2d65..1ce721d8c 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -133,7 +133,7 @@ def _fmt_gear(values: tuple) -> str: _row("Fine Rotation", "geometry", "fine_rotation"), _row("Easel Tilt", "geometry", "converge_v"), _row("Easel Swing", "geometry", "converge_h"), - _row("Distortion Correction", "geometry", "distortion_k1", sticky=True), + _row("Lens Correction", "geometry", "distortion_k1", "lens_from_metadata", sticky=True), _row("Flip Horizontal", "geometry", "flip_horizontal", sticky=True), _row("Flip Vertical", "geometry", "flip_vertical", sticky=True), )), diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 1c3d3cde9..5056e2935 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -189,6 +189,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "crop_guide_next": lambda: controls.geometry_sidebar.cycle_guide(), "crop_guide_orient": controller.cycle_crop_guide_orientation, "auto_crop": lambda: controls.geometry_sidebar.reset_crop_btn.toggle(), + "lens_from_metadata": lambda: controls.geometry_sidebar.metadata_lens_btn.click(), "pick_dust": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_dust_btn), "pick_scratch": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_scratch_btn), "pick_scratch_line": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_line_btn), diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 0cec67172..8485aa597 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -49,6 +49,7 @@ class ShortcutEntry: "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), "crop_guide_next": ShortcutEntry("O", "Next crop guide overlay", "Geometry"), "crop_guide_orient": ShortcutEntry("Shift+O", "Rotate crop guide orientation", "Geometry"), + "lens_from_metadata": ShortcutEntry("", "Lens correction from embedded metadata", "Geometry"), "auto_crop": ShortcutEntry("Shift+A", "Toggle autocrop", "Geometry"), "pick_dust": ShortcutEntry("Shift+D", "Toggle heal tool", "Tools"), "pick_scratch": ShortcutEntry("Shift+S", "Toggle scratch tool", "Tools"), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index e77db6dd2..7faa65ec6 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -394,6 +394,12 @@ def apply_shortcut_tooltips(self) -> None: ret = self.retouch_sidebar ton = self.toning_sidebar fin = self.finish_sidebar + geo.metadata_lens_btn.setToolTip( + tooltip_with_shortcut( + "Apply embedded scanning-lens distortion and lateral CA correction. Replaces manual distortion.", + "lens_from_metadata", + ) + ) col.pick_wb_btn.setToolTip( tooltip_with_shortcut( @@ -881,6 +887,7 @@ def _sync_modified_dots(self) -> None: geo.autocrop_offset != _geo.autocrop_offset, geo.autocrop_rebate_trim != _geo.autocrop_rebate_trim, geo.distortion_k1 != _geo.distortion_k1, + geo.lens_from_metadata != _geo.lens_from_metadata, ] ) diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 0a055a8de..c7b8d1406 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -10,12 +10,15 @@ from negpy.desktop.view.canvas.crop_guides import GUIDE_LABELS, ORIENTATION_COUNT, CropGuide from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label, section_subheader, wrap_tooltip +from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label, hint_label, section_subheader, wrap_tooltip from negpy.desktop.view.widgets.sliders import CompactSlider from negpy.domain.models import CROP_RATIO_CHOICES, canonical_crop_ratio from negpy.features.geometry.logic import has_manual_crop from negpy.features.geometry.models import FINE_ROTATION_LIMIT, AutocropMode from negpy.features.process.models import invalidate_local_bounds +from negpy.features.lens.models import LensMetadata +from negpy.infrastructure.loaders.lens_metadata import read_lens_metadata +from negpy.services.rendering.lens import metadata_lens_enabled class GeometrySidebar(BaseSidebar): @@ -178,6 +181,39 @@ def _init_ui(self) -> None: "Radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight reference." ) self.layout.addWidget(self.distortion_slider) + self.metadata_lens_btn = self._labeled_toggle( + "fa5s.camera", + "From metadata", + conf.lens_from_metadata, + "Apply embedded scanning-lens correction. Replaces manual distortion.", + ) + self.lens_hint = hint_label("") + self.lens_hint.setWordWrap(True) + self.layout.addWidget(self.metadata_lens_btn) + self.layout.addWidget(self.lens_hint) + + def _set_metadata_lens(self, enabled: bool) -> None: + if enabled and not self.metadata_lens_btn.isEnabled(): + return + self.update_config_section("geometry", persist=True, lens_from_metadata=enabled) + + def _sync_metadata_lens(self) -> None: + config = self.state.config + lens = read_lens_metadata(self.state.current_file_path) + if self.state.preview_lens_path == self.state.current_file_path and self.state.preview_lens is not None: + lens = self.state.preview_lens + requested = replace(config, geometry=replace(config.geometry, lens_from_metadata=True)) + if not metadata_lens_enabled(requested): + lens = LensMetadata(reason="Embedded lens correction is unavailable for composites.") + if self.state.has_ir: + lens = LensMetadata(reason="Embedded lens correction is unavailable for RGB+IR sources.") + enabled = config.geometry.lens_from_metadata + self.metadata_lens_btn.setChecked(enabled) + self.metadata_lens_btn.setEnabled(lens.available or enabled) + self.metadata_lens_btn.edited_dot.set_active(enabled) + state = "Active" if enabled and lens.available else "Available" if lens.available else "Unavailable" + self.lens_hint.setText(f"{state}: {lens.description}") + self.distortion_slider.setEnabled(not enabled) def cycle_guide(self) -> None: self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count()) @@ -187,6 +223,7 @@ def _sync_guide_orient_btn(self) -> None: self.guide_orient_btn.setEnabled(ORIENTATION_COUNT.get(CropGuide(guide), 1) > 1 if guide else False) def _connect_signals(self) -> None: + self.metadata_lens_btn.toggled.connect(self._set_metadata_lens) self.guide_combo.currentIndexChanged.connect(lambda _i: self.controller.set_crop_guide(self.guide_combo.currentData())) self.guide_combo.currentIndexChanged.connect(lambda _i: self._sync_guide_orient_btn()) self.guide_orient_btn.clicked.connect(self.controller.cycle_crop_guide_orientation) @@ -295,6 +332,7 @@ def sync_ui(self) -> None: self.converge_v_slider.setValue(conf.converge_v) self.converge_h_slider.setValue(conf.converge_h) self.distortion_slider.setValue(conf.distortion_k1) + self._sync_metadata_lens() self.manual_crop_btn.setChecked(self.state.active_tool == ToolMode.CROP_MANUAL) self.straighten_btn.setChecked(self.state.active_tool == ToolMode.STRAIGHTEN) @@ -318,6 +356,7 @@ def block_signals(self, blocked: bool) -> None: self.converge_v_slider.blockSignals(blocked) self.converge_h_slider.blockSignals(blocked) self.distortion_slider.blockSignals(blocked) + self.metadata_lens_btn.blockSignals(blocked) self.manual_crop_btn.blockSignals(blocked) self.straighten_btn.blockSignals(blocked) self.reset_crop_btn.blockSignals(blocked) diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 4ce559a13..18891a259 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -11,6 +11,8 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.analysis import color_histogram, output_histogram, proof_grid, rotate_grid, strip_mosaic from negpy.features.flatfield.logic import apply_flatfield +from negpy.features.flatfield.models import FlatFieldConfig +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled from negpy.features.hdr.models import HdrConfig, hdr_active from negpy.features.geometry.batch_autocrop import CropEvidence, detect_crop_candidate, resolve_roll_crops from negpy.features.process.capture_color import wb_only_cam_xyz @@ -232,6 +234,8 @@ class PreviewLoadTask: stitch: StitchConfig = StitchConfig() # composite: non-primary parts + stored registration hdr: HdrConfig = HdrConfig() # bracket: the other exposures, merged with file_path (the reference) flatfield_profile_id: str = "" # per-part flat-field profile for stitch previews + lens_from_metadata: bool = False + lens_flatfield: FlatFieldConfig = FlatFieldConfig() demosaic: str = DemosaicMode.AUTO # CFA interpolation for the preview decode half_slice: tuple[int, float, tuple[float, float, float, float] | None, float] | None = ( None # (half, split_x, crop_rect, gutter_thickness) @@ -1067,6 +1071,8 @@ def _process_prefetch(self, task: PreviewLoadTask) -> None: should_cancel=lambda: not self._prefetch_is_current(task), highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, + lens_from_metadata=task.lens_from_metadata, + lens_flatfield=task.lens_flatfield, ) except InterruptedError: pass @@ -1120,7 +1126,12 @@ def cancelled() -> bool: source_cs, ir_preview, detected_mode, - (metadata.get("cam_xyz"), metadata.get("camera_wb")), + ( + metadata.get("cam_xyz"), + metadata.get("camera_wb"), + metadata.get("lens_correction"), + lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1159,7 +1170,12 @@ def cancelled() -> bool: source_cs, ir_preview, detected_mode, - (metadata.get("cam_xyz"), metadata.get("camera_wb")), + ( + metadata.get("cam_xyz"), + metadata.get("camera_wb"), + metadata.get("lens_correction"), + lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1196,7 +1212,12 @@ def cancelled() -> bool: source_cs, ir_preview, detected_mode, - (metadata.get("cam_xyz"), metadata.get("camera_wb")), + ( + metadata.get("cam_xyz"), + metadata.get("camera_wb"), + metadata.get("lens_correction"), + lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1215,6 +1236,8 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, + lens_from_metadata=task.lens_from_metadata, + lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): return @@ -1235,6 +1258,8 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, + lens_from_metadata=task.lens_from_metadata, + lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): return @@ -1256,7 +1281,12 @@ def cancelled() -> bool: source_cs, ir_preview, detected_mode, - (metadata.get("cam_xyz"), metadata.get("camera_wb")), + ( + metadata.get("cam_xyz"), + metadata.get("camera_wb"), + metadata.get("lens_correction"), + lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + ), metadata.get("detect_preview"), ) except InterruptedError: @@ -1361,6 +1391,8 @@ def _decode_asset_preview_with_meta( positive_source=config.process.positive_source, highlight_mode=effective_highlight_reconstruction(config.process), bake_camera_wb=highlight_reconstruction_bakes_wb(config.process, config.exposure.render_intent), + lens_from_metadata=metadata_lens_enabled(config), + lens_flatfield=config.flatfield, **common, ) return slice_for_asset(raw, file_info), meta @@ -1433,7 +1465,7 @@ def _frame_evidence(self, index: int, frame: BatchAutoCropInput, task: BatchAuto return None config = frame.config - corrected = apply_flatfield(raw, config.flatfield) + corrected = raw if metadata_lens_enabled(config) else apply_flatfield(raw, config.flatfield) detection_geometry = replace( config.geometry, crop_rect=None, diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index 9b66aba45..99d1979ad 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -102,6 +102,7 @@ class AutocropMode(StrEnum): @dataclass(frozen=True) class GeometryConfig: + lens_from_metadata: bool = False rotation: int = 0 fine_rotation: float = 0.0 flip_horizontal: bool = False @@ -138,6 +139,8 @@ def __post_init__(self) -> None: """Ensure a JSON-loaded list is converted back to a tuple, keeping the frozen dataclass hashable for pipeline cache keys. Enum fields coerce so a retired or hand-edited saved value degrades to the default, not a load failure.""" + if self.lens_from_metadata: + object.__setattr__(self, "distortion_k1", 0.0) if self.crop_rect is not None: object.__setattr__(self, "crop_rect", tuple(self.crop_rect)) if self.autocrop_mode not in (AutocropMode.IMAGE, AutocropMode.FILM): diff --git a/negpy/features/lens/__init__.py b/negpy/features/lens/__init__.py new file mode 100644 index 000000000..b41e4e503 --- /dev/null +++ b/negpy/features/lens/__init__.py @@ -0,0 +1 @@ +"""Lens correction from source-file coefficients.""" diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py new file mode 100644 index 000000000..d7b29d0c4 --- /dev/null +++ b/negpy/features/lens/logic.py @@ -0,0 +1,73 @@ +"""Inverse lens maps in the scanning camera's linear RGB coordinates.""" + +import cv2 +import numpy as np + +from negpy.domain.types import ImageBuffer +from negpy.features.lens.models import LensMetadata, RectilinearWarp, SonyWarp +from negpy.kernel.image.logic import apply_exif_orientation + + +def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: + h, w = shape[:2] + t, left, b, r = lens.active_area or (0, 0, h, w) + bt, bl, bb, br = lens.buffer_area or (t, left, b, r) + sx, sy = (br - bl) / w, (bb - bt) / h + cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) + radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) + x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius + y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius + return x, y, cx, cy, radius, sx, sy, bl, bt + + +def _dng_maps( + lens: LensMetadata, warp: RectilinearWarp, shape: tuple[int, ...], start: int, stop: int, channel: int +) -> tuple[np.ndarray, np.ndarray]: + x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, warp.center) + k0, k1, k2, k3, t0, t1 = warp.coefficients[0 if len(warp.coefficients) == 1 else channel] + r2 = x * x + y * y + factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) + mx = (cx + radius * (x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x)) - left + 0.5) / sx - 0.5 + my = (cy + radius * (y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y)) - top + 0.5) / sy - 0.5 + return mx.astype(np.float32), my.astype(np.float32) + + +def _sony_maps(warp: SonyWarp, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). + # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc + h, w = shape[:2] + x = np.arange(w, dtype=np.float32)[None, :] - w * 0.5 + y = np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5 + radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) + n = len(warp.distortion) or len(warp.ca_red) + knots = (np.arange(n) + 0.5) / (n - 1) + factors = np.ones(n) + if warp.distortion: + factors += np.asarray(warp.distortion) / 16384.0 + ca = warp.ca_red if channel == 0 else warp.ca_blue if channel == 2 else () + if ca: + factors *= 1 + np.asarray(ca) / 2097152.0 + factor = np.interp(radius, knots, factors).astype(np.float32) + return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) + + +def apply_lens(img: ImageBuffer, lens: LensMetadata, orientation: int = 1) -> ImageBuffer: + """Apply all supported embedded warps, with bounded temporary map memory.""" + if not lens.available or min(img.shape[:2]) < 2: + return img + inverse_orientation = {6: 8, 8: 6}.get(orientation, orientation) + source = np.ascontiguousarray(apply_exif_orientation(img, inverse_orientation)) + for warp in lens.warps: + h, w = source.shape[:2] + result = np.empty_like(source) + for channel in range(3): + plane = np.ascontiguousarray(source[..., channel]) + for start in range(0, h, 256): + stop = min(start + 256, h) + if isinstance(warp, SonyWarp): + mx, my = _sony_maps(warp, source.shape, start, stop, channel) + else: + mx, my = _dng_maps(lens, warp, source.shape, start, stop, channel) + result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) + source = np.clip(result, 0.0, 1.0, out=result) + return np.ascontiguousarray(apply_exif_orientation(source, orientation)) diff --git a/negpy/features/lens/models.py b/negpy/features/lens/models.py new file mode 100644 index 000000000..363150891 --- /dev/null +++ b/negpy/features/lens/models.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass + + +IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +@dataclass(frozen=True) +class RectilinearWarp: + coefficients: tuple[tuple[float, ...], ...] + center: tuple[float, float] = (0.5, 0.5) + + +@dataclass(frozen=True) +class SonyWarp: + distortion: tuple[float, ...] = () + ca_red: tuple[float, ...] = () + ca_blue: tuple[float, ...] = () + + +@dataclass(frozen=True) +class LensMetadata: + source: str = "" + warps: tuple[RectilinearWarp | SonyWarp, ...] = () + reason: str = "No embedded lens correction data." + # DNG opcodes use the active image, before DefaultCrop and EXIF orientation. + active_area: tuple[int, int, int, int] | None = None + buffer_area: tuple[int, int, int, int] | None = None + + @property + def distortion(self) -> bool: + return any( + any(w.distortion) if isinstance(w, SonyWarp) else w.coefficients[0 if len(w.coefficients) == 1 else 1] != IDENTITY + for w in self.warps + ) + + @property + def ca(self) -> bool: + return any( + any(w.ca_red) or any(w.ca_blue) + if isinstance(w, SonyWarp) + else len(w.coefficients) == 3 and (w.coefficients[0] != w.coefficients[1] or w.coefficients[2] != w.coefficients[1]) + for w in self.warps + ) + + @property + def available(self) -> bool: + return self.distortion or self.ca + + @property + def description(self) -> str: + if not self.available: + return self.reason + corrections = " + ".join(label for enabled, label in ((self.distortion, "distortion"), (self.ca, "lateral CA")) if enabled) + return f"{self.source}: {corrections}" diff --git a/negpy/infrastructure/loaders/lens_metadata.py b/negpy/infrastructure/loaders/lens_metadata.py new file mode 100644 index 000000000..15d6a4d71 --- /dev/null +++ b/negpy/infrastructure/loaders/lens_metadata.py @@ -0,0 +1,171 @@ +"""Bounded readers for Sony ARW coefficients and DNG OpcodeList3.""" + +import os +import struct +from dataclasses import replace +from functools import lru_cache +from itertools import islice +from typing import Any + +import numpy as np +import tifffile + +from negpy.features.lens.models import IDENTITY, LensMetadata, RectilinearWarp, SonyWarp + +_MAX_OPCODE_BYTES = 4 * 1024 * 1024 + + +def parse_opcodes(data: bytes) -> tuple[RectilinearWarp, ...]: + """Read DNG 1.3 rectilinear warps; reject incomplete or unsupported lists.""" + if len(data) < 4 or len(data) > _MAX_OPCODE_BYTES: + raise ValueError("Invalid DNG correction data size.") + count = struct.unpack_from(">I", data)[0] + if count > 32: + raise ValueError("Too many DNG correction instructions.") + offset = 4 + warps = [] + for _ in range(count): + if offset + 16 > len(data): + raise ValueError("Incomplete DNG correction header.") + opcode, version, flags, size = struct.unpack_from(">4I", data, offset) + offset += 16 + end = offset + size + if end > len(data) or flags & ~3: + raise ValueError("Invalid DNG correction instruction.") + if opcode != 1 or version > 0x01030000: + raise ValueError("Unsupported DNG correction instruction (requires WarpRectilinear).") + if size < 4: + raise ValueError("Incomplete DNG warp.") + planes = struct.unpack_from(">I", data, offset)[0] + if planes not in (1, 3) or size != 4 + 48 * planes + 16: + raise ValueError("Unsupported DNG warp plane count or size.") + values = struct.unpack_from(f">{6 * planes + 2}d", data, offset + 4) + if not all(np.isfinite(values)) or not all(0 <= v <= 1 for v in values[-2:]): + raise ValueError("Invalid DNG warp coefficients or optical center.") + coeffs = tuple(tuple(values[i * 6 : (i + 1) * 6]) for i in range(planes)) + # A folding radial map is not a lens correction. Tangential folds are checked below. + r2 = np.linspace(0, 1, 257) + for k0, k1, k2, k3, t0, t1 in coeffs: + if max(abs(v) for v in (k0, k1, k2, k3, t0, t1)) > 16: + raise ValueError("DNG warp coefficients are out of range.") + derivative = k0 + r2 * (3 * k1 + r2 * (5 * k2 + 7 * k3 * r2)) + if np.min(derivative) <= 6 * (abs(t0) + abs(t1)): + raise ValueError("DNG warp may fold the image.") + if any(k != IDENTITY for k in coeffs): + warps.append(RectilinearWarp(coeffs, (values[-2], values[-1]))) + offset = end + if offset != len(data): + raise ValueError("Trailing data in DNG correction list.") + return tuple(warps) + + +def _sony_values(tags: Any, code: int, channels: int) -> tuple[float, ...]: + tag = tags.get(code) + if tag is None: + return () + if int(tag.dtype) != 8 or tag.count > 33: + raise ValueError("Unsupported Sony correction data type.") + values = tuple(tag.value) + n = values[0] if values else 0 + if n < 2 * channels or n > 16 * channels or n % channels or len(values) not in (n + 1, 16 * channels + 1): + raise ValueError("Invalid Sony correction coefficient count.") + return tuple(float(v) for v in values[1 : n + 1]) + + +def _read_sony(page: Any) -> LensMetadata: + distortion = _sony_values(page.tags, 0x7037, 1) + ca = _sony_values(page.tags, 0x7035, 2) + for code, values in ((0x7036, distortion), (0x7034, ca)): + status = page.tags.get(code) + if values and status is not None and status.value == 255: + raise ValueError("Sony marks the correction coefficients as unavailable.") + if distortion and ca and len(ca) != 2 * len(distortion): + raise ValueError("Sony correction arrays have different lengths.") + warp = SonyWarp(distortion, ca[: len(ca) // 2], ca[len(ca) // 2 :]) + if any(1 + v / 16384 <= 0 for v in distortion): + raise ValueError("Invalid Sony distortion coefficients.") + return LensMetadata("Sony ARW", (warp,), "No nonzero Sony lens correction coefficients.") + + +def _read_dng(page: Any) -> LensMetadata: + opcode = page.tags.get(51022) + if opcode is None: + return LensMetadata(reason="No embedded DNG lens warp (OpcodeList3).") + if opcode.count > _MAX_OPCODE_BYTES or int(opcode.dtype) not in (1, 7): + raise ValueError("Invalid DNG correction data size or type.") + # Non-square source pixels need a separate coordinate transform. + scale = page.tags.get(50718) + if scale is not None and tuple(scale.value) not in ((1, 1, 1, 1), (1, 1)): + raise ValueError("DNG lens correction requires square source pixels.") + colors = page.tags.get(50710) + if colors is not None and tuple(colors.value) != (0, 1, 2): + raise ValueError("Unsupported DNG color plane order.") + area_tag = page.tags.get(50829) + area = tuple(int(v) for v in area_tag.value) if area_tag is not None else (0, 0, page.imagelength, page.imagewidth) + if len(area) != 4 or not (0 <= area[0] < area[2] <= page.imagelength and 0 <= area[1] < area[3] <= page.imagewidth): + raise ValueError("Invalid DNG active image area.") + buffer_area = area + origin_tag, size_tag = page.tags.get(50719), page.tags.get(50720) + if origin_tag is not None and size_tag is not None: + + def numbers(tag: Any) -> tuple[int, ...]: + values = tag.value + if int(tag.dtype) in (5, 10): + values = tuple(n / d for n, d in zip(values[::2], values[1::2])) + return tuple(round(v) for v in values) + + ox, oy = numbers(origin_tag) + width, height = numbers(size_tag) + buffer_area = (area[0] + oy, area[1] + ox, area[0] + oy + height, area[1] + ox + width) + if not (area[0] <= buffer_area[0] < buffer_area[2] <= area[2] and area[1] <= buffer_area[1] < buffer_area[3] <= area[3]): + raise ValueError("Invalid DNG default crop.") + return LensMetadata("DNG WarpRectilinear", parse_opcodes(bytes(opcode.value)), "Embedded DNG warp is an identity.", area, buffer_area) + + +def read_lens_metadata(file_path: str | None) -> LensMetadata: + """Inspect source metadata without decoding pixels; cache against the file revision.""" + if not file_path: + return LensMetadata(reason="Load a Sony ARW or DNG file.") + if os.path.splitext(file_path)[1].lower() not in (".arw", ".dng"): + return LensMetadata(reason="Embedded correction supports Sony ARW and DNG WarpRectilinear.") + try: + stat = os.stat(file_path) + return _read_cached(os.path.abspath(file_path), stat.st_mtime_ns, stat.st_size) + except OSError: + return LensMetadata(reason="Cannot read source lens metadata.") + + +@lru_cache(maxsize=128) +def _read_cached(file_path: str, mtime_ns: int, size: int) -> LensMetadata: + try: + with tifffile.TiffFile(file_path) as tif: + pages = list(islice(tif.pages, 16)) + for parent in tuple(pages): + if parent.pages is not None: + pages.extend(islice(parent.pages, 16)) + raw_pages = [p for p in pages if int(p.photometric) in (32803, 34892) and p.samplesperpixel in (1, 3)] + if not raw_pages: + return LensMetadata(reason="No supported RAW image plane; rendered images are not corrected again.") + page = max(raw_pages, key=lambda p: p.imagewidth * p.imagelength) + return _read_dng(page) if file_path.lower().endswith(".dng") else _read_sony(page) + except ValueError as exc: + return LensMetadata(reason=str(exc)) + except (OSError, TypeError, struct.error, IndexError, OverflowError, ZeroDivisionError): + return LensMetadata(reason="Cannot read embedded lens correction data.") + + +def bind_decode(lens: LensMetadata, raw: Any, fallback: bool = False) -> LensMetadata: + """Locate LibRaw's visible pixels within the DNG active image.""" + if not lens.available or lens.active_area is None: + return lens + sizes = raw.sizes + if fallback: + area = lens.buffer_area or lens.active_area + if (sizes.raw_height, sizes.raw_width) != (area[2] - area[0], area[3] - area[1]): + return LensMetadata(reason="DNG fallback crop does not match the lens correction area.") + return replace(lens, buffer_area=area) + area = (sizes.top_margin, sizes.left_margin, sizes.top_margin + sizes.height, sizes.left_margin + sizes.width) + t, left, b, r = lens.active_area + if not (t <= area[0] < area[2] <= b and left <= area[1] < area[3] <= r): + return LensMetadata(reason="Decoded DNG area does not match the lens correction area.") + return replace(lens, buffer_area=area) diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py index 74536a829..cc683b6b3 100644 --- a/negpy/infrastructure/loaders/rawpy_loader.py +++ b/negpy/infrastructure/loaders/rawpy_loader.py @@ -18,6 +18,7 @@ read_orientation, ) from negpy.infrastructure.loaders.ir_planes import find_ir_plane +from negpy.infrastructure.loaders.lens_metadata import bind_decode, read_lens_metadata from negpy.infrastructure.loaders.memory import PreviewMemoryEstimate from negpy.kernel.system.logging import get_logger @@ -332,6 +333,7 @@ def tag(name: str) -> Optional[Any]: neutral = _tag_floats(page0.tags.get("AsShotNeutral")) crop_origin = _tag_floats(tag("DefaultCropOrigin")) crop_size = _tag_floats(tag("DefaultCropSize")) + active_area = _tag_floats(tag("ActiveArea")) except Exception as e: logger.warning(f"Linear DNG peek failed for {file_path}: {e}") return None @@ -341,6 +343,11 @@ def tag(name: str) -> Optional[Any]: white3 = _broadcast3(white, dtype_max) denominator = np.maximum(white3 - black3, 1e-6) + if len(active_area) == 4: + top, left, bottom, right = (int(v) for v in active_area) + if 0 <= top < bottom <= data.shape[0] and 0 <= left < right <= data.shape[1]: + data = data[top:bottom, left:right] + if len(crop_origin) >= 2 and len(crop_size) >= 2: ox, oy = int(round(crop_origin[0])), int(round(crop_origin[1])) cw, ch = int(round(crop_size[0])), int(round(crop_size[1])) @@ -543,7 +550,9 @@ def load( "color_space": None, "ir": None, } - return NonStandardFileWrapper(rgb, wb_gains=wb_gains), metadata + wrapper = NonStandardFileWrapper(rgb, wb_gains=wb_gains) + metadata["lens_correction"] = bind_decode(read_lens_metadata(file_path), wrapper, fallback=True) + return wrapper, metadata else: raw = rawpy.imread(file_path) if should_cancel is not None and should_cancel(): @@ -558,6 +567,7 @@ def load( "ir": _peek_hdri_ir_page(file_path), } + metadata["lens_correction"] = bind_decode(read_lens_metadata(file_path), raw) return raw, metadata def load_bounded_preview( diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index a3ca3ae7a..913816674 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -38,6 +38,7 @@ from negpy.features.exposure.analysis import COLOR_HIST_BINS from negpy.features.exposure.models import RenderIntent from negpy.features.flatfield.logic import apply_flatfield, flatfield_token +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled, prepare_lens_source from negpy.features.geometry.logic import autocrop_detection_key, resolve_autocrop_rect from negpy.features.retouch.logic import ( apply_hair_inpaint, @@ -650,6 +651,7 @@ def run_pipeline( source_hash, img.shape, skip_flatfield, + metadata_lens_enabled(settings), flatfield_token(settings.flatfield), sensor_token(settings.process), rgbscan_token(settings.rgbscan), @@ -660,7 +662,7 @@ def run_pipeline( img = self._precorrect_value else: source = img - if not skip_flatfield and not settings.stitch.stitch_enabled: + if not skip_flatfield and not settings.stitch.stitch_enabled and not metadata_lens_enabled(settings): img = apply_flatfield(img, settings.flatfield) # Sensor unmix is a source pre-correction like flat-field. skip_flatfield buffers # come from _load_source_f32, which already applied it. Triplet composites take @@ -679,6 +681,7 @@ def run_pipeline( base_hash = ( source_hash + flatfield_token(settings.flatfield) + + lens_decode_token(metadata_lens_enabled(settings), settings.flatfield) + rgbscan_token(settings.rgbscan) + stitch_token(settings.stitch) + hdr_token(settings.hdr) @@ -942,6 +945,7 @@ def _load_source_f32( cache_key = ( file_path, mtime, + lens_decode_token(metadata_lens_enabled(params), params.flatfield), effective_linear_raw(params.process, params.exposure.render_intent), effective_highlight_reconstruction(params.process), highlight_reconstruction_bakes_wb(params.process, params.exposure.render_intent), @@ -1120,7 +1124,10 @@ def _decode(path: str) -> np.ndarray: orientation = metadata.get("orientation", 1) f32_buffer = apply_exif_orientation(f32_buffer, orientation) - f32_buffer = apply_flatfield(f32_buffer, params.flatfield) + if metadata_lens_enabled(params): + f32_buffer = prepare_lens_source(f32_buffer, metadata, params.flatfield) + else: + f32_buffer = apply_flatfield(f32_buffer, params.flatfield) if not is_triplet: f32_buffer = apply_sensor_correction(f32_buffer, effective_sensor_matrix(params.process)) if ir_full is not None: @@ -1197,6 +1204,7 @@ def _prepare_export_source_locked( detect_key = ( source_hash + flatfield_token(params.flatfield) + + lens_decode_token(metadata_lens_enabled(params), params.flatfield) + rgbscan_token(params.rgbscan) + stitch_token(params.stitch) + hdr_token(params.hdr) @@ -1651,6 +1659,7 @@ def render_display_array( detect_key = ( source_hash + flatfield_token(params.flatfield) + + lens_decode_token(metadata_lens_enabled(params), params.flatfield) + rgbscan_token(params.rgbscan) + stitch_token(params.stitch) + hdr_token(params.hdr) diff --git a/negpy/services/rendering/lens.py b/negpy/services/rendering/lens.py new file mode 100644 index 000000000..fdd52ce69 --- /dev/null +++ b/negpy/services/rendering/lens.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from negpy.domain.types import ImageBuffer +from negpy.features.flatfield.logic import apply_flatfield, flatfield_token +from negpy.features.flatfield.models import FlatFieldConfig +from negpy.features.lens.logic import apply_lens +from negpy.features.lens.models import LensMetadata +from negpy.features.hdr.models import hdr_active +from negpy.features.rgbscan.models import is_rgb_triplet +from negpy.features.stitch.models import stitch_active + +if TYPE_CHECKING: + from negpy.domain.models import WorkspaceConfig + + +def metadata_lens_enabled(config: "WorkspaceConfig") -> bool: + """Composite registrations refer to the unwarped component images.""" + return config.geometry.lens_from_metadata and not ( + stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan) + ) + + +def lens_decode_token(enabled: bool, flatfield: FlatFieldConfig) -> str: + return "|embedded-lens-v1" + flatfield_token(flatfield) if enabled else "" + + +def prepare_lens_source(img: ImageBuffer, metadata: dict, flatfield: FlatFieldConfig) -> ImageBuffer: + """Flat-field in sensor positions before a lens warp moves the samples.""" + img = apply_flatfield(img, flatfield) + lens = metadata.get("lens_correction") + if isinstance(lens, LensMetadata) and metadata.get("ir") is None: + img = apply_lens(img, lens, metadata.get("orientation", 1)) + return img diff --git a/negpy/services/rendering/preview_cache.py b/negpy/services/rendering/preview_cache.py index 8dd1a3652..97f5b3500 100644 --- a/negpy/services/rendering/preview_cache.py +++ b/negpy/services/rendering/preview_cache.py @@ -39,6 +39,7 @@ class PreviewCacheKey: positive_source: bool = False highlight_mode: int = 0 bake_camera_wb: bool = False + lens_token: str = "" def as_tuple(self) -> Hashable: return ( @@ -54,6 +55,7 @@ def as_tuple(self) -> Hashable: self.positive_source, self.highlight_mode, self.bake_camera_wb, + self.lens_token, ) diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 86fde5958..8ad9a2861 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -27,6 +27,7 @@ from negpy.kernel.system.override import effective_max_texture_size from negpy.features.flatfield.logic import apply_flatfield, flatfield_token from negpy.features.flatfield.models import FlatFieldConfig +from negpy.services.rendering.lens import lens_decode_token, prepare_lens_source from negpy.features.retouch.logic import downsample_ir from negpy.features.hdr.logic import apply_render_exposure, merge_providers, resolve_anchor from negpy.features.hdr.models import HdrConfig, hdr_merge_token @@ -229,6 +230,8 @@ def _load_from_open_raw( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, + lens_from_metadata: bool = False, + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ Decode and resize a linear preview from an already-open raw object. @@ -339,6 +342,8 @@ def _load_from_open_raw( # Bake EXIF orientation into the buffer (postprocess runs with user_flip=0). orientation = metadata.get("orientation", 1) full_linear = apply_exif_orientation(uint16_to_float32(np.ascontiguousarray(rgb)), orientation) + if lens_from_metadata: + full_linear = prepare_lens_source(full_linear, metadata, lens_flatfield) del rgb # release the uint16 decode buffer before the resize/copy peak if should_cancel is not None and should_cancel(): raise InterruptedError("preview load cancelled") @@ -446,6 +451,7 @@ def _load_from_open_raw( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, + lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -505,6 +511,8 @@ def load_linear_preview( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, + lens_from_metadata: bool = False, + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ Loads linear RGB, downsamples for display. @@ -528,6 +536,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, + lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -559,6 +568,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, + lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -592,6 +602,8 @@ def load_linear_preview( highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, wb_override=wb_override, + lens_from_metadata=lens_from_metadata, + lens_flatfield=lens_flatfield, ) log( "load-timing load_linear_preview %.0fms (decode %.0fms + open)", @@ -899,6 +911,8 @@ def load_splash_and_linear( should_cancel: Optional[Callable[[], bool]] = None, highlight_mode: int = 0, bake_camera_wb: bool = False, + lens_from_metadata: bool = False, + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[Optional[Tuple[ImageBuffer, Dimensions]], Tuple[ImageBuffer, Dimensions, dict]]: """ Open the RAW file once and return both the splash preview and the linear @@ -920,6 +934,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, + lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -955,6 +970,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, + lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -972,7 +988,7 @@ def load_splash_and_linear( log = logger.info if log_timings else logger.debug splash_result: Optional[Tuple[ImageBuffer, Dimensions]] = None with ctx_mgr as raw: - if not full_resolution: + if not full_resolution and not lens_from_metadata: splash_result = self._try_splash_from_open_raw(raw, file_path, half_slice=half_slice) linear_result = self._load_from_open_raw( raw, @@ -989,6 +1005,8 @@ def load_splash_and_linear( should_cancel=should_cancel, highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, + lens_from_metadata=lens_from_metadata, + lens_flatfield=lens_flatfield, ) log( "load-timing load_splash_and_linear %.0fms (decode %.0fms + open)", diff --git a/negpy/services/rendering/source_identity.py b/negpy/services/rendering/source_identity.py index 7681d03ea..0788a312d 100644 --- a/negpy/services/rendering/source_identity.py +++ b/negpy/services/rendering/source_identity.py @@ -20,6 +20,7 @@ ) from negpy.features.rgbscan.logic import rgbscan_token from negpy.features.stitch.models import stitch_token +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled def source_token(config: WorkspaceConfig) -> str: @@ -39,11 +40,10 @@ def source_token(config: WorkspaceConfig) -> str: rgbscan_token(config.rgbscan), stitch_token(config.stitch), hdr_token(config.hdr), + lens_decode_token(metadata_lens_enabled(config), config.flatfield), ] if config.stitch.stitch_enabled: - # Stitch is the only assembly that flat-fields during the decode, per part and before the - # warp, because a canvas-wide gain map would stretch across the seam. Everywhere else - # flat-field is a render stage, and including it here would force a needless re-decode - # every time the profile is switched. + # Stitch flat-fields each part before assembly; embedded lens mode carries this + # dependency in lens_decode_token. Other frames flat-field at render time. parts.append(flatfield_token(config.flatfield)) return "".join(parts) diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py index c1df8ab3f..0c46a2083 100644 --- a/tests/test_batch_autocrop_worker.py +++ b/tests/test_batch_autocrop_worker.py @@ -31,6 +31,8 @@ def load_linear_preview( positive_source=False, # noqa: ARG002 — accepted, not asserted on highlight_mode=0, # noqa: ARG002 — accepted, not asserted on bake_camera_wb=False, # noqa: ARG002 — accepted, not asserted on + lens_from_metadata=False, # noqa: ARG002 — accepted, not asserted on + lens_flatfield=None, # noqa: ARG002 — accepted, not asserted on ): self.linear_calls.append( { @@ -203,31 +205,10 @@ def test_batch_autocrop_per_file_failure_does_not_abort_roll(qapp, monkeypatch) base = WorkspaceConfig() class _FailFirstPreview(_PreviewService): - def load_linear_preview( - self, - file_path, - color_space, - use_camera_wb, - full_resolution, - file_hash, - demosaic="Auto", - positive_source=False, - highlight_mode=0, - bake_camera_wb=False, - ): + def load_linear_preview(self, file_path, color_space, use_camera_wb, full_resolution, file_hash, demosaic="Auto", **kwargs): if file_hash == "hash-bad": raise RuntimeError("broken preview") - return super().load_linear_preview( - file_path, - color_space, - use_camera_wb, - full_resolution, - file_hash, - demosaic, - positive_source, - highlight_mode, - bake_camera_wb, - ) + return super().load_linear_preview(file_path, color_space, use_camera_wb, full_resolution, file_hash, demosaic, **kwargs) preview = _FailFirstPreview() worker = BatchAutoCropWorker(preview) diff --git a/tests/test_batch_norm_wb.py b/tests/test_batch_norm_wb.py index 0c1c771b5..024268631 100644 --- a/tests/test_batch_norm_wb.py +++ b/tests/test_batch_norm_wb.py @@ -29,6 +29,8 @@ def load_linear_preview( positive_source=False, highlight_mode=0, bake_camera_wb=False, + lens_from_metadata=False, + lens_flatfield=None, ): self.calls[file_hash] = use_camera_wb raw = np.full((8, 8, 3), 0.5, dtype=np.float32) diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py new file mode 100644 index 000000000..600f05c6a --- /dev/null +++ b/tests/test_metadata_lens.py @@ -0,0 +1,382 @@ +import struct +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest +import tifffile + +from negpy.desktop.session import AppState +from negpy.desktop.view.sidebar.geometry import GeometrySidebar +from negpy.domain.models import WorkspaceConfig +from negpy.features.flatfield.models import FlatFieldConfig +from negpy.features.geometry.models import GeometryConfig +from negpy.features.lens.logic import _dng_maps, _sony_maps, apply_lens +from negpy.features.lens.models import IDENTITY, LensMetadata, RectilinearWarp, SonyWarp +from negpy.infrastructure.loaders.lens_metadata import bind_decode, parse_opcodes, read_lens_metadata +from negpy.kernel.image.logic import apply_exif_orientation +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled, prepare_lens_source +from negpy.services.rendering.preview_cache import PreviewCacheKey +from negpy.services.rendering.source_identity import source_token + + +def opcode(coefficients=(IDENTITY,), center=(0.5, 0.5), code=1, flags=0): + values = tuple(v for plane in coefficients for v in plane) + center + payload = struct.pack(">I", len(coefficients)) + struct.pack(f">{len(values)}d", *values) + return struct.pack(">5I", 1, code, 0x01030000, flags, len(payload)) + payload + + +def raw_file(path, tags=(), *, dng=True, subifd=False, byteorder="<"): + image = np.zeros((40, 60), dtype=np.uint16) + extra = [(50706, "B", 4, (1, 7, 1, 0), False)] if dng else [] + extra += list(tags) + with tifffile.TiffWriter(path, byteorder=byteorder) as tif: + if subifd: + tif.write(np.zeros((8, 12, 3), dtype=np.uint8), photometric="rgb", subifds=1, metadata=None) + tif.write(image, photometric=32803, extratags=extra, metadata=None) + return str(path) + + +def dng_file(tmp_path, data, **kwargs): + return raw_file(tmp_path / "source.dng", [(51022, "B", len(data), data, False)], **kwargs) + + +def test_dng_identity_and_separate_capabilities(): + assert parse_opcodes(opcode()) == () + ca = ((1.002, 0, 0, 0, 0, 0), IDENTITY, (0.998, 0, 0, 0, 0, 0)) + lens = LensMetadata("DNG", parse_opcodes(opcode(ca))) + assert lens.available and lens.ca and not lens.distortion + distortion = LensMetadata("DNG", parse_opcodes(opcode(((1, -0.1, 0, 0, 0, 0),)))) + assert distortion.distortion and not distortion.ca + + +@pytest.mark.parametrize("byteorder", ["<", ">"]) +@pytest.mark.parametrize("subifd", [False, True]) +def test_dng_17_and_tiff_byte_order_do_not_change_opcode_endianness(tmp_path, byteorder, subifd): + data = opcode(((1, -0.08, 0.02, 0, 0, 0),)) + lens = read_lens_metadata(dng_file(tmp_path, data, byteorder=byteorder, subifd=subifd)) + assert lens.distortion + assert lens.active_area == (0, 0, 40, 60) + + +@pytest.mark.parametrize( + "data", + [ + b"", + b"\0\0", + opcode()[:-1], + opcode() + b"extra", + struct.pack(">I", 1000000), + opcode(code=14), + opcode(code=6), + opcode(flags=4), + opcode(((float("nan"), 0, 0, 0, 0, 0),)), + opcode(((1, -1, 0, 0, 0, 0),)), + opcode(center=(-0.01, 0.5)), + opcode((IDENTITY, IDENTITY)), + ], +) +def test_bad_or_unsupported_dng_metadata_is_unavailable_without_breaking_load(tmp_path, data): + lens = read_lens_metadata(dng_file(tmp_path, data)) + assert not lens.available + assert lens.reason + + +def test_plain_exif_and_rendered_images_never_enable_embedded_correction(tmp_path): + assert not read_lens_metadata(raw_file(tmp_path / "no-profile.dng")).available + jpeg = tmp_path / "image.jpg" + jpeg.write_bytes(b"not needed") + assert not read_lens_metadata(str(jpeg)).available + rendered = tmp_path / "rendered.dng" + data = opcode(((1, -0.1, 0, 0, 0, 0),)) + tifffile.imwrite(rendered, np.zeros((40, 60, 3), np.uint16), photometric="rgb", extratags=[(51022, "B", len(data), data, False)]) + assert not read_lens_metadata(str(rendered)).available + + +def test_sony_padded_arrays_and_ca_only_are_independent(tmp_path): + dist = (11, *range(-100, 10, 10), *([0] * 5)) + ca = (22, *([100] * 11), *([-100] * 11), *([0] * 10)) + path = raw_file(tmp_path / "source.arw", [(0x7037, "h", 17, dist, False), (0x7035, "h", 33, ca, False)], dng=False, subifd=True) + lens = read_lens_metadata(path) + assert lens.distortion and lens.ca + assert len(lens.warps[0].distortion) == 11 + ca_path = raw_file(tmp_path / "ca.arw", [(0x7035, "h", 33, ca, False)], dng=False) + ca_lens = read_lens_metadata(ca_path) + assert ca_lens.ca and not ca_lens.distortion + + +def test_sony_unavailable_flag_overrides_leftover_coefficients(tmp_path): + data = (16, *([100] * 16)) + path = raw_file(tmp_path / "source.arw", [(0x7037, "h", 17, data, False), (0x7036, "H", 1, 255, False)], dng=False) + assert not read_lens_metadata(path).available + + +def test_dng_does_not_reuse_inherited_sony_coefficients(tmp_path): + data = (16, *([100] * 16)) + path = raw_file(tmp_path / "converted.dng", [(0x7037, "h", 17, data, False)]) + assert not read_lens_metadata(path).available + + +def test_metadata_cache_tracks_file_revision(tmp_path): + path = dng_file(tmp_path, opcode(((1, -0.1, 0, 0, 0, 0),))) + assert read_lens_metadata(path).available + dng_file(tmp_path, struct.pack(">I", 0)) + assert not read_lens_metadata(path).available + + +def test_dng_coordinate_map_matches_spec_with_offset_center_and_tangential_terms(): + warp = RectilinearWarp(((1, -0.03, 0.01, 0, 0.001, -0.002),), (0.4, 0.6)) + lens = LensMetadata("DNG", (warp,), active_area=(10, 20, 110, 220), buffer_area=(15, 25, 105, 215)) + mx, my = _dng_maps(lens, warp, (90, 190, 3), 0, 90, 0) + x, y = 170, 75 + cx, cy = 20 + 0.4 * 199, 10 + 0.6 * 99 + radius = np.hypot(max(cx - 20, 219 - cx), max(cy - 10, 109 - cy)) + dx, dy = (25 + x - cx) / radius, (15 + y - cy) / radius + r2 = dx * dx + dy * dy + factor = 1 - 0.03 * r2 + 0.01 * r2**2 + expected_x = cx + radius * (dx * factor + 0.002 * dx * dy - 0.002 * (r2 + 2 * dx**2)) - 25 + expected_y = cy + radius * (dy * factor - 0.004 * dx * dy + 0.001 * (r2 + 2 * dy**2)) - 15 + assert mx[y, x] == pytest.approx(expected_x, abs=2e-5) + assert my[y, x] == pytest.approx(expected_y, abs=2e-5) + + +def test_sony_known_scale_and_ca_units(): + warp = SonyWarp((-819.2,) * 16, (2097.152,) * 16, (-2097.152,) * 16) + for channel, ca in enumerate((1.001, 1.0, 0.999)): + mx, my = _sony_maps(warp, (80, 120, 3), 0, 80, channel) + assert mx[10, 15] == pytest.approx(60 + (15 - 60) * 0.95 * ca, abs=1e-5) + assert my[10, 15] == pytest.approx(40 + (10 - 40) * 0.95 * ca, abs=1e-5) + + +@pytest.mark.parametrize("orientation", range(1, 9)) +def test_orientation_and_tca_keep_green_unchanged(orientation): + ramp = np.tile(np.linspace(0.1, 0.8, 120, dtype=np.float32), (80, 1)) + image = np.repeat(ramp[..., None], 3, axis=2) + coefficients = ((1.01, 0, 0, 0, 0, 0), IDENTITY, (0.99, 0, 0, 0, 0, 0)) + lens = LensMetadata("DNG", (RectilinearWarp(coefficients, (0.4, 0.6)),)) + expected = apply_exif_orientation(apply_lens(image, lens), orientation) + oriented = apply_exif_orientation(image, orientation) + result = apply_lens(oriented, lens, orientation) + np.testing.assert_allclose(result, expected, atol=1e-6) + np.testing.assert_array_equal(result[..., 1], oriented[..., 1]) + assert not np.array_equal(result[..., 0], oriented[..., 0]) + + +def test_noop_preserves_source_and_distortion_preserves_flat_color(): + image = np.full((40, 60, 3), 0.37, np.float32) + assert apply_lens(image, LensMetadata()) is image + lens = LensMetadata("DNG", (RectilinearWarp(((1, -0.1, 0.02, 0, 0, 0),)),)) + np.testing.assert_allclose(apply_lens(image, lens), image, atol=1e-6) + + +def test_bind_decode_checks_coordinate_compatibility(): + lens = LensMetadata("DNG", (RectilinearWarp(((1, -0.1, 0, 0, 0, 0),)),), active_area=(4, 8, 104, 208)) + raw = SimpleNamespace(sizes=SimpleNamespace(top_margin=4, left_margin=8, height=100, width=200)) + assert bind_decode(lens, raw).buffer_area == lens.active_area + raw.sizes.width = 300 + assert not bind_decode(lens, raw).available + cropped_fallback = SimpleNamespace(sizes=SimpleNamespace(raw_width=180, raw_height=90)) + assert not bind_decode(lens, cropped_fallback, fallback=True).available + + +def test_flatfield_is_applied_before_the_lens_warp(monkeypatch): + from negpy.services.rendering import lens as service + + image = np.ones((40, 60, 3), np.float32) + gain = np.broadcast_to(np.linspace(0.2, 0.8, 60, dtype=np.float32)[None, :, None], image.shape) + metadata = {"lens_correction": LensMetadata("Sony", (SonyWarp((-1000,) * 16),)), "orientation": 1} + monkeypatch.setattr(service, "apply_flatfield", lambda img, config: img * gain) + out = prepare_lens_source(image, metadata, FlatFieldConfig()) + np.testing.assert_array_equal(out, apply_lens(image * gain, metadata["lens_correction"])) + np.testing.assert_array_equal(image, 1.0) + + +def test_setting_roundtrip_and_source_cache_identity(monkeypatch): + from negpy.features.flatfield import logic as ff + + monkeypatch.setitem(ff._GAIN_CACHE, "reference", (np.ones((4, 6, 3), np.float32), "gain-token")) + base = WorkspaceConfig() + enabled = replace(base, geometry=GeometryConfig(lens_from_metadata=True, distortion_k1=0.05)) + restored = WorkspaceConfig.from_flat_dict(enabled.to_dict()) + assert restored.geometry.lens_from_metadata + assert restored.geometry.distortion_k1 == 0 + assert source_token(base) != source_token(enabled) + flat = FlatFieldConfig(apply=True, profile_id="reference") + assert source_token(enabled) != source_token(replace(enabled, flatfield=flat)) + assert source_token(base) == source_token(replace(base, flatfield=flat)) + off = PreviewCacheKey("file", False, "sRGB", False) + on = replace(off, lens_token=lens_decode_token(True, flat)) + assert off.as_tuple() != on.as_tuple() + + +def test_sidebar_uses_source_capabilities_and_can_clear_unavailable_saved_mode(qapp, monkeypatch): + from negpy.desktop.view.sidebar import geometry + + controller = MagicMock() + controller.state = AppState() + monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: LensMetadata()) + sidebar = GeometrySidebar(controller) + sidebar.sync_ui() + assert not sidebar.metadata_lens_btn.isEnabled() + assert sidebar.distortion_slider.isEnabled() + ca = LensMetadata("Sony", (SonyWarp(ca_red=(100,) * 16, ca_blue=(-100,) * 16),)) + monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: ca) + sidebar.sync_ui() + assert sidebar.metadata_lens_btn.isEnabled() + assert "lateral CA" in sidebar.lens_hint.text() + assert "distortion" not in sidebar.lens_hint.text() + sidebar.metadata_lens_btn.click() + requested = controller.apply_config.call_args.args[0] + assert requested.geometry.lens_from_metadata + controller.state.config = requested + monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: LensMetadata()) + sidebar.sync_ui() + assert sidebar.metadata_lens_btn.isEnabled() + assert sidebar.metadata_lens_btn.isChecked() + assert "Unavailable" in sidebar.lens_hint.text() + sidebar.metadata_lens_btn.click() + assert not controller.apply_config.call_args.args[0].geometry.lens_from_metadata + + +def test_composites_do_not_apply_primary_lens_metadata(): + from negpy.features.hdr.models import HdrConfig + from negpy.features.rgbscan.models import RgbScanConfig + from negpy.features.stitch.models import StitchConfig + + config = replace(WorkspaceConfig(), geometry=GeometryConfig(lens_from_metadata=True)) + assert metadata_lens_enabled(config) + assert not metadata_lens_enabled(replace(config, hdr=HdrConfig(hdr_enabled=True, hdr_paths=("b.arw",)))) + assert not metadata_lens_enabled(replace(config, rgbscan=RgbScanConfig(enabled=True, green_path="g.arw", blue_path="b.arw"))) + assert not metadata_lens_enabled(replace(config, stitch=StitchConfig(stitch_enabled=True, stitch_paths=("b.arw",)))) + + +def test_preview_and_export_share_warp_flatfield_and_per_file_coefficients(tmp_path, monkeypatch): + from negpy.features.flatfield import logic as ff + from negpy.infrastructure.loaders import factory + from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper + from negpy.services.rendering.image_processor import ImageProcessor + from negpy.services.rendering.preview_manager import PreviewManager + + ramp = np.tile(np.linspace(0.1, 0.7, 120, dtype=np.float32), (80, 1)) + image = np.repeat(ramp[..., None], 3, axis=2) + first = tmp_path / "one.arw" + second = tmp_path / "two.arw" + first.touch() + second.touch() + lenses = { + str(first): LensMetadata("Sony", (SonyWarp((-1000,) * 16),)), + str(second): LensMetadata("Sony", (SonyWarp((800,) * 16),)), + } + monkeypatch.setattr( + factory.loader_factory, + "get_loader", + lambda path, **kw: ( + NonStandardFileWrapper(image.copy()), + {"orientation": 6, "lens_correction": lenses[path], "ir": None}, + ), + ) + monkeypatch.setitem(ff._GAIN_CACHE, "test-gain", (np.full((8, 12, 3), 1.1, np.float32), "gain")) + config = WorkspaceConfig() + config = replace( + config, + geometry=GeometryConfig(lens_from_metadata=True), + process=replace(config.process, linear_raw=True), + flatfield=FlatFieldConfig(apply=True, profile_id="test-gain"), + ) + preview = PreviewManager() + processor = ImageProcessor() + outputs = [] + for path in (str(first), str(second), str(first)): + out, _, _ = preview.load_linear_preview( + path, + color_space="Adobe RGB", + full_resolution=True, + file_hash=path, + lens_from_metadata=True, + lens_flatfield=config.flatfield, + ) + exported, _, _ = processor._load_source_f32(path, config) + np.testing.assert_allclose(out, exported, atol=1e-6) + outputs.append(out) + assert not np.array_equal(outputs[0], outputs[1]) + np.testing.assert_array_equal(outputs[0], outputs[2]) + unwarped, _, _ = preview.load_linear_preview(str(first), color_space="Adobe RGB", full_resolution=True, file_hash=str(first)) + assert not np.array_equal(unwarped, outputs[0]) + + +def test_linear_dng_fallback_crop_is_relative_to_active_area(tmp_path): + from negpy.infrastructure.loaders.rawpy_loader import _peek_linear_dng_rgb + + image = np.arange(60 * 80 * 3, dtype=np.uint16).reshape(60, 80, 3) + data = opcode(((1, -0.05, 0, 0, 0, 0),)) + path = tmp_path / "linear.dng" + tifffile.imwrite( + path, + image, + photometric=34892, + planarconfig="contig", + metadata=None, + extratags=[ + (50706, "B", 4, (1, 7, 1, 0), False), + (50829, "I", 4, (4, 8, 56, 72), False), + (50719, "I", 2, (3, 2), False), + (50720, "I", 2, (58, 48), False), + (51022, "B", len(data), data, False), + ], + ) + result = _peek_linear_dng_rgb(str(path)) + assert result is not None + np.testing.assert_allclose(result[0], image[6:54, 11:69] / 65535, atol=1e-7) + lens = read_lens_metadata(str(path)) + bound = bind_decode(lens, SimpleNamespace(sizes=SimpleNamespace(raw_height=48, raw_width=58)), fallback=True) + assert bound.available and bound.buffer_area == (6, 11, 54, 69) + + +def test_dng_17_jpegxl_fallback_keeps_preview_export_and_optical_center_in_sync(tmp_path): + from negpy.infrastructure.loaders.rawpy_loader import RawpyLoader + from negpy.services.rendering.image_processor import ImageProcessor + from negpy.services.rendering.preview_manager import PreviewManager + + ramp = np.tile(np.linspace(4000, 45000, 160).astype(np.uint16), (100, 1)) + image = np.repeat(ramp[..., None], 3, axis=2) + data = opcode(((1, -0.05, 0.01, 0, 0.001, -0.001),), center=(0.4, 0.6)) + path = tmp_path / "jpegxl.dng" + tifffile.imwrite( + path, + image, + photometric=34892, + planarconfig="contig", + compression=52546, + metadata=None, + extratags=[ + (50706, "B", 4, (1, 7, 1, 0), False), + (50707, "B", 4, (1, 4, 0, 0), False), + (50829, "I", 4, (4, 8, 96, 152), False), + (50719, "I", 2, (3, 2), False), + (50720, "I", 2, (138, 88), False), + (51022, "B", len(data), data, False), + ], + ) + raw, metadata = RawpyLoader().load(str(path)) + with raw: + assert metadata["lens_correction"].available + assert metadata["lens_correction"].buffer_area == (6, 11, 94, 149) + config = WorkspaceConfig() + config = replace(config, geometry=GeometryConfig(lens_from_metadata=True), process=replace(config.process, linear_raw=True)) + preview, _, _ = PreviewManager().load_linear_preview(str(path), full_resolution=True, lens_from_metadata=True) + exported, _, _ = ImageProcessor()._load_source_f32(str(path), config) + assert preview.shape == (88, 138, 3) + np.testing.assert_array_equal(preview, exported) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_history_or_reset_reloads_pixels_when_metadata_mode_changes(enabled): + from negpy.desktop.controller import AppController + + state = AppState(current_file_path="scan.arw") + state.config = replace(state.config, geometry=GeometryConfig(lens_from_metadata=enabled)) + state.preview_lens_token = lens_decode_token(not enabled, state.config.flatfield) + controller = SimpleNamespace(state=state, _render_debounce=MagicMock(), load_file=MagicMock()) + AppController.request_render(controller) + controller.load_file.assert_called_once_with("scan.arw", preserve_zoom=True) From aed35a0b2d27eac528ceb7fc4723b9cb54018c34 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 10 Sep 2026 07:05:26 +0200 Subject: [PATCH 2/8] refactor(lens): dispatch embedded corrections through a warp protocol Keep model capabilities and inverse maps in frozen warp classes. Use one file-reader registry, with shared TIFF traversal for Sony and DNG, so additional formats do not change metadata aggregation or rendering. Validation: 61 metadata tests passed; 32 image cases are pixel-identical to the previous implementation. Ruff and repository ty checks passed, with strict ty checks on the lens modules. Full suite: 5496 passed, 1 failed, 26 skipped, 14 deselected. The unchanged crosstalk profile test hit a same-mtime/size cache collision; reproduced separately, and all 17 profile tests passed on rerun. --- CLAUDE.md | 4 + negpy/features/lens/logic.py | 50 +----- negpy/features/lens/models.py | 37 ++-- negpy/features/lens/warps.py | 79 +++++++++ negpy/infrastructure/loaders/lens_metadata.py | 47 +++-- tests/test_metadata_lens.py | 164 +++++++++++++++++- 6 files changed, 289 insertions(+), 92 deletions(-) create mode 100644 negpy/features/lens/warps.py diff --git a/CLAUDE.md b/CLAUDE.md index 812b3b096..4a54f1dab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,10 @@ Every feature lives in `negpy/features//`: One exception: `features/altprocess/` holds only `models.py`. Lith and cyanotype are mutually exclusive, so they share the Alternative Processes panel and one `AltProcessConfig`; their logic and shaders stay in `features/lith/` and `features/cyanotype/`. +`features/lens/warps.py` holds frozen lens models with `has_distortion`, `has_ca`, and +`remap(...)`, as defined by `LensWarp` in `models.py`. `logic.py` applies their maps in +row blocks. File readers are registered in `infrastructure/loaders/lens_metadata.py`. + ### Desktop (MVC) - `AppState` (`negpy/desktop/session.py`) — mutable session state diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py index d7b29d0c4..f916962d3 100644 --- a/negpy/features/lens/logic.py +++ b/negpy/features/lens/logic.py @@ -4,53 +4,10 @@ import numpy as np from negpy.domain.types import ImageBuffer -from negpy.features.lens.models import LensMetadata, RectilinearWarp, SonyWarp +from negpy.features.lens.models import LensMetadata from negpy.kernel.image.logic import apply_exif_orientation -def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: - h, w = shape[:2] - t, left, b, r = lens.active_area or (0, 0, h, w) - bt, bl, bb, br = lens.buffer_area or (t, left, b, r) - sx, sy = (br - bl) / w, (bb - bt) / h - cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) - radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) - x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius - y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius - return x, y, cx, cy, radius, sx, sy, bl, bt - - -def _dng_maps( - lens: LensMetadata, warp: RectilinearWarp, shape: tuple[int, ...], start: int, stop: int, channel: int -) -> tuple[np.ndarray, np.ndarray]: - x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, warp.center) - k0, k1, k2, k3, t0, t1 = warp.coefficients[0 if len(warp.coefficients) == 1 else channel] - r2 = x * x + y * y - factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) - mx = (cx + radius * (x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x)) - left + 0.5) / sx - 0.5 - my = (cy + radius * (y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y)) - top + 0.5) / sy - 0.5 - return mx.astype(np.float32), my.astype(np.float32) - - -def _sony_maps(warp: SonyWarp, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: - # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). - # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc - h, w = shape[:2] - x = np.arange(w, dtype=np.float32)[None, :] - w * 0.5 - y = np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5 - radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) - n = len(warp.distortion) or len(warp.ca_red) - knots = (np.arange(n) + 0.5) / (n - 1) - factors = np.ones(n) - if warp.distortion: - factors += np.asarray(warp.distortion) / 16384.0 - ca = warp.ca_red if channel == 0 else warp.ca_blue if channel == 2 else () - if ca: - factors *= 1 + np.asarray(ca) / 2097152.0 - factor = np.interp(radius, knots, factors).astype(np.float32) - return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) - - def apply_lens(img: ImageBuffer, lens: LensMetadata, orientation: int = 1) -> ImageBuffer: """Apply all supported embedded warps, with bounded temporary map memory.""" if not lens.available or min(img.shape[:2]) < 2: @@ -64,10 +21,7 @@ def apply_lens(img: ImageBuffer, lens: LensMetadata, orientation: int = 1) -> Im plane = np.ascontiguousarray(source[..., channel]) for start in range(0, h, 256): stop = min(start + 256, h) - if isinstance(warp, SonyWarp): - mx, my = _sony_maps(warp, source.shape, start, stop, channel) - else: - mx, my = _dng_maps(lens, warp, source.shape, start, stop, channel) + mx, my = warp.remap(lens, source.shape, start, stop, channel) result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) source = np.clip(result, 0.0, 1.0, out=result) return np.ascontiguousarray(apply_exif_orientation(source, orientation)) diff --git a/negpy/features/lens/models.py b/negpy/features/lens/models.py index 363150891..13ed692f7 100644 --- a/negpy/features/lens/models.py +++ b/negpy/features/lens/models.py @@ -1,26 +1,27 @@ -from dataclasses import dataclass +from __future__ import annotations +from dataclasses import dataclass +from typing import Protocol -IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) +import numpy as np -@dataclass(frozen=True) -class RectilinearWarp: - coefficients: tuple[tuple[float, ...], ...] - center: tuple[float, float] = (0.5, 0.5) +class LensWarp(Protocol): + @property + def has_distortion(self) -> bool: ... + @property + def has_ca(self) -> bool: ... -@dataclass(frozen=True) -class SonyWarp: - distortion: tuple[float, ...] = () - ca_red: tuple[float, ...] = () - ca_blue: tuple[float, ...] = () + def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + """Return float32 inverse x/y maps for one channel and rows [start, stop).""" + ... @dataclass(frozen=True) class LensMetadata: source: str = "" - warps: tuple[RectilinearWarp | SonyWarp, ...] = () + warps: tuple[LensWarp, ...] = () reason: str = "No embedded lens correction data." # DNG opcodes use the active image, before DefaultCrop and EXIF orientation. active_area: tuple[int, int, int, int] | None = None @@ -28,19 +29,11 @@ class LensMetadata: @property def distortion(self) -> bool: - return any( - any(w.distortion) if isinstance(w, SonyWarp) else w.coefficients[0 if len(w.coefficients) == 1 else 1] != IDENTITY - for w in self.warps - ) + return any(warp.has_distortion for warp in self.warps) @property def ca(self) -> bool: - return any( - any(w.ca_red) or any(w.ca_blue) - if isinstance(w, SonyWarp) - else len(w.coefficients) == 3 and (w.coefficients[0] != w.coefficients[1] or w.coefficients[2] != w.coefficients[1]) - for w in self.warps - ) + return any(warp.has_ca for warp in self.warps) @property def available(self) -> bool: diff --git a/negpy/features/lens/warps.py b/negpy/features/lens/warps.py new file mode 100644 index 000000000..0d2e853f9 --- /dev/null +++ b/negpy/features/lens/warps.py @@ -0,0 +1,79 @@ +"""Inverse lens maps in unrotated sensor coordinates.""" + +from dataclasses import dataclass + +import numpy as np + +from negpy.features.lens.models import LensMetadata + +IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) + + +def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: + h, w = shape[:2] + t, left, b, r = lens.active_area or (0, 0, h, w) + bt, bl, bb, br = lens.buffer_area or (t, left, b, r) + sx, sy = (br - bl) / w, (bb - bt) / h + cx, cy = left + center[0] * (r - left - 1), t + center[1] * (b - t - 1) + radius = np.hypot(max(cx - left, r - 1 - cx), max(cy - t, b - 1 - cy)) + x = (bl + (np.arange(w, dtype=np.float32)[None, :] + 0.5) * sx - 0.5 - cx) / radius + y = (bt + (np.arange(start, stop, dtype=np.float32)[:, None] + 0.5) * sy - 0.5 - cy) / radius + return x, y, cx, cy, radius, sx, sy, bl, bt + + +@dataclass(frozen=True) +class RectilinearWarp: + coefficients: tuple[tuple[float, ...], ...] + center: tuple[float, float] = (0.5, 0.5) + + @property + def has_distortion(self) -> bool: + return self.coefficients[0 if len(self.coefficients) == 1 else 1] != IDENTITY + + @property + def has_ca(self) -> bool: + return len(self.coefficients) == 3 and ( + self.coefficients[0] != self.coefficients[1] or self.coefficients[2] != self.coefficients[1] + ) + + def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, self.center) + k0, k1, k2, k3, t0, t1 = self.coefficients[0 if len(self.coefficients) == 1 else channel] + r2 = x * x + y * y + factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) + mx = (cx + radius * (x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x)) - left + 0.5) / sx - 0.5 + my = (cy + radius * (y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y)) - top + 0.5) / sy - 0.5 + return mx.astype(np.float32), my.astype(np.float32) + + +@dataclass(frozen=True) +class SonyWarp: + distortion: tuple[float, ...] = () + ca_red: tuple[float, ...] = () + ca_blue: tuple[float, ...] = () + + @property + def has_distortion(self) -> bool: + return any(self.distortion) + + @property + def has_ca(self) -> bool: + return any(self.ca_red) or any(self.ca_blue) + + def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). + # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc + h, w = shape[:2] + x = np.arange(w, dtype=np.float32)[None, :] - w * 0.5 + y = np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5 + radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) + n = len(self.distortion) or len(self.ca_red) + knots = (np.arange(n) + 0.5) / (n - 1) + factors = np.ones(n) + if self.distortion: + factors += np.asarray(self.distortion) / 16384.0 + ca = self.ca_red if channel == 0 else self.ca_blue if channel == 2 else () + if ca: + factors *= 1 + np.asarray(ca) / 2097152.0 + factor = np.interp(radius, knots, factors).astype(np.float32) + return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) diff --git a/negpy/infrastructure/loaders/lens_metadata.py b/negpy/infrastructure/loaders/lens_metadata.py index 15d6a4d71..2e9822b78 100644 --- a/negpy/infrastructure/loaders/lens_metadata.py +++ b/negpy/infrastructure/loaders/lens_metadata.py @@ -2,15 +2,17 @@ import os import struct +from collections.abc import Callable from dataclasses import replace -from functools import lru_cache +from functools import lru_cache, partial from itertools import islice from typing import Any import numpy as np import tifffile -from negpy.features.lens.models import IDENTITY, LensMetadata, RectilinearWarp, SonyWarp +from negpy.features.lens.models import LensMetadata +from negpy.features.lens.warps import IDENTITY, RectilinearWarp, SonyWarp _MAX_OPCODE_BYTES = 4 * 1024 * 1024 @@ -122,32 +124,43 @@ def numbers(tag: Any) -> tuple[int, ...]: return LensMetadata("DNG WarpRectilinear", parse_opcodes(bytes(opcode.value)), "Embedded DNG warp is an identity.", area, buffer_area) +def _read_tiff(file_path: str, parse_page: Callable[[Any], LensMetadata]) -> LensMetadata: + with tifffile.TiffFile(file_path) as tif: + pages = list(islice(tif.pages, 16)) + for parent in tuple(pages): + if parent.pages is not None: + pages.extend(islice(parent.pages, 16)) + raw_pages = [p for p in pages if int(p.photometric) in (32803, 34892) and p.samplesperpixel in (1, 3)] + if not raw_pages: + return LensMetadata(reason="No supported RAW image plane; rendered images are not corrected again.") + page = max(raw_pages, key=lambda p: p.imagewidth * p.imagelength) + return parse_page(page) + + +_READERS: dict[str, Callable[[str], LensMetadata]] = { + ".arw": partial(_read_tiff, parse_page=_read_sony), + ".dng": partial(_read_tiff, parse_page=_read_dng), +} + + def read_lens_metadata(file_path: str | None) -> LensMetadata: """Inspect source metadata without decoding pixels; cache against the file revision.""" if not file_path: - return LensMetadata(reason="Load a Sony ARW or DNG file.") - if os.path.splitext(file_path)[1].lower() not in (".arw", ".dng"): - return LensMetadata(reason="Embedded correction supports Sony ARW and DNG WarpRectilinear.") + return LensMetadata(reason="Load a source file to check for embedded lens correction data.") + reader = _READERS.get(os.path.splitext(file_path)[1].lower()) + if reader is None: + return LensMetadata(reason="Embedded lens correction is not supported for this file type.") try: stat = os.stat(file_path) - return _read_cached(os.path.abspath(file_path), stat.st_mtime_ns, stat.st_size) + return _read_cached(os.path.abspath(file_path), stat.st_mtime_ns, stat.st_size, reader) except OSError: return LensMetadata(reason="Cannot read source lens metadata.") @lru_cache(maxsize=128) -def _read_cached(file_path: str, mtime_ns: int, size: int) -> LensMetadata: +def _read_cached(file_path: str, mtime_ns: int, size: int, reader: Callable[[str], LensMetadata]) -> LensMetadata: try: - with tifffile.TiffFile(file_path) as tif: - pages = list(islice(tif.pages, 16)) - for parent in tuple(pages): - if parent.pages is not None: - pages.extend(islice(parent.pages, 16)) - raw_pages = [p for p in pages if int(p.photometric) in (32803, 34892) and p.samplesperpixel in (1, 3)] - if not raw_pages: - return LensMetadata(reason="No supported RAW image plane; rendered images are not corrected again.") - page = max(raw_pages, key=lambda p: p.imagewidth * p.imagelength) - return _read_dng(page) if file_path.lower().endswith(".dng") else _read_sony(page) + return reader(file_path) except ValueError as exc: return LensMetadata(reason=str(exc)) except (OSError, TypeError, struct.error, IndexError, OverflowError, ZeroDivisionError): diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index 600f05c6a..faec323e7 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -1,5 +1,6 @@ import struct -from dataclasses import replace +from dataclasses import dataclass, replace +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -12,8 +13,9 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.geometry.models import GeometryConfig -from negpy.features.lens.logic import _dng_maps, _sony_maps, apply_lens -from negpy.features.lens.models import IDENTITY, LensMetadata, RectilinearWarp, SonyWarp +from negpy.features.lens.logic import apply_lens +from negpy.features.lens.models import LensMetadata, LensWarp +from negpy.features.lens.warps import IDENTITY, RectilinearWarp, SonyWarp from negpy.infrastructure.loaders.lens_metadata import bind_decode, parse_opcodes, read_lens_metadata from negpy.kernel.image.logic import apply_exif_orientation from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled, prepare_lens_source @@ -128,7 +130,7 @@ def test_metadata_cache_tracks_file_revision(tmp_path): def test_dng_coordinate_map_matches_spec_with_offset_center_and_tangential_terms(): warp = RectilinearWarp(((1, -0.03, 0.01, 0, 0.001, -0.002),), (0.4, 0.6)) lens = LensMetadata("DNG", (warp,), active_area=(10, 20, 110, 220), buffer_area=(15, 25, 105, 215)) - mx, my = _dng_maps(lens, warp, (90, 190, 3), 0, 90, 0) + mx, my = warp.remap(lens, (90, 190, 3), 0, 90, 0) x, y = 170, 75 cx, cy = 20 + 0.4 * 199, 10 + 0.6 * 99 radius = np.hypot(max(cx - 20, 219 - cx), max(cy - 10, 109 - cy)) @@ -144,7 +146,7 @@ def test_dng_coordinate_map_matches_spec_with_offset_center_and_tangential_terms def test_sony_known_scale_and_ca_units(): warp = SonyWarp((-819.2,) * 16, (2097.152,) * 16, (-2097.152,) * 16) for channel, ca in enumerate((1.001, 1.0, 0.999)): - mx, my = _sony_maps(warp, (80, 120, 3), 0, 80, channel) + mx, my = warp.remap(LensMetadata(), (80, 120, 3), 0, 80, channel) assert mx[10, 15] == pytest.approx(60 + (15 - 60) * 0.95 * ca, abs=1e-5) assert my[10, 15] == pytest.approx(40 + (10 - 40) * 0.95 * ca, abs=1e-5) @@ -380,3 +382,155 @@ def test_history_or_reset_reloads_pixels_when_metadata_mode_changes(enabled): controller = SimpleNamespace(state=state, _render_debounce=MagicMock(), load_file=MagicMock()) AppController.request_render(controller) controller.load_file.assert_called_once_with("scan.arw", preserve_zoom=True) + + +@pytest.mark.parametrize("splash", [False, True]) +@pytest.mark.parametrize("color_space", [None, "Adobe RGB"]) +def test_positive_source_and_lens_mode_have_independent_preview_cache_entries(tmp_path, monkeypatch, splash, color_space): + from negpy.infrastructure.loaders import factory + from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper + from negpy.services.rendering.image_processor import ImageProcessor + from negpy.services.rendering.preview_manager import PreviewManager + + path = str(tmp_path / "source.arw") + ramp = np.tile(np.linspace(0.1, 0.7, 120, dtype=np.float32), (80, 1)) + image = np.repeat(ramp[..., None], 3, axis=2) + lens = LensMetadata("Sony", (SonyWarp((-1000,) * 16),)) + + def get_loader(file_path, *, linear_raw=False, positive_source=False): + pixels = image * (0.5 if positive_source else 1.0) + return NonStandardFileWrapper(pixels), {"orientation": 1, "color_space": "Adobe RGB", "lens_correction": lens} + + monkeypatch.setattr(factory.loader_factory, "get_loader", get_loader) + monkeypatch.setattr("negpy.services.rendering.preview_manager.APP_CONFIG.preview_cache_max_full_res_entries", 4) + manager = PreviewManager() + processor = ImageProcessor() + load = manager.load_splash_and_linear if splash else manager.load_linear_preview + outputs = {} + for positive, enabled in [(False, False), (False, True), (True, False), (True, True)] * 2: + result = load( + path, + color_space=color_space, + use_camera_wb=False, + full_resolution=True, + file_hash="source", + positive_source=positive, + lens_from_metadata=enabled, + ) + preview = result[1][0] if splash else result[0] + config = WorkspaceConfig() + config = replace( + config, + geometry=GeometryConfig(lens_from_metadata=enabled), + process=replace(config.process, linear_raw=True, positive_source=positive), + ) + exported, _, _ = processor._load_source_f32(path, config) + np.testing.assert_allclose(preview, exported, atol=1e-6) + if (positive, enabled) in outputs: + assert preview is outputs[positive, enabled] + outputs[positive, enabled] = preview + assert not np.array_equal(outputs[False, False], outputs[False, True]) + assert not np.array_equal(outputs[False, True], outputs[True, True]) + + +@pytest.mark.parametrize("mode", ["linear", "splash", "warm"]) +def test_preview_worker_forwards_positive_source_and_lens_settings(mode): + from negpy.desktop.workers.render import PreviewLoadTask, PreviewLoadWorker + + service = MagicMock() + result = (np.full((8, 12, 3), 0.5, np.float32), (8, 12), {}) + service.load_linear_preview.return_value = result + service.load_splash_and_linear.return_value = (None, result) + task = PreviewLoadTask( + file_path="source.arw", + workspace_color_space="Adobe RGB", + use_camera_wb=False, + positive_source=True, + lens_from_metadata=True, + lens_flatfield=FlatFieldConfig(apply=True, profile_id="gain"), + use_splash=mode == "splash", + for_cache_warm=mode == "warm", + ) + worker = PreviewLoadWorker(service) + errors = [] + worker.error.connect(errors.append) + worker.process(task) + call = service.load_splash_and_linear if mode == "splash" else service.load_linear_preview + assert call.call_count == 1 + assert call.call_args.kwargs["positive_source"] is True + assert call.call_args.kwargs["lens_from_metadata"] is True + assert call.call_args.kwargs["lens_flatfield"] == task.lens_flatfield + assert not errors + + +@pytest.mark.parametrize("orientation", [1, 6]) +def test_registered_reader_and_structural_warp_use_shared_rendering(tmp_path, monkeypatch, orientation): + from negpy.infrastructure.loaders import lens_metadata as reader + + calls = [] + reads = [] + + @dataclass(frozen=True) + class OffsetWarp: + offsets: tuple[int, ...] + + @property + def has_distortion(self) -> bool: + return self.offsets[1] != 0 + + @property + def has_ca(self) -> bool: + return self.offsets[0] != self.offsets[1] or self.offsets[2] != self.offsets[1] + + def remap(self, lens, shape, start, stop, channel): + calls.append((lens, shape, start, stop, channel)) + y, x = np.mgrid[start:stop, : shape[1]].astype(np.float32) + return x + self.offsets[channel], y + + def read_offsets(file_path: str) -> LensMetadata: + reads.append(file_path) + offsets = tuple(int(v) for v in Path(file_path).read_text().split(",")) + warp: LensWarp = OffsetWarp(offsets) + return LensMetadata("Offset reader", (warp,)) + + path = tmp_path / "source.CUSTOM" + path.write_text("2,1,-1") + monkeypatch.setitem(reader._READERS, ".custom", read_offsets) + lens = read_lens_metadata(str(path)) + assert read_lens_metadata(str(path)) is lens + assert reads == [str(path)] + assert lens.description == "Offset reader: distortion + lateral CA" + + image = np.random.default_rng(7).uniform(0.1, 0.9, (521, 35, 3)).astype(np.float32) + expected = np.stack([image[:, np.clip(np.arange(35) + shift, 0, 34), ch] for ch, shift in enumerate((2, 1, -1))], axis=-1) + oriented = apply_exif_orientation(image, orientation) + result = apply_lens(oriented, lens, orientation) + np.testing.assert_array_equal(result, apply_exif_orientation(expected, orientation)) + assert all(context is lens and shape == image.shape for context, shape, *_ in calls) + assert [(start, stop, channel) for _, _, start, stop, channel in calls] == [ + (start, min(start + 256, 521), channel) for channel in range(3) for start in range(0, 521, 256) + ] + + +@pytest.mark.parametrize( + "warp, distortion, ca", + [ + (RectilinearWarp((IDENTITY,)), False, False), + (RectilinearWarp((IDENTITY,) * 3), False, False), + (RectilinearWarp(((1, -0.1, 0, 0, 0, 0),)), True, False), + (RectilinearWarp(((1.01, 0, 0, 0, 0, 0), IDENTITY, IDENTITY)), False, True), + (SonyWarp(), False, False), + (SonyWarp((0,) * 16, (0,) * 16, (0,) * 16), False, False), + (SonyWarp(distortion=(100,) * 16), True, False), + (SonyWarp(ca_red=(100,) * 16, ca_blue=(-100,) * 16), False, True), + (SonyWarp((100,) * 16, (100,) * 16, (-100,) * 16), True, True), + ], +) +def test_warp_capabilities_drive_availability(warp: LensWarp, distortion, ca): + lens = LensMetadata("Test", (warp,)) + assert lens.distortion is distortion + assert lens.ca is ca + assert lens.available is (distortion or ca) + if not lens.available: + image = np.full((16, 24, 3), 0.5, np.float32) + assert apply_lens(image, lens) is image From f13837581b3f05ad7bc51779d7027d7cf10324d4 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 17 Sep 2026 07:42:57 +0200 Subject: [PATCH 3/8] fix(lens): preserve source range and composite alignment --- CLAUDE.md | 2 +- docs/PIPELINE.md | 2 +- negpy/desktop/workers/hdr.py | 8 ++-- negpy/desktop/workers/stitch.py | 7 ++- negpy/features/lens/logic.py | 3 +- tests/test_metadata_lens.py | 81 +++++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a54f1dab..94f089b2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Migrations that rewrite *rows* rather than a config payload need a repository, s - **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. - **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. - **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages. -- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its mode and flat-field token belong to the source identity. Composite and RGB+IR sources do not use it. +- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its mode and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. - **Source bakes** run before either engine, on the linear source: flat-field, sensor unmix and every defect repair (IR, detected specks, painted heal strokes). Both engines re-upload that source per frame, so a bake reaches them parity-free and needs no shader. Each bake folds a token into `source_hash` to invalidate the engine cache. - **Working space**: scene-linear internally; the working OETF (Adobe RGB 1998 TRC — a pure 563/256 power, no linear segment) is the final engine step. Lab/toning compute CIELAB directly from linear, D65. Adobe RGB rather than a wide gamut because ProPhoto's imaginary primaries inflate chroma in the saturation and toning stages. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index e47b75daa..9fe2643d0 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -49,8 +49,8 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Physical model**: we treat the input as a **radiometric measurement**. Pixel values are linear transmittance captured by the sensor. * **Source corrections** (linear domain, before the log conversion): - * **Embedded lens correction** (`features/lens`): when enabled, single-file preview and export decode apply flat-field first, then cubic RGB inverse warps, before sensor unmix, user geometry and crop. Sony radial tables use their recorded knots; DNG WarpRectilinear uses the active-image optical center and per-plane radial/tangential coefficients. EXIF orientation and decoder crop offsets are accounted for. Edges replicate; unsupported instructions disable the warp. Only the mode is serialized. The decode cache includes the mode and flat-field content token. * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff using a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The reference is decoded and the gain **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so the render never touches the original reference file. Moving or deleting it is harmless. The per-image edit stores only the profile id. The render path resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py` at startup) and caches it, and the profile id plus a content token of the gain are folded into the render's source hash. + * **Embedded lens correction** (`features/lens`): applies cubic RGB inverse warps after flat-field, before sensor unmix, user geometry and crop. Preview and export use the same source coefficients, with orientation and crop offsets preserved. Edges replicate; values above 1 remain available to later stages. Composite and RGB+IR sources skip this correction. * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, the camera's CFA passbands overlap the light source's bands, so a pure R/G/B exposure leaks into the other channels. That is a fixed property of the sensor and light pair, independent of film. It is calibrated once from three bare-light exposures, with response columns normalized to a unit diagonal so per-capture exposure cancels, then inverted. It is applied as a 3×3 unmix of the **linear** capture, ahead of the log and inversion where the film-dye crosstalk below lives. It is independent of film but *not* of the light: it is meaningful only for a capture made under narrowband light, so `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband, see below) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The related **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. diff --git a/negpy/desktop/workers/hdr.py b/negpy/desktop/workers/hdr.py index e0b335155..a7b82a06b 100644 --- a/negpy/desktop/workers/hdr.py +++ b/negpy/desktop/workers/hdr.py @@ -71,9 +71,10 @@ def run(self, task: HdrTask) -> None: self.cancelled.emit() return self.progress.emit(i, total, f"Decoding {f['name']}") - # Flat-field off for the solve: the decode pins the white level, so saturation sits at - # exactly 1.0 and the reference and ratio thresholds mean what they say. A gain map - # applied first moves that point. The merge at decode time defers it for the same reason. + # Flat-field and lens correction off for the solve: the decode uses unwarped pixels + # and pins the white level, so saturation sits at exactly 1.0 and the reference and + # ratio thresholds mean what they say. A gain map or lens warp applied first moves + # that point. The merge at decode time defers both for the same reason. # # Reconstruction off explicitly: WorkspaceConfig.__post_init__ only zeroes it once # `hdr` names this bracket, which is exactly what has not happened yet here — the @@ -86,6 +87,7 @@ def run(self, task: HdrTask) -> None: flatfield=FlatFieldConfig(), hdr=HdrConfig(), process=replace(original.process, highlight_reconstruction=0), + geometry=replace(original.geometry, lens_from_metadata=False), ) f32, _, _ = self._processor._decode_oriented_f32(f["path"], params, wb_override=bracket_wb) if i == 0: diff --git a/negpy/desktop/workers/stitch.py b/negpy/desktop/workers/stitch.py index 87e8c2e36..5fced47e8 100644 --- a/negpy/desktop/workers/stitch.py +++ b/negpy/desktop/workers/stitch.py @@ -1,6 +1,6 @@ import gc import threading -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Dict, Tuple from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot @@ -56,7 +56,10 @@ def run(self, task: StitchTask) -> None: self.cancelled.emit() return self.progress.emit(i, total, f"Decoding {f['name']}") - f32, _, _ = self._processor._decode_oriented_f32(f["path"], task.params_by_path[f["path"]]) + # Registration and composite assembly both use unwarped sources. + params = task.params_by_path[f["path"]] + params = replace(params, geometry=replace(params.geometry, lens_from_metadata=False)) + f32, _, _ = self._processor._decode_oriented_f32(f["path"], params) parts.append(f32) self.progress.emit(len(task.files), total, "Registering frames") transforms, canvas = register_parts(parts, is_cancelled=self._cancel.is_set) diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py index f916962d3..866e17b3f 100644 --- a/negpy/features/lens/logic.py +++ b/negpy/features/lens/logic.py @@ -23,5 +23,6 @@ def apply_lens(img: ImageBuffer, lens: LensMetadata, orientation: int = 1) -> Im stop = min(start + 256, h) mx, my = warp.remap(lens, source.shape, start, stop, channel) result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) - source = np.clip(result, 0.0, 1.0, out=result) + # Flat-field gains can exceed white before sensor unmix. + source = np.maximum(result, 0.0, out=result) return np.ascontiguousarray(apply_exif_orientation(source, orientation)) diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index faec323e7..63b8e73a6 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -194,6 +194,32 @@ def test_flatfield_is_applied_before_the_lens_warp(monkeypatch): np.testing.assert_array_equal(image, 1.0) +@pytest.mark.parametrize( + "warp", + [ + SonyWarp(ca_red=(100,) * 16, ca_blue=(-100,) * 16), + RectilinearWarp(((1.01, 0, 0, 0, 0, 0), IDENTITY, (0.99, 0, 0, 0, 0, 0))), + ], +) +def test_lens_preserves_flatfield_values_above_one_for_sensor_unmix(monkeypatch, warp): + from negpy.features.flatfield import logic as ff + from negpy.features.process.sensor import apply_sensor_correction, build_sensor_matrix + + image = np.full((40, 60, 3), 0.8, np.float32) + gain = np.full_like(image, 1.6) + monkeypatch.setitem(ff._GAIN_CACHE, "reference", (gain, "gain-token")) + flatfield = FlatFieldConfig(apply=True, profile_id="reference") + lens = LensMetadata("Test", (warp,)) + out = prepare_lens_source(image, {"lens_correction": lens, "orientation": 1}, flatfield) + np.testing.assert_allclose(out, image * gain, atol=1e-6) + + matrix = build_sensor_matrix((1, 0.25, 0.25), (0.25, 1, 0.25), (0.25, 0.25, 1)) + expected = apply_sensor_correction(image * gain, matrix) + assert expected.max() < 1.0 + np.testing.assert_allclose(apply_sensor_correction(out, matrix), expected, atol=1e-6) + np.testing.assert_array_equal(image, np.float32(0.8)) + + def test_setting_roundtrip_and_source_cache_identity(monkeypatch): from negpy.features.flatfield import logic as ff @@ -253,6 +279,61 @@ def test_composites_do_not_apply_primary_lens_metadata(): assert not metadata_lens_enabled(replace(config, stitch=StitchConfig(stitch_enabled=True, stitch_paths=("b.arw",)))) +@pytest.mark.parametrize("kind", ["stitch", "hdr"]) +def test_composite_solve_uses_unwarped_sources(monkeypatch, kind): + from negpy.desktop.workers import hdr, stitch + from negpy.features.flatfield import logic as ff + from negpy.services.rendering.image_processor import ImageProcessor + + rng = np.random.default_rng(7) + sources = {path: rng.integers(6500, 40000, (60, 80, 3), dtype=np.uint16) for path in ("a.dng", "b.dng")} + lens = LensMetadata("DNG", (RectilinearWarp(((1, -0.1, 0, 0, 0, 0),)),)) + monkeypatch.setattr( + ImageProcessor, + "_decode_sensor_rgb", + lambda self, path, *args, **kwargs: (sources[path].copy(), {"lens_correction": lens, "orientation": 1}), + ) + monkeypatch.setitem(ff._GAIN_CACHE, "reference", (np.full((60, 80, 3), 1.1, np.float32), "gain-token")) + config = replace( + WorkspaceConfig(), + geometry=GeometryConfig(lens_from_metadata=True), + flatfield=FlatFieldConfig(apply=True, profile_id="reference"), + ) + seen = [] + + def solve(buffers, *args, **kwargs): + seen.extend(buffer.copy() for buffer in buffers) + if kind == "stitch": + return [np.eye(2, 3), np.eye(2, 3)], (80, 60) + return [1.0, 2.0] + + if kind == "stitch": + monkeypatch.setattr(stitch, "register_parts", solve) + worker, task_type = stitch.StitchWorker(), stitch.StitchTask + completed = worker.registered + else: + monkeypatch.setattr(hdr, "solve_ratios", solve) + worker, task_type = hdr.HdrWorker(), hdr.HdrTask + completed = worker.solved + results, errors = [], [] + completed.connect(results.append) + worker.error.connect(errors.append) + worker.run( + task_type( + files=tuple({"path": path, "name": path} for path in sources), + params_by_path={path: config for path in sources}, + ) + ) + assert not errors and len(results) == 1 + assert len(seen) == len(sources) + for actual, source in zip(seen, sources.values()): + expected = source.astype(np.float32) / 65535.0 + if kind == "stitch": + expected *= 1.1 + np.testing.assert_array_equal(actual, expected) + assert config.geometry.lens_from_metadata + + def test_preview_and_export_share_warp_flatfield_and_per_file_coefficients(tmp_path, monkeypatch): from negpy.features.flatfield import logic as ff from negpy.infrastructure.loaders import factory From 10a801757e5ee606ba5a0cc29d7d5d46ed8901c0 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 17 Sep 2026 09:13:53 +0200 Subject: [PATCH 4/8] fix(preview): keep rendered image visible during correction toggles --- negpy/desktop/controller.py | 8 +++--- tests/test_controller.py | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index f7578e986..e7b39873d 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1849,6 +1849,7 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b self._foreground_preview_generation = self._prefetch_gen self._pause_background_thumbnails() self._preview_load_t0 = time.perf_counter() + keep_preview = preserve_zoom and self._requested_file_path == file_path self._requested_file_path = file_path # A strip belongs to one frame, and the memo fast path below repaints without # going through request_render, so drop it here too. Zone pins froze their sample @@ -1866,7 +1867,7 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b if not preserve_zoom: self.zoom_requested.emit(1.0) - if memo is None: + if memo is None and not keep_preview: self.loading_started.emit() self._thumb_config = None @@ -1955,9 +1956,8 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b # The half suffix distinguishes the two halves' preview caches now # that the slice happens pre-downsample (each half is its own buffer). file_hash=self._file_hash_for_path(file_path), - # A memoized frame is already painted, so the embedded-JPEG splash would - # repaint stale pixels over it. - use_splash=memo is None, + # A reload or memo hit keeps the rendered frame until its replacement is ready. + use_splash=memo is None and not keep_preview, detect_mode=( pending_import.detect_mode if pending_import is not None diff --git a/tests/test_controller.py b/tests/test_controller.py index bb9cebca5..34e8c8736 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -297,6 +297,58 @@ def test_load_file_tags_the_decode_with_the_current_generation(self): self.assertEqual(tasks[-1].generation, self.controller._prefetch_gen) + def test_lens_toggles_keep_the_displayed_texture_during_reload(self): + from negpy.infrastructure.gpu.resources import GPUTexture + from negpy.services.rendering.lens import lens_decode_token + + self.controller.preview_load_requested.disconnect(self.controller.preview_load_worker.process) + state = self.controller.state + state.current_file_path = "scan.arw" + self.controller._requested_file_path = state.current_file_path + texture = MagicMock(spec=GPUTexture) + state.last_metrics["base_positive"] = texture + loading, released, cleanup, decode, zoom = (MagicMock() for _ in range(5)) + self.controller.loading_started.connect(loading) + self.controller.gpu_textures_released.connect(released) + self.controller._render_cleanup_requested.connect(cleanup) + self.controller.preview_load_requested.connect(decode) + self.controller.zoom_requested.connect(zoom) + + for enabled in (True, False, True): + state.config = replace(state.config, geometry=replace(state.config.geometry, lens_from_metadata=enabled)) + state.preview_lens_token = lens_decode_token(not enabled, state.config.flatfield) + self.controller.request_render() + task = decode.call_args.args[0] + self.assertEqual(task.lens_from_metadata, enabled) + self.assertFalse(task.use_splash) + self.assertIs(cleanup.call_args.args[0], texture) + + loading.assert_not_called() + released.assert_not_called() + zoom.assert_not_called() + texture.destroy.assert_not_called() + + def test_cpu_reload_keeps_preview_but_navigation_shows_loading(self): + import numpy as np + + self.controller.preview_load_requested.disconnect(self.controller.preview_load_worker.process) + self.controller._requested_file_path = "scan.arw" + self.controller.state.last_metrics["base_positive"] = np.ones((4, 4, 3), dtype=np.float32) + loading, decode, repaint = (MagicMock() for _ in range(3)) + self.controller.loading_started.connect(loading) + self.controller.preview_load_requested.connect(decode) + self.controller.image_updated.connect(repaint) + + for _ in range(2): + self.controller.load_file("scan.arw", preserve_zoom=True) + self.assertFalse(decode.call_args.args[0].use_splash) + loading.assert_not_called() + repaint.assert_not_called() + + self.controller.load_file("next.arw", preserve_zoom=True) + loading.assert_called_once() + self.assertTrue(decode.call_args.args[0].use_splash) + def test_preview_load_defers_neighbor_prefetch_until_render_finishes(self): self.controller._requested_file_path = "/tmp/a.dng" self.controller.request_render = MagicMock() From d18efba8d17bc3180cc69c8c757a5f0d34b3f29a Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 17 Sep 2026 09:40:37 +0200 Subject: [PATCH 5/8] feat(lens): split metadata distortion and CA controls --- CLAUDE.md | 2 +- docs/PIPELINE.md | 2 +- docs/USER_GUIDE.md | 3 +- negpy/desktop/controller.py | 11 +- negpy/desktop/settings_catalog.py | 3 +- negpy/desktop/view/keyboard_shortcuts.py | 3 +- negpy/desktop/view/shortcut_registry.py | 3 +- negpy/desktop/view/sidebar/controls_panel.py | 15 +- negpy/desktop/view/sidebar/geometry.py | 52 ++++--- negpy/desktop/workers/hdr.py | 2 +- negpy/desktop/workers/render.py | 23 +-- negpy/desktop/workers/stitch.py | 4 +- negpy/domain/migrations.py | 5 + negpy/features/geometry/models.py | 5 +- negpy/features/lens/logic.py | 17 ++- negpy/features/lens/models.py | 19 ++- negpy/features/lens/warps.py | 70 +++++++-- negpy/services/rendering/image_processor.py | 18 +-- negpy/services/rendering/lens.py | 23 +-- negpy/services/rendering/preview_manager.py | 27 ++-- negpy/services/rendering/source_identity.py | 4 +- tests/test_batch_autocrop_worker.py | 3 +- tests/test_batch_norm_wb.py | 3 +- tests/test_controller.py | 16 +- tests/test_metadata_lens.py | 149 +++++++++++++------ 25 files changed, 332 insertions(+), 150 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94f089b2e..6a4803cda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Migrations that rewrite *rows* rather than a config payload need a repository, s - **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. - **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. - **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages. -- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its mode and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. +- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its independent distortion and CA settings and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. - **Source bakes** run before either engine, on the linear source: flat-field, sensor unmix and every defect repair (IR, detected specks, painted heal strokes). Both engines re-upload that source per frame, so a bake reaches them parity-free and needs no shader. Each bake folds a token into `source_hash` to invalidate the engine cache. - **Working space**: scene-linear internally; the working OETF (Adobe RGB 1998 TRC — a pure 563/256 power, no linear segment) is the final engine step. Lab/toning compute CIELAB directly from linear, D65. Adobe RGB rather than a wide gamut because ProPhoto's imaginary primaries inflate chroma in the saturation and toning stages. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 9fe2643d0..6df04c15d 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -50,7 +50,7 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Physical model**: we treat the input as a **radiometric measurement**. Pixel values are linear transmittance captured by the sensor. * **Source corrections** (linear domain, before the log conversion): * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff using a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The reference is decoded and the gain **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so the render never touches the original reference file. Moving or deleting it is harmless. The per-image edit stores only the profile id. The render path resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py` at startup) and caches it, and the profile id plus a content token of the gain are folded into the render's source hash. - * **Embedded lens correction** (`features/lens`): applies cubic RGB inverse warps after flat-field, before sensor unmix, user geometry and crop. Preview and export use the same source coefficients, with orientation and crop offsets preserved. Edges replicate; values above 1 remain available to later stages. Composite and RGB+IR sources skip this correction. + * **Embedded lens correction** (`features/lens`): applies selected distortion and lateral CA as cubic RGB inverse warps after flat-field, before sensor unmix and geometry. CA-only keeps green-channel geometry and extends relative edge correction beyond the calibrated radius. Preview and export share source coefficients, orientation and crop offsets. Edges replicate; values above 1 remain available. Composite and RGB+IR sources skip correction. * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, the camera's CFA passbands overlap the light source's bands, so a pure R/G/B exposure leaks into the other channels. That is a fixed property of the sensor and light pair, independent of film. It is calibrated once from three bare-light exposures, with response columns normalized to a unit diagonal so per-capture exposure cancels, then inverted. It is applied as a 3×3 unmix of the **linear** capture, ahead of the log and inversion where the film-dye crosstalk below lives. It is independent of film but *not* of the light: it is meaningful only for a capture made under narrowband light, so `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband, see below) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The related **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 72b24cf86..fd69ff04d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -511,7 +511,8 @@ Where the frame gets its final shape: what is inside the print, and whether it s Both replicate a wedge along the squeezed edge, as Fine Rotation does; crop it off. Crop before correcting if you can, because the meters read the corrected frame: on an uncropped scan a big correction pulls rebate and surround into the metered area and the print darkens. * **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight-edge reference. Corrected before Tilt and Swing. -* **From metadata**: use the source file's embedded scanning-lens correction; replaces manual distortion. The readout shows available/active distortion and lateral chromatic aberration, or why correction is unavailable. Supports Sony ARW coefficients and DNG WarpRectilinear (including DNG 1.7 containers); other DNG warp types, composites and RGB+IR are unsupported. Lens names and camera on/off flags alone do not enable it. Choose this before cropping or retouching; crop off replicated edge pixels if needed. The mode is saved with the edit, while each file supplies its own coefficients. +* **Metadata Distortion**: correct scanning-lens distortion from Sony ARW or DNG WarpRectilinear coefficients; replaces manual distortion. Available when the file supplies distortion data. Choose before cropping or retouching; crop off replicated edges if needed. Composite and RGB+IR sources are unsupported. +* **Metadata CA**: correct lateral chromatic aberration from embedded coefficients. Independent of Metadata Distortion; can be used with manual distortion. Available when the file supplies CA data. Both settings are saved with the edit; each file supplies its own coefficients. ### 5.2 Flat Field: even out the light diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index e7b39873d..31c9ded5c 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -152,7 +152,7 @@ from negpy.services.rendering.prefetch_policy import MIN_RAM_RESERVE_BYTES from negpy.services.rendering.preview_manager import PreviewManager from negpy.services.rendering.source_identity import source_token -from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_corrections from negpy.services.view.coordinate_mapping import CoordinateMapping logger = get_logger(__name__) @@ -237,7 +237,8 @@ def _autocrop_fingerprint(config: WorkspaceConfig, workspace_color_space: str) - int(geometry.autocrop_offset), round(float(geometry.autocrop_rebate_trim), 4), round(float(geometry.distortion_k1), 9), - bool(geometry.lens_from_metadata), + bool(geometry.lens_distortion_from_metadata), + bool(geometry.lens_ca_from_metadata), bool(flatfield.apply), str(flatfield.profile_id), bool(config.process.linear_raw), @@ -1971,7 +1972,7 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b flatfield_profile_id=flatfield.profile_id if (stitch.stitch_enabled and flatfield.apply) else "", half_slice=half_info, demosaic=self.state.config.process.demosaic_preview, - lens_from_metadata=metadata_lens_enabled(self.state.config), + lens_corrections=metadata_lens_corrections(self.state.config), lens_flatfield=self.state.config.flatfield, ) ) @@ -2125,7 +2126,7 @@ def _neighbor_prefetch_task( protected_file_hashes=protected_file_hashes, half_slice=self._half_slice_for_asset(asset["path"], file_hash), demosaic=saved.process.demosaic_preview if saved else self.state.config.process.demosaic_preview, - lens_from_metadata=metadata_lens_enabled(saved or self.state.config), + lens_corrections=metadata_lens_corrections(saved or self.state.config), lens_flatfield=(saved or self.state.config).flatfield, ) @@ -4489,7 +4490,7 @@ def request_render( before/after split instead of being displayed. """ self._render_debounce.stop() - lens_token = lens_decode_token(metadata_lens_enabled(self.state.config), self.state.config.flatfield) + lens_token = lens_decode_token(metadata_lens_corrections(self.state.config), self.state.config.flatfield) if not ephemeral and self.state.current_file_path and lens_token != self.state.preview_lens_token: self.load_file(self.state.current_file_path, preserve_zoom=True) return diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 1ce721d8c..055f6654d 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -133,7 +133,8 @@ def _fmt_gear(values: tuple) -> str: _row("Fine Rotation", "geometry", "fine_rotation"), _row("Easel Tilt", "geometry", "converge_v"), _row("Easel Swing", "geometry", "converge_h"), - _row("Lens Correction", "geometry", "distortion_k1", "lens_from_metadata", sticky=True), + _row("Lens Correction", "geometry", "distortion_k1", "lens_distortion_from_metadata", sticky=True), + _row("Metadata CA", "geometry", "lens_ca_from_metadata", sticky=True), _row("Flip Horizontal", "geometry", "flip_horizontal", sticky=True), _row("Flip Vertical", "geometry", "flip_vertical", sticky=True), )), diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 5056e2935..c9817acd4 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -189,7 +189,8 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "crop_guide_next": lambda: controls.geometry_sidebar.cycle_guide(), "crop_guide_orient": controller.cycle_crop_guide_orientation, "auto_crop": lambda: controls.geometry_sidebar.reset_crop_btn.toggle(), - "lens_from_metadata": lambda: controls.geometry_sidebar.metadata_lens_btn.click(), + "lens_distortion_from_metadata": lambda: controls.geometry_sidebar.metadata_distortion_btn.click(), + "lens_ca_from_metadata": lambda: controls.geometry_sidebar.metadata_ca_btn.click(), "pick_dust": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_dust_btn), "pick_scratch": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_scratch_btn), "pick_scratch_line": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_line_btn), diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 8485aa597..7b88f89c5 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -49,7 +49,8 @@ class ShortcutEntry: "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), "crop_guide_next": ShortcutEntry("O", "Next crop guide overlay", "Geometry"), "crop_guide_orient": ShortcutEntry("Shift+O", "Rotate crop guide orientation", "Geometry"), - "lens_from_metadata": ShortcutEntry("", "Lens correction from embedded metadata", "Geometry"), + "lens_distortion_from_metadata": ShortcutEntry("", "Metadata Distortion", "Geometry"), + "lens_ca_from_metadata": ShortcutEntry("", "Metadata CA", "Geometry"), "auto_crop": ShortcutEntry("Shift+A", "Toggle autocrop", "Geometry"), "pick_dust": ShortcutEntry("Shift+D", "Toggle heal tool", "Tools"), "pick_scratch": ShortcutEntry("Shift+S", "Toggle scratch tool", "Tools"), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 7faa65ec6..18118c573 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -394,10 +394,16 @@ def apply_shortcut_tooltips(self) -> None: ret = self.retouch_sidebar ton = self.toning_sidebar fin = self.finish_sidebar - geo.metadata_lens_btn.setToolTip( + geo.metadata_distortion_btn.setToolTip( tooltip_with_shortcut( - "Apply embedded scanning-lens distortion and lateral CA correction. Replaces manual distortion.", - "lens_from_metadata", + "Apply embedded scanning-lens distortion correction. Replaces manual distortion.", + "lens_distortion_from_metadata", + ) + ) + geo.metadata_ca_btn.setToolTip( + tooltip_with_shortcut( + "Apply embedded lateral chromatic aberration correction. Can be used with manual distortion.", + "lens_ca_from_metadata", ) ) @@ -887,7 +893,8 @@ def _sync_modified_dots(self) -> None: geo.autocrop_offset != _geo.autocrop_offset, geo.autocrop_rebate_trim != _geo.autocrop_rebate_trim, geo.distortion_k1 != _geo.distortion_k1, - geo.lens_from_metadata != _geo.lens_from_metadata, + geo.lens_distortion_from_metadata != _geo.lens_distortion_from_metadata, + geo.lens_ca_from_metadata != _geo.lens_ca_from_metadata, ] ) diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index c7b8d1406..41f2bbe94 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -18,7 +18,7 @@ from negpy.features.process.models import invalidate_local_bounds from negpy.features.lens.models import LensMetadata from negpy.infrastructure.loaders.lens_metadata import read_lens_metadata -from negpy.services.rendering.lens import metadata_lens_enabled +from negpy.services.rendering.lens import metadata_lens_corrections class GeometrySidebar(BaseSidebar): @@ -181,39 +181,49 @@ def _init_ui(self) -> None: "Radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight reference." ) self.layout.addWidget(self.distortion_slider) - self.metadata_lens_btn = self._labeled_toggle( + self.metadata_distortion_btn = self._labeled_toggle( "fa5s.camera", - "From metadata", - conf.lens_from_metadata, - "Apply embedded scanning-lens correction. Replaces manual distortion.", + "Metadata Distortion", + conf.lens_distortion_from_metadata, + "Apply embedded scanning-lens distortion correction. Replaces manual distortion.", + ) + self.metadata_ca_btn = self._labeled_toggle( + "fa5s.camera", + "Metadata CA", + conf.lens_ca_from_metadata, + "Apply embedded lateral chromatic aberration correction. Can be used with manual distortion.", ) self.lens_hint = hint_label("") self.lens_hint.setWordWrap(True) - self.layout.addWidget(self.metadata_lens_btn) + self.layout.addWidget(self.metadata_distortion_btn) + self.layout.addWidget(self.metadata_ca_btn) self.layout.addWidget(self.lens_hint) - def _set_metadata_lens(self, enabled: bool) -> None: - if enabled and not self.metadata_lens_btn.isEnabled(): + def _set_metadata_lens(self, field: str, enabled: bool) -> None: + button = self.metadata_distortion_btn if field == "lens_distortion_from_metadata" else self.metadata_ca_btn + if enabled and not button.isEnabled(): return - self.update_config_section("geometry", persist=True, lens_from_metadata=enabled) + self.update_config_section("geometry", persist=True, **{field: enabled}) def _sync_metadata_lens(self) -> None: config = self.state.config lens = read_lens_metadata(self.state.current_file_path) if self.state.preview_lens_path == self.state.current_file_path and self.state.preview_lens is not None: lens = self.state.preview_lens - requested = replace(config, geometry=replace(config.geometry, lens_from_metadata=True)) - if not metadata_lens_enabled(requested): + requested = replace(config, geometry=replace(config.geometry, lens_distortion_from_metadata=True, lens_ca_from_metadata=True)) + if not metadata_lens_corrections(requested): lens = LensMetadata(reason="Embedded lens correction is unavailable for composites.") if self.state.has_ir: lens = LensMetadata(reason="Embedded lens correction is unavailable for RGB+IR sources.") - enabled = config.geometry.lens_from_metadata - self.metadata_lens_btn.setChecked(enabled) - self.metadata_lens_btn.setEnabled(lens.available or enabled) - self.metadata_lens_btn.edited_dot.set_active(enabled) - state = "Active" if enabled and lens.available else "Available" if lens.available else "Unavailable" - self.lens_hint.setText(f"{state}: {lens.description}") - self.distortion_slider.setEnabled(not enabled) + for button, enabled, available in ( + (self.metadata_distortion_btn, config.geometry.lens_distortion_from_metadata, lens.distortion), + (self.metadata_ca_btn, config.geometry.lens_ca_from_metadata, lens.ca), + ): + button.setChecked(enabled) + button.setEnabled(available or enabled) + button.edited_dot.set_active(enabled) + self.lens_hint.setText(lens.description if lens.available else f"Unavailable: {lens.reason}") + self.distortion_slider.setEnabled(not config.geometry.lens_distortion_from_metadata) def cycle_guide(self) -> None: self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count()) @@ -223,7 +233,8 @@ def _sync_guide_orient_btn(self) -> None: self.guide_orient_btn.setEnabled(ORIENTATION_COUNT.get(CropGuide(guide), 1) > 1 if guide else False) def _connect_signals(self) -> None: - self.metadata_lens_btn.toggled.connect(self._set_metadata_lens) + self.metadata_distortion_btn.toggled.connect(lambda enabled: self._set_metadata_lens("lens_distortion_from_metadata", enabled)) + self.metadata_ca_btn.toggled.connect(lambda enabled: self._set_metadata_lens("lens_ca_from_metadata", enabled)) self.guide_combo.currentIndexChanged.connect(lambda _i: self.controller.set_crop_guide(self.guide_combo.currentData())) self.guide_combo.currentIndexChanged.connect(lambda _i: self._sync_guide_orient_btn()) self.guide_orient_btn.clicked.connect(self.controller.cycle_crop_guide_orientation) @@ -356,7 +367,8 @@ def block_signals(self, blocked: bool) -> None: self.converge_v_slider.blockSignals(blocked) self.converge_h_slider.blockSignals(blocked) self.distortion_slider.blockSignals(blocked) - self.metadata_lens_btn.blockSignals(blocked) + self.metadata_distortion_btn.blockSignals(blocked) + self.metadata_ca_btn.blockSignals(blocked) self.manual_crop_btn.blockSignals(blocked) self.straighten_btn.blockSignals(blocked) self.reset_crop_btn.blockSignals(blocked) diff --git a/negpy/desktop/workers/hdr.py b/negpy/desktop/workers/hdr.py index a7b82a06b..b950cdf19 100644 --- a/negpy/desktop/workers/hdr.py +++ b/negpy/desktop/workers/hdr.py @@ -87,7 +87,7 @@ def run(self, task: HdrTask) -> None: flatfield=FlatFieldConfig(), hdr=HdrConfig(), process=replace(original.process, highlight_reconstruction=0), - geometry=replace(original.geometry, lens_from_metadata=False), + geometry=replace(original.geometry, lens_distortion_from_metadata=False, lens_ca_from_metadata=False), ) f32, _, _ = self._processor._decode_oriented_f32(f["path"], params, wb_override=bracket_wb) if i == 0: diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 18891a259..a24f39d33 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -12,7 +12,8 @@ from negpy.features.exposure.analysis import color_histogram, output_histogram, proof_grid, rotate_grid, strip_mosaic from negpy.features.flatfield.logic import apply_flatfield from negpy.features.flatfield.models import FlatFieldConfig -from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled +from negpy.features.lens.models import LensCorrections +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_corrections from negpy.features.hdr.models import HdrConfig, hdr_active from negpy.features.geometry.batch_autocrop import CropEvidence, detect_crop_candidate, resolve_roll_crops from negpy.features.process.capture_color import wb_only_cam_xyz @@ -234,7 +235,7 @@ class PreviewLoadTask: stitch: StitchConfig = StitchConfig() # composite: non-primary parts + stored registration hdr: HdrConfig = HdrConfig() # bracket: the other exposures, merged with file_path (the reference) flatfield_profile_id: str = "" # per-part flat-field profile for stitch previews - lens_from_metadata: bool = False + lens_corrections: LensCorrections = LensCorrections() lens_flatfield: FlatFieldConfig = FlatFieldConfig() demosaic: str = DemosaicMode.AUTO # CFA interpolation for the preview decode half_slice: tuple[int, float, tuple[float, float, float, float] | None, float] | None = ( @@ -1071,7 +1072,7 @@ def _process_prefetch(self, task: PreviewLoadTask) -> None: should_cancel=lambda: not self._prefetch_is_current(task), highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, - lens_from_metadata=task.lens_from_metadata, + lens_corrections=task.lens_corrections, lens_flatfield=task.lens_flatfield, ) except InterruptedError: @@ -1130,7 +1131,7 @@ def cancelled() -> bool: metadata.get("cam_xyz"), metadata.get("camera_wb"), metadata.get("lens_correction"), - lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + lens_decode_token(task.lens_corrections, task.lens_flatfield), ), metadata.get("detect_preview"), ) @@ -1174,7 +1175,7 @@ def cancelled() -> bool: metadata.get("cam_xyz"), metadata.get("camera_wb"), metadata.get("lens_correction"), - lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + lens_decode_token(task.lens_corrections, task.lens_flatfield), ), metadata.get("detect_preview"), ) @@ -1216,7 +1217,7 @@ def cancelled() -> bool: metadata.get("cam_xyz"), metadata.get("camera_wb"), metadata.get("lens_correction"), - lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + lens_decode_token(task.lens_corrections, task.lens_flatfield), ), metadata.get("detect_preview"), ) @@ -1236,7 +1237,7 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, - lens_from_metadata=task.lens_from_metadata, + lens_corrections=task.lens_corrections, lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): @@ -1258,7 +1259,7 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, - lens_from_metadata=task.lens_from_metadata, + lens_corrections=task.lens_corrections, lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): @@ -1285,7 +1286,7 @@ def cancelled() -> bool: metadata.get("cam_xyz"), metadata.get("camera_wb"), metadata.get("lens_correction"), - lens_decode_token(task.lens_from_metadata, task.lens_flatfield), + lens_decode_token(task.lens_corrections, task.lens_flatfield), ), metadata.get("detect_preview"), ) @@ -1391,7 +1392,7 @@ def _decode_asset_preview_with_meta( positive_source=config.process.positive_source, highlight_mode=effective_highlight_reconstruction(config.process), bake_camera_wb=highlight_reconstruction_bakes_wb(config.process, config.exposure.render_intent), - lens_from_metadata=metadata_lens_enabled(config), + lens_corrections=metadata_lens_corrections(config), lens_flatfield=config.flatfield, **common, ) @@ -1465,7 +1466,7 @@ def _frame_evidence(self, index: int, frame: BatchAutoCropInput, task: BatchAuto return None config = frame.config - corrected = raw if metadata_lens_enabled(config) else apply_flatfield(raw, config.flatfield) + corrected = raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield) detection_geometry = replace( config.geometry, crop_rect=None, diff --git a/negpy/desktop/workers/stitch.py b/negpy/desktop/workers/stitch.py index 5fced47e8..95efaafac 100644 --- a/negpy/desktop/workers/stitch.py +++ b/negpy/desktop/workers/stitch.py @@ -58,7 +58,9 @@ def run(self, task: StitchTask) -> None: self.progress.emit(i, total, f"Decoding {f['name']}") # Registration and composite assembly both use unwarped sources. params = task.params_by_path[f["path"]] - params = replace(params, geometry=replace(params.geometry, lens_from_metadata=False)) + params = replace( + params, geometry=replace(params.geometry, lens_distortion_from_metadata=False, lens_ca_from_metadata=False) + ) f32, _, _ = self._processor._decode_oriented_f32(f["path"], params) parts.append(f32) self.progress.emit(len(task.files), total, "Registering frames") diff --git a/negpy/domain/migrations.py b/negpy/domain/migrations.py index 113f76b73..5b78be9ba 100644 --- a/negpy/domain/migrations.py +++ b/negpy/domain/migrations.py @@ -114,6 +114,11 @@ def migrate_flat_config(data: Dict[str, Any]) -> Dict[str, Any]: data.setdefault("use_luma_average", legacy) data.setdefault("use_color_average", legacy) + if "lens_from_metadata" in data: + legacy = bool(data.pop("lens_from_metadata")) + data.setdefault("lens_distortion_from_metadata", legacy) + data.setdefault("lens_ca_from_metadata", legacy) + # Lab "Separation" moved to ProcessConfig crosstalk: the 1.0-2.0 slider maps to # strength 0-1. crosstalk_matrix/crosstalk_profile keep their names and re-route # by field membership; the old serialized DEFAULT_MATRIX field is dropped. diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index 99d1979ad..e373b20c3 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -102,7 +102,8 @@ class AutocropMode(StrEnum): @dataclass(frozen=True) class GeometryConfig: - lens_from_metadata: bool = False + lens_distortion_from_metadata: bool = False + lens_ca_from_metadata: bool = False rotation: int = 0 fine_rotation: float = 0.0 flip_horizontal: bool = False @@ -139,7 +140,7 @@ def __post_init__(self) -> None: """Ensure a JSON-loaded list is converted back to a tuple, keeping the frozen dataclass hashable for pipeline cache keys. Enum fields coerce so a retired or hand-edited saved value degrades to the default, not a load failure.""" - if self.lens_from_metadata: + if self.lens_distortion_from_metadata: object.__setattr__(self, "distortion_k1", 0.0) if self.crop_rect is not None: object.__setattr__(self, "crop_rect", tuple(self.crop_rect)) diff --git a/negpy/features/lens/logic.py b/negpy/features/lens/logic.py index 866e17b3f..8b48817a9 100644 --- a/negpy/features/lens/logic.py +++ b/negpy/features/lens/logic.py @@ -4,24 +4,31 @@ import numpy as np from negpy.domain.types import ImageBuffer -from negpy.features.lens.models import LensMetadata +from negpy.features.lens.models import LensCorrections, LensMetadata from negpy.kernel.image.logic import apply_exif_orientation -def apply_lens(img: ImageBuffer, lens: LensMetadata, orientation: int = 1) -> ImageBuffer: - """Apply all supported embedded warps, with bounded temporary map memory.""" - if not lens.available or min(img.shape[:2]) < 2: +def apply_lens( + img: ImageBuffer, + lens: LensMetadata, + orientation: int = 1, + corrections: LensCorrections = LensCorrections(True, True), +) -> ImageBuffer: + """Apply selected embedded warps, with bounded temporary map memory.""" + if not corrections or not lens.available or min(img.shape[:2]) < 2: return img inverse_orientation = {6: 8, 8: 6}.get(orientation, orientation) source = np.ascontiguousarray(apply_exif_orientation(img, inverse_orientation)) for warp in lens.warps: + if not (corrections.distortion and warp.has_distortion or corrections.ca and warp.has_ca): + continue h, w = source.shape[:2] result = np.empty_like(source) for channel in range(3): plane = np.ascontiguousarray(source[..., channel]) for start in range(0, h, 256): stop = min(start + 256, h) - mx, my = warp.remap(lens, source.shape, start, stop, channel) + mx, my = warp.remap(lens, source.shape, start, stop, channel, corrections) result[start:stop, :, channel] = cv2.remap(plane, mx, my, cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) # Flat-field gains can exceed white before sensor unmix. source = np.maximum(result, 0.0, out=result) diff --git a/negpy/features/lens/models.py b/negpy/features/lens/models.py index 13ed692f7..fa127f6ea 100644 --- a/negpy/features/lens/models.py +++ b/negpy/features/lens/models.py @@ -6,6 +6,15 @@ import numpy as np +@dataclass(frozen=True) +class LensCorrections: + distortion: bool = False + ca: bool = False + + def __bool__(self) -> bool: + return self.distortion or self.ca + + class LensWarp(Protocol): @property def has_distortion(self) -> bool: ... @@ -13,7 +22,15 @@ def has_distortion(self) -> bool: ... @property def has_ca(self) -> bool: ... - def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + def remap( + self, + lens: LensMetadata, + shape: tuple[int, ...], + start: int, + stop: int, + channel: int, + corrections: LensCorrections = LensCorrections(True, True), + ) -> tuple[np.ndarray, np.ndarray]: """Return float32 inverse x/y maps for one channel and rows [start, stop).""" ... diff --git a/negpy/features/lens/warps.py b/negpy/features/lens/warps.py index 0d2e853f9..a335272e7 100644 --- a/negpy/features/lens/warps.py +++ b/negpy/features/lens/warps.py @@ -2,13 +2,21 @@ from dataclasses import dataclass +import cv2 import numpy as np -from negpy.features.lens.models import LensMetadata +from negpy.features.lens.models import LensCorrections, LensMetadata IDENTITY = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0) +def _rectilinear_xy(x: np.ndarray, y: np.ndarray, coefficients: tuple[float, ...]) -> tuple[np.ndarray, np.ndarray]: + k0, k1, k2, k3, t0, t1 = coefficients + r2 = x * x + y * y + factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) + return x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x), y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y) + + def _coordinates(lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, center: tuple[float, float]) -> tuple: h, w = shape[:2] t, left, b, r = lens.active_area or (0, 0, h, w) @@ -36,13 +44,45 @@ def has_ca(self) -> bool: self.coefficients[0] != self.coefficients[1] or self.coefficients[2] != self.coefficients[1] ) - def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + def remap( + self, + lens: LensMetadata, + shape: tuple[int, ...], + start: int, + stop: int, + channel: int, + corrections: LensCorrections = LensCorrections(True, True), + ) -> tuple[np.ndarray, np.ndarray]: x, y, cx, cy, radius, sx, sy, left, top = _coordinates(lens, shape, start, stop, self.center) - k0, k1, k2, k3, t0, t1 = self.coefficients[0 if len(self.coefficients) == 1 else channel] - r2 = x * x + y * y - factor = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)) - mx = (cx + radius * (x * factor + 2 * t0 * x * y + t1 * (r2 + 2 * x * x)) - left + 0.5) / sx - 0.5 - my = (cy + radius * (y * factor + 2 * t1 * x * y + t0 * (r2 + 2 * y * y)) - top + 0.5) / sy - 0.5 + common = self.coefficients[0 if len(self.coefficients) == 1 else 1] + selected = self.coefficients[channel] if corrections.ca and len(self.coefficients) == 3 else common + target_x, target_y = x, y + if not corrections.distortion: + if selected == common: + my, mx = np.mgrid[start:stop, : shape[1]].astype(np.float32) + return mx, my + # CA-only maps to the original green geometry: channel(green_inverse(x, y)). + g0, g1, g2, g3, t0, t1 = common + points = np.stack(np.broadcast_arrays(x, y), axis=-1) + unwarped = cv2.undistortPointsIter( + points.reshape(-1, 1, 2), + np.diag([g0, g0, 1.0]), + np.array([g1, g2, t0, t1, g3]) / g0, + None, + None, + (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 50, 1e-8), + ).reshape(points.shape) + x, y = unwarped[..., 0], unwarped[..., 1] + # Outside the calibrated radius, extend the edge's relative CA displacement. + limit = np.maximum(np.hypot(x, y), 1.0) + x, y = x / limit, y / limit + wx, wy = _rectilinear_xy(x, y, selected) + if not corrections.distortion: + gx, gy = _rectilinear_xy(x, y, common) + scale = np.hypot(target_x, target_y) / np.maximum(np.hypot(gx, gy), 1e-8) + wx, wy = target_x + (wx - gx) * scale, target_y + (wy - gy) * scale + mx = (cx + radius * wx - left + 0.5) / sx - 0.5 + my = (cy + radius * wy - top + 0.5) / sy - 0.5 return mx.astype(np.float32), my.astype(np.float32) @@ -60,20 +100,28 @@ def has_distortion(self) -> bool: def has_ca(self) -> bool: return any(self.ca_red) or any(self.ca_blue) - def remap(self, lens: LensMetadata, shape: tuple[int, ...], start: int, stop: int, channel: int) -> tuple[np.ndarray, np.ndarray]: + def remap( + self, + lens: LensMetadata, + shape: tuple[int, ...], + start: int, + stop: int, + channel: int, + corrections: LensCorrections = LensCorrections(True, True), + ) -> tuple[np.ndarray, np.ndarray]: # Sony's knot positions and units follow darktable's embedded-metadata model (GPL-3.0+). # https://github.com/darktable-org/darktable/blob/master/src/iop/lens.cc h, w = shape[:2] x = np.arange(w, dtype=np.float32)[None, :] - w * 0.5 y = np.arange(start, stop, dtype=np.float32)[:, None] - h * 0.5 radius = np.hypot(x, y) / np.hypot(w * 0.5, h * 0.5) - n = len(self.distortion) or len(self.ca_red) + n = len(self.distortion) or len(self.ca_red) or len(self.ca_blue) knots = (np.arange(n) + 0.5) / (n - 1) factors = np.ones(n) - if self.distortion: + if corrections.distortion and self.distortion: factors += np.asarray(self.distortion) / 16384.0 ca = self.ca_red if channel == 0 else self.ca_blue if channel == 2 else () - if ca: + if corrections.ca and ca: factors *= 1 + np.asarray(ca) / 2097152.0 factor = np.interp(radius, knots, factors).astype(np.float32) return (x * factor + w * 0.5).astype(np.float32), (y * factor + h * 0.5).astype(np.float32) diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 913816674..778ad0aa6 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -38,7 +38,7 @@ from negpy.features.exposure.analysis import COLOR_HIST_BINS from negpy.features.exposure.models import RenderIntent from negpy.features.flatfield.logic import apply_flatfield, flatfield_token -from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled, prepare_lens_source +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_corrections, prepare_lens_source from negpy.features.geometry.logic import autocrop_detection_key, resolve_autocrop_rect from negpy.features.retouch.logic import ( apply_hair_inpaint, @@ -651,7 +651,7 @@ def run_pipeline( source_hash, img.shape, skip_flatfield, - metadata_lens_enabled(settings), + metadata_lens_corrections(settings), flatfield_token(settings.flatfield), sensor_token(settings.process), rgbscan_token(settings.rgbscan), @@ -662,7 +662,7 @@ def run_pipeline( img = self._precorrect_value else: source = img - if not skip_flatfield and not settings.stitch.stitch_enabled and not metadata_lens_enabled(settings): + if not skip_flatfield and not settings.stitch.stitch_enabled and not metadata_lens_corrections(settings): img = apply_flatfield(img, settings.flatfield) # Sensor unmix is a source pre-correction like flat-field. skip_flatfield buffers # come from _load_source_f32, which already applied it. Triplet composites take @@ -681,7 +681,7 @@ def run_pipeline( base_hash = ( source_hash + flatfield_token(settings.flatfield) - + lens_decode_token(metadata_lens_enabled(settings), settings.flatfield) + + lens_decode_token(metadata_lens_corrections(settings), settings.flatfield) + rgbscan_token(settings.rgbscan) + stitch_token(settings.stitch) + hdr_token(settings.hdr) @@ -945,7 +945,7 @@ def _load_source_f32( cache_key = ( file_path, mtime, - lens_decode_token(metadata_lens_enabled(params), params.flatfield), + lens_decode_token(metadata_lens_corrections(params), params.flatfield), effective_linear_raw(params.process, params.exposure.render_intent), effective_highlight_reconstruction(params.process), highlight_reconstruction_bakes_wb(params.process, params.exposure.render_intent), @@ -1124,8 +1124,8 @@ def _decode(path: str) -> np.ndarray: orientation = metadata.get("orientation", 1) f32_buffer = apply_exif_orientation(f32_buffer, orientation) - if metadata_lens_enabled(params): - f32_buffer = prepare_lens_source(f32_buffer, metadata, params.flatfield) + if metadata_lens_corrections(params): + f32_buffer = prepare_lens_source(f32_buffer, metadata, params.flatfield, metadata_lens_corrections(params)) else: f32_buffer = apply_flatfield(f32_buffer, params.flatfield) if not is_triplet: @@ -1204,7 +1204,7 @@ def _prepare_export_source_locked( detect_key = ( source_hash + flatfield_token(params.flatfield) - + lens_decode_token(metadata_lens_enabled(params), params.flatfield) + + lens_decode_token(metadata_lens_corrections(params), params.flatfield) + rgbscan_token(params.rgbscan) + stitch_token(params.stitch) + hdr_token(params.hdr) @@ -1659,7 +1659,7 @@ def render_display_array( detect_key = ( source_hash + flatfield_token(params.flatfield) - + lens_decode_token(metadata_lens_enabled(params), params.flatfield) + + lens_decode_token(metadata_lens_corrections(params), params.flatfield) + rgbscan_token(params.rgbscan) + stitch_token(params.stitch) + hdr_token(params.hdr) diff --git a/negpy/services/rendering/lens.py b/negpy/services/rendering/lens.py index fdd52ce69..731efa237 100644 --- a/negpy/services/rendering/lens.py +++ b/negpy/services/rendering/lens.py @@ -4,7 +4,7 @@ from negpy.features.flatfield.logic import apply_flatfield, flatfield_token from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.lens.logic import apply_lens -from negpy.features.lens.models import LensMetadata +from negpy.features.lens.models import LensCorrections, LensMetadata from negpy.features.hdr.models import hdr_active from negpy.features.rgbscan.models import is_rgb_triplet from negpy.features.stitch.models import stitch_active @@ -13,21 +13,26 @@ from negpy.domain.models import WorkspaceConfig -def metadata_lens_enabled(config: "WorkspaceConfig") -> bool: +def metadata_lens_corrections(config: "WorkspaceConfig") -> LensCorrections: """Composite registrations refer to the unwarped component images.""" - return config.geometry.lens_from_metadata and not ( - stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan) - ) + if stitch_active(config.stitch) or hdr_active(config.hdr) or is_rgb_triplet(config.rgbscan): + return LensCorrections() + return LensCorrections(config.geometry.lens_distortion_from_metadata, config.geometry.lens_ca_from_metadata) -def lens_decode_token(enabled: bool, flatfield: FlatFieldConfig) -> str: - return "|embedded-lens-v1" + flatfield_token(flatfield) if enabled else "" +def lens_decode_token(corrections: LensCorrections, flatfield: FlatFieldConfig) -> str: + return f"|embedded-lens-v2-d{int(corrections.distortion)}-ca{int(corrections.ca)}" + flatfield_token(flatfield) if corrections else "" -def prepare_lens_source(img: ImageBuffer, metadata: dict, flatfield: FlatFieldConfig) -> ImageBuffer: +def prepare_lens_source( + img: ImageBuffer, + metadata: dict, + flatfield: FlatFieldConfig, + corrections: LensCorrections = LensCorrections(True, True), +) -> ImageBuffer: """Flat-field in sensor positions before a lens warp moves the samples.""" img = apply_flatfield(img, flatfield) lens = metadata.get("lens_correction") if isinstance(lens, LensMetadata) and metadata.get("ir") is None: - img = apply_lens(img, lens, metadata.get("orientation", 1)) + img = apply_lens(img, lens, metadata.get("orientation", 1), corrections) return img diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 8ad9a2861..0480662e1 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -27,6 +27,7 @@ from negpy.kernel.system.override import effective_max_texture_size from negpy.features.flatfield.logic import apply_flatfield, flatfield_token from negpy.features.flatfield.models import FlatFieldConfig +from negpy.features.lens.models import LensCorrections from negpy.services.rendering.lens import lens_decode_token, prepare_lens_source from negpy.features.retouch.logic import downsample_ir from negpy.features.hdr.logic import apply_render_exposure, merge_providers, resolve_anchor @@ -230,7 +231,7 @@ def _load_from_open_raw( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, - lens_from_metadata: bool = False, + lens_corrections: LensCorrections = LensCorrections(), lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ @@ -342,8 +343,8 @@ def _load_from_open_raw( # Bake EXIF orientation into the buffer (postprocess runs with user_flip=0). orientation = metadata.get("orientation", 1) full_linear = apply_exif_orientation(uint16_to_float32(np.ascontiguousarray(rgb)), orientation) - if lens_from_metadata: - full_linear = prepare_lens_source(full_linear, metadata, lens_flatfield) + if lens_corrections: + full_linear = prepare_lens_source(full_linear, metadata, lens_flatfield, lens_corrections) del rgb # release the uint16 decode buffer before the resize/copy peak if should_cancel is not None and should_cancel(): raise InterruptedError("preview load cancelled") @@ -451,7 +452,7 @@ def _load_from_open_raw( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, - lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -511,7 +512,7 @@ def load_linear_preview( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, - lens_from_metadata: bool = False, + lens_corrections: LensCorrections = LensCorrections(), lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ @@ -536,7 +537,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, - lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -568,7 +569,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, - lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -602,7 +603,7 @@ def load_linear_preview( highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, wb_override=wb_override, - lens_from_metadata=lens_from_metadata, + lens_corrections=lens_corrections, lens_flatfield=lens_flatfield, ) log( @@ -911,7 +912,7 @@ def load_splash_and_linear( should_cancel: Optional[Callable[[], bool]] = None, highlight_mode: int = 0, bake_camera_wb: bool = False, - lens_from_metadata: bool = False, + lens_corrections: LensCorrections = LensCorrections(), lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[Optional[Tuple[ImageBuffer, Dimensions]], Tuple[ImageBuffer, Dimensions, dict]]: """ @@ -934,7 +935,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, - lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -970,7 +971,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, demosaic=demosaic, - lens_token=lens_decode_token(lens_from_metadata, lens_flatfield), + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -988,7 +989,7 @@ def load_splash_and_linear( log = logger.info if log_timings else logger.debug splash_result: Optional[Tuple[ImageBuffer, Dimensions]] = None with ctx_mgr as raw: - if not full_resolution and not lens_from_metadata: + if not full_resolution and not lens_corrections: splash_result = self._try_splash_from_open_raw(raw, file_path, half_slice=half_slice) linear_result = self._load_from_open_raw( raw, @@ -1005,7 +1006,7 @@ def load_splash_and_linear( should_cancel=should_cancel, highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, - lens_from_metadata=lens_from_metadata, + lens_corrections=lens_corrections, lens_flatfield=lens_flatfield, ) log( diff --git a/negpy/services/rendering/source_identity.py b/negpy/services/rendering/source_identity.py index 0788a312d..5bf83e3e7 100644 --- a/negpy/services/rendering/source_identity.py +++ b/negpy/services/rendering/source_identity.py @@ -20,7 +20,7 @@ ) from negpy.features.rgbscan.logic import rgbscan_token from negpy.features.stitch.models import stitch_token -from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_corrections def source_token(config: WorkspaceConfig) -> str: @@ -40,7 +40,7 @@ def source_token(config: WorkspaceConfig) -> str: rgbscan_token(config.rgbscan), stitch_token(config.stitch), hdr_token(config.hdr), - lens_decode_token(metadata_lens_enabled(config), config.flatfield), + lens_decode_token(metadata_lens_corrections(config), config.flatfield), ] if config.stitch.stitch_enabled: # Stitch flat-fields each part before assembly; embedded lens mode carries this diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py index 0c46a2083..ea13384a1 100644 --- a/tests/test_batch_autocrop_worker.py +++ b/tests/test_batch_autocrop_worker.py @@ -2,6 +2,7 @@ from types import SimpleNamespace import numpy as np +from negpy.features.lens.models import LensCorrections import negpy.desktop.workers.render as render_workers from negpy.desktop.workers.render import ( @@ -31,7 +32,7 @@ def load_linear_preview( positive_source=False, # noqa: ARG002 — accepted, not asserted on highlight_mode=0, # noqa: ARG002 — accepted, not asserted on bake_camera_wb=False, # noqa: ARG002 — accepted, not asserted on - lens_from_metadata=False, # noqa: ARG002 — accepted, not asserted on + lens_corrections=LensCorrections(), # noqa: ARG002 — accepted, not asserted on lens_flatfield=None, # noqa: ARG002 — accepted, not asserted on ): self.linear_calls.append( diff --git a/tests/test_batch_norm_wb.py b/tests/test_batch_norm_wb.py index 024268631..ae9a0855f 100644 --- a/tests/test_batch_norm_wb.py +++ b/tests/test_batch_norm_wb.py @@ -10,6 +10,7 @@ from negpy.desktop.workers.render import NormalizationInput, NormalizationTask, NormalizationWorker from negpy.domain.models import WorkspaceConfig +from negpy.features.lens.models import LensCorrections class _FakePreviewService: @@ -29,7 +30,7 @@ def load_linear_preview( positive_source=False, highlight_mode=0, bake_camera_wb=False, - lens_from_metadata=False, + lens_corrections=LensCorrections(), lens_flatfield=None, ): self.calls[file_hash] = use_camera_wb diff --git a/tests/test_controller.py b/tests/test_controller.py index 34e8c8736..271fb0938 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -299,6 +299,7 @@ def test_load_file_tags_the_decode_with_the_current_generation(self): def test_lens_toggles_keep_the_displayed_texture_during_reload(self): from negpy.infrastructure.gpu.resources import GPUTexture + from negpy.features.lens.models import LensCorrections from negpy.services.rendering.lens import lens_decode_token self.controller.preview_load_requested.disconnect(self.controller.preview_load_worker.process) @@ -314,14 +315,21 @@ def test_lens_toggles_keep_the_displayed_texture_during_reload(self): self.controller.preview_load_requested.connect(decode) self.controller.zoom_requested.connect(zoom) - for enabled in (True, False, True): - state.config = replace(state.config, geometry=replace(state.config.geometry, lens_from_metadata=enabled)) - state.preview_lens_token = lens_decode_token(not enabled, state.config.flatfield) + previous = LensCorrections() + for corrections in (LensCorrections(True, False), LensCorrections(True, True), LensCorrections(False, True), LensCorrections()): + state.config = replace( + state.config, + geometry=replace( + state.config.geometry, lens_distortion_from_metadata=corrections.distortion, lens_ca_from_metadata=corrections.ca + ), + ) + state.preview_lens_token = lens_decode_token(previous, state.config.flatfield) self.controller.request_render() task = decode.call_args.args[0] - self.assertEqual(task.lens_from_metadata, enabled) + self.assertEqual(task.lens_corrections, corrections) self.assertFalse(task.use_splash) self.assertIs(cleanup.call_args.args[0], texture) + previous = corrections loading.assert_not_called() released.assert_not_called() diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index 63b8e73a6..7b1a215b8 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -14,11 +14,11 @@ from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.geometry.models import GeometryConfig from negpy.features.lens.logic import apply_lens -from negpy.features.lens.models import LensMetadata, LensWarp +from negpy.features.lens.models import LensCorrections, LensMetadata, LensWarp from negpy.features.lens.warps import IDENTITY, RectilinearWarp, SonyWarp from negpy.infrastructure.loaders.lens_metadata import bind_decode, parse_opcodes, read_lens_metadata from negpy.kernel.image.logic import apply_exif_orientation -from negpy.services.rendering.lens import lens_decode_token, metadata_lens_enabled, prepare_lens_source +from negpy.services.rendering.lens import lens_decode_token, metadata_lens_corrections, prepare_lens_source from negpy.services.rendering.preview_cache import PreviewCacheKey from negpy.services.rendering.source_identity import source_token @@ -53,6 +53,36 @@ def test_dng_identity_and_separate_capabilities(): assert distortion.distortion and not distortion.ca +@pytest.mark.parametrize("distortion,ca", [(False, False), (True, False), (False, True), (True, True)]) +def test_sony_components_are_independent(distortion, ca): + shape = (40, 60, 3) + warp = SonyWarp((-1024,) * 16, (32768,) * 16, (-16384,) * 16) + lens = LensMetadata("Sony", (warp,)) + y, x = np.mgrid[:40, :60].astype(np.float32) + for channel, ca_gain in enumerate((1 + 1 / 64, 1, 1 - 1 / 128)): + mx, my = warp.remap(lens, shape, 0, 40, channel, LensCorrections(distortion, ca)) + factor = (1 - 1 / 16 if distortion else 1) * (ca_gain if ca else 1) + np.testing.assert_allclose(mx, (x - 30) * factor + 30, atol=1e-5) + np.testing.assert_allclose(my, (y - 20) * factor + 20, atol=1e-5) + + +@pytest.mark.parametrize("common", [(0.9, 0, 0, 0, 0, 0), (1, -0.3, 0, 0, 0, 0), (1.02, 0.1, 0.02, 0.001, 0.003, -0.002)]) +def test_dng_ca_only_preserves_green_geometry_with_crop_and_off_center_lens(common): + warp = RectilinearWarp(tuple(tuple(v * scale for v in common) for scale in (1.02, 1, 0.98)), (0.37, 0.61)) + lens = LensMetadata("DNG", (warp,), active_area=(4, 8, 104, 168), buffer_area=(10, 20, 90, 140)) + y, x = np.mgrid[:40, :60].astype(np.float32) + cx = (8 + 0.37 * 159 - 20 + 0.5) / 2 - 0.5 + cy = (4 + 0.61 * 99 - 10 + 0.5) / 2 - 0.5 + for channel, factor in enumerate((1.02, 1, 0.98)): + mx, my = warp.remap(lens, (40, 60, 3), 0, 40, channel, LensCorrections(ca=True)) + np.testing.assert_allclose(mx, (x - cx) * factor + cx, atol=1e-4) + np.testing.assert_allclose(my, (y - cy) * factor + cy, atol=1e-4) + dx, dy = warp.remap(lens, (40, 60, 3), 0, 40, channel, LensCorrections(distortion=True)) + gx, gy = warp.remap(lens, (40, 60, 3), 0, 40, 1) + np.testing.assert_array_equal(dx, gx) + np.testing.assert_array_equal(dy, gy) + + @pytest.mark.parametrize("byteorder", ["<", ">"]) @pytest.mark.parametrize("subifd", [False, True]) def test_dng_17_and_tiff_byte_order_do_not_change_opcode_endianness(tmp_path, byteorder, subifd): @@ -220,22 +250,33 @@ def test_lens_preserves_flatfield_values_above_one_for_sensor_unmix(monkeypatch, np.testing.assert_array_equal(image, np.float32(0.8)) -def test_setting_roundtrip_and_source_cache_identity(monkeypatch): +@pytest.mark.parametrize("distortion,ca", [(False, False), (True, False), (False, True), (True, True)]) +def test_setting_roundtrip_and_source_cache_identity(monkeypatch, distortion, ca): from negpy.features.flatfield import logic as ff monkeypatch.setitem(ff._GAIN_CACHE, "reference", (np.ones((4, 6, 3), np.float32), "gain-token")) base = WorkspaceConfig() - enabled = replace(base, geometry=GeometryConfig(lens_from_metadata=True, distortion_k1=0.05)) + enabled = replace(base, geometry=GeometryConfig(lens_distortion_from_metadata=distortion, lens_ca_from_metadata=ca, distortion_k1=0.05)) restored = WorkspaceConfig.from_flat_dict(enabled.to_dict()) - assert restored.geometry.lens_from_metadata - assert restored.geometry.distortion_k1 == 0 - assert source_token(base) != source_token(enabled) + assert restored == enabled + assert restored.geometry.distortion_k1 == (0 if distortion else 0.05) + assert (source_token(base) != source_token(enabled)) == (distortion or ca) flat = FlatFieldConfig(apply=True, profile_id="reference") - assert source_token(enabled) != source_token(replace(enabled, flatfield=flat)) + assert (source_token(enabled) != source_token(replace(enabled, flatfield=flat))) == (distortion or ca) assert source_token(base) == source_token(replace(base, flatfield=flat)) off = PreviewCacheKey("file", False, "sRGB", False) - on = replace(off, lens_token=lens_decode_token(True, flat)) - assert off.as_tuple() != on.as_tuple() + on = replace(off, lens_token=lens_decode_token(LensCorrections(distortion, ca), flat)) + assert (off.as_tuple() != on.as_tuple()) == (distortion or ca) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_combined_saved_lens_mode_migrates_without_overriding_split_settings(enabled): + config = WorkspaceConfig.from_flat_dict({"lens_from_metadata": enabled}) + assert config.geometry.lens_distortion_from_metadata is enabled + assert config.geometry.lens_ca_from_metadata is enabled + explicit = WorkspaceConfig.from_flat_dict({"lens_from_metadata": enabled, "lens_ca_from_metadata": not enabled}) + assert explicit.geometry.lens_ca_from_metadata is not enabled + assert "lens_from_metadata" not in config.to_dict() def test_sidebar_uses_source_capabilities_and_can_clear_unavailable_saved_mode(qapp, monkeypatch): @@ -246,25 +287,41 @@ def test_sidebar_uses_source_capabilities_and_can_clear_unavailable_saved_mode(q monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: LensMetadata()) sidebar = GeometrySidebar(controller) sidebar.sync_ui() - assert not sidebar.metadata_lens_btn.isEnabled() + assert not sidebar.metadata_distortion_btn.isEnabled() + assert not sidebar.metadata_ca_btn.isEnabled() assert sidebar.distortion_slider.isEnabled() ca = LensMetadata("Sony", (SonyWarp(ca_red=(100,) * 16, ca_blue=(-100,) * 16),)) monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: ca) sidebar.sync_ui() - assert sidebar.metadata_lens_btn.isEnabled() + assert not sidebar.metadata_distortion_btn.isEnabled() + assert sidebar.metadata_ca_btn.isEnabled() assert "lateral CA" in sidebar.lens_hint.text() assert "distortion" not in sidebar.lens_hint.text() - sidebar.metadata_lens_btn.click() + sidebar.metadata_ca_btn.click() requested = controller.apply_config.call_args.args[0] - assert requested.geometry.lens_from_metadata + assert requested.geometry.lens_ca_from_metadata + assert not requested.geometry.lens_distortion_from_metadata controller.state.config = requested + sidebar.sync_ui() + assert sidebar.distortion_slider.isEnabled() monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: LensMetadata()) sidebar.sync_ui() - assert sidebar.metadata_lens_btn.isEnabled() - assert sidebar.metadata_lens_btn.isChecked() + assert sidebar.metadata_ca_btn.isEnabled() + assert sidebar.metadata_ca_btn.isChecked() assert "Unavailable" in sidebar.lens_hint.text() - sidebar.metadata_lens_btn.click() - assert not controller.apply_config.call_args.args[0].geometry.lens_from_metadata + sidebar.metadata_ca_btn.click() + assert not controller.apply_config.call_args.args[0].geometry.lens_ca_from_metadata + + distortion = LensMetadata("Sony", (SonyWarp(distortion=(100,) * 16),)) + controller.state.config = WorkspaceConfig() + monkeypatch.setattr(geometry, "read_lens_metadata", lambda path: distortion) + sidebar.sync_ui() + assert sidebar.metadata_distortion_btn.isEnabled() + assert not sidebar.metadata_ca_btn.isEnabled() + sidebar.metadata_distortion_btn.click() + controller.state.config = controller.apply_config.call_args.args[0] + sidebar.sync_ui() + assert not sidebar.distortion_slider.isEnabled() def test_composites_do_not_apply_primary_lens_metadata(): @@ -272,11 +329,11 @@ def test_composites_do_not_apply_primary_lens_metadata(): from negpy.features.rgbscan.models import RgbScanConfig from negpy.features.stitch.models import StitchConfig - config = replace(WorkspaceConfig(), geometry=GeometryConfig(lens_from_metadata=True)) - assert metadata_lens_enabled(config) - assert not metadata_lens_enabled(replace(config, hdr=HdrConfig(hdr_enabled=True, hdr_paths=("b.arw",)))) - assert not metadata_lens_enabled(replace(config, rgbscan=RgbScanConfig(enabled=True, green_path="g.arw", blue_path="b.arw"))) - assert not metadata_lens_enabled(replace(config, stitch=StitchConfig(stitch_enabled=True, stitch_paths=("b.arw",)))) + config = replace(WorkspaceConfig(), geometry=GeometryConfig(lens_distortion_from_metadata=True, lens_ca_from_metadata=True)) + assert metadata_lens_corrections(config) + assert not metadata_lens_corrections(replace(config, hdr=HdrConfig(hdr_enabled=True, hdr_paths=("b.arw",)))) + assert not metadata_lens_corrections(replace(config, rgbscan=RgbScanConfig(enabled=True, green_path="g.arw", blue_path="b.arw"))) + assert not metadata_lens_corrections(replace(config, stitch=StitchConfig(stitch_enabled=True, stitch_paths=("b.arw",)))) @pytest.mark.parametrize("kind", ["stitch", "hdr"]) @@ -296,7 +353,7 @@ def test_composite_solve_uses_unwarped_sources(monkeypatch, kind): monkeypatch.setitem(ff._GAIN_CACHE, "reference", (np.full((60, 80, 3), 1.1, np.float32), "gain-token")) config = replace( WorkspaceConfig(), - geometry=GeometryConfig(lens_from_metadata=True), + geometry=GeometryConfig(lens_distortion_from_metadata=True, lens_ca_from_metadata=True), flatfield=FlatFieldConfig(apply=True, profile_id="reference"), ) seen = [] @@ -331,7 +388,7 @@ def solve(buffers, *args, **kwargs): if kind == "stitch": expected *= 1.1 np.testing.assert_array_equal(actual, expected) - assert config.geometry.lens_from_metadata + assert config.geometry.lens_distortion_from_metadata def test_preview_and_export_share_warp_flatfield_and_per_file_coefficients(tmp_path, monkeypatch): @@ -363,7 +420,7 @@ def test_preview_and_export_share_warp_flatfield_and_per_file_coefficients(tmp_p config = WorkspaceConfig() config = replace( config, - geometry=GeometryConfig(lens_from_metadata=True), + geometry=GeometryConfig(lens_distortion_from_metadata=True, lens_ca_from_metadata=True), process=replace(config.process, linear_raw=True), flatfield=FlatFieldConfig(apply=True, profile_id="test-gain"), ) @@ -376,7 +433,7 @@ def test_preview_and_export_share_warp_flatfield_and_per_file_coefficients(tmp_p color_space="Adobe RGB", full_resolution=True, file_hash=path, - lens_from_metadata=True, + lens_corrections=LensCorrections(True, True), lens_flatfield=config.flatfield, ) exported, _, _ = processor._load_source_f32(path, config) @@ -446,8 +503,12 @@ def test_dng_17_jpegxl_fallback_keeps_preview_export_and_optical_center_in_sync( assert metadata["lens_correction"].available assert metadata["lens_correction"].buffer_area == (6, 11, 94, 149) config = WorkspaceConfig() - config = replace(config, geometry=GeometryConfig(lens_from_metadata=True), process=replace(config.process, linear_raw=True)) - preview, _, _ = PreviewManager().load_linear_preview(str(path), full_resolution=True, lens_from_metadata=True) + config = replace( + config, + geometry=GeometryConfig(lens_distortion_from_metadata=True, lens_ca_from_metadata=True), + process=replace(config.process, linear_raw=True), + ) + preview, _, _ = PreviewManager().load_linear_preview(str(path), full_resolution=True, lens_corrections=LensCorrections(True, True)) exported, _, _ = ImageProcessor()._load_source_f32(str(path), config) assert preview.shape == (88, 138, 3) np.testing.assert_array_equal(preview, exported) @@ -458,8 +519,8 @@ def test_history_or_reset_reloads_pixels_when_metadata_mode_changes(enabled): from negpy.desktop.controller import AppController state = AppState(current_file_path="scan.arw") - state.config = replace(state.config, geometry=GeometryConfig(lens_from_metadata=enabled)) - state.preview_lens_token = lens_decode_token(not enabled, state.config.flatfield) + state.config = replace(state.config, geometry=GeometryConfig(lens_distortion_from_metadata=enabled, lens_ca_from_metadata=enabled)) + state.preview_lens_token = lens_decode_token(LensCorrections(not enabled, not enabled), state.config.flatfield) controller = SimpleNamespace(state=state, _render_debounce=MagicMock(), load_file=MagicMock()) AppController.request_render(controller) controller.load_file.assert_called_once_with("scan.arw", preserve_zoom=True) @@ -476,19 +537,20 @@ def test_positive_source_and_lens_mode_have_independent_preview_cache_entries(tm path = str(tmp_path / "source.arw") ramp = np.tile(np.linspace(0.1, 0.7, 120, dtype=np.float32), (80, 1)) image = np.repeat(ramp[..., None], 3, axis=2) - lens = LensMetadata("Sony", (SonyWarp((-1000,) * 16),)) + lens = LensMetadata("Sony", (SonyWarp((-1000,) * 16, (32768,) * 16, (-16384,) * 16),)) def get_loader(file_path, *, linear_raw=False, positive_source=False): pixels = image * (0.5 if positive_source else 1.0) return NonStandardFileWrapper(pixels), {"orientation": 1, "color_space": "Adobe RGB", "lens_correction": lens} monkeypatch.setattr(factory.loader_factory, "get_loader", get_loader) - monkeypatch.setattr("negpy.services.rendering.preview_manager.APP_CONFIG.preview_cache_max_full_res_entries", 4) + monkeypatch.setattr("negpy.services.rendering.preview_manager.APP_CONFIG.preview_cache_max_full_res_entries", 8) manager = PreviewManager() processor = ImageProcessor() load = manager.load_splash_and_linear if splash else manager.load_linear_preview outputs = {} - for positive, enabled in [(False, False), (False, True), (True, False), (True, True)] * 2: + modes = [LensCorrections(d, ca) for d in (False, True) for ca in (False, True)] + for positive, corrections in [(positive, mode) for positive in (False, True) for mode in modes] * 2: result = load( path, color_space=color_space, @@ -496,22 +558,21 @@ def get_loader(file_path, *, linear_raw=False, positive_source=False): full_resolution=True, file_hash="source", positive_source=positive, - lens_from_metadata=enabled, + lens_corrections=corrections, ) preview = result[1][0] if splash else result[0] config = WorkspaceConfig() config = replace( config, - geometry=GeometryConfig(lens_from_metadata=enabled), + geometry=GeometryConfig(lens_distortion_from_metadata=corrections.distortion, lens_ca_from_metadata=corrections.ca), process=replace(config.process, linear_raw=True, positive_source=positive), ) exported, _, _ = processor._load_source_f32(path, config) np.testing.assert_allclose(preview, exported, atol=1e-6) - if (positive, enabled) in outputs: - assert preview is outputs[positive, enabled] - outputs[positive, enabled] = preview - assert not np.array_equal(outputs[False, False], outputs[False, True]) - assert not np.array_equal(outputs[False, True], outputs[True, True]) + if (positive, corrections) in outputs: + assert preview is outputs[positive, corrections] + outputs[positive, corrections] = preview + assert len({out.tobytes() for out in outputs.values()}) == 8 @pytest.mark.parametrize("mode", ["linear", "splash", "warm"]) @@ -527,7 +588,7 @@ def test_preview_worker_forwards_positive_source_and_lens_settings(mode): workspace_color_space="Adobe RGB", use_camera_wb=False, positive_source=True, - lens_from_metadata=True, + lens_corrections=LensCorrections(True, True), lens_flatfield=FlatFieldConfig(apply=True, profile_id="gain"), use_splash=mode == "splash", for_cache_warm=mode == "warm", @@ -539,7 +600,7 @@ def test_preview_worker_forwards_positive_source_and_lens_settings(mode): call = service.load_splash_and_linear if mode == "splash" else service.load_linear_preview assert call.call_count == 1 assert call.call_args.kwargs["positive_source"] is True - assert call.call_args.kwargs["lens_from_metadata"] is True + assert call.call_args.kwargs["lens_corrections"] == LensCorrections(True, True) assert call.call_args.kwargs["lens_flatfield"] == task.lens_flatfield assert not errors @@ -563,7 +624,7 @@ def has_distortion(self) -> bool: def has_ca(self) -> bool: return self.offsets[0] != self.offsets[1] or self.offsets[2] != self.offsets[1] - def remap(self, lens, shape, start, stop, channel): + def remap(self, lens, shape, start, stop, channel, corrections=LensCorrections(True, True)): calls.append((lens, shape, start, stop, channel)) y, x = np.mgrid[start:stop, : shape[1]].astype(np.float32) return x + self.offsets[channel], y From eb10249fa7194a61488517f516e36ac6692873e4 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 17 Sep 2026 10:36:22 +0200 Subject: [PATCH 6/8] Keep correction previews cached for seamless toggling --- negpy/desktop/controller.py | 33 ++++++++++++---------- negpy/desktop/render_memo.py | 33 ++++++++++++++-------- tests/test_controller.py | 48 ++++++++++++++++++++++++++++++++ tests/test_navigate_back_memo.py | 1 + tests/test_render_memo.py | 34 ++++++++++++++++++++++ 5 files changed, 122 insertions(+), 27 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 31c9ded5c..2790e1809 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -546,12 +546,12 @@ def __init__(self, session_manager: DesktopSessionManager): self._busy_toast = False self._pending_render_task: Any = None - # Last displayed render per frame, so navigate-back paints instantly while the - # authoritative render refreshes underneath. - self._render_memo = RenderMemo() + # Correction states share the bounded cache, so toggling back can paint immediately. + self._render_memo = RenderMemo(keep_variants=True) # (source_hash, memo_key, content_rect) of the on-screen GPU render; load_file # files its texture under this on the way out. self._last_render_identity: Optional[tuple] = None + self._expected_render_key = "" self._render_memo.large_entries = self.state.hq_preview # Test strips, keyed density/grade-blind (see _strip_memo_key). Four mosaics per # entry, hence the conservative budget. @@ -1864,7 +1864,8 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b # shaped it has changed, since select_file already hydrated its config. Paint it # now, with no spinner and no toasts, and let the real render refresh the metrics. target_hash = self._file_hash_for_path(file_path) - memo = self._render_memo.get(target_hash, self._render_memo_key()) if target_hash else None + self._expected_render_key = self._render_memo_key() + memo = self._render_memo.get(target_hash, self._expected_render_key) if target_hash else None if not preserve_zoom: self.zoom_requested.emit(1.0) @@ -2033,6 +2034,9 @@ def _on_preview_loaded( if self._requested_file_path != file_path: return self._foreground_preview_generation = None + decoded_lens_token = cam_matrix[3] if cam_matrix and len(cam_matrix) > 3 else "" + if decoded_lens_token != lens_decode_token(metadata_lens_corrections(self.state.config), self.state.config.flatfield): + return logger.info( "load-timing preview_e2e %.0fms (load request -> decoded buffer) %s", (time.perf_counter() - self._preview_load_t0) * 1000, @@ -2045,7 +2049,7 @@ def _on_preview_loaded( self.state.preview_cam_xyz, self.state.preview_camera_wb = cam_matrix[:2] if cam_matrix else (None, None) self.state.preview_lens = cam_matrix[2] if cam_matrix and len(cam_matrix) > 2 else None self.state.preview_lens_path = file_path - self.state.preview_lens_token = cam_matrix[3] if cam_matrix and len(cam_matrix) > 3 else "" + self.state.preview_lens_token = decoded_lens_token self.state.preview_proxy = _interactive_proxy(raw) self.state.preview_ir = ir_preview self.state.preview_ir_proxy = _interactive_ir_proxy(ir_preview, self.state.preview_proxy) @@ -4544,6 +4548,7 @@ def request_render( memo_key = "" if config_override is None and not ephemeral and not crop_preview_full and not interactive: memo_key = self._render_memo_key() + self._expected_render_key = memo_key dip = self.active_diptych() cam_xyz, camera_wb = self._effective_cam_xyz() @@ -5667,16 +5672,12 @@ def _clear_busy_toast(self) -> None: self.set_status("") def _renders_another_frame(self, metrics: Dict[str, Any]) -> bool: - """True when a render belongs to a frame that is no longer selected. - - A render carries the hash it was dispatched for, and nothing cancels one that is - already in flight — click the next frame mid-render and it still lands. Its pixels - and its measurements describe the frame the user has left, so they must not reach - the canvas or ``last_metrics``. A task dispatched before the file had a hash - carries the same ``"preview"`` placeholder ``request_render`` gives it. - """ + """Reject pixels and measurements for a different file or superseded edit.""" src = metrics.get("source_hash") - return src is not None and src != (self.state.current_file_hash or "preview") + key = metrics.get("memo_key") + return (src is not None and src != (self.state.current_file_hash or "preview")) or bool( + key and self._expected_render_key and key != self._expected_render_key + ) def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: self._is_rendering = False @@ -5857,7 +5858,9 @@ def _on_metrics_updated(self, metrics: Dict[str, Any]) -> None: # Move the frame's memo entry to the updated config's key so the first # navigate-back after an initial render still hits. A GPU render is not filed # until navigate-away, so its identity follows too. - self._render_memo.rekey(src or self.state.current_file_hash or "", self._render_memo_key()) + self._render_memo.rekey( + src or self.state.current_file_hash or "", self._render_memo_key(), old_key=metrics.get("memo_key", "") + ) if self._last_render_identity is not None: self._last_render_identity = ( self._last_render_identity[0], diff --git a/negpy/desktop/render_memo.py b/negpy/desktop/render_memo.py index 678141af7..9d6dd757d 100644 --- a/negpy/desktop/render_memo.py +++ b/negpy/desktop/render_memo.py @@ -22,14 +22,18 @@ class RenderMemo: - """LRU of the last render per file: file_hash -> (memo_key, payload).""" + """Bounded render LRU, optionally retaining multiple settings per file.""" - def __init__(self, app_config: Any = None) -> None: + def __init__(self, app_config: Any = None, *, keep_variants: bool = False) -> None: self._app = app_config or APP_CONFIG - self._entries: "OrderedDict[str, tuple[str, dict]]" = OrderedDict() + self._keep_variants = keep_variants + self._entries: "OrderedDict[tuple[str, str], tuple[str, dict]]" = OrderedDict() # Hundreds of MB an entry (HQ renders, strip mosaics): use the full-res knob. self.large_entries = False + def _entry_key(self, file_hash: str, memo_key: str) -> tuple[str, str]: + return file_hash, memo_key if self._keep_variants else "" + def _budget(self) -> int: if self.large_entries: return max(2, int(getattr(self._app, "preview_cache_max_full_res_entries", 2))) @@ -50,28 +54,33 @@ def _dispose(self, entry: "Optional[tuple[str, dict]]", keep: "Optional[dict]" = def store(self, file_hash: str, memo_key: str, payload: dict) -> None: if not file_hash or not memo_key: return - self._dispose(self._entries.pop(file_hash, None), keep=payload) - self._entries[file_hash] = (memo_key, payload) + key = self._entry_key(file_hash, memo_key) + self._dispose(self._entries.pop(key, None), keep=payload) + self._entries[key] = (memo_key, payload) while len(self._entries) > self._budget(): self._dispose(self._entries.popitem(last=False)[1], keep=payload) def get(self, file_hash: str, memo_key: str) -> Optional[dict]: - entry = self._entries.get(file_hash) + key = self._entry_key(file_hash, memo_key) + entry = self._entries.get(key) if entry is None or entry[0] != memo_key: return None - self._entries.move_to_end(file_hash) + self._entries.move_to_end(key) return entry[1] - def rekey(self, file_hash: str, new_key: str) -> None: + def rekey(self, file_hash: str, new_key: str, *, old_key: str = "") -> None: """Follow a render-neutral config change (e.g. measured bounds persisted after the render, with render=False): the stored pixels are still valid, only their identity moved.""" - entry = self._entries.get(file_hash) - if entry is not None and new_key: - self._entries[file_hash] = (new_key, entry[1]) + if not new_key or (self._keep_variants and not old_key): + return + entry = self._entries.pop(self._entry_key(file_hash, old_key), None) + if entry is not None: + self.store(file_hash, new_key, entry[1]) def invalidate(self, file_hash: str) -> None: - self._dispose(self._entries.pop(file_hash, None)) + for key in [key for key in self._entries if key[0] == file_hash]: + self._dispose(self._entries.pop(key)) def clear(self) -> None: for entry in self._entries.values(): diff --git a/tests/test_controller.py b/tests/test_controller.py index 271fb0938..bd06892d3 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -357,6 +357,41 @@ def test_cpu_reload_keeps_preview_but_navigation_shows_loading(self): loading.assert_called_once() self.assertTrue(decode.call_args.args[0].use_splash) + def test_lens_toggle_repaints_live_cached_texture_before_decode(self): + from negpy.infrastructure.gpu.resources import GPUTexture + + self.controller.preview_load_requested.disconnect(self.controller.preview_load_worker.process) + state = self.controller.state + state.current_file_path = "scan.arw" + state.current_file_hash = "scan" + state.uploaded_files = [{"path": "scan.arw", "hash": "scan"}] + self.controller._requested_file_path = state.current_file_path + cached, outgoing = MagicMock(spec=GPUTexture), MagicMock(spec=GPUTexture) + self.controller._render_memo.store("scan", "off", {"base_positive": cached}) + state.last_metrics["base_positive"] = outgoing + self.controller._last_render_identity = ("scan", "on", None) + events = [] + self.controller.image_updated.connect(lambda: events.append(("paint", state.last_metrics["base_positive"]))) + self.controller.preview_load_requested.connect(lambda task: events.append(("decode", task.file_path))) + + with patch.object(self.controller, "_render_memo_key", return_value="off"): + self.controller.load_file(state.current_file_path, preserve_zoom=True) + + self.assertEqual(events, [("paint", cached), ("decode", "scan.arw")]) + cached.destroy.assert_not_called() + outgoing.destroy.assert_not_called() + self.assertIs(self.controller._render_memo.get("scan", "on")["base_positive"], outgoing) + + stale_metrics = {"source_hash": "scan", "memo_key": "on", "base_positive": outgoing} + metrics_available = MagicMock() + self.controller.metrics_available.connect(metrics_available) + self.controller._on_render_finished(outgoing, stale_metrics) + self.controller._on_metrics_updated(stale_metrics) + + self.assertEqual(events, [("paint", cached), ("decode", "scan.arw")]) + self.assertIs(state.last_metrics["base_positive"], cached) + metrics_available.assert_not_called() + def test_preview_load_defers_neighbor_prefetch_until_render_finishes(self): self.controller._requested_file_path = "/tmp/a.dng" self.controller.request_render = MagicMock() @@ -1022,6 +1057,19 @@ def test_stale_preview_decode_is_dropped(self): self.assertIsNone(self.controller.state.preview_raw) self.controller.request_render.assert_not_called() + def test_stale_lens_correction_decode_is_dropped(self): + self.controller.request_render = MagicMock() + self.controller._requested_file_path = "current.arw" + state = self.controller.state + state.config = replace(state.config, geometry=replace(state.config.geometry, lens_ca_from_metadata=True)) + current_raw = object() + state.preview_raw = current_raw + + self.controller._on_preview_loaded("current.arw", object(), (10, 20), "", None, "", (None, None, None, "")) + + self.assertIs(state.preview_raw, current_raw) + self.controller.request_render.assert_not_called() + def test_apply_auto_crop_enables_auto_crop_and_clears_manual_rect(self): geometry = replace(self.controller.state.config.geometry, crop_rect=(0.1, 0.1, 0.9, 0.9), crop_from_auto=False) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) diff --git a/tests/test_navigate_back_memo.py b/tests/test_navigate_back_memo.py index 10829622c..9e1c5e55d 100644 --- a/tests/test_navigate_back_memo.py +++ b/tests/test_navigate_back_memo.py @@ -36,6 +36,7 @@ def _stub(memo, **overrides): _thumb_config=object(), _render_memo=memo, _last_render_identity=None, + _expected_render_key="", _spared_texture=None, _gpu_fallback_notified=True, _freeze_resolved_auto_crop=MagicMock(), diff --git a/tests/test_render_memo.py b/tests/test_render_memo.py index 20ded5f43..b5ff50b30 100644 --- a/tests/test_render_memo.py +++ b/tests/test_render_memo.py @@ -158,3 +158,37 @@ def test_array_payloads_are_left_alone() -> None: m.store("A", "k", _payload()) m.store("A", "k2", _payload()) m.clear() # must not raise: ndarray has no destroy() + + +def test_variants_share_the_budget_and_keep_the_selected_texture_alive() -> None: + m = RenderMemo(_cfg(slots=2), keep_variants=True) + selected, evicted, outgoing = (_FakeTexture() for _ in range(3)) + m.store("A", "off", _gpu_payload(selected)) + m.store("A", "ca", _gpu_payload(evicted)) + assert m.get("A", "off")["base_positive"] is selected + m.store("A", "distortion", _gpu_payload(outgoing)) + assert m.get("A", "off")["base_positive"] is selected + assert m.get("A", "distortion")["base_positive"] is outgoing + assert m.get("A", "ca") is None + assert selected.destroyed == outgoing.destroyed == 0 + assert evicted.destroyed == 1 + m.invalidate("A") + assert selected.destroyed == outgoing.destroyed == 1 + + +def test_variant_rekey_only_moves_the_measured_state() -> None: + m = RenderMemo(_cfg(slots=4), keep_variants=True) + off, on = _FakeTexture(), _FakeTexture() + m.store("A", "off", _gpu_payload(off)) + m.store("A", "on", _gpu_payload(on)) + m.rekey("A", "settled-on", old_key="on") + assert m.get("A", "on") is None + assert m.get("A", "off")["base_positive"] is off + assert m.get("A", "settled-on")["base_positive"] is on + m.rekey("A", "missing-render", old_key="missing") + m.rekey("A", "unspecified-render") + assert m.get("A", "missing-render") is None + assert m.get("A", "unspecified-render") is None + assert off.destroyed == on.destroyed == 0 + m.clear() + assert off.destroyed == on.destroyed == 1 From a2873dafc264020832856b148d75cead44556675 Mon Sep 17 00:00:00 2001 From: Henrik Nilsson Date: Thu, 17 Sep 2026 11:12:28 +0200 Subject: [PATCH 7/8] docs(lens): simplify correction guidance and clarify reader scope --- docs/PIPELINE.md | 1 - docs/USER_GUIDE.md | 4 ++-- negpy/infrastructure/loaders/lens_metadata.py | 5 ++++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 6df04c15d..674684ab0 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -50,7 +50,6 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Physical model**: we treat the input as a **radiometric measurement**. Pixel values are linear transmittance captured by the sensor. * **Source corrections** (linear domain, before the log conversion): * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff using a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The reference is decoded and the gain **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so the render never touches the original reference file. Moving or deleting it is harmless. The per-image edit stores only the profile id. The render path resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py` at startup) and caches it, and the profile id plus a content token of the gain are folded into the render's source hash. - * **Embedded lens correction** (`features/lens`): applies selected distortion and lateral CA as cubic RGB inverse warps after flat-field, before sensor unmix and geometry. CA-only keeps green-channel geometry and extends relative edge correction beyond the calibrated radius. Preview and export share source coefficients, orientation and crop offsets. Edges replicate; values above 1 remain available. Composite and RGB+IR sources skip correction. * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, the camera's CFA passbands overlap the light source's bands, so a pure R/G/B exposure leaks into the other channels. That is a fixed property of the sensor and light pair, independent of film. It is calibrated once from three bare-light exposures, with response columns normalized to a unit diagonal so per-capture exposure cancels, then inverted. It is applied as a 3×3 unmix of the **linear** capture, ahead of the log and inversion where the film-dye crosstalk below lives. It is independent of film but *not* of the light: it is meaningful only for a capture made under narrowband light, so `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband, see below) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The related **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fd69ff04d..df63a7c8a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -511,8 +511,8 @@ Where the frame gets its final shape: what is inside the print, and whether it s Both replicate a wedge along the squeezed edge, as Fine Rotation does; crop it off. Crop before correcting if you can, because the meters read the corrected frame: on an uncropped scan a big correction pulls rebate and surround into the metered area and the print darkens. * **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight-edge reference. Corrected before Tilt and Swing. -* **Metadata Distortion**: correct scanning-lens distortion from Sony ARW or DNG WarpRectilinear coefficients; replaces manual distortion. Available when the file supplies distortion data. Choose before cropping or retouching; crop off replicated edges if needed. Composite and RGB+IR sources are unsupported. -* **Metadata CA**: correct lateral chromatic aberration from embedded coefficients. Independent of Metadata Distortion; can be used with manual distortion. Available when the file supplies CA data. Both settings are saved with the edit; each file supplies its own coefficients. +* **Metadata Distortion**: straighten curved lines with lens correction data stored in the file. Replaces manual distortion correction. Set before cropping or retouching. Available when the file contains supported distortion data. +* **Metadata CA**: reduce color fringes along edges with lens correction data stored in the file. Works independently of Metadata Distortion and can be used with manual distortion correction. Available when the file contains supported CA data. ### 5.2 Flat Field: even out the light diff --git a/negpy/infrastructure/loaders/lens_metadata.py b/negpy/infrastructure/loaders/lens_metadata.py index 2e9822b78..68535f3f5 100644 --- a/negpy/infrastructure/loaders/lens_metadata.py +++ b/negpy/infrastructure/loaders/lens_metadata.py @@ -1,4 +1,7 @@ -"""Bounded readers for Sony ARW coefficients and DNG OpcodeList3.""" +"""Read embedded lens metadata through format-specific readers. + +Supported formats: Sony ARW and DNG WarpRectilinear. +""" import os import struct From c0b2df0bc04ff64084185c5ac68554e29e70973d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 19 Sep 2026 20:20:09 +0200 Subject: [PATCH 8/8] fix(lens): correct ActiveArea crop and thread lens corrections through prefetch ActiveArea cropping in the linear-DNG RGB peek referenced an undefined `data` instead of `arr`. prefetch_linear_preview() never accepted lens_corrections/ lens_flatfield, so the warmed neighbor cache entry ignored the active lens correction mode. Test stubs updated to match current preview_max_edge and lens_corrections signatures. --- negpy/infrastructure/loaders/rawpy_loader.py | 4 ++-- negpy/services/rendering/preview_manager.py | 5 +++++ tests/test_metadata_lens.py | 10 ++++++++-- tests/test_thumbnail_refresh_worker.py | 3 +++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/negpy/infrastructure/loaders/rawpy_loader.py b/negpy/infrastructure/loaders/rawpy_loader.py index cc683b6b3..ea3dba447 100644 --- a/negpy/infrastructure/loaders/rawpy_loader.py +++ b/negpy/infrastructure/loaders/rawpy_loader.py @@ -345,8 +345,8 @@ def tag(name: str) -> Optional[Any]: if len(active_area) == 4: top, left, bottom, right = (int(v) for v in active_area) - if 0 <= top < bottom <= data.shape[0] and 0 <= left < right <= data.shape[1]: - data = data[top:bottom, left:right] + if 0 <= top < bottom <= arr.shape[0] and 0 <= left < right <= arr.shape[1]: + arr = arr[top:bottom, left:right] if len(crop_origin) >= 2 and len(crop_size) >= 2: ox, oy = int(round(crop_origin[0])), int(round(crop_origin[1])) diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 0480662e1..e8c70fa70 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -105,6 +105,8 @@ def prefetch_linear_preview( should_cancel: Optional[Callable[[], bool]] = None, highlight_mode: int = 0, bake_camera_wb: bool = False, + lens_corrections: LensCorrections = LensCorrections(), + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> bool: """Warm one preview when its cache and system-memory budgets both admit it.""" if not file_hash: @@ -115,6 +117,7 @@ def prefetch_linear_preview( workspace_color_space=color_space, full_resolution=False, demosaic=demosaic, + lens_token=lens_decode_token(lens_corrections, lens_flatfield), half=half_slice[0] if half_slice else 0, split_x=half_slice[1] if half_slice else 0.5, crop_rect=half_slice[2] if half_slice else None, @@ -170,6 +173,8 @@ def prefetch_linear_preview( should_cancel=should_cancel, highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, + lens_corrections=lens_corrections, + lens_flatfield=lens_flatfield, ) return True diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index 7b1a215b8..79efd9f3b 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -539,7 +539,7 @@ def test_positive_source_and_lens_mode_have_independent_preview_cache_entries(tm image = np.repeat(ramp[..., None], 3, axis=2) lens = LensMetadata("Sony", (SonyWarp((-1000,) * 16, (32768,) * 16, (-16384,) * 16),)) - def get_loader(file_path, *, linear_raw=False, positive_source=False): + def get_loader(file_path, *, linear_raw=False, positive_source=False, preview_max_edge=None, should_cancel=None): pixels = image * (0.5 if positive_source else 1.0) return NonStandardFileWrapper(pixels), {"orientation": 1, "color_space": "Adobe RGB", "lens_correction": lens} @@ -583,6 +583,7 @@ def test_preview_worker_forwards_positive_source_and_lens_settings(mode): result = (np.full((8, 12, 3), 0.5, np.float32), (8, 12), {}) service.load_linear_preview.return_value = result service.load_splash_and_linear.return_value = (None, result) + service.prefetch_linear_preview.return_value = True task = PreviewLoadTask( file_path="source.arw", workspace_color_space="Adobe RGB", @@ -597,7 +598,12 @@ def test_preview_worker_forwards_positive_source_and_lens_settings(mode): errors = [] worker.error.connect(errors.append) worker.process(task) - call = service.load_splash_and_linear if mode == "splash" else service.load_linear_preview + if mode == "splash": + call = service.load_splash_and_linear + elif mode == "warm": + call = service.prefetch_linear_preview + else: + call = service.load_linear_preview assert call.call_count == 1 assert call.call_args.kwargs["positive_source"] is True assert call.call_args.kwargs["lens_corrections"] == LensCorrections(True, True) diff --git a/tests/test_thumbnail_refresh_worker.py b/tests/test_thumbnail_refresh_worker.py index 69705d43a..cc5a4eb67 100644 --- a/tests/test_thumbnail_refresh_worker.py +++ b/tests/test_thumbnail_refresh_worker.py @@ -9,6 +9,7 @@ ThumbnailRenderWorker, ) from negpy.domain.models import WorkspaceConfig +from negpy.features.lens.models import LensCorrections from negpy.features.rgbscan.models import RgbScanConfig from negpy.features.stitch.models import StitchConfig @@ -39,6 +40,8 @@ def load_linear_preview( positive_source=False, # noqa: ARG002 — accepted, not asserted on highlight_mode=0, # noqa: ARG002 — accepted, not asserted on bake_camera_wb=False, # noqa: ARG002 — accepted, not asserted on + lens_corrections=LensCorrections(), # noqa: ARG002 — accepted, not asserted on + lens_flatfield=None, # noqa: ARG002 — accepted, not asserted on ): self.linear_calls.append({"file_path": file_path, "file_hash": file_hash}) raw = np.full((4, 6, 3), 0.5, dtype=np.float32)