diff --git a/CLAUDE.md b/CLAUDE.md index f3910691e..6a4803cda 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 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. @@ -63,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/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 90855e6c7..df63a7c8a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -511,6 +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**: 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/desktop/controller.py b/negpy/desktop/controller.py index 47dd89951..2790e1809 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_corrections from negpy.services.view.coordinate_mapping import CoordinateMapping logger = get_logger(__name__) @@ -236,6 +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_distortion_from_metadata), + bool(geometry.lens_ca_from_metadata), bool(flatfield.apply), str(flatfield.profile_id), bool(config.process.linear_raw), @@ -543,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. @@ -1847,6 +1850,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 @@ -1860,11 +1864,12 @@ 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) - if memo is None: + if memo is None and not keep_preview: self.loading_started.emit() self._thumb_config = None @@ -1953,9 +1958,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 @@ -1969,6 +1973,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_corrections=metadata_lens_corrections(self.state.config), + lens_flatfield=self.state.config.flatfield, ) ) @@ -2028,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, @@ -2037,7 +2046,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 = 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) @@ -2118,6 +2130,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_corrections=metadata_lens_corrections(saved or self.state.config), + lens_flatfield=(saved or self.state.config).flatfield, ) def _start_next_neighbor_prefetch(self) -> None: @@ -4480,6 +4494,10 @@ def request_render( before/after split instead of being displayed. """ self._render_debounce.stop() + 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 # Any direct render exits the flat preview-peek. if config_override is None and self.state.flat_peek: @@ -4530,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() @@ -5653,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 @@ -5843,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/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..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("Distortion Correction", "geometry", "distortion_k1", 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 1c3d3cde9..c9817acd4 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -189,6 +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_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 0cec67172..7b88f89c5 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -49,6 +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_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 e77db6dd2..18118c573 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -394,6 +394,18 @@ def apply_shortcut_tooltips(self) -> None: ret = self.retouch_sidebar ton = self.toning_sidebar fin = self.finish_sidebar + geo.metadata_distortion_btn.setToolTip( + tooltip_with_shortcut( + "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", + ) + ) col.pick_wb_btn.setToolTip( tooltip_with_shortcut( @@ -881,6 +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_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 0a055a8de..41f2bbe94 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_corrections class GeometrySidebar(BaseSidebar): @@ -178,6 +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_distortion_btn = self._labeled_toggle( + "fa5s.camera", + "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_distortion_btn) + self.layout.addWidget(self.metadata_ca_btn) + self.layout.addWidget(self.lens_hint) + + 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, **{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_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.") + 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()) @@ -187,6 +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_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) @@ -295,6 +343,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 +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_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 e0b335155..b950cdf19 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_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 4ce559a13..a24f39d33 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -11,6 +11,9 @@ 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.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 @@ -232,6 +235,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_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 = ( None # (half, split_x, crop_rect, gutter_thickness) @@ -1067,6 +1072,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_corrections=task.lens_corrections, + lens_flatfield=task.lens_flatfield, ) except InterruptedError: pass @@ -1120,7 +1127,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_corrections, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1159,7 +1171,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_corrections, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1196,7 +1213,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_corrections, task.lens_flatfield), + ), metadata.get("detect_preview"), ) return @@ -1215,6 +1237,8 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, + lens_corrections=task.lens_corrections, + lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): return @@ -1235,6 +1259,8 @@ def cancelled() -> bool: should_cancel=cancelled, highlight_mode=task.highlight_mode, bake_camera_wb=task.bake_camera_wb, + lens_corrections=task.lens_corrections, + lens_flatfield=task.lens_flatfield, ) if not self._is_current(task): return @@ -1256,7 +1282,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_corrections, task.lens_flatfield), + ), metadata.get("detect_preview"), ) except InterruptedError: @@ -1361,6 +1392,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_corrections=metadata_lens_corrections(config), + lens_flatfield=config.flatfield, **common, ) return slice_for_asset(raw, file_info), meta @@ -1433,7 +1466,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_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 87e8c2e36..95efaafac 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,12 @@ 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_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") transforms, canvas = register_parts(parts, is_cancelled=self._cancel.is_set) 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 9b66aba45..e373b20c3 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -102,6 +102,8 @@ class AutocropMode(StrEnum): @dataclass(frozen=True) class GeometryConfig: + lens_distortion_from_metadata: bool = False + lens_ca_from_metadata: bool = False rotation: int = 0 fine_rotation: float = 0.0 flip_horizontal: bool = False @@ -138,6 +140,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_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)) 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..8b48817a9 --- /dev/null +++ b/negpy/features/lens/logic.py @@ -0,0 +1,35 @@ +"""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 LensCorrections, LensMetadata +from negpy.kernel.image.logic import apply_exif_orientation + + +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, 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) + 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..fa127f6ea --- /dev/null +++ b/negpy/features/lens/models.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +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: ... + + @property + def has_ca(self) -> bool: ... + + 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).""" + ... + + +@dataclass(frozen=True) +class LensMetadata: + source: str = "" + 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 + buffer_area: tuple[int, int, int, int] | None = None + + @property + def distortion(self) -> bool: + return any(warp.has_distortion for warp in self.warps) + + @property + def ca(self) -> bool: + return any(warp.has_ca for warp 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/features/lens/warps.py b/negpy/features/lens/warps.py new file mode 100644 index 000000000..a335272e7 --- /dev/null +++ b/negpy/features/lens/warps.py @@ -0,0 +1,127 @@ +"""Inverse lens maps in unrotated sensor coordinates.""" + +from dataclasses import dataclass + +import cv2 +import numpy as np + +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) + 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, + 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) + 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) + + +@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, + 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) or len(self.ca_blue) + knots = (np.arange(n) + 0.5) / (n - 1) + factors = np.ones(n) + 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 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/infrastructure/loaders/lens_metadata.py b/negpy/infrastructure/loaders/lens_metadata.py new file mode 100644 index 000000000..68535f3f5 --- /dev/null +++ b/negpy/infrastructure/loaders/lens_metadata.py @@ -0,0 +1,187 @@ +"""Read embedded lens metadata through format-specific readers. + +Supported formats: Sony ARW and DNG WarpRectilinear. +""" + +import os +import struct +from collections.abc import Callable +from dataclasses import replace +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 LensMetadata +from negpy.features.lens.warps import IDENTITY, 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_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 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, 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, reader: Callable[[str], LensMetadata]) -> LensMetadata: + try: + return reader(file_path) + 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..ea3dba447 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 <= 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])) 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..778ad0aa6 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_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, @@ -650,6 +651,7 @@ def run_pipeline( source_hash, img.shape, skip_flatfield, + metadata_lens_corrections(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_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 @@ -679,6 +681,7 @@ def run_pipeline( base_hash = ( source_hash + flatfield_token(settings.flatfield) + + lens_decode_token(metadata_lens_corrections(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_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), @@ -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_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: 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_corrections(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_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 new file mode 100644 index 000000000..731efa237 --- /dev/null +++ b/negpy/services/rendering/lens.py @@ -0,0 +1,38 @@ +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 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 + +if TYPE_CHECKING: + from negpy.domain.models import WorkspaceConfig + + +def metadata_lens_corrections(config: "WorkspaceConfig") -> LensCorrections: + """Composite registrations refer to the unwarped component images.""" + 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(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, + 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), corrections) + 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..e8c70fa70 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -27,6 +27,8 @@ 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 from negpy.features.hdr.models import HdrConfig, hdr_merge_token @@ -103,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: @@ -113,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, @@ -168,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 @@ -229,6 +236,8 @@ def _load_from_open_raw( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, + lens_corrections: LensCorrections = LensCorrections(), + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ Decode and resize a linear preview from an already-open raw object. @@ -339,6 +348,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_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") @@ -446,6 +457,7 @@ def _load_from_open_raw( workspace_color_space=color_space, full_resolution=full_resolution, 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, @@ -505,6 +517,8 @@ def load_linear_preview( highlight_mode: int = 0, bake_camera_wb: bool = False, wb_override: Optional[Sequence[float]] = None, + lens_corrections: LensCorrections = LensCorrections(), + lens_flatfield: FlatFieldConfig = FlatFieldConfig(), ) -> Tuple[ImageBuffer, Dimensions, dict]: """ Loads linear RGB, downsamples for display. @@ -528,6 +542,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, 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, @@ -559,6 +574,7 @@ def load_linear_preview( workspace_color_space=color_space, full_resolution=full_resolution, 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, @@ -592,6 +608,8 @@ def load_linear_preview( highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, wb_override=wb_override, + lens_corrections=lens_corrections, + lens_flatfield=lens_flatfield, ) log( "load-timing load_linear_preview %.0fms (decode %.0fms + open)", @@ -899,6 +917,8 @@ def load_splash_and_linear( should_cancel: Optional[Callable[[], bool]] = None, highlight_mode: int = 0, bake_camera_wb: bool = False, + lens_corrections: LensCorrections = LensCorrections(), + 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 +940,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, 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, @@ -955,6 +976,7 @@ def load_splash_and_linear( workspace_color_space=color_space, full_resolution=full_resolution, 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, @@ -972,7 +994,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_corrections: 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 +1011,8 @@ def load_splash_and_linear( should_cancel=should_cancel, highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, + lens_corrections=lens_corrections, + 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..5bf83e3e7 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_corrections 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_corrections(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..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,6 +32,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( { @@ -203,31 +206,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..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,6 +30,8 @@ def load_linear_preview( positive_source=False, highlight_mode=0, bake_camera_wb=False, + lens_corrections=LensCorrections(), + 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_controller.py b/tests/test_controller.py index bb9cebca5..bd06892d3 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -297,6 +297,101 @@ 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.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) + 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) + + 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_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() + 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_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() @@ -962,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_metadata_lens.py b/tests/test_metadata_lens.py new file mode 100644 index 000000000..79efd9f3b --- /dev/null +++ b/tests/test_metadata_lens.py @@ -0,0 +1,684 @@ +import struct +from dataclasses import dataclass, replace +from pathlib import Path +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 apply_lens +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_corrections, 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("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): + 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 = 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)) + 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 = 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) + + +@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) + + +@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)) + + +@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_distortion_from_metadata=distortion, lens_ca_from_metadata=ca, distortion_k1=0.05)) + restored = WorkspaceConfig.from_flat_dict(enabled.to_dict()) + 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))) == (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(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): + 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_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 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_ca_btn.click() + requested = controller.apply_config.call_args.args[0] + 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_ca_btn.isEnabled() + assert sidebar.metadata_ca_btn.isChecked() + assert "Unavailable" in sidebar.lens_hint.text() + 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(): + 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_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"]) +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_distortion_from_metadata=True, lens_ca_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_distortion_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 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_distortion_from_metadata=True, lens_ca_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_corrections=LensCorrections(True, 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_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) + + +@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_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) + + +@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, (32768,) * 16, (-16384,) * 16),)) + + 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} + + monkeypatch.setattr(factory.loader_factory, "get_loader", get_loader) + 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 = {} + 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, + use_camera_wb=False, + full_resolution=True, + file_hash="source", + positive_source=positive, + lens_corrections=corrections, + ) + preview = result[1][0] if splash else result[0] + config = WorkspaceConfig() + config = replace( + config, + 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, 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"]) +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) + service.prefetch_linear_preview.return_value = True + task = PreviewLoadTask( + file_path="source.arw", + workspace_color_space="Adobe RGB", + use_camera_wb=False, + positive_source=True, + lens_corrections=LensCorrections(True, 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) + 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) + 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, 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 + + 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 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 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)