Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,15 +412,15 @@ 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:

* **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:**
Expand Down
72 changes: 57 additions & 15 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand All @@ -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,
Expand Down Expand Up @@ -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,
)
)
Expand All @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 7 additions & 4 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
}
)

Expand Down
10 changes: 5 additions & 5 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/canvas/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading