From 00acf2cf5fc06366f4aff2ae3d4ccbca0b4de219 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 6 Jul 2026 22:30:50 +0200 Subject: [PATCH 1/6] feat(retouch): membrane-clone healing with real grain + scratch/hair stroke tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the quality-limited fills with two patch-copy primitives (identical math on CPU/numba and GPU/WGSL, parity by construction): - Manual heals: Georgiev-style membrane clone — copy a real source patch (grain exactly matched) and blend the seam with a mean-value-coordinates membrane on the boundary difference (solver-free, single compute pass). Clone source is auto-picked at click time by rim-band SSD scoring and stored in the config. The Telea uint8 + synthetic-grain path is deleted. - Auto/IR heals: guarded reflection copy — sample outward from the defect centroid for grain continuity, rotate away from other defects, fall back to the old trimmed mean so the worst case is unchanged. Detection untouched. New Scratch Tool heals elongated defects: click a polyline along the hair/scratch, double-click to commit, Esc cancels (mirrors the lasso). Strokes live in RetouchConfig.manual_heal_strokes; legacy manual_dust_spots still load and convert at pipeline time with deterministic fallback offsets. Placed heals draw as outlines while a retouch tool is active. Tiled export halo now grows with heal radius + source offset (capped 512px), fixing the pre-existing bug where large spots sampled past the fixed 32px halo across tile boundaries. --- negpy/desktop/controller.py | 55 +- negpy/desktop/session.py | 1 + negpy/desktop/view/canvas/overlay.py | 119 +++- negpy/desktop/view/canvas/widget.py | 2 + negpy/desktop/view/main_window.py | 1 + negpy/desktop/view/sidebar/retouch.py | 33 +- negpy/features/retouch/logic.py | 581 ++++++++++++++++---- negpy/features/retouch/models.py | 4 + negpy/features/retouch/processor.py | 37 +- negpy/features/retouch/shaders/retouch.wgsl | 343 +++++++----- negpy/services/rendering/gpu_engine.py | 102 ++-- tests/test_gpu_engine.py | 29 + tests/test_ir_dust.py | 4 +- tests/test_pipeline_parity.py | 21 + tests/test_retouch_logic.py | 154 +++++- 15 files changed, 1154 insertions(+), 332 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 16435ec4..8d292ba2 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -46,6 +46,7 @@ from negpy.features.lab.models import LabConfig from negpy.features.local.models import LocalAdjustmentsConfig from negpy.features.process.models import ProcessMode, invalidate_local_bounds +from negpy.features.retouch.logic import fallback_source_offset, select_source_offset from negpy.features.retouch.models import RetouchConfig from negpy.features.toning.models import ToningConfig from negpy.infrastructure.display.color_spaces import ColorSpaceRegistry @@ -728,25 +729,30 @@ def clear_retouch(self) -> None: self.session.update_config( replace( self.state.config, - retouch=replace(self.state.config.retouch, manual_dust_spots=[]), + retouch=replace(self.state.config.retouch, manual_dust_spots=[], manual_heal_strokes=[]), ) ) self.request_render() def undo_last_retouch(self) -> None: """ - Removes the most recently added dust spot. + Removes the most recently added heal (strokes first, then legacy spots). """ + strokes = list(self.state.config.retouch.manual_heal_strokes) spots = list(self.state.config.retouch.manual_dust_spots) - if spots: + if strokes: + strokes.pop() + elif spots: spots.pop() - self.session.update_config( - replace( - self.state.config, - retouch=replace(self.state.config.retouch, manual_dust_spots=spots), - ) + else: + return + self.session.update_config( + replace( + self.state.config, + retouch=replace(self.state.config.retouch, manual_dust_spots=spots, manual_heal_strokes=strokes), ) - self.request_render() + ) + self.request_render() def _handle_dust_pick(self, nx: float, ny: float) -> None: with self.state.metrics_lock: @@ -754,11 +760,38 @@ def _handle_dust_pick(self, nx: float, ny: float) -> None: if uv_grid is None: return rx, ry = CoordinateMapping.map_click_to_raw(nx, ny, uv_grid) - new_spots = self.state.config.retouch.manual_dust_spots + [(rx, ry, float(self.state.config.retouch.manual_dust_size))] + self._commit_heal_stroke([(rx, ry)]) + + def handle_heal_stroke_completed(self, viewport_pts: list) -> None: + """Commits a scratch-tool polyline (viewport-normalized points).""" + with self.state.metrics_lock: + uv_grid = self.state.last_metrics.get("uv_grid") + if uv_grid is None or not viewport_pts: + return + raw_pts = [CoordinateMapping.map_click_to_raw(nx, ny, uv_grid) for nx, ny in viewport_pts] + self._commit_heal_stroke(raw_pts) + + def _commit_heal_stroke(self, raw_pts: list) -> None: + conf = self.state.config.retouch + size = float(conf.manual_dust_size) + index = len(conf.manual_heal_strokes) + + # Score the clone source on the source-frame preview; size is in source px, + # the preview is downsampled, so scale the radius accordingly. + offset = (0.0, 0.0) + preview = self.state.preview_raw + if preview is not None: + src_w, src_h = self.state.original_res + scale = max(preview.shape[:2]) / max(src_w, src_h, 1) + offset = select_source_offset(preview, raw_pts, size * scale, index) + else: + offset = fallback_source_offset(index, size, (self.state.original_res[1], self.state.original_res[0])) + + stroke = ([[rx, ry] for rx, ry in raw_pts], size, float(offset[0]), float(offset[1])) self.session.update_config( replace( self.state.config, - retouch=replace(self.state.config.retouch, manual_dust_spots=new_spots), + retouch=replace(self.state.config.retouch, manual_heal_strokes=conf.manual_heal_strokes + [stroke]), ) ) self.request_render() diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index d8dd2478..b5398629 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -19,6 +19,7 @@ class ToolMode(Enum): WB_PICK = auto() CROP_MANUAL = auto() DUST_PICK = auto() + SCRATCH_PICK = auto() LOCAL_DRAW = auto() diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index ec0f0169..b4b2f1b1 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -54,6 +54,7 @@ class CanvasOverlay(QWidget): cursor_moved = pyqtSignal(float, float) cursor_left = pyqtSignal() lasso_completed = pyqtSignal(list) + scratch_completed = pyqtSignal(list) local_mask_selected = pyqtSignal(int) def __init__(self, state: AppState, parent=None): @@ -79,6 +80,9 @@ def __init__(self, state: AppState, parent=None): # Lasso (polygon mask) interaction state self._lasso_pts: List[QPointF] = [] self._lasso_drawing: bool = False + + # Scratch heal (open polyline) interaction state + self._scratch_pts: List[QPointF] = [] self._local_mask_screen_polys: List[List[QPointF]] = [] self._mask_img_cache: Dict[tuple, QImage] = {} @@ -146,6 +150,8 @@ def set_tool_mode(self, mode: ToolMode) -> None: if mode != ToolMode.LOCAL_DRAW: self._lasso_pts = [] self._lasso_drawing = False + if mode != ToolMode.SCRATCH_PICK: + self._scratch_pts = [] self.update() def _end_crop_drag(self) -> None: @@ -162,6 +168,9 @@ def _cancel_lasso(self) -> None: self._lasso_pts = [] self._lasso_drawing = False self.update() + elif self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: + self._scratch_pts = [] + self.update() def update_buffer( self, @@ -269,7 +278,7 @@ def _draw_ui(self, painter: QPainter) -> None: painter.drawRect(inner) if self._tool_mode != ToolMode.NONE and visible_rect.contains(self._mouse_pos): - if self._tool_mode == ToolMode.DUST_PICK: + if self._tool_mode in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): self._draw_brush(painter) elif self._tool_mode != ToolMode.LOCAL_DRAW: pen = QPen(QColor(255, 255, 255, 80), 1, Qt.PenStyle.DotLine) @@ -283,6 +292,10 @@ 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.DUST_PICK, ToolMode.SCRATCH_PICK): + self._draw_placed_heals(painter) + if self._tool_mode == ToolMode.SCRATCH_PICK: + self._draw_scratch_in_progress(painter) if self._rotation_grid_visible: self._draw_rotation_grid(painter, visible_rect) @@ -315,9 +328,7 @@ def _draw_compare_badge(self, painter: QPainter, visible_rect: QRectF) -> None: painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "BEFORE") def _draw_brush(self, painter: QPainter) -> None: - conf = self.state.config.retouch - max_screen_dim = max(self._view_rect.width(), self._view_rect.height()) - radius = (conf.manual_dust_size / (2.0 * APP_CONFIG.preview_render_size)) * max_screen_dim + radius = self._brush_screen_radius(self.state.config.retouch.manual_dust_size) painter.setBrush(Qt.BrushStyle.NoBrush) pen = QPen(Qt.GlobalColor.white, 1.0, Qt.PenStyle.SolidLine) @@ -331,6 +342,76 @@ def _draw_brush(self, painter: QPainter) -> None: painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(self._mouse_pos, radius, radius) + def _brush_screen_radius(self, size: float) -> float: + max_screen_dim = max(self._view_rect.width(), self._view_rect.height()) + return (size / (2.0 * APP_CONFIG.preview_render_size)) * max_screen_dim + + def _draw_scratch_in_progress(self, painter: QPainter) -> None: + if not self._scratch_pts: + return + width = max(1.5, 2.0 * self._brush_screen_radius(self.state.config.retouch.manual_dust_size)) + + band = QColor(THEME.accent_primary) + band.setAlpha(60) + pen = QPen(band, width, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + path = QPainterPath(self._scratch_pts[0]) + for pt in self._scratch_pts[1:]: + path.lineTo(pt) + if self._view_rect.contains(self._mouse_pos): + path.lineTo(self._mouse_pos) + painter.drawPath(path) + + centerline = QPen(Qt.GlobalColor.white, 1.0, Qt.PenStyle.SolidLine) + centerline.setCosmetic(True) + painter.setPen(centerline) + painter.drawPath(path) + painter.setBrush(QColor(255, 255, 255, 180)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(self._scratch_pts[0], 3.0, 3.0) + + def _draw_placed_heals(self, painter: QPainter) -> None: + """Thin outlines of committed heals (strokes + legacy spots) while a retouch tool is active.""" + conf = self.state.config.retouch + if not (conf.manual_heal_strokes or conf.manual_dust_spots): + return + with self.state.metrics_lock: + uv_grid = self.state.last_metrics.get("uv_grid") + if uv_grid is None: + return + + pen = QPen(QColor(THEME.accent_primary), 1.0, Qt.PenStyle.SolidLine) + pen.setCosmetic(True) + painter.setBrush(Qt.BrushStyle.NoBrush) + + for points, size, _dx, _dy in conf.manual_heal_strokes: + screen_pts = [self._raw_to_screen(px, py, uv_grid) for px, py in points] + radius = max(2.0, self._brush_screen_radius(size)) + if len(screen_pts) == 1: + painter.setPen(pen) + painter.drawEllipse(screen_pts[0], radius, radius) + else: + band = QPen( + QColor(THEME.accent_primary), 2.0 * radius, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin + ) + band_color = QColor(THEME.accent_primary) + band_color.setAlpha(40) + band.setColor(band_color) + painter.setPen(band) + path = QPainterPath(screen_pts[0]) + for pt in screen_pts[1:]: + path.lineTo(pt) + painter.drawPath(path) + painter.setPen(pen) + painter.drawPath(path) + + painter.setPen(pen) + for rx, ry, size in conf.manual_dust_spots: + center = self._raw_to_screen(rx, ry, uv_grid) + radius = max(2.0, self._brush_screen_radius(size)) + painter.drawEllipse(center, radius, radius) + def _raw_to_screen(self, rx: float, ry: float, uv_grid: np.ndarray, buckets: int = 100) -> QPointF: """ Inverse UV-grid lookup: raw-normalised (0-1) -> screen position. @@ -600,6 +681,13 @@ def mousePressEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._tool_mode == ToolMode.SCRATCH_PICK: + if self._view_rect.contains(event.position()): + self._scratch_pts.append(event.position()) + self.update() + event.accept() + return + coords = self._map_to_image_coords(event.position()) if coords: self.clicked.emit(*coords) @@ -764,8 +852,31 @@ def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: self._finish_lasso() event.accept() return + if self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: + self._finish_scratch() + event.accept() + return super().mouseDoubleClickEvent(event) + def _finish_scratch(self) -> None: + pts = self._scratch_pts + self._scratch_pts = [] + # The double-click lands as an extra press at the previous point — drop near-duplicates. + deduped: List[QPointF] = [] + for pt in pts: + if not deduped or (pt - deduped[-1]).manhattanLength() > 2.0: + deduped.append(pt) + vertices = [] + for pt in deduped: + coords = self._map_to_image_coords(pt) + if coords is None: + self.update() + return + vertices.append(coords) + if vertices: + self.scratch_completed.emit(vertices) + self.update() + def mouseReleaseEvent(self, event: QMouseEvent) -> None: if self.parent()._is_panning: self.parent()._is_panning = False diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 699a411a..ffbfbf95 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -78,6 +78,7 @@ class ImageCanvas(QWidget): cursor_position_changed = pyqtSignal(float, float) cursor_left_canvas = pyqtSignal() lasso_completed = pyqtSignal(list) + scratch_completed = pyqtSignal(list) local_mask_selected = pyqtSignal(int) def __init__(self, state: AppState, parent=None): @@ -123,6 +124,7 @@ def __init__(self, state: AppState, parent=None): 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.scratch_completed.connect(self.scratch_completed.emit) self.overlay.local_mask_selected.connect(self.local_mask_selected.emit) self.grabGesture(Qt.GestureType.PinchGesture) diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 7a21ce3c..3d529fec 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -328,6 +328,7 @@ def _connect_signals(self) -> None: self.canvas.clicked.connect(self.controller.handle_canvas_clicked) self.canvas.crop_rect_changed.connect(self.controller.handle_crop_rect_changed) self.canvas.lasso_completed.connect(self.controller.handle_lasso_completed) + self.canvas.scratch_completed.connect(self.controller.handle_heal_stroke_completed) self.canvas.local_mask_selected.connect(self.controller.select_local_mask) self.controller.export_progress.connect(self._on_export_progress) diff --git a/negpy/desktop/view/sidebar/retouch.py b/negpy/desktop/view/sidebar/retouch.py index 17953ff1..e091c72a 100644 --- a/negpy/desktop/view/sidebar/retouch.py +++ b/negpy/desktop/view/sidebar/retouch.py @@ -36,10 +36,16 @@ def _init_ui(self) -> None: self.pick_dust_btn = QPushButton(" Heal Tool") self.pick_dust_btn.setCheckable(True) self.pick_dust_btn.setIcon(qta.icon("fa5s.eye-dropper", color=THEME.text_primary)) - self.pick_dust_btn.setToolTip(tooltip_with_shortcut("Toggle heal tool", "pick_dust")) + self.pick_dust_btn.setToolTip(tooltip_with_shortcut("Toggle heal tool — click a dust spot to heal it", "pick_dust")) + + self.pick_scratch_btn = QPushButton(" Scratch Tool") + self.pick_scratch_btn.setCheckable(True) + self.pick_scratch_btn.setIcon(qta.icon("fa5s.pen-nib", color=THEME.text_primary)) + self.pick_scratch_btn.setToolTip("Heal a scratch or hair: click points along it, double-click to finish, Esc cancels") buttons_row.addWidget(self.auto_dust_btn) buttons_row.addWidget(self.pick_dust_btn) + buttons_row.addWidget(self.pick_scratch_btn) self.layout.addLayout(buttons_row) self.manual_size_slider = CompactSlider("Brush Size", 2.0, 16.0, float(conf.manual_dust_size), step=1.0, precision=1, unit=" px") @@ -52,11 +58,11 @@ def _init_ui(self) -> None: actions_row = QHBoxLayout() self.undo_btn = QPushButton(" Undo Last") self.undo_btn.setIcon(qta.icon("fa5s.undo", color=THEME.text_primary)) - self.undo_btn.setToolTip("Remove the most recent manual healing spot") + self.undo_btn.setToolTip("Remove the most recent manual heal") self.clear_btn = QPushButton(" Clear All") self.clear_btn.setIcon(qta.icon("fa5s.trash-alt", color=THEME.text_primary)) - self.clear_btn.setToolTip("Remove all manual healing spots (auto-detected dust is unaffected)") + self.clear_btn.setToolTip("Remove all manual heals (auto-detected dust is unaffected)") actions_row.addWidget(self.undo_btn, 1) actions_row.addWidget(self.clear_btn, 1) @@ -90,6 +96,7 @@ def _connect_signals(self) -> None: lambda v: self.update_config_section("retouch", readback_metrics=False, dust_size=int(v)) # TODO: precision loss from int cast ) self.pick_dust_btn.toggled.connect(self._on_pick_toggled) + self.pick_scratch_btn.toggled.connect(self._on_scratch_toggled) self.manual_size_slider.valueChanged.connect( lambda v: self.update_config_section("retouch", render=False, persist=True, manual_dust_size=int(v)) ) @@ -103,7 +110,11 @@ def _connect_signals(self) -> None: def _on_pick_toggled(self, checked: bool) -> None: self.controller.set_active_tool(ToolMode.DUST_PICK if checked else ToolMode.NONE) - self.manual_size_slider.setVisible(checked) + self.manual_size_slider.setVisible(checked or self.pick_scratch_btn.isChecked()) + + def _on_scratch_toggled(self, checked: bool) -> None: + self.controller.set_active_tool(ToolMode.SCRATCH_PICK if checked else ToolMode.NONE) + self.manual_size_slider.setVisible(checked or self.pick_dust_btn.isChecked()) def _set_ir_controls_enabled(self, enabled: bool) -> None: tip = "" if enabled else "No IR channel in this scan" @@ -120,14 +131,15 @@ def sync_ui(self) -> None: self.auto_size_slider.setValue(float(conf.dust_size)) self.manual_size_slider.setValue(float(conf.manual_dust_size)) self.pick_dust_btn.setChecked(self.state.active_tool == ToolMode.DUST_PICK) - self.manual_size_slider.setVisible(self.state.active_tool == ToolMode.DUST_PICK) + self.pick_scratch_btn.setChecked(self.state.active_tool == ToolMode.SCRATCH_PICK) + self.manual_size_slider.setVisible(self.state.active_tool in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK)) - num_spots = len(conf.manual_dust_spots) - self.heals_subheader.setText(f"HEALS · {num_spots}") + num_heals = len(conf.manual_dust_spots) + len(conf.manual_heal_strokes) + self.heals_subheader.setText(f"HEALS · {num_heals}") - has_spots = num_spots > 0 - self.undo_btn.setEnabled(has_spots) - self.clear_btn.setEnabled(has_spots) + has_heals = num_heals > 0 + self.undo_btn.setEnabled(has_heals) + self.clear_btn.setEnabled(has_heals) self.ir_dust_btn.setChecked(conf.ir_dust_remove) self.ir_threshold_slider.setValue(float(conf.ir_threshold)) @@ -142,6 +154,7 @@ def block_signals(self, blocked: bool) -> None: self.auto_size_slider, self.manual_size_slider, self.pick_dust_btn, + self.pick_scratch_btn, self.ir_dust_btn, self.ir_threshold_slider, ] diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 0694e718..80603ba1 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -1,11 +1,32 @@ +import math + import numpy as np import cv2 from numba import njit # type: ignore from typing import List, Optional, Tuple from negpy.domain.types import ImageBuffer, LUMA_R, LUMA_G, LUMA_B +from negpy.features.geometry.logic import map_coords_to_geometry from negpy.kernel.image.validation import ensure_image from negpy.kernel.image.logic import get_luminance, working_oetf_decode, working_oetf_encode +# Golden-angle fallback used when a heal has no scored source offset +# (legacy spots, or no preview buffer at click time). +_GOLDEN_ANGLE = 2.39996322972865332 +_FALLBACK_OFFSET_FACTOR = 2.6 + + +@njit(cache=True, fastmath=True) +def _hash2(x: float, y: float) -> float: + """Port of the WGSL hash() so degenerate-direction picks match the GPU.""" + px = (x * 0.1031) % 1.0 + py = (y * 0.1031) % 1.0 + pz = (x * 0.1031) % 1.0 + d = px * (py + 33.33) + py * (pz + 33.33) + pz * (px + 33.33) + px += d + py += d + pz += d + return ((px + py) * pz) % 1.0 + @njit(cache=True, fastmath=True) def _heal_with_mask_jit( @@ -14,18 +35,31 @@ def _heal_with_mask_jit( exp_rad: int, p_rad: int, ) -> np.ndarray: - """Stochastic perimeter sampling with cubic-smoothstep feather. - - For each pixel, finds nearest masked pixel within ``exp_rad``, builds a - feather weight from the distance, samples 8 perimeter points at ``p_rad``, - trim-means them, and blends into the original. + """Guarded reflection-copy heal with cubic-smoothstep feather. + + For each pixel, finds the nearest masked pixel within ``exp_rad`` and the + centroid of masked pixels in the window, then copies the pixel at + ``p + normalize(p - centroid) * p_rad`` — a coherent outward copy that + preserves grain/texture. If the source lands on another defect the + direction is rotated (±45°, ±90°); if all rotations fail it falls back to + the old 8-point trimmed mean, so the worst case equals the previous fill. """ h, w, _ = img.shape res = img.copy() + cos_a = np.empty(5, dtype=np.float64) + sin_a = np.empty(5, dtype=np.float64) + angles = (0.0, math.pi / 4.0, -math.pi / 4.0, math.pi / 2.0, -math.pi / 2.0) + for i in range(5): + cos_a[i] = math.cos(angles[i]) + sin_a[i] = math.sin(angles[i]) + for y in range(h): for x in range(w): min_d2 = 1e6 + c_x = 0.0 + c_y = 0.0 + c_n = 0.0 for dy in range(-exp_rad, exp_rad + 1): for dx in range(-exp_rad, exp_rad + 1): ry, rx = y + dy, x + dx @@ -33,6 +67,9 @@ def _heal_with_mask_jit( d2 = float(dy * dy + dx * dx) if d2 < min_d2: min_d2 = d2 + c_x += float(rx) + c_y += float(ry) + c_n += 1.0 if min_d2 < float(exp_rad * exp_rad + 1): dist = np.sqrt(min_d2) @@ -42,32 +79,62 @@ def _heal_with_mask_jit( feather = feather * feather * (3.0 - 2.0 * feather) if feather > 0.001: - s_r = np.zeros(8) - s_g = np.zeros(8) - s_b = np.zeros(8) - s_l = np.zeros(8) - - dy_off = np.array([-p_rad, p_rad, 0, 0, -p_rad, -p_rad, p_rad, p_rad]) - dx_off = np.array([0, 0, -p_rad, p_rad, -p_rad, p_rad, -p_rad, p_rad]) - - for i in range(8): - sy, sx = y + dy_off[i], x + dx_off[i] - sy, sx = max(0, min(h - 1, sy)), max(0, min(w - 1, sx)) - r, g, b = img[sy, sx, 0], img[sy, sx, 1], img[sy, sx, 2] - s_r[i], s_g[i], s_b[i] = r, g, b - s_l[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b - - for i in range(8): - for j in range(i + 1, 8): - if s_l[i] > s_l[j]: - s_l[i], s_l[j] = s_l[j], s_l[i] - s_r[i], s_r[j] = s_r[j], s_r[i] - s_g[i], s_g[j] = s_g[j], s_g[i] - s_b[i], s_b[j] = s_b[j], s_b[i] - - bg_r = (s_r[2] + s_r[3] + s_r[4] + s_r[5]) / 4.0 - bg_g = (s_g[2] + s_g[3] + s_g[4] + s_g[5]) / 4.0 - bg_b = (s_b[2] + s_b[3] + s_b[4] + s_b[5]) / 4.0 + ux = float(x) - c_x / c_n + uy = float(y) - c_y / c_n + ul = math.sqrt(ux * ux + uy * uy) + if ul < 1e-3: + ang = _hash2(float(x), float(y)) * 6.28318530718 + ux = math.cos(ang) + uy = math.sin(ang) + else: + ux /= ul + uy /= ul + + found = False + bg_r = 0.0 + bg_g = 0.0 + bg_b = 0.0 + for k in range(5): + rx_dir = ux * cos_a[k] - uy * sin_a[k] + ry_dir = ux * sin_a[k] + uy * cos_a[k] + sx = int(round(float(x) + rx_dir * float(p_rad))) + sy = int(round(float(y) + ry_dir * float(p_rad))) + sx = max(0, min(w - 1, sx)) + sy = max(0, min(h - 1, sy)) + if hit_mask[sy, sx] < 0.5: + bg_r = img[sy, sx, 0] + bg_g = img[sy, sx, 1] + bg_b = img[sy, sx, 2] + found = True + break + + if not found: + s_r = np.zeros(8) + s_g = np.zeros(8) + s_b = np.zeros(8) + s_l = np.zeros(8) + + dy_off = np.array([-p_rad, p_rad, 0, 0, -p_rad, -p_rad, p_rad, p_rad]) + dx_off = np.array([0, 0, -p_rad, p_rad, -p_rad, p_rad, -p_rad, p_rad]) + + for i in range(8): + sy2, sx2 = y + dy_off[i], x + dx_off[i] + sy2, sx2 = max(0, min(h - 1, sy2)), max(0, min(w - 1, sx2)) + r, g, b = img[sy2, sx2, 0], img[sy2, sx2, 1], img[sy2, sx2, 2] + s_r[i], s_g[i], s_b[i] = r, g, b + s_l[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b + + for i in range(8): + for j in range(i + 1, 8): + if s_l[i] > s_l[j]: + s_l[i], s_l[j] = s_l[j], s_l[i] + s_r[i], s_r[j] = s_r[j], s_r[i] + s_g[i], s_g[j] = s_g[j], s_g[i] + s_b[i], s_b[j] = s_b[j], s_b[i] + + bg_r = (s_r[2] + s_r[3] + s_r[4] + s_r[5]) / 4.0 + bg_g = (s_g[2] + s_g[3] + s_g[4] + s_g[5]) / 4.0 + bg_b = (s_b[2] + s_b[3] + s_b[4] + s_b[5]) / 4.0 res[y, x, 0] = img[y, x, 0] * (1.0 - feather) + bg_r * feather res[y, x, 1] = img[y, x, 1] * (1.0 - feather) + bg_g * feather @@ -137,61 +204,394 @@ def _apply_auto_retouch_jit( @njit(cache=True, fastmath=True) -def _apply_inpainting_grain_jit( - img: np.ndarray, - img_inpainted: np.ndarray, - mask_final: np.ndarray, - noise: np.ndarray, -) -> np.ndarray: - h, w, c = img_inpainted.shape - res = np.empty_like(img_inpainted) - - for y in range(h): - for x in range(w): - lum = (LUMA_R * img_inpainted[y, x, 0] + LUMA_G * img_inpainted[y, x, 1] + LUMA_B * img_inpainted[y, x, 2]) / 255.0 - mod = 3.0 * lum * (1.0 - lum) - m = mask_final[y, x, 0] - - orig_luma = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - heal_luma = (LUMA_R * img_inpainted[y, x, 0] + LUMA_G * img_inpainted[y, x, 1] + LUMA_B * img_inpainted[y, x, 2]) / 255.0 - - luma_key = (orig_luma - heal_luma - 0.04) / 0.08 - if luma_key < 0.0: - luma_key = 0.0 - if luma_key > 1.0: - luma_key = 1.0 - - final_m = m * luma_key +def _dist_to_chain(px: float, py: float, pts: np.ndarray) -> float: + """Distance from (px, py) to the polyline ``pts`` ((M, 2) pixel coords).""" + m = pts.shape[0] + if m == 1: + dx = px - pts[0, 0] + dy = py - pts[0, 1] + return math.sqrt(dx * dx + dy * dy) + best = 1e18 + for s in range(m - 1): + ax, ay = pts[s, 0], pts[s, 1] + bx, by = pts[s + 1, 0], pts[s + 1, 1] + abx, aby = bx - ax, by - ay + ab2 = abx * abx + aby * aby + if ab2 < 1e-12: + t = 0.0 + else: + t = ((px - ax) * abx + (py - ay) * aby) / ab2 + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + cx = ax + t * abx + cy = ay + t * aby + dx = px - cx + dy = py - cy + d = math.sqrt(dx * dx + dy * dy) + if d < best: + best = d + return best - for ch in range(3): - val = img_inpainted[y, x, ch] + noise[y, x, ch] * 0.4 * mod * final_m - res[y, x, ch] = img[y, x, ch] * (1.0 - final_m) + (val / 255.0) * final_m - return res +@njit(cache=True, fastmath=True) +def _membrane_heal_jit( + buf: np.ndarray, + reg_i: np.ndarray, + reg_f: np.ndarray, + pts: np.ndarray, +) -> None: + """Mean-value-coordinates membrane clone (Georgiev healing brush), in place. + + ``reg_i``: (R, 4) int32 — pt_start, pt_count, bnd_start, bnd_count into ``pts``. + ``reg_f``: (R, 3) float32 — radius_px, src_off_x, src_off_y (pixels). + ``pts``: (P, 2) float32 pixel coords (continuous, +0.5 = pixel center). + + out(p) = img(p + off) + Σ ŵ_i (img(b_i) − img(b_i + off)) — the copied + source patch carries real grain; the MVC-weighted boundary-difference field + is the smooth membrane that matches the destination at the rim. + Heal values sample the immutable stage input (matching the GPU's + single-pass ``input_tex`` reads); only the blend base evolves in ``buf``. + """ + img = buf.copy() + h, w, _ = buf.shape + n_reg = reg_i.shape[0] + diffs = np.empty((64, 3), dtype=np.float32) + tans = np.empty(64, dtype=np.float64) + vlen = np.empty(64, dtype=np.float64) + vx = np.empty(64, dtype=np.float64) + vy = np.empty(64, dtype=np.float64) + + for r in range(n_reg): + ps, pc, bs, bc = reg_i[r, 0], reg_i[r, 1], reg_i[r, 2], reg_i[r, 3] + rad = reg_f[r, 0] + ox = reg_f[r, 1] + oy = reg_f[r, 2] + if bc < 3 or bc > 64 or pc < 1: + continue + + for i in range(bc): + bxf = pts[bs + i, 0] + byf = pts[bs + i, 1] + bx = max(0, min(w - 1, int(bxf))) + by = max(0, min(h - 1, int(byf))) + sx = max(0, min(w - 1, int(bxf + ox))) + sy = max(0, min(h - 1, int(byf + oy))) + for c in range(3): + diffs[i, c] = img[by, bx, c] - img[sy, sx, c] + + x0 = int(pts[ps, 0]) + x1 = x0 + y0 = int(pts[ps, 1]) + y1 = y0 + for i in range(pc): + x0 = min(x0, int(pts[ps + i, 0])) + x1 = max(x1, int(pts[ps + i, 0])) + y0 = min(y0, int(pts[ps + i, 1])) + y1 = max(y1, int(pts[ps + i, 1])) + pad = int(rad) + 2 + x0 = max(0, x0 - pad) + y0 = max(0, y0 - pad) + x1 = min(w - 1, x1 + pad) + y1 = min(h - 1, y1 + pad) + + chain = pts[ps : ps + pc] + + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + px = float(x) + 0.5 + py = float(y) + 0.5 + d = _dist_to_chain(px, py, chain) + if d >= rad: + continue + + on_sample = -1 + for i in range(bc): + vix = pts[bs + i, 0] - px + viy = pts[bs + i, 1] - py + li = math.sqrt(vix * vix + viy * viy) + vx[i] = vix + vy[i] = viy + vlen[i] = li + if li < 1e-4: + on_sample = i + + mr = 0.0 + mg = 0.0 + mb = 0.0 + if on_sample >= 0: + mr = diffs[on_sample, 0] + mg = diffs[on_sample, 1] + mb = diffs[on_sample, 2] + else: + for i in range(bc): + j = i + 1 + if j == bc: + j = 0 + cross = vx[i] * vy[j] - vy[i] * vx[j] + if -1e-9 < cross < 1e-9: + cross = 1e-9 + tans[i] = (vlen[i] * vlen[j] - (vx[i] * vx[j] + vy[i] * vy[j])) / cross + wsum = 0.0 + for i in range(bc): + prev = i - 1 + if prev < 0: + prev = bc - 1 + wi = (tans[prev] + tans[i]) / vlen[i] + wsum += wi + mr += wi * diffs[i, 0] + mg += wi * diffs[i, 1] + mb += wi * diffs[i, 2] + if -1e-12 < wsum < 1e-12: + continue + mr /= wsum + mg /= wsum + mb /= wsum + + sx = max(0, min(w - 1, int(px + ox))) + sy = max(0, min(h - 1, int(py + oy))) + + # 1.5px feather at the rim hides boundary-sampling aliasing. + t = (d - (rad - 1.5)) / 1.5 + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + alpha = 1.0 - t * t * (3.0 - 2.0 * t) + if alpha <= 0.0: + continue + + hr = img[sy, sx, 0] + mr + hg = img[sy, sx, 1] + mg + hb = img[sy, sx, 2] + mb + buf[y, x, 0] = buf[y, x, 0] * (1.0 - alpha) + hr * alpha + buf[y, x, 1] = buf[y, x, 1] * (1.0 - alpha) + hg * alpha + buf[y, x, 2] = buf[y, x, 2] * (1.0 - alpha) + hb * alpha + + +def _capsule_boundary(pts_px: np.ndarray, radius: float, n: int) -> np.ndarray: + """Ordered closed loop of ``n`` samples on the capsule outline around a polyline. + + Left side → end cap → right side (reversed) → start cap, so the loop is a + simple polygon suitable for mean-value coordinates. + """ + m = pts_px.shape[0] + if m == 1: + ang = np.linspace(0.0, 2.0 * np.pi, n, endpoint=False) + return np.stack([pts_px[0, 0] + radius * np.cos(ang), pts_px[0, 1] + radius * np.sin(ang)], axis=1).astype(np.float32) + + seg = np.diff(pts_px, axis=0) + seg_len = np.hypot(seg[:, 0], seg[:, 1]) + total = float(seg_len.sum()) + n_cap = max(3, int(round(n * (np.pi * radius) / (2.0 * total + 2.0 * np.pi * radius)))) + n_side = max(2, (n - 2 * n_cap) // 2) + + # Resample chain at n_side points; normals from central-difference tangents. + t_targets = np.linspace(0.0, total, n_side) + cum = np.concatenate([[0.0], np.cumsum(seg_len)]) + samples = np.empty((n_side, 2), dtype=np.float64) + normals = np.empty((n_side, 2), dtype=np.float64) + for i, t in enumerate(t_targets): + k = int(np.searchsorted(cum, t, side="right") - 1) + k = min(max(k, 0), m - 2) + f = 0.0 if seg_len[k] < 1e-9 else (t - cum[k]) / seg_len[k] + samples[i] = pts_px[k] + f * seg[k] + tx, ty = seg[k] + ln = math.hypot(tx, ty) + if ln < 1e-9: + tx, ty = 1.0, 0.0 + else: + tx, ty = tx / ln, ty / ln + normals[i] = (-ty, tx) + + left = samples + radius * normals + right = samples - radius * normals + + def _cap(center: np.ndarray, from_pt: np.ndarray) -> np.ndarray: + # Half-circle from the loop's current end, swept clockwise — that side + # bulges outward past the chain end (the CCW side crosses the chain). + a0 = math.atan2(from_pt[1] - center[1], from_pt[0] - center[0]) + ang = np.linspace(a0, a0 - np.pi, n_cap + 2)[1:-1] + return np.stack([center[0] + radius * np.cos(ang), center[1] + radius * np.sin(ang)], axis=1) + + end_cap = _cap(samples[-1], left[-1]) + start_cap = _cap(samples[0], right[0]) + loop = np.concatenate([left, end_cap, right[::-1], start_cap], axis=0) + return loop.astype(np.float32) + + +def fallback_source_offset(index: int, size_px: float, orig_shape: Tuple[int, int]) -> Tuple[float, float]: + ang = _GOLDEN_ANGLE * float(index) + dist = _FALLBACK_OFFSET_FACTOR * max(1.0, size_px) + h, w = orig_shape + return (math.cos(ang) * dist / max(1, w), math.sin(ang) * dist / max(1, h)) + + +def build_heal_regions( + strokes: List[Tuple], + legacy_spots: List[Tuple[float, float, float]], + orig_shape: Tuple[int, int], + rotation: int, + fine_rotation: float, + flip_h: bool, + flip_v: bool, + distortion_k1: float, + scale_factor: float, + full_dims: Tuple[int, int], + max_regions: int = 512, + max_points: int = 16384, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Maps manual heals into the geometry frame as capsule regions. + + Returns ``(reg_i, reg_f, pts)`` in the layout `_membrane_heal_jit` consumes; + ``pts`` are continuous pixel coords in the post-geometry frame at ``full_dims``. + Shared by the CPU processor and the GPU storage upload so both paths heal + from identical geometry. + """ + fw, fh = float(full_dims[0]), float(full_dims[1]) + + def _map(nx: float, ny: float) -> Tuple[float, float]: + mx, my = map_coords_to_geometry(nx, ny, orig_shape, rotation, fine_rotation, flip_h, flip_v, distortion_k1=distortion_k1) + return mx * fw, my * fh + + entries: List[Tuple[List, float, float, float]] = [] + for points, size, sdx, sdy in strokes: + entries.append((list(points), float(size), float(sdx), float(sdy))) + for i, (nx, ny, size) in enumerate(legacy_spots): + fdx, fdy = fallback_source_offset(i, float(size), orig_shape) + entries.append(([[nx, ny]], float(size), fdx, fdy)) + + reg_i_list = [] + reg_f_list = [] + pts_list: List[np.ndarray] = [] + n_pts = 0 + + for points, size, sdx, sdy in entries[:max_regions]: + chain = np.array([_map(p[0], p[1]) for p in points], dtype=np.float32) + radius = max(1.0, float(size) * float(scale_factor)) + + cx = float(np.mean([p[0] for p in points])) + cy = float(np.mean([p[1] for p in points])) + c_px = _map(cx, cy) + s_px = _map(cx + sdx, cy + sdy) + off_x, off_y = s_px[0] - c_px[0], s_px[1] - c_px[1] + + seg = np.diff(chain, axis=0) + perimeter = 2.0 * float(np.hypot(seg[:, 0], seg[:, 1]).sum()) + 2.0 * np.pi * radius + n_bnd = int(min(64, max(16, perimeter / 4.0))) + boundary = _capsule_boundary(chain.astype(np.float64), radius, n_bnd) + + if n_pts + len(chain) + len(boundary) > max_points: + break + reg_i_list.append((n_pts, len(chain), n_pts + len(chain), len(boundary))) + reg_f_list.append((radius, off_x, off_y)) + pts_list.append(chain) + pts_list.append(boundary) + n_pts += len(chain) + len(boundary) + + if not reg_i_list: + return ( + np.zeros((0, 4), dtype=np.int32), + np.zeros((0, 3), dtype=np.float32), + np.zeros((0, 2), dtype=np.float32), + ) + return ( + np.array(reg_i_list, dtype=np.int32), + np.array(reg_f_list, dtype=np.float32), + np.concatenate(pts_list, axis=0).astype(np.float32), + ) -def _inpaint_with_mask(img: ImageBuffer, mask_u8: np.ndarray, inpaint_rad: int) -> ImageBuffer: - """Telea inpaint + grain restoration. Shared by manual_spots and IR paths.""" - rad = max(1, inpaint_rad) | 1 - img_u8 = np.clip(np.nan_to_num(img * 255), 0, 255).astype(np.uint8) - img_inpainted_u8 = ensure_image(cv2.inpaint(img_u8, mask_u8, rad, cv2.INPAINT_TELEA)) +def select_source_offset( + preview_img: np.ndarray, + pts_norm: List[Tuple[float, float]], + radius_px: float, + index: int, +) -> Tuple[float, float]: + """Lightroom-style automatic clone-source pick, scored on the source-frame preview. - noise_arr = np.random.normal(0, 3.5, img_inpainted_u8.shape).astype(np.float32) - mask_base = mask_u8.astype(np.float32) / 255.0 - mask_blur = cv2.GaussianBlur(mask_base, (rad, rad), 0) - if mask_blur.ndim == 2: - mask_final = mask_blur[:, :, None].astype(np.float32) + Candidates sit perpendicular to the stroke (ring for spots) at 2.6r/3.6r; + each is scored by RGB SSD between a clean rim band around the defect and + the same band shifted by the candidate. Returns a source-normalized offset. + """ + h, w = preview_img.shape[:2] + orig_shape = (h, w) + pts_px = np.array([[p[0] * w, p[1] * h] for p in pts_norm], dtype=np.float64) + r = max(1.5, float(radius_px)) + + if len(pts_px) >= 2: + d = pts_px[-1] - pts_px[0] + ln = math.hypot(d[0], d[1]) + tx, ty = (d[0] / ln, d[1] / ln) if ln > 1e-6 else (1.0, 0.0) else: - mask_final = mask_blur.astype(np.float32) - - return ensure_image( - _apply_inpainting_grain_jit( - np.ascontiguousarray(img.astype(np.float32)), - np.ascontiguousarray(img_inpainted_u8.astype(np.float32)), - np.ascontiguousarray(mask_final.astype(np.float32)), - np.ascontiguousarray(noise_arr.astype(np.float32)), - ) + tx, ty = 1.0, 0.0 + nx_, ny_ = -ty, tx + + candidates = [] + for dist in (_FALLBACK_OFFSET_FACTOR * r, (_FALLBACK_OFFSET_FACTOR + 1.0) * r): + candidates.append((nx_ * dist, ny_ * dist)) + candidates.append((-nx_ * dist, -ny_ * dist)) + if len(pts_px) == 1: + for k in range(4): + ang = np.pi / 4.0 + k * np.pi / 2.0 + dist = _FALLBACK_OFFSET_FACTOR * r + candidates.append((math.cos(ang) * dist, math.sin(ang) * dist)) + else: + # Along-stroke candidates must clear the whole stroke length. + seg = np.diff(pts_px, axis=0) + length = float(np.hypot(seg[:, 0], seg[:, 1]).sum()) + for sgn in (1.0, -1.0): + candidates.append((sgn * tx * (length + _FALLBACK_OFFSET_FACTOR * r), sgn * ty * (length + _FALLBACK_OFFSET_FACTOR * r))) + + # Clean rim band just outside the defect. + boundary = _capsule_boundary(pts_px, 1.6 * r, 32) + # Chain samples (vertices + midpoints) for the shifted-defect overlap test. + chain_samples = [tuple(p) for p in pts_px] + for a, b in zip(pts_px[:-1], pts_px[1:]): + chain_samples.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)) + + best = None + best_score = np.inf + for cdx, cdy in candidates: + # The shifted defect must clear the original defect entirely. + if any(_dist_to_chain(cx + cdx, cy + cdy, pts_px) < 2.2 * r for cx, cy in chain_samples): + continue + score = 0.0 + valid = True + for bx, by in boundary: + sx, sy = bx + cdx, by + cdy + if not (0 <= sx < w - 1 and 0 <= sy < h - 1): + valid = False + break + diff = preview_img[int(sy), int(sx)] - preview_img[int(by), int(bx)] + score += float(np.dot(diff, diff)) + if valid and score < best_score: + best_score = score + best = (cdx, cdy) + + if best is None: + return fallback_source_offset(index, r, orig_shape) + return (best[0] / w, best[1] / h) + + +def apply_manual_heals( + img: ImageBuffer, + reg_i: np.ndarray, + reg_f: np.ndarray, + pts: np.ndarray, +) -> ImageBuffer: + """Membrane-clones all manual heal regions. Perceptual op — brackets the linear buffer.""" + if len(reg_i) == 0: + return img + buf = np.ascontiguousarray(working_oetf_encode(img).astype(np.float32)) + _membrane_heal_jit( + buf, + np.ascontiguousarray(reg_i), + np.ascontiguousarray(reg_f), + np.ascontiguousarray(pts), ) + return ensure_image(working_oetf_decode(buf)) def apply_ir_dust_removal( @@ -201,7 +601,7 @@ def apply_ir_dust_removal( inpaint_radius: int, scale_factor: float, ) -> Tuple[ImageBuffer, np.ndarray]: - """Threshold IR → perimeter-sample inpaint with cubic-smoothstep feather. + """Threshold IR → guarded reflection-copy heal with cubic-smoothstep feather. Returns (img_out, mask_u8). IR convention: dye = high IR transmittance, physical defects = low transmittance, so `ir < threshold` marks defects. @@ -236,16 +636,17 @@ def apply_dust_removal( dust_remove: bool, dust_threshold: float, dust_size: int, - manual_spots: List[Tuple[float, float, float]], + heal_regions: Optional[Tuple[np.ndarray, np.ndarray, np.ndarray]], scale_factor: float, ir_buffer: Optional[np.ndarray] = None, ir_dust_remove: bool = False, ir_threshold: float = 0.55, ir_inpaint_radius: int = 3, ) -> ImageBuffer: - """Composite dust removal: luminance-auto → IR → manual spots.""" + """Composite dust removal: luminance-auto → IR → manual heals.""" do_ir = ir_dust_remove and ir_buffer is not None - if not (dust_remove or manual_spots or do_ir): + has_manual = heal_regions is not None and len(heal_regions[0]) > 0 + if not (dust_remove or has_manual or do_ir): return img if dust_remove: @@ -281,17 +682,7 @@ def apply_dust_removal( scale_factor, ) - if manual_spots: - h_img, w_img = img.shape[:2] - manual_mask_u8 = np.zeros((h_img, w_img), dtype=np.uint8) - for spot in manual_spots: - nx, ny, s_size = spot - radius = int(max(1, s_size * scale_factor)) - cv2.circle(manual_mask_u8, (int(nx * w_img), int(ny * h_img)), radius, 255, -1) - - inpaint_rad = int(3 * scale_factor) - # Telea inpaint + grain restoration are display-domain; bracket the linear buffer. - enc = _inpaint_with_mask(ensure_image(working_oetf_encode(img)), manual_mask_u8, inpaint_rad) - img = ensure_image(working_oetf_decode(enc)) + if has_manual and heal_regions is not None: + img = apply_manual_heals(img, *heal_regions) return ensure_image(img) diff --git a/negpy/features/retouch/models.py b/negpy/features/retouch/models.py index b482143d..0d6afab4 100644 --- a/negpy/features/retouch/models.py +++ b/negpy/features/retouch/models.py @@ -8,6 +8,10 @@ class RetouchConfig: dust_threshold: float = 0.66 dust_size: int = 4 manual_dust_spots: List[Tuple[float, float, float]] = field(default_factory=list) + # Each stroke: (points, size, src_dx, src_dy); points = [[nx, ny], ...] source-normalized, + # size in source px, (src_dx, src_dy) = source-normalized offset to the clone source. + # A single-point stroke is a spot. manual_dust_spots is the legacy pre-stroke format. + manual_heal_strokes: List[Tuple] = field(default_factory=list) manual_dust_size: int = 6 ir_dust_remove: bool = False ir_threshold: float = 0.5 diff --git a/negpy/features/retouch/processor.py b/negpy/features/retouch/processor.py index d9aa079f..8e7a8f92 100644 --- a/negpy/features/retouch/processor.py +++ b/negpy/features/retouch/processor.py @@ -1,8 +1,7 @@ from negpy.domain.interfaces import PipelineContext from negpy.domain.types import ImageBuffer from negpy.features.retouch.models import RetouchConfig -from negpy.features.retouch.logic import apply_dust_removal -from negpy.features.geometry.logic import map_coords_to_geometry +from negpy.features.retouch.logic import apply_dust_removal, build_heal_regions class RetouchProcessor: @@ -28,26 +27,22 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: "flip_vertical": False, }, ) - rotation = rot_params.get("rotation", 0) - fine_rotation = rot_params.get("fine_rotation", 0.0) - flip_h = rot_params.get("flip_horizontal", False) - flip_v = rot_params.get("flip_vertical", False) distortion_k1 = context.metrics.get("distortion_k1", 0.0) - mapped_spots = [] - if self.config.manual_dust_spots: - for nx, ny, size in self.config.manual_dust_spots: - mnx, mny = map_coords_to_geometry( - nx, - ny, - (orig_h, orig_w), - rotation, - fine_rotation, - flip_h, - flip_v, - distortion_k1=distortion_k1, - ) - mapped_spots.append((mnx, mny, size)) + heal_regions = None + if self.config.manual_heal_strokes or self.config.manual_dust_spots: + heal_regions = build_heal_regions( + self.config.manual_heal_strokes, + self.config.manual_dust_spots, + (orig_h, orig_w), + rot_params.get("rotation", 0), + rot_params.get("fine_rotation", 0.0), + rot_params.get("flip_horizontal", False), + rot_params.get("flip_vertical", False), + distortion_k1, + scale_factor, + (img.shape[1], img.shape[0]), + ) ir_post_geometry = context.metrics.get("ir_post_geometry") @@ -56,7 +51,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: self.config.dust_remove, self.config.dust_threshold, self.config.dust_size, - mapped_spots, + heal_regions, scale_factor, ir_buffer=ir_post_geometry, ir_dust_remove=self.config.ir_dust_remove, diff --git a/negpy/features/retouch/shaders/retouch.wgsl b/negpy/features/retouch/shaders/retouch.wgsl index 0616fa71..0e9ce732 100644 --- a/negpy/features/retouch/shaders/retouch.wgsl +++ b/negpy/features/retouch/shaders/retouch.wgsl @@ -1,7 +1,7 @@ struct RetouchUniforms { dust_threshold: f32, dust_size: f32, - num_manual_spots: u32, + num_regions: u32, enabled_auto: u32, global_offset: vec2, full_dims: vec2, @@ -11,17 +11,25 @@ struct RetouchUniforms { ir_inpaint_radius: f32, }; -struct ManualSpot { - pos: vec2, +// Capsule-chain heal region: polyline points [pt_start, pt_start+pt_count) and +// ordered boundary-loop samples [bnd_start, bnd_start+bnd_count) index into +// heal_pts (global pixel coords). src_off is the clone-source offset in pixels. +struct HealRegion { + pt_start: u32, + pt_count: u32, + bnd_start: u32, + bnd_count: u32, radius: f32, - pad: f32, + pad0: f32, + src_off: vec2, }; @group(0) @binding(0) var input_tex: texture_2d; @group(0) @binding(1) var output_tex: texture_storage_2d; @group(0) @binding(2) var params: RetouchUniforms; -@group(0) @binding(3) var manual_spots: array; +@group(0) @binding(3) var heal_regions: array; @group(0) @binding(4) var ir_tex: texture_2d; +@group(0) @binding(5) var heal_pts: array>; fn hash(p: vec2) -> f32 { var p3 = fract(vec3(p.xyx) * 0.1031); @@ -29,80 +37,17 @@ fn hash(p: vec2) -> f32 { return fract((p3.x + p3.y) * p3.z); } -fn get_noise(p: vec2) -> f32 { - return hash(p) * 2.0 - 1.0; +fn load_global_px(gp: vec2, idims: vec2) -> vec3 { + let gi = vec2(floor(gp)) - params.global_offset; + return textureLoad(input_tex, clamp(gi, vec2(0), idims - 1), 0).rgb; } -fn median3x3(coords: vec2, dims: vec2) -> vec3 { - var r = array(); var g = array(); var b = array(); - var idx = 0; - for (var j = -1; j <= 1; j++) { - for (var i = -1; i <= 1; i++) { - let s = textureLoad(input_tex, clamp(coords + vec2(i, j), vec2(0), dims - 1), 0).rgb; - r[idx] = s.r; g[idx] = s.g; b[idx] = s.b; idx++; - } - } - for (var i = 0; i < 9; i++) { - for (var j = i + 1; j < 9; j++) { - if (r[i] > r[j]) { let t = r[i]; r[i] = r[j]; r[j] = t; } - if (g[i] > g[j]) { let t = g[i]; g[i] = g[j]; g[j] = t; } - if (b[i] > b[j]) { let t = b[i]; b[i] = b[j]; b[j] = t; } - } - } - return vec3(r[4], g[4], b[4]); -} - -fn median5x5(coords: vec2, dims: vec2) -> vec3 { - var r = array(); var g = array(); var b = array(); - var idx = 0; - for (var j = -2; j <= 2; j++) { - for (var i = -2; i <= 2; i++) { - let s = textureLoad(input_tex, clamp(coords + vec2(i, j), vec2(0), dims - 1), 0).rgb; - r[idx] = s.r; g[idx] = s.g; b[idx] = s.b; idx++; - } - } - for (var i = 0; i <= 12; i++) { - var min_idx_r = i; var min_idx_g = i; var min_idx_b = i; - for (var j = i + 1; j < 25; j++) { - if (r[j] < r[min_idx_r]) { min_idx_r = j; } - if (g[j] < g[min_idx_g]) { min_idx_g = j; } - if (b[j] < b[min_idx_b]) { min_idx_b = j; } - } - let tr = r[i]; r[i] = r[min_idx_r]; r[min_idx_r] = tr; - let tg = g[i]; g[i] = g[min_idx_g]; g[min_idx_g] = tg; - let tb = b[i]; b[i] = b[min_idx_b]; b[min_idx_b] = tb; - } - return vec3(r[12], g[12], b[12]); -} - -fn median7x7(coords: vec2, dims: vec2) -> vec3 { - var luma = array(); var colors = array, 49>(); - var idx = 0; - for (var j = -3; j <= 3; j++) { - for (var i = -3; i <= 3; i++) { - let s = textureLoad(input_tex, clamp(coords + vec2(i, j), vec2(0), dims - 1), 0).rgb; - colors[idx] = s; luma[idx] = dot(s, vec3(0.2126, 0.7152, 0.0722)); idx++; - } - } - for (var i = 0; i <= 24; i++) { - var min_idx = i; - for (var j = i + 1; j < 49; j++) { - if (luma[j] < luma[min_idx]) { min_idx = j; } - } - let tl = luma[i]; luma[i] = luma[min_idx]; luma[min_idx] = tl; - let tc = colors[i]; colors[i] = colors[min_idx]; colors[min_idx] = tc; - } - return colors[24]; -} - -fn min3x3(coords: vec2, dims: vec2) -> vec3 { - var m = vec3(1.0); - for (var j = -1; j <= 1; j++) { - for (var i = -1; i <= 1; i++) { - m = min(m, textureLoad(input_tex, clamp(coords + vec2(i, j), vec2(0), dims - 1), 0).rgb); - } - } - return m; +fn dist_to_seg(p: vec2, a: vec2, b: vec2) -> f32 { + let ab = b - a; + let ab2 = dot(ab, ab); + var t = 0.0; + if (ab2 > 1e-12) { t = clamp(dot(p - a, ab) / ab2, 0.0, 1.0); } + return distance(p, a + t * ab); } @compute @workgroup_size(8, 8) @@ -112,18 +57,18 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let coords = vec2(i32(gid.x), i32(gid.y)); let idims = vec2(dims); - let global_coords = vec2(f32(coords.x + params.global_offset.x) + 0.5, + let global_coords = vec2(f32(coords.x + params.global_offset.x) + 0.5, f32(coords.y + params.global_offset.y) + 0.5); let global_uv = global_coords / vec2(f32(params.full_dims.x), f32(params.full_dims.y)); - + let original = textureLoad(input_tex, coords, 0).rgb; var res = original; if (params.enabled_auto == 1u) { let base_s = max(1.0, params.dust_size); let scale = max(1.0, params.scale_factor); - - let v_rad = i32(max(3.0, base_s * 3.0 * scale)); + + let v_rad = i32(max(3.0, base_s * 3.0 * scale)); var luma_sum = 0.0; var luma_sq_sum = 0.0; let step_v = max(1, v_rad / 4); var samples_v = 0.0; @@ -137,7 +82,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let mean = luma_sum / samples_v; let luma_std = sqrt(max(0.0, (luma_sq_sum / samples_v) - (mean * mean))); - let w_rad = i32(max(7.0, base_s * 4.0 * scale)); + let w_rad = i32(max(7.0, base_s * 4.0 * scale)); var w_luma_sum = 0.0; var w_luma_sq_sum = 0.0; let step_w = max(1, w_rad / 6); var samples_w = 0.0; @@ -149,7 +94,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } let w_std = sqrt(max(0.0, (w_luma_sq_sum / samples_w) - (pow(w_luma_sum / samples_w, 2.0)))); - + let luma = dot(original, vec3(0.2126, 0.7152, 0.0722)); let local_s = max(0.005, luma_std); let z_score = (luma - mean) / local_s; @@ -157,16 +102,17 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let thresh = (params.dust_threshold * 0.4) + (local_s * 1.0) + wide_penalty; var min_d2 = 1000.0; + var c_x = 0.0; var c_y = 0.0; var c_n = 0.0; let exp_rad = i32(clamp(params.dust_size * 0.4 * scale, 1.0, 16.0)); let p_rad = exp_rad + i32(3.0 * scale); - + for (var yoff = -exp_rad; yoff <= exp_rad; yoff++) { for (var xoff = -exp_rad; xoff <= exp_rad; xoff++) { let nc = clamp(coords + vec2(xoff, yoff), vec2(0), idims - 1); let ns = textureLoad(input_tex, nc, 0).rgb; let nl = dot(ns, vec3(0.2126, 0.7152, 0.0722)); let n_diff = nl - mean; - + if (n_diff > thresh && nl > 0.15 && (nl - mean) / local_s > 3.0) { let is_strong = n_diff > (thresh * 2.5) || n_diff > 0.25; var is_max = true; @@ -179,9 +125,10 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } if (!is_max) { break; } } - if (is_max || is_strong) { + if (is_max || is_strong) { let d2 = f32(xoff*xoff + yoff*yoff); if (d2 < min_d2) { min_d2 = d2; } + c_x += f32(nc.x); c_y += f32(nc.y); c_n += 1.0; } } } @@ -193,38 +140,67 @@ fn main(@builtin(global_invocation_id) gid: vec3) { feather = clamp(feather, 0.0, 1.0); feather = feather * feather * (3.0 - 2.0 * feather); - // 8-point trimmed sampling - var s_r = array(); var s_g = array(); var s_b = array(); var s_l = array(); - let dxs = array(-p_rad, p_rad, 0, 0, -p_rad, -p_rad, p_rad, p_rad); - let dys = array(0, 0, -p_rad, p_rad, -p_rad, p_rad, -p_rad, p_rad); + // Guarded reflection copy: sample outward from the defect centroid so + // neighbouring pixels copy neighbouring sources (grain continuity). + var u = vec2(f32(coords.x), f32(coords.y)) - vec2(c_x, c_y) / c_n; + let ul = length(u); + if (ul < 1e-3) { + let ang = hash(vec2(f32(coords.x + params.global_offset.x), f32(coords.y + params.global_offset.y))) * 6.28318530718; + u = vec2(cos(ang), sin(ang)); + } else { + u = u / ul; + } - for (var i = 0; i < 8; i++) { - let pix = textureLoad(input_tex, clamp(coords + vec2(dxs[i], dys[i]), vec2(0), idims - 1), 0).rgb; - s_r[i] = pix.r; s_g[i] = pix.g; s_b[i] = pix.b; - s_l[i] = dot(pix, vec3(0.2126, 0.7152, 0.0722)); + // Guard rotations (0°, ±45°, ±90°) — order mirrored in the CPU engine. + let g_cos = array(1.0, 0.70710678, 0.70710678, 0.0, 0.0); + let g_sin = array(0.0, 0.70710678, -0.70710678, 1.0, -1.0); + var found = false; + var healed_val = vec3(0.0); + for (var k = 0; k < 5; k++) { + let dir = vec2(u.x * g_cos[k] - u.y * g_sin[k], + u.x * g_sin[k] + u.y * g_cos[k]); + let sp = clamp(vec2(round(vec2(coords) + dir * f32(p_rad))), vec2(0), idims - 1); + let sv = textureLoad(input_tex, sp, 0).rgb; + let sl = dot(sv, vec3(0.2126, 0.7152, 0.0722)); + // Guard: source must not be a defect itself. + if (sl - mean <= params.dust_threshold * 0.4) { + healed_val = sv; + found = true; + break; + } } - // Inline sort for outlier rejection - for (var i = 0; i < 7; i++) { - for (var j = i + 1; j < 8; j++) { - if (s_l[i] > s_l[j]) { - let tl = s_l[i]; s_l[i] = s_l[j]; s_l[j] = tl; - let tr = s_r[i]; s_r[i] = s_r[j]; s_r[j] = tr; - let tg = s_g[i]; s_g[i] = s_g[j]; s_g[j] = tg; - let tb = s_b[i]; s_b[i] = s_b[j]; s_b[j] = tb; + if (!found) { + // Fallback: 8-point trimmed sampling (previous fill — worst case unchanged). + var s_r = array(); var s_g = array(); var s_b = array(); var s_l = array(); + let dxs = array(-p_rad, p_rad, 0, 0, -p_rad, -p_rad, p_rad, p_rad); + let dys = array(0, 0, -p_rad, p_rad, -p_rad, p_rad, -p_rad, p_rad); + + for (var i = 0; i < 8; i++) { + let pix = textureLoad(input_tex, clamp(coords + vec2(dxs[i], dys[i]), vec2(0), idims - 1), 0).rgb; + s_r[i] = pix.r; s_g[i] = pix.g; s_b[i] = pix.b; + s_l[i] = dot(pix, vec3(0.2126, 0.7152, 0.0722)); + } + + for (var i = 0; i < 7; i++) { + for (var j = i + 1; j < 8; j++) { + if (s_l[i] > s_l[j]) { + let tl = s_l[i]; s_l[i] = s_l[j]; s_l[j] = tl; + let tr = s_r[i]; s_r[i] = s_r[j]; s_r[j] = tr; + let tg = s_g[i]; s_g[i] = s_g[j]; s_g[j] = tg; + let tb = s_b[i]; s_b[i] = s_b[j]; s_b[j] = tb; + } } } - } - // Average middle 50% - let healed_val = vec3( - (s_r[2] + s_r[3] + s_r[4] + s_r[5]) / 4.0, - (s_g[2] + s_g[3] + s_g[4] + s_g[5]) / 4.0, - (s_b[2] + s_b[3] + s_b[4] + s_b[5]) / 4.0 - ); + healed_val = vec3( + (s_r[2] + s_r[3] + s_r[4] + s_r[5]) / 4.0, + (s_g[2] + s_g[3] + s_g[4] + s_g[5]) / 4.0, + (s_b[2] + s_b[3] + s_b[4] + s_b[5]) / 4.0 + ); + } - let grain = get_noise(global_uv * 1000.0) * 0.003 * (4.0 * mean * (1.0 - mean)); - res = mix(original, healed_val + vec3(grain), feather); + res = mix(original, healed_val, feather); } } @@ -234,12 +210,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let ir_p_rad = ir_exp_rad + i32(max(2.0, 3.0 * ir_scale)); var ir_min_d2 = 1.0e9; + var ir_cx = 0.0; var ir_cy = 0.0; var ir_cn = 0.0; for (var yoff = -ir_exp_rad; yoff <= ir_exp_rad; yoff++) { for (var xoff = -ir_exp_rad; xoff <= ir_exp_rad; xoff++) { let nc = clamp(coords + vec2(xoff, yoff), vec2(0), idims - 1); if (textureLoad(ir_tex, nc, 0).r < params.ir_threshold) { let d2 = f32(xoff*xoff + yoff*yoff); if (d2 < ir_min_d2) { ir_min_d2 = d2; } + ir_cx += f32(nc.x); ir_cy += f32(nc.y); ir_cn += 1.0; } } } @@ -249,54 +227,121 @@ fn main(@builtin(global_invocation_id) gid: vec3) { var ir_feather = clamp(1.0 - dist / f32(ir_exp_rad + 1), 0.0, 1.0); ir_feather = ir_feather * ir_feather * (3.0 - 2.0 * ir_feather); - var ir_sr = array(); var ir_sg = array(); var ir_sb = array(); var ir_sl = array(); - let ir_dxs = array(-ir_p_rad, ir_p_rad, 0, 0, -ir_p_rad, -ir_p_rad, ir_p_rad, ir_p_rad); - let ir_dys = array(0, 0, -ir_p_rad, ir_p_rad, -ir_p_rad, ir_p_rad, -ir_p_rad, ir_p_rad); - for (var i = 0; i < 8; i++) { - let sc = clamp(coords + vec2(ir_dxs[i], ir_dys[i]), vec2(0), idims - 1); - let pix = textureLoad(input_tex, sc, 0).rgb; - ir_sr[i] = pix.r; ir_sg[i] = pix.g; ir_sb[i] = pix.b; - ir_sl[i] = dot(pix, vec3(0.2126, 0.7152, 0.0722)); + var u = vec2(f32(coords.x), f32(coords.y)) - vec2(ir_cx, ir_cy) / ir_cn; + let ul = length(u); + if (ul < 1e-3) { + let ang = hash(vec2(f32(coords.x + params.global_offset.x), f32(coords.y + params.global_offset.y))) * 6.28318530718; + u = vec2(cos(ang), sin(ang)); + } else { + u = u / ul; + } + + let g_cos = array(1.0, 0.70710678, 0.70710678, 0.0, 0.0); + let g_sin = array(0.0, 0.70710678, -0.70710678, 1.0, -1.0); + var found = false; + var ir_healed = vec3(0.0); + for (var k = 0; k < 5; k++) { + let dir = vec2(u.x * g_cos[k] - u.y * g_sin[k], + u.x * g_sin[k] + u.y * g_cos[k]); + let sp = clamp(vec2(round(vec2(coords) + dir * f32(ir_p_rad))), vec2(0), idims - 1); + if (textureLoad(ir_tex, sp, 0).r >= params.ir_threshold) { + ir_healed = textureLoad(input_tex, sp, 0).rgb; + found = true; + break; + } } - for (var i = 0; i < 7; i++) { - for (var j = i + 1; j < 8; j++) { - if (ir_sl[i] > ir_sl[j]) { - let tl = ir_sl[i]; ir_sl[i] = ir_sl[j]; ir_sl[j] = tl; - let tr = ir_sr[i]; ir_sr[i] = ir_sr[j]; ir_sr[j] = tr; - let tg = ir_sg[i]; ir_sg[i] = ir_sg[j]; ir_sg[j] = tg; - let tb = ir_sb[i]; ir_sb[i] = ir_sb[j]; ir_sb[j] = tb; + + if (!found) { + var ir_sr = array(); var ir_sg = array(); var ir_sb = array(); var ir_sl = array(); + let ir_dxs = array(-ir_p_rad, ir_p_rad, 0, 0, -ir_p_rad, -ir_p_rad, ir_p_rad, ir_p_rad); + let ir_dys = array(0, 0, -ir_p_rad, ir_p_rad, -ir_p_rad, ir_p_rad, -ir_p_rad, ir_p_rad); + for (var i = 0; i < 8; i++) { + let sc = clamp(coords + vec2(ir_dxs[i], ir_dys[i]), vec2(0), idims - 1); + let pix = textureLoad(input_tex, sc, 0).rgb; + ir_sr[i] = pix.r; ir_sg[i] = pix.g; ir_sb[i] = pix.b; + ir_sl[i] = dot(pix, vec3(0.2126, 0.7152, 0.0722)); + } + for (var i = 0; i < 7; i++) { + for (var j = i + 1; j < 8; j++) { + if (ir_sl[i] > ir_sl[j]) { + let tl = ir_sl[i]; ir_sl[i] = ir_sl[j]; ir_sl[j] = tl; + let tr = ir_sr[i]; ir_sr[i] = ir_sr[j]; ir_sr[j] = tr; + let tg = ir_sg[i]; ir_sg[i] = ir_sg[j]; ir_sg[j] = tg; + let tb = ir_sb[i]; ir_sb[i] = ir_sb[j]; ir_sb[j] = tb; + } } } + ir_healed = vec3( + (ir_sr[2] + ir_sr[3] + ir_sr[4] + ir_sr[5]) / 4.0, + (ir_sg[2] + ir_sg[3] + ir_sg[4] + ir_sg[5]) / 4.0, + (ir_sb[2] + ir_sb[3] + ir_sb[4] + ir_sb[5]) / 4.0, + ); } - let ir_healed = vec3( - (ir_sr[2] + ir_sr[3] + ir_sr[4] + ir_sr[5]) / 4.0, - (ir_sg[2] + ir_sg[3] + ir_sg[4] + ir_sg[5]) / 4.0, - (ir_sb[2] + ir_sb[3] + ir_sb[4] + ir_sb[5]) / 4.0, - ); res = mix(res, ir_healed, ir_feather); } } - for (var i = 0u; i < params.num_manual_spots; i++) { - let spot = manual_spots[i]; - let d = distance(global_uv, spot.pos); - if (d < spot.radius) { - let pi = 3.14159265; - let full_f = vec2(f32(params.full_dims.x), f32(params.full_dims.y)); - let delta = global_uv - spot.pos; - let pixel_angle = atan2(delta.y, delta.x); - let seed = global_coords + f32(i) * 7.77; - var heal = vec3(0.0); - for(var s = 0.0; s < 3.0; s += 1.0) { - let jitter = (hash(seed + s * 0.555) - 0.5) * (pi * 0.2); - let p_off = vec2(cos(pixel_angle + jitter), sin(pixel_angle + jitter)) * (spot.radius * 0.95); - let pc = vec2((spot.pos + p_off) * full_f) - params.global_offset; - heal += min3x3(pc, idims); + // Manual heals: mean-value-coordinates membrane clone (Georgiev healing + // brush). out = src_patch + MVC-interpolated boundary difference — copied + // pixels carry real grain, the membrane matches the rim seamlessly. + for (var ri = 0u; ri < params.num_regions; ri++) { + let reg = heal_regions[ri]; + if (reg.bnd_count < 3u || reg.bnd_count > 64u || reg.pt_count < 1u) { continue; } + + let p = global_coords; + var d = 1e18; + if (reg.pt_count == 1u) { + d = distance(p, heal_pts[reg.pt_start]); + } else { + for (var s = 0u; s + 1u < reg.pt_count; s++) { + d = min(d, dist_to_seg(p, heal_pts[reg.pt_start + s], heal_pts[reg.pt_start + s + 1u])); + } + } + if (d >= reg.radius) { continue; } + + let n = reg.bnd_count; + var vxs: array; var vys: array; var vls: array; + var diffs: array, 64>; + var on_sample = -1; + for (var i = 0u; i < n; i++) { + let b = heal_pts[reg.bnd_start + i]; + diffs[i] = load_global_px(b, idims) - load_global_px(b + reg.src_off, idims); + let v = b - p; + let l = length(v); + vxs[i] = v.x; vys[i] = v.y; vls[i] = l; + if (l < 1e-4) { on_sample = i32(i); } + } + + var mem = vec3(0.0); + if (on_sample >= 0) { + mem = diffs[on_sample]; + } else { + var tans: array; + for (var i = 0u; i < n; i++) { + var j = i + 1u; + if (j == n) { j = 0u; } + var cr = vxs[i] * vys[j] - vys[i] * vxs[j]; + if (abs(cr) < 1e-9) { cr = 1e-9; } + tans[i] = (vls[i] * vls[j] - (vxs[i] * vxs[j] + vys[i] * vys[j])) / cr; } - let healed_val = heal / 3.0; - let luma_mask = smoothstep(0.04, 0.12, dot(res, vec3(0.2126, 0.7152, 0.0722)) - dot(healed_val, vec3(0.2126, 0.7152, 0.0722))); - res = mix(res, healed_val, smoothstep(spot.radius, spot.radius * 0.8, d) * luma_mask); + var wsum = 0.0; + for (var i = 0u; i < n; i++) { + var prev = n - 1u; + if (i > 0u) { prev = i - 1u; } + let wi = (tans[prev] + tans[i]) / vls[i]; + wsum += wi; + mem += wi * diffs[i]; + } + if (abs(wsum) < 1e-12) { continue; } + mem /= wsum; } + + let healed = load_global_px(p + reg.src_off, idims) + mem; + // 1.5px feather at the rim hides boundary-sampling aliasing. + let t = clamp((d - (reg.radius - 1.5)) / 1.5, 0.0, 1.0); + let alpha = 1.0 - t * t * (3.0 - 2.0 * t); + res = mix(res, healed, alpha); } + textureStore(output_tex, coords, vec4(res, 1.0)); } diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 58a41e62..8d467b52 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -37,10 +37,10 @@ compute_distortion_scale, get_autocrop_coords, get_manual_rect_coords, - map_coords_to_geometry, ) from negpy.features.local.logic import compute_local_ev_map from negpy.features.process.models import ProcessMode +from negpy.features.retouch.logic import build_heal_regions from negpy.infrastructure.gpu.device import GPUDevice from negpy.infrastructure.gpu.resources import GPUBuffer, GPUTexture from negpy.infrastructure.gpu.shader_loader import ShaderLoader @@ -220,6 +220,7 @@ def __init__(self) -> None: self._last_scale_factor: float = 1.0 self._pending_ir_buffer: Optional[np.ndarray] = None self._ir_upload_key: Optional[Tuple[int, Any, int, int]] = None + self._retouch_num_regions = 0 # Bind groups reference resources, not contents, so they survive across frames; # cache and reuse (cleared in cleanup()). Saves ~28 wgpu calls per frame. @@ -321,7 +322,9 @@ def _init_resources(self) -> None: 65536, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, ) - self._buffers["retouch_s"] = GPUBuffer(8192, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) + # 512 heal regions × 32 B, and 16K polyline/boundary points × 8 B. + self._buffers["retouch_s"] = GPUBuffer(16384, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) + self._buffers["retouch_p"] = GPUBuffer(131072, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) self._buffers["metrics"] = GPUBuffer( METRICS_BUFFER_SIZE, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_SRC | wgpu.BufferUsage.COPY_DST, @@ -581,6 +584,16 @@ def _analyze_bounds() -> LogNegativeBounds: pw, ph, cw, ch, ox, oy = self._calculate_layout_dims(settings, crop_w, crop_h, render_size_ref) + # Regions before uniforms: the uniform block reads the uploaded region count. + self._update_retouch_storage( + settings.retouch, + (h, w), + settings.geometry, + global_offset, + actual_full_dims, + scale_factor, + distortion_k1=k1_eff, + ) self._upload_unified_uniforms( settings, bounds, @@ -599,15 +612,6 @@ def _analyze_bounds() -> LogNegativeBounds: neutral_axis_refs=neutral_axis_refs, unmix=unmix_m, ) - self._update_retouch_storage( - settings.retouch, - (h, w), - settings.geometry, - global_offset, - actual_full_dims, - scale_factor, - distortion_k1=k1_eff, - ) if clahe_cdf_override is not None: self._buffers["clahe_c"].upload(clahe_cdf_override) @@ -791,6 +795,7 @@ def _analyze_bounds() -> LogNegativeBounds: (2, self._get_uniform_binding("retouch_u")), (3, self._buffers["retouch_s"]), (4, tex_ir.view), + (5, self._buffers["retouch_p"]), ], w_rot, h_rot, @@ -1145,7 +1150,7 @@ def _upload_unified_uniforms( "ffIIiiIIfIff", float(ret.dust_threshold), float(ret.dust_size), - len(ret.manual_dust_spots), + self._retouch_num_regions, (1 if ret.dust_remove else 0), offset[0], offset[1], @@ -1264,24 +1269,45 @@ def _update_retouch_storage( scale_factor: float, distortion_k1: float = 0.0, ) -> None: - """Uploads manual retouch spots to GPU storage buffer.""" - spot_data = bytearray() - for x, y, size in conf.manual_dust_spots[:512]: - mx, my = map_coords_to_geometry( - x, - y, - orig_shape, - geom.rotation, - geom.fine_rotation, - geom.flip_horizontal, - geom.flip_vertical, - distortion_k1=distortion_k1, + """Uploads manual heal regions (capsule chains + boundary loops) to GPU storage.""" + self._retouch_num_regions = 0 + if not (conf.manual_heal_strokes or conf.manual_dust_spots): + return + + reg_i, reg_f, pts = build_heal_regions( + conf.manual_heal_strokes, + conf.manual_dust_spots, + orig_shape, + geom.rotation, + geom.fine_rotation, + geom.flip_horizontal, + geom.flip_vertical, + distortion_k1, + scale_factor, + full_dims, + ) + n_entries = len(conf.manual_heal_strokes) + len(conf.manual_dust_spots) + if len(reg_i) < n_entries: + logger.warning("Retouch storage full: %d of %d heals uploaded", len(reg_i), n_entries) + if len(reg_i) == 0: + return + + reg_data = bytearray() + for k in range(len(reg_i)): + reg_data += struct.pack( + "IIIIffff", + int(reg_i[k, 0]), + int(reg_i[k, 1]), + int(reg_i[k, 2]), + int(reg_i[k, 3]), + float(reg_f[k, 0]), + 0.0, + float(reg_f[k, 1]), + float(reg_f[k, 2]), ) - # Correctly scale radius using scale_factor - scaled_radius = (size * scale_factor) / max(orig_shape) - spot_data += struct.pack("ffff", mx, my, scaled_radius, 0.0) - if spot_data: - self._buffers["retouch_s"].upload(np.frombuffer(spot_data, dtype=np.uint8)) + self._buffers["retouch_s"].upload(np.frombuffer(reg_data, dtype=np.uint8)) + self._buffers["retouch_p"].upload(np.ascontiguousarray(pts, dtype=np.float32)) + self._retouch_num_regions = len(reg_i) def _calculate_layout_dims( self, settings: WorkspaceConfig, cw: int, ch: int, size_ref: Optional[float] @@ -1692,13 +1718,25 @@ def _analyze_global_bounds() -> LogNegativeBounds: paper_w, paper_h, content_w, content_h, off_x, off_y = self._calculate_layout_dims(settings, crop_w, crop_h, None) full_source_res = np.zeros((crop_h, crop_w, 3), dtype=np.float32) + # Manual heals sample up to radius + |source offset| beyond a pixel, so the + # halo must grow with them or tile-edge heals read clamped garbage. + halo = TILE_HALO + ret = settings.retouch + for _pts, size, sdx, sdy in ret.manual_heal_strokes: + off_px = float(np.hypot(sdx * w_rot, sdy * h_rot)) + halo = max(halo, int(np.ceil(size * scale_factor + off_px)) + 2) + for _x, _y, size in ret.manual_dust_spots: + # Legacy spots get a golden-angle fallback offset of 2.6·radius. + halo = max(halo, int(np.ceil(size * scale_factor * 3.6)) + 2) + halo = min(halo, 512) + for ty in range(0, crop_h, TILE_SIZE): for tx in range(0, crop_w, TILE_SIZE): tw, th = min(TILE_SIZE, crop_w - tx), min(TILE_SIZE, crop_h - ty) - ix1, iy1 = max(0, x1 + tx - TILE_HALO), max(0, y1 + ty - TILE_HALO) + ix1, iy1 = max(0, x1 + tx - halo), max(0, y1 + ty - halo) ix2, iy2 = ( - min(w_rot, x1 + tx + tw + TILE_HALO), - min(h_rot, y1 + ty + th + TILE_HALO), + min(w_rot, x1 + tx + tw + halo), + min(h_rot, y1 + ty + th + halo), ) ir_tile = np.ascontiguousarray(ir_rot[iy1:iy2, ix1:ix2]) if ir_rot is not None else None ev_tile = np.ascontiguousarray(local_ev_rot[iy1:iy2, ix1:ix2]) if local_ev_rot is not None else None diff --git a/tests/test_gpu_engine.py b/tests/test_gpu_engine.py index 0634fe33..7ec1bc9e 100644 --- a/tests/test_gpu_engine.py +++ b/tests/test_gpu_engine.py @@ -124,6 +124,35 @@ def test_gpu_tiled_export_propagates_ir_buffer(self): diff_max = float(np.abs(res_with - res_without).max()) self.assertGreater(diff_max, 0.05, "Tiled export ignored IR buffer; output identical to IR-off") + def test_gpu_tiled_manual_stroke_matches_untiled(self): + """A heal stroke crossing a tile boundary must render like the untiled path — + the dynamic tile halo has to cover the stroke radius + source offset.""" + from negpy.features.retouch.models import RetouchConfig + from dataclasses import replace + + h, w = 128, 2200 # spans the TILE_SIZE=2048 boundary + rng = np.random.default_rng(1) + img = (rng.random((h, w, 3), dtype=np.float32) * 0.05 + 0.45).astype(np.float32) + img[60:66, 1980:2120] = 0.95 # scratch across the boundary + + stroke = ([[1980.0 / w, 63.0 / h], [2120.0 / w, 63.0 / h]], 8.0, 0.0, 0.3) + base = WorkspaceConfig() + settings = replace( + base, + retouch=RetouchConfig(manual_heal_strokes=[stroke]), + # Native output size so the tiled result is comparable 1:1 with the untiled texture. + export=replace(base.export, export_resolution_mode="original"), + ) + + res_tiled, _ = self.engine._process_tiled(img, settings, scale_factor=1.0) + tex, _ = self.engine.process_to_texture(img, settings, scale_factor=1.0, apply_layout=False) + res_direct = self.engine._readback_downsampled(tex) + + self.assertEqual(res_tiled.shape, res_direct.shape) + band = np.s_[40:90, 1900:2200] + diff = float(np.abs(res_tiled[band] - res_direct[band]).max()) + self.assertLess(diff, 0.05, "Tiled heal diverges from untiled across the tile boundary") + def test_gpu_tiled_export_ir_no_crash_without_buffer(self): """ir_dust_remove enabled but ir_buffer=None must not crash the tiled path.""" from negpy.features.retouch.models import RetouchConfig diff --git a/tests/test_ir_dust.py b/tests/test_ir_dust.py index 1f9862f4..f1cf5cf7 100644 --- a/tests/test_ir_dust.py +++ b/tests/test_ir_dust.py @@ -70,7 +70,7 @@ def test_apply_dust_removal_with_ir_enabled_heals_defect(): dust_remove=False, dust_threshold=0.5, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ir_buffer=ir, ir_dust_remove=True, @@ -87,7 +87,7 @@ def test_apply_dust_removal_noop_when_all_disabled(): dust_remove=False, dust_threshold=0.5, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ir_buffer=None, ir_dust_remove=False, diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index a89f76e6..04064dee 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -487,6 +487,27 @@ def test_auto_dust(self): s = replace(_make_base_settings(), retouch=RetouchConfig(dust_remove=True, dust_threshold=0.5, dust_size=2)) self._run_and_compare(s) + # Manual heal sizes below are large because the 64px test image renders at + # scale 0.0625 — radius_px = size * scale. + + def test_manual_spot_stroke(self): + s = replace( + _make_base_settings(), + retouch=RetouchConfig(manual_heal_strokes=[([[45.5 / 64.0, 30.5 / 64.0]], 80.0, 0.25, 0.0)]), + ) + self._run_and_compare(s) + + def test_manual_polyline_stroke(self): + s = replace( + _make_base_settings(), + retouch=RetouchConfig(manual_heal_strokes=[([[0.3, 0.3], [40.5 / 64.0, 40.5 / 64.0], [0.85, 0.75]], 64.0, 0.0, 0.3)]), + ) + self._run_and_compare(s) + + def test_legacy_manual_spot(self): + s = replace(_make_base_settings(), retouch=RetouchConfig(manual_dust_spots=[(45.5 / 64.0, 30.5 / 64.0, 80.0)])) + self._run_and_compare(s) + class TestLocalParity: """CPU vs GPU parity for the dodge/burn local shader. diff --git a/tests/test_retouch_logic.py b/tests/test_retouch_logic.py index bd8a55c4..fdb7fbe3 100644 --- a/tests/test_retouch_logic.py +++ b/tests/test_retouch_logic.py @@ -1,5 +1,21 @@ +import json + import numpy as np -from negpy.features.retouch.logic import apply_dust_removal + +from negpy.domain.models import WorkspaceConfig +from negpy.features.retouch.logic import ( + _capsule_boundary, + apply_dust_removal, + apply_manual_heals, + build_heal_regions, + select_source_offset, +) +from negpy.features.retouch.models import RetouchConfig + + +def _regions_for_spot(nx, ny, size, shape, scale=1.0): + h, w = shape + return build_heal_regions([([[nx, ny]], size, 0.15, 0.0)], [], (h, w), 0, 0.0, False, False, 0.0, scale, (w, h)) def test_manual_dust_removal_effect(): @@ -8,14 +24,13 @@ def test_manual_dust_removal_effect(): img[48:53, 48:53] = 1.0 orig_mean = np.mean(img) - manual_spots = [(0.5, 0.5, 10)] res = apply_dust_removal( img.copy(), dust_remove=False, dust_threshold=0.75, dust_size=2, - manual_spots=manual_spots, + heal_regions=_regions_for_spot(0.5, 0.5, 10, (100, 100)), scale_factor=1.0, ) @@ -34,7 +49,7 @@ def test_manual_dust_removal_no_spots(): dust_remove=False, dust_threshold=0.75, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ) assert np.array_equal(img, res) @@ -50,7 +65,7 @@ def test_auto_dust_removal_low_res(): dust_remove=True, dust_threshold=0.5, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ) @@ -68,7 +83,7 @@ def test_auto_dust_removal_high_res(): dust_remove=True, dust_threshold=0.5, dust_size=4, - manual_spots=[], + heal_regions=None, scale_factor=2.0, ) @@ -88,7 +103,7 @@ def test_auto_detection_uses_perceptual_luma(): dust_remove=True, dust_threshold=0.5, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ) @@ -106,9 +121,132 @@ def test_auto_dust_removal_cloud_protection(): dust_remove=True, dust_threshold=0.5, dust_size=2, - manual_spots=[], + heal_regions=None, scale_factor=1.0, ) # Soft gradients should remain identical or very close np.testing.assert_allclose(img, res, atol=0.01) + + +def test_auto_heal_avoids_other_defects(): + """P2 guard: the reflection-copy source must skip masked pixels — a second + speck one heal-radius away must not be copied into the healed area.""" + img = np.zeros((100, 100, 3), dtype=np.float32) + img[50, 50] = 1.0 + img[50, 55] = 1.0 # decoy defect near the reflection source distance + + res = apply_dust_removal( + img.copy(), + dust_remove=True, + dust_threshold=0.5, + dust_size=2, + heal_regions=None, + scale_factor=1.0, + ) + assert res[50, 50, 0] < 0.5 + assert res[50, 55, 0] < 0.5 + + +def test_membrane_recovers_gradient(): + """The MVC membrane clone must reconstruct a linear gradient under a speck — + diffusion-style fills can't; this is the quality bar for the new heal.""" + h, w = 80, 120 + grad = np.linspace(0.2, 0.6, w, dtype=np.float32)[None, :, None].repeat(h, axis=0) + img = np.repeat(grad, 3, axis=2) + clean = img.copy() + img[36:44, 56:64] = 0.95 + + regions = _regions_for_spot(60.0 / w, 40.0 / h, 8.0, (h, w)) + out = apply_manual_heals(img, *regions) + + err = np.abs(out[36:44, 56:64] - clean[36:44, 56:64]).mean() + assert err < 0.02 + + +def test_stroke_heals_scratch(): + """A polyline stroke heals a diagonal scratch line.""" + rng = np.random.default_rng(7) + h, w = 120, 160 + grad = np.linspace(0.2, 0.6, w, dtype=np.float32)[None, :, None].repeat(h, axis=0) + img = (np.repeat(grad, 3, axis=2) + rng.normal(0, 0.01, (h, w, 3))).astype(np.float32) + clean = img.copy() + mask = np.zeros((h, w), bool) + for t in np.linspace(0, 1, 200): + x, y = int(30 + t * 90), int(30 + t * 50) + img[y : y + 2, x : x + 2] = 0.9 + mask[y : y + 2, x : x + 2] = True + + pts = [[30.0 / w, 30.0 / h], [75.0 / w, 55.0 / h], [120.0 / w, 80.0 / h]] + off = select_source_offset(img, pts, 5.0, 0) + regions = build_heal_regions([(pts, 5.0, off[0], off[1])], [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) + out = apply_manual_heals(img, *regions) + + err_before = np.abs(img[mask] - clean[mask]).mean() + err_after = np.abs(out[mask] - clean[mask]).mean() + assert err_after < err_before * 0.2 + + +def test_capsule_boundary_is_closed_ordered_loop(): + pts = np.array([[20.0, 20.0], [60.0, 40.0]], dtype=np.float64) + loop = _capsule_boundary(pts, 5.0, 32) + assert loop.shape[1] == 2 + assert len(loop) >= 16 + # Every sample sits on the capsule outline (distance ~radius from the chain). + from negpy.features.retouch.logic import _dist_to_chain + + for bx, by in loop: + assert abs(_dist_to_chain(float(bx), float(by), pts) - 5.0) < 0.5 + # Ordered loop: consecutive samples are close relative to the perimeter. + seg = np.diff(np.vstack([loop, loop[:1]]), axis=0) + step = np.hypot(seg[:, 0], seg[:, 1]) + assert step.max() < 5.0 * step.mean() + + +def test_select_source_offset_avoids_defect(): + """Scoring must reject candidates whose band lands on a second defect.""" + rng = np.random.default_rng(3) + h, w = 100, 100 + img = (np.full((h, w, 3), 0.5) + rng.normal(0, 0.005, (h, w, 3))).astype(np.float32) + img[46:54, 46:54] = 0.95 # the defect being healed + img[46:54, 20:36] = 0.05 # strong anomaly left of it + + off = select_source_offset(img, [[0.5, 0.5]], 4.0, 0) + sx, sy = 50 + off[0] * w, 50 + off[1] * h + val = img[int(np.clip(sy, 0, h - 1)), int(np.clip(sx, 0, w - 1))] + assert abs(float(val.mean()) - 0.5) < 0.1 + + +def test_legacy_spot_conversion(): + regions = build_heal_regions([], [(0.5, 0.5, 8.0)], (100, 100), 0, 0.0, False, False, 0.0, 1.0, (100, 100)) + reg_i, reg_f, pts = regions + assert len(reg_i) == 1 + assert reg_i[0, 1] == 1 # single-point chain + assert reg_i[0, 3] >= 16 # boundary loop present + assert reg_f[0, 0] == 8.0 # radius px + assert np.hypot(reg_f[0, 1], reg_f[0, 2]) > 8.0 # fallback offset clears the spot + + +def test_heal_strokes_serialization_roundtrip(): + cfg = WorkspaceConfig( + retouch=RetouchConfig( + manual_dust_spots=[(0.1, 0.2, 6.0)], + manual_heal_strokes=[([[0.3, 0.4], [0.5, 0.6]], 5.0, 0.02, -0.01)], + ) + ) + data = json.loads(json.dumps(cfg.to_dict())) + restored = WorkspaceConfig.from_flat_dict(data) + strokes = restored.retouch.manual_heal_strokes + assert len(strokes) == 1 + pts, size, dx, dy = strokes[0] + assert pts == [[0.3, 0.4], [0.5, 0.6]] + assert (size, dx, dy) == (5.0, 0.02, -0.01) + assert list(map(list, restored.retouch.manual_dust_spots))[0] == [0.1, 0.2, 6.0] + + +def test_old_config_without_strokes_loads_default(): + cfg = WorkspaceConfig(retouch=RetouchConfig(manual_dust_spots=[(0.1, 0.2, 6.0)])) + data = cfg.to_dict() + data.pop("manual_heal_strokes") + restored = WorkspaceConfig.from_flat_dict(data) + assert restored.retouch.manual_heal_strokes == [] From d9d7abbe177bfd579a6f18c29f870d60a5aa3722 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 07:11:17 +0200 Subject: [PATCH 2/6] fix(retouch): never reclone dust from the heal source patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Membrane clone sampled the source patch and boundary points raw, so a speck inside the source band got copied straight into the healed area. Two-layer fix, mirrored on CPU (numba) and GPU (WGSL): - Clone-sample guard: every cloned sample (source patch, both boundary sides) whose luma pops above its 3x3 luma-median neighbour by 0.06 is a speck — the median-luma pixel is used instead (a real pixel, grain kept). Ceiling: specks wider than ~2px fill the window and pass through. - Source scoring now probes the candidate patch interior (chain + inner ring) and heavily penalizes lumas above the candidate band's median, so dusty patches lose to clean ones upfront — rim-band SSD alone could not see interior dust. --- negpy/features/retouch/logic.py | 78 +++++++++++++++++++-- negpy/features/retouch/shaders/retouch.wgsl | 42 +++++++++-- tests/test_retouch_logic.py | 33 +++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 80603ba1..e7622338 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -13,6 +13,10 @@ # (legacy spots, or no preview buffer at click time). _GOLDEN_ANGLE = 2.39996322972865332 _FALLBACK_OFFSET_FACTOR = 2.6 +# Clone-sample dust guard: a sample whose luma exceeds its 3×3 luma-median +# neighbour by this much is treated as dust and replaced by the median pixel, +# so dust in the source patch is never recloned. Mirrored in retouch.wgsl. +_CLONE_GUARD_LUMA = 0.06 @njit(cache=True, fastmath=True) @@ -235,6 +239,42 @@ def _dist_to_chain(px: float, py: float, pts: np.ndarray) -> float: return best +@njit(cache=True, fastmath=True) +def _sample_clean_jit(img: np.ndarray, ix: int, iy: int, out: np.ndarray) -> None: + """Dust-guarded clone sample: the pixel at (ix, iy), or its 3×3 luma-median + neighbour when the pixel is a strong bright outlier (a dust speck). + + Keeps grain (a real neighbouring pixel is returned, never an average). + Ceiling: specks wider than ~2px fill the 3×3 window and pass through — + the source-scoring penalty in select_source_offset avoids those upfront. + """ + h, w, _ = img.shape + lums = np.empty(9, dtype=np.float64) + sxs = np.empty(9, dtype=np.int64) + sys_ = np.empty(9, dtype=np.int64) + n = 0 + for dy in range(-1, 2): + for dx in range(-1, 2): + sx = max(0, min(w - 1, ix + dx)) + sy = max(0, min(h - 1, iy + dy)) + lums[n] = LUMA_R * img[sy, sx, 0] + LUMA_G * img[sy, sx, 1] + LUMA_B * img[sy, sx, 2] + sxs[n] = sx + sys_[n] = sy + n += 1 + + order = np.argsort(lums) + mi = order[4] + lv = LUMA_R * img[iy, ix, 0] + LUMA_G * img[iy, ix, 1] + LUMA_B * img[iy, ix, 2] + if lv - lums[mi] > _CLONE_GUARD_LUMA: + out[0] = img[sys_[mi], sxs[mi], 0] + out[1] = img[sys_[mi], sxs[mi], 1] + out[2] = img[sys_[mi], sxs[mi], 2] + else: + out[0] = img[iy, ix, 0] + out[1] = img[iy, ix, 1] + out[2] = img[iy, ix, 2] + + @njit(cache=True, fastmath=True) def _membrane_heal_jit( buf: np.ndarray, @@ -250,7 +290,9 @@ def _membrane_heal_jit( out(p) = img(p + off) + Σ ŵ_i (img(b_i) − img(b_i + off)) — the copied source patch carries real grain; the MVC-weighted boundary-difference field - is the smooth membrane that matches the destination at the rim. + is the smooth membrane that matches the destination at the rim. All clone + samples go through the `_sample_clean_jit` dust guard so specks in the + source patch or on the boundary are never recloned. Heal values sample the immutable stage input (matching the GPU's single-pass ``input_tex`` reads); only the blend base evolves in ``buf``. """ @@ -262,6 +304,8 @@ def _membrane_heal_jit( vlen = np.empty(64, dtype=np.float64) vx = np.empty(64, dtype=np.float64) vy = np.empty(64, dtype=np.float64) + smp_a = np.empty(3, dtype=np.float32) + smp_b = np.empty(3, dtype=np.float32) for r in range(n_reg): ps, pc, bs, bc = reg_i[r, 0], reg_i[r, 1], reg_i[r, 2], reg_i[r, 3] @@ -278,8 +322,10 @@ def _membrane_heal_jit( by = max(0, min(h - 1, int(byf))) sx = max(0, min(w - 1, int(bxf + ox))) sy = max(0, min(h - 1, int(byf + oy))) + _sample_clean_jit(img, bx, by, smp_a) + _sample_clean_jit(img, sx, sy, smp_b) for c in range(3): - diffs[i, c] = img[by, bx, c] - img[sy, sx, c] + diffs[i, c] = smp_a[c] - smp_b[c] x0 = int(pts[ps, 0]) x1 = x0 @@ -362,9 +408,10 @@ def _membrane_heal_jit( if alpha <= 0.0: continue - hr = img[sy, sx, 0] + mr - hg = img[sy, sx, 1] + mg - hb = img[sy, sx, 2] + mb + _sample_clean_jit(img, sx, sy, smp_a) + hr = smp_a[0] + mr + hg = smp_a[1] + mg + hb = smp_a[2] + mb buf[y, x, 0] = buf[y, x, 0] * (1.0 - alpha) + hr * alpha buf[y, x, 1] = buf[y, x, 1] * (1.0 - alpha) + hg * alpha buf[y, x, 2] = buf[y, x, 2] * (1.0 - alpha) + hb * alpha @@ -550,6 +597,9 @@ def select_source_offset( chain_samples = [tuple(p) for p in pts_px] for a, b in zip(pts_px[:-1], pts_px[1:]): chain_samples.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)) + # Interior probes of the candidate patch (dust check inside, not just the rim). + interior = chain_samples + [tuple(p) for p in _capsule_boundary(pts_px, 0.6 * r, 16)] + luma_w = np.array([LUMA_R, LUMA_G, LUMA_B], dtype=np.float64) best = None best_score = np.inf @@ -559,13 +609,29 @@ def select_source_offset( continue score = 0.0 valid = True + band_lums = [] for bx, by in boundary: sx, sy = bx + cdx, by + cdy if not (0 <= sx < w - 1 and 0 <= sy < h - 1): valid = False break - diff = preview_img[int(sy), int(sx)] - preview_img[int(by), int(bx)] + src_px = preview_img[int(sy), int(sx)] + diff = src_px - preview_img[int(by), int(bx)] score += float(np.dot(diff, diff)) + band_lums.append(float(np.dot(src_px[:3], luma_w))) + if not valid: + continue + # Heavy penalty for dust inside the candidate patch: interior lumas that + # pop above the candidate band's median mean the patch contains a speck. + med = float(np.median(band_lums)) + for cx_, cy_ in interior: + sx, sy = cx_ + cdx, cy_ + cdy + if not (0 <= sx < w - 1 and 0 <= sy < h - 1): + valid = False + break + excess = float(np.dot(preview_img[int(sy), int(sx)][:3], luma_w)) - med - _CLONE_GUARD_LUMA + if excess > 0.0: + score += excess * excess * 100.0 * len(boundary) if valid and score < best_score: best_score = score best = (cdx, cdy) diff --git a/negpy/features/retouch/shaders/retouch.wgsl b/negpy/features/retouch/shaders/retouch.wgsl index 0e9ce732..2af8f9f0 100644 --- a/negpy/features/retouch/shaders/retouch.wgsl +++ b/negpy/features/retouch/shaders/retouch.wgsl @@ -37,9 +37,41 @@ fn hash(p: vec2) -> f32 { return fract((p3.x + p3.y) * p3.z); } -fn load_global_px(gp: vec2, idims: vec2) -> vec3 { - let gi = vec2(floor(gp)) - params.global_offset; - return textureLoad(input_tex, clamp(gi, vec2(0), idims - 1), 0).rgb; +// Clone-sample dust guard (mirrors _sample_clean_jit): if the pixel's luma +// exceeds its 3x3 luma-median neighbour by CLONE_GUARD_LUMA it's a speck — +// return the median-luma pixel (a real pixel, grain preserved) instead, so +// dust in the source patch or on the boundary is never recloned. +// Ceiling: specks wider than ~2px fill the window and pass through; the +// source-offset scoring on the CPU avoids those upfront. +const CLONE_GUARD_LUMA: f32 = 0.06; + +fn sample_clean(gp: vec2, idims: vec2) -> vec3 { + let gi = clamp(vec2(floor(gp)) - params.global_offset, vec2(0), idims - 1); + var lums: array; + var cols: array, 9>; + var n = 0; + for (var dy = -1; dy <= 1; dy++) { + for (var dx = -1; dx <= 1; dx++) { + let sc = clamp(gi + vec2(dx, dy), vec2(0), idims - 1); + let v = textureLoad(input_tex, sc, 0).rgb; + cols[n] = v; + lums[n] = dot(v, vec3(0.2126, 0.7152, 0.0722)); + n++; + } + } + for (var i = 0; i <= 4; i++) { + var mi = i; + for (var j = i + 1; j < 9; j++) { + if (lums[j] < lums[mi]) { mi = j; } + } + let tl = lums[i]; lums[i] = lums[mi]; lums[mi] = tl; + let tc = cols[i]; cols[i] = cols[mi]; cols[mi] = tc; + } + let v = textureLoad(input_tex, gi, 0).rgb; + if (dot(v, vec3(0.2126, 0.7152, 0.0722)) - lums[4] > CLONE_GUARD_LUMA) { + return cols[4]; + } + return v; } fn dist_to_seg(p: vec2, a: vec2, b: vec2) -> f32 { @@ -305,7 +337,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { var on_sample = -1; for (var i = 0u; i < n; i++) { let b = heal_pts[reg.bnd_start + i]; - diffs[i] = load_global_px(b, idims) - load_global_px(b + reg.src_off, idims); + diffs[i] = sample_clean(b, idims) - sample_clean(b + reg.src_off, idims); let v = b - p; let l = length(v); vxs[i] = v.x; vys[i] = v.y; vls[i] = l; @@ -336,7 +368,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { mem /= wsum; } - let healed = load_global_px(p + reg.src_off, idims) + mem; + let healed = sample_clean(p + reg.src_off, idims) + mem; // 1.5px feather at the rim hides boundary-sampling aliasing. let t = clamp((d - (reg.radius - 1.5)) / 1.5, 0.0, 1.0); let alpha = 1.0 - t * t * (3.0 - 2.0 * t); diff --git a/tests/test_retouch_logic.py b/tests/test_retouch_logic.py index fdb7fbe3..ff8f938b 100644 --- a/tests/test_retouch_logic.py +++ b/tests/test_retouch_logic.py @@ -187,6 +187,39 @@ def test_stroke_heals_scratch(): assert err_after < err_before * 0.2 +def test_clone_source_dust_not_recloned(): + """Dust sitting in the clone-source patch must not be copied into the heal — + the sample guard replaces bright outliers with their 3×3 luma-median pixel.""" + rng = np.random.default_rng(11) + h, w = 100, 100 + img = (np.full((h, w, 3), 0.5) + rng.normal(0, 0.01, (h, w, 3))).astype(np.float32) + img[47:53, 47:53] = 0.95 # defect being healed + img[49:51, 69:71] = 0.95 # dust inside the source patch (offset +20px) + + strokes = [([[0.5, 0.5]], 8.0, 20.0 / w, 0.0)] + regions = build_heal_regions(strokes, [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) + out = apply_manual_heals(img, *regions) + + healed = out[44:56, 44:56] + assert healed.max() < 0.7, "dust from the source patch was recloned into the heal" + + +def test_source_scoring_penalizes_dusty_patch(): + """select_source_offset must prefer a clean patch over one with a speck inside + (rim-band SSD alone can't see interior dust).""" + rng = np.random.default_rng(5) + h, w = 120, 120 + img = (np.full((h, w, 3), 0.5) + rng.normal(0, 0.005, (h, w, 3))).astype(np.float32) + img[56:64, 56:64] = 0.95 # defect at center + # Dust inside the +x candidate patch interior (ring candidate at 2.6r ≈ 10px) + img[59:61, 69:71] = 0.95 + + off = select_source_offset(img, [[0.5, 0.5]], 4.0, 0) + sx, sy = 60 + off[0] * w, 60 + off[1] * h + patch = img[int(sy) - 4 : int(sy) + 4, int(sx) - 4 : int(sx) + 4] + assert patch.max() < 0.7, "scoring picked a source patch containing dust" + + def test_capsule_boundary_is_closed_ordered_loop(): pts = np.array([[20.0, 20.0], [60.0, 40.0]], dtype=np.float64) loop = _capsule_boundary(pts, 5.0, 32) From 6bfa503f28176e0a7203c2e079215affbeb820d4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 07:22:51 +0200 Subject: [PATCH 3/6] fix(retouch): heal only bright dust within the brush, not the whole capsule The manual heal replaced the entire brushed capsule with shifted source texture, which read as cloning a bigger area than selected and could still drag a large speck along. The brush is now a search area: - Destination dust gate (CPU + WGSL): a brushed pixel is healed only when its luma exceeds the membrane-predicted clean value (smoothstep 0.04-0.12, encoded domain). Clean pixels inside the brush stay untouched. - The directly-cloned source sample upgrades from a 3x3 to a 5x5 luma-median guard, catching specks up to ~4px that slipped through before; boundary samples keep the cheaper 3x3. --- negpy/features/retouch/logic.py | 56 ++++++++++++++++++++- negpy/features/retouch/shaders/retouch.wgsl | 39 +++++++++++++- tests/test_retouch_logic.py | 25 +++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index e7622338..01028ad6 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -17,6 +17,11 @@ # neighbour by this much is treated as dust and replaced by the median pixel, # so dust in the source patch is never recloned. Mirrored in retouch.wgsl. _CLONE_GUARD_LUMA = 0.06 +# Destination dust gate: a brushed pixel is healed only when its luma exceeds +# the membrane-predicted clean value by this ramp (encoded domain) — the brush +# marks a search area, only the bright dust inside it gets replaced. +_HEAL_GATE_LO = 0.04 +_HEAL_GATE_HI = 0.12 @njit(cache=True, fastmath=True) @@ -275,6 +280,37 @@ def _sample_clean_jit(img: np.ndarray, ix: int, iy: int, out: np.ndarray) -> Non out[2] = img[iy, ix, 2] +@njit(cache=True, fastmath=True) +def _sample_clean5_jit(img: np.ndarray, ix: int, iy: int, out: np.ndarray) -> None: + """5×5 variant of `_sample_clean_jit` for the directly-cloned source sample — + catches specks up to ~4px that slip through the 3×3 window.""" + h, w, _ = img.shape + lums = np.empty(25, dtype=np.float64) + sxs = np.empty(25, dtype=np.int64) + sys_ = np.empty(25, dtype=np.int64) + n = 0 + for dy in range(-2, 3): + for dx in range(-2, 3): + sx = max(0, min(w - 1, ix + dx)) + sy = max(0, min(h - 1, iy + dy)) + lums[n] = LUMA_R * img[sy, sx, 0] + LUMA_G * img[sy, sx, 1] + LUMA_B * img[sy, sx, 2] + sxs[n] = sx + sys_[n] = sy + n += 1 + + order = np.argsort(lums) + mi = order[12] + lv = LUMA_R * img[iy, ix, 0] + LUMA_G * img[iy, ix, 1] + LUMA_B * img[iy, ix, 2] + if lv - lums[mi] > _CLONE_GUARD_LUMA: + out[0] = img[sys_[mi], sxs[mi], 0] + out[1] = img[sys_[mi], sxs[mi], 1] + out[2] = img[sys_[mi], sxs[mi], 2] + else: + out[0] = img[iy, ix, 0] + out[1] = img[iy, ix, 1] + out[2] = img[iy, ix, 2] + + @njit(cache=True, fastmath=True) def _membrane_heal_jit( buf: np.ndarray, @@ -292,7 +328,9 @@ def _membrane_heal_jit( source patch carries real grain; the MVC-weighted boundary-difference field is the smooth membrane that matches the destination at the rim. All clone samples go through the `_sample_clean_jit` dust guard so specks in the - source patch or on the boundary are never recloned. + source patch or on the boundary are never recloned, and a destination + dust gate limits the heal to pixels brighter than the membrane-predicted + clean value — the brush marks a search area, clean pixels stay untouched. Heal values sample the immutable stage input (matching the GPU's single-pass ``input_tex`` reads); only the blend base evolves in ``buf``. """ @@ -408,10 +446,24 @@ def _membrane_heal_jit( if alpha <= 0.0: continue - _sample_clean_jit(img, sx, sy, smp_a) + _sample_clean5_jit(img, sx, sy, smp_a) hr = smp_a[0] + mr hg = smp_a[1] + mg hb = smp_a[2] + mb + + # Dust gate: heal only pixels brighter than the membrane-predicted + # clean value — the brush is a search area, not a clone stamp. + dest_l = LUMA_R * buf[y, x, 0] + LUMA_G * buf[y, x, 1] + LUMA_B * buf[y, x, 2] + heal_l = LUMA_R * hr + LUMA_G * hg + LUMA_B * hb + g = (dest_l - heal_l - _HEAL_GATE_LO) / (_HEAL_GATE_HI - _HEAL_GATE_LO) + if g < 0.0: + g = 0.0 + elif g > 1.0: + g = 1.0 + alpha *= g * g * (3.0 - 2.0 * g) + if alpha <= 0.0: + continue + buf[y, x, 0] = buf[y, x, 0] * (1.0 - alpha) + hr * alpha buf[y, x, 1] = buf[y, x, 1] * (1.0 - alpha) + hg * alpha buf[y, x, 2] = buf[y, x, 2] * (1.0 - alpha) + hb * alpha diff --git a/negpy/features/retouch/shaders/retouch.wgsl b/negpy/features/retouch/shaders/retouch.wgsl index 2af8f9f0..3a0e0b9a 100644 --- a/negpy/features/retouch/shaders/retouch.wgsl +++ b/negpy/features/retouch/shaders/retouch.wgsl @@ -74,6 +74,37 @@ fn sample_clean(gp: vec2, idims: vec2) -> vec3 { return v; } +// 5x5 variant for the directly-cloned source sample — catches specks up to +// ~4px that slip through the 3x3 window. Mirrors _sample_clean5_jit. +fn sample_clean5(gp: vec2, idims: vec2) -> vec3 { + let gi = clamp(vec2(floor(gp)) - params.global_offset, vec2(0), idims - 1); + var lums: array; + var cols: array, 25>; + var n = 0; + for (var dy = -2; dy <= 2; dy++) { + for (var dx = -2; dx <= 2; dx++) { + let sc = clamp(gi + vec2(dx, dy), vec2(0), idims - 1); + let v = textureLoad(input_tex, sc, 0).rgb; + cols[n] = v; + lums[n] = dot(v, vec3(0.2126, 0.7152, 0.0722)); + n++; + } + } + for (var i = 0; i <= 12; i++) { + var mi = i; + for (var j = i + 1; j < 25; j++) { + if (lums[j] < lums[mi]) { mi = j; } + } + let tl = lums[i]; lums[i] = lums[mi]; lums[mi] = tl; + let tc = cols[i]; cols[i] = cols[mi]; cols[mi] = tc; + } + let v = textureLoad(input_tex, gi, 0).rgb; + if (dot(v, vec3(0.2126, 0.7152, 0.0722)) - lums[12] > CLONE_GUARD_LUMA) { + return cols[12]; + } + return v; +} + fn dist_to_seg(p: vec2, a: vec2, b: vec2) -> f32 { let ab = b - a; let ab2 = dot(ab, ab); @@ -368,10 +399,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { mem /= wsum; } - let healed = sample_clean(p + reg.src_off, idims) + mem; + let healed = sample_clean5(p + reg.src_off, idims) + mem; // 1.5px feather at the rim hides boundary-sampling aliasing. let t = clamp((d - (reg.radius - 1.5)) / 1.5, 0.0, 1.0); - let alpha = 1.0 - t * t * (3.0 - 2.0 * t); + var alpha = 1.0 - t * t * (3.0 - 2.0 * t); + // Dust gate: heal only pixels brighter than the membrane-predicted + // clean value — the brush is a search area, not a clone stamp. + let gate = smoothstep(0.04, 0.12, dot(res, vec3(0.2126, 0.7152, 0.0722)) - dot(healed, vec3(0.2126, 0.7152, 0.0722))); + alpha *= gate; res = mix(res, healed, alpha); } diff --git a/tests/test_retouch_logic.py b/tests/test_retouch_logic.py index ff8f938b..6580cbdf 100644 --- a/tests/test_retouch_logic.py +++ b/tests/test_retouch_logic.py @@ -204,6 +204,31 @@ def test_clone_source_dust_not_recloned(): assert healed.max() < 0.7, "dust from the source patch was recloned into the heal" +def test_heal_gate_leaves_clean_pixels_untouched(): + """The brush marks a search area: only bright dust inside it is replaced, + clean pixels within the brush stay byte-identical (modulo OETF round-trip).""" + rng = np.random.default_rng(21) + h, w = 100, 100 + img = (np.full((h, w, 3), 0.5) + rng.normal(0, 0.01, (h, w, 3))).astype(np.float32) + img[49:52, 49:52] = 0.95 # small speck, large brush around it + + strokes = [([[0.5, 0.5]], 15.0, 25.0 / w, 0.0)] + regions = build_heal_regions(strokes, [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) + out = apply_manual_heals(img, *regions) + + assert out[49:52, 49:52].max() < 0.7, "dust inside the brush was not healed" + + yy, xx = np.mgrid[0:h, 0:w] + dist = np.hypot(xx - 50, yy - 50) + clean_in_brush = (dist < 13) & (dist > 4) + np.testing.assert_allclose( + out[clean_in_brush], + img[clean_in_brush], + atol=2e-3, + err_msg="clean pixels inside the brush were altered", + ) + + def test_source_scoring_penalizes_dusty_patch(): """select_source_offset must prefer a clean patch over one with a speck inside (rim-band SSD alone can't see interior dust).""" From 070a827fbf5641277c8bdcb3f351e0a96a3c14a7 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 07:31:29 +0200 Subject: [PATCH 4/6] fix(retouch): heal footprint now matches the brush cursor exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline treated brush size as a radius while the overlay cursor draws size/(2*preview_render_size) — half of it — so the healed area was 2x the visible circle. Brush size is now a diameter everywhere: pipeline radius is size/2 * scale_factor, matching overlay._brush_screen_radius 1:1 at any zoom and resolution (the source-offset scoring radius in the controller follows the same convention). Locked in by tests: a dust strip crossing the brush is healed strictly inside the brush circle (max changed-pixel distance <= radius, strip untouched outside), and the pipeline radius fraction equals the cursor fraction exactly. --- negpy/desktop/controller.py | 10 +++---- negpy/features/retouch/logic.py | 4 ++- tests/test_retouch_logic.py | 46 +++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 8d292ba2..8906596a 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -776,14 +776,14 @@ def _commit_heal_stroke(self, raw_pts: list) -> None: size = float(conf.manual_dust_size) index = len(conf.manual_heal_strokes) - # Score the clone source on the source-frame preview; size is in source px, - # the preview is downsampled, so scale the radius accordingly. + # Score the clone source on the source-frame preview. Brush size is a + # diameter at preview_render_size scale (same convention as the pipeline + # radius size/2·scale_factor and the overlay cursor). offset = (0.0, 0.0) preview = self.state.preview_raw if preview is not None: - src_w, src_h = self.state.original_res - scale = max(preview.shape[:2]) / max(src_w, src_h, 1) - offset = select_source_offset(preview, raw_pts, size * scale, index) + scale = max(preview.shape[:2]) / float(APP_CONFIG.preview_render_size) + offset = select_source_offset(preview, raw_pts, 0.5 * size * scale, index) else: offset = fallback_source_offset(index, size, (self.state.original_res[1], self.state.original_res[0])) diff --git a/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 01028ad6..5240b441 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -568,7 +568,9 @@ def _map(nx: float, ny: float) -> Tuple[float, float]: for points, size, sdx, sdy in entries[:max_regions]: chain = np.array([_map(p[0], p[1]) for p in points], dtype=np.float32) - radius = max(1.0, float(size) * float(scale_factor)) + # Brush size is a DIAMETER: the healed footprint must match the on-screen + # cursor circle (overlay._brush_screen_radius draws size/2 at preview scale). + radius = max(1.0, float(size) * float(scale_factor) * 0.5) cx = float(np.mean([p[0] for p in points])) cy = float(np.mean([p[1] for p in points])) diff --git a/tests/test_retouch_logic.py b/tests/test_retouch_logic.py index 6580cbdf..4d39cc33 100644 --- a/tests/test_retouch_logic.py +++ b/tests/test_retouch_logic.py @@ -157,7 +157,7 @@ def test_membrane_recovers_gradient(): clean = img.copy() img[36:44, 56:64] = 0.95 - regions = _regions_for_spot(60.0 / w, 40.0 / h, 8.0, (h, w)) + regions = _regions_for_spot(60.0 / w, 40.0 / h, 16.0, (h, w)) out = apply_manual_heals(img, *regions) err = np.abs(out[36:44, 56:64] - clean[36:44, 56:64]).mean() @@ -179,7 +179,7 @@ def test_stroke_heals_scratch(): pts = [[30.0 / w, 30.0 / h], [75.0 / w, 55.0 / h], [120.0 / w, 80.0 / h]] off = select_source_offset(img, pts, 5.0, 0) - regions = build_heal_regions([(pts, 5.0, off[0], off[1])], [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) + regions = build_heal_regions([(pts, 10.0, off[0], off[1])], [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) out = apply_manual_heals(img, *regions) err_before = np.abs(img[mask] - clean[mask]).mean() @@ -196,7 +196,7 @@ def test_clone_source_dust_not_recloned(): img[47:53, 47:53] = 0.95 # defect being healed img[49:51, 69:71] = 0.95 # dust inside the source patch (offset +20px) - strokes = [([[0.5, 0.5]], 8.0, 20.0 / w, 0.0)] + strokes = [([[0.5, 0.5]], 12.0, 20.0 / w, 0.0)] regions = build_heal_regions(strokes, [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) out = apply_manual_heals(img, *regions) @@ -281,8 +281,44 @@ def test_legacy_spot_conversion(): assert len(reg_i) == 1 assert reg_i[0, 1] == 1 # single-point chain assert reg_i[0, 3] >= 16 # boundary loop present - assert reg_f[0, 0] == 8.0 # radius px - assert np.hypot(reg_f[0, 1], reg_f[0, 2]) > 8.0 # fallback offset clears the spot + assert reg_f[0, 0] == 4.0 # radius px = size/2 (brush size is a diameter) + assert np.hypot(reg_f[0, 1], reg_f[0, 2]) > 4.0 # fallback offset clears the spot + + +def test_heal_footprint_stays_within_brush(): + """Nothing outside the brush circle may change — the healed footprint must + not exceed the on-screen cursor. A bright strip crossing the brush is healed + only inside it.""" + rng = np.random.default_rng(31) + h, w = 100, 100 + img = (np.full((h, w, 3), 0.4) + rng.normal(0, 0.01, (h, w, 3))).astype(np.float32) + img[48:52, :] = 0.95 # dust strip across the whole frame + + strokes = [([[0.5, 0.5]], 16.0, 0.0, 25.0 / h)] # radius 8 at scale 1 + regions = build_heal_regions(strokes, [], (h, w), 0, 0.0, False, False, 0.0, 1.0, (w, h)) + out = apply_manual_heals(img, *regions) + + changed = np.abs(out.astype(np.float64) - img).max(axis=2) > 5e-3 + ys, xs = np.where(changed) + assert len(ys) > 0, "strip inside the brush was not healed" + dist = np.hypot(xs + 0.5 - 50.0, ys + 0.5 - 50.0) + assert dist.max() <= 8.0, f"heal leaked {dist.max():.2f}px from center, brush radius is 8" + assert out[48:52, 80:].min() > 0.9, "strip outside the brush must stay untouched" + + +def test_heal_radius_matches_cursor_fraction(): + """Pipeline heal radius must equal the overlay cursor circle: the cursor + (overlay._brush_screen_radius) draws size/(2·preview_render_size) of the + view; the pipeline radius normalized by the render long edge is the same.""" + from negpy.kernel.system.config import APP_CONFIG + + size = 12.0 + full_dims = (1600, 1067) + scale_factor = max(full_dims) / float(APP_CONFIG.preview_render_size) + _, reg_f, _ = build_heal_regions([([[0.5, 0.5]], size, 0.1, 0.0)], [], (2000, 3000), 0, 0.0, False, False, 0.0, scale_factor, full_dims) + pipeline_fraction = reg_f[0, 0] / max(full_dims) + cursor_fraction = size / (2.0 * APP_CONFIG.preview_render_size) + assert abs(pipeline_fraction - cursor_fraction) < 1e-9 def test_heal_strokes_serialization_roundtrip(): From 29e468922489e26193fa01737a8faf9029d2c839 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 07:45:36 +0200 Subject: [PATCH 5/6] style: formatter line-wrapping cleanup in gear/metadata files Pure formatting (AST-identical): line joins/splits and blank-line removal from a ruff format pass; no behavior change. --- negpy/desktop/view/sidebar/metadata.py | 4 +- .../view/widgets/gear_library_dialog.py | 4 +- negpy/features/metadata/writer.py | 15 +- tests/metadata/test_gear_payload.py | 158 ------------------ 4 files changed, 16 insertions(+), 165 deletions(-) diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index c582027d..e5576480 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -451,9 +451,7 @@ def _update_preview(self) -> None: for title, rows in sections: header = QLabel(title) - header.setStyleSheet( - f"color: {THEME.text_secondary}; font-size: {THEME.font_size_xs}px; font-weight: 600;" - ) + header.setStyleSheet(f"color: {THEME.text_secondary}; font-size: {THEME.font_size_xs}px; font-weight: 600;") self.preview_rows.addWidget(header) for label, value in rows: row = QWidget() diff --git a/negpy/desktop/view/widgets/gear_library_dialog.py b/negpy/desktop/view/widgets/gear_library_dialog.py index c796adab..7e8e189c 100644 --- a/negpy/desktop/view/widgets/gear_library_dialog.py +++ b/negpy/desktop/view/widgets/gear_library_dialog.py @@ -44,9 +44,7 @@ _CATEGORY_FIELDS: dict[str, frozenset[str]] = { "cameras": frozenset({"display_name", "make", "model", "notes"}), "lenses": frozenset({"display_name", "make", "lens_model", "focal", "aperture", "notes"}), - "film_stocks": frozenset( - {"display_name", "manufacturer", "stock_name", "iso", "format", "color_type", "notes"} - ), + "film_stocks": frozenset({"display_name", "manufacturer", "stock_name", "iso", "format", "color_type", "notes"}), "gear_presets": frozenset({"display_name", "preset_camera", "preset_lens", "preset_film", "notes"}), } diff --git a/negpy/features/metadata/writer.py b/negpy/features/metadata/writer.py index 89356b5b..9c978fe5 100644 --- a/negpy/features/metadata/writer.py +++ b/negpy/features/metadata/writer.py @@ -162,7 +162,20 @@ def _short_overflows(value) -> bool: _JPEG_STRIP_0TH = frozenset( { - 254, 256, 257, 258, 259, 262, 273, 277, 278, 279, 284, 330, 513, 514, + 254, + 256, + 257, + 258, + 259, + 262, + 273, + 277, + 278, + 279, + 284, + 330, + 513, + 514, } ) diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index 4f5fd780..da7aeee3 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -1,17 +1,13 @@ """Tests for gear library and metadata payload resolution.""" - - import os - import piexif import pytest - from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary, GearPreset, Lens from negpy.features.metadata.gear_logic import metadata_from_gear @@ -27,43 +23,25 @@ from negpy.services.assets.gear import GearProfiles - - - @pytest.fixture - def gear_dir(tmp_path, monkeypatch): - monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(tmp_path)) return tmp_path - - - def test_seed_example_copies_bundled_files(gear_dir): - GearProfiles.seed_example() assert os.path.isfile(os.path.join(gear_dir, "cameras.json")) - - - def test_load_and_save_library(gear_dir): - library = GearLibrary( - cameras=[Camera(id="c1", make="Canon", model="AE-1")], - lenses=[Lens(id="l1", lens_model="50mm", make="Canon")], - film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], - gear_presets=[GearPreset(id="p1", display_name="Test", camera_id="c1", lens_id="l1", film_stock_id="f1")], - ) GearProfiles.save_library(library) @@ -77,21 +55,12 @@ def test_load_and_save_library(gear_dir): assert loaded.gear_presets[0].display_name == "Test" - - - def test_metadata_from_gear_preset(): - library = GearLibrary( - cameras=[Camera(id="c1", make="Canon", model="AE-1 Program")], - lenses=[Lens(id="l1", lens_model="FD 50mm f/1.4", make="Canon", focal_length_mm=50, max_aperture=1.4)], - film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], - gear_presets=[GearPreset(id="p1", display_name="Combo", camera_id="c1", lens_id="l1", film_stock_id="f1")], - ) config = metadata_from_gear(MetadataConfig(), library, gear_preset_id="p1") @@ -105,47 +74,26 @@ def test_metadata_from_gear_preset(): assert config.film_iso == 400 - - - def test_build_image_description(): - from negpy.features.metadata.payload import MetadataPayload - - payload = MetadataPayload( - camera_make="Canon", - camera_model="AE-1", - lens_model="50mm f/1.4", - film_stock="Portra 400", - iso=400, - ) assert build_image_description(payload) == "Canon AE-1 • 50mm f/1.4 • Portra 400 • ISO 400" - - - def test_build_metadata_payload_preview_pairs(): - library = GearLibrary( - cameras=[Camera(id="c1", make="Canon", model="AE-1")], - lenses=[], - film_stocks=[], - gear_presets=[], - ) config = MetadataConfig(camera_id="c1", developer="D-76 1+1") @@ -161,33 +109,18 @@ def test_build_metadata_payload_preview_pairs(): assert payload.exif_flags.camera is True - - - def test_developer_only_does_not_trigger_capture_exif(): - assert has_capture_gear(MetadataConfig(developer="D-76")) is False - - - def test_xmp_contains_negpy_capture_namespace(): - from negpy.features.metadata.payload import MetadataPayload - - payload = MetadataPayload( - film_stock="Portra 400", - film_manufacturer="Kodak", - film_format="35mm", - developer="D-76", - ) xml = build_xmp_xml(payload) @@ -201,65 +134,37 @@ def test_xmp_contains_negpy_capture_namespace(): assert "tiff:Make" not in xml - - - def test_scan_rig_preserved_in_xmp_while_exif_shows_analog(): - library = GearLibrary( - cameras=[Camera(id="c1", make="Nikon", model="FM2")], - lenses=[Lens(id="l1", lens_model="Nikkor 28mm f/2.8 AIS", make="Nikkor", focal_length_mm=28, max_aperture=2.8)], - film_stocks=[], - gear_presets=[], - ) source_exif = { - "0th": { - piexif.ImageIFD.Make: b"NIKON CORPORATION", - piexif.ImageIFD.Model: b"NIKON D750", - }, - "Exif": { - piexif.ExifIFD.LensMake: b"NIKON", - piexif.ExifIFD.LensModel: b"AF-S 60mm f/2.8G", - piexif.ExifIFD.FocalLength: (600, 10), - piexif.ExifIFD.FocalLengthIn35mmFilm: 60, - piexif.ExifIFD.ExposureTime: (1, 640), - piexif.ExifIFD.FNumber: (56, 10), - piexif.ExifIFD.ISOSpeedRatings: 100, - }, - "GPS": {}, - "Interop": {}, - "1st": {}, - } config = MetadataConfig(camera_id="c1", lens_id="l1", scanning="DSLR copy-stand") payload = build_metadata_payload(config, library, source_exif) - - assert payload.camera_model == "FM2" assert payload.scan_camera_make == "NIKON CORPORATION" @@ -268,8 +173,6 @@ def test_scan_rig_preserved_in_xmp_while_exif_shows_analog(): assert payload.exif_flags.lens is True - - xml = build_xmp_xml(payload) assert "negpy:ScanCameraMake" in xml @@ -279,87 +182,49 @@ def test_scan_rig_preserved_in_xmp_while_exif_shows_analog(): assert "NIKON CORPORATION" in xml - - - def test_embed_jpeg_analog_exif_and_scan_xmp(): - from PIL import Image - - buf = __import__("io").BytesIO() Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="JPEG") jpeg = buf.getvalue() - - source_exif = { - "0th": { - piexif.ImageIFD.Make: b"NIKON CORPORATION", - piexif.ImageIFD.Model: b"NIKON D750", - }, - "Exif": { - piexif.ExifIFD.LensMake: b"NIKON", - piexif.ExifIFD.LensModel: b"AF-S 60mm f/2.8G", - piexif.ExifIFD.FocalLength: (600, 10), - piexif.ExifIFD.FocalLengthIn35mmFilm: 60, - piexif.ExifIFD.ISOSpeedRatings: 100, - }, - "GPS": {}, - "Interop": {}, - "1st": {}, - } - - library = GearLibrary( - cameras=[Camera(id="c1", make="Nikon", model="FM2")], - lenses=[Lens(id="l1", lens_model="Nikkor 28mm f/2.8 AIS", make="Nikkor", focal_length_mm=28, max_aperture=2.8)], - film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], - gear_presets=[], - ) config = MetadataConfig( - camera_id="c1", - lens_id="l1", - film_stock_id="f1", - film="Portra 400", - scanning="DSLR scan", - ) out = embed_metadata(jpeg, config, source_exif, gear=library) - - assert b"http://ns.adobe.com/xap/1.0/" in out assert b"negpy:ScanCameraMake" in out @@ -385,53 +250,31 @@ def test_embed_jpeg_analog_exif_and_scan_xmp(): assert loaded["0th"][piexif.ImageIFD.Software] == b"NegPy" - - - def test_embed_keeps_scan_exif_when_capture_not_set(): - from PIL import Image - - buf = __import__("io").BytesIO() Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="JPEG") jpeg = buf.getvalue() - - source_exif = { - "0th": { - piexif.ImageIFD.Make: b"Plustek", - piexif.ImageIFD.Model: b"OpticFilm 8200", - }, - "Exif": { - piexif.ExifIFD.LensModel: b"", - piexif.ExifIFD.ISOSpeedRatings: 200, - }, - "GPS": {}, - "Interop": {}, - "1st": {}, - } out = embed_metadata(jpeg, MetadataConfig(developer="HC-110"), source_exif) - - loaded = piexif.load(out) assert loaded["0th"][piexif.ImageIFD.Make] == b"Plustek" @@ -439,4 +282,3 @@ def test_embed_keeps_scan_exif_when_capture_not_set(): assert loaded["0th"][piexif.ImageIFD.Model] == b"OpticFilm 8200" assert loaded["Exif"][piexif.ExifIFD.ISOSpeedRatings] == 200 - From d9542ca3b8661c7386c211050480b4b908d628e7 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 08:52:22 +0200 Subject: [PATCH 6/6] feat(desktop): Enter finishes scratch and dodge/burn polylines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same behaviour as double-click: Enter commits an in-progress scratch stroke or closes a lasso polygon (only when it has >= 3 vertices — fewer keeps the draw alive instead of wiping it). The overlay now takes click focus so its widget-context shortcuts (Esc cancel, Enter finish) actually fire after the user clicks the canvas; without a focus policy they never could. --- negpy/desktop/view/canvas/overlay.py | 15 ++++++ negpy/desktop/view/sidebar/local.py | 2 +- negpy/desktop/view/sidebar/retouch.py | 2 +- tests/test_canvas_polyline_finish.py | 67 +++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/test_canvas_polyline_finish.py diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index b4b2f1b1..e2a15577 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -105,11 +105,20 @@ def __init__(self, state: AppState, parent=None): self.setMouseTracking(True) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + # Widget-context shortcuts (Esc cancel, Enter finish) need focus to fire; + # clicking the canvas to draw grants it. + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) self._escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) self._escape_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) self._escape_shortcut.activated.connect(self._cancel_lasso) + # Enter finishes an in-progress scratch/lasso polyline, same as double-click. + for key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + sc = QShortcut(QKeySequence(key), self) + sc.setContext(Qt.ShortcutContext.WidgetShortcut) + sc.activated.connect(self._finish_draw_if_active) + if sys.platform == "win32": self.setAttribute(Qt.WidgetAttribute.WA_StaticContents, False) @@ -858,6 +867,12 @@ def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: return super().mouseDoubleClickEvent(event) + def _finish_draw_if_active(self) -> None: + if self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: + self._finish_scratch() + elif self._tool_mode == ToolMode.LOCAL_DRAW and self._lasso_drawing and len(self._lasso_pts) >= 3: + self._finish_lasso() + def _finish_scratch(self) -> None: pts = self._scratch_pts self._scratch_pts = [] diff --git a/negpy/desktop/view/sidebar/local.py b/negpy/desktop/view/sidebar/local.py index 4125381d..42c36e5c 100644 --- a/negpy/desktop/view/sidebar/local.py +++ b/negpy/desktop/view/sidebar/local.py @@ -19,7 +19,7 @@ def _init_ui(self) -> None: self.draw_btn.setCheckable(True) self.draw_btn.setIcon(qta.icon("fa5s.draw-polygon", color=THEME.text_primary)) self.draw_btn.setToolTip( - "Click to place vertices, double-click or click near the start to close. " + "Click to place vertices; double-click, Enter, or a click near the start closes. " "Click inside an existing mask to select it. Esc cancels the current shape." ) self.show_btn = QPushButton(" Show Masks") diff --git a/negpy/desktop/view/sidebar/retouch.py b/negpy/desktop/view/sidebar/retouch.py index e091c72a..736b3a2b 100644 --- a/negpy/desktop/view/sidebar/retouch.py +++ b/negpy/desktop/view/sidebar/retouch.py @@ -41,7 +41,7 @@ def _init_ui(self) -> None: self.pick_scratch_btn = QPushButton(" Scratch Tool") self.pick_scratch_btn.setCheckable(True) self.pick_scratch_btn.setIcon(qta.icon("fa5s.pen-nib", color=THEME.text_primary)) - self.pick_scratch_btn.setToolTip("Heal a scratch or hair: click points along it, double-click to finish, Esc cancels") + self.pick_scratch_btn.setToolTip("Heal a scratch or hair: click points along it, double-click or Enter to finish, Esc cancels") buttons_row.addWidget(self.auto_dust_btn) buttons_row.addWidget(self.pick_dust_btn) diff --git a/tests/test_canvas_polyline_finish.py b/tests/test_canvas_polyline_finish.py new file mode 100644 index 00000000..1b8bb6d1 --- /dev/null +++ b/tests/test_canvas_polyline_finish.py @@ -0,0 +1,67 @@ +from PyQt6.QtCore import QPointF, QRectF + +from negpy.desktop.session import AppState, ToolMode +from negpy.desktop.view.canvas.overlay import CanvasOverlay + + +def _overlay_with_view() -> CanvasOverlay: + overlay = CanvasOverlay(AppState()) + overlay._view_rect = QRectF(0, 0, 100, 100) + return overlay + + +def test_enter_finishes_scratch_polyline() -> None: + overlay = _overlay_with_view() + overlay.set_tool_mode(ToolMode.SCRATCH_PICK) + overlay._scratch_pts = [QPointF(10, 10), QPointF(40, 40)] + + emitted = [] + overlay.scratch_completed.connect(emitted.append) + overlay._finish_draw_if_active() + + assert len(emitted) == 1 + assert len(emitted[0]) == 2 + assert overlay._scratch_pts == [] + + +def test_enter_finishes_lasso_polygon() -> None: + overlay = _overlay_with_view() + overlay.set_tool_mode(ToolMode.LOCAL_DRAW) + overlay._lasso_drawing = True + overlay._lasso_pts = [QPointF(10, 10), QPointF(40, 10), QPointF(25, 40)] + + emitted = [] + overlay.lasso_completed.connect(emitted.append) + overlay._finish_draw_if_active() + + assert len(emitted) == 1 + assert len(emitted[0]) == 3 + assert overlay._lasso_drawing is False + + +def test_enter_ignores_incomplete_lasso() -> None: + overlay = _overlay_with_view() + overlay.set_tool_mode(ToolMode.LOCAL_DRAW) + overlay._lasso_drawing = True + overlay._lasso_pts = [QPointF(10, 10), QPointF(40, 10)] + + emitted = [] + overlay.lasso_completed.connect(emitted.append) + overlay._finish_draw_if_active() + + # Two points can't close a polygon — keep drawing instead of wiping them. + assert emitted == [] + assert overlay._lasso_drawing is True + assert len(overlay._lasso_pts) == 2 + + +def test_enter_noop_without_active_draw() -> None: + overlay = _overlay_with_view() + overlay.set_tool_mode(ToolMode.DUST_PICK) + + emitted = [] + overlay.scratch_completed.connect(emitted.append) + overlay.lasso_completed.connect(emitted.append) + overlay._finish_draw_if_active() + + assert emitted == []