diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index b33dd7bb3..5f424d462 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -128,6 +128,7 @@ remap_workspace_config, remember_split_scans, saved_crop_rect, + slice_chain, split_scans, ) from negpy.services.export.templating import path_safe, render_export_filename @@ -3663,9 +3664,16 @@ 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, orientation=self.state.preview_orientation) - ) + if config.stitch.stitch_enabled or metadata_lens_corrections(config): + source = raw + else: + cuts = self._preview_gain_slices(self.active_diptych()) + source = apply_flatfield( + raw, + config.flatfield, + (lambda g: slice_chain(g, cuts)) if cuts else None, + orientation=self.state.preview_orientation, + ) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: fit = squared_geometry(source, geo, config.process.process_mode) diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index 91b76ab4a..e35e3d607 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -1627,9 +1627,18 @@ def _frame_evidence( return None, None config = frame.config - corrected = ( - raw if metadata_lens_corrections(config) else apply_flatfield(raw, config.flatfield, orientation=meta.get("orientation", 1)) - ) + if config.stitch.stitch_enabled or metadata_lens_corrections(config): + corrected = raw + else: + from negpy.services.assets.half_frame import slice_chain + + cuts = gain_slices_for_asset(file_info) + corrected = apply_flatfield( + raw, + config.flatfield, + (lambda g: slice_chain(g, cuts)) if cuts else None, + orientation=meta.get("orientation", 1), + ) geometry = config.geometry keystone = None if squares_on_arm(geometry): diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py index 7cc127bb5..64b2a5c6b 100644 --- a/tests/test_batch_autocrop_worker.py +++ b/tests/test_batch_autocrop_worker.py @@ -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, orientation=1): + def _apply_flatfield(image, received_config, gain_crop=None, orientation=1): captured["flatfield_config"] = received_config + captured["gain_crop"] = gain_crop captured["orientation"] = orientation return image + 1.0 @@ -189,6 +190,7 @@ def _detect(key, transformed, *, target_ratio, rebate_trim=1.0): assert captured["flatfield_config"] == flatfield assert captured["orientation"] == 6 + assert captured["gain_crop"] is None assert captured["geometry"].crop_rect is None assert captured["geometry"].crop_from_auto is False assert captured["geometry"].autocrop_offset == 0 @@ -204,6 +206,36 @@ def _detect(key, transformed, *, target_ratio, rebate_trim=1.0): assert captured["context"].scale_factor == 1.0 +def _flatfield_config() -> WorkspaceConfig: + base = WorkspaceConfig() + return replace(base, flatfield=replace(base.flatfield, apply=True, profile_id="rig-1")) + + +def test_batch_autocrop_cuts_the_flatfield_gain_to_the_half(qapp, monkeypatch) -> None: + _stub_detection(monkeypatch) + crops: list = [] + monkeypatch.setattr(render_workers, "apply_flatfield", lambda image, _config, gain_crop=None, **_kw: crops.append(gain_crop) or image) + frame = _input("frame", _flatfield_config()) + frame.file_info.update(half=2, split_x=0.5, gutter_thickness=0.0) + + BatchAutoCropWorker(_PreviewService()).process(_task(frame)) + + assert crops[0] is not None + assert crops[0](np.zeros((8, 12, 3), dtype=np.float32)).shape == (8, 6, 3) + + +def test_batch_autocrop_skips_the_flatfield_on_a_stitch(qapp, monkeypatch) -> None: + _stub_detection(monkeypatch) + calls: list = [] + monkeypatch.setattr(render_workers, "apply_flatfield", lambda image, *_args, **_kwargs: calls.append(1) or image) + config = _flatfield_config() + config = replace(config, stitch=replace(config.stitch, stitch_enabled=True)) + + BatchAutoCropWorker(_PreviewService()).process(_task(_input("frame", config))) + + assert calls == [] + + def test_batch_autocrop_per_file_failure_does_not_abort_roll(qapp, monkeypatch) -> None: base = WorkspaceConfig() diff --git a/tests/test_controller.py b/tests/test_controller.py index 3a1029fd5..84075a68b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -218,6 +218,54 @@ def test_auto_skew_measures_the_flat_fielded_source(self): bake.assert_called_once() self.assertEqual(float(fit.call_args.args[0].mean()), 0.5) + def test_auto_skew_cuts_the_flatfield_gain_to_the_active_half(self): + self.controller.state.preview_raw = np.zeros((100, 60, 3), dtype=np.float32) + self.controller.request_render = MagicMock() + self.controller.set_status = MagicMock() + half = (2, 0.5, None, 0.0, "x") + + with ( + patch.object(self.controller, "active_diptych", return_value=None), + patch.object(self.controller, "_active_half", return_value=half), + patch("negpy.desktop.controller.apply_flatfield", side_effect=lambda image, *_, **__: image) as bake, + patch("negpy.features.geometry.skew.trusted_frame_skew", return_value=None), + ): + self.controller.auto_skew_frame() + + gain_crop = bake.call_args.args[2] + self.assertEqual(gain_crop(np.zeros((100, 120, 3), dtype=np.float32)).shape, (100, 60, 3)) + + def test_auto_skew_cuts_the_flatfield_gain_to_the_diptych_crop(self): + self.controller.state.preview_raw = np.zeros((100, 60, 3), dtype=np.float32) + self.controller.request_render = MagicMock() + self.controller.set_status = MagicMock() + dip = ({"split_x": 0.5, "crop_rect": (0.0, 0.0, 0.5, 1.0), "gutter_thickness": 0.0, "split_axis": "x"}, None) + + with ( + patch.object(self.controller, "active_diptych", return_value=dip), + patch("negpy.desktop.controller.apply_flatfield", side_effect=lambda image, *_, **__: image) as bake, + patch("negpy.features.geometry.skew.trusted_frame_skew", return_value=None), + ): + self.controller.auto_skew_frame() + + gain_crop = bake.call_args.args[2] + self.assertEqual(gain_crop(np.zeros((100, 120, 3), dtype=np.float32)).shape, (100, 60, 3)) + + def test_auto_skew_skips_the_flatfield_on_a_stitch(self): + config = self.controller.state.config + self.controller.state.config = replace(config, stitch=replace(config.stitch, stitch_enabled=True)) + self.controller.state.preview_raw = np.zeros((100, 120, 3), dtype=np.float32) + self.controller.request_render = MagicMock() + self.controller.set_status = MagicMock() + + with ( + patch("negpy.desktop.controller.apply_flatfield") as bake, + patch("negpy.features.geometry.skew.trusted_frame_skew", return_value=None), + ): + self.controller.auto_skew_frame() + + bake.assert_not_called() + def test_auto_skew_failure_reports_and_changes_nothing(self): self.controller.state.preview_raw = np.zeros((100, 120, 3), dtype=np.float32) self.controller.request_render = MagicMock()