setiastrosuitepro 1.7.4__py3-none-any.whl → 1.7.5.post1__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.
- setiastro/images/clonestamp.png +0 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/blemish_blaster.py +54 -14
- setiastro/saspro/blink_comparator_pro.py +146 -2
- setiastro/saspro/clone_stamp.py +753 -0
- setiastro/saspro/gui/main_window.py +22 -1
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -13
- setiastro/saspro/resources.py +2 -0
- setiastro/saspro/stacking_suite.py +646 -164
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/RECORD +16 -14
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.7.4.dist-info → setiastrosuitepro-1.7.5.post1.dist-info}/licenses/license.txt +0 -0
|
@@ -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)
|