Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 44 additions & 11 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -728,37 +729,69 @@ 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:
uv_grid = self.state.last_metrics.get("uv_grid")
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()
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class ToolMode(Enum):
WB_PICK = auto()
CROP_MANUAL = auto()
DUST_PICK = auto()
SCRATCH_PICK = auto()
LOCAL_DRAW = auto()


Expand Down
134 changes: 130 additions & 4 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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] = {}

Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion negpy/desktop/view/sidebar/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 1 addition & 3 deletions negpy/desktop/view/sidebar/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading