From a3725bc305efcae0a98873abf8f0c19b01e53335 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 8 Aug 2026 15:37:27 +0200 Subject: [PATCH 1/3] docs: require ASD-STE100 Simplified Technical English Trim the Style section to the two rules that govern how prose gets written. --- CLAUDE.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b43e143f..890c73e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,12 +77,5 @@ Every feature lives in `negpy/features//`: ## 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. From 918f1c02d6680d2b97abdb3d8c1c6da6ad1ed521 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 8 Aug 2026 15:37:27 +0200 Subject: [PATCH 2/3] feat: oval and card-edge dodge/burn masks, plus per-mask invert Dodge & Burn had one shape, a clicked polygon. Two common darkroom moves did not fit it: burning through a hole in the card (a smooth oval took a dozen vertices) and the graduated card-edge burn (an unbounded ramp that a closed polygon and a Gaussian cannot express). Add a `shape` field to the mask, renamed `PolygonMask` -> `LocalMask`. The vertices stay the universal store and `shape` says how to read them: a polygon keeps N control points, an oval takes 3 (centre and one end of each axis, an affine frame that permits oblique axes), a gradient takes 2. Geometry mapping stays shape-blind, because every control point still goes through `map_coords_to_geometry`. An oval outline is generated, so the existing `fillPoly` and feather serve it unchanged. The gradient is a smoothstep ramp along its axis, and ignores Feather because the distance between its handles sets the softness. `invert` flips the alpha, which applies a mask outside its own shape. One rasteriser (`local/logic.rasterise`) now serves the render, the canvas tint and the printing-notes map. The GPU consumes the same CPU-rasterised map, so no shader work and no parity surface. Old saves load as polygons. --- docs/PIPELINE.md | 11 +- docs/USER_GUIDE.md | 13 +- negpy/desktop/controller.py | 24 +- negpy/desktop/session.py | 2 + negpy/desktop/view/canvas/overlay.py | 304 ++++++++++++++++---- negpy/desktop/view/canvas/printing_notes.py | 38 ++- negpy/desktop/view/canvas/widget.py | 6 +- negpy/desktop/view/keyboard_shortcuts.py | 2 + negpy/desktop/view/main_window.py | 2 +- negpy/desktop/view/shortcut_registry.py | 2 + negpy/desktop/view/sidebar/local.py | 68 ++++- negpy/domain/models.py | 6 +- negpy/features/local/logic.py | 83 ++++-- negpy/features/local/models.py | 24 +- negpy/services/view/printing_notes.py | 2 +- tests/test_canvas_border_mapping.py | 2 +- tests/test_canvas_mask_edit.py | 69 ++++- tests/test_canvas_polyline_finish.py | 6 +- tests/test_controller.py | 27 +- tests/test_desktop_session.py | 4 +- tests/test_gpu_curve_parity.py | 6 +- tests/test_local_grade.py | 4 +- tests/test_local_logic.py | 89 +++++- tests/test_local_overlay.py | 51 +++- tests/test_local_sidebar.py | 58 +++- tests/test_pipeline_parity.py | 10 +- tests/test_printing_notes.py | 14 +- tests/test_printing_notes_overlay.py | 20 +- tests/test_sidecar.py | 4 +- 29 files changed, 760 insertions(+), 191 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 70d97e94..06d955b7 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -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. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bf45dd2e..f77a0e20 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -377,15 +377,18 @@ In R/G/B mode the sliders become per-layer trims on top of the global value, for ### 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 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: diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index f4298722..50c1bc85 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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) @@ -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) @@ -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 :] diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index a7d642f9..9e82a1c4 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -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() diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index ec7cb3db..2138b777 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -13,7 +13,7 @@ from negpy.desktop.converters import ImageConverter from negpy.desktop.session import AppState, ToolMode from negpy.desktop.view.canvas.crop_guides import CropGuide, guide_shapes -from negpy.desktop.view.canvas.printing_notes import notes_sheet, paint_card, paint_map +from negpy.desktop.view.canvas.printing_notes import notes_outline, notes_sheet, paint_card, paint_map from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.stats import PIN_COLOURS from negpy.features.exposure.analysis import ( @@ -34,7 +34,8 @@ ) 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.local.logic import _rasterise_mask +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 from negpy.services.view.coordinate_mapping import CoordinateMapping from negpy.services.view.printing_notes import mask_notes, recipe_lines @@ -52,6 +53,14 @@ _GRID_ALPHA = 70 _MASK_RASTER_MAX = 384 # px cap for feathered overlay rasters +# The shape that each tool draws, and the tools that permit mask edits. +_SHAPE_FOR_TOOL = { + ToolMode.LOCAL_DRAW: MaskShape.POLYGON, + ToolMode.LOCAL_OVAL: MaskShape.OVAL, + ToolMode.LOCAL_GRADIENT: MaskShape.GRADIENT, +} +_LOCAL_TOOLS = (ToolMode.NONE, *_SHAPE_FOR_TOOL) + # Dust-overlay marker colours: bright, distinct from the muted accent used by # manual heals so detected auto vs IR spots are told apart at a glance. _DUST_MARK_LUMA = QColor(57, 255, 20) # neon green — auto-luma detection @@ -134,13 +143,24 @@ def _distance_to_polyline(pos: QPointF, pts: List[QPointF]) -> float: return best -def feathered_mask_image(local_pts: List[Tuple[float, float]], w: int, h: int, sigma_px: float, color: QColor, max_alpha: int) -> QImage: - """Tinted premultiplied-alpha QImage of a feathered polygon. - - `local_pts` in raster pixel coords; `sigma_px` in raster pixels. +def feathered_mask_image( + shape: MaskShape, + local_pts: List[Tuple[float, float]], + w: int, + h: int, + sigma_px: float, + color: QColor, + max_alpha: int, + invert: bool = False, +) -> QImage: + """A tinted premultiplied-alpha QImage of a feathered mask. + + `local_pts` are the control points, in raster pixels, and `sigma_px` is also in + raster pixels. The engine rasteriser makes the alpha, so the tint agrees with + the render. """ norm = [(x / w, y / h) for x, y in local_pts] - alpha = _rasterise_mask(norm, h, w, sigma_px) + alpha = rasterise(shape, norm, h, w, sigma_px, invert) a = alpha * (max_alpha / 255.0) buf = np.empty((h, w, 4), dtype=np.uint8) buf[..., 0] = (color.red() * a).astype(np.uint8) @@ -164,7 +184,7 @@ class CanvasOverlay(QWidget): analysis_confirmed = pyqtSignal() cursor_moved = pyqtSignal(float, float) cursor_left = pyqtSignal() - lasso_completed = pyqtSignal(list) + local_mask_created = pyqtSignal(str, list) # (shape value, viewport-normalised points) scratch_completed = pyqtSignal(list) local_mask_selected = pyqtSignal(int) local_mask_edited = pyqtSignal(int, list) # (mask index, viewport-normalized vertices) @@ -216,10 +236,17 @@ def __init__(self, state: AppState, parent=None): self._lasso_pts: List[QPointF] = [] self._lasso_drawing: bool = False + # The oval and card-edge tools drag out a shape. They do not click each point. + self._shape_draw_p1: Optional[QPointF] = None + self._shape_draw_p2: Optional[QPointF] = None + # Scratch heal (open polyline) interaction state self._scratch_pts: List[QPointF] = [] self._heal_drag_pts: List[QPointF] = [] + # Per mask, in list order: the outline (hit test, notes) and the control points + # (drag handles). Only a polygon has the same points in both lists. self._local_mask_screen_polys: List[List[QPointF]] = [] + self._local_mask_screen_ctrl: List[List[QPointF]] = [] self._mask_img_cache: Dict[tuple, QImage] = {} # Geometry-aligned IR layer raster, cached by (uv_grid, preview_ir) @@ -244,6 +271,8 @@ def __init__(self, state: AppState, parent=None): # Working screen points while a selected-mask vertex is dragged/added. self._local_edit_verts: Optional[List[QPointF]] = None self._local_drag_vertex: Optional[int] = None + # Set when the handle moves the full mask, as an oval centre does. + self._local_drag_anchor: Optional[QPointF] = None # Straighten tool: reference-line drag (press -> drag -> release applies). self._straighten_p1: Optional[QPointF] = None @@ -341,7 +370,10 @@ def set_tool_mode(self, mode: ToolMode) -> None: if mode != ToolMode.LOCAL_DRAW: self._lasso_pts = [] self._lasso_drawing = False - self._end_local_edit() + self._end_local_edit() + if mode not in (ToolMode.LOCAL_OVAL, ToolMode.LOCAL_GRADIENT): + self._shape_draw_p1 = None + self._shape_draw_p2 = None if mode != ToolMode.SCRATCH_PICK: self._scratch_pts = [] if mode != ToolMode.DUST_PICK: @@ -356,6 +388,7 @@ def set_tool_mode(self, mode: ToolMode) -> None: def _end_local_edit(self) -> None: self._local_edit_verts = None self._local_drag_vertex = None + self._local_drag_anchor = None def _end_crop_drag(self) -> None: self._crop_drag_mode = None @@ -385,6 +418,11 @@ def cancel_in_progress(self) -> bool: self._lasso_drawing = False self.update() return True + if self._shape_draw_p1 is not None: + self._shape_draw_p1 = None + self._shape_draw_p2 = None + self.update() + return True if self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: self._scratch_pts = [] self.update() @@ -472,6 +510,10 @@ def remap(p: QPointF) -> QPointF: if self._lasso_pts: self._lasso_pts = [remap(p) for p in self._lasso_pts] + if self._shape_draw_p1 is not None: + self._shape_draw_p1 = remap(self._shape_draw_p1) + if self._shape_draw_p2 is not None: + self._shape_draw_p2 = remap(self._shape_draw_p2) if self._scratch_pts: self._scratch_pts = [remap(p) for p in self._scratch_pts] if self._heal_drag_pts: @@ -545,7 +587,7 @@ def _draw_ui(self, painter: QPainter) -> None: if self._tool_mode != ToolMode.NONE and visible_rect.contains(self._mouse_pos): if self._tool_mode in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): self._draw_brush(painter) - elif self._tool_mode != ToolMode.LOCAL_DRAW: + elif self._tool_mode not in _SHAPE_FOR_TOOL: pen = QPen(QColor(255, 255, 255, 80), 1, Qt.PenStyle.DotLine) pen.setCosmetic(True) painter.setPen(pen) @@ -556,6 +598,8 @@ def _draw_ui(self, painter: QPainter) -> None: self._draw_local_masks(painter) if self._tool_mode == ToolMode.LOCAL_DRAW: self._draw_lasso_in_progress(painter) + if self._tool_mode in (ToolMode.LOCAL_OVAL, ToolMode.LOCAL_GRADIENT): + self._draw_shape_in_progress(painter) if self._tool_mode in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): self._draw_placed_heals(painter) if self._tool_mode == ToolMode.SCRATCH_PICK: @@ -1520,6 +1564,7 @@ def _draw_local_masks(self, painter: QPainter) -> None: return masks = self.state.config.local.masks self._local_mask_screen_polys = [] + self._local_mask_screen_ctrl = [] if not masks: return @@ -1531,19 +1576,21 @@ def _draw_local_masks(self, painter: QPainter) -> None: selected = getattr(self.state, "local_selected_mask", -1) fresh_cache: Dict[tuple, QImage] = {} for i, mask in enumerate(masks): - if len(mask.vertices) < 3: + is_selected = i == selected + if len(mask.vertices) < min_points(mask.shape): self._local_mask_screen_polys.append([]) + self._local_mask_screen_ctrl.append([]) continue ctrl = [self._raw_to_screen(rx, ry, uv_grid) for rx, ry in mask.vertices] - self._local_mask_screen_polys.append(ctrl) - - is_selected = i == selected - if i in getattr(self.state, "local_hidden_masks", ()): - continue working = self._local_edit_verts if is_selected else None drag_this = working is not None draw_ctrl = working if working is not None else ctrl - curve = smooth_polyline([(p.x(), p.y()) for p in draw_ctrl], closed=True) + curve = outline_points(mask.shape, [(p.x(), p.y()) for p in draw_ctrl]) + self._local_mask_screen_polys.append([QPointF(x, y) for x, y in curve]) + self._local_mask_screen_ctrl.append(ctrl) + + if i in getattr(self.state, "local_hidden_masks", ()): + continue outline = QColor(74, 143, 232) if mask.stops > 0 else QColor(232, 200, 74) max_alpha = 70 if is_selected else 32 @@ -1551,19 +1598,25 @@ def _draw_local_masks(self, painter: QPainter) -> None: if not drag_this: sigma_screen = mask.feather * min(self._view_rect.width(), self._view_rect.height()) pad = 3.0 * sigma_screen + 2.0 - xs = [x for x, _ in curve] - ys = [y for _, y in curve] - x0, y0 = min(xs) - pad, min(ys) - pad - bw, bh = max(xs) + pad - x0, max(ys) + pad - y0 + # A gradient has no boundary, and an inverted mask applies outside its + # own. Rasterise both on the full frame, not on a padded bounding box. + if mask.shape == MaskShape.GRADIENT or mask.invert: + box = self._content_view_rect() + x0, y0, bw, bh = box.x(), box.y(), box.width(), box.height() + else: + xs = [x for x, _ in curve] + ys = [y for _, y in curve] + x0, y0 = min(xs) - pad, min(ys) - pad + bw, bh = max(xs) + pad - x0, max(ys) + pad - y0 scale = min(1.0, _MASK_RASTER_MAX / max(bw, bh, 1.0)) rw, rh = max(int(bw * scale), 2), max(int(bh * scale), 2) # Bbox-relative points are pan-invariant, so panning reuses the cache. - local = tuple((round((x - x0) * scale, 1), round((y - y0) * scale, 1)) for x, y in curve) + local = tuple((round((p.x() - x0) * scale, 1), round((p.y() - y0) * scale, 1)) for p in draw_ctrl) - key = (local, rw, rh, round(sigma_screen * scale, 2), outline.rgb(), max_alpha) + key = (mask.shape, mask.invert, local, rw, rh, round(sigma_screen * scale, 2), outline.rgb(), max_alpha) img = self._mask_img_cache.get(key) if img is None: - img = feathered_mask_image(local, rw, rh, sigma_screen * scale, outline, max_alpha) + img = feathered_mask_image(mask.shape, local, rw, rh, sigma_screen * scale, outline, max_alpha, mask.invert) fresh_cache[key] = img painter.drawImage(QRectF(x0, y0, bw, bh), img) @@ -1576,12 +1629,35 @@ def _draw_local_masks(self, painter: QPainter) -> None: pen.setCosmetic(True) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawPolygon(QPolygonF([QPointF(x, y) for x, y in curve])) + if mask.shape == MaskShape.GRADIENT: + self._draw_gradient_axis(painter, draw_ctrl[0], draw_ctrl[1]) + else: + painter.drawPolygon(QPolygonF([QPointF(x, y) for x, y in curve])) - if is_selected and self._tool_mode in (ToolMode.NONE, ToolMode.LOCAL_DRAW) and not self._lasso_drawing: - self._draw_local_handles(painter, draw_ctrl, outline) + if is_selected and self._tool_mode in _LOCAL_TOOLS and not self._lasso_drawing: + self._draw_local_handles(painter, mask.shape, draw_ctrl, outline) self._mask_img_cache = fresh_cache + def _draw_gradient_axis(self, painter: QPainter, a: QPointF, b: QPointF) -> None: + """Draw the card edge. A solid line shows full exposure, a dashed line shows + zero exposure, and a third line joins them.""" + dx, dy = b.x() - a.x(), b.y() - a.y() + length = math.hypot(dx, dy) + if length < 1e-3: + return + # Make the perpendicular long enough to cross the frame at any angle. + span = self._content_view_rect() + reach = math.hypot(span.width(), span.height()) + px, py = -dy / length * reach, dx / length * reach + pen = painter.pen() + for point, style in ((a, Qt.PenStyle.SolidLine), (b, Qt.PenStyle.DashLine)): + edge = QPen(pen) + edge.setStyle(style) + painter.setPen(edge) + painter.drawLine(QPointF(point.x() - px, point.y() - py), QPointF(point.x() + px, point.y() + py)) + painter.setPen(pen) + painter.drawLine(a, b) + def _frame_name(self) -> str: path = self.state.current_file_path return os.path.basename(path) if path else "" @@ -1594,13 +1670,17 @@ def _draw_printing_notes(self, painter: QPainter) -> None: """The printer's marked-up work print: hatched burns, open dodges, ±stop badges, and the print recipe. Every mask is on the map, hidden ones included — the eye unclutters editing, but a record that omits a burn is wrong.""" + rect = self._content_view_rect() polys = [ - ([QPointF(x, y) for x, y in smooth_polyline([(p.x(), p.y()) for p in pts], closed=True)], note) - for pts, note in zip(self._local_mask_screen_polys, mask_notes(self.state.config.local, self.state.config.exposure.grade)) - if len(pts) >= 3 + (notes_outline(mask.shape, ctrl, rect), note) + for mask, ctrl, note in zip( + self.state.config.local.masks, + self._local_mask_screen_ctrl, + mask_notes(self.state.config.local, self.state.config.exposure.grade), + ) + if len(ctrl) >= min_points(mask.shape) ] paint_map(painter, polys) - rect = self._content_view_rect() paint_card(painter, QPointF(rect.x() + _NOTES_CARD_INSET_PX, rect.y() + _NOTES_CARD_TOP_PX), self._recipe_lines()) def printing_notes_sheet(self) -> Optional[QImage]: @@ -1616,8 +1696,9 @@ def printing_notes_sheet(self) -> Optional[QImage]: self._qimage, self._content_rect, self.state.config.local, uv_grid, self._recipe_lines(), self.state.config.exposure.grade ) - def _draw_local_handles(self, painter: QPainter, ctrl_pts: List[QPointF], color: QColor) -> None: - """Draggable vertices + '+' discs on edge midpoints for the selected mask.""" + def _draw_local_handles(self, painter: QPainter, shape: MaskShape, ctrl_pts: List[QPointF], color: QColor) -> None: + """Draw the vertex handles. Only a polygon gets the '+' discs, because only a + polygon can take more points.""" n = len(ctrl_pts) if n < 2: return @@ -1625,7 +1706,7 @@ def _draw_local_handles(self, painter: QPainter, ctrl_pts: List[QPointF], color: # Edge-midpoint "add point" handles: white disc with a plus glyph. plus_pen = QPen(QColor(35, 35, 35, 235), 1.5) plus_pen.setCosmetic(True) - for i in range(n): + for i in range(n if shape == MaskShape.POLYGON else 0): a, b = ctrl_pts[i], ctrl_pts[(i + 1) % n] m = QPointF((a.x() + b.x()) / 2.0, (a.y() + b.y()) / 2.0) painter.setPen(Qt.PenStyle.NoPen) @@ -1666,6 +1747,27 @@ def _draw_lasso_in_progress(self, painter: QPainter) -> None: r = 5.0 if near_close else 3.0 painter.drawEllipse(first, r, r) + def _draw_shape_in_progress(self, painter: QPainter) -> None: + """Draw the oval or the card edge during the drag, in the lasso white.""" + if self._shape_draw_p1 is None or self._shape_draw_p2 is None: + return + pen = QPen(Qt.GlobalColor.white, 1.5, Qt.PenStyle.SolidLine) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + if self._tool_mode == ToolMode.LOCAL_GRADIENT: + self._draw_gradient_axis(painter, self._shape_draw_p1, self._shape_draw_p2) + return + ctrl = self._oval_ctrl_from_drag(self._shape_draw_p1, self._shape_draw_p2) + curve = outline_points(MaskShape.OVAL, [(p.x(), p.y()) for p in ctrl]) + painter.drawPolygon(QPolygonF([QPointF(x, y) for x, y in curve])) + + @staticmethod + def _oval_ctrl_from_drag(p1: QPointF, p2: QPointF) -> List[QPointF]: + """Convert a bounding-box drag to the oval centre and its two axis ends.""" + cx, cy = (p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0 + return [QPointF(cx, cy), QPointF(p2.x(), cy), QPointF(cx, p2.y())] + def _map_to_image_coords(self, screen_pos: QPointF) -> Optional[Tuple[float, float]]: rect = self._content_view_rect() if rect.isEmpty() or not rect.contains(screen_pos): @@ -1715,6 +1817,11 @@ def mousePressEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._tool_mode in (ToolMode.LOCAL_OVAL, ToolMode.LOCAL_GRADIENT): + self._handle_shape_press(event.position()) + event.accept() + return + if self._tool_mode == ToolMode.SCRATCH_PICK: if self._content_view_rect().contains(event.position()): self._scratch_pts.append(event.position()) @@ -1897,7 +2004,22 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: rect = self._content_view_rect() px = float(np.clip(event.position().x(), rect.left(), rect.right())) py = float(np.clip(event.position().y(), rect.top(), rect.bottom())) - self._local_edit_verts[self._local_drag_vertex] = QPointF(px, py) + if self._local_drag_anchor is not None: + delta = QPointF(px, py) - self._local_drag_anchor + self._local_drag_anchor = QPointF(px, py) + self._local_edit_verts = [p + delta for p in self._local_edit_verts] + else: + self._local_edit_verts[self._local_drag_vertex] = QPointF(px, py) + self.update() + event.accept() + return + + if self._shape_draw_p1 is not None and event.buttons() & Qt.MouseButton.LeftButton: + rect = self._content_view_rect() + self._shape_draw_p2 = QPointF( + float(np.clip(event.position().x(), rect.left(), rect.right())), + float(np.clip(event.position().y(), rect.top(), rect.bottom())), + ) self.update() event.accept() return @@ -2022,13 +2144,33 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: self.update() - def _selected_mask_screen_pts(self) -> Optional[List[QPointF]]: + def _selected_mask(self): + """The selected mask and its screen control points, or None.""" idx = getattr(self.state, "local_selected_mask", -1) - if 0 <= idx < len(self._local_mask_screen_polys): - pts = self._local_mask_screen_polys[idx] - return pts if len(pts) >= 3 else None + masks = self.state.config.local.masks + if 0 <= idx < len(masks) and idx < len(self._local_mask_screen_ctrl): + pts = self._local_mask_screen_ctrl[idx] + if len(pts) >= min_points(masks[idx].shape): + return masks[idx], pts return None + def _try_select_mask_at(self, pos: QPointF) -> bool: + """Select the mask at `pos`, inside its outline. A card edge has no inside, so + it hits near its axis. Returns True if a mask is hit.""" + masks = self.state.config.local.masks + for i, poly_pts in enumerate(self._local_mask_screen_polys): + if i >= len(masks): + break + if masks[i].shape == MaskShape.GRADIENT: + ctrl = self._local_mask_screen_ctrl[i] + hit = len(ctrl) >= 2 and _distance_to_polyline(pos, ctrl) <= _CROP_HANDLE_PX + else: + hit = len(poly_pts) >= 3 and QPolygonF(poly_pts).containsPoint(pos, Qt.FillRule.OddEvenFill) + if hit: + self.local_mask_selected.emit(i) + return True + return False + def _hit_local_vertex(self, pos: QPointF, pts: List[QPointF]) -> Optional[int]: for i, p in enumerate(pts): dx, dy = pos.x() - p.x(), pos.y() - p.y() @@ -2048,11 +2190,12 @@ def _hit_local_edge_midpoint(self, pos: QPointF, pts: List[QPointF]) -> Optional return None def try_delete_local_vertex(self, pos: QPointF) -> bool: - """Right-click on a selected-mask vertex removes it. Returns True if handled.""" - pts = self._selected_mask_screen_pts() - if pts is None: + """Delete the vertex at `pos` from the selected mask. Only a polygon can lose a + point. Returns True if handled.""" + selected = self._selected_mask() + if selected is None or selected[0].shape != MaskShape.POLYGON: return False - vi = self._hit_local_vertex(pos, pts) + vi = self._hit_local_vertex(pos, selected[1]) if vi is None: return False self.local_vertex_deleted.emit(getattr(self.state, "local_selected_mask", -1), vi) @@ -2060,15 +2203,20 @@ def try_delete_local_vertex(self, pos: QPointF) -> bool: def _try_start_vertex_edit(self, pos: QPointF) -> bool: """Grab a selected-mask vertex, or insert one at an edge midpoint; True if started.""" - pts = self._selected_mask_screen_pts() - if pts is None: + selected = self._selected_mask() + if selected is None: return False + mask, pts = selected vi = self._hit_local_vertex(pos, pts) if vi is not None: self._local_edit_verts = list(pts) self._local_drag_vertex = vi + # The oval centre moves its axes with it. All other handles move alone. + self._local_drag_anchor = pos if (mask.shape == MaskShape.OVAL and vi == 0) else None self.update() return True + if mask.shape != MaskShape.POLYGON: + return False ei = self._hit_local_edge_midpoint(pos, pts) if ei is not None: work = list(pts) @@ -2085,14 +2233,8 @@ def _handle_lasso_press(self, pos: QPointF) -> None: return if not self._lasso_drawing: - if self._try_start_vertex_edit(pos): + if self._try_start_vertex_edit(pos) or self._try_select_mask_at(pos): return - for i, poly_pts in enumerate(self._local_mask_screen_polys): - if len(poly_pts) < 3: - continue - if QPolygonF(poly_pts).containsPoint(pos, Qt.FillRule.OddEvenFill): - self.local_mask_selected.emit(i) - return self._lasso_drawing = True self._lasso_pts = [pos] self.update() @@ -2110,19 +2252,52 @@ def _finish_lasso(self) -> None: pts = self._lasso_pts self._lasso_pts = [] self._lasso_drawing = False - if len(pts) < 3: - self.update() - return + self._emit_mask(MaskShape.POLYGON, pts) + + def _emit_mask(self, shape: MaskShape, pts: List[QPointF]) -> None: + """Send the drawn points to the controller. Discard the mask if one point is + outside the frame.""" vertices = [] - for pt in pts: - coords = self._map_to_image_coords(pt) - if coords is None: - self.update() - return - vertices.append(coords) - self.lasso_completed.emit(vertices) + if len(pts) >= min_points(shape): + for pt in pts: + coords = self._map_to_image_coords(pt) + if coords is None: + self.update() + return + vertices.append(coords) + self.local_mask_created.emit(str(shape), vertices) + self.update() + + def _handle_shape_press(self, pos: QPointF) -> None: + """Start the drag of an oval or a card edge. A click on an existing mask + selects that mask, as the lasso tool does.""" + rect = self._content_view_rect() + if not rect.contains(pos): + return + if self._try_start_vertex_edit(pos) or self._try_select_mask_at(pos): + return + self._shape_draw_p1 = pos + self._shape_draw_p2 = pos self.update() + def _finish_shape_draw(self, pos: QPointF) -> None: + p1, self._shape_draw_p1, self._shape_draw_p2 = self._shape_draw_p1, None, None + if p1 is None: + return + rect = self._content_view_rect() + p2 = QPointF( + float(np.clip(pos.x(), rect.left(), rect.right())), + float(np.clip(pos.y(), rect.top(), rect.bottom())), + ) + # A click without movement is an error. Do not make a mask with no size. + if (p2 - p1).manhattanLength() < 8.0: + self.update() + return + if self._tool_mode == ToolMode.LOCAL_GRADIENT: + self._emit_mask(MaskShape.GRADIENT, [p1, p2]) + else: + self._emit_mask(MaskShape.OVAL, self._oval_ctrl_from_drag(p1, p2)) + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: if self._tool_mode == ToolMode.LOCAL_DRAW and self._lasso_drawing: self._finish_lasso() @@ -2267,6 +2442,11 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._shape_draw_p1 is not None and event.button() == Qt.MouseButton.LeftButton: + self._finish_shape_draw(event.position()) + event.accept() + return + if self._local_drag_vertex is not None: verts = self._local_edit_verts or [] selected = getattr(self.state, "local_selected_mask", -1) diff --git a/negpy/desktop/view/canvas/printing_notes.py b/negpy/desktop/view/canvas/printing_notes.py index da4c0c1e..e82b53cb 100644 --- a/negpy/desktop/view/canvas/printing_notes.py +++ b/negpy/desktop/view/canvas/printing_notes.py @@ -9,14 +9,15 @@ hairline would vanish. """ +import math from typing import List, Optional, Sequence, Tuple import numpy as np from PyQt6.QtCore import QPointF, QRectF, Qt from PyQt6.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPolygonF -from negpy.features.geometry.logic import smooth_polyline -from negpy.features.local.models import LocalAdjustmentsConfig +from negpy.features.local.logic import min_points, outline_points +from negpy.features.local.models import LocalAdjustmentsConfig, MaskShape from negpy.services.view.coordinate_mapping import CoordinateMapping from negpy.services.view.printing_notes import MaskNote, mask_notes @@ -150,17 +151,42 @@ def paint_card( return rect +def notes_outline(shape: MaskShape, ctrl: List[QPointF], content: QRectF) -> List[QPointF]: + """The region that a mask marks on the work print. + + A card edge has no boundary, so it shows as the half plane that gets the full + exposure. The ramp is a soft edge, like the feather of a polygon, and stays undrawn. + """ + if shape != MaskShape.GRADIENT: + return [QPointF(x, y) for x, y in outline_points(shape, [(p.x(), p.y()) for p in ctrl])] + a, b = ctrl[0], ctrl[1] + dx, dy = b.x() - a.x(), b.y() - a.y() + length = math.hypot(dx, dy) + if length < 1e-6: + return [] + reach = math.hypot(content.width(), content.height()) + ux, uy = dx / length * reach, dy / length * reach + px, py = -dy / length * reach, dx / length * reach + far = QPointF(a.x() - ux, a.y() - uy) + return [ + QPointF(a.x() - px, a.y() - py), + QPointF(a.x() + px, a.y() + py), + QPointF(far.x() + px, far.y() + py), + QPointF(far.x() - px, far.y() - py), + ] + + def mapped_polys(local: LocalAdjustmentsConfig, uv_grid: Optional[np.ndarray], content: QRectF, grade: float = 0.0) -> List[Poly]: - """Mask vertices as smoothed polygons inside `content`, paired with their notes.""" + """The mask outlines inside `content`, with their notes.""" polys: List[Poly] = [] if uv_grid is None: return polys for mask, note in zip(local.masks, mask_notes(local, grade)): - if len(mask.vertices) < 3: + if len(mask.vertices) < min_points(mask.shape): continue ctrl = [CoordinateMapping.map_raw_to_viewport(rx, ry, uv_grid) for rx, ry in mask.vertices] - curve = smooth_polyline([(content.x() + nx * content.width(), content.y() + ny * content.height()) for nx, ny in ctrl], closed=True) - polys.append(([QPointF(x, y) for x, y in curve], note)) + screen = [QPointF(content.x() + nx * content.width(), content.y() + ny * content.height()) for nx, ny in ctrl] + polys.append((notes_outline(mask.shape, screen, content), note)) return polys diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 510c4fcc..5604f608 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -41,6 +41,8 @@ def clamp_canvas_zoom_level(zoom: float) -> float: ToolMode.CROP_MANUAL: Qt.CursorShape.CrossCursor, ToolMode.DUST_PICK: Qt.CursorShape.BlankCursor, ToolMode.LOCAL_DRAW: Qt.CursorShape.CrossCursor, + ToolMode.LOCAL_OVAL: Qt.CursorShape.CrossCursor, + ToolMode.LOCAL_GRADIENT: Qt.CursorShape.CrossCursor, ToolMode.ANALYSIS_DRAW: Qt.CursorShape.CrossCursor, ToolMode.STRAIGHTEN: Qt.CursorShape.CrossCursor, ToolMode.ZONE_PLACE: Qt.CursorShape.CrossCursor, @@ -112,7 +114,7 @@ class ImageCanvas(QWidget): zoom_changed = pyqtSignal(float) cursor_position_changed = pyqtSignal(float, float) cursor_left_canvas = pyqtSignal() - lasso_completed = pyqtSignal(list) + local_mask_created = pyqtSignal(str, list) scratch_completed = pyqtSignal(list) straighten_completed = pyqtSignal(float) test_strip_picked = pyqtSignal(int, int) @@ -168,7 +170,7 @@ def __init__(self, state: AppState, parent=None): self.overlay.analysis_confirmed.connect(self.analysis_confirmed.emit) self.overlay.cursor_moved.connect(self.cursor_position_changed.emit) self.overlay.cursor_left.connect(self.cursor_left_canvas.emit) - self.overlay.lasso_completed.connect(self.lasso_completed.emit) + self.overlay.local_mask_created.connect(self.local_mask_created.emit) self.overlay.scratch_completed.connect(self.scratch_completed.emit) self.overlay.straighten_completed.connect(self.straighten_completed.emit) self.overlay.test_strip_picked.connect(self.test_strip_picked.emit) diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index bfda59ff..5ce5f42d 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -106,6 +106,8 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "pick_dust": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_dust_btn), "pick_scratch": lambda: _toggle_tool_button(self.window, "finish", controls.retouch_sidebar.pick_scratch_btn), "local_draw": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.draw_btn), + "local_oval": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.oval_btn), + "local_gradient": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.gradient_btn), "analysis_draw": lambda: _toggle_tool_button(self.window, "setup", controls.process_sidebar.analysis_region_btn), "toggle_flat_peek": controller.toggle_flat_peek, "toggle_zones": controller.toggle_zones_overlay, diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 51a79888..d7d31146 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -442,7 +442,7 @@ def _connect_signals(self) -> None: self.canvas.crop_confirmed.connect(self.controller.confirm_manual_crop) self.canvas.analysis_rect_changed.connect(self.controller.handle_analysis_rect_changed) self.canvas.analysis_confirmed.connect(self.controller.confirm_analysis_region) - self.canvas.lasso_completed.connect(self.controller.handle_lasso_completed) + self.canvas.local_mask_created.connect(self.controller.handle_local_mask_created) self.canvas.scratch_completed.connect(self.controller.handle_heal_stroke_completed) self.canvas.straighten_completed.connect(self.controller.handle_straighten_completed) self.canvas.zone_pin_moved.connect(self.controller.move_zone_pin) diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 75779527..849d5fb1 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -39,6 +39,8 @@ class ShortcutEntry: "pick_dust": ShortcutEntry("Shift+D", "Toggle heal tool", "Tools"), "pick_scratch": ShortcutEntry("Shift+S", "Toggle scratch tool", "Tools"), "local_draw": ShortcutEntry("Shift+B", "Toggle dodge & burn mask draw", "Tools"), + "local_oval": ShortcutEntry("", "Toggle dodge & burn oval mask draw", "Tools"), + "local_gradient": ShortcutEntry("", "Toggle dodge & burn card-edge mask draw", "Tools"), "analysis_draw": ShortcutEntry("Shift+R", "Toggle analysis region draw", "Tools"), "toggle_flat_peek": ShortcutEntry("|", "Peek flat scan (digital intermediate)", "Tools"), "toggle_zones": ShortcutEntry("Shift+Z", "Adams zone overlay", "Tools"), diff --git a/negpy/desktop/view/sidebar/local.py b/negpy/desktop/view/sidebar/local.py index da055e18..95cac81f 100644 --- a/negpy/desktop/view/sidebar/local.py +++ b/negpy/desktop/view/sidebar/local.py @@ -6,9 +6,15 @@ from negpy.desktop.session import ToolMode from negpy.desktop.view.styles.templates import field_label_qss from negpy.desktop.view.styles.theme import THEME +from negpy.features.local.models import MaskShape _MASK_ROW_H = 30 +_SHAPE_ICONS = { + MaskShape.POLYGON: "fa5s.draw-polygon", + MaskShape.OVAL: "fa5s.circle", + MaskShape.GRADIENT: "fa5s.grip-lines", +} class _MaskRow(QWidget): @@ -36,7 +42,24 @@ def _init_ui(self) -> None: "re-enter this tool): drag a vertex to move it, click an edge '+' dot to add a point, " "right-click a vertex to delete it.", ) - self.layout.addWidget(self.draw_btn) + self.oval_btn = self._tool_toggle( + "fa5s.circle", + "Oval", + "Burn through a hole in the card, or dodge with a wand: drag out an oval. Its three " + "handles move it (centre) and set each axis, so it can be stretched and tilted.", + ) + self.gradient_btn = self._tool_toggle( + "fa5s.grip-lines", + "Card Edge", + "The graduated burn a printer makes by moving a card across the paper: drag from the " + "full-exposure edge (solid line) to where it fades out (dashed). The distance between " + "the two handles is the softness, so Feather does nothing here.", + ) + tool_row = QHBoxLayout() + tool_row.addWidget(self.draw_btn) + tool_row.addWidget(self.oval_btn) + tool_row.addWidget(self.gradient_btn) + self.layout.addLayout(tool_row) self.mask_list = QListWidget() self.mask_list.setToolTip("Click a mask to select it. Use the eye to show/hide its outline and the trash icon to delete it.") @@ -69,11 +92,19 @@ def _init_ui(self) -> None: "midtone holds, so this changes its contrast without moving its overall density." ) + self.invert_btn = QPushButton("Invert") + self.invert_btn.setCheckable(True) + self.invert_btn.setToolTip( + "Act everywhere except inside the selected mask — the card itself instead of the hole " + "cut in it. Burn the surround and hold the face, in one mask." + ) + slider_row = QHBoxLayout() slider_row.addWidget(self.burn_slider) slider_row.addWidget(self.grade_slider) self.layout.addLayout(slider_row) self.layout.addWidget(self.feather_slider) + self.layout.addWidget(self.invert_btn) self.mask_count_label = QLabel("0 masks") self.mask_count_label.setStyleSheet(field_label_qss()) @@ -82,13 +113,22 @@ def _init_ui(self) -> None: self.layout.addStretch() def _connect_signals(self) -> None: - self.draw_btn.toggled.connect(self._on_draw_toggled) + for btn, mode in self._tool_modes().items(): + btn.toggled.connect(lambda checked, m=mode: self._on_draw_toggled(checked, m)) self.burn_slider.valueChanged.connect(lambda v: self.controller.update_selected_local_mask(stops=float(v))) self.feather_slider.valueChanged.connect(lambda v: self.controller.update_selected_local_mask(feather=float(v))) self.grade_slider.valueChanged.connect(lambda v: self.controller.update_selected_local_mask(grade=float(v))) + self.invert_btn.toggled.connect(lambda v: self.controller.update_selected_local_mask(invert=bool(v))) + + def _tool_modes(self) -> dict: + return { + self.draw_btn: ToolMode.LOCAL_DRAW, + self.oval_btn: ToolMode.LOCAL_OVAL, + self.gradient_btn: ToolMode.LOCAL_GRADIENT, + } - def _on_draw_toggled(self, checked: bool) -> None: - self.controller.set_active_tool(ToolMode.LOCAL_DRAW if checked else ToolMode.NONE) + def _on_draw_toggled(self, checked: bool, mode: ToolMode) -> None: + self.controller.set_active_tool(mode if checked else ToolMode.NONE) def _row_icon_btn(self, icon_name: str, checkable: bool) -> QPushButton: btn = QPushButton() @@ -116,6 +156,11 @@ def _build_mask_row(self, i: int, mask) -> _MaskRow: values = [f"{mask.stops:+.2f} st"] if mask.stops else [] if mask.grade: values.append(f"{mask.grade:+.0f} R") + if mask.invert: + values.append("inv") + shape_icon = QLabel() + shape_icon.setPixmap(qta.icon(_SHAPE_ICONS[mask.shape], color=colour).pixmap(12, 12)) + shape_icon.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) label = QLabel(f"{i + 1}. {kind} " + " ".join(values)) label.setStyleSheet(f"color: {colour};") label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) @@ -127,6 +172,7 @@ def _build_mask_row(self, i: int, mask) -> _MaskRow: delete = self._row_icon_btn("fa5s.trash-alt", checkable=False) delete.setToolTip("Delete this mask") + lay.addWidget(shape_icon) lay.addWidget(label) lay.addStretch() lay.addWidget(eye) @@ -145,7 +191,8 @@ def sync_ui(self) -> None: conf = self.state.config.local self.block_signals(True) try: - self.draw_btn.setChecked(self.state.active_tool == ToolMode.LOCAL_DRAW) + for btn, mode in self._tool_modes().items(): + btn.setChecked(self.state.active_tool == mode) n = len(conf.masks) self.mask_count_label.setText(f"{n} mask{'s' if n != 1 else ''}") @@ -169,17 +216,20 @@ def sync_ui(self) -> None: if n: self.mask_list.setFixedHeight(_MASK_ROW_H * n + 2 * self.mask_list.frameWidth()) self.mask_list.blockSignals(False) + mask = conf.masks[idx] if has_selection else None self.burn_slider.setEnabled(has_selection) - self.feather_slider.setEnabled(has_selection) + # The distance between the handles sets the card-edge softness, not a blur. + self.feather_slider.setEnabled(has_selection and mask.shape != MaskShape.GRADIENT) self.grade_slider.setEnabled(has_selection) - if has_selection: - mask = conf.masks[idx] + self.invert_btn.setEnabled(has_selection) + if mask is not None: self.burn_slider.setValue(mask.stops) self.feather_slider.setValue(mask.feather) self.grade_slider.setValue(mask.grade) + self.invert_btn.setChecked(mask.invert) finally: self.block_signals(False) def block_signals(self, blocked: bool) -> None: - for w in [self.draw_btn, self.burn_slider, self.feather_slider, self.grade_slider]: + for w in [*self._tool_modes(), self.burn_slider, self.feather_slider, self.grade_slider, self.invert_btn]: w.blockSignals(blocked) diff --git a/negpy/domain/models.py b/negpy/domain/models.py index 92557b4b..e6c0baf9 100644 --- a/negpy/domain/models.py +++ b/negpy/domain/models.py @@ -9,7 +9,7 @@ from negpy.features.exposure.models import ExposureConfig, RenderIntent from negpy.features.geometry.models import GeometryConfig from negpy.features.lab.models import LabConfig -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape from negpy.features.retouch.models import RetouchConfig from negpy.features.toning.models import ToningConfig from negpy.features.finish.models import FinishConfig @@ -442,11 +442,13 @@ def _build_local(d: Dict[str, Any]) -> LocalAdjustmentsConfig: # on the legacy name, which only a pre-flip save carries. stops = -float(m["strength"]) if "strength" in m else float(m.get("stops", 0.0)) masks.append( - PolygonMask( + LocalMask( vertices=verts, stops=stops, feather=float(m.get("feather", 0.04)), grade=float(m.get("grade", 0.0)), + shape=MaskShape(m.get("shape", MaskShape.POLYGON)), + invert=bool(m.get("invert", False)), ) ) return LocalAdjustmentsConfig(masks=tuple(masks)) diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 13fda26f..f4692f18 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -1,30 +1,78 @@ -from typing import List, Tuple +import math +from typing import List, Sequence, Tuple import cv2 import numpy as np -from negpy.features.local.models import LocalAdjustmentsConfig +from negpy.features.local.models import LocalAdjustmentsConfig, MaskShape from negpy.features.geometry.logic import map_coords_to_geometry, smooth_polyline +_OVAL_SAMPLES = 64 -def _rasterise_mask( - vertices_img: List[Tuple[float, float]], +Point = Tuple[float, float] + + +def min_points(shape: MaskShape) -> int: + """The minimum number of vertices that this shape needs.""" + return 2 if shape == MaskShape.GRADIENT else 3 + + +def outline_points(shape: MaskShape, pts: Sequence[Point]) -> List[Point]: + """The closed mask outline, in the same space as `pts`. + + The result is empty for a gradient, which has no boundary. The rasteriser, the + canvas overlay and the printing-notes map all use this function, so they agree. + """ + if shape == MaskShape.GRADIENT: + return [] + if shape == MaskShape.OVAL: + (cx, cy), (px, py), (qx, qy) = pts[:3] + ux, uy = px - cx, py - cy + vx, vy = qx - cx, qy - cy + return [ + (cx + ux * math.cos(t) + vx * math.sin(t), cy + uy * math.cos(t) + vy * math.sin(t)) + for t in (2.0 * math.pi * i / _OVAL_SAMPLES for i in range(_OVAL_SAMPLES)) + ] + return smooth_polyline(list(pts), closed=True) + + +def _rasterise_ramp(a: Point, b: Point, h: int, w: int) -> np.ndarray: + """The card-edge ramp. Alpha is 1 at `a` and 0 at `b`, and constant beyond each.""" + ax, ay = a[0] * w, a[1] * h + dx, dy = b[0] * w - ax, b[1] * h - ay + denom = dx * dx + dy * dy + if denom <= 1e-9: + return np.zeros((h, w), dtype=np.float32) + xs = (np.arange(w, dtype=np.float32) - ax) * (dx / denom) + ys = (np.arange(h, dtype=np.float32) - ay) * (dy / denom) + t = np.clip(xs[None, :] + ys[:, None], 0.0, 1.0) + return (1.0 - t * t * (3.0 - 2.0 * t)).astype(np.float32) + + +def rasterise( + shape: MaskShape, + pts: Sequence[Point], h: int, w: int, feather_sigma: float, + invert: bool = False, ) -> np.ndarray: + """Rasterise the control points, normalised to [0,1], to a float32 alpha [h, w]. + + `feather_sigma` is a Gaussian sigma in pixels on the hard fill. A gradient ignores + it, because the distance between its two points sets the softness. """ - Rasterise a polygon (in image-pixel coords) to a float32 mask [h, w]. - Feather is a Gaussian sigma in pixels applied to the hard binary fill. - """ - pts = np.array([[v[0] * w, v[1] * h] for v in vertices_img], dtype=np.float32) - mask = np.zeros((h, w), dtype=np.uint8) - cv2.fillPoly(mask, [pts.astype(np.int32)], 255) - mask_f = mask.astype(np.float32) / 255.0 - if feather_sigma > 1e-3: - k = int(feather_sigma * 3) | 1 # odd kernel covering ~3 sigma - mask_f = cv2.GaussianBlur(mask_f, (k, k), feather_sigma) - return mask_f + if shape == MaskShape.GRADIENT: + alpha = _rasterise_ramp(pts[0], pts[1], h, w) + else: + outline = np.array([[x * w, y * h] for x, y in outline_points(shape, pts)], dtype=np.float32) + filled = np.zeros((h, w), dtype=np.uint8) + cv2.fillPoly(filled, [outline.astype(np.int32)], 255) + alpha = filled.astype(np.float32) / 255.0 + if feather_sigma > 1e-3: + k = int(feather_sigma * 3) | 1 # odd kernel covering ~3 sigma + alpha = cv2.GaussianBlur(alpha, (k, k), feather_sigma) + return 1.0 - alpha if invert else alpha def compute_local_maps( @@ -50,7 +98,7 @@ def compute_local_maps( short_side = float(min(h, w)) for mask in config.masks: - if len(mask.vertices) < 3: + if len(mask.vertices) < min_points(mask.shape): continue transformed = [ @@ -67,8 +115,7 @@ def compute_local_maps( for rx, ry in mask.vertices ] - sigma_px = mask.feather * short_side - alpha = _rasterise_mask(smooth_polyline(transformed, closed=True), h, w, sigma_px) + alpha = rasterise(mask.shape, transformed, h, w, mask.feather * short_side, mask.invert) maps[:, :, 0] += mask.stops * alpha if mask.grade: maps[:, :, 1] += mask.grade * alpha diff --git a/negpy/features/local/models.py b/negpy/features/local/models.py index 7d3fc1e7..438e19f0 100644 --- a/negpy/features/local/models.py +++ b/negpy/features/local/models.py @@ -1,10 +1,25 @@ from dataclasses import dataclass, field +from enum import StrEnum from typing import Tuple +class MaskShape(StrEnum): + """How to read a mask's vertices. + + POLYGON: control points of a closed, smooth outline. + OVAL: 3 points, the centre and one end of each axis. The outline is the unit + circle under the matrix [u v], u = p1 - c, v = p2 - c. The axes can be oblique. + GRADIENT: 2 points. The effect is full at the first point and zero at the second. + """ + + POLYGON = "polygon" + OVAL = "oval" + GRADIENT = "gradient" + + @dataclass(frozen=True) -class PolygonMask: - # Vertices in raw-image normalised coords [0,1]×[0,1]. +class LocalMask: + # Vertices in raw-image normalised coords [0,1]. The `shape` field tells how to read them. vertices: Tuple[Tuple[float, float], ...] = field(default_factory=tuple) # Print exposure in stops, darkroom-signed like vignette_stops: positive = burn # (longer exposure, darker paper), negative = dodge. 0 = the frame's own exposure, @@ -14,8 +29,11 @@ class PolygonMask: # Local grade in ISO-R points off the global grade (negative = harder), the # darkroom's "burn this in through the hard filter". 0 = print at the frame's grade. grade: float = 0.0 + shape: MaskShape = MaskShape.POLYGON + # Apply the mask outside the shape, not inside it. + invert: bool = False @dataclass(frozen=True) class LocalAdjustmentsConfig: - masks: Tuple[PolygonMask, ...] = field(default_factory=tuple) + masks: Tuple[LocalMask, ...] = field(default_factory=tuple) diff --git a/negpy/services/view/printing_notes.py b/negpy/services/view/printing_notes.py index cb15eaf5..2dc0a57c 100644 --- a/negpy/services/view/printing_notes.py +++ b/negpy/services/view/printing_notes.py @@ -1,7 +1,7 @@ """Printer's notes: the print recipe and the dodge/burn map as a darkroom printer writes them. Everything here is text, in the *exposure* domain a printing record uses: a burn adds -exposure and reads `+`, a dodge withholds it and reads `−`. `PolygonMask.stops` carries +exposure and reads `+`, a dodge withholds it and reads `−`. `LocalMask.stops` carries that same convention, so nothing here re-signs it. """ diff --git a/tests/test_canvas_border_mapping.py b/tests/test_canvas_border_mapping.py index 099f43b5..a9ab4af2 100644 --- a/tests/test_canvas_border_mapping.py +++ b/tests/test_canvas_border_mapping.py @@ -102,7 +102,7 @@ def test_lasso_vertices_content_normalized() -> None: overlay._lasso_drawing = True overlay._lasso_pts = [QPointF(20, 16), QPointF(180, 16), QPointF(100, 144)] emitted: list = [] - overlay.lasso_completed.connect(emitted.append) + overlay.local_mask_created.connect(lambda _shape, pts: emitted.append(pts)) overlay._finish_lasso() diff --git a/tests/test_canvas_mask_edit.py b/tests/test_canvas_mask_edit.py index c76641f6..4f60f760 100644 --- a/tests/test_canvas_mask_edit.py +++ b/tests/test_canvas_mask_edit.py @@ -1,18 +1,30 @@ +from dataclasses import replace + from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt from PyQt6.QtGui import QMouseEvent from negpy.desktop.session import AppState, ToolMode from negpy.desktop.view.canvas.overlay import CanvasOverlay +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape _TRIANGLE = [QPointF(20, 20), QPointF(80, 20), QPointF(50, 80)] -def _overlay_with_mask(tool: ToolMode = ToolMode.LOCAL_DRAW) -> CanvasOverlay: - overlay = CanvasOverlay(AppState()) +def _overlay_with_mask(tool: ToolMode = ToolMode.LOCAL_DRAW, shape: MaskShape = MaskShape.POLYGON) -> CanvasOverlay: + from PyQt6.QtWidgets import QWidget + + parent = QWidget() # The move path reads parent()._is_panning. + parent._is_panning = False + overlay = CanvasOverlay(AppState(), parent) + overlay._test_parent = parent # Keep the parent alive with the overlay. overlay._view_rect = QRectF(0, 0, 100, 100) overlay.set_tool_mode(tool) overlay.state.local_selected_mask = 0 - overlay._local_mask_screen_polys = [list(_TRIANGLE)] # normally set during paint + mask = LocalMask(vertices=tuple((p.x() / 100.0, p.y() / 100.0) for p in _TRIANGLE), shape=shape) + overlay.state.config = replace(overlay.state.config, local=LocalAdjustmentsConfig(masks=(mask,))) + # Normally set during paint. + overlay._local_mask_screen_polys = [list(_TRIANGLE)] + overlay._local_mask_screen_ctrl = [list(_TRIANGLE)] return overlay @@ -62,3 +74,54 @@ def test_press_selects_mask_when_off_handles() -> None: overlay._handle_lasso_press(QPointF(50, 45)) # inside, clear of vertices/midpoints assert selected == [0] assert overlay._local_drag_vertex is None + + +def test_fixed_arity_shapes_refuse_point_edits() -> None: + # An oval has 3 points. No point insert and no point delete are possible. + overlay = _overlay_with_mask(shape=MaskShape.OVAL) + overlay._handle_lasso_press(QPointF(50, 20)) # midpoint of edge 0->1 + assert overlay._local_edit_verts is None + assert overlay.try_delete_local_vertex(QPointF(80, 20)) is False + + +def test_dragging_an_ovals_centre_carries_its_axes() -> None: + overlay = _overlay_with_mask(shape=MaskShape.OVAL) + overlay._handle_lasso_press(QPointF(20, 20)) # the centre handle + assert overlay._local_drag_anchor is not None + + overlay.mouseMoveEvent( + QMouseEvent( + QEvent.Type.MouseMove, + QPointF(30, 25), + Qt.MouseButton.NoButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + ) + assert overlay._local_edit_verts == [QPointF(30, 25), QPointF(90, 25), QPointF(60, 85)] + + +def test_dragging_out_an_oval_emits_three_control_points() -> None: + overlay = _overlay_with_mask(ToolMode.LOCAL_OVAL) + emitted: list = [] + overlay.local_mask_created.connect(lambda shape, pts: emitted.append((shape, pts))) + + overlay._handle_shape_press(QPointF(90, 90)) # Away from the existing mask. + overlay._finish_shape_draw(QPointF(50, 50)) + + assert len(emitted) == 1 + shape, pts = emitted[0] + assert shape == "oval" + assert [(round(x, 3), round(y, 3)) for x, y in pts] == [(0.7, 0.7), (0.5, 0.7), (0.7, 0.5)] + + +def test_a_shape_click_without_travel_draws_nothing() -> None: + overlay = _overlay_with_mask(ToolMode.LOCAL_GRADIENT) + emitted: list = [] + overlay.local_mask_created.connect(lambda shape, pts: emitted.append(pts)) + + overlay._handle_shape_press(QPointF(90, 90)) + overlay._finish_shape_draw(QPointF(92, 91)) + + assert emitted == [] + assert overlay._shape_draw_p1 is None diff --git a/tests/test_canvas_polyline_finish.py b/tests/test_canvas_polyline_finish.py index eeb9609b..e72bac63 100644 --- a/tests/test_canvas_polyline_finish.py +++ b/tests/test_canvas_polyline_finish.py @@ -36,7 +36,7 @@ def test_enter_finishes_lasso_polygon() -> None: overlay._lasso_pts = [QPointF(10, 10), QPointF(40, 10), QPointF(25, 40)] emitted = [] - overlay.lasso_completed.connect(emitted.append) + overlay.local_mask_created.connect(lambda _shape, pts: emitted.append(pts)) overlay._finish_draw_if_active() assert len(emitted) == 1 @@ -51,7 +51,7 @@ def test_enter_ignores_incomplete_lasso() -> None: overlay._lasso_pts = [QPointF(10, 10), QPointF(40, 10)] emitted = [] - overlay.lasso_completed.connect(emitted.append) + overlay.local_mask_created.connect(lambda _shape, pts: emitted.append(pts)) overlay._finish_draw_if_active() # Two points can't close a polygon — keep drawing instead of wiping them. @@ -80,7 +80,7 @@ def test_enter_noop_without_active_draw() -> None: emitted = [] overlay.scratch_completed.connect(emitted.append) - overlay.lasso_completed.connect(emitted.append) + overlay.local_mask_created.connect(lambda _shape, pts: emitted.append(pts)) overlay._finish_draw_if_active() assert emitted == [] diff --git a/tests/test_controller.py b/tests/test_controller.py index 95344c58..16c27cb5 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -775,12 +775,12 @@ def test_apply_auto_crop_exits_manual_crop_tool(self): self.assertEqual(self.controller.state.active_tool, ToolMode.NONE) def _seed_two_masks(self): - from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask + from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask verts = ((0.1, 0.1), (0.9, 0.1), (0.5, 0.9)) masks = ( - PolygonMask(vertices=verts, stops=-0.3, feather=0.02), - PolygonMask(vertices=verts, stops=0.3, feather=0.02), + LocalMask(vertices=verts, stops=-0.3, feather=0.02), + LocalMask(vertices=verts, stops=0.3, feather=0.02), ) self.controller.state.config = replace(self.controller.state.config, local=LocalAdjustmentsConfig(masks=masks)) # Hidden-mask state is keyed by the open file's hash; give the tests one. @@ -817,7 +817,7 @@ def test_hidden_masks_cleared_hash_is_pruned(self): self.assertNotIn("hashA", self.controller.state.local_hidden_masks_by_hash) def test_hidden_masks_clamped_when_mask_count_shrinks(self): - from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask + from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask self._seed_two_masks() # 2 masks under hashA self.controller.canvas = None @@ -827,7 +827,7 @@ def test_hidden_masks_clamped_when_mask_count_shrinks(self): # Simulate an undo/redo/jump that swaps in a config with fewer masks: the stored # index 1 now points past the end and must be dropped from the returned set. verts = ((0.1, 0.1), (0.9, 0.1), (0.5, 0.9)) - one_mask = (PolygonMask(vertices=verts, stops=-0.3, feather=0.02),) + one_mask = (LocalMask(vertices=verts, stops=-0.3, feather=0.02),) self.controller.state.config = replace(self.controller.state.config, local=LocalAdjustmentsConfig(masks=one_mask)) self.assertEqual(self.controller.state.local_hidden_masks, set()) @@ -862,12 +862,27 @@ def test_lasso_completion_adds_mask_and_exits_draw_mode(self): self.controller.state.last_metrics["uv_grid"] = np.zeros((2, 2, 2), dtype=np.float32) self.controller.request_render = MagicMock() - self.controller.handle_lasso_completed([(0.1, 0.1), (0.9, 0.1), (0.5, 0.9)]) + self.controller.handle_local_mask_created("polygon", [(0.1, 0.1), (0.9, 0.1), (0.5, 0.9)]) saved_config = self.mock_session_manager.update_config.call_args.args[0] self.assertEqual(len(saved_config.local.masks), 1) self.assertEqual(self.controller.state.active_tool, ToolMode.NONE) + def test_a_card_edge_mask_needs_only_two_points(self): + import numpy as np + + from negpy.features.local.models import MaskShape + + self.controller.state.active_tool = ToolMode.LOCAL_GRADIENT + self.controller.state.last_metrics["uv_grid"] = np.zeros((2, 2, 2), dtype=np.float32) + self.controller.request_render = MagicMock() + + self.controller.handle_local_mask_created("gradient", [(0.1, 0.1), (0.9, 0.9)]) + + saved_config = self.mock_session_manager.update_config.call_args.args[0] + self.assertEqual(len(saved_config.local.masks), 1) + self.assertEqual(saved_config.local.masks[0].shape, MaskShape.GRADIENT) + class TestBatchExportFiltering(unittest.TestCase): def setUp(self): diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 09002346..30e46ba6 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -1016,12 +1016,12 @@ def tearDown(self): self.tmp.cleanup() def test_hidden_masks_survive_restart(self): - from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask + from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask # hash1 has two masks on disk; index 1 is hidden. The property clamps against the # hydrated mask list, so persistence only "counts" if that config reloads too. verts = ((0.1, 0.1), (0.9, 0.1), (0.5, 0.9)) - two_masks = (PolygonMask(vertices=verts), PolygonMask(vertices=verts, stops=0.3)) + two_masks = (LocalMask(vertices=verts), LocalMask(vertices=verts, stops=0.3)) cfg = replace(WorkspaceConfig(), local=LocalAdjustmentsConfig(masks=two_masks)) self.repo.save_file_settings("hash1", cfg, file_path=self.session.state.uploaded_files[0]["path"]) diff --git a/tests/test_gpu_curve_parity.py b/tests/test_gpu_curve_parity.py index a78259b6..60fe017d 100644 --- a/tests/test_gpu_curve_parity.py +++ b/tests/test_gpu_curve_parity.py @@ -305,7 +305,7 @@ def test_cpu_gpu_match_local_grade(self): """Dodge/burn EV and the local grade share one uploaded texture (.r and .g) on the GPU and two kernel arguments on the CPU — a mask that changes both catches a swapped plane or a missing multiply in either path.""" - from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask + from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.services.rendering.image_processor import ImageProcessor processor = ImageProcessor() @@ -322,8 +322,8 @@ def test_cpu_gpu_match_local_grade(self): s = WorkspaceConfig() square = ((0.15, 0.15), (0.85, 0.15), (0.85, 0.85), (0.15, 0.85)) masks = ( - PolygonMask(vertices=square, stops=0.8, feather=0.03, grade=-35.0), - PolygonMask(vertices=((0.0, 0.6), (0.5, 0.6), (0.5, 1.0), (0.0, 1.0)), stops=0.0, feather=0.0, grade=30.0), + LocalMask(vertices=square, stops=0.8, feather=0.03, grade=-35.0), + LocalMask(vertices=((0.0, 0.6), (0.5, 0.6), (0.5, 1.0), (0.0, 1.0)), stops=0.0, feather=0.0, grade=30.0), ) settings = replace(s, local=LocalAdjustmentsConfig(masks=masks)) cpu = self._render(processor, settings, img, prefer_gpu=False) diff --git a/tests/test_local_grade.py b/tests/test_local_grade.py index d04106b5..2e6623c0 100644 --- a/tests/test_local_grade.py +++ b/tests/test_local_grade.py @@ -8,7 +8,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.logic import apply_characteristic_curve, local_grade_factor_map from negpy.features.exposure.models import EXPOSURE_CONSTANTS -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.services.rendering.engine import DarkroomEngine R_MIN = float(EXPOSURE_CONSTANTS["iso_r_min"]) @@ -86,7 +86,7 @@ def _frame(self) -> np.ndarray: return np.ascontiguousarray(np.stack([img, img * 0.95, img * 0.9], axis=-1)) def _config(self, grade_delta: float) -> WorkspaceConfig: - mask = PolygonMask(vertices=((0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)), stops=0.0, feather=0.0, grade=grade_delta) + mask = LocalMask(vertices=((0.0, 0.0), (1.0, 0.0), (1.0, 0.5), (0.0, 0.5)), stops=0.0, feather=0.0, grade=grade_delta) return WorkspaceConfig(local=LocalAdjustmentsConfig(masks=(mask,))) def _render(self, grade_delta: float) -> np.ndarray: diff --git a/tests/test_local_logic.py b/tests/test_local_logic.py index 964b31fa..857a18e4 100644 --- a/tests/test_local_logic.py +++ b/tests/test_local_logic.py @@ -1,16 +1,17 @@ import unittest +from dataclasses import replace import numpy as np from negpy.domain.models import WorkspaceConfig from negpy.features.geometry.logic import smooth_polyline from negpy.features.local.logic import compute_local_maps -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape -def _center_square_mask(stops: float, feather: float = 0.0) -> PolygonMask: +def _center_square_mask(stops: float, feather: float = 0.0) -> LocalMask: """Polygon covering the central 50% of the frame.""" - return PolygonMask( + return LocalMask( vertices=((0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)), stops=stops, feather=feather, @@ -51,7 +52,7 @@ def test_overlapping_masks_are_additive(self) -> None: def test_degenerate_mask_skipped(self) -> None: """A mask with fewer than 3 vertices is ignored.""" - cfg = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=((0.4, 0.4), (0.6, 0.6)), stops=-1.0),)) + cfg = LocalAdjustmentsConfig(masks=(LocalMask(vertices=((0.4, 0.4), (0.6, 0.6)), stops=-1.0),)) ev = _ev(cfg) np.testing.assert_array_equal(ev, np.zeros((100, 100), dtype=np.float32)) @@ -72,7 +73,7 @@ def test_zero_without_local_grade(self) -> None: np.testing.assert_array_equal(_grades(cfg), np.zeros((100, 100), dtype=np.float32)) def test_interior_equals_delta_and_exterior_is_clean(self) -> None: - cfg = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=_center_square_mask(0.0).vertices, stops=0.0, grade=-30.0),)) + cfg = LocalAdjustmentsConfig(masks=(LocalMask(vertices=_center_square_mask(0.0).vertices, stops=0.0, grade=-30.0),)) grades = _grades(cfg) self.assertAlmostEqual(float(grades[50, 50]), -30.0, places=4) self.assertAlmostEqual(float(grades[5, 5]), 0.0, places=5) @@ -81,21 +82,70 @@ def test_overlapping_masks_are_additive(self) -> None: mask = _center_square_mask(0.0) cfg = LocalAdjustmentsConfig( masks=( - PolygonMask(vertices=mask.vertices, stops=0.0, grade=-10.0), - PolygonMask(vertices=mask.vertices, stops=0.0, grade=-15.0), + LocalMask(vertices=mask.vertices, stops=0.0, grade=-10.0), + LocalMask(vertices=mask.vertices, stops=0.0, grade=-15.0), ) ) self.assertAlmostEqual(float(_grades(cfg)[50, 50]), -25.0, places=4) def test_exposure_and_grade_ride_the_same_alpha(self) -> None: """One mask, both values: the feathered edge must weight them identically.""" - cfg = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=_center_square_mask(0.0).vertices, stops=1.0, feather=0.05, grade=-20.0),)) + cfg = LocalAdjustmentsConfig(masks=(LocalMask(vertices=_center_square_mask(0.0).vertices, stops=1.0, feather=0.05, grade=-20.0),)) maps = compute_local_maps(cfg, 100, 100, (100, 100)) alpha_ev = maps[:, :, 0] / 1.0 alpha_grade = maps[:, :, 1] / -20.0 np.testing.assert_allclose(alpha_ev, alpha_grade, atol=1e-6) +class TestMaskShapes(unittest.TestCase): + """Oval and card-edge masks feed the same two planes as a polygon.""" + + def test_an_oval_fills_its_axes_and_not_the_bounding_corners(self) -> None: + # Frame centre, both radii 0.25. The corner of that box is outside the oval. + oval = LocalMask(vertices=((0.5, 0.5), (0.75, 0.5), (0.5, 0.75)), stops=1.0, feather=0.0, shape=MaskShape.OVAL) + ev = _ev(LocalAdjustmentsConfig(masks=(oval,))) + self.assertAlmostEqual(float(ev[50, 50]), 1.0, places=5) + self.assertAlmostEqual(float(ev[50, 72]), 1.0, places=5) + self.assertAlmostEqual(float(ev[72, 72]), 0.0, places=5) + + def test_an_ovals_axes_need_not_be_perpendicular(self) -> None: + """The control points are an affine frame, so a tilted oval is a sheared one.""" + tilted = LocalMask(vertices=((0.5, 0.5), (0.75, 0.6), (0.4, 0.75)), stops=1.0, feather=0.0, shape=MaskShape.OVAL) + ev = _ev(LocalAdjustmentsConfig(masks=(tilted,))) + self.assertAlmostEqual(float(ev[50, 50]), 1.0, places=5) + self.assertAlmostEqual(float(ev[5, 5]), 0.0, places=5) + + def test_a_card_edge_ramps_from_full_to_nothing(self) -> None: + grad = LocalMask(vertices=((0.25, 0.5), (0.75, 0.5)), stops=1.0, shape=MaskShape.GRADIENT) + ev = _ev(LocalAdjustmentsConfig(masks=(grad,))) + self.assertAlmostEqual(float(ev[50, 10]), 1.0, places=5) # behind the full edge + self.assertAlmostEqual(float(ev[50, 90]), 0.0, places=5) # past the fade-out + self.assertAlmostEqual(float(ev[50, 50]), 0.5, places=2) + # The ramp decreases across the axis and stays constant along it. + row = ev[50, 25:75] + self.assertTrue(np.all(np.diff(row) <= 1e-6)) + np.testing.assert_allclose(ev[10, :], ev[90, :], atol=1e-6) + + def test_a_card_edge_needs_only_two_points(self) -> None: + grad = LocalMask(vertices=((0.25, 0.5), (0.75, 0.5)), stops=1.0, shape=MaskShape.GRADIENT) + self.assertGreater(float(_ev(LocalAdjustmentsConfig(masks=(grad,))).max()), 0.9) + + def test_a_degenerate_card_edge_is_skipped(self) -> None: + grad = LocalMask(vertices=((0.5, 0.5), (0.5, 0.5)), stops=1.0, shape=MaskShape.GRADIENT) + ev = _ev(LocalAdjustmentsConfig(masks=(grad,))) + np.testing.assert_array_equal(ev, np.zeros((100, 100), dtype=np.float32)) + + def test_invert_swaps_inside_for_outside(self) -> None: + plain = _ev(LocalAdjustmentsConfig(masks=(_center_square_mask(1.0, feather=0.05),))) + inverted = _ev(LocalAdjustmentsConfig(masks=(replace(_center_square_mask(1.0, feather=0.05), invert=True),))) + np.testing.assert_allclose(plain + inverted, np.ones((100, 100), dtype=np.float32), atol=1e-6) + + def test_feather_does_not_touch_a_card_edge(self) -> None: + soft = LocalMask(vertices=((0.25, 0.5), (0.75, 0.5)), stops=1.0, feather=0.15, shape=MaskShape.GRADIENT) + hard = replace(soft, feather=0.0) + np.testing.assert_allclose(_ev(LocalAdjustmentsConfig(masks=(soft,))), _ev(LocalAdjustmentsConfig(masks=(hard,))), atol=0.0) + + class TestSmoothPolyline(unittest.TestCase): """Mask outlines and heal paths are always drawn as a Catmull-Rom curve.""" @@ -129,7 +179,7 @@ def test_smoothed_square_mask_still_fills_interior(self) -> None: class TestLocalSerialization(unittest.TestCase): def test_roundtrip_preserves_masks(self) -> None: """to_dict -> from_flat_dict preserves polygon mask fields.""" - mask = PolygonMask( + mask = LocalMask( vertices=((0.1, 0.1), (0.9, 0.1), (0.5, 0.9)), stops=-0.4, feather=0.03, @@ -154,6 +204,27 @@ def test_legacy_mask_migrates_brightness_signed_strength_to_stops(self) -> None: self.assertAlmostEqual(mask.stops, -0.4) self.assertEqual(mask.grade, 0.0) + def test_roundtrip_preserves_shape_and_invert(self) -> None: + cfg = WorkspaceConfig( + local=LocalAdjustmentsConfig( + masks=( + LocalMask(vertices=((0.2, 0.5), (0.8, 0.5)), stops=1.0, shape=MaskShape.GRADIENT), + LocalMask(vertices=((0.5, 0.5), (0.7, 0.5), (0.5, 0.7)), shape=MaskShape.OVAL, invert=True), + ) + ) + ) + restored = WorkspaceConfig.from_flat_dict(cfg.to_dict()).local.masks + self.assertEqual(restored[0].shape, MaskShape.GRADIENT) + self.assertEqual(restored[1].shape, MaskShape.OVAL) + self.assertTrue(restored[1].invert) + self.assertFalse(restored[0].invert) + + def test_a_mask_saved_before_shapes_loads_as_a_polygon(self) -> None: + legacy = {"local_masks": {"masks": [{"vertices": [[0.1, 0.1], [0.9, 0.1], [0.5, 0.9]], "stops": 0.5}]}} + mask = WorkspaceConfig.from_flat_dict(legacy).local.masks[0] + self.assertEqual(mask.shape, MaskShape.POLYGON) + self.assertFalse(mask.invert) + def test_a_legacy_burn_migrates_to_a_positive_burn(self) -> None: legacy = {"local_masks": {"masks": [{"vertices": [[0.1, 0.1], [0.9, 0.1], [0.5, 0.9]], "strength": -1.0}]}} self.assertAlmostEqual(WorkspaceConfig.from_flat_dict(legacy).local.masks[0].stops, 1.0) diff --git a/tests/test_local_overlay.py b/tests/test_local_overlay.py index fcd2476e..53d5324c 100644 --- a/tests/test_local_overlay.py +++ b/tests/test_local_overlay.py @@ -1,7 +1,8 @@ import numpy as np from negpy.desktop.view.canvas.overlay import feathered_mask_image -from negpy.features.local.logic import _rasterise_mask +from negpy.features.local.logic import rasterise +from negpy.features.local.models import MaskShape from PyQt6.QtGui import QColor, QImage DODGE = QColor(232, 200, 74) @@ -12,37 +13,59 @@ def _to_array(img: QImage) -> np.ndarray: bits = img.bits() bits.setsize(img.sizeInBytes()) - return np.frombuffer(bits, np.uint8).reshape(img.height(), img.bytesPerLine() // 4, 4)[:, : img.width()] + # Copy the data. The view points to Qt memory that the QImage releases. + return np.frombuffer(bits, np.uint8).reshape(img.height(), img.bytesPerLine() // 4, 4)[:, : img.width()].copy() + + +def _tint(shape: MaskShape = MaskShape.POLYGON, pts=None, sigma: float = 6.0, invert: bool = False) -> np.ndarray: + img = feathered_mask_image(shape, pts or SQUARE, W, H, sigma_px=sigma, color=DODGE, max_alpha=70, invert=invert) + return _to_array(img) def test_interior_fully_tinted(): - img = feathered_mask_image(SQUARE, W, H, sigma_px=6.0, color=DODGE, max_alpha=70) - arr = _to_array(img) - center = arr[50, 50] + center = _tint()[50, 50] assert center[3] == 70 expected = [int(c * 70 / 255) for c in (DODGE.red(), DODGE.green(), DODGE.blue())] assert list(center[:3]) == expected def test_edge_is_feathered(): - img = feathered_mask_image(SQUARE, W, H, sigma_px=6.0, color=DODGE, max_alpha=70) - alpha = _to_array(img)[..., 3] - inside, edge, outside = int(alpha[50, 26]), int(alpha[50, 20]), int(alpha[50, 14]) + alpha = _tint()[..., 3] + # The smooth outline goes out past its control points. The edge is near x=12. + inside, edge, outside = int(alpha[50, 30]), int(alpha[50, 12]), int(alpha[50, 0]) assert inside > edge > outside assert abs(edge - 35) <= 10 def test_zero_sigma_hard_edge(): - img = feathered_mask_image(SQUARE, W, H, sigma_px=0.0, color=DODGE, max_alpha=70) - alpha = _to_array(img)[..., 3] + alpha = _tint(sigma=0.0)[..., 3] assert alpha[50, 50] == 70 - assert alpha[50, 17] == 0 + assert alpha[50, 5] == 0 + + +def test_invert_tints_outside_instead(): + alpha = _tint(sigma=0.0, invert=True)[..., 3] + assert alpha[50, 50] == 0 + assert alpha[50, 5] == 70 + + +def test_oval_tint_is_round(): + # Centre and both axis ends. The area in the axes is tinted, the box corner is not. + alpha = _tint(MaskShape.OVAL, [(50.0, 50.0), (90.0, 50.0), (50.0, 90.0)], sigma=0.0)[..., 3] + assert alpha[50, 85] == 70 + assert alpha[85, 85] == 0 + + +def test_gradient_tint_ramps_along_its_axis(): + alpha = _tint(MaskShape.GRADIENT, [(20.0, 50.0), (80.0, 50.0)], sigma=0.0)[..., 3] + assert alpha[50, 10] == 70 # behind the full-exposure edge + assert alpha[50, 90] == 0 # past the fade-out edge + assert 20 < int(alpha[50, 50]) < 50 def test_parity_with_pipeline_rasteriser(): sigma = 4.0 - img = feathered_mask_image(SQUARE, W, H, sigma_px=sigma, color=DODGE, max_alpha=70) - alpha = _to_array(img)[..., 3] + alpha = _tint(sigma=sigma)[..., 3] norm = [(x / W, y / H) for x, y in SQUARE] - expected = (_rasterise_mask(norm, H, W, sigma) * 70).astype(np.uint8) + expected = (rasterise(MaskShape.POLYGON, norm, H, W, sigma) * 70).astype(np.uint8) assert np.array_equal(alpha, expected) diff --git a/tests/test_local_sidebar.py b/tests/test_local_sidebar.py index 3f510d91..f3545189 100644 --- a/tests/test_local_sidebar.py +++ b/tests/test_local_sidebar.py @@ -4,14 +4,14 @@ from dataclasses import replace from unittest.mock import MagicMock -from negpy.desktop.session import AppState +from negpy.desktop.session import AppState, ToolMode from negpy.desktop.view.sidebar.local import LocalSidebar -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape SQUARE = ((0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.2, 0.8)) -def _sidebar(*masks: PolygonMask, selected: int = 0): +def _sidebar(*masks: LocalMask, selected: int = 0): controller = MagicMock() controller.state = AppState() cfg = controller.state.config @@ -21,9 +21,9 @@ def _sidebar(*masks: PolygonMask, selected: int = 0): def _row_text(sidebar: LocalSidebar, index: int = 0) -> str: - """The mask row's label — first widget in the row layout.""" + """The mask row label. It is the second widget, after the shape icon.""" row = sidebar.mask_list.itemWidget(sidebar.mask_list.item(index)) - return row.layout().itemAt(0).widget().text() + return row.layout().itemAt(1).widget().text() def test_grade_slider_is_disabled_without_a_selection(qapp): @@ -35,8 +35,8 @@ def test_grade_slider_is_disabled_without_a_selection(qapp): def test_grade_slider_syncs_from_the_selected_mask(qapp): _, sidebar = _sidebar( - PolygonMask(vertices=SQUARE, stops=1.0, grade=-20.0), - PolygonMask(vertices=SQUARE, stops=-0.5, grade=15.0), + LocalMask(vertices=SQUARE, stops=1.0, grade=-20.0), + LocalMask(vertices=SQUARE, stops=-0.5, grade=15.0), selected=1, ) sidebar.sync_ui() @@ -46,7 +46,7 @@ def test_grade_slider_syncs_from_the_selected_mask(qapp): def test_moving_the_grade_slider_edits_only_that_mask(qapp): - controller, sidebar = _sidebar(PolygonMask(vertices=SQUARE, stops=1.0), selected=0) + controller, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=1.0), selected=0) sidebar.sync_ui() # setValue is the external-sync path and blocks signals; adjust_by is a gesture. @@ -58,7 +58,7 @@ def test_moving_the_grade_slider_edits_only_that_mask(qapp): def test_a_grade_only_mask_is_labelled_grade(qapp): """Strength 0 with a grade is neither dodge nor burn, and an EV of +0.00 would read as a dodge that does nothing.""" - _, sidebar = _sidebar(PolygonMask(vertices=SQUARE, stops=0.0, grade=-30.0)) + _, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=0.0, grade=-30.0)) sidebar.sync_ui() assert "Grade" in _row_text(sidebar) and "-30 R" in _row_text(sidebar) @@ -66,7 +66,7 @@ def test_a_grade_only_mask_is_labelled_grade(qapp): def test_a_burn_with_a_grade_shows_both(qapp): - _, sidebar = _sidebar(PolygonMask(vertices=SQUARE, stops=1.0, grade=-20.0)) + _, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=1.0, grade=-20.0)) sidebar.sync_ui() text = _row_text(sidebar) @@ -75,7 +75,7 @@ def test_a_burn_with_a_grade_shows_both(qapp): def test_burn_slider_syncs_and_is_exposure_signed(qapp): """Positive is a burn, matching Print Density and the Finishing edge burn.""" - controller, sidebar = _sidebar(PolygonMask(vertices=SQUARE, stops=0.75), selected=0) + controller, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=0.75), selected=0) sidebar.sync_ui() assert sidebar.burn_slider.value() == 0.75 @@ -86,7 +86,7 @@ def test_burn_slider_syncs_and_is_exposure_signed(qapp): def test_a_dodge_is_a_negative_burn(qapp): - _, sidebar = _sidebar(PolygonMask(vertices=SQUARE, stops=-0.5)) + _, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=-0.5)) sidebar.sync_ui() text = _row_text(sidebar) @@ -96,9 +96,41 @@ def test_a_dodge_is_a_negative_burn(qapp): def test_a_fresh_mask_starts_at_the_frames_own_exposure(qapp): """Default 0 stops: drawing a mask must not change the print until it is given a value, so the range can run the full +/-2 stops either way.""" - _, sidebar = _sidebar(PolygonMask(vertices=SQUARE)) + _, sidebar = _sidebar(LocalMask(vertices=SQUARE)) sidebar.sync_ui() assert sidebar.burn_slider.value() == 0.0 assert (sidebar.burn_slider._min, sidebar.burn_slider._max) == (-2.0, 2.0) assert "Grade" in _row_text(sidebar) + + +def test_each_draw_tool_arms_its_own_mode(qapp): + controller, sidebar = _sidebar(LocalMask(vertices=SQUARE)) + for btn, mode in ( + (sidebar.draw_btn, ToolMode.LOCAL_DRAW), + (sidebar.oval_btn, ToolMode.LOCAL_OVAL), + (sidebar.gradient_btn, ToolMode.LOCAL_GRADIENT), + ): + btn.setChecked(True) + controller.set_active_tool.assert_called_with(mode) + btn.setChecked(False) + controller.set_active_tool.assert_called_with(ToolMode.NONE) + + +def test_feather_is_inert_on_a_card_edge(qapp): + """The handle distance sets the softness, so the slider does not apply.""" + _, sidebar = _sidebar(LocalMask(vertices=((0.2, 0.5), (0.8, 0.5)), shape=MaskShape.GRADIENT)) + sidebar.sync_ui() + + assert not sidebar.feather_slider.isEnabled() + assert sidebar.burn_slider.isEnabled() + + +def test_invert_toggle_syncs_and_writes_back(qapp): + controller, sidebar = _sidebar(LocalMask(vertices=SQUARE, stops=1.0, invert=True)) + sidebar.sync_ui() + + assert sidebar.invert_btn.isChecked() + assert "inv" in _row_text(sidebar) + sidebar.invert_btn.setChecked(False) + controller.update_selected_local_mask.assert_called_with(invert=False) diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 00f63246..45afc9e9 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -20,7 +20,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.models import ExposureConfig from negpy.features.lab.models import LabConfig -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.features.retouch.models import RetouchConfig from negpy.features.toning.models import ToningConfig from negpy.features.geometry.models import GeometryConfig @@ -744,9 +744,9 @@ def _run_and_compare(self, settings: WorkspaceConfig) -> None: _assert_mostly_close(cpu_result, gpu_result, atol=1.5e-1, rtol=1.5e-1, max_violation_frac=0.01) @staticmethod - def _mask(stops: float, feather: float = 0.0) -> PolygonMask: + def _mask(stops: float, feather: float = 0.0) -> LocalMask: """Print exposure in stops: positive burns, negative dodges.""" - return PolygonMask( + return LocalMask( vertices=((0.25, 0.25), (0.75, 0.25), (0.75, 0.75), (0.25, 0.75)), stops=stops, feather=feather, @@ -769,8 +769,8 @@ def test_feathered(self): def test_multiple_masks(self): masks = ( - PolygonMask(vertices=((0.1, 0.1), (0.45, 0.1), (0.45, 0.45), (0.1, 0.45)), stops=-1.0), - PolygonMask(vertices=((0.55, 0.55), (0.9, 0.55), (0.9, 0.9), (0.55, 0.9)), stops=1.0), + LocalMask(vertices=((0.1, 0.1), (0.45, 0.1), (0.45, 0.45), (0.1, 0.45)), stops=-1.0), + LocalMask(vertices=((0.55, 0.55), (0.9, 0.55), (0.9, 0.9), (0.55, 0.9)), stops=1.0), ) s = replace(_make_base_settings(), local=LocalAdjustmentsConfig(masks=masks)) self._run_and_compare(s) diff --git a/tests/test_printing_notes.py b/tests/test_printing_notes.py index 3ec3296e..f40523e6 100644 --- a/tests/test_printing_notes.py +++ b/tests/test_printing_notes.py @@ -5,7 +5,7 @@ from negpy.features.exposure.models import ExposureConfig from negpy.features.finish.models import FinishConfig -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.services.view.printing_notes import mask_notes, recipe_lines, stops_label SQUARE = ((0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.2, 0.8)) @@ -13,7 +13,7 @@ def _local(*stops: float) -> LocalAdjustmentsConfig: """Masks by print exposure: positive burns, negative dodges.""" - return LocalAdjustmentsConfig(masks=tuple(PolygonMask(vertices=SQUARE, stops=s) for s in stops)) + return LocalAdjustmentsConfig(masks=tuple(LocalMask(vertices=SQUARE, stops=s) for s in stops)) def test_stops_are_written_as_darkroom_fractions() -> None: @@ -84,7 +84,7 @@ def test_auto_flags_are_marked() -> None: def test_a_local_grade_is_written_as_the_grade_it_prints_at() -> None: - local = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=SQUARE, stops=1.0, grade=-20.0),)) + local = LocalAdjustmentsConfig(masks=(LocalMask(vertices=SQUARE, stops=1.0, grade=-20.0),)) (note,) = mask_notes(local, grade=115.0) assert note.local_r == "R95" @@ -93,7 +93,7 @@ def test_a_local_grade_is_written_as_the_grade_it_prints_at() -> None: def test_a_grade_only_mask_is_neither_a_dodge_nor_a_burn() -> None: - local = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=SQUARE, stops=0.0, grade=30.0),)) + local = LocalAdjustmentsConfig(masks=(LocalMask(vertices=SQUARE, stops=0.0, grade=30.0),)) (note,) = mask_notes(local, grade=115.0) assert note.kind == "Grade" @@ -102,7 +102,7 @@ def test_a_grade_only_mask_is_neither_a_dodge_nor_a_burn() -> None: def test_a_local_grade_off_the_ladder_is_written_clamped() -> None: - local = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=SQUARE, stops=0.0, grade=-90.0),)) + local = LocalAdjustmentsConfig(masks=(LocalMask(vertices=SQUARE, stops=0.0, grade=-90.0),)) (note,) = mask_notes(local, grade=115.0) assert note.local_r == "R50" @@ -119,8 +119,8 @@ def test_masks_at_the_frame_grade_carry_no_grade_note() -> None: def test_the_record_names_each_mask_grade() -> None: local = LocalAdjustmentsConfig( masks=( - PolygonMask(vertices=SQUARE, stops=1.0, grade=-20.0), - PolygonMask(vertices=SQUARE, stops=-0.25), + LocalMask(vertices=SQUARE, stops=1.0, grade=-20.0), + LocalMask(vertices=SQUARE, stops=-0.25), ) ) lines = recipe_lines(replace(ExposureConfig(), grade=115.0), local, FinishConfig()) diff --git a/tests/test_printing_notes_overlay.py b/tests/test_printing_notes_overlay.py index b3c8523e..e2484d89 100644 --- a/tests/test_printing_notes_overlay.py +++ b/tests/test_printing_notes_overlay.py @@ -11,11 +11,11 @@ from negpy.desktop.session import AppState, ToolMode from negpy.desktop.view.canvas.overlay import CanvasOverlay from negpy.desktop.view.canvas.printing_notes import card_size, notes_sheet -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask, MaskShape W = H = 200 -BURN = PolygonMask(vertices=((0.05, 0.05), (0.45, 0.05), (0.45, 0.45), (0.05, 0.45)), stops=1.0) -DODGE = PolygonMask(vertices=((0.55, 0.55), (0.95, 0.55), (0.95, 0.95), (0.55, 0.95)), stops=-0.5) +BURN = LocalMask(vertices=((0.05, 0.05), (0.45, 0.05), (0.45, 0.45), (0.05, 0.45)), stops=1.0) +DODGE = LocalMask(vertices=((0.55, 0.55), (0.95, 0.55), (0.95, 0.95), (0.55, 0.95)), stops=-0.5) def _uv_grid(h: int = H, w: int = W) -> np.ndarray: @@ -108,6 +108,20 @@ def test_the_sheet_hatches_the_burn_and_leaves_the_dodge_open() -> None: assert (dodge_interior != grey).any(axis=-1).mean() == 0.0 # open +def test_the_sheet_hatches_a_card_edges_full_exposure_side() -> None: + """A gradient has no outline. The map marks the half plane with the full burn and + leaves the other side clean.""" + frame = QImage(W, H, QImage.Format.Format_RGB32) + frame.fill(0x00808080) + edge = LocalMask(vertices=((0.4, 0.5), (0.8, 0.5)), stops=1.0, shape=MaskShape.GRADIENT) + + arr = _to_array(notes_sheet(frame, None, LocalAdjustmentsConfig(masks=(edge,)), _uv_grid(), [])) + + grey = np.array([128, 128, 128, 255], dtype=np.uint8) + assert (arr[20:60, 5:45] != grey).any(axis=-1).mean() > 0.05 # Hatched, behind the edge. + assert (arr[20:60, 175:195] != grey).any(axis=-1).mean() == 0.0 # Past the fade-out. + + def test_the_sheet_carries_the_recipe_in_a_band_below_the_frame() -> None: frame = QImage(W, H, QImage.Format.Format_RGB32) frame.fill(0x00808080) diff --git a/tests/test_sidecar.py b/tests/test_sidecar.py index d160d065..329bcb17 100644 --- a/tests/test_sidecar.py +++ b/tests/test_sidecar.py @@ -7,7 +7,7 @@ from negpy.domain.models import WorkspaceConfig from negpy.features.exposure.models import ExposureConfig from negpy.features.geometry.models import GeometryConfig -from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.features.local.models import LocalAdjustmentsConfig, LocalMask from negpy.infrastructure.storage.repository import StorageRepository from negpy.services.assets.sidecar import load_or_promote, load_sidecar, sidecar_path_for, write_sidecar @@ -17,7 +17,7 @@ def _rich_config() -> WorkspaceConfig: 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)), - local=LocalAdjustmentsConfig(masks=(PolygonMask(vertices=((0.0, 0.0), (0.5, 0.5)), stops=-0.7, feather=0.05),)), + local=LocalAdjustmentsConfig(masks=(LocalMask(vertices=((0.0, 0.0), (0.5, 0.5)), stops=-0.7, feather=0.05),)), ) From cfaf7da0840101e1e9ca55ebace776220b6530fa Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sat, 8 Aug 2026 16:29:54 +0200 Subject: [PATCH 3/3] fix: let dodge/burn mask handles go outside the picture A tilted card edge could not burn a full corner. The ramp is already an unbounded half-plane, but the handles were held inside the frame, so the line through the start point always cut one corner off the full-exposure side as soon as you tilted the axis. To hold the whole top edge, the start must sit past the top-right corner, which is off the frame. The render path was ready for this: `map_coords_to_geometry` is analytic and takes coords outside [0,1]. Only the view path clamped. Both uv-grid lookups now continue past the boundary with an affine model of the grid, taken from central differences in the middle of the grid. The samples avoid the border, because a fine rotation fills it with zeros and those are not coordinates. Drop the clamps on the shape drag, on the handle drag and on the emitted points. This also permits an oval whose centre is off the frame, and a polygon vertex outside the picture. --- docs/USER_GUIDE.md | 2 + negpy/desktop/view/canvas/overlay.py | 67 ++++++++++------------- negpy/services/view/coordinate_mapping.py | 46 ++++++++++++++-- tests/test_canvas_mask_edit.py | 36 ++++++++++++ tests/test_coordinate_mapping_offframe.py | 57 +++++++++++++++++++ tests/test_local_logic.py | 22 ++++++++ 6 files changed, 186 insertions(+), 44 deletions(-) create mode 100644 tests/test_coordinate_mapping_offframe.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f77a0e20..c93c770c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -382,6 +382,8 @@ Draw masks and lighten or darken just those areas. Three shapes, one per darkroo * **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. Inactive on a Card Edge. diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 2138b777..7759ab6b 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -1778,6 +1778,17 @@ def _map_to_image_coords(self, screen_pos: QPointF) -> Optional[Tuple[float, flo return float(np.clip(nb_x, 0, 1)), float(np.clip(nb_y, 0, 1)) + def _map_to_image_coords_unbounded(self, screen_pos: QPointF) -> Optional[Tuple[float, float]]: + """As `_map_to_image_coords`, but keeps points off the frame. + + A mask handle can sit outside the picture. A card edge needs this: to burn a + full corner at an angle, its line must start beyond that corner. + """ + rect = self._content_view_rect() + if rect.isEmpty(): + return None + return (screen_pos.x() - rect.x()) / rect.width(), (screen_pos.y() - rect.y()) / rect.height() + def mousePressEvent(self, event: QMouseEvent) -> None: # While the strip is up the canvas is a picker: a click keeps a patch, and no # tool (or pan) gets the event. @@ -2000,26 +2011,22 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: event.accept() return + # Mask handles are not held inside the frame: a card edge that burns a full + # corner at an angle has its line outside the picture. if self._local_drag_vertex is not None and self._local_edit_verts is not None and not self._view_rect.isEmpty(): - rect = self._content_view_rect() - px = float(np.clip(event.position().x(), rect.left(), rect.right())) - py = float(np.clip(event.position().y(), rect.top(), rect.bottom())) + pos = event.position() if self._local_drag_anchor is not None: - delta = QPointF(px, py) - self._local_drag_anchor - self._local_drag_anchor = QPointF(px, py) + delta = pos - self._local_drag_anchor + self._local_drag_anchor = pos self._local_edit_verts = [p + delta for p in self._local_edit_verts] else: - self._local_edit_verts[self._local_drag_vertex] = QPointF(px, py) + self._local_edit_verts[self._local_drag_vertex] = pos self.update() event.accept() return if self._shape_draw_p1 is not None and event.buttons() & Qt.MouseButton.LeftButton: - rect = self._content_view_rect() - self._shape_draw_p2 = QPointF( - float(np.clip(event.position().x(), rect.left(), rect.right())), - float(np.clip(event.position().y(), rect.top(), rect.bottom())), - ) + self._shape_draw_p2 = event.position() self.update() event.accept() return @@ -2255,24 +2262,17 @@ def _finish_lasso(self) -> None: self._emit_mask(MaskShape.POLYGON, pts) def _emit_mask(self, shape: MaskShape, pts: List[QPointF]) -> None: - """Send the drawn points to the controller. Discard the mask if one point is - outside the frame.""" - vertices = [] + """Send the drawn points to the controller. A point off the frame is kept.""" if len(pts) >= min_points(shape): - for pt in pts: - coords = self._map_to_image_coords(pt) - if coords is None: - self.update() - return - vertices.append(coords) - self.local_mask_created.emit(str(shape), vertices) + vertices = [self._map_to_image_coords_unbounded(pt) for pt in pts] + if all(v is not None for v in vertices): + self.local_mask_created.emit(str(shape), vertices) self.update() def _handle_shape_press(self, pos: QPointF) -> None: """Start the drag of an oval or a card edge. A click on an existing mask - selects that mask, as the lasso tool does.""" - rect = self._content_view_rect() - if not rect.contains(pos): + selects that mask, as the lasso tool does. The drag can start off the frame.""" + if self._content_view_rect().isEmpty(): return if self._try_start_vertex_edit(pos) or self._try_select_mask_at(pos): return @@ -2284,11 +2284,7 @@ def _finish_shape_draw(self, pos: QPointF) -> None: p1, self._shape_draw_p1, self._shape_draw_p2 = self._shape_draw_p1, None, None if p1 is None: return - rect = self._content_view_rect() - p2 = QPointF( - float(np.clip(pos.x(), rect.left(), rect.right())), - float(np.clip(pos.y(), rect.top(), rect.bottom())), - ) + p2 = pos # A click without movement is an error. Do not make a mask with no size. if (p2 - p1).manhattanLength() < 8.0: self.update() @@ -2452,16 +2448,9 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: selected = getattr(self.state, "local_selected_mask", -1) self._end_local_edit() if verts and selected >= 0 and not self._view_rect.isEmpty(): - rect = self._content_view_rect() - w, h = rect.width(), rect.height() - vp = [ - ( - float(np.clip((p.x() - rect.x()) / w, 0.0, 1.0)), - float(np.clip((p.y() - rect.y()) / h, 0.0, 1.0)), - ) - for p in verts - ] - self.local_mask_edited.emit(selected, vp) + vp = [self._map_to_image_coords_unbounded(p) for p in verts] + if all(v is not None for v in vp): + self.local_mask_edited.emit(selected, vp) self.update() event.accept() return diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index b68767ed..548a5601 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -60,16 +60,43 @@ def create_uv_grid( return uv_grid + @staticmethod + def _grid_affine(uv_grid: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """The grid as an affine map: (viewport reference, raw reference, 2x2 rate). + + Two central differences give the rate, so the model is exact for the affine part + of the geometry (rotation, flips, fine rotation, crop). Distortion is not affine, + but the model applies only off the frame, where the grid has no data anyway. + + All samples come from the middle of the grid. A fine rotation fills the grid + border with zeros, and those are not coordinates. + """ + h_uv, w_uv = uv_grid.shape[:2] + x0, x1 = w_uv // 4, w_uv - 1 - w_uv // 4 + y0, y1 = h_uv // 4, h_uv - 1 - h_uv // 4 + cx, cy = w_uv // 2, h_uv // 2 + d_col = (uv_grid[cy, x1] - uv_grid[cy, x0]) * ((w_uv - 1) / max(x1 - x0, 1)) + d_row = (uv_grid[y1, cx] - uv_grid[y0, cx]) * ((h_uv - 1) / max(y1 - y0, 1)) + jacobian = np.stack([d_col, d_row], axis=-1).astype(np.float64) + viewport_ref = np.array([cx / (w_uv - 1), cy / (h_uv - 1)], dtype=np.float64) + return viewport_ref, np.asarray(uv_grid[cy, cx], dtype=np.float64), jacobian + @staticmethod def map_click_to_raw(nx: float, ny: float, uv_grid: np.ndarray) -> Tuple[float, float]: """ Viewport (0-1) -> Raw (0-1). + + A point off the frame has no grid sample, so the affine model of the grid gives + it. Dodge/burn masks use this, because a card edge must start outside the + picture to cover a corner when you tilt it. """ h_uv, w_uv = uv_grid.shape[:2] - px = int(np.clip(nx * (w_uv - 1), 0, w_uv - 1)) - py = int(np.clip(ny * (h_uv - 1), 0, h_uv - 1)) - raw_uv = uv_grid[py, px] - return float(raw_uv[0]), float(raw_uv[1]) + if 0.0 <= nx <= 1.0 and 0.0 <= ny <= 1.0: + raw_uv = uv_grid[int(ny * (h_uv - 1)), int(nx * (w_uv - 1))] + return float(raw_uv[0]), float(raw_uv[1]) + viewport_ref, raw_ref, jacobian = CoordinateMapping._grid_affine(uv_grid) + out = raw_ref + jacobian @ (np.array([nx, ny], dtype=np.float64) - viewport_ref) + return float(out[0]), float(out[1]) @staticmethod def map_raw_to_viewport(rx: float, ry: float, uv_grid: np.ndarray, buckets: int = 100) -> Tuple[float, float]: @@ -99,4 +126,13 @@ def map_raw_to_viewport(rx: float, ry: float, uv_grid: np.ndarray, buckets: int widx = int(np.argmin(wdist)) wy, wx = divmod(widx, window.shape[1]) - return min((x0 + wx + 0.5) / w_uv, 1.0), min((y0 + wy + 0.5) / h_uv, 1.0) + nx, ny = min((x0 + wx + 0.5) / w_uv, 1.0), min((y0 + wy + 0.5) / h_uv, 1.0) + + # The nearest sample is more than one grid step away only if the raw point is off + # the frame. Then the affine model gives the answer, the inverse of what + # map_click_to_raw does there. + if float(wdist.flat[widx]) > (2.0 / max(h_uv, w_uv)) ** 2: + viewport_ref, raw_ref, jacobian = CoordinateMapping._grid_affine(uv_grid) + out = viewport_ref + np.linalg.solve(jacobian, np.array([rx, ry], dtype=np.float64) - raw_ref) + return float(out[0]), float(out[1]) + return nx, ny diff --git a/tests/test_canvas_mask_edit.py b/tests/test_canvas_mask_edit.py index 4f60f760..9bc95f1e 100644 --- a/tests/test_canvas_mask_edit.py +++ b/tests/test_canvas_mask_edit.py @@ -10,6 +10,16 @@ _TRIANGLE = [QPointF(20, 20), QPointF(80, 20), QPointF(50, 80)] +def _move(pos: QPointF) -> QMouseEvent: + return QMouseEvent(QEvent.Type.MouseMove, pos, Qt.MouseButton.NoButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier) + + +def _release(pos: QPointF) -> QMouseEvent: + return QMouseEvent( + QEvent.Type.MouseButtonRelease, pos, Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier + ) + + def _overlay_with_mask(tool: ToolMode = ToolMode.LOCAL_DRAW, shape: MaskShape = MaskShape.POLYGON) -> CanvasOverlay: from PyQt6.QtWidgets import QWidget @@ -115,6 +125,32 @@ def test_dragging_out_an_oval_emits_three_control_points() -> None: assert [(round(x, 3), round(y, 3)) for x, y in pts] == [(0.7, 0.7), (0.5, 0.7), (0.7, 0.5)] +def test_a_card_edge_can_be_drawn_off_the_frame() -> None: + """The start of a tilted card edge must go past the corner it burns.""" + overlay = _overlay_with_mask(ToolMode.LOCAL_GRADIENT) + emitted: list = [] + overlay.local_mask_created.connect(lambda shape, pts: emitted.append(pts)) + + overlay._handle_shape_press(QPointF(130, -20)) # Outside the 100x100 content rect. + overlay._finish_shape_draw(QPointF(60, 40)) + + assert len(emitted) == 1 + assert [(round(x, 2), round(y, 2)) for x, y in emitted[0]] == [(1.3, -0.2), (0.6, 0.4)] + + +def test_a_dragged_handle_is_not_held_inside_the_frame() -> None: + overlay = _overlay_with_mask(shape=MaskShape.OVAL) + edits: list = [] + overlay.local_mask_edited.connect(lambda i, pts: edits.append(pts)) + + overlay._handle_lasso_press(QPointF(80, 20)) # An axis handle. + overlay.mouseMoveEvent(_move(QPointF(150, -30))) + overlay.mouseReleaseEvent(_release(QPointF(150, -30))) + + assert len(edits) == 1 + assert edits[0][1] == (1.5, -0.3) + + def test_a_shape_click_without_travel_draws_nothing() -> None: overlay = _overlay_with_mask(ToolMode.LOCAL_GRADIENT) emitted: list = [] diff --git a/tests/test_coordinate_mapping_offframe.py b/tests/test_coordinate_mapping_offframe.py new file mode 100644 index 00000000..ec4f1080 --- /dev/null +++ b/tests/test_coordinate_mapping_offframe.py @@ -0,0 +1,57 @@ +"""Viewport <-> raw mapping for points off the frame. + +A dodge/burn handle can sit outside the picture. The uv grid has no sample there, so +both directions continue past the boundary at the grid rate. +""" + +import numpy as np +import pytest + +from negpy.services.view.coordinate_mapping import CoordinateMapping + +CASES = [ + {"rotation": 0, "fine_rot": 0.0, "flip_h": False}, + {"rotation": 1, "fine_rot": 0.0, "flip_h": False}, + {"rotation": 0, "fine_rot": 0.0, "flip_h": True}, + {"rotation": 2, "fine_rot": 6.0, "flip_h": False}, +] + +OFF_FRAME = [(1.4, 0.5), (-0.3, 0.25), (0.5, -0.2), (1.2, 1.3)] + + +def _grid(rotation: int = 0, fine_rot: float = 0.0, flip_h: bool = False) -> np.ndarray: + return CoordinateMapping.create_uv_grid(240, 320, rotation, fine_rot, flip_h=flip_h) + + +def test_an_inside_point_is_unchanged() -> None: + grid = _grid() + assert CoordinateMapping.map_click_to_raw(0.25, 0.75, grid) == pytest.approx((0.25, 0.75), abs=0.01) + + +def test_an_outside_point_keeps_its_distance() -> None: + grid = _grid() + assert CoordinateMapping.map_click_to_raw(1.5, 0.5, grid) == pytest.approx((1.5, 0.5), abs=0.01) + assert CoordinateMapping.map_click_to_raw(-0.4, 0.5, grid) == pytest.approx((-0.4, 0.5), abs=0.01) + + +def test_the_boundary_has_no_step() -> None: + """The affine model agrees with the grid, so the two paths meet at the edge.""" + grid = _grid(rotation=1) + inside = np.array(CoordinateMapping.map_click_to_raw(0.999, 0.4, grid)) + outside = np.array(CoordinateMapping.map_click_to_raw(1.001, 0.4, grid)) + assert float(np.abs(outside - inside).max()) < 0.01 + + +@pytest.mark.parametrize("case", CASES) +@pytest.mark.parametrize("point", OFF_FRAME) +def test_an_off_frame_point_round_trips(case, point) -> None: + grid = _grid(**case) + raw = CoordinateMapping.map_click_to_raw(*point, grid) + assert CoordinateMapping.map_raw_to_viewport(*raw, grid) == pytest.approx(point, abs=0.02) + + +@pytest.mark.parametrize("case", CASES) +def test_an_inside_point_still_round_trips(case) -> None: + grid = _grid(**case) + raw = CoordinateMapping.map_click_to_raw(0.4, 0.6, grid) + assert CoordinateMapping.map_raw_to_viewport(*raw, grid) == pytest.approx((0.4, 0.6), abs=0.02) diff --git a/tests/test_local_logic.py b/tests/test_local_logic.py index 857a18e4..3ee9385b 100644 --- a/tests/test_local_logic.py +++ b/tests/test_local_logic.py @@ -126,6 +126,28 @@ def test_a_card_edge_ramps_from_full_to_nothing(self) -> None: self.assertTrue(np.all(np.diff(row) <= 1e-6)) np.testing.assert_allclose(ev[10, :], ev[90, :], atol=1e-6) + def test_a_tilted_card_edge_can_burn_a_full_corner(self) -> None: + """A tilted line through a point in the frame always cuts a corner off the full + side. The start must go outside the picture to hold the full top edge.""" + axis = (0.2, 0.4) + + def top_corners(start): + grad = LocalMask( + vertices=(start, (start[0] + axis[0], start[1] + axis[1])), + stops=1.0, + shape=MaskShape.GRADIENT, + ) + ev = _ev(LocalAdjustmentsConfig(masks=(grad,))) + return float(ev[0, 0]), float(ev[0, 99]) + + inside_left, inside_right = top_corners((0.5, 0.0)) + self.assertAlmostEqual(inside_left, 1.0, places=5) + self.assertLess(inside_right, 1.0) # the corner the tilt cuts off + + outside_left, outside_right = top_corners((1.1, 0.0)) + self.assertAlmostEqual(outside_left, 1.0, places=5) + self.assertAlmostEqual(outside_right, 1.0, places=5) + def test_a_card_edge_needs_only_two_points(self) -> None: grad = LocalMask(vertices=((0.25, 0.5), (0.75, 0.5)), stops=1.0, shape=MaskShape.GRADIENT) self.assertGreater(float(_ev(LocalAdjustmentsConfig(masks=(grad,))).max()), 0.9)