From 427ac4478ac022395f6e6bf6532c52c6aa757b84 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:39:38 +0200 Subject: [PATCH 1/2] fix(flatfield): store the gain in sensor layout and turn it by each frame's EXIF orientation A profile was baked from its reference after the decode had applied the reference's EXIF orientation, and applied to each frame after the frame's own orientation, with cv2.resize stretching the gain to fit. A camera on a copy stand tags frames close to randomly (one Canon session here wrote tag 1 on 7 frames and tag 8 on 6), so a frame tagged differently from its flat got the gain upside down or squashed across the turn. On a synthetic rig that leaves a 103-104% spread on tag 6/8 frames, worse than the 73% uncorrected. The bake now undoes the reference's orientation, and every apply site takes the decode's own tag first: preview and test strip renders, rendered thumbnails, export, Linear Output, Roll Analysis, Auto-crop all frames, Auto Skew, stitch parts and the lens path. The tag reaches the canvas render with the decode metadata, beside the camera matrix. `orientation` is a required argument of apply_flatfield, so a new call site cannot leave it out. A startup migration moves existing profiles to sensor layout. It works out the tag the bake applied, not the one read today: a JXL decode applies none, and a tag only LibRaw reads (CR3, RAF, read since fb1d733a) is trusted only when the gain's aspect shows the bake made that quarter turn. A profile whose reference cannot be read, or whose bake tag cannot be told, is left unmarked, applies as baked, and is tried again on the next launch. Check Flat Field takes the open frame's tag. THUMBNAIL_RENDER_VERSION is bumped: frames with a Flat Field and a tag different from their reference render differently. --- docs/PIPELINE.md | 2 +- negpy/desktop/controller.py | 15 +- negpy/desktop/main.py | 2 + negpy/desktop/session.py | 2 + negpy/desktop/workers/render.py | 43 ++-- negpy/features/flatfield/logic.py | 47 +++- negpy/services/assets/flatfield.py | 25 +- .../assets/migrations/flatfield_layout.py | 97 +++++++ .../services/assets/thumbnail_fingerprint.py | 2 +- negpy/services/export/linear_output.py | 8 +- negpy/services/rendering/image_processor.py | 13 +- negpy/services/rendering/lens.py | 2 +- negpy/services/rendering/preview_manager.py | 2 +- tests/test_autocrop_square.py | 4 +- tests/test_batch_autocrop_worker.py | 6 +- tests/test_controller.py | 24 ++ tests/test_flatfield_layout_migration.py | 237 ++++++++++++++++++ tests/test_flatfield_logic.py | 95 ++++++- tests/test_flatfield_profiles.py | 28 ++- tests/test_image_processor.py | 2 +- tests/test_linear_output.py | 5 +- tests/test_metadata_lens.py | 2 +- tests/test_preview_load_worker_detect_mode.py | 14 ++ tests/thumbnail_fingerprint_defaults.json | 8 +- 24 files changed, 609 insertions(+), 76 deletions(-) create mode 100644 negpy/services/assets/migrations/flatfield_layout.py create mode 100644 tests/test_flatfield_layout_migration.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index e396c8cb9..9123624e8 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -39,7 +39,7 @@ These steps run in order. Each stage passes its buffer to the next. * **Physical model**: the input is a **radiometric measurement**: pixel values are linear transmittance. * **Source corrections** (linear domain, before the log conversion): - * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The Gaussian blur has $\sigma$ = 1/64 of the long side: small enough to follow the light source's own pattern (LED hot spots behind a diffuser), which repeats from shot to shot. A profile stores its baked gain, so a profile made with an earlier blur keeps that gain until a new profile is made from its reference. The blur and the mean cover only the lit area: pixels below 0.2 of the 95th-percentile luminance (a carrier edge in the reference) are masked out of a normalized convolution, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) and its self-check. Check Flat Field is a canvas view, not a stage: that copy times the gain, each channel over its lit-area median, ±10% to black..white; a profile without the copy shows 1/gain over ±15%. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so moving or deleting the reference file is harmless. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash. + * **Flat-field** (`negpy.features.flatfield`): divides out illumination falloff with a blank reference frame. A per-channel gain map $\text{mean}(\text{blur})/\text{blur}$, computed on a 256 px copy and clamped to $[0.25, 4]$, multiplies the linear source. The gain is stored in **sensor layout**: the bake undoes the reference's EXIF orientation, and each frame's decode orientation turns the gain before it is applied, so a camera on a copy stand that tags frames differently from its flat still gets the gain where the light fell. The Gaussian blur has $\sigma$ = 1/64 of the long side: small enough to follow the light source's own pattern (LED hot spots behind a diffuser), which repeats from shot to shot. A profile stores its baked gain, so a profile made with an earlier blur keeps that gain until a new profile is made from its reference. The blur and the mean cover only the lit area: pixels below 0.2 of the 95th-percentile luminance (a carrier edge in the reference) are masked out of a normalized convolution, so a dark border does not overcorrect the frame next to it. Pixels too far from the lit area take the nearest valid value. A profile also stores a 512 px copy of the reference (float16) and its self-check. Check Flat Field is a canvas view, not a stage: that copy times the gain, each channel over its lit-area median, ±10% to black..white; a profile without the copy shows 1/gain over ±15%. The gain is **baked once** into a profile (an `.npz` in `APP_CONFIG.flatfield_dir`, keyed by an opaque id), so moving or deleting the reference file is harmless. The edit stores only the profile id. The render resolves the gain through a provider (`set_gain_provider`, wired to `services/assets/flatfield.py`) and caches it; the profile id and a gain content token fold into the source hash. * **Embedded lens correction** (`negpy.features.lens`): the scanning camera's own distortion and lateral CA profile (DNG `WarpRectilinear`, Sony ARW, and a radial distortion polynomial for Panasonic RW2), read from the source file and applied at decode after flat-field and before the unmix, so neither engine needs a shader. The RW2 model follows darktable's embedded-metadata reader and carries no lateral CA. It is skipped for composites and RGB+IR sources. A correction that reads past the frame edge is scaled about its center to fill (`fill_scale` ≤ 1); a CA-only correction is not scaled. * **Sensor crosstalk unmix** (`sensor_matrix`, `features/process/sensor.py`): for single-shot narrowband camera scans, CFA passbands overlap the light's bands, so each channel leaks into the others. It is a property of the sensor and light, not the film. It is calibrated from three bare-light exposures (columns normalized to a unit diagonal), inverted, and applied as a 3×3 unmix of the **linear** capture, ahead of the log. Where the film passes almost none of a band's light, the subtraction leaves a value smaller than its grain and calibration error, and the log turns it into maximum density. `sensor_unmix` picks the handling (default `two_scale`). `linear` clips at zero. `density` applies $C = \mathrm{diag}(1/(M b))\,M\,\mathrm{diag}(b)$, the unmix linearized at the film base $b$ (rows sum to 1), to $\ln x - \ln b$; each channel is held at $10^{-3} b_c$ or above, so it never reaches zero. `two_scale` is the clipped linear unmix where its own-channel gain $M_{cc}\bar x_c / (M\bar x)_c$ stays below 1.15 $C_{cc}$, and blends by a smoothstep (full at 1.5 $C_{cc}$, one weight per pixel) toward $\mathrm{SF}(M\bar x)\,\exp\!\big(C(\ln x - \ln \bar x)\big)$. $\bar x$ is the capture blurred by $\sigma = $ long edge / 1200; $\mathrm{SF}$ is the smooth maximum $\tfrac12\big(u + f + \sqrt{(u - f)^2 + f^2}\big)$ of each unmixed channel and $f = 0.15\,M_{cc}\bar x_c$; a channel whose detail $\ln(x_c/\bar x_c)$ sits $\ln 64$ below both others' is a dead photosite, not an edge, and takes their mean. $b$ is the median color of the frame-interior cells thinnest in all three channels together; only its color matters. A channel whose 99.5th percentile sits at its maximum is clipped, and both methods fall back to `linear`. A stitch is unmixed once, assembled, and a half-frame after it is sliced, so preview and export read the same base. `unmix_block_reason` refuses it on a transparency (E-6 is not scanned narrowband) and on a camera-WB decode (`linear_raw` off), where a diagonal gain does not commute with the unmix. The **narrowband scan** toggle instead applies the bundled RGBScan *input* profile at the display and export boundary; an explicit Input ICC overrides it. * **HDR merge** (`features/hdr`): combines a bracket of one frame into a single linear source at decode, next to the triplet merge. It has no shader and no GPU parity surface. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 49d64f17b..692fed1b8 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -14,7 +14,7 @@ from negpy.kernel.system.memory import available_system_memory_bytes from negpy.kernel.system.text import count_of, plural -from negpy.kernel.image.logic import working_oetf_encode +from negpy.kernel.image.logic import apply_exif_orientation, working_oetf_encode from negpy.desktop.auto_sliders import record_meters from negpy.desktop.converters import ImageConverter from negpy.desktop.render_memo import RenderMemo @@ -2924,6 +2924,7 @@ def _on_preview_loaded( self.state.preview_raw = raw 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_orientation = int(cam_matrix[4] or 1) if cam_matrix and len(cam_matrix) > 4 else 1 self.state.preview_lens_path = file_path self.state.preview_lens_token = decoded_lens_token self._decoded_source_token = source_token(self.state.config) @@ -3506,6 +3507,7 @@ def toggle_test_strip(self, force: Optional[bool] = None, kind: str = "tone") -> detect_buffer=self.state.preview_detect, cam_xyz=cam_xyz, camera_wb=camera_wb, + source_orientation=self.state.preview_orientation, ) ) @@ -3661,7 +3663,9 @@ def auto_skew_frame(self) -> None: config = self.state.config geo = config.geometry # Every detection path reads the flat-fielded source. - source = raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield) + source = ( + raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield, orientation=self.state.preview_orientation) + ) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: fit = squared_geometry(source, geo, config.process.process_mode) @@ -6760,6 +6764,7 @@ def request_render( gutter_thickness=dip[0]["gutter_thickness"] if dip is not None else 0.0, split_axis=str(dip[0].get("split_axis") or "x") if dip is not None else "x", gain_slices=self._preview_gain_slices(dip), + source_orientation=self.state.preview_orientation, ) self._cancel_neighbor_prefetch() @@ -7125,8 +7130,8 @@ def _paint_flatfield_peek(self) -> None: The profile's stored reference copy times its gain, each channel over its lit-area median, ±EVENNESS_RANGE as black..white, so a good profile shows gray. A profile saved - before the copy was stored shows the light its gain corrects instead. Sensor layout: - the frame's geometry is not the reference's. + before the copy was stored shows the light its gain corrects instead. A sensor-layout + profile takes the open frame's EXIF orientation; the frame's own geometry is not applied. """ import cv2 @@ -7154,6 +7159,8 @@ def _paint_flatfield_peek(self) -> None: f"Flat Field '{name}' was saved before the check: showing the light it corrects, " "darker where there is less light. Save the profile again to check it" ) + if stored.sensor_layout: + view = apply_exif_orientation(view, self.state.preview_orientation) h, w = view.shape[:2] scale = APP_CONFIG.preview_render_size / max(h, w) view = cv2.resize(view, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR) diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 732eddd6f..ed7d41d6f 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -18,6 +18,7 @@ from negpy.services.assets.crosstalk import CrosstalkProfiles from negpy.services.assets.flatfield import FlatFieldProfiles from negpy.services.assets.migrations.flatfield import migrate_legacy_flatfield_profiles +from negpy.services.assets.migrations.flatfield_layout import migrate_flatfield_sensor_layout from negpy.services.assets.gear import GearProfiles from negpy.services.assets.migrations.gear_presets import migrate_gear_presets from negpy.services.assets.migrations.normalization_roll import migrate_legacy_normalization_rolls @@ -296,6 +297,7 @@ def main() -> None: # into that store. set_gain_provider(FlatFieldProfiles.load_gain) migrate_legacy_flatfield_profiles(repo) + migrate_flatfield_sensor_layout() migrate_gear_presets(repo) migrate_legacy_normalization_rolls(repo) migrate_auto_meter_for_positive_frames(repo) diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index facc9965b..a8a3c187d 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -125,6 +125,8 @@ 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 + # EXIF orientation the decode baked into preview_raw; the flat-field gain takes it. + preview_orientation: int = 1 preview_lens: Optional[LensMetadata] = None preview_lens_path: str = "" preview_lens_token: str = "" diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 810a1037b..91b76ab4a 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -127,6 +127,8 @@ class RenderTask: # The slice_half cuts that took `buffer` out of the decoded frame (a half, or a # diptych's crop), for the flat-field gain to take too. gain_slices: tuple = () + # EXIF orientation the decode baked into `buffer`, for the flat-field gain to take first. + source_orientation: int = 1 @dataclass(frozen=True) @@ -148,6 +150,7 @@ class TestStripTask: cam_xyz: Optional[list] = None # As-shot WB multipliers, needed only when the buffer was decoded without WB. camera_wb: Optional[list] = None + source_orientation: int = 1 @dataclass(frozen=True) @@ -369,6 +372,7 @@ def _render_diptych(self, task: RenderTask, pipeline_source_hash: str) -> tuple[ cam_xyz=task.cam_xyz, camera_wb=task.camera_wb, gain_slices=task.gain_slices + ((n, task.split_x, None, task.gutter_thickness, task.split_axis),), + source_orientation=task.source_orientation, ) if isinstance(out, GPUTexture): out = np.ascontiguousarray(out.readback()[:, :, :3]) @@ -408,6 +412,7 @@ def process(self, task: RenderTask) -> None: cam_xyz=task.cam_xyz, camera_wb=task.camera_wb, gain_slices=task.gain_slices, + source_orientation=task.source_orientation, ) # CPU renders have no in-shader histogram; bin the float output here. @@ -479,6 +484,7 @@ def build_strip(self, task: TestStripTask) -> None: wants_uv_grid=False, cam_xyz=task.cam_xyz, camera_wb=task.camera_wb, + source_orientation=task.source_orientation, ) if isinstance(result, GPUTexture): result = result.readback() @@ -1266,6 +1272,7 @@ def cancelled() -> bool: metadata.get("camera_wb"), metadata.get("lens_correction"), lens_decode_token(task.lens_corrections, task.lens_flatfield), + metadata.get("orientation", 1), ), metadata.get("detect_preview"), task.generation, @@ -1311,6 +1318,7 @@ def cancelled() -> bool: metadata.get("camera_wb"), metadata.get("lens_correction"), lens_decode_token(task.lens_corrections, task.lens_flatfield), + metadata.get("orientation", 1), ), metadata.get("detect_preview"), task.generation, @@ -1354,6 +1362,7 @@ def cancelled() -> bool: metadata.get("camera_wb"), metadata.get("lens_correction"), lens_decode_token(task.lens_corrections, task.lens_flatfield), + metadata.get("orientation", 1), ), metadata.get("detect_preview"), task.generation, @@ -1424,6 +1433,7 @@ def cancelled() -> bool: metadata.get("camera_wb"), metadata.get("lens_correction"), lens_decode_token(task.lens_corrections, task.lens_flatfield), + metadata.get("orientation", 1), ), metadata.get("detect_preview"), task.generation, @@ -1467,16 +1477,6 @@ def _detect_mode(self, task: PreviewLoadTask, raw) -> str: return "" -def decode_asset_preview( - preview_service, - file_info: dict, - config: WorkspaceConfig, - workspace_color_space: str, -) -> np.ndarray: - """Decode one asset the way the render path does. See `_decode_asset_preview_with_meta`.""" - return _decode_asset_preview_with_meta(preview_service, file_info, config, workspace_color_space)[0] - - def gain_slices_for_asset(file_info: dict) -> tuple: """The slice_half cuts a thumbnail's buffer took, for run_pipeline's gain_slices.""" from negpy.services.assets.half_frame import asset_slice @@ -1608,8 +1608,8 @@ def _emit_finished_unless_cancelled(self, generation: int, results: list[BatchAu if cancelled: self.cancelled.emit() - def _decode(self, frame: BatchAutoCropInput, workspace_color_space: str) -> np.ndarray: - return decode_asset_preview(self._preview_service, frame.file_info, frame.config, workspace_color_space) + def _decode(self, frame: BatchAutoCropInput, workspace_color_space: str) -> tuple[np.ndarray, dict]: + return _decode_asset_preview_with_meta(self._preview_service, frame.file_info, frame.config, workspace_color_space) def _frame_evidence( self, index: int, frame: BatchAutoCropInput, task: BatchAutoCropTask, generation: int @@ -1622,12 +1622,14 @@ def _frame_evidence( if self._cancel_requested(generation): return None, None try: - raw = self._decode(frame, task.workspace_color_space) + raw, meta = self._decode(frame, task.workspace_color_space) if self._cancel_requested(generation): return None, None config = frame.config - corrected = raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield) + corrected = ( + raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield, orientation=meta.get("orientation", 1)) + ) geometry = config.geometry keystone = None if squares_on_arm(geometry): @@ -1872,6 +1874,7 @@ def process(self, task: ThumbnailRenderTask) -> None: cam_xyz=cam_xyz, camera_wb=meta.get("camera_wb"), gain_slices=gain_slices_for_asset(frame.file_info), + source_orientation=meta.get("orientation", 1), ) render_s = time.perf_counter() - started if isinstance(result, np.ndarray) and not self._cancel_requested(generation): @@ -1972,13 +1975,13 @@ async def _analyze_file(frame: NormalizationInput): process_mode = params.process.process_mode geometry = params.geometry - # to_thread for the blocking load and analysis. decode_asset_preview picks + # to_thread for the blocking load and analysis. The decode picks # the same WB the render path uses (use_camera_wb = not effective linear # RAW): the roll-average bounds are applied to the render-decoded image, so # analyzing in a different WB space shifts the per-channel floors and ceils # and produces a color cast. - raw = await asyncio.to_thread( - decode_asset_preview, + raw, decode_meta = await asyncio.to_thread( + _decode_asset_preview_with_meta, self._preview_service, f_info, params, @@ -1991,7 +1994,11 @@ async def _analyze_file(frame: NormalizationInput): cuts = gain_slices_for_asset(f_info) raw = await asyncio.to_thread( - apply_flatfield, raw, params.flatfield, (lambda g: slice_chain(g, cuts)) if cuts else None + apply_flatfield, + raw, + params.flatfield, + (lambda g: slice_chain(g, cuts)) if cuts else None, + orientation=decode_meta.get("orientation", 1), ) # Bounds must be measured on the same channel mix the preview normalizes. sensor_matrix = effective_sensor_matrix(params.process) diff --git a/negpy/features/flatfield/logic.py b/negpy/features/flatfield/logic.py index 8cc1e7e37..0407f7a87 100644 --- a/negpy/features/flatfield/logic.py +++ b/negpy/features/flatfield/logic.py @@ -6,6 +6,7 @@ from negpy.domain.types import ImageBuffer from negpy.features.flatfield.models import FlatFieldConfig +from negpy.kernel.image.logic import apply_exif_orientation from negpy.kernel.system.logging import get_logger logger = get_logger(__name__) @@ -47,12 +48,19 @@ _CLIP_LEVEL = 0.98 _CLIP_FRACTION = 0.001 -# Resolved gains keyed by profile id: (gain map, content token). A cached ``None`` marks a -# known-missing profile, so a broken reference does not re-hit the store every render. -# Populated lazily through the injected provider: the desktop app wires it to the on-disk -# profile store (services/assets/flatfield.py) at startup, and tests may seed this map -# directly. -GainEntry = Tuple[np.ndarray, str] + +class GainEntry(NamedTuple): + gain: np.ndarray + token: str + # False for a profile still in its reference's EXIF orientation, whose tag is unknown: it + # applies untouched, as it was baked. + sensor_layout: bool = True + + +# Resolved gains keyed by profile id. A cached ``None`` marks a known-missing profile, so a +# broken reference does not re-hit the store every render. Populated lazily through the +# injected provider: the desktop app wires it to the on-disk profile store +# (services/assets/flatfield.py) at startup, and tests may seed this map directly. _GAIN_CACHE: Dict[str, Optional[GainEntry]] = {} _gain_provider: Optional[Callable[[str], Optional[GainEntry]]] = None @@ -75,9 +83,9 @@ def invalidate_gain(profile_id: Optional[str] = None) -> None: def _resolve(profile_id: str) -> Optional[GainEntry]: if not profile_id: return None - if profile_id in _GAIN_CACHE: - return _GAIN_CACHE[profile_id] - entry = _gain_provider(profile_id) if _gain_provider is not None else None + entry = _GAIN_CACHE[profile_id] if profile_id in _GAIN_CACHE else _gain_provider(profile_id) if _gain_provider is not None else None + if entry is not None and not isinstance(entry, GainEntry): + entry = GainEntry(*entry) _GAIN_CACHE[profile_id] = entry return entry @@ -210,22 +218,35 @@ def flatfield_token(config: FlatFieldConfig) -> str: entry = _resolve(config.profile_id) if entry is None: return "" - return f"|ff:{config.profile_id}:{entry[1]}" + return f"|ff:{config.profile_id}:{entry.token}" + ("" if entry.sensor_layout else ":ref") + + +def to_sensor_layout(image: np.ndarray, orientation: Optional[int]) -> np.ndarray: + """Undo an EXIF orientation that a decode baked into *image*.""" + return apply_exif_orientation(image, {6: 8, 8: 6}.get(orientation or 1, orientation)) def apply_flatfield( - image: ImageBuffer, config: FlatFieldConfig, gain_crop: Optional[Callable[[np.ndarray], np.ndarray]] = None + image: ImageBuffer, + config: FlatFieldConfig, + gain_crop: Optional[Callable[[np.ndarray], np.ndarray]] = None, + *, + orientation: Optional[int], ) -> ImageBuffer: """Multiply the linear source by the reference gain map. No-op when inactive or unresolved. - ``gain_crop`` cuts the gain to the part of the frame *image* holds, such as one half-frame; + The gain is stored in sensor layout and takes *image*'s decode ``orientation`` first, so a + frame tagged differently from the reference is still corrected where the light fell. + ``gain_crop`` then cuts it to the part of the frame *image* holds, such as one half-frame; the gain is then resized to *image*.""" if not config.apply or not config.profile_id: return image entry = _resolve(config.profile_id) if entry is None: return image - gain = entry[0] if gain_crop is None else gain_crop(entry[0]) + gain = apply_exif_orientation(entry.gain, orientation) if entry.sensor_layout else entry.gain + if gain_crop is not None: + gain = gain_crop(gain) if gain.shape[:2] != image.shape[:2]: gain = cv2.resize(gain, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_LINEAR) return (image * gain).astype(np.float32) diff --git a/negpy/services/assets/flatfield.py b/negpy/services/assets/flatfield.py index 350de7a34..9b2052013 100644 --- a/negpy/services/assets/flatfield.py +++ b/negpy/services/assets/flatfield.py @@ -4,13 +4,16 @@ import numpy as np -from negpy.features.flatfield.logic import Evenness, check_copy, check_reference, compute_gain, gain_token +from negpy.features.flatfield.logic import Evenness, GainEntry, check_copy, check_reference, compute_gain, gain_token, to_sensor_layout from negpy.kernel.system.config import APP_CONFIG from negpy.kernel.system.logging import get_logger logger = get_logger(__name__) _EXT = ".npz" +# Marks a profile whose gain and reference are in sensor layout; one without it is in its +# reference's EXIF orientation (services/assets/migrations/flatfield_layout.py). +SENSOR_LAYOUT = "sensor" class FlatFieldProfile(NamedTuple): @@ -24,6 +27,7 @@ class StoredCheck(NamedTuple): gain: np.ndarray reference: Optional[np.ndarray] # the reference's check copy; None on a profile baked before checks check: Optional[Evenness] + sensor_layout: bool = True class Baked(NamedTuple): @@ -55,13 +59,15 @@ def _path_for_id(profile_id: str) -> str: @staticmethod def _bake_gain(reference_path: str) -> Optional[Baked]: - """Decode a reference like a negative (no WB, linear); its gain map, self-check and check copy.""" + """Decode a reference like a negative (no WB, linear); its gain map, self-check and check copy, + in sensor layout.""" if not reference_path or not os.path.exists(reference_path): return None try: from negpy.services.rendering.preview_manager import PreviewManager - reference, _, _ = PreviewManager().load_linear_preview(reference_path, use_camera_wb=False, full_resolution=False) + reference, _, meta = PreviewManager().load_linear_preview(reference_path, use_camera_wb=False, full_resolution=False) + reference = to_sensor_layout(reference, meta.get("orientation", 1)) gain = compute_gain(reference) return Baked(gain, check_reference(reference, gain), check_copy(reference)) except Exception: @@ -93,6 +99,7 @@ def _write( name=name, k1=float(k1), source=source, + layout=SENSOR_LAYOUT, **extra, ) @@ -138,7 +145,7 @@ def import_gain(gain: np.ndarray, *, name: str, k1: float = 0.0, source: str = " @staticmethod def load_check(profile_id: str) -> Optional[StoredCheck]: """The gain with the stored reference copy and self-check, for Check Flat Field.""" - data = FlatFieldProfiles._read(profile_id, ("gain", "reference", "check")) + data = FlatFieldProfiles._read(profile_id, ("gain", "reference", "check", "layout")) if data is None or "gain" not in data: return None reference = data["reference"].astype(np.float32) if "reference" in data else None @@ -146,17 +153,17 @@ def load_check(profile_id: str) -> Optional[StoredCheck]: if "check" in data: low, high, color, clipped = (float(v) for v in data["check"]) check = Evenness(low, high, color, bool(clipped)) - return StoredCheck(np.ascontiguousarray(data["gain"], dtype=np.float32), reference, check) + return StoredCheck(np.ascontiguousarray(data["gain"], dtype=np.float32), reference, check, "layout" in data) @staticmethod - def load_gain(profile_id: str) -> Optional[Tuple[np.ndarray, str]]: - """(gain map, content token) for the render-path provider; None if missing/unreadable.""" - data = FlatFieldProfiles._read(profile_id, ("gain", "token")) + def load_gain(profile_id: str) -> Optional[GainEntry]: + """The gain map, its content token and layout for the render-path provider; None if missing/unreadable.""" + data = FlatFieldProfiles._read(profile_id, ("gain", "token", "layout")) if data is None or "gain" not in data: return None gain = np.ascontiguousarray(data["gain"], dtype=np.float32) token = str(data["token"]) if "token" in data else gain_token(gain) - return gain, token + return GainEntry(gain, token, "layout" in data) @staticmethod def get(profile_id: str) -> Optional[FlatFieldProfile]: diff --git a/negpy/services/assets/migrations/flatfield_layout.py b/negpy/services/assets/migrations/flatfield_layout.py new file mode 100644 index 000000000..9e12b3eb1 --- /dev/null +++ b/negpy/services/assets/migrations/flatfield_layout.py @@ -0,0 +1,97 @@ +"""Rewrite flat-field profiles baked in their reference's EXIF orientation into sensor layout. + +A profile without the layout marker holds its gain and check copy as the reference decoded, +EXIF orientation applied. That orientation is worked out from the reference file and undone. +A profile whose reference cannot be read, or whose bake orientation cannot be told, stays +unmarked and applies as baked; it is tried again on every launch. Nothing raises into app +startup. +""" + +import os +import uuid +from typing import Dict, Optional + +import numpy as np + +from negpy.features.flatfield.logic import gain_token, invalidate_gain, to_sensor_layout +from negpy.infrastructure.loaders.constants import SUPPORTED_JXL_EXTENSIONS +from negpy.infrastructure.loaders.helpers import read_exif_from_file, read_orientation +from negpy.kernel.system.config import APP_CONFIG +from negpy.kernel.system.logging import get_logger +from negpy.services.assets.flatfield import SENSOR_LAYOUT + +logger = get_logger(__name__) + +_EXT = ".npz" +_TIFF_MARKERS = (b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+") + + +def _baked_orientation(source: str, gain: np.ndarray) -> Optional[int]: + """The EXIF orientation the bake applied to *source*, or None when it cannot be told. + + The JXL decode applies none. A tag only LibRaw reads (CR3, RAF) was read as 1 by earlier + builds, so the gain's aspect against the sensor's tells whether the bake made the quarter + turn; a half turn or a mirror leaves no trace in the aspect.""" + if os.path.splitext(source)[1].lower() in SUPPORTED_JXL_EXTENSIONS: + return 1 + with open(source, "rb") as fh: + tiff = fh.read(4) in _TIFF_MARKERS + tag = read_orientation(source) + if tag == 1 or tiff or (read_exif_from_file(source) or {}).get("0th", {}).get(274) is not None: + return tag + if tag not in (5, 6, 7, 8): + return None + import rawpy + + with rawpy.imread(source) as raw: + sensor_portrait = raw.sizes.height > raw.sizes.width + return tag if (gain.shape[0] > gain.shape[1]) != sensor_portrait else 1 + + +def _migrate_file(path: str) -> None: + with np.load(path, allow_pickle=False) as data: + if "layout" in data.files or "gain" not in data.files: + return + members: Dict[str, np.ndarray] = {k: data[k] for k in data.files} + source = str(members.get("source", "")) + if not source or not os.path.exists(source): + return + try: + orientation = _baked_orientation(source, members["gain"]) + except Exception: + orientation = None + if orientation is None: + return + gain = np.ascontiguousarray(to_sensor_layout(members["gain"], orientation), dtype=np.float32) + members["gain"] = gain + members["token"] = np.array(gain_token(gain)) + if "reference" in members: + members["reference"] = to_sensor_layout(members["reference"], orientation) + members["layout"] = np.array(SENSOR_LAYOUT) + # Unique and without the profile extension, so a concurrent launch never shares it and the + # profile list never shows one a crash left behind. + temp = f"{path}.{uuid.uuid4().hex}.part" + try: + with open(temp, "wb") as fh: + np.savez_compressed(fh, **members) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.remove(temp) + invalidate_gain(os.path.basename(path)[: -len(_EXT)]) + if orientation != 1: + logger.info("Flat-field: %s turned to sensor layout from EXIF orientation %d", os.path.basename(path), orientation) + + +def migrate_flatfield_sensor_layout() -> None: + """Turn every unmarked profile whose reference can be read into sensor layout.""" + directory = APP_CONFIG.flatfield_dir + if not os.path.isdir(directory): + return + for name in sorted(os.listdir(directory)): + if not name.endswith(_EXT): + continue + try: + _migrate_file(os.path.join(directory, name)) + except Exception: + logger.exception("Flat-field: could not move %s to sensor layout", name) diff --git a/negpy/services/assets/thumbnail_fingerprint.py b/negpy/services/assets/thumbnail_fingerprint.py index 2dcd8a0a8..adaa890cf 100644 --- a/negpy/services/assets/thumbnail_fingerprint.py +++ b/negpy/services/assets/thumbnail_fingerprint.py @@ -12,7 +12,7 @@ from negpy.domain.models import WorkspaceConfig # Bump in any change that alters rendered pixels for unchanged settings, a changed default included. -THUMBNAIL_RENDER_VERSION = 2 +THUMBNAIL_RENDER_VERSION = 3 # Marks a thumbnail made from the source preview, which runs none of the frame's settings. QUICK = "quick" diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 2e1b4080b..023423b60 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -410,7 +410,7 @@ def _decode_source( if hdr is not None and hdr_active(hdr): rgb, wb, meta = _decode_hdr(file_path, hdr, expansion=expansion, gamma_key=gamma_key, process=process) if apply_flatfield and flatfield is not None: - rgb = _apply_flatfield_correction(rgb, flatfield) + rgb = _apply_flatfield_correction(rgb, flatfield, orientation=read_orientation(file_path)) if apply_sensor and process is not None and process.sensor_matrix is not None: rgb = apply_sensor_correction(rgb, process.sensor_matrix, process.sensor_unmix) if apply_wb and not wb_blocked and wb is not None: @@ -440,14 +440,14 @@ def _decode_source( if rgbscan is not None and is_rgb_triplet(rgbscan): rgb, ir, wb, meta = _decode_camera_raw_triplet(file_path, rgbscan, demosaic) if apply_flatfield and flatfield is not None: - rgb = _apply_flatfield_correction(rgb, flatfield) + rgb = _apply_flatfield_correction(rgb, flatfield, orientation=read_orientation(file_path)) if apply_wb and not wb_blocked and wb is not None: rgb = _apply_white_balance(rgb, wb) return rgb, ir, wb, meta meta = _read_source_meta_tiff(file_path) rgb, wb, decode_meta, lens_meta = _decode_camera_raw_buffer(file_path, demosaic) if apply_flatfield and flatfield is not None: - rgb = _apply_flatfield_correction(rgb, flatfield) + rgb = _apply_flatfield_correction(rgb, flatfield, orientation=read_orientation(file_path)) warped = bool(lens.distortion and lens_meta.distortion or lens.ca and lens_meta.ca) if warped: rgb = apply_lens(rgb, lens_meta, read_orientation(file_path), lens) @@ -797,7 +797,7 @@ def _decode(path: str) -> np.ndarray: f32, _, wb, meta = _decode_linear(file_path, process=process) if flatfield is not None: - f32 = _apply_flatfield_correction(f32, flatfield) + f32 = _apply_flatfield_correction(f32, flatfield, orientation=read_orientation(file_path)) if unmix and not is_triplet and process is not None and process.sensor_matrix is not None: f32 = apply_sensor_correction(f32, process.sensor_matrix, process.sensor_unmix) return f32, wb, meta diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index 242d51f8c..89680b11d 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -719,6 +719,7 @@ def run_pipeline( camera_wb: Optional[list] = None, cache_stages: bool = True, gain_slices: tuple = (), + source_orientation: int = 1, ) -> Tuple[Any, Dict[str, Any]]: """ Executes rendering pipeline. Returns result (ndarray/GPUTexture) and metrics. @@ -726,6 +727,8 @@ def run_pipeline( ``skip_flatfield``: the export CPU fallbacks pass an already-flat-fielded buffer. ``gain_slices``: the ``slice_half`` cuts that took *img* out of the decoded frame, such as one half-frame; the flat-field gain takes the same cuts. + ``source_orientation``: the EXIF orientation the decode baked into *img*; the sensor-layout + flat-field gain takes it first. """ # Flat-field is a source pre-correction, before geometry and crop. Folding its token # into source_hash invalidates the engine cache when it changes. Stitch buffers arrive @@ -737,6 +740,7 @@ def run_pipeline( img.shape, skip_flatfield, gain_slices, + source_orientation, metadata_lens_corrections(settings), flatfield_token(settings.flatfield), sensor_token(settings.process), @@ -751,7 +755,12 @@ def run_pipeline( if not skip_flatfield and not settings.stitch.stitch_enabled and not metadata_lens_corrections(settings): from negpy.services.assets.half_frame import slice_chain - img = apply_flatfield(img, settings.flatfield, (lambda g: slice_chain(g, gain_slices)) if gain_slices else None) + img = apply_flatfield( + img, + settings.flatfield, + (lambda g: slice_chain(g, gain_slices)) if gain_slices else None, + orientation=source_orientation, + ) # Sensor unmix is a source pre-correction like flat-field. skip_flatfield buffers # come from _load_source_f32, which already applied it. if not skip_flatfield and preview_takes_unmix(settings): @@ -1233,7 +1242,7 @@ def _decode(path: str) -> np.ndarray: 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) + f32_buffer = apply_flatfield(f32_buffer, params.flatfield, orientation=orientation) if unmix and not is_triplet: f32_buffer = apply_sensor_correction(f32_buffer, effective_sensor_matrix(params.process), params.process.sensor_unmix) if ir_full is not None: diff --git a/negpy/services/rendering/lens.py b/negpy/services/rendering/lens.py index e1585b0c4..a2c37feef 100644 --- a/negpy/services/rendering/lens.py +++ b/negpy/services/rendering/lens.py @@ -31,7 +31,7 @@ def prepare_lens_source( corrections: LensCorrections = LensCorrections(True, True), ) -> ImageBuffer: """Flat-field in sensor positions before a lens warp moves the samples.""" - img = apply_flatfield(img, flatfield) + img = apply_flatfield(img, flatfield, orientation=metadata.get("orientation", 1)) 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) diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index a455c08b3..d714face8 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -956,7 +956,7 @@ def load_linear_preview_stitch( highlight_mode=highlight_mode, bake_camera_wb=bake_camera_wb, ) - parts.append(apply_flatfield(np.asarray(out, dtype=np.float32), flatfield)) + parts.append(apply_flatfield(np.asarray(out, dtype=np.float32), flatfield, orientation=part_meta.get("orientation", 1))) irs.append(part_meta.get("ir_preview")) if i == 0: meta = dict(part_meta) diff --git a/tests/test_autocrop_square.py b/tests/test_autocrop_square.py index ed8571599..cb936a34e 100644 --- a/tests/test_autocrop_square.py +++ b/tests/test_autocrop_square.py @@ -184,7 +184,7 @@ def detect(key, image, **_kw): return CropEvidence(key=key, canvas_shape=image.shape[:2], roi=None, correction_angle=0.25, confidence=0.9) with ( - patch.object(worker, "_decode", return_value=np.zeros((40, 60, 3), np.float32)), + patch.object(worker, "_decode", return_value=(np.zeros((40, 60, 3), np.float32), {})), patch("negpy.desktop.workers.render.squared_geometry", side_effect=_square(fine=2.0, cv=0.5, ch=-0.3)), patch("negpy.desktop.workers.render.GeometryProcessor", Recorder), patch("negpy.desktop.workers.render.detect_crop_candidate", side_effect=detect), @@ -197,7 +197,7 @@ def detect(key, image, **_kw): hand = replace(config, geometry=replace(config.geometry, fine_rotation=0.4)) with ( - patch.object(worker, "_decode", return_value=np.zeros((40, 60, 3), np.float32)), + patch.object(worker, "_decode", return_value=(np.zeros((40, 60, 3), np.float32), {})), patch("negpy.desktop.workers.render.squared_geometry") as fit, patch("negpy.desktop.workers.render.detect_crop_candidate", side_effect=detect), ): diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py index ea13384a1..7cc127bb5 100644 --- a/tests/test_batch_autocrop_worker.py +++ b/tests/test_batch_autocrop_worker.py @@ -46,7 +46,7 @@ def load_linear_preview( } ) raw = np.full((8, 12, 3), 0.5, dtype=np.float32) - return raw, (12, 8), {} + return raw, (12, 8), {"orientation": 6} def load_linear_preview_rgb( self, @@ -159,8 +159,9 @@ def test_batch_autocrop_applies_flatfield_and_crop_free_geometry(qapp, monkeypat worker = BatchAutoCropWorker(preview) captured: dict = {} - def _apply_flatfield(image, received_config): + def _apply_flatfield(image, received_config, orientation=1): captured["flatfield_config"] = received_config + captured["orientation"] = orientation return image + 1.0 class _GeometryProcessor: @@ -187,6 +188,7 @@ def _detect(key, transformed, *, target_ratio, rebate_trim=1.0): worker.process(_task(_input("frame", config))) assert captured["flatfield_config"] == flatfield + assert captured["orientation"] == 6 assert captured["geometry"].crop_rect is None assert captured["geometry"].crop_from_auto is False assert captured["geometry"].autocrop_offset == 0 diff --git a/tests/test_controller.py b/tests/test_controller.py index 5ed5ed1b0..92e4805af 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -2316,6 +2316,19 @@ def test_preview_loaded_updates_state_and_emits_signal(self): mock_slot.assert_called_once_with() self.controller.request_render.assert_called_once_with() + def test_the_decode_orientation_reaches_the_render_for_the_flat_field(self): + import numpy as np + + emitted = [] + self.controller.render_requested.connect(emitted.append) + self.controller._requested_file_path = "turned.arw" + raw = np.zeros((4, 6, 3), dtype=np.float32) + + self.controller._on_preview_loaded("turned.arw", raw, (4, 6), "", None, "", (None, None, None, "", 6)) + + self.assertEqual(self.controller.state.preview_orientation, 6) + self.assertEqual(emitted[-1].source_orientation, 6) + def test_stale_preview_decode_is_dropped(self): """A decode that lands after the user switched files must not be applied — accepting it pairs the old buffer with the new file's hash and poisons the @@ -5426,6 +5439,17 @@ def test_an_old_profile_shows_the_light_it_corrects(self): self.assertGreater(view[h // 2, w // 2].mean(), view[1, 1].mean()) self.assertIn("saved before the check", seen[-1]) + def test_it_turns_with_the_frame_orientation_unless_the_profile_is_still_as_baked(self): + for sensor_layout, portrait in ((True, True), (False, False)): + with self.subTest(sensor_layout=sensor_layout): + self.controller.state.flatfield_peek = False + self._select(self._stored()._replace(sensor_layout=sensor_layout)) + self.controller.state.preview_orientation = 6 + self.controller.toggle_flatfield_peek(force=True) + + h, w = self.controller.state.peek_frame["base_positive"].shape[:2] + self.assertEqual(h > w, portrait) + def test_with_no_profile_it_says_so_and_stays_off(self): seen: list = [] self.controller.flatfield_peek_changed.connect(seen.append) diff --git a/tests/test_flatfield_layout_migration.py b/tests/test_flatfield_layout_migration.py new file mode 100644 index 000000000..724918414 --- /dev/null +++ b/tests/test_flatfield_layout_migration.py @@ -0,0 +1,237 @@ +import os + +import numpy as np +import pytest + +from negpy.features.flatfield import logic as ff +from negpy.kernel.image.logic import apply_exif_orientation +from negpy.services.assets import flatfield as ffstore +from negpy.services.assets.flatfield import FlatFieldProfiles +from negpy.services.assets.migrations import flatfield_layout +from negpy.services.assets.migrations.flatfield_layout import migrate_flatfield_sensor_layout + + +def _sensor_gain(h: int = 24, w: int = 36) -> np.ndarray: + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + g = 1.0 + 0.5 * (xx / w) + 0.25 * (yy / h) + return np.repeat(g[:, :, None], 3, axis=2).astype(np.float32) + + +@pytest.fixture +def store(tmp_path, monkeypatch): + directory = tmp_path / "flatfield" + directory.mkdir() + monkeypatch.setattr(ffstore.APP_CONFIG, "flatfield_dir", str(directory), raising=False) + return directory + + +def _exif_tag(monkeypatch, tag: int) -> None: + """The reference's tag as EXIF carries it, which every build has read.""" + monkeypatch.setattr(flatfield_layout, "read_orientation", lambda path: tag) + monkeypatch.setattr(flatfield_layout, "read_exif_from_file", lambda path: {"0th": {274: tag}}) + + +def _libraw_tag(monkeypatch, tag: int, sensor_hw: tuple[int, int]) -> None: + """The reference's tag as only LibRaw reads it (CR3, RAF), over a sensor of *sensor_hw*.""" + from types import SimpleNamespace + + monkeypatch.setattr(flatfield_layout, "read_orientation", lambda path: tag) + monkeypatch.setattr(flatfield_layout, "read_exif_from_file", lambda path: None) + raw = SimpleNamespace(sizes=SimpleNamespace(height=sensor_hw[0], width=sensor_hw[1])) + fake = SimpleNamespace() + + class _Raw: + def __enter__(self): + return raw + + def __exit__(self, *exc): + return False + + fake.imread = lambda path: _Raw() + monkeypatch.setitem(__import__("sys").modules, "rawpy", fake) + + +def _write_legacy(directory, profile_id: str, gain: np.ndarray, source: str) -> str: + """A profile as written before the layout marker: gain and check copy as the reference decoded.""" + path = str(directory / f"{profile_id}.npz") + np.savez_compressed( + path, + gain=gain, + token=ff.gain_token(gain), + name="rig", + k1=0.0, + source=source, + reference=(gain * 0.4).astype(np.float16), + check=np.array([-0.01, 0.01, 0.005, 0.0]), + ) + return path + + +def test_a_profile_from_a_turned_reference_moves_to_sensor_layout(store, tmp_path, monkeypatch): + source = tmp_path / "flat.arw" + source.write_bytes(b"") + _exif_tag(monkeypatch, 6) + sensor = _sensor_gain() + _write_legacy(store, "p", apply_exif_orientation(sensor, 6), str(source)) + + migrate_flatfield_sensor_layout() + + gain, token, sensor_layout = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(gain, sensor) + assert token == ff.gain_token(sensor) + assert sensor_layout + assert FlatFieldProfiles.load_check("p").reference.shape[:2] == sensor.shape[:2] + assert FlatFieldProfiles.get("p").name == "rig" + assert str(FlatFieldProfiles._read("p", ("layout",))["layout"]) == ffstore.SENSOR_LAYOUT + + +def test_an_upright_profile_keeps_its_gain_and_token(store, tmp_path, monkeypatch): + source = tmp_path / "flat.arw" + source.write_bytes(b"") + _exif_tag(monkeypatch, 1) + gain = _sensor_gain() + _write_legacy(store, "p", gain, str(source)) + + migrate_flatfield_sensor_layout() + + entry = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(entry.gain, gain) + assert entry.token == ff.gain_token(gain) + assert entry.sensor_layout + + +def test_a_profile_without_its_reference_stays_as_baked_until_the_reference_is_back(store, tmp_path, monkeypatch): + source = tmp_path / "offline" / "flat.arw" + _exif_tag(monkeypatch, 6) + turned = apply_exif_orientation(_sensor_gain(), 6) + _write_legacy(store, "p", turned, str(source)) + + migrate_flatfield_sensor_layout() + + entry = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(entry.gain, turned) + assert not entry.sensor_layout + + source.parent.mkdir() + source.write_bytes(b"") + migrate_flatfield_sensor_layout() + + entry = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(entry.gain, _sensor_gain()) + assert entry.sensor_layout + + +def test_an_unmarked_profile_applies_as_baked_whatever_the_frame_orientation(store, tmp_path): + from negpy.features.flatfield.models import FlatFieldConfig + + turned = apply_exif_orientation(_sensor_gain(), 6) + _write_legacy(store, "p", turned, str(tmp_path / "gone.arw")) + frame = np.full(turned.shape, 0.5, np.float32) + ff.set_gain_provider(FlatFieldProfiles.load_gain) + try: + out = ff.apply_flatfield(frame, FlatFieldConfig(apply=True, profile_id="p"), orientation=6) + np.testing.assert_allclose(out, frame * turned) + assert ff.flatfield_token(FlatFieldConfig(apply=True, profile_id="p")).endswith(":ref") + finally: + ff.set_gain_provider(None) + + +def test_a_marked_profile_is_left_alone(store, tmp_path, monkeypatch): + source = tmp_path / "flat.arw" + source.write_bytes(b"") + _exif_tag(monkeypatch, 6) + _write_legacy(store, "p", apply_exif_orientation(_sensor_gain(), 6), str(source)) + migrate_flatfield_sensor_layout() + first = FlatFieldProfiles.load_gain("p").gain + + migrate_flatfield_sensor_layout() + + second = FlatFieldProfiles.load_gain("p").gain + np.testing.assert_array_equal(first, second) + + +def test_a_write_cut_short_is_never_listed_as_a_profile(store, tmp_path, monkeypatch): + source = tmp_path / "flat.arw" + source.write_bytes(b"") + _exif_tag(monkeypatch, 6) + gain = apply_exif_orientation(_sensor_gain(), 6) + _write_legacy(store, "p", gain, str(source)) + + def crash(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr(flatfield_layout.os, "replace", crash) + migrate_flatfield_sensor_layout() + + assert [i for i, _ in FlatFieldProfiles.list_profiles()] == ["p"] + assert sorted(os.listdir(store)) == ["p.npz"] + np.testing.assert_array_equal(FlatFieldProfiles.load_gain("p").gain, gain) + + +def test_a_libraw_only_tag_the_bake_never_read_leaves_the_gain_in_place(store, tmp_path, monkeypatch): + source = tmp_path / "flat.cr3" + source.write_bytes(b"") + _libraw_tag(monkeypatch, 8, sensor_hw=(4000, 6000)) + sensor = _sensor_gain() # landscape like the sensor: the bake made no quarter turn + _write_legacy(store, "p", sensor, str(source)) + + migrate_flatfield_sensor_layout() + + entry = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(entry.gain, sensor) + assert entry.sensor_layout + + +def test_a_libraw_only_tag_the_bake_applied_is_undone(store, tmp_path, monkeypatch): + source = tmp_path / "flat.cr3" + source.write_bytes(b"") + _libraw_tag(monkeypatch, 8, sensor_hw=(4000, 6000)) + _write_legacy(store, "p", apply_exif_orientation(_sensor_gain(), 8), str(source)) + + migrate_flatfield_sensor_layout() + + np.testing.assert_array_equal(FlatFieldProfiles.load_gain("p").gain, _sensor_gain()) + + +def test_a_libraw_only_half_turn_cannot_be_told_and_stays_as_baked(store, tmp_path, monkeypatch): + source = tmp_path / "flat.cr3" + source.write_bytes(b"") + _libraw_tag(monkeypatch, 3, sensor_hw=(4000, 6000)) + _write_legacy(store, "p", _sensor_gain(), str(source)) + + migrate_flatfield_sensor_layout() + + assert not FlatFieldProfiles.load_gain("p").sensor_layout + + +def test_a_jxl_reference_was_baked_upright_whatever_its_exif_says(store, tmp_path, monkeypatch): + source = tmp_path / "flat.jxl" + source.write_bytes(b"") + _exif_tag(monkeypatch, 6) + sensor = _sensor_gain() + _write_legacy(store, "p", sensor, str(source)) + + migrate_flatfield_sensor_layout() + + entry = FlatFieldProfiles.load_gain("p") + np.testing.assert_array_equal(entry.gain, sensor) + assert entry.sensor_layout + + +def test_an_unreadable_reference_stays_as_baked(store, tmp_path, monkeypatch): + source = tmp_path / "flat.arw" + source.write_bytes(b"") + _exif_tag(monkeypatch, 6) + _write_legacy(store, "p", apply_exif_orientation(_sensor_gain(), 6), str(source)) + real_open = open + + def refuse(path, *args, **kwargs): + if str(path) == str(source): + raise PermissionError(path) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr("builtins.open", refuse) + migrate_flatfield_sensor_layout() + monkeypatch.setattr("builtins.open", real_open) + + assert not FlatFieldProfiles.load_gain("p").sensor_layout diff --git a/tests/test_flatfield_logic.py b/tests/test_flatfield_logic.py index 6b519a304..92c07294b 100644 --- a/tests/test_flatfield_logic.py +++ b/tests/test_flatfield_logic.py @@ -32,19 +32,19 @@ def register(profile_id: str, reference: np.ndarray) -> None: def test_disabled_is_noop(): img = np.full((16, 16, 3), 0.5, dtype=np.float32) - out = ff.apply_flatfield(img, FlatFieldConfig(apply=False, profile_id="anything")) + out = ff.apply_flatfield(img, FlatFieldConfig(apply=False, profile_id="anything"), orientation=1) assert out is img def test_empty_profile_is_noop(): img = np.full((16, 16, 3), 0.5, dtype=np.float32) - out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id="")) + out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id=""), orientation=1) assert out is img def test_unknown_profile_is_noop(gain_store): img = np.full((16, 16, 3), 0.5, dtype=np.float32) - out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id="ghost")) + out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id="ghost"), orientation=1) assert out is img assert ff.flatfield_token(FlatFieldConfig(apply=True, profile_id="ghost")) == "" @@ -56,7 +56,7 @@ def test_correction_flattens_uneven_illumination(gain_store): # A uniform scene captured under this illumination is just the falloff map. captured = falloff.copy() - corrected = ff.apply_flatfield(captured, FlatFieldConfig(apply=True, profile_id="rig")) + corrected = ff.apply_flatfield(captured, FlatFieldConfig(apply=True, profile_id="rig"), orientation=1) # Before: clearly uneven. After: near-flat across the field. assert captured.std() > 0.05 @@ -129,7 +129,7 @@ def test_gain_resized_to_image(gain_store): # Gain baked at one size must resize to a differently-sized working image. gain_store("rig", _radial_falloff(64, 64)) img = np.full((100, 140, 3), 0.5, dtype=np.float32) - out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id="rig")) + out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id="rig"), orientation=1) assert out.shape == img.shape @@ -169,10 +169,10 @@ def test_a_half_frame_takes_its_own_half_of_the_gain(gain_store): scan = np.full((120, 200, 3), 0.4, np.float32) * _radial_falloff(120, 200) cut = ((1, 0.5, None, 0.04, "x"),) - expected = slice_chain(ff.apply_flatfield(scan, cfg), cut) + expected = slice_chain(ff.apply_flatfield(scan, cfg, orientation=1), cut) half = np.ascontiguousarray(slice_chain(scan, cut)) - np.testing.assert_allclose(ff.apply_flatfield(half, cfg, lambda g: slice_chain(g, cut)), expected, rtol=0.02) + np.testing.assert_allclose(ff.apply_flatfield(half, cfg, lambda g: slice_chain(g, cut), orientation=1), expected, rtol=0.02) processor = ImageProcessor() processor.run_pipeline( @@ -195,8 +195,8 @@ def test_without_the_cut_the_gain_would_be_stretched(gain_store): scan = np.full((120, 200, 3), 0.4, np.float32) * _radial_falloff(120, 200) cut = ((1, 0.5, None, 0.04, "x"),) half = np.ascontiguousarray(slice_chain(scan, cut)) - expected = slice_chain(ff.apply_flatfield(scan, cfg), cut) - assert not np.allclose(ff.apply_flatfield(half, cfg), expected, rtol=0.02) + expected = slice_chain(ff.apply_flatfield(scan, cfg, orientation=1), cut) + assert not np.allclose(ff.apply_flatfield(half, cfg, orientation=1), expected, rtol=0.02) def test_thumbnail_ir_planes_are_cut_like_the_buffer(): @@ -276,3 +276,80 @@ def test_a_profile_check_copy_is_finer_than_the_gain(): assert max(ff.check_copy(reference).shape[:2]) == 512 assert max(ff.compute_gain(reference).shape[:2]) == 256 assert ff.check_reference(reference, ff.compute_gain(reference)).spread < ff.UNEVEN_LIMIT + + +def _sensor_falloff(h: int = 120, w: int = 180) -> np.ndarray: + """Falloff with no symmetry, so a wrong turn or flip shows.""" + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + light = (0.6 + 0.4 * xx / w) * (1.0 - 0.2 * yy / h) + return np.repeat(light[:, :, None], 3, axis=2).astype(np.float32) + + +def _spread(image: np.ndarray) -> float: + lum = image.mean(axis=2) + return float((lum.max() - lum.min()) / np.median(lum)) + + +@pytest.mark.parametrize("orientation", [1, 2, 3, 4, 5, 6, 7, 8]) +def test_to_sensor_layout_undoes_every_exif_orientation(orientation): + from negpy.kernel.image.logic import apply_exif_orientation + + sensor = _sensor_falloff(12, 18) + np.testing.assert_array_equal(ff.to_sensor_layout(apply_exif_orientation(sensor, orientation), orientation), sensor) + + +@pytest.mark.parametrize("orientation", [1, 2, 3, 4, 5, 6, 7, 8]) +def test_a_frame_of_any_exif_orientation_is_corrected_where_the_light_fell(gain_store, orientation): + from negpy.kernel.image.logic import apply_exif_orientation + + light = _sensor_falloff() + gain_store("rig", light) + frame = apply_exif_orientation(np.full_like(light, 0.5) * light, orientation) + + out = ff.apply_flatfield(frame, FlatFieldConfig(apply=True, profile_id="rig"), orientation=orientation) + + assert out.shape == frame.shape + assert _spread(out) < 0.06 + + +@pytest.mark.parametrize("orientation", [3, 6, 8]) +def test_a_turned_frame_read_as_upright_is_corrected_worse_than_not_at_all(gain_store, orientation): + from negpy.kernel.image.logic import apply_exif_orientation + + light = _sensor_falloff() + gain_store("rig", light) + frame = apply_exif_orientation(np.full_like(light, 0.5) * light, orientation) + + assert _spread(ff.apply_flatfield(frame, FlatFieldConfig(apply=True, profile_id="rig"), orientation=1)) > _spread(frame) + + +def test_a_turned_half_frame_takes_the_turned_gain_before_its_cut(gain_store): + from dataclasses import replace + + from negpy.domain.models import WorkspaceConfig + from negpy.kernel.image.logic import apply_exif_orientation + from negpy.services.assets.half_frame import slice_chain + from negpy.services.rendering.image_processor import ImageProcessor + + light = _sensor_falloff(120, 200) + gain_store("p", light) + cfg = FlatFieldConfig(apply=True, profile_id="p") + scan = apply_exif_orientation(np.full_like(light, 0.4) * light, 6) + cut = ((1, 0.5, None, 0.04, "y"),) + expected = slice_chain(ff.apply_flatfield(scan, cfg, orientation=6), cut) + + half = np.ascontiguousarray(slice_chain(scan, cut)) + np.testing.assert_allclose(ff.apply_flatfield(half, cfg, lambda g: slice_chain(g, cut), orientation=6), expected, rtol=0.02) + + processor = ImageProcessor() + processor.run_pipeline( + half, + replace(WorkspaceConfig(), flatfield=cfg), + "half", + render_size_ref=100.0, + prefer_gpu=False, + readback_metrics=False, + gain_slices=cut, + source_orientation=6, + ) + np.testing.assert_allclose(processor._precorrect_value, expected, rtol=0.02) diff --git a/tests/test_flatfield_profiles.py b/tests/test_flatfield_profiles.py index 6b24762fc..b5274acde 100644 --- a/tests/test_flatfield_profiles.py +++ b/tests/test_flatfield_profiles.py @@ -30,7 +30,7 @@ def test_import_and_load_round_trip(store_dir): gain = _gain() pid = FlatFieldProfiles.import_gain(gain, name="Rig A", k1=-0.08, source="/refs/flat.dng") - loaded, token = FlatFieldProfiles.load_gain(pid) + loaded, token, _ = FlatFieldProfiles.load_gain(pid) assert np.array_equal(loaded, gain) assert token == ff.gain_token(gain) @@ -73,9 +73,9 @@ def test_create_bakes_and_activates_via_provider(store_dir): ff.set_gain_provider(FlatFieldProfiles.load_gain) try: - stored, _ = FlatFieldProfiles.load_gain(pid) + stored = FlatFieldProfiles.load_gain(pid).gain img = np.full((24, 32, 3), 0.5, dtype=np.float32) - out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id=pid)) + out = ff.apply_flatfield(img, FlatFieldConfig(apply=True, profile_id=pid), orientation=1) assert np.allclose(out, img * stored) # provider resolved the baked gain and applied it finally: ff.set_gain_provider(None) @@ -123,3 +123,25 @@ def test_an_imported_gain_has_no_reference_copy(store_dir): def test_load_check_of_a_missing_profile_is_none(store_dir): assert FlatFieldProfiles.load_check("ghost") is None + + +def test_a_reference_is_baked_in_sensor_layout(tmp_path, monkeypatch): + from negpy.kernel.image.logic import apply_exif_orientation + from negpy.services.rendering.preview_manager import PreviewManager + + monkeypatch.setattr(ffstore.APP_CONFIG, "flatfield_dir", str(tmp_path / "flatfield"), raising=False) + sensor = _gain(48, 64) * 0.4 + reference_path = tmp_path / "flat.arw" + reference_path.write_bytes(b"") + monkeypatch.setattr( + PreviewManager, + "load_linear_preview", + lambda self, path, **kw: (apply_exif_orientation(sensor, 6), (48, 64), {"orientation": 6}), + ) + + pid = FlatFieldProfiles.create("rig", str(reference_path)) + + gain = FlatFieldProfiles.load_gain(pid).gain + np.testing.assert_allclose(gain, ff.compute_gain(sensor), rtol=1e-5) + assert FlatFieldProfiles.load_check(pid).reference.shape[:2] == ff.check_copy(sensor).shape[:2] + assert str(FlatFieldProfiles._read(pid, ("layout",))["layout"]) == ffstore.SENSOR_LAYOUT diff --git a/tests/test_image_processor.py b/tests/test_image_processor.py index d1a545f17..f94eabae6 100644 --- a/tests/test_image_processor.py +++ b/tests/test_image_processor.py @@ -420,7 +420,7 @@ def test_run_pipeline_skip_flatfield(monkeypatch) -> None: monkeypatch.setattr(service.engine_cpu, "process", lambda img, s, sh, ctx: img) calls = [] real = ip.apply_flatfield - monkeypatch.setattr(ip, "apply_flatfield", lambda img, ff, *a: (calls.append(1), real(img, ff, *a))[1]) + monkeypatch.setattr(ip, "apply_flatfield", lambda img, ff, *a, **kw: (calls.append(1), real(img, ff, *a, **kw))[1]) img = np.full((64, 64, 3), 0.5, dtype=np.float32) service.run_pipeline(img, WorkspaceConfig(), "h", render_size_ref=512, prefer_gpu=False, readback_metrics=False) diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py index 8ed07f8fd..6a6aeeb33 100644 --- a/tests/test_linear_output.py +++ b/tests/test_linear_output.py @@ -11,6 +11,7 @@ import pytest import tifffile +from negpy.features.flatfield.logic import GainEntry from negpy.features.geometry.models import GeometryConfig from negpy.features.lens.models import LensMetadata from negpy.features.process.models import ProcessConfig @@ -2368,7 +2369,7 @@ def fake_decode(path: str, demosaic="Auto"): first = next(iter(paths)) with ( mock.patch("negpy.services.export.linear_output._decode_camera_raw_buffer", side_effect=fake_decode), - mock.patch("negpy.features.flatfield.logic._resolve", return_value=(self._gain(), "tok")), + mock.patch("negpy.features.flatfield.logic._resolve", return_value=GainEntry(self._gain(), "tok")), ): rgb, _, _, _ = _decode_linear( first, @@ -2448,7 +2449,7 @@ def fake_decode(path: str, demosaic="Auto"): with ( mock.patch("negpy.services.export.linear_output._decode_camera_raw_buffer", side_effect=fake_decode), - mock.patch("negpy.features.flatfield.logic._resolve", return_value=(self._gain(), "tok")), + mock.patch("negpy.features.flatfield.logic._resolve", return_value=GainEntry(self._gain(), "tok")), ): return _decode_linear( next(iter(bufs)), diff --git a/tests/test_metadata_lens.py b/tests/test_metadata_lens.py index 04adfd2f4..acd0b0394 100644 --- a/tests/test_metadata_lens.py +++ b/tests/test_metadata_lens.py @@ -323,7 +323,7 @@ def test_flatfield_is_applied_before_the_lens_warp(monkeypatch): 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) + monkeypatch.setattr(service, "apply_flatfield", lambda img, config, orientation=1: 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) diff --git a/tests/test_preview_load_worker_detect_mode.py b/tests/test_preview_load_worker_detect_mode.py index aa94665b8..f4e3ddd9d 100644 --- a/tests/test_preview_load_worker_detect_mode.py +++ b/tests/test_preview_load_worker_detect_mode.py @@ -207,3 +207,17 @@ def fail_after_navigation(*_args, **_kwargs): assert errors == [] assert failures == [] + + +def test_the_decode_orientation_rides_out_with_the_loaded_preview(qapp): + from negpy.desktop.workers.render import PreviewLoadTask, PreviewLoadWorker + + service = MagicMock() + service.load_linear_preview.return_value = (np.ones((4, 4, 3), dtype=np.float32), (4, 4), {"orientation": 6}) + worker = PreviewLoadWorker(service) + finished = [] + worker.finished.connect(lambda *args: finished.append(args)) + + worker.process(PreviewLoadTask(file_path="turned.ARW", workspace_color_space="Adobe RGB", use_camera_wb=False, use_splash=False)) + + assert finished[0][6][4] == 6 diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index cf7bbd935..e2cfddf8f 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -9,7 +9,10 @@ "cyano_tannin": "0.0", "lith_abruptness": "0.6", "lith_exposure": "2.0", - "lith_snatch": "0.55" + "lith_snatch": "0.55", + "sabattier_agitation": "0.7", + "sabattier_reexposure": "0.45", + "sabattier_strength": "1.3" }, "exposure": { "auto_exposure": "true", @@ -17,6 +20,7 @@ "cast_removal_strength": "1.0", "contrast_mask": "0.0", "density": "1.0", + "diffusion": "0.0", "dye_separation": "1.0", "dye_separation_trim_blue": "0.0", "dye_separation_trim_green": "0.0", @@ -199,5 +203,5 @@ "vanadium_strength": "0.0" } }, - "render_version": 2 + "render_version": 3 } From 2afa59f5f780f058f9e6232f7f4211481362ddbf Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:26:11 +0200 Subject: [PATCH 2/2] feat(flatfield): Check Flat Field turns and flips with the frame The check sat in the camera's orientation while the frame on the canvas was turned or flipped, so a portrait frame showed a landscape check and a dark corner in the check did not match the same corner in the photo. It now takes the frame's quarter turns and flips, after its EXIF orientation. Fine rotation, keystone and crop are left out, so the edges, where falloff shows, stay in view. --- docs/USER_GUIDE.md | 2 +- negpy/desktop/controller.py | 13 +++++++++++-- tests/test_controller.py | 24 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index cd17c9010..ebae30d5e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -879,7 +879,7 @@ Corrects uneven illumination (vignetting, falloff) from a copy-stand or scanner * **Profile**, with **+** and **trash** on the FLAT FIELD CORRECTION header: **+** bakes a reference image into a named profile in NegPy's `flatfield` folder, after which the image can be deleted. **Trash** asks first; every frame using the profile loses its correction. * **Apply Flat Field** (bulb toggle beside the dropdown): apply the selected profile to this roll, enabled once a profile exists. -* **Check Flat Field** (eye toggle): shows how well the selected profile corrects its own reference, from any frame and without the reference file. A good profile shows an even gray; a band or patch shows where it is off, and the stronger it is, the larger the error: full white or black is 10% or more. The status line gives the spread. A profile saved before this check shows the light it corrects instead; save it again to check it. Saving a profile warns when its reference is clipped or does not correct itself evenly. +* **Check Flat Field** (eye toggle): shows how well the selected profile corrects its own reference, from any frame and without the reference file. A good profile shows an even gray; a band or patch shows where it is off, and the stronger it is, the larger the error: full white or black is 10% or more. It sits the way the open frame does, turned and flipped with it but without fine rotation, keystone or crop, so the edges stay in view. The status line gives the spread. A profile saved before this check shows the light it corrects instead; save it again to check it. Saving a profile warns when its reference is clipped or does not correct itself evenly. A newly chosen profile becomes the rig's default for the next roll. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 692fed1b8..b33dd7bb3 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -7130,8 +7130,9 @@ def _paint_flatfield_peek(self) -> None: The profile's stored reference copy times its gain, each channel over its lit-area median, ±EVENNESS_RANGE as black..white, so a good profile shows gray. A profile saved - before the copy was stored shows the light its gain corrects instead. A sensor-layout - profile takes the open frame's EXIF orientation; the frame's own geometry is not applied. + before the copy was stored shows the light its gain corrects instead. It sits the way the + frame does: a sensor-layout profile takes the frame's EXIF orientation, then the frame's + quarter turns and flips. Fine rotation, keystone and crop are left out, so the edges show. """ import cv2 @@ -7161,6 +7162,14 @@ def _paint_flatfield_peek(self) -> None: ) if stored.sensor_layout: view = apply_exif_orientation(view, self.state.preview_orientation) + # GeometryProcessor's order. + geometry = self.state.config.geometry + view = np.rot90(view, k=geometry.rotation) + if geometry.flip_horizontal: + view = np.fliplr(view) + if geometry.flip_vertical: + view = np.flipud(view) + view = np.ascontiguousarray(view) h, w = view.shape[:2] scale = APP_CONFIG.preview_render_size / max(h, w) view = cv2.resize(view, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR) diff --git a/tests/test_controller.py b/tests/test_controller.py index 92e4805af..3a1029fd5 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -5450,6 +5450,30 @@ def test_it_turns_with_the_frame_orientation_unless_the_profile_is_still_as_bake h, w = self.controller.state.peek_frame["base_positive"].shape[:2] self.assertEqual(h > w, portrait) + def test_it_takes_the_frame_quarter_turns_and_flips(self): + import numpy as np + from dataclasses import replace + + from negpy.services.assets.flatfield import StoredCheck + + yy, xx = np.mgrid[0:32, 0:48].astype(np.float32) + lopsided = np.repeat((1.0 + 0.3 * xx / 48 + 0.1 * yy / 32)[..., None], 3, axis=2).astype(np.float32) + self._select(StoredCheck(lopsided, None, None)) + state = self.controller.state + + def view(**geometry): + state.flatfield_peek = False + settings = {"rotation": 0, "flip_horizontal": False, "flip_vertical": False, **geometry} + state.config = replace(state.config, geometry=replace(state.config.geometry, **settings)) + self.controller.toggle_flatfield_peek(force=True) + return state.peek_frame["base_positive"] + + upright = view() + h, w = view(rotation=1).shape[:2] + self.assertGreater(h, w) + np.testing.assert_allclose(view(flip_horizontal=True), np.fliplr(upright), atol=1e-4) + np.testing.assert_allclose(view(flip_vertical=True), np.flipud(upright), atol=1e-4) + def test_with_no_profile_it_says_so_and_stays_off(self): seen: list = [] self.controller.flatfield_peek_changed.connect(seen.append)