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
9 changes: 1 addition & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,5 @@ Every feature lives in `negpy/features/<name>/`:

## Style

- Use **ASD-STE100 Simplified Technical English**
- **Comments minimal.** Comment only non-obvious constraints the code can't express (a cache contract, an ordering requirement, a rejected-alternative trap). No comments that narrate what the next line does, restate the diff, or justify a change to a reviewer. Prefer one dense line over a paragraph; docstrings short and factual.

## Invariants & gotchas

- **CPU/GPU parity**: any change to a stage's math must land in both `logic.py` and its `.wgsl` shader. Constants mirrored as WGSL literals (histogram bins, zone density, metrics offsets) have parity tests — keep them in sync.
- **Working-space OETF + luminance row are inlined** in `lab_sharpen_h.wgsl` and `rl_init.wgsl` (Adobe RGB 1998 gamma 563/256, D65 Y row) — a TRC or primaries change must update them, not just `kernel/image/logic.py`. `LabUniforms` is declared in 6 lab shaders (lab, lab_sharpen_h/v, rl_blur_h, rl_div_v, rl_mult_v) — any field change touches all six plus the `struct.pack` in `gpu_engine.py`, and the trailing `_pad*` floats keep the block at 48 bytes. `rl_init.wgsl` binds no uniform at all (the auto layout prunes it).
- **Flat-field gains resolve through a provider, not the config.** The per-image `FlatFieldConfig` carries only an opaque `profile_id`; the baked gain map lives in a per-profile `.npz` in `APP_CONFIG.flatfield_dir` (`services/assets/flatfield.py`, the sensor/crosstalk file-store pattern). `apply_flatfield`/`flatfield_token` look the gain up via `set_gain_provider`, which `desktop/main.py` wires at startup — any render path outside the desktop app (a script, a headless test exercising flat-field) must call `set_gain_provider` first or the correction silently no-ops. Legacy DB profiles (the retired `flatfield_profiles` table) are one-shot migrated by `flatfield_migration.py`.
- **The content hash is an identity, not a checksum.** `file_hashes` (`kernel/image/logic.py`) keys every edit, mark, history step and thumbnail, so changing what it samples orphans every persisted edit in the wild. It samples size + 1 MiB head + 1 MiB tail + 16 interior chunks — the interior is not optional padding: without it, two same-size scans of one frame collide (identical container header and trailer) and only one can be opened. Any future change to the sampling repeats the migration dance in `services/assets/hash_migration.py`: return the superseded digest from the same pass, carry it on the asset as `legacy_hash`, and rehome. Files ≤ 2 MiB have no interior, so both digests agree.
- **The two IR methods must stay unshared.** `RetouchConfig.ir_method` picks between the chain in `retouch/logic.py` (`negpy`) and the Digital ICE port in `retouch/openice.py` (`openice`). `openice.py` imports nothing from `logic.py`; the only join is one branch in `ImageProcessor._ir_bake`. This is a bake-off, not an architecture: one gets deleted once real scans decide, and factoring out the "shared" parts is what would make that impossible. Constants that look duplicated (dead floor, route budget) are duplicated on purpose. See `docs/PIPELINE.md` §5.
11 changes: 9 additions & 2 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,15 @@ Here is what actually happens to your image. We apply these steps in order, pass
* **Shoulder**, highlights. Lifts the paper-white floor (compresses/greys highlights): $D_{min,eff} = D_{min} + \text{shoulder} \cdot 0.35$ (`shoulder_height`).
* **Grade-coupled baseline**: hard grades (high slope) physically have snappier toes and compressed shoulders, so a slope-proportional amount is added automatically (`toe_grade_strength` $\approx 0.058$, rescaled with the `toe_height` retune so the baseline $\Delta D$ matches the old $0.15 \cdot 0.35$, and `shoulder_grade_strength` $= 0.12$, scaled by the normalized slope).
* **Zone Density (ΔD)**: two achromatic sliders (`shadow_density` ±0.9, `highlight_density` ±0.5) brighten/darken the shadow and highlight zones without reshaping the knees. The slider value is a literal density offset at full zone weight. Unlike the regional CMY (a broad complementary blend that pushes half of each offset into the mids), each slider has its own **mid-sparing** weight centred in the three-quarter/quarter tones: $v \mathrel{+}= \Delta D_{sh} \cdot \sigma\big(k(v - z_{sh})\big) + \Delta D_{hl} \cdot \big(1 - \sigma(k(v - z_{hl}))\big)$ with $z_{sh} = z + 0.75$, $z_{hl} = z - 0.40$, $k = 4$ (`zone_density_*` constants, mirrored as literals in `exposure.wgsl`), so midtones get neither offset. It is applied before the softplus bounds, so a shadow burn can never exceed paper black and a highlight bleach never crosses paper white; a highlight burn shows first in the quarter-tones (near paper white the shoulder bound absorbs it, like a real print). Ranges are asymmetric because density is $\log_{10}$: an equal $\Delta D$ reads far smaller near $D_{max}$ than near $D_{min}$. The chart mirrors the shift (`CharacteristicCurve`).
* **Dodge & Burn** (`negpy.features.local`): polygon masks drawn over the print, each with a print exposure in **stops** (`PolygonMask.stops`, ±2, default 0; positive = burn / more light, negative = dodge / held back — exposure-signed like `vignette_stops`) and a Gaussian feather ($\sigma$ as a fraction of the short side). The masks rasterize to a per-pixel stop map added to the log-exposure input alongside the CMY offsets, so it is a true print-exposure change that rides the full curve rather than a brightness overlay. One stop is $\log_{10}(2)$ scaled by each channel's stretch range (`local_ev_scale`), so a 1-stop burn adds exactly one stop of print exposure regardless of the frame's bounds. Vertices are stored in raw-image coordinates and follow geometry (rotation, flips, distortion). The Flat intent skips them.
* **Local Grade** (`PolygonMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's.
* **Dodge & Burn** (`negpy.features.local`): masks drawn over the print, each with a print exposure in **stops** (`LocalMask.stops`, ±2, default 0; positive = burn / more light, negative = dodge / held back — exposure-signed like `vignette_stops`) and a Gaussian feather ($\sigma$ as a fraction of the short side). The masks rasterize to a per-pixel stop map added to the log-exposure input alongside the CMY offsets, so it is a true print-exposure change that rides the full curve rather than a brightness overlay. One stop is $\log_{10}(2)$ scaled by each channel's stretch range (`local_ev_scale`), so a 1-stop burn adds exactly one stop of print exposure regardless of the frame's bounds. Vertices are stored in raw-image coordinates and follow geometry (rotation, flips, distortion). The Flat intent skips them.
* **Mask shapes** (`LocalMask.shape`): the vertices are the universal store and `shape` says how to read them, so geometry mapping is shape-blind — every control point goes through `map_coords_to_geometry` the same way.
* *Polygon*: N control points, closed and Catmull-Rom smoothed (`smooth_polyline`). The smoothing bows the outline out past its own control points, by design — a cut card has no sharp corners either.
* *Oval*: 3 points, $(c, p_1, p_2)$. The outline is the unit circle under the affine frame $[u\ v]$, $u = p_1 - c$, $v = p_2 - c$: $c + u\cos\theta + v\sin\theta$ over 64 samples. The axes need not be perpendicular, so a tilted or sheared oval is the same expression, and because the points are mapped to pixels before the outline is generated, the raw-image aspect ratio is handled for free.
* *Gradient* (the card edge): 2 points $(a, b)$, alpha $= 1 - \text{smoothstep}(t)$ with $t = \big((p-a)\cdot d\big)/|d|^2$ clamped to $[0,1]$, $d = b - a$. Full exposure at and behind $a$, none at and past $b$. Feather does not apply — the handle spacing *is* the softness.
* *Invert* (`LocalMask.invert`): $\alpha \rightarrow 1 - \alpha$ after the feather, the card rather than the hole cut in it.

One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map, so none of the three can describe a different shape than the others. The GPU consumes the same CPU-rasterised map (`compute_local_maps` → the dodge/burn texture), so shapes need no shader work and have no parity surface.
* **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's.
* **Output**: Converts print density back to **scene-linear** reflectance (transmittance):
$$I_{out} = 10^{-D}$$
* **Paper Black** (`paper_black`, off): off applies black point compensation, the same idea as ICC relative-colorimetric soft-proofing. A reflection print's D-max ($2.3$) floors reflectance at $10^{-2.3} \approx 0.005$, but the adapted eye reads paper black as black, so the display should too. On preserves the paper's lifted D-max instead. With compensation (the default), each channel becomes $I_{out} = (I - t_b) / (1 - t_b)$, clamped at $0$, where $t_b = 10^{-D_b}$ and $D_b$ is the physical $D_{max}$, or $D_{max} + \text{toe}_{ch} \cdot 0.90$ when that layer's toe is negative. The curve reaches $D_{max}$ only asymptotically, so a **negative toe raises the clip point** into the shadows. That is what makes exact $0$ reachable ("negative toe deepens blacks", literally). A lifted toe and per-layer shadow casts survive because the reference is the *physical* $D_{max}$, not $D_{max,eff}$. A negative per-layer toe trim (with compensation on) tints the deepest black.
Expand Down
15 changes: 10 additions & 5 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,15 +377,20 @@ In R/G/B mode the sliders become per-layer trims on top of the global value, for
<!-- panel:local -->
### 6.3 Dodge & Burn: local exposure

Paint polygon masks and lighten or darken just those areas.
Draw masks and lighten or darken just those areas. Three shapes, one per darkroom move:

* **Draw Mask**: click to place vertices; double-click / Enter / a click near the start closes the mask; Esc cancels. To edit an existing mask, select it in the list, then drag a vertex, click an edge "+" to add a point, or right-click a vertex to delete.
* **Mask list**: each mask shows Dodge (lighten), Burn (darken) or Grade (contrast only), with the values it carries. The eye toggles its outline; the trash deletes it.
* **Draw Mask** (the cut card): click to place vertices; double-click / Enter / a click near the start closes the mask; Esc cancels. To edit an existing mask, select it in the list, then drag a vertex, click an edge "+" to add a point, or right-click a vertex to delete.
* **Oval** (the hole in the card, or a dodging wand): drag out an oval. Three handles: the centre moves it, the other two set each axis, so it can be stretched and tilted. It has a fixed three points — no adding or deleting them.
* **Card Edge** (the graduated burn): drag from the edge that gets the full exposure (solid line) to where it fades out (dashed). This is the printer moving a card across the paper — a sky burn, a corner held back. The gap between the two handles is the softness, so **Feather does nothing on this shape**.

Mask handles can go outside the picture, and a tilted Card Edge usually needs that: its line must start past the corner it burns, or the tilt cuts that corner off the full-exposure side. Drag into the grey area around the frame.
* **Mask list**: each mask shows its shape icon and Dodge (lighten), Burn (darken) or Grade (contrast only), with the values it carries. The eye toggles its outline; the trash deletes it.
* **Burn** (-2 to 2 stops, default 0): print exposure for the selected mask, signed the way the rest of NegPy signs light on paper — **positive burns** (longer exposure, darker paper), **negative dodges** (held back, brighter paper). Same direction as Print Density and the Finishing edge burn. A freshly drawn mask sits at 0, so it changes nothing until you give it a value.
* **Feather** (0.0 to 0.15): edge softness for the selected mask, as a fraction of the frame's short side.
* **Feather** (0.0 to 0.15): edge softness for the selected mask, as a fraction of the frame's short side. Inactive on a Card Edge.
* **Invert**: acts everywhere *except* inside the selected mask — the card itself rather than the hole cut in it. Burn the surround and hold the face with one shape.
* **Grade** (-40 to 40 R): prints the selected mask at its own contrast, in ISO-R points off the frame's Grade — negative is harder. This is the darkroom's burn-in through the hard filter: burn a sky at −20 R and it darkens without the highlights beside it flattening; dodge a face at +15 R and the shadow opens without going chalky. The rotation happens about the region's own midtone, so a mask with Burn 0 and a Grade set changes only contrast, not overall density. Overlapping masks add their grades, and the result is clamped to the ISO-R ladder (R50…R180) like every other grade in NegPy.

**Printing Notes** (Export tab, or **Shift+N**) turns the frame into the printer's marked-up work print. Each mask is outlined and labelled with its number and its value in stops, and a card in the corner carries the print recipe: paper, Print Density, ISO-R Grade (with the split-grade trims when they are set), filtration, toe/shoulder, Snap, edge burn, and the dodge/burn list.
**Printing Notes** (Export tab, or **Shift+N**) turns the frame into the printer's marked-up work print. Each mask is outlined and labelled with its number and its value in stops — a Card Edge has no outline, so it is marked as the side of the frame that gets the full exposure — and a card in the corner carries the print recipe: paper, Print Density, ISO-R Grade (with the split-grade trims when they are set), filtration, toe/shoulder, Snap, edge burn, and the dodge/burn list.

Two conventions worth knowing, both borrowed from the darkroom rather than from the sliders:

Expand Down
24 changes: 17 additions & 7 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2364,17 +2364,19 @@ def _commit_heal_stroke(self, raw_pts: list) -> None:
)
self.request_render()

def handle_lasso_completed(self, viewport_vertices: list) -> None:
def handle_local_mask_created(self, shape: str, viewport_vertices: list) -> None:
from negpy.features.local.logic import min_points
from negpy.features.local.models import LocalMask, MaskShape

mask_shape = MaskShape(shape)
with self.state.metrics_lock:
uv_grid = self.state.last_metrics.get("uv_grid")
if uv_grid is None or len(viewport_vertices) < 3:
if uv_grid is None or len(viewport_vertices) < min_points(mask_shape):
return

raw_vertices = tuple(CoordinateMapping.map_click_to_raw(nx, ny, uv_grid) for nx, ny in viewport_vertices)

from negpy.features.local.models import PolygonMask

mask = PolygonMask(vertices=raw_vertices)
mask = LocalMask(vertices=raw_vertices, shape=mask_shape)
local = self.state.config.local
new_masks = local.masks + (mask,)
new_local = replace(local, masks=new_masks)
Expand All @@ -2386,10 +2388,14 @@ def handle_lasso_completed(self, viewport_vertices: list) -> None:

def handle_local_mask_edited(self, index: int, viewport_vertices: list) -> None:
"""Replace a mask's vertices after an on-canvas drag/add edit (persist on release)."""
from negpy.features.local.logic import min_points

with self.state.metrics_lock:
uv_grid = self.state.last_metrics.get("uv_grid")
local = self.state.config.local
if uv_grid is None or not (0 <= index < len(local.masks)) or len(viewport_vertices) < 3:
if uv_grid is None or not (0 <= index < len(local.masks)):
return
if len(viewport_vertices) < min_points(local.masks[index].shape):
return
raw_vertices = tuple(CoordinateMapping.map_click_to_raw(nx, ny, uv_grid) for nx, ny in viewport_vertices)
masks = list(local.masks)
Expand All @@ -2400,11 +2406,15 @@ def handle_local_mask_edited(self, index: int, viewport_vertices: list) -> None:
self.request_render()

def delete_local_vertex(self, index: int, vertex_index: int) -> None:
"""Remove one vertex from a mask (keeps a minimum of 3)."""
"""Remove one vertex from a polygon mask. Keep a minimum of 3 vertices."""
from negpy.features.local.models import MaskShape

local = self.state.config.local
if not (0 <= index < len(local.masks)):
return
mask = local.masks[index]
if mask.shape != MaskShape.POLYGON:
return
if len(mask.vertices) <= 3 or not (0 <= vertex_index < len(mask.vertices)):
return
verts = mask.vertices[:vertex_index] + mask.vertices[vertex_index + 1 :]
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class ToolMode(Enum):
DUST_PICK = auto()
SCRATCH_PICK = auto()
LOCAL_DRAW = auto()
LOCAL_OVAL = auto()
LOCAL_GRADIENT = auto()
ANALYSIS_DRAW = auto()
STRAIGHTEN = auto()
ZONE_PLACE = auto()
Expand Down
Loading
Loading