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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Every feature under `negpy/features/<name>/` follows this structure:

`DarkroomEngine.process()` runs stages in order: base (geometry + normalization) → exposure → retouch → lab → toning → crop → finish. The cached stages (base, exposure, retouch, lab) go through `_run_stage()`, which hashes the stage config and skips re-execution if the hash matches the cached `CacheEntry`; toning/crop/finish run unconditionally. Source image change clears the whole cache; a process-mode change invalidates only base/exposure/retouch/lab. Note `settings.geometry` drives both the early geometry transform and the late crop stage.

**Manual crop rect is in transformed (display) space.** `GeometryConfig.manual_crop_rect` is normalized to the **already-transformed** image (post rotation/fine-rotation/flip/distortion) — the space the user draws it on in the canvas overlay — so `get_manual_rect_coords` is a plain axis-aligned slice (`rect × transformed dims`), **not** a corner-map through `map_coords_to_geometry` + AABB (that inflated the crop as fine rotation tilted the mapped rect). The overlay maps screen↔rect linearly through its view rect (no uv_grid). Autocrop/retouch/dodge-burn placements still round-trip through `map_coords_to_geometry`/uv_grid; only the manual crop is display-space.

**Freehand analysis region.** `ProcessConfig.analysis_rect` (optional, transformed-normalized like the crop rect) overrides the centered `analysis_buffer` inset for the exposure meters: `resolve_analysis_region` (`exposure/normalization.py`) turns it into the analysis ROI with buffer 0, applied at the single chokepoint in `NormalizationProcessor` (CPU) and the GPU analysis path. It keys the CPU `base_key` and rides in `settings.process` for the GPU `_analysis_cache_key`. UI: a "draw region" tool (`ToolMode.ANALYSIS_DRAW`) on the Process page; the tool shows the uncropped preview (`crop_preview_full`) like the crop tool.

**Dodge/burn lives in the exposure stage.** Polygon masks (`negpy/features/local/`, `LocalAdjustmentsConfig`) rasterise to a per-pixel **EV map** (`compute_local_ev_map`, Σ strength·alpha) consumed by the print curve as a print-exposure offset next to the WB `cmy_offsets` (`local_ev_scale` = −log10(2)/stretch range per channel) — burns roll into paper black through the toe, dodges lift through the shoulder, like enlarger exposure. There is no separate local stage or shader: the exposure `_run_stage` is keyed on `(settings.exposure, settings.local)`, the GPU binds the EV map as a texture in the exposure pass (`ev_scale.w` gates a 1×1 dummy when no masks), and tiled export slices the full-res EV map per tile. The UI section sits on the Exposure ("tone") page.

**Flat-field profile (rig-level) + lens distortion.** Flat-field is a per-rig **profile** (DB `flatfield_profiles`: name, path, **k1**), not a per-image edit. `FlatFieldConfig` (`apply`, `reference_path`, `k1`) mirrors the active profile into the recipe but the profile is canonical — `reference_path` and `k1` are **re-seeded from the active profile on every file load** (`session.py`), so a roll shares them. The *photometric* correction (illumination falloff) is baked at **source-load** (`apply_flatfield`, before the pipeline → before any warp, so the flat stays registered to the raw sensor grid). The *geometric* correction is a single radial coefficient **k1** (poly3 / Brown-Conrady, half-diagonal–normalized so it's rotation/aspect invariant) folded into the **geometry transform** (`apply_radial_distortion` / `transform.wgsl`), gated by the same `apply` toggle (`k1_eff = k1 if apply else 0`). Being a geometry op, k1 invalidates the **base stage** (CPU `base_key`; GPU `_detect_invalidated_stage` checks `flatfield.apply`/`k1`) — **not** `source_hash`/`flatfield_token` (that would force a RAW re-decode per slider step). Scale-to-fill is baked into the same remap (`compute_distortion_scale`, no empty borders). The correction must stay consistent across the image warp **and** the coordinate mappers (`create_uv_grid` forward, `map_coords_to_geometry` inverse via `map_point_radial`) so crop/autocrop/retouch/dodge-burn placements stay aligned; CPU stages read the effective k1 from `context.metrics["distortion_k1"]`, GPU passes `k1_eff` directly. Tiled export applies it on the CPU `img_rot`/IR path (the shader geometry uniform is zeroed there).
Expand Down
47 changes: 42 additions & 5 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,10 @@ def handle_canvas_clicked(self, nx: float, ny: float) -> None:
self._handle_dust_pick(nx, ny)

def set_active_tool(self, mode: ToolMode) -> None:
crop_tool_changed = ToolMode.CROP_MANUAL in (self.state.active_tool, mode)
# Both the crop and analysis-region tools show the full uncropped frame, so
# crossing into/out of that set must re-render to swap the preview.
uncropped = {ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW}
preview_mode_changed = (self.state.active_tool in uncropped) != (mode in uncropped)
leaving_crop = self.state.active_tool == ToolMode.CROP_MANUAL and mode != ToolMode.CROP_MANUAL
self.state.active_tool = mode
self.tool_sync_requested.emit()
Expand All @@ -615,9 +618,7 @@ def set_active_tool(self, mode: ToolMode) -> None:
new_proc = replace(self.state.config.process, **invalidate_local_bounds(self.state.config.process))
self.session.update_config(replace(self.state.config, process=new_proc), render=False)
self._crop_bounds_dirty = False
if crop_tool_changed:
# Entering/leaving the crop tool swaps between the full uncropped preview
# and the normal cropped preview, so the canvas must re-render immediately.
if preview_mode_changed:
self.request_render()

def cancel_active_tool(self) -> None:
Expand Down Expand Up @@ -652,6 +653,42 @@ def handle_crop_rect_changed(self, nx1: float, ny1: float, nx2: float, ny2: floa
else:
self._render_debounce.start()

def confirm_manual_crop(self) -> None:
"""Close the crop tool (committing the current rect) — invoked by a double-click
inside the crop box so the user needn't return to the Crop button."""
if self.state.active_tool == ToolMode.CROP_MANUAL:
self.set_active_tool(ToolMode.NONE)

def handle_analysis_rect_changed(self, nx1: float, ny1: float, nx2: float, ny2: float, persist: bool) -> None:
"""Live-update (persist=False) or commit (persist=True) the freehand analysis
region while the tool is open. Setting a region re-meters the frame, so a commit
clears the per-file bounds (unless bounds are locked) and re-renders."""
if self.state.active_tool != ToolMode.ANALYSIS_DRAW:
return
rect = (min(nx1, nx2), min(ny1, ny2), max(nx1, nx2), max(ny1, ny2))
proc = replace(self.state.config.process, analysis_rect=rect)
if persist:
proc = replace(proc, **invalidate_local_bounds(proc))
self.session.update_config(replace(self.state.config, process=proc), persist=persist)
if persist:
self.request_render()
else:
self._render_debounce.start()

def clear_analysis_region(self) -> None:
"""Drop the freehand analysis region; metering falls back to the Analysis Buffer slider."""
if self.state.config.process.analysis_rect is None:
return
proc = replace(self.state.config.process, analysis_rect=None)
proc = replace(proc, **invalidate_local_bounds(proc))
self.session.update_config(replace(self.state.config, process=proc), persist=True)
self.request_render()

def confirm_analysis_region(self) -> None:
"""Close the analysis-region tool (double-click inside the region)."""
if self.state.active_tool == ToolMode.ANALYSIS_DRAW:
self.set_active_tool(ToolMode.NONE)

def reset_crop(self) -> None:
self._crop_bounds_dirty = False
new_proc = replace(self.state.config.process, **invalidate_local_bounds(self.state.config.process))
Expand Down Expand Up @@ -1271,7 +1308,7 @@ def request_render(
readback_metrics=readback_metrics,
ir_buffer=self.state.preview_ir,
monitor_icc_bytes=self.state.monitor_icc_bytes,
crop_preview_full=self.state.active_tool == ToolMode.CROP_MANUAL,
crop_preview_full=self.state.active_tool in (ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW),
ephemeral=ephemeral,
)

Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ToolMode(Enum):
DUST_PICK = auto()
SCRATCH_PICK = auto()
LOCAL_DRAW = auto()
ANALYSIS_DRAW = auto()


@dataclass
Expand Down
Loading
Loading