setiastrosuitepro 1.7.4__py3-none-any.whl → 1.7.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

@@ -0,0 +1,753 @@
1
+ # src/setiastro/saspro/clone_stamp.py
2
+ from __future__ import annotations
3
+
4
+ import numpy as np
5
+ from typing import Optional, Tuple
6
+
7
+ from PyQt6.QtCore import Qt, QEvent, QPointF,QTimer
8
+ from PyQt6.QtGui import QImage, QPixmap, QPen, QBrush, QAction, QKeySequence, QColor, QWheelEvent, QPainter
9
+ from PyQt6.QtWidgets import (
10
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, QLabel, QPushButton, QSlider,
11
+ QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsEllipseItem, QMessageBox,
12
+ QScrollArea, QCheckBox, QDoubleSpinBox, QGraphicsLineItem, QWidget, QFrame, QSizePolicy
13
+ )
14
+
15
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
16
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
17
+
18
+
19
+ def _circle_mask(radius: int, feather: float) -> np.ndarray:
20
+ """
21
+ Returns float32 mask (2r+1, 2r+1) in [0..1], with optional feather falloff.
22
+ feather: 0..1 (0=hard edge, 1=soft)
23
+ """
24
+ r = int(max(1, radius))
25
+ y, x = np.ogrid[-r:r+1, -r:r+1]
26
+ d = np.sqrt(x*x + y*y).astype(np.float32)
27
+ m = (d <= r).astype(np.float32)
28
+
29
+ if feather <= 0:
30
+ return m
31
+
32
+ # Soft edge: inner radius where mask=1 then falls to 0 at r
33
+ # feather=1 => inner radius 0, feather small => thin falloff band
34
+ inner = float(r) * (1.0 - float(np.clip(feather, 0.0, 1.0)))
35
+ if inner < 0.5:
36
+ inner = 0.5
37
+
38
+ # Linear falloff in [inner..r]
39
+ fall = np.clip((float(r) - d) / max(1e-6, float(r) - inner), 0.0, 1.0)
40
+ return np.where(d <= inner, 1.0, np.where(d <= r, fall, 0.0)).astype(np.float32)
41
+
42
+ def _blend_clone_inplace(
43
+ img: np.ndarray,
44
+ tx: int, ty: int,
45
+ sx: int, sy: int,
46
+ mask_full: np.ndarray, # (2r+1,2r+1)
47
+ r: int,
48
+ opacity: float,
49
+ ) -> None:
50
+ """
51
+ In-place clone dab. img is float32 HxWx3 in [0..1].
52
+ mask_full already includes feather falloff (0..1). We multiply by opacity here.
53
+ """
54
+ h, w = img.shape[:2]
55
+
56
+ # target bounds
57
+ x0 = tx - r; x1 = tx + r + 1
58
+ y0 = ty - r; y1 = ty + r + 1
59
+
60
+ # clip target
61
+ cx0 = max(0, x0); cx1 = min(w, x1)
62
+ cy0 = max(0, y0); cy1 = min(h, y1)
63
+ if cx0 >= cx1 or cy0 >= cy1:
64
+ return
65
+
66
+ # map to mask coords
67
+ mx0 = cx0 - x0
68
+ my0 = cy0 - y0
69
+ tw = cx1 - cx0
70
+ th = cy1 - cy0
71
+
72
+ # source aligned bounds for same shape
73
+ sx0 = (sx - r) + mx0
74
+ sy0 = (sy - r) + my0
75
+ sx1 = sx0 + tw
76
+ sy1 = sy0 + th
77
+
78
+ # clip both if source out of bounds
79
+ adj_cx0, adj_cy0, adj_cx1, adj_cy1 = cx0, cy0, cx1, cy1
80
+ if sx0 < 0:
81
+ d = -sx0
82
+ sx0 = 0
83
+ adj_cx0 += d
84
+ if sy0 < 0:
85
+ d = -sy0
86
+ sy0 = 0
87
+ adj_cy0 += d
88
+ if sx1 > w:
89
+ d = sx1 - w
90
+ sx1 = w
91
+ adj_cx1 -= d
92
+ if sy1 > h:
93
+ d = sy1 - h
94
+ sy1 = h
95
+ adj_cy1 -= d
96
+
97
+ if adj_cx0 >= adj_cx1 or adj_cy0 >= adj_cy1:
98
+ return
99
+
100
+ tw = adj_cx1 - adj_cx0
101
+ th = adj_cy1 - adj_cy0
102
+ if tw <= 0 or th <= 0:
103
+ return
104
+
105
+ # recompute mask slice indices after clipping shift
106
+ mx0 = adj_cx0 - x0
107
+ my0 = adj_cy0 - y0
108
+
109
+ tgt = img[adj_cy0:adj_cy1, adj_cx0:adj_cx1, :]
110
+ src = img[sy0:sy0+th, sx0:sx0+tw, :]
111
+
112
+ a = (mask_full[my0:my0+th, mx0:mx0+tw] * float(opacity)).astype(np.float32)
113
+ if a.max() <= 0:
114
+ return
115
+
116
+ a3 = a[:, :, None]
117
+ # in-place blend
118
+ tgt[:] = (1.0 - a3) * tgt + a3 * src
119
+
120
+
121
+ class CloneStampDialogPro(QDialog):
122
+ """
123
+ Interactive Clone Stamp:
124
+ - Ctrl+Click sets source point.
125
+ - Left-drag paints source onto target with classic offset-follow behavior.
126
+ Writes back to the provided document when 'Apply' is pressed.
127
+ """
128
+ def __init__(self, parent, doc):
129
+ super().__init__(parent)
130
+ self.setWindowTitle(self.tr("Clone Stamp"))
131
+ self.setWindowFlag(Qt.WindowType.Window, True)
132
+ self.setWindowModality(Qt.WindowModality.NonModal)
133
+ self.setModal(False)
134
+ self.setMinimumSize(900, 650)
135
+ self._mask_cache_key = None
136
+ self._mask_cache = None
137
+
138
+ self._doc = doc
139
+ base = getattr(doc, "image", None)
140
+ if base is None:
141
+ raise RuntimeError("Document has no image.")
142
+
143
+ # normalize to float32 [0..1]
144
+ self._orig_shape = base.shape
145
+ self._orig_mono = (base.ndim == 2) or (base.ndim == 3 and base.shape[2] == 1)
146
+
147
+ img = np.asarray(base, dtype=np.float32)
148
+ if img.dtype.kind in "ui":
149
+ maxv = float(np.nanmax(img)) or 1.0
150
+ img = img / max(1.0, maxv)
151
+ img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
152
+
153
+ # display/working is 3-channel
154
+ if img.ndim == 2:
155
+ img3 = np.repeat(img[:, :, None], 3, axis=2)
156
+ elif img.ndim == 3 and img.shape[2] == 1:
157
+ img3 = np.repeat(img, 3, axis=2)
158
+ elif img.ndim == 3 and img.shape[2] >= 3:
159
+ img3 = img[:, :, :3]
160
+ else:
161
+ raise ValueError(f"Unsupported image shape: {img.shape}")
162
+
163
+ self._image = img3.copy() # linear working
164
+ self._display = self._image.copy()
165
+
166
+ # --- stroke state ---
167
+ self._has_source = False
168
+ self._src_point: Tuple[int, int] = (0, 0) # absolute source point (current anchor)
169
+ self._offset: Tuple[int, int] = (0, 0) # src - tgt at stroke start
170
+ self._painting = False
171
+ self._last_tgt: Optional[Tuple[int, int]] = None
172
+
173
+ # ── Scene/View
174
+ self.scene = QGraphicsScene(self)
175
+ self.view = QGraphicsView(self.scene)
176
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
177
+ self.pix = QGraphicsPixmapItem()
178
+ self.scene.addItem(self.pix)
179
+
180
+ # Brush circle (green, always visible on move)
181
+ self.circle = QGraphicsEllipseItem()
182
+ self.circle.setPen(QPen(QColor(0, 255, 0), 2, Qt.PenStyle.SolidLine))
183
+ self.circle.setBrush(QBrush(Qt.BrushStyle.NoBrush))
184
+ self.circle.setVisible(False)
185
+ self.scene.addItem(self.circle)
186
+
187
+ # Source X (two line items)
188
+ self.src_x1 = QGraphicsLineItem()
189
+ self.src_x2 = QGraphicsLineItem()
190
+ pen_src = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.SolidLine)
191
+ self.src_x1.setPen(pen_src)
192
+ self.src_x2.setPen(pen_src)
193
+ self.src_x1.setVisible(False)
194
+ self.src_x2.setVisible(False)
195
+ self.scene.addItem(self.src_x1)
196
+ self.scene.addItem(self.src_x2)
197
+
198
+ # Optional line from target to source
199
+ self.link = QGraphicsLineItem()
200
+ self.link.setPen(QPen(QColor(0, 255, 0), 1, Qt.PenStyle.DotLine))
201
+ self.link.setVisible(False)
202
+ self.scene.addItem(self.link)
203
+
204
+ # scroll container
205
+ self.scroll = QScrollArea(self)
206
+ self.scroll.setWidgetResizable(True)
207
+ self.scroll.setWidget(self.view)
208
+ self.scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
209
+ # Zoom controls
210
+ self._zoom = 1.0
211
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
212
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
213
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
214
+
215
+ # ── Controls
216
+ ctrls = QGroupBox(self.tr("Controls"))
217
+ form = QFormLayout(ctrls)
218
+
219
+ self.s_radius = QSlider(Qt.Orientation.Horizontal); self.s_radius.setRange(1, 900); self.s_radius.setValue(24)
220
+ self.s_feather = QSlider(Qt.Orientation.Horizontal); self.s_feather.setRange(0, 100); self.s_feather.setValue(50)
221
+ self.s_opacity = QSlider(Qt.Orientation.Horizontal); self.s_opacity.setRange(0, 100); self.s_opacity.setValue(100)
222
+
223
+ form.addRow(self.tr("Radius:"), self.s_radius)
224
+ form.addRow(self.tr("Feather:"), self.s_feather)
225
+ form.addRow(self.tr("Opacity:"), self.s_opacity)
226
+
227
+ self.brush_preview = QLabel(self)
228
+ self.brush_preview.setFixedSize(90, 90)
229
+ self.brush_preview.setStyleSheet("background:#000; border:1px solid #333;")
230
+ form.addRow(self.tr("Brush preview:"), self.brush_preview)
231
+
232
+ self.lbl_help = QLabel(self.tr(
233
+ "Ctrl+Click to set source. Then Left-drag to paint.\n"
234
+ "Source follows the cursor (classic clone stamp)."
235
+ ))
236
+ self.lbl_help.setStyleSheet("color:#888;")
237
+ self.lbl_help.setWordWrap(True)
238
+ form.addRow(self.lbl_help)
239
+
240
+ self.btn_clear_src = QPushButton(self.tr("Clear Source"))
241
+ self.btn_clear_src.clicked.connect(self._clear_source)
242
+ form.addRow(self.btn_clear_src)
243
+
244
+ # Preview autostretch (display only)
245
+ self.cb_autostretch = QCheckBox(self.tr("Auto-stretch preview"))
246
+ self.cb_autostretch.setChecked(False)
247
+ form.addRow(self.cb_autostretch)
248
+
249
+ self.s_target_median = QDoubleSpinBox()
250
+ self.s_target_median.setRange(0.01, 0.60)
251
+ self.s_target_median.setSingleStep(0.01)
252
+ self.s_target_median.setDecimals(3)
253
+ self.s_target_median.setValue(0.25)
254
+ form.addRow(self.tr("Target median:"), self.s_target_median)
255
+
256
+ self.cb_linked = QCheckBox(self.tr("Linked color channels"))
257
+ self.cb_linked.setChecked(True)
258
+ form.addRow(self.cb_linked)
259
+
260
+ self.cb_autostretch.toggled.connect(self._update_display_autostretch)
261
+ self.s_target_median.valueChanged.connect(self._update_display_autostretch)
262
+ self.cb_linked.toggled.connect(self._update_display_autostretch)
263
+ self.cb_autostretch.toggled.connect(lambda on: (self.s_target_median.setEnabled(on),
264
+ self.cb_linked.setEnabled(on)))
265
+
266
+ # buttons
267
+ bb = QHBoxLayout()
268
+ self.btn_undo = QPushButton(self.tr("Undo"))
269
+ self.btn_redo = QPushButton(self.tr("Redo"))
270
+ self.btn_apply = QPushButton(self.tr("Apply to Document"))
271
+ self.btn_close = QPushButton(self.tr("Close"))
272
+
273
+ self.btn_undo.setEnabled(False)
274
+ self.btn_redo.setEnabled(False)
275
+
276
+ bb.addStretch()
277
+ bb.addWidget(self.btn_undo)
278
+ bb.addWidget(self.btn_redo)
279
+ bb.addSpacing(12)
280
+ bb.addWidget(self.btn_apply)
281
+ bb.addWidget(self.btn_close)
282
+
283
+ # ─────────────────────────────────────────────────────────────
284
+ # Layout: Left = Preview, Right = Zoom + Controls + Buttons
285
+ # ─────────────────────────────────────────────────────────────
286
+ root = QHBoxLayout(self)
287
+ root.setContentsMargins(8, 8, 8, 8)
288
+ root.setSpacing(10)
289
+
290
+ # ---- LEFT: Preview ----
291
+ left = QVBoxLayout()
292
+ left.setSpacing(8)
293
+
294
+ left.addWidget(self.scroll, 1) # preview expands
295
+
296
+
297
+ # optional vertical separator (nice on wide screens)
298
+ sep = QFrame(self)
299
+ sep.setFrameShape(QFrame.Shape.VLine)
300
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
301
+
302
+ # ---- RIGHT: Zoom + Controls + Buttons ----
303
+ right = QVBoxLayout()
304
+ right.setSpacing(10)
305
+
306
+ # Zoom group (top of right column)
307
+ zoom_box = QGroupBox(self.tr("Zoom"))
308
+ zoom_lay = QHBoxLayout(zoom_box)
309
+ zoom_lay.addStretch(1)
310
+ zoom_lay.addWidget(self.btn_zoom_out)
311
+ zoom_lay.addWidget(self.btn_zoom_in)
312
+ zoom_lay.addWidget(self.btn_zoom_fit)
313
+ zoom_lay.addStretch(1)
314
+
315
+ right.addWidget(zoom_box)
316
+
317
+ # Controls group (already built as `ctrls`)
318
+ right.addWidget(ctrls, 0)
319
+
320
+ # Bottom buttons row (already built as `bb`)
321
+ btn_row = QWidget(self)
322
+ btn_row.setLayout(bb)
323
+ right.addWidget(btn_row, 0)
324
+
325
+ right.addStretch(1)
326
+
327
+ # Wrap right column in a scroll area so small monitors don't get cramped
328
+ right_widget = QWidget(self)
329
+ right_widget.setLayout(right)
330
+
331
+ right_scroll = QScrollArea(self)
332
+ right_scroll.setWidgetResizable(True)
333
+ right_scroll.setMinimumWidth(320) # adjust if you want (300–360 is good)
334
+ right_scroll.setWidget(right_widget)
335
+
336
+ root.addWidget(right_scroll, 0) # RIGHT column becomes LEFT side now
337
+ root.addWidget(sep)
338
+ root.addLayout(left, 1) # preview becomes RIGHT side now
339
+
340
+ # behavior
341
+ self.view.setMouseTracking(True)
342
+ self.view.viewport().installEventFilter(self)
343
+
344
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
345
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
346
+ self.btn_zoom_fit.clicked.connect(self._fit_view)
347
+
348
+ self.btn_apply.clicked.connect(self._commit_to_doc)
349
+ self.btn_close.clicked.connect(self.reject)
350
+ self.btn_undo.clicked.connect(self._undo_step)
351
+ self.btn_redo.clicked.connect(self._redo_step)
352
+ self.s_radius.valueChanged.connect(self._update_brush_preview)
353
+ self.s_feather.valueChanged.connect(self._update_brush_preview)
354
+ self.s_opacity.valueChanged.connect(self._update_brush_preview)
355
+ self._undo, self._redo = [], []
356
+ self._update_undo_redo_buttons()
357
+
358
+ self._update_display_autostretch()
359
+ self._fit_view()
360
+ self._stroke_last_pos: Optional[Tuple[float, float]] = None
361
+ self._stroke_spacing_frac = 0.25 # dab spacing = radius * this
362
+
363
+ self._paint_refresh_pending = False
364
+ self._paint_refresh_timer = QTimer(self)
365
+ self._paint_refresh_timer.setSingleShot(True)
366
+ self._paint_refresh_timer.timeout.connect(self._do_paint_refresh)
367
+
368
+ # shortcuts
369
+ a_undo = QAction(self); a_undo.setShortcut(QKeySequence.StandardKey.Undo); a_undo.triggered.connect(self._undo_step)
370
+ a_redo = QAction(self); a_redo.setShortcut(QKeySequence.StandardKey.Redo); a_redo.triggered.connect(self._redo_step)
371
+ self.addAction(a_undo); self.addAction(a_redo)
372
+ self._update_brush_preview()
373
+
374
+ def _schedule_paint_refresh(self):
375
+ if self._paint_refresh_pending:
376
+ return
377
+ self._paint_refresh_pending = True
378
+ self._paint_refresh_timer.start(33) # ~30 FPS
379
+
380
+ def _do_paint_refresh(self):
381
+ self._paint_refresh_pending = False
382
+ self._display = self._image
383
+ self._refresh_pix()
384
+
385
+
386
+ # ──────────────────────────────────────────────────────────────────────────
387
+ def _get_mask(self, r: int, feather: float) -> np.ndarray:
388
+ key = (int(r), float(feather))
389
+ if self._mask_cache_key != key or self._mask_cache is None:
390
+ self._mask_cache_key = key
391
+ self._mask_cache = _circle_mask(r, feather)
392
+ return self._mask_cache
393
+
394
+
395
+ def _clear_source(self):
396
+ self._has_source = False
397
+ self._src_point = (0, 0)
398
+ self.src_x1.setVisible(False)
399
+ self.src_x2.setVisible(False)
400
+ self.link.setVisible(False)
401
+
402
+ def _update_undo_redo_buttons(self):
403
+ self.btn_undo.setEnabled(len(self._undo) > 0)
404
+ self.btn_redo.setEnabled(len(self._redo) > 0)
405
+
406
+ def _update_display_autostretch(self):
407
+ src = self._image
408
+ if not self.cb_autostretch.isChecked():
409
+ self._display = src.astype(np.float32, copy=False)
410
+ self._refresh_pix()
411
+ return
412
+
413
+ tm = float(self.s_target_median.value())
414
+ if not self._orig_mono:
415
+ disp = stretch_color_image(src, target_median=tm, linked=self.cb_linked.isChecked(),
416
+ normalize=False, apply_curves=False)
417
+ else:
418
+ mono = src[..., 0]
419
+ mono_st = stretch_mono_image(mono, target_median=tm, normalize=False, apply_curves=False)
420
+ disp = np.stack([mono_st]*3, axis=-1)
421
+
422
+ self._display = disp.astype(np.float32, copy=False)
423
+ self._refresh_pix()
424
+
425
+ def _update_brush_preview(self):
426
+ w = self.brush_preview.width()
427
+ h = self.brush_preview.height()
428
+
429
+ # Use a fixed "preview radius" so the graphic always fills nicely.
430
+ # Feather/opacity are the main teaching tools here.
431
+ r = min(w, h) * 0.42
432
+
433
+ feather = float(self.s_feather.value()) / 100.0
434
+ opacity = float(self.s_opacity.value()) / 100.0
435
+
436
+ # Build a tiny mask using the same logic as the real stamp.
437
+ # Convert radius -> int pixel radius for the preview buffer.
438
+ pr = int(max(1, round(r)))
439
+ mask = _circle_mask(pr, feather) # (2pr+1, 2pr+1) float32
440
+
441
+ # Make preview canvas
442
+ canvas = np.zeros((h, w), dtype=np.float32)
443
+
444
+ # Center the mask
445
+ cy, cx = h // 2, w // 2
446
+ y0 = cy - pr
447
+ x0 = cx - pr
448
+ y1 = y0 + mask.shape[0]
449
+ x1 = x0 + mask.shape[1]
450
+
451
+ # Clip just in case
452
+ yy0 = max(0, y0); xx0 = max(0, x0)
453
+ yy1 = min(h, y1); xx1 = min(w, x1)
454
+
455
+ my0 = yy0 - y0; mx0 = xx0 - x0
456
+ my1 = my0 + (yy1 - yy0); mx1 = mx0 + (xx1 - xx0)
457
+
458
+ canvas[yy0:yy1, xx0:xx1] = mask[my0:my1, mx0:mx1] * opacity
459
+
460
+ # Convert to QPixmap (grayscale)
461
+ arr8 = np.ascontiguousarray(np.clip(canvas * 255.0, 0, 255).astype(np.uint8))
462
+ qimg = QImage(arr8.data, w, h, w, QImage.Format.Format_Grayscale8)
463
+ self.brush_preview.setPixmap(QPixmap.fromImage(qimg))
464
+
465
+
466
+ # ── Event filter
467
+ def eventFilter(self, src, ev):
468
+ if src is self.view.viewport():
469
+ if ev.type() == QEvent.Type.MouseMove:
470
+ pos = self.view.mapToScene(ev.position().toPoint())
471
+ self._on_mouse_move(pos, ev)
472
+ return True
473
+
474
+ if ev.type() == QEvent.Type.MouseButtonPress:
475
+ pos = self.view.mapToScene(ev.position().toPoint())
476
+ if ev.button() == Qt.MouseButton.LeftButton:
477
+ mods = ev.modifiers()
478
+ if mods & Qt.KeyboardModifier.ControlModifier:
479
+ self._set_source_at(pos)
480
+ else:
481
+ self._start_paint_at(pos)
482
+ return True
483
+
484
+ if ev.type() == QEvent.Type.MouseButtonRelease:
485
+ if ev.button() == Qt.MouseButton.LeftButton:
486
+ self._end_paint()
487
+ return True
488
+
489
+ if ev.type() == QEvent.Type.Wheel:
490
+ self._wheel_zoom(ev)
491
+ return True
492
+
493
+ return super().eventFilter(src, ev)
494
+
495
+ def _on_mouse_move(self, scene_pos: QPointF, ev):
496
+ x, y = float(scene_pos.x()), float(scene_pos.y())
497
+ r = int(self.s_radius.value())
498
+ self.circle.setRect(x - r, y - r, 2*r, 2*r)
499
+ self.circle.setVisible(True)
500
+
501
+ if self._painting and self._has_source:
502
+ self._paint_segment(scene_pos)
503
+
504
+ # Update source overlay while hovering (and while painting)
505
+ self._update_source_overlay(scene_pos)
506
+
507
+ def _set_source_at(self, scene_pos: QPointF):
508
+ x, y = int(round(scene_pos.x())), int(round(scene_pos.y()))
509
+ if not (0 <= x < self._image.shape[1] and 0 <= y < self._image.shape[0]):
510
+ return
511
+ self._has_source = True
512
+ self._src_point = (x, y)
513
+ self._last_tgt = None # reset stroke history
514
+ self._painting = False
515
+
516
+ # show X at the chosen source (no offset yet)
517
+ self._draw_source_x(x, y, size=8)
518
+ self.src_x1.setVisible(True)
519
+ self.src_x2.setVisible(True)
520
+ self.link.setVisible(False)
521
+
522
+ def _start_paint_at(self, scene_pos: QPointF):
523
+ if not self._has_source:
524
+ QMessageBox.information(self, "Clone Stamp", "Ctrl+Click to set a source point first.")
525
+ return
526
+
527
+ tx, ty = int(round(scene_pos.x())), int(round(scene_pos.y()))
528
+ if not (0 <= tx < self._image.shape[1] and 0 <= ty < self._image.shape[0]):
529
+ return
530
+
531
+ # Start of stroke: lock offset = src - tgt
532
+ sx, sy = self._src_point
533
+ self._offset = (sx - tx, sy - ty)
534
+
535
+ # Push undo snapshot once per stroke
536
+ self._undo.append(self._image.copy())
537
+ self._redo.clear()
538
+ self._update_undo_redo_buttons()
539
+
540
+ self._painting = True
541
+ self._stroke_last_pos = (scene_pos.x(), scene_pos.y())
542
+ self._paint_segment(scene_pos) # do first dab
543
+
544
+ def _end_paint(self):
545
+ self._painting = False
546
+ self._last_tgt = None
547
+ self._stroke_last_pos = None
548
+ # Now do the expensive autostretch once:
549
+ self._update_display_autostretch()
550
+
551
+ def _paint_segment(self, scene_pos: QPointF):
552
+ if not (self._painting and self._has_source):
553
+ return
554
+
555
+ x1, y1 = float(scene_pos.x()), float(scene_pos.y())
556
+ if self._stroke_last_pos is None:
557
+ self._stroke_last_pos = (x1, y1)
558
+
559
+ x0, y0 = self._stroke_last_pos
560
+ dx = x1 - x0
561
+ dy = y1 - y0
562
+ dist = float((dx*dx + dy*dy) ** 0.5)
563
+
564
+ radius = int(self.s_radius.value())
565
+ feather = float(self.s_feather.value()) / 100.0
566
+ opacity = float(self.s_opacity.value()) / 100.0
567
+
568
+ # spacing: smaller = smoother (but more compute)
569
+ spacing = max(1.0, radius * self._stroke_spacing_frac)
570
+ steps = max(1, int(dist / spacing))
571
+
572
+ mask = self._get_mask(radius, feather)
573
+ ox, oy = self._offset
574
+
575
+ h, w = self._image.shape[:2]
576
+
577
+ # stamp along the line
578
+ for i in range(1, steps + 1):
579
+ t = i / steps
580
+ xt = x0 + dx * t
581
+ yt = y0 + dy * t
582
+ tx, ty = int(round(xt)), int(round(yt))
583
+ if not (0 <= tx < w and 0 <= ty < h):
584
+ continue
585
+ sx, sy = tx + ox, ty + oy
586
+ _blend_clone_inplace(self._image, tx, ty, sx, sy, mask, radius, opacity)
587
+
588
+ self._stroke_last_pos = (x1, y1)
589
+
590
+ # During painting: do NOT autostretch every dab.
591
+ # Just show linear buffer quickly.
592
+ self._schedule_paint_refresh()
593
+
594
+
595
+ def _paint_at(self, scene_pos: QPointF):
596
+ tx, ty = int(round(scene_pos.x())), int(round(scene_pos.y()))
597
+ h, w = self._image.shape[:2]
598
+ if not (0 <= tx < w and 0 <= ty < h):
599
+ return
600
+
601
+ # spacing: don’t spam dabs if mouse barely moved
602
+ if self._last_tgt is not None:
603
+ lx, ly = self._last_tgt
604
+ if (tx - lx)*(tx - lx) + (ty - ly)*(ty - ly) < 2: # ~1px
605
+ return
606
+ self._last_tgt = (tx, ty)
607
+
608
+ ox, oy = self._offset
609
+ sx, sy = tx + ox, ty + oy
610
+
611
+ radius = int(self.s_radius.value())
612
+ feather = float(self.s_feather.value()) / 100.0
613
+ opacity = float(self.s_opacity.value()) / 100.0
614
+
615
+ self._image = _blend_clone(
616
+ self._image, (tx, ty), (sx, sy),
617
+ radius=radius, feather=feather, opacity=opacity
618
+ ).astype(np.float32, copy=False)
619
+
620
+ # update display quickly without recomputing expensive stuff every dab:
621
+ # - if autostretch is ON, we still need a rebuild (can be heavy),
622
+ # but in practice it’s fine; if it’s too slow, we can add a 60–120ms debounce.
623
+ self._update_display_autostretch()
624
+
625
+ # update overlays immediately
626
+ self._update_source_overlay(scene_pos)
627
+
628
+ def _update_source_overlay(self, tgt_scene_pos: QPointF):
629
+ if not self._has_source:
630
+ self.src_x1.setVisible(False)
631
+ self.src_x2.setVisible(False)
632
+ self.link.setVisible(False)
633
+ return
634
+
635
+ tx, ty = float(tgt_scene_pos.x()), float(tgt_scene_pos.y())
636
+
637
+ if self._painting:
638
+ # live source follows the cursor via offset
639
+ sx = tx + float(self._offset[0])
640
+ sy = ty + float(self._offset[1])
641
+
642
+ self._draw_source_x(sx, sy, size=8)
643
+ self.src_x1.setVisible(True)
644
+ self.src_x2.setVisible(True)
645
+
646
+ self.link.setLine(tx, ty, sx, sy)
647
+ self.link.setVisible(True)
648
+ else:
649
+ # not painting: show X at the anchored source point
650
+ sx, sy = self._src_point
651
+ self._draw_source_x(float(sx), float(sy), size=8)
652
+ self.src_x1.setVisible(True)
653
+ self.src_x2.setVisible(True)
654
+ self.link.setVisible(False)
655
+
656
+ def _draw_source_x(self, x: float, y: float, size: int = 8):
657
+ s = float(size)
658
+ self.src_x1.setLine(x - s, y - s, x + s, y + s)
659
+ self.src_x2.setLine(x - s, y + s, x + s, y - s)
660
+
661
+ # ── Zoom
662
+ def _wheel_zoom(self, ev: QWheelEvent):
663
+ step = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
664
+ self._set_zoom(self._zoom * step)
665
+
666
+ def _set_zoom(self, z: float):
667
+ z = float(max(0.05, min(4.0, z)))
668
+ if abs(z - self._zoom) < 1e-4:
669
+ return
670
+ self._zoom = z
671
+ self.view.resetTransform()
672
+ self.view.scale(self._zoom, self._zoom)
673
+
674
+ def _fit_view(self):
675
+ if self.pix is None or self.pix.pixmap().isNull():
676
+ return
677
+ br = self.pix.boundingRect()
678
+ if br.isNull():
679
+ return
680
+ self.scene.setSceneRect(br)
681
+ self.view.resetTransform()
682
+ self.view.fitInView(br, Qt.AspectRatioMode.KeepAspectRatio)
683
+ t = self.view.transform()
684
+ self._zoom = t.m11()
685
+
686
+ # ── Undo/Redo
687
+ def _undo_step(self):
688
+ if not self._undo:
689
+ return
690
+ self._redo.append(self._image.copy())
691
+ self._image = self._undo.pop()
692
+ self._update_display_autostretch()
693
+ self._update_undo_redo_buttons()
694
+
695
+ def _redo_step(self):
696
+ if not self._redo:
697
+ return
698
+ self._undo.append(self._image.copy())
699
+ self._image = self._redo.pop()
700
+ self._update_display_autostretch()
701
+ self._update_undo_redo_buttons()
702
+
703
+ # ── Commit
704
+ def _commit_to_doc(self):
705
+ out = self._image
706
+ if self._orig_mono:
707
+ mono = np.mean(out, axis=2, dtype=np.float32)
708
+ if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
709
+ mono = mono[:, :, None]
710
+ out = mono.astype(np.float32, copy=False)
711
+ else:
712
+ if out.ndim == 2:
713
+ out = np.repeat(out[:, :, None], 3, axis=2)
714
+ elif out.ndim == 3 and out.shape[2] >= 3:
715
+ out = out[:, :, :3]
716
+
717
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
718
+
719
+ applied = False
720
+ try:
721
+ if hasattr(self._doc, "set_image"):
722
+ self._doc.set_image(out, step_name="Clone Stamp"); applied = True
723
+ elif hasattr(self._doc, "apply_numpy"):
724
+ self._doc.apply_numpy(out, step_name="Clone Stamp"); applied = True
725
+ elif hasattr(self._doc, "image"):
726
+ self._doc.image = out; applied = True
727
+ except Exception as e:
728
+ QMessageBox.critical(self, "Clone Stamp", f"Failed to write to document:\n{e}")
729
+ return
730
+
731
+ if applied and hasattr(self.parent(), "_refresh_active_view"):
732
+ try:
733
+ self.parent()._refresh_active_view()
734
+ except Exception:
735
+ pass
736
+
737
+ self.accept()
738
+
739
+ # ── display helpers
740
+ def _np_to_qpix(self, img: np.ndarray) -> QPixmap:
741
+ arr = np.ascontiguousarray(np.clip(img * 255.0, 0, 255).astype(np.uint8))
742
+ if arr.ndim == 2:
743
+ h, w = arr.shape
744
+ arr = np.repeat(arr[:, :, None], 3, axis=2)
745
+ qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
746
+ else:
747
+ h, w, _ = arr.shape
748
+ qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
749
+ return QPixmap.fromImage(qimg)
750
+
751
+ def _refresh_pix(self):
752
+ self.pix.setPixmap(self._np_to_qpix(self._display))
753
+ self.circle.setVisible(False)