diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 16435ec4..8906596a 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. 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: + 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])) + + 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..e2a15577 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] = {} @@ -101,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) @@ -146,6 +159,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 +177,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 +287,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 +301,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 +337,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 +351,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 +690,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 +861,37 @@ 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_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 = [] + # 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/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/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/sidebar/retouch.py b/negpy/desktop/view/sidebar/retouch.py index 17953ff1..736b3a2b 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 or Enter 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/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/negpy/features/retouch/logic.py b/negpy/features/retouch/logic.py index 0694e718..5240b441 100644 --- a/negpy/features/retouch/logic.py +++ b/negpy/features/retouch/logic.py @@ -1,11 +1,41 @@ +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 +# 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 +# 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) +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 +44,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 +76,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 +88,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 +213,505 @@ 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) +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 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 +@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). - 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 + 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] - final_m = m * luma_key - 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 +@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] + - 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. All clone + samples go through the `_sample_clean_jit` dust guard so specks in the + 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``. + """ + 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) + 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] + 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))) + _sample_clean_jit(img, bx, by, smp_a) + _sample_clean_jit(img, sx, sy, smp_b) + for c in range(3): + diffs[i, c] = smp_a[c] - smp_b[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 + + _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 + + +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) + # 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])) + 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)) + # 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 + 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 + 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 + 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) + + 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 +721,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 +756,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 +802,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..3a0e0b9a 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,80 @@ 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; -} +// 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 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++; +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 < 9; i++) { + for (var i = 0; i <= 4; i++) { + var mi = 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; } + 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 vec3(r[4], g[4], b[4]); + return v; } -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++; +// 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 min_idx_r = i; var min_idx_g = i; var min_idx_b = i; + var mi = 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++; + 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; } - 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; + 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 colors[24]; + return v; } -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 +120,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 +145,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 +157,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 +165,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 +188,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 +203,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 +273,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 +290,125 @@ 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; } - 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; + + 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; + } + } + + 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])); } - 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); } + 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] = 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; + 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; + } + 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 = 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); + 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); } + 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/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 - 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 == [] 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..4d39cc33 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,226 @@ 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, 16.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, 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() + err_after = np.abs(out[mask] - clean[mask]).mean() + 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]], 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) + + healed = out[44:56, 44:56] + 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).""" + 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) + 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] == 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(): + 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 == []