diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index adccd1eaa..3ec06ac94 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -11,6 +11,8 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Lens distortion**: a radial $k_1$ coefficient, a rig property mirrored from the active flat-field profile (`flatfield.k1`), corrected in the same resample. * **Autocrop**: we detect where the film ends and the scanner bed begins by looking for the density jump. It is not perfect, because light leaks and odd scanning holders can fool it, so there is a manual override. + Detection runs **once per edit**, in `ImageProcessor` ahead of either engine, and the rect it finds is stored on the edit (`geometry.crop_rect`, with `crop_from_auto` marking where it came from). Neither engine detects anything: they slice the stored rect. This is not an optimization. The border walk reads whatever buffer it is handed, and a 1600 px preview and a full-resolution export are not the same pixels — on a borderless frame they can stop on different edges, which used to export a crop the user had never seen. A rect carries the detection key it was found under (`autocrop_detection_key`: orientation, ratio, mode, rebate trim), so changing any of those re-detects on the next render, while Crop Offset, which is re-applied to the rect every render, does not. + A detected film box is checked against its surround, which has to read either as bed (uniform and *near-clipping*, where the luma anchor puts the light source; merely bright is not enough, since a thin negative's own highlights clear that bar) or as a dark holder. If neither holds, the box is rejected and detection falls back to the full frame, which the border refinement below then trims. The crop is then squared to the Ratio setting. On `Free`, the default, the ratio comes from the frame that was just found, snapped to the nearest real film format, so a 6x6 stays square. That snap is sanity-checked against the sensor's own dimensions, but only where the detected box is the *longer* of the two, since a box stretched by a bad detection runs past the frame and is never rounder than it. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8d09f2abb..4aecb1b49 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -412,7 +412,7 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Ratio** (default `Free`): target aspect ratio: `Free`, `1:1`, `3:2`, `4:3`, `5:4`, `6:7`, `7:5`, `65:24`, `16:9`, `16:10`, `11:8.5`. There is one entry per shape, because the crop tool auto-orients to portrait or landscape as you drag. On `Free` the crop tool is unconstrained, and auto-crop takes the ratio from the film format it detects, so 6x6, 645, 6x7 and 35mm each keep their own shape. Pick a ratio to force every frame to it instead. * **Detect** (crosshairs): snap the ratio to the closest standard. -* **Crop** tool: draw a crop rectangle on the canvas. **Reset** clears it and turns auto-crop off. +* **Crop** tool: draw a crop rectangle on the canvas. It opens on whatever crop is already set, including one **Auto** found, so an auto crop can be nudged rather than redrawn. Adjusting it by hand makes the crop yours: nothing re-detects over it afterwards. **Reset** clears it and turns auto-crop off. * **Guide**: overlay a composition guide while cropping: *Thirds*, *Phi Grid*, *Diagonals*, *Golden Triangles*, *Golden Spiral*, *Armature*, *Diagonal Method*, *Grid* or *Off*. The redo button rotates guides that have orientations; the spiral has 8, the triangles 2. **Auto Crop**, to detect the frame edge automatically: @@ -420,7 +420,7 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Mode**: *Image only* (exposed area) or *Film edge* (full film, including rebate and sprockets). * **Crop Offset** (-5 to 100 px): inset the detected edge inward. Positive trims more; negative bleeds slightly outside, for when detection clips too tightly. * **Rebate Trim** (0 to 150%): how far into the detected rebate to cut. 0% stops at the film edge, 100% lands on the detected image edge, and above 100% bites into the picture to clear a stubborn white border. *Image only* mode; it applies to both **Auto** and **Batch Autocrop**. -* **Auto**: detect and crop this frame. Best on clean rebate. +* **Auto**: detect and crop this frame. Best on clean rebate. Detection runs once and the crop it finds is stored, so the export is framed exactly like the preview. Changing **Mode**, **Ratio**, **Rebate Trim** or the orientation re-detects; **Crop Offset** adjusts the stored crop without re-detecting. * **Batch Autocrop**: analyze all visible landscape frames as a roll, using confident detections to calibrate weaker ones. It runs in the background with progress and cancellation. Manual, Film-edge, portrait and ambiguous frames are left alone. *Image only* mode only. **Alignment:** diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 59cb7f17a..5ef7d8410 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -87,7 +87,13 @@ from negpy.features.altprocess.models import AltProcess from negpy.features.exposure.models import ExposureConfig from negpy.features.finish.models import FinishConfig -from negpy.features.geometry.logic import apply_fine_rotation, detect_closest_aspect_ratio, enforce_roi_aspect_ratio +from negpy.features.geometry.logic import ( + apply_fine_rotation, + autocrop_detection_key, + detect_closest_aspect_ratio, + enforce_roi_aspect_ratio, + has_manual_crop, +) from negpy.features.geometry.models import FINE_ROTATION_LIMIT, AutocropMode from negpy.features.lab.models import LabConfig from negpy.features.local.models import LocalAdjustmentsConfig @@ -2158,15 +2164,17 @@ def handle_crop_rect_changed(self, nx1: float, ny1: float, nx2: float, ny2: floa continuous adjustment, not a one-shot drag-then-close.""" if self.state.active_tool != ToolMode.CROP_MANUAL: return + # Dragging the handles takes ownership of the rect, auto-detected or not: nothing + # re-arms detection over an edit the user has framed by hand. new_geo = replace( self.state.config.geometry, - manual_crop_rect=( + crop_rect=( min(nx1, nx2), min(ny1, ny2), max(nx1, nx2), max(ny1, ny2), ), - auto_crop_enabled=False, + crop_from_auto=False, ) # Defer the bounds recompute to crop-tool close. Clearing here re-normalizes on # every drag step. @@ -2236,7 +2244,10 @@ def set_crop_ratio(self, ratio: str) -> None: return new_geo = replace(geom, autocrop_ratio=ratio) - rect = geom.manual_crop_rect + # An auto rect is left alone: the ratio is part of its detection key, so the next + # render re-detects under the new one. The frame Auto finds at 5:4 is not the 3:2 + # frame shrunk to fit. + rect = None if geom.crop_from_auto else geom.crop_rect img = self.state.preview_raw if rect is not None and img is not None: h, w = img.shape[:2] @@ -2245,7 +2256,7 @@ def set_crop_ratio(self, ratio: str) -> None: nx1, ny1, nx2, ny2 = rect roi_px = (round(ny1 * h), round(ny2 * h), round(nx1 * w), round(nx2 * w)) y1, y2, x1, x2 = enforce_roi_aspect_ratio(roi_px, h, w, ratio) - new_geo = replace(new_geo, manual_crop_rect=(x1 / w, y1 / h, x2 / w, y2 / h)) + new_geo = replace(new_geo, crop_rect=(x1 / w, y1 / h, x2 / w, y2 / h)) self.session.update_config(replace(self.state.config, geometry=new_geo), persist=True) # Same spinner treatment as reset_crop/apply_auto_crop: the base stage re-runs, @@ -2289,7 +2300,7 @@ def reset_crop(self) -> None: self.session.update_config( replace( self.state.config, - geometry=replace(self.state.config.geometry, manual_crop_rect=None, auto_crop_enabled=False), + geometry=replace(self.state.config.geometry, crop_rect=None, crop_from_auto=False), process=new_proc, ) ) @@ -2299,6 +2310,10 @@ def reset_crop(self) -> None: self.request_render() def apply_auto_crop(self) -> None: + """Arm Auto Crop: clear the rect and let the next render detect one. + + The render reports the rect it found and _on_render_finished freezes it into the + edit, so the crop the user is looking at is the crop that gets exported.""" # Autocrop supersedes a manual crop in progress: leave the tool. if self.state.active_tool == ToolMode.CROP_MANUAL: self.state.active_tool = ToolMode.NONE @@ -2310,8 +2325,8 @@ def apply_auto_crop(self) -> None: self.state.config, geometry=replace( self.state.config.geometry, - manual_crop_rect=None, - auto_crop_enabled=True, + crop_rect=None, + crop_from_auto=True, ), process=new_proc, ) @@ -2340,7 +2355,7 @@ def request_batch_auto_crop(self) -> None: preflight_skipped = 0 for asset in visible_files: config = self._config_for_autocrop_asset(asset) - if config.geometry.manual_crop_rect is not None or config.geometry.autocrop_mode != AutocropMode.IMAGE: + if has_manual_crop(config.geometry) or config.geometry.autocrop_mode != AutocropMode.IMAGE: preflight_skipped += 1 continue frames.append( @@ -2393,14 +2408,14 @@ def _on_batch_autocrop_finished(self, results: list[BatchAutoCropResult]) -> Non asset = result.file_info try: latest = self._config_for_autocrop_asset(asset) - if latest.geometry.manual_crop_rect is not None: + if has_manual_crop(latest.geometry): conflicted += 1 continue if _autocrop_fingerprint(latest, self.state.workspace_color_space) != result.fingerprint: conflicted += 1 continue - rect = result.manual_crop_rect + rect = result.crop_rect if len(rect) != 4 or not (0.0 <= rect[0] < rect[2] <= 1.0 and 0.0 <= rect[1] < rect[3] <= 1.0): conflicted += 1 continue @@ -2411,8 +2426,8 @@ def _on_batch_autocrop_finished(self, results: list[BatchAutoCropResult]) -> Non new_geometry = replace( latest.geometry, - manual_crop_rect=tuple(float(value) for value in rect), - auto_crop_enabled=False, + crop_rect=tuple(float(value) for value in rect), + crop_from_auto=False, fine_rotation=float(fine_rotation), ) new_process = replace(latest.process, **invalidate_local_bounds(latest.process)) @@ -2500,7 +2515,7 @@ def detect_aspect_ratio(self) -> None: # Emit manually so UI syncs (combo dropdown updates), but without triggering # a render via the state_changed debounce. self.config_updated.emit() - if geom.auto_crop_enabled: + if geom.crop_from_auto: self.request_render() def save_current_edits(self) -> None: @@ -2851,7 +2866,7 @@ def request_batch_normalization(self) -> None: cropped = 0 for f in visible_files: p = self.session.repo.load_file_settings(f["hash"]) - if p and (p.geometry.manual_crop_rect or p.geometry.auto_crop_enabled): + if p and (p.geometry.crop_rect or p.geometry.crop_from_auto): cropped += 1 if cropped == 0: @@ -4620,6 +4635,8 @@ def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: self.state.last_metrics.update(metrics) self.state.last_metrics["splash"] = False + self._freeze_resolved_auto_crop(metrics) + result = metrics.get("base_positive") memoizable = bool(metrics.get("memo_key")) and metrics.get("source_hash") == self.state.current_file_hash # The pool overwrites a GPU texture on the next frame, so only its identity is kept @@ -4652,6 +4669,31 @@ def _on_render_finished(self, _result: Any, metrics: Dict[str, Any]) -> None: self._dispatch_pending_render() + def _freeze_resolved_auto_crop(self, metrics: Dict[str, Any]) -> None: + """Store the crop this render detected, so nothing detects it a second time. + + Without this the crop is re-derived per render and a preview and its export can + disagree — the detector reads a 1600 px preview buffer and a full-res export, and + on a borderless frame those do not always find the same edge. + + No render is requested: the rect is exactly what was just painted. The key guards + the gap between the render starting and this landing — change the ratio mid-flight + and the result is dropped, because a render is already queued under the new one. + """ + rect = metrics.get("autocrop_resolved_rect") + if rect is None: + return + geom = self.state.config.geometry + if not geom.crop_from_auto or autocrop_detection_key(geom) != metrics.get("autocrop_resolved_key"): + return + if geom.crop_rect == rect and geom.crop_detect_key == metrics["autocrop_resolved_key"]: + return + new_geo = replace(geom, crop_rect=tuple(float(v) for v in rect), crop_detect_key=metrics["autocrop_resolved_key"]) + # record_history=False: this is the tail of the Auto press the user already made, + # not a second edit to undo past. + self.session.update_config(replace(self.state.config, geometry=new_geo), persist=True, render=False, record_history=False) + self.config_updated.emit() + def _dispatch_pending_render(self) -> None: """Start the render queued while the last one was running, if any.""" if self._pending_render_task: diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index e2b42660c..03db84a64 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -84,12 +84,14 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Hue Trim", "process", "hue_trim"), )), ("Crop", ( - _row("Auto Crop", "geometry", "auto_crop_enabled"), + _row("Auto Crop", "geometry", "crop_from_auto"), _row("Crop Offset", "geometry", "autocrop_offset"), _row("Rebate Trim", "geometry", "autocrop_rebate_trim"), _row("Crop Ratio", "geometry", "autocrop_ratio"), _row("Crop Mode", "geometry", "autocrop_mode"), - _row("Manual Crop", "geometry", "manual_crop_rect"), + # Rect and detection key copy atomically: the key is what tells a copied auto rect + # from one the target detected itself, so the rect alone would look freshly resolved. + _row("Crop", "geometry", "crop_rect", "crop_detect_key", fmt=lambda v: _fmt_scalar(v[0])), )), ("Rotation", ( _row("Rotation", "geometry", "rotation"), @@ -254,11 +256,12 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: "crosstalk_matrix", "sensor_profile", "sensor_matrix", - "auto_crop_enabled", + "crop_from_auto", "autocrop_offset", "autocrop_rebate_trim", "autocrop_mode", - "manual_crop_rect", + "crop_rect", + "crop_detect_key", } ) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 8f09b57b5..9c3b873a3 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -34,7 +34,7 @@ zone_region_labels, ) from negpy.features.exposure.densitometer import zone_roman -from negpy.features.geometry.logic import rotation_drag_angle, smooth_polyline, straighten_delta_degrees, translate_manual_crop_rect +from negpy.features.geometry.logic import rotation_drag_angle, smooth_polyline, straighten_delta_degrees, translate_normalized_rect from negpy.features.local.logic import min_points, outline_points, rasterise from negpy.features.local.models import MaskShape from negpy.features.retouch.models import HEAL_SIZE_REF @@ -373,7 +373,7 @@ def _hide_rotation_grid(self) -> None: def set_tool_mode(self, mode: ToolMode) -> None: self._tool_mode = mode if mode == ToolMode.CROP_MANUAL: - self._crop_rect_norm = self.state.config.geometry.manual_crop_rect + self._crop_rect_norm = self.state.config.geometry.crop_rect else: self._crop_rect_norm = None self._end_crop_drag() @@ -478,7 +478,7 @@ def update_buffer( self._current_size = gpu_size if self._tool_mode == ToolMode.CROP_MANUAL and self._crop_drag_mode is None: - self._crop_rect_norm = self.state.config.geometry.manual_crop_rect + self._crop_rect_norm = self.state.config.geometry.crop_rect if self._tool_mode == ToolMode.ANALYSIS_DRAW and self._analysis_drag_mode is None: self._analysis_rect_norm = self.state.config.process.analysis_rect @@ -2149,7 +2149,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: curr_norm = self._screen_to_norm(event.position()) dx = curr_norm[0] - self._analysis_press_norm[0] dy = curr_norm[1] - self._analysis_press_norm[1] - new_rect = translate_manual_crop_rect(self._analysis_orig_rect, dx, dy) + new_rect = translate_normalized_rect(self._analysis_orig_rect, dx, dy) if any(abs(a - b) > 5e-4 for a, b in zip(new_rect, self._analysis_rect_norm or new_rect)): self._analysis_rect_norm = new_rect self.analysis_rect_changed.emit(*new_rect, False) @@ -2201,7 +2201,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: sensitivity = 0.5 if fine else 1.0 dx = (curr_norm[0] - self._crop_press_norm[0]) * sensitivity dy = (curr_norm[1] - self._crop_press_norm[1]) * sensitivity - new_rect = translate_manual_crop_rect(self._crop_orig_rect, dx, dy) + new_rect = translate_normalized_rect(self._crop_orig_rect, dx, dy) if any(abs(a - b) > 5e-4 for a, b in zip(new_rect, self._crop_rect_norm or new_rect)): self._crop_rect_norm = new_rect self.crop_rect_changed.emit(*new_rect, False) diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 36f3d0e27..9b6f8c4e4 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -651,8 +651,8 @@ def rotate(self, direction: int) -> None: new_geo = replace(geo, rotation=new_rot) # Rotate the manual crop rect with the content so it keeps framing the same area. Without # this it stayed put and misaligned after a quarter or half turn. - if geo.manual_crop_rect is not None: - new_geo = replace(new_geo, manual_crop_rect=rotate_normalized_rect(geo.manual_crop_rect, visual_turns_ccw)) + if geo.crop_rect is not None: + new_geo = replace(new_geo, crop_rect=rotate_normalized_rect(geo.crop_rect, visual_turns_ccw)) new_config = replace(config, geometry=new_geo) # The freehand analysis region is display-space too; rotate it alongside. if config.process.analysis_rect is not None: diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index a77358fa9..1102864eb 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -836,8 +836,8 @@ def _sync_modified_dots(self) -> None: geo.fine_rotation != _geo.fine_rotation, geo.flip_horizontal != _geo.flip_horizontal, geo.flip_vertical != _geo.flip_vertical, - geo.auto_crop_enabled != _geo.auto_crop_enabled, - geo.manual_crop_rect is not None, + geo.crop_from_auto != _geo.crop_from_auto, + geo.crop_rect is not None, geo.autocrop_ratio != _geo.autocrop_ratio, geo.autocrop_mode != _geo.autocrop_mode, geo.autocrop_offset != _geo.autocrop_offset, diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index f5db5865d..55913535f 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -16,6 +16,7 @@ from negpy.desktop.view.styles.theme import THEME 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 @@ -271,9 +272,9 @@ def sync_ui(self) -> None: self.manual_crop_btn.setChecked(self.state.active_tool == ToolMode.CROP_MANUAL) self.straighten_btn.setChecked(self.state.active_tool == ToolMode.STRAIGHTEN) - self.reset_crop_btn.setChecked(conf.auto_crop_enabled) - self.manual_crop_btn.set_crop_active(conf.manual_crop_rect is not None) - self.reset_crop_btn.set_crop_active(conf.auto_crop_enabled) + self.reset_crop_btn.setChecked(conf.crop_from_auto) + self.manual_crop_btn.set_crop_active(has_manual_crop(conf)) + self.reset_crop_btn.set_crop_active(conf.crop_from_auto) self.auto_crop_all_btn.setEnabled(conf.autocrop_mode == AutocropMode.IMAGE) self.rebate_trim_slider.setEnabled(conf.autocrop_mode == AutocropMode.IMAGE) finally: diff --git a/negpy/desktop/view/widgets/scan_window_geometry.py b/negpy/desktop/view/widgets/scan_window_geometry.py index c7d3fcd02..aedf4ccbc 100644 --- a/negpy/desktop/view/widgets/scan_window_geometry.py +++ b/negpy/desktop/view/widgets/scan_window_geometry.py @@ -1,7 +1,7 @@ """Pure rect math for the scan-window picker — no Qt, unit-testable. Rects are normalized ``(x1, y1, x2, y2)`` in 0..1 (left, top, right, bottom), -matching the crop convention (``GeometryConfig.manual_crop_rect``). +matching the crop convention (``GeometryConfig.crop_rect``). """ Rect = tuple[float, float, float, float] diff --git a/negpy/desktop/view/widgets/scan_window_label.py b/negpy/desktop/view/widgets/scan_window_label.py index 9e9898ef6..03df28f2d 100644 --- a/negpy/desktop/view/widgets/scan_window_label.py +++ b/negpy/desktop/view/widgets/scan_window_label.py @@ -15,7 +15,7 @@ normalize_rect, resize_corner, ) -from negpy.features.geometry.logic import translate_manual_crop_rect +from negpy.features.geometry.logic import translate_normalized_rect _HANDLE_TOL = 0.03 # corner grab radius, fraction of frame _HANDLE_PX = 5 # drawn handle half-size, widget px @@ -155,7 +155,7 @@ def mouseMoveEvent(self, ev: QMouseEvent) -> None: elif self._mode == "resize" and self._rect is not None and self._active_corner is not None: self._rect = resize_corner(self._rect, self._active_corner, fx, fy) elif self._mode == "move" and self._rect_at_press is not None: - self._rect = translate_manual_crop_rect(self._rect_at_press, fx - self._press_frac[0], fy - self._press_frac[1]) + self._rect = translate_normalized_rect(self._rect_at_press, fx - self._press_frac[0], fy - self._press_frac[1]) self.update() def mouseReleaseEvent(self, _ev: QMouseEvent) -> None: diff --git a/negpy/desktop/workers/render.py b/negpy/desktop/workers/render.py index e40b91c89..4549bd780 100644 --- a/negpy/desktop/workers/render.py +++ b/negpy/desktop/workers/render.py @@ -141,7 +141,7 @@ class BatchAutoCropResult: file_info: dict fingerprint: tuple - manual_crop_rect: tuple[float, float, float, float] + crop_rect: tuple[float, float, float, float] correction_angle: float confidence: float calibrated: bool @@ -1000,8 +1000,8 @@ def process(self, task: BatchAutoCropTask) -> None: return detection_geometry = replace( config.geometry, - manual_crop_rect=None, - auto_crop_enabled=False, + crop_rect=None, + crop_from_auto=False, autocrop_offset=0, ) context = PipelineContext( @@ -1047,7 +1047,7 @@ def process(self, task: BatchAutoCropTask) -> None: BatchAutoCropResult( file_info=source.file_info, fingerprint=source.fingerprint, - manual_crop_rect=crop.manual_crop_rect, + crop_rect=crop.crop_rect, correction_angle=crop.correction_angle, confidence=crop.confidence, calibrated=crop.calibrated, diff --git a/negpy/domain/migrations.py b/negpy/domain/migrations.py index e29d2715b..d9daf1401 100644 --- a/negpy/domain/migrations.py +++ b/negpy/domain/migrations.py @@ -35,6 +35,11 @@ "density_saturation_trim_green": "dye_separation_trim_green", "density_saturation_trim_blue": "dye_separation_trim_blue", "use_colour_average": "use_color_average", + # Auto and manual crops converged on one rect: auto_crop_enabled now means "this rect + # came from Auto", and an old save carrying True with no rect lands in the armed state, + # so it resolves on the next render exactly as it used to detect on every render. + "manual_crop_rect": "crop_rect", + "auto_crop_enabled": "crop_from_auto", } # Fields removed over time. Old saves still carry them, so drop them silently and diff --git a/negpy/features/geometry/batch_autocrop.py b/negpy/features/geometry/batch_autocrop.py index 1ce6a21f3..6755902dd 100644 --- a/negpy/features/geometry/batch_autocrop.py +++ b/negpy/features/geometry/batch_autocrop.py @@ -106,10 +106,10 @@ class RollCropTemplate: @dataclass(frozen=True) class ResolvedCrop: - """Explicit manual-crop payload ready for controller-side conflict checks.""" + """Explicit crop payload ready for controller-side conflict checks.""" key: str - manual_crop_rect: tuple[float, float, float, float] + crop_rect: tuple[float, float, float, float] correction_angle: float confidence: float calibrated: bool @@ -731,7 +731,7 @@ def resolve_roll_crops( results.append( ResolvedCrop( key=item.key, - manual_crop_rect=tuple(float(np.clip(v, 0.0, 1.0)) for v in manual_rect), + crop_rect=tuple(float(np.clip(v, 0.0, 1.0)) for v in manual_rect), correction_angle=angle, confidence=float(np.clip(confidence, 0.0, 1.0)), calibrated=calibrated, diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 182f594cb..3d70452c3 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1990,6 +1990,87 @@ def get_autocrop_coords( return _enforce_ratio_by_occupancy(roi, h, w, ratio_str, row_occ, col_occ, det_scale) +def has_manual_crop(geometry: GeometryConfig) -> bool: + """True when the crop was drawn or adjusted by hand, so Auto must not overwrite it.""" + return geometry.crop_rect is not None and not geometry.crop_from_auto + + +def autocrop_detection_key(geometry: GeometryConfig) -> str: + """ + Everything border detection reads, as one comparable string. + + A resolved auto crop stays valid only while this is unchanged. Crop Offset is absent + on purpose: it is applied to the stored rect on every render, so moving that slider + must not throw the detection away. + """ + return "|".join( + str(part) + for part in ( + geometry.rotation, + int(geometry.flip_horizontal), + int(geometry.flip_vertical), + round(geometry.fine_rotation, 4), + geometry.autocrop_ratio, + geometry.autocrop_mode, + round(geometry.autocrop_rebate_trim, 4), + ) + ) + + +def resolve_autocrop_rect( + img: ImageBuffer, + geometry: GeometryConfig, + preview_size: float, +) -> Optional[Tuple[float, float, float, float]]: + """ + Detects the auto crop once and returns it as a normalized rect in transformed-image + space — the space GeometryConfig.crop_rect is stored in, and the space the canvas + overlay draws in. + + The one place border detection is reached from during a render. The caller freezes + the result into the config, so an edit is detected once and every later render of it, + at any resolution, crops identically. Detection runs on a copy normalized to + AUTOCROP_DETECT_RES and replays the GeometryProcessor transform order + (rot90 -> flips -> fine rotation) on it. + + The rect excludes Crop Offset: that slider applies to any crop, auto or manual, and + get_manual_rect_coords adds it back on every render. Only the 2 px baseline margin + is baked in. Returns None when the buffer is too small to detect on. + """ + h, w = img.shape[:2] + det_s = min(1.0, AUTOCROP_DETECT_RES / max(h, w)) + if det_s < 1.0: + tmp = cv2.resize(img, (max(1, round(w * det_s)), max(1, round(h * det_s))), interpolation=cv2.INTER_AREA) + else: + tmp = img + if geometry.rotation != 0: + tmp = np.rot90(tmp, k=geometry.rotation) + if geometry.flip_horizontal: + tmp = np.fliplr(tmp) + if geometry.flip_vertical: + tmp = np.flipud(tmp) + tmp = np.ascontiguousarray(tmp.astype(np.float32, copy=False)) + if geometry.fine_rotation != 0.0: + tmp = apply_fine_rotation(tmp, geometry.fine_rotation) + + rh, rw = tmp.shape[:2] + if rh < 2 or rw < 2: + return None + y1, y2, x1, x2 = get_autocrop_coords( + tmp, + offset_px=0, + # The margin is in detection pixels, so it scales by the detection buffer's own + # size, not the source's. + scale_factor=max(rh, rw) / float(preview_size), + target_ratio_str=geometry.autocrop_ratio, + mode=geometry.autocrop_mode, + rebate_trim=geometry.autocrop_rebate_trim, + ) + if y2 - y1 < 2 or x2 - x1 < 2: + return None + return (x1 / rw, y1 / rh, x2 / rw, y2 / rh) + + def map_coords_to_geometry( nx: float, ny: float, @@ -2079,7 +2160,7 @@ def smooth_polyline( return out -def translate_manual_crop_rect( +def translate_normalized_rect( rect: Tuple[float, float, float, float], dx: float, dy: float, @@ -2138,8 +2219,8 @@ def toggle_flip(geo: GeometryConfig, horizontal: bool) -> GeometryConfig: CURRENTLY rendered image. The pipeline applies flips BEFORE fine rotation, and mirror(rotate(+a, img)) == rotate(-a, mirror(img)) — so each single mirror must negate the fine-rotation angle, or toggling a flip visibly - changes the horizon (the tilt doubles instead of mirroring). The manual - crop rect lives in transformed space and mirrors along with the content + changes the horizon (the tilt doubles instead of mirroring). The crop + rect lives in transformed space and mirrors along with the content it frames. """ if horizontal: @@ -2148,8 +2229,8 @@ def toggle_flip(geo: GeometryConfig, horizontal: bool) -> GeometryConfig: new_geo = replace(geo, flip_vertical=not geo.flip_vertical) if geo.fine_rotation != 0.0: new_geo = replace(new_geo, fine_rotation=-geo.fine_rotation) - if geo.manual_crop_rect is not None: - new_geo = replace(new_geo, manual_crop_rect=mirror_normalized_rect(geo.manual_crop_rect, horizontal)) + if geo.crop_rect is not None: + new_geo = replace(new_geo, crop_rect=mirror_normalized_rect(geo.crop_rect, horizontal)) return new_geo diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index dc5c013bd..3758b868d 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -106,8 +106,6 @@ class GeometryConfig: fine_rotation: float = 0.0 flip_horizontal: bool = False flip_vertical: bool = False - auto_crop_enabled: bool = False - autocrop_offset: int = 0 # Free, not 3:2: autocrop reads the film format off the detected frame, so the # default fits 6x6, 645 and 6x7 as well as 35mm. A fixed 3:2 center-cropped every @@ -117,14 +115,35 @@ class GeometryConfig: # Fraction of the detected rebate to cut: 0.0 = film edge, 1.0 = image edge, above # 1.0 bites into the picture. Image mode only. autocrop_rebate_trim: float = 1.0 - manual_crop_rect: Optional[Tuple[float, float, float, float]] = None + + # The crop, always one normalized rect in transformed-image space, whoever drew it. + # `crop_from_auto` records where it came from and doubles as Auto Crop's armed state: + # + # None + False no crop + # None + True Auto Crop on, not yet resolved — the next render detects the rect + # and the controller freezes it here + # rect + True a resolved auto crop + # rect + False a manual crop (or an auto crop the user has since dragged) + # + # Detection never runs twice on the same edit, so preview and export cannot disagree + # about where the frame is. Anything detection depends on (ratio, mode, rebate trim, + # orientation) re-arms by clearing the rect, and the crop handles take ownership by + # clearing the flag. + crop_rect: Optional[Tuple[float, float, float, float]] = None + crop_from_auto: bool = False + # What the auto rect was detected under (autocrop_detection_key). Re-arming is a + # comparison, not something every control that feeds detection has to remember to do: + # change the ratio, the mode, the rebate trim or the orientation and the key stops + # matching, so the next render re-detects. Empty for a manual rect, which nothing + # invalidates. + crop_detect_key: str = "" 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.manual_crop_rect is not None: - object.__setattr__(self, "manual_crop_rect", tuple(self.manual_crop_rect)) + 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): object.__setattr__(self, "autocrop_mode", AutocropMode.IMAGE) try: diff --git a/negpy/features/geometry/processor.py b/negpy/features/geometry/processor.py index 7b15a436a..d66dcdefd 100644 --- a/negpy/features/geometry/processor.py +++ b/negpy/features/geometry/processor.py @@ -6,7 +6,6 @@ apply_fine_rotation, apply_margin_to_roi, apply_radial_distortion, - get_autocrop_coords, get_manual_rect_coords, ) @@ -47,24 +46,17 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: # Downstream coordinate mappers (retouch/local) need the same correction. context.metrics["distortion_k1"] = self.distortion_k1 - if self.config.manual_crop_rect: + # One crop source, whether the rect was drawn or detected: border detection runs + # before the engines (ImageProcessor resolves it into crop_rect) and never here, + # so a preview and an export of the same edit cannot land on different boxes. + if self.config.crop_rect: roi = get_manual_rect_coords( img, - self.config.manual_crop_rect, + self.config.crop_rect, offset_px=self.config.autocrop_offset, scale_factor=context.scale_factor, ) context.active_roi = roi - elif self.config.auto_crop_enabled: - roi = get_autocrop_coords( - img, - offset_px=self.config.autocrop_offset, - scale_factor=context.scale_factor, - target_ratio_str=self.config.autocrop_ratio, - mode=self.config.autocrop_mode, - rebate_trim=self.config.autocrop_rebate_trim, - ) - context.active_roi = roi elif self.config.autocrop_offset > 0: h_img, w_img = img.shape[:2] margin = self.config.autocrop_offset * context.scale_factor diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index 40785d593..afad4947a 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -93,8 +93,8 @@ def process( current_img = img - if settings.geometry.manual_crop_rect: - logger.debug(f"Engine process with manual_crop_rect: {settings.geometry.manual_crop_rect}") + if settings.geometry.crop_rect: + logger.debug(f"Engine process with crop_rect: {settings.geometry.crop_rect}") # Folded into the base stage like fine_rotation, not into source_hash, so the slider # re-renders without re-decoding the RAW. @@ -105,7 +105,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: return NormalizationProcessor(settings.process).process(img_in, ctx) # While the crop tool shows the full uncropped frame, the crop-selection fields - # (manual_crop_rect, auto_crop_*) only feed context.active_roi, which is itself unused + # (crop_rect, autocrop_offset) only feed context.active_roi, which is itself unused # for output in that mode, since CropProcessor and uv_grid ROI slicing are both bypassed. # Keying on them would force a full base, exposure, clahe, lab and local recompute on # every crop-rect drag step. diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index a2a27c399..7721c0f52 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -31,12 +31,10 @@ resolve_bounds_detailed, ) from negpy.features.geometry.logic import ( - AUTOCROP_DETECT_RES, apply_fine_rotation, apply_margin_to_roi, apply_radial_distortion, compute_distortion_scale, - get_autocrop_coords, get_manual_rect_coords, ) from negpy.features.lab.logic import gaussian_kernel_1d, rl_iterations @@ -89,47 +87,6 @@ _METRICS_ZEROS = np.zeros(METRICS_BUFFER_SIZE // 4, dtype=np.uint32) -def _detect_autocrop_roi(img: np.ndarray, settings: WorkspaceConfig, h_rot: int, w_rot: int) -> Tuple[int, int, int, int]: - """ - Computes the autocrop ROI on a detection-resolution copy, mirroring the CPU - GeometryProcessor transform order (rot90 -> flips -> fine rotation), and - returns it scaled to full post-rotation resolution (h_rot, w_rot). - """ - h, w = img.shape[:2] - det_s = min(1.0, AUTOCROP_DETECT_RES / max(h, w)) - if det_s < 1.0: - tmp = cv2.resize(img, (max(1, round(w * det_s)), max(1, round(h * det_s))), interpolation=cv2.INTER_AREA) - else: - tmp = img - if settings.geometry.rotation != 0: - tmp = np.rot90(tmp, k=settings.geometry.rotation) - if settings.geometry.flip_horizontal: - tmp = np.fliplr(tmp) - if settings.geometry.flip_vertical: - tmp = np.flipud(tmp) - tmp = np.ascontiguousarray(tmp.astype(np.float32, copy=False)) - if settings.geometry.fine_rotation != 0.0: - tmp = apply_fine_rotation(tmp, settings.geometry.fine_rotation) - roi_tmp = get_autocrop_coords( - tmp, - offset_px=settings.geometry.autocrop_offset, - # Margin parity with CPU: (2+offset)*L/preview_size in det coords, upscaled - # by full/L below, equals the CPU path's (2+offset)*context.scale_factor. - scale_factor=max(tmp.shape[:2]) / APP_CONFIG.preview_render_size, - target_ratio_str=settings.geometry.autocrop_ratio, - mode=settings.geometry.autocrop_mode, - rebate_trim=settings.geometry.autocrop_rebate_trim, - ) - rh, rw = tmp.shape[:2] - sy, sx = h_rot / rh, w_rot / rw - return ( - int(roi_tmp[0] * sy), - int(roi_tmp[1] * sy), - int(roi_tmp[2] * sx), - int(roi_tmp[3] * sx), - ) - - def _downsample_for_analysis(img: np.ndarray, max_size: int) -> np.ndarray: h, w = img.shape[:2] scale = min(1.0, max_size / max(h, w)) @@ -292,8 +249,6 @@ def __init__(self) -> None: # (key, grid): a pure function of geometry, reused across settled frames self._uv_grid_cache: Optional[Tuple[Tuple, np.ndarray]] = None - # (key, roi): autocrop detection, likewise geometry-only - self._autocrop_cache: Optional[Tuple[Tuple, Tuple[int, int, int, int]]] = None # Identity of the dodge/burn EV map currently sitting in the local_ev texture. self._local_ev_key: Optional[Tuple] = None @@ -350,36 +305,6 @@ def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: flo return 9 # Nothing changed - def _cached_autocrop_roi( - self, img: np.ndarray, settings: WorkspaceConfig, h_rot: int, w_rot: int, source_hash: Optional[str] - ) -> Tuple[int, int, int, int]: - """Autocrop detection is a CPU scan that no creative slider moves. - - Uncached without a source hash (export/tiled paths): the key could not tell - two different buffers apart. - """ - if source_hash is None: - return _detect_autocrop_roi(img, settings, h_rot, w_rot) - g = settings.geometry - key = ( - source_hash, - g.rotation, - g.flip_horizontal, - g.flip_vertical, - g.fine_rotation, - g.autocrop_offset, - g.autocrop_ratio, - g.autocrop_mode, - g.autocrop_rebate_trim, - h_rot, - w_rot, - ) - if self._autocrop_cache is not None and self._autocrop_cache[0] == key: - return self._autocrop_cache[1] - roi = _detect_autocrop_roi(img, settings, h_rot, w_rot) - self._autocrop_cache = (key, roi) - return roi - def _get_intermediate_texture(self, w: int, h: int, usage: int, label: str) -> GPUTexture: """Retrieves or creates a texture from the pool. @@ -540,15 +465,13 @@ def process_to_texture( # calls, this invariant must be re-checked. assert w_rot > 0 and h_rot > 0 actual_full_dims, orig_shape = (w_rot, h_rot), (h, w) - if settings.geometry.manual_crop_rect: + if settings.geometry.crop_rect: roi = get_manual_rect_coords( (h_rot, w_rot), - settings.geometry.manual_crop_rect, + settings.geometry.crop_rect, offset_px=settings.geometry.autocrop_offset, scale_factor=scale_factor, ) - elif settings.geometry.auto_crop_enabled: - roi = self._cached_autocrop_roi(img, settings, h_rot, w_rot, analysis_source_hash) elif settings.geometry.autocrop_offset > 0: margin = settings.geometry.autocrop_offset * scale_factor roi = apply_margin_to_roi((0, h_rot, 0, w_rot), h_rot, w_rot, margin) @@ -1956,15 +1879,13 @@ def _process_tiled( rot = settings.geometry.rotation % 4 w_rot, h_rot = (h, w) if rot in (1, 3) else (w, h) - if settings.geometry.manual_crop_rect: + if settings.geometry.crop_rect: roi = get_manual_rect_coords( (h_rot, w_rot), - settings.geometry.manual_crop_rect, + settings.geometry.crop_rect, offset_px=settings.geometry.autocrop_offset, scale_factor=scale_factor, ) - elif settings.geometry.auto_crop_enabled: - roi = _detect_autocrop_roi(img, settings, h_rot, w_rot) elif settings.geometry.autocrop_offset > 0: margin = settings.geometry.autocrop_offset * scale_factor roi = apply_margin_to_roi((0, h_rot, 0, w_rot), h_rot, w_rot, margin) diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index fe068538a..61499a247 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -26,6 +26,7 @@ from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix, sensor_token from negpy.features.exposure.models import RenderIntent from negpy.features.flatfield.logic import apply_flatfield, flatfield_token +from negpy.features.geometry.logic import autocrop_detection_key, resolve_autocrop_rect from negpy.features.retouch.logic import ( apply_hair_inpaint, apply_ir_attenuation, @@ -96,6 +97,32 @@ } +def _resolve_armed_autocrop( + img: np.ndarray, settings: WorkspaceConfig +) -> Tuple[WorkspaceConfig, Optional[Tuple[Tuple[float, float, float, float], str]]]: + """Turns an armed Auto Crop into a concrete rect, once, before either engine runs. + + Armed = crop_from_auto with either no rect yet (a fresh Auto press, or an edit saved + before the two crops converged on one field) or one detected under a stale key. Returns + the settings this render should use, and the (rect, key) worth freezing — None when + there was nothing to resolve. + + Detection lives here rather than inside the engines on purpose. Reached per render, it + ran on whatever buffer that render held — a 1600 px preview against a full-res export — + and the two disagreed about where the frame was. + """ + geom = settings.geometry + if not geom.crop_from_auto: + return settings, None + key = autocrop_detection_key(geom) + if geom.crop_rect is not None and geom.crop_detect_key == key: + return settings, None + rect = resolve_autocrop_rect(img, geom, APP_CONFIG.preview_render_size) + if rect is None: + return settings, None + return dc_replace(settings, geometry=dc_replace(geom, crop_rect=rect, crop_detect_key=key)), (rect, key) + + def _use_half_size_decode(raw: Any, linear_raw: bool) -> bool: """Mirrors the preview fast path (PreviewManager): rawpy half_size aliases the X-Trans 6x6 CFA on linear (no-camera-WB) decodes, so those stay full-res.""" @@ -490,6 +517,8 @@ def run_pipeline( scale_factor = max(h_orig, w_cols) / float(APP_CONFIG.preview_render_size) + settings, resolved_crop = _resolve_armed_autocrop(img, settings) + context = PipelineContext( scale_factor=scale_factor, original_size=(h_orig, w_cols), @@ -501,6 +530,12 @@ def run_pipeline( ) if metrics: context.metrics.update(metrics) + # The crop this render detected, for the controller to freeze into the edit. Only + # present on the render that resolved it; every later one reads the stored rect. The + # key rides along so a freeze that lands after the user has moved on is discarded. + if resolved_crop is not None: + context.metrics["autocrop_resolved_rect"] = resolved_crop[0] + context.metrics["autocrop_resolved_key"] = resolved_crop[1] # Display-overlay data: the detection-scale set that was repaired. Absent when # detection is off, so the overlay draws nothing. if detected_dust is not None: @@ -826,6 +861,11 @@ def _render_export_buffer( h_raw, w_raw = f32_buffer.shape[:2] export_scale = max(h_raw, w_raw) / float(APP_CONFIG.preview_render_size) + # Only reached by an edit never opened in the app (armed by copy-settings, or + # restored from an old sidecar). Anything previewed arrives with its rect already + # frozen, which is what keeps this export identical to what was on screen. + params, _ = _resolve_armed_autocrop(f32_buffer, params) + if self._is_flat(params): prefer_gpu = False @@ -1142,6 +1182,8 @@ def render_display_array( if hair_masks: f32_buffer = self._hair_inpaint(f32_buffer, hair_masks, detect_key + hair_bake_token(orig_ret)) + params, _ = _resolve_armed_autocrop(f32_buffer, params) + if self._is_flat(params): prefer_gpu = False diff --git a/tests/test_autocrop_resolution.py b/tests/test_autocrop_resolution.py new file mode 100644 index 000000000..03623bdfd --- /dev/null +++ b/tests/test_autocrop_resolution.py @@ -0,0 +1,125 @@ +"""Auto Crop resolves once and is then a stored rect. + +The bug these cover: Auto Crop used to re-detect inside every render, so a 1600 px +preview buffer and a full-resolution export ran the border walk on different pixels and +could land on different frames — the exported file was cropped somewhere the user never +saw. Detection now happens once, upstream of both engines, and is frozen into the config. +""" + +from dataclasses import replace + +import cv2 +import numpy as np + +from negpy.domain.interfaces import PipelineContext +from negpy.domain.models import WorkspaceConfig +from negpy.features.geometry.logic import autocrop_detection_key, has_manual_crop, resolve_autocrop_rect +from negpy.features.geometry.models import AutocropMode, GeometryConfig +from negpy.features.geometry.processor import GeometryProcessor +from negpy.services.rendering.image_processor import _resolve_armed_autocrop + + +def _frame_image(h: int, w: int) -> np.ndarray: + """A bright bed with a dark exposed frame inside it, at any resolution.""" + img = np.ones((h, w, 3), dtype=np.float32) + img[round(0.12 * h) : round(0.88 * h), round(0.10 * w) : round(0.90 * w)] = 0.05 + return img + + +def _armed(**geometry) -> WorkspaceConfig: + return replace( + WorkspaceConfig(), + geometry=GeometryConfig(crop_from_auto=True, autocrop_ratio="Free", **geometry), + ) + + +def _normalized_roi(img: np.ndarray, config: WorkspaceConfig, scale_factor: float) -> tuple: + h, w = img.shape[:2] + context = PipelineContext(scale_factor=scale_factor, original_size=(h, w)) + GeometryProcessor(config.geometry).process(img, context) + y1, y2, x1, x2 = context.active_roi + return (y1 / h, y2 / h, x1 / w, x2 / w) + + +def test_export_crops_exactly_where_the_preview_cropped(): + """The regression: resolve on a preview-sized buffer, then render full-res off the + same edit and get the same frame. The two buffers detect differently in isolation — + only one of them is ever asked to.""" + full = _frame_image(2400, 3600) + preview = cv2.resize(full, (1600, 1067), interpolation=cv2.INTER_AREA) + + previewed, resolved = _resolve_armed_autocrop(preview, _armed()) + assert resolved is not None + + # Export re-enters with the settings the preview stored, and finds nothing to resolve. + exported, again = _resolve_armed_autocrop(full, previewed) + assert again is None + assert exported.geometry.crop_rect == previewed.geometry.crop_rect + + preview_roi = _normalized_roi(preview, previewed, scale_factor=1.0) + export_roi = _normalized_roi(full, exported, scale_factor=2400 / 1600) + assert np.allclose(preview_roi, export_roi, atol=1e-3) + + +def test_resolving_is_idempotent(): + img = _frame_image(1200, 1800) + once, first = _resolve_armed_autocrop(img, _armed()) + assert first is not None + twice, second = _resolve_armed_autocrop(img, once) + assert second is None + assert twice is once + + +def test_resolved_rect_excludes_crop_offset(): + """Crop Offset is applied to the stored rect on every render, so baking it into the + rect as well would count it twice.""" + img = _frame_image(1200, 1800) + _, plain = _resolve_armed_autocrop(img, _armed()) + _, offset = _resolve_armed_autocrop(img, _armed(autocrop_offset=25)) + assert plain[0] == offset[0] + + +def test_offset_change_keeps_the_detected_rect(): + img = _frame_image(1200, 1800) + resolved, _ = _resolve_armed_autocrop(img, _armed()) + moved = replace(resolved, geometry=replace(resolved.geometry, autocrop_offset=12)) + _, again = _resolve_armed_autocrop(img, moved) + assert again is None + + +def test_detection_inputs_rearm_the_crop(): + img = _frame_image(1200, 1800) + resolved, _ = _resolve_armed_autocrop(img, _armed()) + for field, value in ( + ("autocrop_ratio", "5:4"), + ("autocrop_mode", AutocropMode.FILM), + ("autocrop_rebate_trim", 0.5), + ("rotation", 1), + ("flip_horizontal", True), + ("fine_rotation", 2.0), + ): + stale = replace(resolved, geometry=replace(resolved.geometry, **{field: value})) + _, redetected = _resolve_armed_autocrop(img, stale) + assert redetected is not None, f"{field} must re-arm the auto crop" + + +def test_manual_rect_is_never_resolved_over(): + img = _frame_image(1200, 1800) + manual = replace( + WorkspaceConfig(), + geometry=GeometryConfig(crop_rect=(0.2, 0.2, 0.8, 0.8), crop_from_auto=False), + ) + kept, resolved = _resolve_armed_autocrop(img, manual) + assert resolved is None + assert kept.geometry.crop_rect == (0.2, 0.2, 0.8, 0.8) + assert has_manual_crop(kept.geometry) + + +def test_detection_key_ignores_offset_only(): + base = GeometryConfig(crop_from_auto=True) + assert autocrop_detection_key(replace(base, autocrop_offset=40)) == autocrop_detection_key(base) + assert autocrop_detection_key(replace(base, autocrop_ratio="5:4")) != autocrop_detection_key(base) + + +def test_resolve_returns_none_on_a_degenerate_buffer(): + assert resolve_autocrop_rect(np.zeros((1, 1, 3), dtype=np.float32), GeometryConfig(), 1600) is None diff --git a/tests/test_batch_autocrop.py b/tests/test_batch_autocrop.py index baad31c57..2725e66a5 100644 --- a/tests/test_batch_autocrop.py +++ b/tests/test_batch_autocrop.py @@ -107,7 +107,7 @@ def test_short_detection_expands_to_roll_width_from_supported_left_edge() -> Non resolved = _resolved_by_key([*_trusted_roll(), short])["short"] - assert resolved.manual_crop_rect == pytest.approx((0.15, 0.1, 0.95, 0.9)) + assert resolved.crop_rect == pytest.approx((0.15, 0.1, 0.95, 0.9)) assert resolved.calibrated is True @@ -129,7 +129,7 @@ def test_weak_frame_resolves_from_profile_edges_near_roll_template() -> None: resolved = _resolved_by_key([*_trusted_roll(), weak])["weak-profile"] expected = _map_rect_between_rotations((0.1, 0.1, 0.9, 0.9), _LANDSCAPE_SHAPE, 0.0, 1.0) - assert resolved.manual_crop_rect == pytest.approx(expected, abs=6e-4) + assert resolved.crop_rect == pytest.approx(expected, abs=6e-4) assert resolved.correction_angle == pytest.approx(1.0) assert resolved.confidence == pytest.approx(0.55) assert resolved.calibrated is True @@ -285,7 +285,7 @@ def test_divergent_frame_maps_crop_before_using_roll_median_angle() -> None: resolved = _resolved_by_key([*_trusted_roll(), divergent])["divergent-angle"] expected = _map_rect_between_rotations((0.1, 0.1, 0.9, 0.9), _LANDSCAPE_SHAPE, 4.0, 1.0) - assert resolved.manual_crop_rect == pytest.approx(expected, abs=6e-4) + assert resolved.crop_rect == pytest.approx(expected, abs=6e-4) assert resolved.correction_angle == pytest.approx(1.0) assert resolved.calibrated is True @@ -310,7 +310,7 @@ def test_roll_templates_do_not_mix_different_target_ratios() -> None: resolved = _resolved_by_key([*three_two, four_three])["four-three"] - assert resolved.manual_crop_rect == pytest.approx((0.145, 0.1, 0.855, 0.9)) + assert resolved.crop_rect == pytest.approx((0.145, 0.1, 0.855, 0.9)) def test_resolved_rect_preserves_half_open_coordinates_when_normalized() -> None: @@ -323,9 +323,9 @@ def test_resolved_rect_preserves_half_open_coordinates_when_normalized() -> None resolved = _resolved_by_key([evidence])["exclusive"] - assert resolved.manual_crop_rect == pytest.approx((11 / 203, 7 / 101, 199 / 203, 97 / 101)) - assert (resolved.manual_crop_rect[2] - resolved.manual_crop_rect[0]) * 203 == pytest.approx(188) - assert (resolved.manual_crop_rect[3] - resolved.manual_crop_rect[1]) * 101 == pytest.approx(90) + assert resolved.crop_rect == pytest.approx((11 / 203, 7 / 101, 199 / 203, 97 / 101)) + assert (resolved.crop_rect[2] - resolved.crop_rect[0]) * 203 == pytest.approx(188) + assert (resolved.crop_rect[3] - resolved.crop_rect[1]) * 101 == pytest.approx(90) _NAN = float("nan") @@ -410,7 +410,7 @@ def test_border_inset_keeps_the_rect_when_it_would_collapse() -> None: def test_resolved_crop_trims_the_rebate_when_the_roll_measured_one() -> None: evidence = [_evidence(f"f{index}", border=(0.02, 0.02, 0.02, 0.02)) for index in range(6)] - x1, y1, x2, y2 = _resolved_by_key(evidence)["f0"].manual_crop_rect + x1, y1, x2, y2 = _resolved_by_key(evidence)["f0"].crop_rect assert x1 > 0.1 and y1 > 0.1 assert x2 < 0.9 and y2 < 0.9 @@ -419,12 +419,12 @@ def test_resolved_crop_trims_the_rebate_when_the_roll_measured_one() -> None: def test_resolved_crop_passes_through_untouched_without_a_roll_border() -> None: resolved = _resolved_by_key(_trusted_roll())["trusted-0"] - assert resolved.manual_crop_rect == pytest.approx((0.1, 0.1, 0.9, 0.9)) + assert resolved.crop_rect == pytest.approx((0.1, 0.1, 0.9, 0.9)) def _trimmed_roll(rebate_trim: float) -> tuple[float, float, float, float]: evidence = [_evidence(f"f{index}", border=(0.02, 0.02, 0.02, 0.02), rebate_trim=rebate_trim) for index in range(6)] - return _resolved_by_key(evidence)["f0"].manual_crop_rect + return _resolved_by_key(evidence)["f0"].crop_rect def test_rebate_trim_zero_keeps_the_whole_film_box() -> None: @@ -563,7 +563,7 @@ def test_edge_pair_at_the_roll_width_resolves_through_a_busy_picture() -> None: resolved = _resolved_by_key([*_trusted_roll(), busy]) assert "busy" in resolved - x1, _, x2, _ = resolved["busy"].manual_crop_rect + x1, _, x2, _ = resolved["busy"].crop_rect assert x2 - x1 == pytest.approx(0.8, abs=0.02) diff --git a/tests/test_batch_autocrop_controller.py b/tests/test_batch_autocrop_controller.py index 65f4de9a2..5eed1dc96 100644 --- a/tests/test_batch_autocrop_controller.py +++ b/tests/test_batch_autocrop_controller.py @@ -57,7 +57,7 @@ def test_request_dispatches_visible_uncropped_frames_and_preserves_manual(self) self.controller.state.current_file_hash = "active" self.controller.state.current_file_path = files[0]["path"] active = replace(WorkspaceConfig(), geometry=replace(WorkspaceConfig().geometry, autocrop_ratio="4:3")) - manual = replace(WorkspaceConfig(), geometry=replace(WorkspaceConfig().geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9))) + manual = replace(WorkspaceConfig(), geometry=replace(WorkspaceConfig().geometry, crop_rect=(0.1, 0.1, 0.9, 0.9))) fresh = WorkspaceConfig() self.controller.state.config = active self.session.asset_model.visible_actual_indices_ordered.return_value = [0, 1, 2] @@ -151,16 +151,16 @@ def test_finish_merges_only_crop_and_rotation_then_invalidates_bounds(self) -> N self.controller._on_batch_autocrop_finished(results) active_saved = self.session.persist_active_batch_config.call_args.args[0] - assert active_saved.geometry.manual_crop_rect == rect_a + assert active_saved.geometry.crop_rect == rect_a assert active_saved.geometry.fine_rotation == 1.75 - assert not active_saved.geometry.auto_crop_enabled + assert not active_saved.geometry.crop_from_auto assert active_saved.process.local_floors == (0.0, 0.0, 0.0) assert active_saved.process.local_ceils == (0.0, 0.0, 0.0) self.session.persist_active_batch_config.assert_called_once_with(active_saved) self.session.update_config.assert_not_called() _, other_saved = self.session.repo.save_file_settings.call_args.args[:2] - assert other_saved.geometry.manual_crop_rect == rect_b + assert other_saved.geometry.crop_rect == rect_b assert other_saved.geometry.fine_rotation == -0.25 assert other_saved.process.local_floors == (0.0, 0.0, 0.0) self.controller.request_render.assert_called_once_with() @@ -238,7 +238,7 @@ def test_finish_preserves_new_manual_crop_and_changed_geometry(self) -> None: manual_asset = {"name": "manual.dng", "path": "/roll/manual.dng", "hash": "manual"} changed_asset = {"name": "changed.dng", "path": "/roll/changed.dng", "hash": "changed"} original = WorkspaceConfig() - manual = replace(original, geometry=replace(original.geometry, manual_crop_rect=(0.1, 0.1, 0.8, 0.8))) + manual = replace(original, geometry=replace(original.geometry, crop_rect=(0.1, 0.1, 0.8, 0.8))) changed = replace(original, geometry=replace(original.geometry, fine_rotation=2.0)) self.session.config_for_asset.side_effect = [manual, changed] token = self.controller._begin_batch("autocrop", "Auto cropping roll", True) diff --git a/tests/test_batch_autocrop_worker.py b/tests/test_batch_autocrop_worker.py index 7df0afbb0..eb04ccbfa 100644 --- a/tests/test_batch_autocrop_worker.py +++ b/tests/test_batch_autocrop_worker.py @@ -140,8 +140,8 @@ def test_batch_autocrop_applies_flatfield_and_crop_free_geometry( rotation=1, fine_rotation=2.5, flip_horizontal=True, - manual_crop_rect=(0.1, 0.2, 0.8, 0.9), - auto_crop_enabled=True, + crop_rect=(0.1, 0.2, 0.8, 0.9), + crop_from_auto=True, autocrop_offset=17, autocrop_ratio="4:3", autocrop_rebate_trim=1.25, @@ -186,8 +186,8 @@ def _detect(key, transformed, *, target_ratio, rebate_trim=1.0): worker.process(_task(_input("frame", config))) assert captured["flatfield_config"] == flatfield - assert captured["geometry"].manual_crop_rect is None - assert captured["geometry"].auto_crop_enabled is False + assert captured["geometry"].crop_rect is None + assert captured["geometry"].crop_from_auto is False assert captured["geometry"].autocrop_offset == 0 assert captured["geometry"].rotation == 1 assert captured["geometry"].fine_rotation == 2.5 diff --git a/tests/test_config_deserialization.py b/tests/test_config_deserialization.py index 2639f51cd..878dbcb53 100644 --- a/tests/test_config_deserialization.py +++ b/tests/test_config_deserialization.py @@ -221,26 +221,26 @@ def test_retired_dng_export_migrates_outside_flat_dict(self): self.assertEqual(ExportConfig(export_fmt="DNG").export_fmt, ExportFormat.TIFF) self.assertEqual(ExportPreset.from_dict({"export_fmt": "DNG"}).export_fmt, ExportFormat.TIFF) - def test_manual_crop_rect_survives_db_roundtrip_as_tuple(self): + def test_crop_rect_survives_db_roundtrip_as_tuple(self): """Manual crop saved to JSON reloads as a list, making the frozen GeometryConfig unhashable and crashing the pipeline hash. The reloaded rect must be a tuple and geometry must stay hashable.""" config = WorkspaceConfig() - config = replace(config, geometry=replace(config.geometry, manual_crop_rect=(0.1, 0.2, 0.8, 0.9))) + config = replace(config, geometry=replace(config.geometry, crop_rect=(0.1, 0.2, 0.8, 0.9))) # Exactly what repository.save_file_settings / load_file_settings do. reloaded = WorkspaceConfig.from_flat_dict(json.loads(json.dumps(config.to_dict(), default=str))) - self.assertIsInstance(reloaded.geometry.manual_crop_rect, tuple) - self.assertEqual(reloaded.geometry.manual_crop_rect, (0.1, 0.2, 0.8, 0.9)) + self.assertIsInstance(reloaded.geometry.crop_rect, tuple) + self.assertEqual(reloaded.geometry.crop_rect, (0.1, 0.2, 0.8, 0.9)) hash(reloaded.geometry) # must not raise - def test_manual_crop_rect_hashable_in_engine_base_key(self): + def test_crop_rect_hashable_in_engine_base_key(self): """DarkroomEngine wraps geometry in a plain tuple (base_key) before hashing; an unhashable geometry made calculate_config_hash fall through to asdict(tuple) -> 'asdict() should be called on dataclass instances'.""" config = WorkspaceConfig() - config = replace(config, geometry=replace(config.geometry, manual_crop_rect=(0.1, 0.2, 0.8, 0.9))) + config = replace(config, geometry=replace(config.geometry, crop_rect=(0.1, 0.2, 0.8, 0.9))) reloaded = WorkspaceConfig.from_flat_dict(json.loads(json.dumps(config.to_dict(), default=str))) base_key = ( diff --git a/tests/test_controller.py b/tests/test_controller.py index 39d7d85d1..8b24161c9 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -11,6 +11,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.session import DesktopSessionManager, AppState, ToolMode from negpy.desktop.workers.export import ExportTask, resolve_export_target_path +from negpy.features.geometry.logic import autocrop_detection_key from negpy.domain.models import ( ColorSpace, ExportConfig, @@ -130,7 +131,7 @@ def test_normalization_finished_uses_hydrated_base_not_active_edit(self): state.current_file_hash = "hash1" state.config = replace( WorkspaceConfig(), - geometry=GeometryConfig(manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + geometry=GeometryConfig(crop_rect=(0.1, 0.1, 0.9, 0.9)), retouch=RetouchConfig(manual_dust_spots=[(0.5, 0.5, 3)]), ) self.mock_session_manager.repo.load_file_settings.return_value = None @@ -141,7 +142,7 @@ def test_normalization_finished_uses_hydrated_base_not_active_edit(self): saved = {c.args[0]: c.args[1] for c in self.mock_session_manager.repo.save_file_settings.call_args_list} self.assertIn("hash2", saved) - self.assertIsNone(saved["hash2"].geometry.manual_crop_rect) + self.assertIsNone(saved["hash2"].geometry.crop_rect) self.assertEqual(saved["hash2"].retouch.manual_dust_spots, []) # Baseline still broadcast onto the fresh frame. self.assertTrue(saved["hash2"].process.use_luma_average) @@ -154,7 +155,7 @@ def test_write_edit_sidecars_uses_hydrated_base_not_active_edit(self): from negpy.domain.models import GeometryConfig state = self.mock_session_manager.state - state.config = replace(state.config, geometry=GeometryConfig(manual_crop_rect=(0.1, 0.1, 0.9, 0.9))) + state.config = replace(state.config, geometry=GeometryConfig(crop_rect=(0.1, 0.1, 0.9, 0.9))) hydrated = WorkspaceConfig() self.mock_session_manager.config_for_asset.return_value = hydrated frame = {"name": "b.dng", "path": "/tmp/b.dng", "hash": "hash2"} @@ -169,7 +170,7 @@ def test_write_edit_sidecars_uses_hydrated_base_not_active_edit(self): self.mock_session_manager.config_for_asset.assert_called_once_with(frame) params = mock_write.call_args.args[1] self.assertIs(params, hydrated) - self.assertIsNone(params.geometry.manual_crop_rect) + self.assertIsNone(params.geometry.crop_rect) def test_clear_roll_baseline_resets_axes(self): state = self.mock_session_manager.state @@ -668,27 +669,27 @@ def test_stale_preview_decode_is_dropped(self): 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, manual_crop_rect=(0.1, 0.1, 0.9, 0.9), auto_crop_enabled=False) + 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) self.controller.request_render = MagicMock() self.controller.apply_auto_crop() saved_config = self.mock_session_manager.update_config.call_args.args[0] - self.assertTrue(saved_config.geometry.auto_crop_enabled) - self.assertIsNone(saved_config.geometry.manual_crop_rect) + self.assertTrue(saved_config.geometry.crop_from_auto) + self.assertIsNone(saved_config.geometry.crop_rect) self.controller.request_render.assert_called_once_with() def test_reset_crop_disables_auto_crop_and_clears_manual_rect(self): - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9), auto_crop_enabled=True) + geometry = replace(self.controller.state.config.geometry, crop_rect=(0.1, 0.1, 0.9, 0.9), crop_from_auto=True) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.request_render = MagicMock() self.controller.reset_crop() saved_config = self.mock_session_manager.update_config.call_args.args[0] - self.assertFalse(saved_config.geometry.auto_crop_enabled) - self.assertIsNone(saved_config.geometry.manual_crop_rect) + self.assertFalse(saved_config.geometry.crop_from_auto) + self.assertIsNone(saved_config.geometry.crop_rect) self.controller.request_render.assert_called_once_with() def test_set_crop_ratio_updates_config_when_no_manual_rect(self): @@ -698,7 +699,7 @@ def test_set_crop_ratio_updates_config_when_no_manual_rect(self): saved_config = self.mock_session_manager.update_config.call_args.args[0] self.assertEqual(saved_config.geometry.autocrop_ratio, "4:3") - self.assertIsNone(saved_config.geometry.manual_crop_rect) + self.assertIsNone(saved_config.geometry.crop_rect) self.controller.request_render.assert_called_once_with() def test_set_crop_ratio_is_noop_when_unchanged(self): @@ -723,7 +724,7 @@ def test_set_crop_ratio_preserves_metering_bounds(self): config = replace( self.controller.state.config, process=replace(self.controller.state.config.process, local_floors=floors, local_ceils=ceils) ) - config = replace(config, geometry=replace(config.geometry, manual_crop_rect=(0.15, 0.15, 0.85, 0.85))) + config = replace(config, geometry=replace(config.geometry, crop_rect=(0.15, 0.15, 0.85, 0.85))) self.controller.state.config = config self.controller.request_render = MagicMock() @@ -744,7 +745,7 @@ def test_set_crop_ratio_reshape_never_grows_the_box(self): rect = (0.15, 0.15, 0.85, 0.85) self.controller.state.config = replace( self.controller.state.config, - geometry=replace(self.controller.state.config.geometry, manual_crop_rect=rect), + geometry=replace(self.controller.state.config.geometry, crop_rect=rect), ) self.controller.request_render = MagicMock() @@ -752,10 +753,10 @@ def test_set_crop_ratio_reshape_never_grows_the_box(self): self.mock_session_manager.reset_mock() self.controller.state.config = replace( self.controller.state.config, - geometry=replace(self.controller.state.config.geometry, autocrop_ratio="Free", manual_crop_rect=rect), + geometry=replace(self.controller.state.config.geometry, autocrop_ratio="Free", crop_rect=rect), ) self.controller.set_crop_ratio(ratio) - nx1, ny1, nx2, ny2 = self.mock_session_manager.update_config.call_args.args[0].geometry.manual_crop_rect + nx1, ny1, nx2, ny2 = self.mock_session_manager.update_config.call_args.args[0].geometry.crop_rect self.assertGreaterEqual(nx1, rect[0] - 1e-6, f"{ratio}: box grew left") self.assertGreaterEqual(ny1, rect[1] - 1e-6, f"{ratio}: box grew up") self.assertLessEqual(nx2, rect[2] + 1e-6, f"{ratio}: box grew right") @@ -769,7 +770,7 @@ def test_set_crop_ratio_reshapes_manual_rect_centered_pixel_aware(self): import numpy as np self.controller.state.preview_raw = np.empty((800, 1200, 3), dtype=np.float32) # h=800, w=1200 - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=(0.25, 0.25, 0.75, 0.75)) + geometry = replace(self.controller.state.config.geometry, crop_rect=(0.25, 0.25, 0.75, 0.75)) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.request_render = MagicMock() @@ -777,7 +778,7 @@ def test_set_crop_ratio_reshapes_manual_rect_centered_pixel_aware(self): saved_config = self.mock_session_manager.update_config.call_args.args[0] self.assertEqual(saved_config.geometry.autocrop_ratio, "1:1") - nx1, ny1, nx2, ny2 = saved_config.geometry.manual_crop_rect + nx1, ny1, nx2, ny2 = saved_config.geometry.crop_rect # Center unchanged. self.assertAlmostEqual((nx1 + nx2) / 2, 0.5, places=3) self.assertAlmostEqual((ny1 + ny2) / 2, 0.5, places=3) @@ -796,7 +797,7 @@ def test_set_crop_ratio_accounts_for_90_degree_rotation(self): geometry = replace( self.controller.state.config.geometry, rotation=1, - manual_crop_rect=(0.25, 0.25, 0.75, 0.75), + crop_rect=(0.25, 0.25, 0.75, 0.75), ) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.request_render = MagicMock() @@ -804,7 +805,7 @@ def test_set_crop_ratio_accounts_for_90_degree_rotation(self): self.controller.set_crop_ratio("1:1") saved_config = self.mock_session_manager.update_config.call_args.args[0] - nx1, ny1, nx2, ny2 = saved_config.geometry.manual_crop_rect + nx1, ny1, nx2, ny2 = saved_config.geometry.crop_rect # Display dims after a 90 rotation: h=1200, w=800. px_w = (nx2 - nx1) * 800 px_h = (ny2 - ny1) * 1200 @@ -883,8 +884,63 @@ def test_export_no_conflict_passes_through_without_prompt(self): self.assertEqual(out, [task]) self.controller._prompt_overwrite_conflicts.assert_not_called() - def test_manual_crop_rect_changed_disables_auto_crop(self): - geometry = replace(self.controller.state.config.geometry, auto_crop_enabled=True) + def _armed_auto_crop(self): + geometry = replace(self.controller.state.config.geometry, crop_from_auto=True, crop_rect=None) + self.controller.state.config = replace(self.controller.state.config, geometry=geometry) + return autocrop_detection_key(geometry) + + def test_freeze_stores_the_crop_the_render_detected(self): + key = self._armed_auto_crop() + metrics = {"autocrop_resolved_rect": (0.05, 0.04, 0.99, 0.98), "autocrop_resolved_key": key} + + self.controller._freeze_resolved_auto_crop(metrics) + + saved_config = self.mock_session_manager.update_config.call_args.args[0] + self.assertEqual(saved_config.geometry.crop_rect, (0.05, 0.04, 0.99, 0.98)) + self.assertEqual(saved_config.geometry.crop_detect_key, key) + self.assertTrue(saved_config.geometry.crop_from_auto) + + def test_freeze_requests_no_render(self): + """The rect is what was just painted, so re-rendering it would only cost a frame.""" + key = self._armed_auto_crop() + self.controller._freeze_resolved_auto_crop({"autocrop_resolved_rect": (0.1, 0.1, 0.9, 0.9), "autocrop_resolved_key": key}) + self.assertFalse(self.mock_session_manager.update_config.call_args.kwargs["render"]) + + def test_freeze_drops_a_result_the_user_has_moved_past(self): + """Ratio changed while the render was in flight: a render under the new one is + already queued, and storing this rect would file it under the wrong detection.""" + self._armed_auto_crop() + metrics = {"autocrop_resolved_rect": (0.05, 0.04, 0.99, 0.98), "autocrop_resolved_key": "stale-key"} + + self.controller._freeze_resolved_auto_crop(metrics) + + self.mock_session_manager.update_config.assert_not_called() + + def test_freeze_ignores_a_render_of_a_manual_crop(self): + geometry = replace(self.controller.state.config.geometry, crop_from_auto=False, crop_rect=(0.2, 0.2, 0.8, 0.8)) + self.controller.state.config = replace(self.controller.state.config, geometry=geometry) + + self.controller._freeze_resolved_auto_crop( + {"autocrop_resolved_rect": (0.0, 0.0, 1.0, 1.0), "autocrop_resolved_key": autocrop_detection_key(geometry)} + ) + + self.mock_session_manager.update_config.assert_not_called() + + def test_apply_auto_crop_arms_without_a_rect(self): + self.controller.request_render = MagicMock() + self.controller.state.config = replace( + self.controller.state.config, + geometry=replace(self.controller.state.config.geometry, crop_rect=(0.2, 0.2, 0.8, 0.8)), + ) + + self.controller.apply_auto_crop() + + saved_config = self.mock_session_manager.update_config.call_args.args[0] + self.assertTrue(saved_config.geometry.crop_from_auto) + self.assertIsNone(saved_config.geometry.crop_rect) + + def test_crop_rect_changed_disables_auto_crop(self): + geometry = replace(self.controller.state.config.geometry, crop_from_auto=True) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.state.active_tool = ToolMode.CROP_MANUAL self.controller.request_render = MagicMock() @@ -892,12 +948,12 @@ def test_manual_crop_rect_changed_disables_auto_crop(self): self.controller.handle_crop_rect_changed(0.2, 0.3, 0.8, 0.9, True) saved_config = self.mock_session_manager.update_config.call_args.args[0] - self.assertFalse(saved_config.geometry.auto_crop_enabled) - self.assertEqual(saved_config.geometry.manual_crop_rect, (0.2, 0.3, 0.8, 0.9)) + self.assertFalse(saved_config.geometry.crop_from_auto) + self.assertEqual(saved_config.geometry.crop_rect, (0.2, 0.3, 0.8, 0.9)) self.controller.request_render.assert_called_once_with() def test_handle_crop_rect_changed_updates_rect(self): - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=(0.2, 0.2, 0.6, 0.5)) + geometry = replace(self.controller.state.config.geometry, crop_rect=(0.2, 0.2, 0.6, 0.5)) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.state.active_tool = ToolMode.CROP_MANUAL self.controller.request_render = MagicMock() @@ -905,11 +961,11 @@ def test_handle_crop_rect_changed_updates_rect(self): self.controller.handle_crop_rect_changed(0.3, 0.25, 0.7, 0.55, True) saved_config = self.mock_session_manager.update_config.call_args.args[0] - self.assertEqual(saved_config.geometry.manual_crop_rect, (0.3, 0.25, 0.7, 0.55)) + self.assertEqual(saved_config.geometry.crop_rect, (0.3, 0.25, 0.7, 0.55)) self.controller.request_render.assert_called_once_with() def test_handle_crop_rect_changed_noop_when_tool_inactive(self): - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=None) + geometry = replace(self.controller.state.config.geometry, crop_rect=None) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.state.active_tool = ToolMode.NONE self.controller.request_render = MagicMock() @@ -920,7 +976,7 @@ def test_handle_crop_rect_changed_noop_when_tool_inactive(self): self.controller.request_render.assert_not_called() def test_handle_crop_rect_changed_does_not_deactivate_tool(self): - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=(0.2, 0.2, 0.6, 0.5)) + geometry = replace(self.controller.state.config.geometry, crop_rect=(0.2, 0.2, 0.6, 0.5)) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.state.active_tool = ToolMode.CROP_MANUAL self.controller.request_render = MagicMock() @@ -930,7 +986,7 @@ def test_handle_crop_rect_changed_does_not_deactivate_tool(self): self.assertEqual(self.controller.state.active_tool, ToolMode.CROP_MANUAL) def test_handle_crop_rect_changed_live_drag_does_not_persist(self): - geometry = replace(self.controller.state.config.geometry, manual_crop_rect=(0.2, 0.2, 0.6, 0.5)) + geometry = replace(self.controller.state.config.geometry, crop_rect=(0.2, 0.2, 0.6, 0.5)) self.controller.state.config = replace(self.controller.state.config, geometry=geometry) self.controller.state.active_tool = ToolMode.CROP_MANUAL self.controller.request_render = MagicMock() diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index b7d2f728a..b46460442 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -418,7 +418,7 @@ def test_contact_sheet_template_in_sticky_export(self): def test_sync_selected_settings_exclusions(self): source_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=1.5), - geometry=GeometryConfig(rotation=1, fine_rotation=5.5, manual_crop_rect=(0, 0, 1, 1)), + geometry=GeometryConfig(rotation=1, fine_rotation=5.5, crop_rect=(0, 0, 1, 1)), retouch=RetouchConfig(dust_remove=True, manual_dust_spots=[(0.1, 0.1, 5)]), process=ProcessConfig(process_mode=ProcessMode.E6, e6_normalize=True), ) @@ -428,7 +428,7 @@ def test_sync_selected_settings_exclusions(self): target_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=0.0), - geometry=GeometryConfig(rotation=0, fine_rotation=0.0, manual_crop_rect=None), + geometry=GeometryConfig(rotation=0, fine_rotation=0.0, crop_rect=None), retouch=RetouchConfig(dust_remove=False, manual_dust_spots=[]), process=ProcessConfig(process_mode=ProcessMode.C41, e6_normalize=False), ) @@ -447,7 +447,7 @@ def test_sync_selected_settings_exclusions(self): # Geometry not selected → entirely preserved from target self.assertEqual(saved_config.geometry.rotation, 0) self.assertEqual(saved_config.geometry.fine_rotation, 0.0) - self.assertIsNone(saved_config.geometry.manual_crop_rect) + self.assertIsNone(saved_config.geometry.crop_rect) # Per-file retouch fields preserved from target even though Dust Removal was synced self.assertEqual(saved_config.retouch.manual_dust_spots, []) self.assertTrue(saved_config.retouch.dust_remove) @@ -455,7 +455,7 @@ def test_sync_selected_settings_exclusions(self): def test_sync_selected_settings_edits_with_geometry(self): source_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=1.5), - geometry=GeometryConfig(rotation=1, fine_rotation=5.5, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + geometry=GeometryConfig(rotation=1, fine_rotation=5.5, crop_rect=(0.1, 0.1, 0.9, 0.9)), retouch=RetouchConfig(dust_remove=True, manual_dust_spots=[(0.1, 0.1, 5)]), process=ProcessConfig(process_mode=ProcessMode.E6, e6_normalize=True), ) @@ -465,21 +465,21 @@ def test_sync_selected_settings_edits_with_geometry(self): target_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=0.0), - geometry=GeometryConfig(rotation=0, fine_rotation=0.0, manual_crop_rect=None), + geometry=GeometryConfig(rotation=0, fine_rotation=0.0, crop_rect=None), retouch=RetouchConfig(dust_remove=False, manual_dust_spots=[(0.5, 0.5, 3)]), process=ProcessConfig(process_mode=ProcessMode.C41, e6_normalize=False), ) self.mock_repo.load_file_settings.return_value = target_config self.session.update_selection([0, 1]) - self.session.sync_selected_settings([_row("Print Density"), _row("Fine Rotation"), _row("Rotation"), _row("Manual Crop")]) + self.session.sync_selected_settings([_row("Print Density"), _row("Fine Rotation"), _row("Rotation"), _row("Crop")]) args, _ = self.mock_repo.save_file_settings.call_args saved_config = args[1] # Crop and fine_rotation should now propagate from source self.assertEqual(saved_config.geometry.fine_rotation, 5.5) - self.assertEqual(saved_config.geometry.manual_crop_rect, (0.1, 0.1, 0.9, 0.9)) + self.assertEqual(saved_config.geometry.crop_rect, (0.1, 0.1, 0.9, 0.9)) self.assertEqual(saved_config.geometry.rotation, 1) # Edits still synced self.assertEqual(saved_config.exposure.density, 1.5) @@ -489,7 +489,7 @@ def test_sync_selected_settings_edits_with_geometry(self): def test_sync_selected_settings_geometry_only(self): source_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=1.5), - geometry=GeometryConfig(rotation=2, fine_rotation=3.0, manual_crop_rect=(0.0, 0.0, 0.5, 0.5)), + geometry=GeometryConfig(rotation=2, fine_rotation=3.0, crop_rect=(0.0, 0.0, 0.5, 0.5)), ) self.session.state.selected_file_idx = 0 self.session.state.current_file_hash = "hash1" @@ -497,12 +497,12 @@ def test_sync_selected_settings_geometry_only(self): target_config = WorkspaceConfig( exposure=replace(WorkspaceConfig().exposure, density=0.7), - geometry=GeometryConfig(rotation=0, fine_rotation=0.0, manual_crop_rect=None), + geometry=GeometryConfig(rotation=0, fine_rotation=0.0, crop_rect=None), ) self.mock_repo.load_file_settings.return_value = target_config self.session.update_selection([0, 1]) - self.session.sync_selected_settings([_row("Rotation"), _row("Fine Rotation"), _row("Manual Crop")]) + self.session.sync_selected_settings([_row("Rotation"), _row("Fine Rotation"), _row("Crop")]) args, _ = self.mock_repo.save_file_settings.call_args saved_config = args[1] @@ -510,7 +510,7 @@ def test_sync_selected_settings_geometry_only(self): # Geometry comes from source self.assertEqual(saved_config.geometry.rotation, 2) self.assertEqual(saved_config.geometry.fine_rotation, 3.0) - self.assertEqual(saved_config.geometry.manual_crop_rect, (0.0, 0.0, 0.5, 0.5)) + self.assertEqual(saved_config.geometry.crop_rect, (0.0, 0.0, 0.5, 0.5)) # Other config preserved from target self.assertEqual(saved_config.exposure.density, 0.7) @@ -677,7 +677,7 @@ def test_reset_settings_drops_edits_and_bounds(self): locked_ceils=(0.95, 0.95, 0.95), lock_bounds=True, ), - geometry=replace(self.session.state.config.geometry, rotation=2, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + geometry=replace(self.session.state.config.geometry, rotation=2, crop_rect=(0.1, 0.1, 0.9, 0.9)), ) self.session.update_config(dirty, persist=True) diff --git a/tests/test_engine.py b/tests/test_engine.py index 444d5a95b..c01b878ad 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -23,7 +23,7 @@ def test_pipeline_with_offset(self): engine = DarkroomEngine() img = np.random.rand(200, 200, 3).astype(np.float32) # Use explicit auto-crop plus offset to shrink image. - settings = WorkspaceConfig.from_flat_dict({"auto_crop_enabled": True, "autocrop_offset": 10}) + settings = WorkspaceConfig.from_flat_dict({"crop_from_auto": True, "autocrop_offset": 10}) res = engine.process(img, settings, source_hash="dummy") diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py index b04a076d7..139731bfe 100644 --- a/tests/test_file_browser_widget.py +++ b/tests/test_file_browser_widget.py @@ -20,7 +20,7 @@ def _edited_cfg() -> WorkspaceConfig: return replace( c, exposure=replace(c.exposure, density=1.5), - geometry=replace(c.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + geometry=replace(c.geometry, crop_rect=(0.1, 0.1, 0.9, 0.9)), ) @@ -172,7 +172,7 @@ def test_apply_dialog_check_all_and_none(qapp): assert not dlg.apply_btn.isEnabled() dlg._set_all_checked(True) # unchanged rows stay hidden and unchecked until "Show unchanged settings" - assert {r.label for r in dlg.selected()} == {"Print Density", "Manual Crop"} + assert {r.label for r in dlg.selected()} == {"Print Density", "Crop"} assert dlg.apply_btn.isEnabled() @@ -182,13 +182,13 @@ def test_apply_dialog_apply_collects_checked_rows_and_scope(qapp): dlg._on_apply() labels = {r.label for r in dlg.selected()} assert "Print Density" in labels # the edited exposure setting - assert "Manual Crop" in labels # the edited geometry setting + assert "Crop" in labels # the edited geometry setting assert dlg.scope() == "roll" def test_apply_dialog_only_preselects_edited_settings(qapp): dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=1, roll_count=3) - assert {r.label for r in dlg.selected()} == {"Print Density", "Manual Crop"} # nothing else was non-default + assert {r.label for r in dlg.selected()} == {"Print Density", "Crop"} # nothing else was non-default # the rest are still built, just hidden, so they can be applied on demand (#656) assert "Crop Offset" in {row.label for _box, row, _edited, _line in dlg._checks} diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index 5c3a69c57..ac07cc439 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -53,7 +53,7 @@ def test_get_manual_crop_coords_negative_offset(): def test_geometry_processor_manual_offset(): img = np.zeros((100, 200, 3), dtype=np.float32) # Manual crop rect defined -> should skip auto-crop - config = GeometryConfig(manual_crop_rect=(0.1, 0.1, 0.9, 0.9), autocrop_offset=0) + config = GeometryConfig(crop_rect=(0.1, 0.1, 0.9, 0.9), autocrop_offset=0) processor = GeometryProcessor(config) context = PipelineContext(scale_factor=1.0, original_size=(100, 200)) @@ -75,20 +75,19 @@ def test_geometry_processor_no_manual_rect_no_offset(): assert context.active_roi is None -def test_geometry_processor_auto_crop_requires_explicit_enable(): +def test_geometry_processor_never_detects(): + """An armed auto crop carries no rect yet, and the processor does not go looking for + one: detection belongs to ImageProcessor, upstream of both engines, so a preview and + an export of one edit cannot resolve different frames.""" img = np.ones((240, 360, 3), dtype=np.float32) img[50:190, 90:270] = 0.05 - config = GeometryConfig(auto_crop_enabled=True, autocrop_offset=0, autocrop_ratio="Free") - processor = GeometryProcessor(config) + config = GeometryConfig(crop_from_auto=True, autocrop_offset=0, autocrop_ratio="Free") context = PipelineContext(scale_factor=1.0, original_size=(240, 360)) - processor.process(img, context) + GeometryProcessor(config).process(img, context) - assert context.active_roi is not None - y1, y2, x1, x2 = context.active_roi - assert y2 > y1 - assert x2 > x1 + assert context.active_roi is None def test_get_autocrop_coords_detects_dark_frame_on_light_bed(): @@ -159,7 +158,7 @@ def test_crop_consistency_across_resolutions(): full_h, full_w = 3000, 4500 prev_h, prev_w = 1000, 1500 - config = GeometryConfig(auto_crop_enabled=True, autocrop_offset=10) + config = GeometryConfig(crop_from_auto=True, crop_rect=(0.1, 0.1, 0.9, 0.9), autocrop_offset=10) processor = GeometryProcessor(config) ctx_full = PipelineContext( @@ -230,10 +229,10 @@ def test_manual_crop_no_inflation_under_fine_rotation(): img = np.zeros((400, 600, 3), dtype=np.float32) ctx_base = PipelineContext(scale_factor=1.0, original_size=(400, 600)) - GeometryProcessor(GeometryConfig(manual_crop_rect=rect, autocrop_offset=0)).process(img, ctx_base) + GeometryProcessor(GeometryConfig(crop_rect=rect, autocrop_offset=0)).process(img, ctx_base) ctx_rot = PipelineContext(scale_factor=1.0, original_size=(400, 600)) - GeometryProcessor(GeometryConfig(manual_crop_rect=rect, autocrop_offset=0, fine_rotation=4.0)).process(img, ctx_rot) + GeometryProcessor(GeometryConfig(crop_rect=rect, autocrop_offset=0, fine_rotation=4.0)).process(img, ctx_rot) assert ctx_base.active_roi == ctx_rot.active_roi # Exactly the fractional slice of the transformed frame, un-inflated. @@ -242,19 +241,19 @@ def test_manual_crop_no_inflation_under_fine_rotation(): def test_translate_within_bounds(): from pytest import approx - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.2, 0.2, 0.6, 0.5) - result = translate_manual_crop_rect(rect, 0.1, 0.05) + result = translate_normalized_rect(rect, 0.1, 0.05) assert result == approx((0.3, 0.25, 0.7, 0.55)) def test_translate_clamps_at_right_edge(): from pytest import approx - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.6, 0.2, 0.9, 0.5) - nx1, ny1, nx2, ny2 = translate_manual_crop_rect(rect, 0.5, 0.0) + nx1, ny1, nx2, ny2 = translate_normalized_rect(rect, 0.5, 0.0) assert nx2 == approx(1.0) assert nx1 == approx(0.7) # 1.0 - width 0.3 assert (ny1, ny2) == approx((0.2, 0.5)) @@ -262,10 +261,10 @@ def test_translate_clamps_at_right_edge(): def test_translate_clamps_at_left_edge(): from pytest import approx - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.2, 0.2, 0.6, 0.5) - nx1, ny1, nx2, ny2 = translate_manual_crop_rect(rect, -0.5, 0.0) + nx1, ny1, nx2, ny2 = translate_normalized_rect(rect, -0.5, 0.0) assert nx1 == approx(0.0) assert nx2 == approx(0.4) # width preserved assert (ny1, ny2) == approx((0.2, 0.5)) @@ -273,39 +272,39 @@ def test_translate_clamps_at_left_edge(): def test_translate_clamps_top_and_bottom(): from pytest import approx - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.2, 0.2, 0.6, 0.5) - _, ny1_top, _, ny2_top = translate_manual_crop_rect(rect, 0.0, -0.5) + _, ny1_top, _, ny2_top = translate_normalized_rect(rect, 0.0, -0.5) assert ny1_top == approx(0.0) assert ny2_top == approx(0.3) # height 0.3 preserved - _, ny1_bot, _, ny2_bot = translate_manual_crop_rect(rect, 0.0, 0.9) + _, ny1_bot, _, ny2_bot = translate_normalized_rect(rect, 0.0, 0.9) assert ny2_bot == approx(1.0) assert ny1_bot == approx(0.7) # 1.0 - 0.3 def test_translate_clamps_diagonally(): from pytest import approx - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.6, 0.6, 0.9, 0.9) - result = translate_manual_crop_rect(rect, 0.5, 0.5) + result = translate_normalized_rect(rect, 0.5, 0.5) assert result == approx((0.7, 0.7, 1.0, 1.0)) def test_translate_zero_delta_is_identity(): - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.2, 0.3, 0.7, 0.8) - assert translate_manual_crop_rect(rect, 0.0, 0.0) == rect + assert translate_normalized_rect(rect, 0.0, 0.0) == rect def test_translate_full_size_rect_no_movement(): - from negpy.features.geometry.logic import translate_manual_crop_rect + from negpy.features.geometry.logic import translate_normalized_rect rect = (0.0, 0.0, 1.0, 1.0) - assert translate_manual_crop_rect(rect, 0.5, -0.5) == rect + assert translate_normalized_rect(rect, 0.5, -0.5) == rect def test_straighten_horizontal_right_end_down_rotates_ccw(): @@ -419,7 +418,7 @@ def test_negative_offset_yields_full_image_roi(): def test_manual_crop_applies_offset(): - config = GeometryConfig(manual_crop_rect=(0.1, 0.1, 0.9, 0.9), autocrop_offset=20) + config = GeometryConfig(crop_rect=(0.1, 0.1, 0.9, 0.9), autocrop_offset=20) processor = GeometryProcessor(config) ctx = PipelineContext(scale_factor=1.0, original_size=(100, 200)) processor.process(np.zeros((100, 200, 3), dtype=np.float32), ctx) @@ -620,7 +619,7 @@ def test_manual_crop_roi_consistent_preview_vs_export(rotation_k, flip_h): full_h, full_w = 3000, 4500 prev_h, prev_w = 1000, 1500 - config = GeometryConfig(manual_crop_rect=(0.15, 0.2, 0.7, 0.85), rotation=rotation_k, flip_horizontal=flip_h) + config = GeometryConfig(crop_rect=(0.15, 0.2, 0.7, 0.85), rotation=rotation_k, flip_horizontal=flip_h) proc = GeometryProcessor(config) ctx_full = PipelineContext(scale_factor=1.0, original_size=(full_h, full_w)) @@ -647,7 +646,7 @@ def test_manual_crop_extracts_same_marker_at_preview_and_export(rotation_k): full[210:390, 315:585] = 1.0 # centered block, normalized (0.35..0.65) in both axes prev = cv2.resize(full, (300, 200), interpolation=cv2.INTER_AREA) - config = GeometryConfig(manual_crop_rect=(0.35, 0.35, 0.65, 0.65), rotation=rotation_k) + config = GeometryConfig(crop_rect=(0.35, 0.35, 0.65, 0.65), rotation=rotation_k) proc = GeometryProcessor(config) cropper = CropProcessor(config) @@ -1048,17 +1047,17 @@ def test_mirror_normalized_rect(): assert mirror_normalized_rect((0.1, 0.2, 0.5, 0.7), horizontal=False) == approx((0.1, 0.3, 0.5, 0.8)) -def test_toggle_flip_mirrors_manual_crop_rect(): +def test_toggle_flip_mirrors_crop_rect(): from pytest import approx from negpy.features.geometry.logic import toggle_flip - geo = GeometryConfig(fine_rotation=3.0, manual_crop_rect=(0.1, 0.2, 0.5, 0.7)) + geo = GeometryConfig(fine_rotation=3.0, crop_rect=(0.1, 0.2, 0.5, 0.7)) flipped_h = toggle_flip(geo, horizontal=True) - assert flipped_h.manual_crop_rect == approx((0.5, 0.2, 0.9, 0.7)) + assert flipped_h.crop_rect == approx((0.5, 0.2, 0.9, 0.7)) flipped_v = toggle_flip(geo, horizontal=False) - assert flipped_v.manual_crop_rect == approx((0.1, 0.3, 0.5, 0.8)) + assert flipped_v.crop_rect == approx((0.1, 0.3, 0.5, 0.8)) # Round trip restores the rect (corner order preserved). - assert toggle_flip(flipped_h, horizontal=True).manual_crop_rect == approx(geo.manual_crop_rect) + assert toggle_flip(flipped_h, horizontal=True).crop_rect == approx(geo.crop_rect) @pytest.mark.parametrize("horizontal", [True, False]) diff --git a/tests/test_gpu_stage_skip.py b/tests/test_gpu_stage_skip.py index 752cf8f5c..242f8e856 100644 --- a/tests/test_gpu_stage_skip.py +++ b/tests/test_gpu_stage_skip.py @@ -129,78 +129,6 @@ def test_export_render_does_not_poison_the_next_preview(self): self._assert_same("preview after export", cfg) -@unittest.skipUnless(_gpu_available(), "GPU not available") -class TestAutocropCache(unittest.TestCase): - """Autocrop detection is a CPU scan no creative slider moves.""" - - @classmethod - def setUpClass(cls): - from negpy.services.rendering.gpu_engine import GPUEngine - - cls.GPUEngine = GPUEngine - rng = np.random.default_rng(2) - img = rng.random((256, 320, 3), dtype=np.float32) * 0.2 + 0.05 - img[40:210, 50:270] += 0.5 # a frame inside a rebate, so autocrop has something to find - cls.img = img - - def setUp(self): - self.eng = self.GPUEngine() - self.addCleanup(self.eng.destroy_all) - self.cfg = _sub(WorkspaceConfig(), "geometry", auto_crop_enabled=True) - - def _render(self, cfg, source_hash="frame"): - self.eng.process_to_texture( - self.img, cfg, scale_factor=1.0, readback_metrics=False, source_hash=source_hash, analysis_source_hash=source_hash - ) - - def _count_detections(self, fn): - import negpy.services.rendering.gpu_engine as ge - - calls = {"n": 0} - real = ge._detect_autocrop_roi - - def spy(*a, **k): - calls["n"] += 1 - return real(*a, **k) - - ge._detect_autocrop_roi = spy - try: - fn() - finally: - ge._detect_autocrop_roi = real - return calls["n"] - - def test_creative_drag_detects_once(self): - def drag(): - for d in (0.1, 0.2, 0.3, 0.4): - self._render(_sub(self.cfg, "exposure", density=d)) - - self.assertEqual(self._count_detections(drag), 1) - - def test_geometry_change_redetects(self): - def move_geometry(): - self._render(self.cfg) - self._render(_sub(self.cfg, "geometry", auto_crop_enabled=True, autocrop_offset=3.0)) - - self.assertEqual(self._count_detections(move_geometry), 2) - - def test_new_source_redetects(self): - def switch_source(): - self._render(self.cfg, source_hash="a") - self._render(self.cfg, source_hash="b") - - self.assertEqual(self._count_detections(switch_source), 2) - - def test_uncached_without_a_source_hash(self): - """The export/tiled paths pass none — the key could not tell two buffers apart.""" - - def no_hash(): - for _ in range(3): - self.eng.process_to_texture(self.img, self.cfg, scale_factor=1.0, readback_metrics=False) - - self.assertEqual(self._count_detections(no_hash), 3) - - @unittest.skipUnless(_gpu_available(), "GPU not available") class TestLocalMapCache(unittest.TestCase): """The dodge/burn EV map is rasterised on the CPU and uploaded whole.""" diff --git a/tests/test_granular_save_mode.py b/tests/test_granular_save_mode.py index 4d38d21ef..e324900c0 100644 --- a/tests/test_granular_save_mode.py +++ b/tests/test_granular_save_mode.py @@ -11,7 +11,7 @@ def _edited_cfg() -> WorkspaceConfig: return replace( c, exposure=replace(c.exposure, density=1.5), - geometry=replace(c.geometry, manual_crop_rect=(0.1, 0.1, 0.9, 0.9)), + geometry=replace(c.geometry, crop_rect=(0.1, 0.1, 0.9, 0.9)), ) @@ -37,7 +37,7 @@ def test_exclude_sections_hides_geometry_rows(qapp): ask_name=True, exclude_sections=frozenset({"Crop", "Rotation"}), ) - assert "Manual Crop" not in {row.label for _box, row, _edited, _line in dlg._checks} + assert "Crop" not in {row.label for _box, row, _edited, _line in dlg._checks} assert {row.label for row in dlg.selected()} == {"Print Density"} @@ -88,13 +88,13 @@ def test_hiding_unchanged_rows_unchecks_them(qapp): dlg._show_unchanged.setChecked(False) assert not _crop_offset_box(dlg).isChecked() - assert {row.label for row in dlg.selected()} == {"Print Density", "Manual Crop"} + assert {row.label for row in dlg.selected()} == {"Print Density", "Crop"} def test_check_all_skips_hidden_unchanged_rows(qapp): dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG.cr2") dlg._set_all_checked(True) - assert {row.label for row in dlg.selected()} == {"Print Density", "Manual Crop"} + assert {row.label for row in dlg.selected()} == {"Print Density", "Crop"} def test_default_mode_unchanged(qapp): diff --git a/tests/test_interactive_decoupling.py b/tests/test_interactive_decoupling.py index e2d0533b6..a8a4464a4 100644 --- a/tests/test_interactive_decoupling.py +++ b/tests/test_interactive_decoupling.py @@ -83,6 +83,7 @@ def test_thumbnail_is_not_refreshed_mid_gesture(self): _thumb_config=object(), _render_memo=MagicMock(), _gpu_fallback_notified=True, + _freeze_resolved_auto_crop=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_navigate_back_memo.py b/tests/test_navigate_back_memo.py index 2522f3a9d..08e46d15a 100644 --- a/tests/test_navigate_back_memo.py +++ b/tests/test_navigate_back_memo.py @@ -38,6 +38,7 @@ def _stub(memo, **overrides): _last_render_identity=None, _spared_texture=None, _gpu_fallback_notified=True, + _freeze_resolved_auto_crop=MagicMock(), state=SimpleNamespace( config=object(), metrics_lock=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None), diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index aa99a5e6f..e2577126c 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -70,7 +70,7 @@ def _make_identity_geometry() -> GeometryConfig: fine_rotation=0.0, flip_horizontal=False, flip_vertical=False, - manual_crop_rect=(0.0, 0.0, 1.0, 1.0), + crop_rect=(0.0, 0.0, 1.0, 1.0), autocrop_offset=0, ) diff --git a/tests/test_scene_linear_relocation.py b/tests/test_scene_linear_relocation.py index a16b9ab3d..adbd1b2af 100644 --- a/tests/test_scene_linear_relocation.py +++ b/tests/test_scene_linear_relocation.py @@ -66,7 +66,7 @@ def _base_settings() -> WorkspaceConfig: fine_rotation=0.0, flip_horizontal=False, flip_vertical=False, - manual_crop_rect=(0.0, 0.0, 1.0, 1.0), + crop_rect=(0.0, 0.0, 1.0, 1.0), autocrop_offset=0, ) return replace( diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index 329bcb170..a595acea8 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -16,7 +16,7 @@ def _rich_config() -> WorkspaceConfig: """A config exercising scalar + crop + local-mask paths, so the round trip is meaningful.""" return WorkspaceConfig( exposure=ExposureConfig(density=0.42, grade=130.0), - geometry=GeometryConfig(fine_rotation=1.5, manual_crop_rect=(0.1, 0.2, 0.8, 0.9)), + geometry=GeometryConfig(fine_rotation=1.5, crop_rect=(0.1, 0.2, 0.8, 0.9)), local=LocalAdjustmentsConfig(masks=(LocalMask(vertices=((0.0, 0.0), (0.5, 0.5)), stops=-0.7, feather=0.05),)), ) @@ -38,7 +38,7 @@ def test_roundtrip_next_to_source(tmp_path): d = loaded.to_dict() assert d["density"] == 0.42 assert d["grade"] == 130.0 - assert tuple(d["manual_crop_rect"]) == (0.1, 0.2, 0.8, 0.9) + assert tuple(d["crop_rect"]) == (0.1, 0.2, 0.8, 0.9) masks = d["local_masks"]["masks"] assert len(masks) == 1 assert masks[0]["stops"] == -0.7 diff --git a/tests/test_sync_settings.py b/tests/test_sync_settings.py index 59384e762..f547cb666 100644 --- a/tests/test_sync_settings.py +++ b/tests/test_sync_settings.py @@ -117,7 +117,7 @@ def _metered_target(): return replace(c, process=replace(c.process, local_floors=(0.1, 0.2, 0.3), local_ceils=(0.9, 0.8, 0.7))) -@pytest.mark.parametrize("label", ["Analysis Buffer", "Mode", "Range", "Color", "Crosstalk", "Trichrome Calibration", "Manual Crop"]) +@pytest.mark.parametrize("label", ["Analysis Buffer", "Mode", "Range", "Color", "Crosstalk", "Trichrome Calibration", "Crop"]) def test_apply_metering_row_clears_local_bounds(label): tgt = _metered_target() out = apply_selected_fields(WorkspaceConfig(), tgt, [_row(label)])