setiastrosuitepro 1.6.0__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/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# pro/blemish_blaster.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import math
|
|
4
|
+
import numpy as np
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from PyQt6.QtCore import Qt, QEvent, QPointF, QRunnable, QThreadPool, pyqtSlot, QObject, pyqtSignal
|
|
7
|
+
from PyQt6.QtGui import QImage, QPixmap, QPen, QBrush, QAction, QKeySequence, QColor, QWheelEvent, QIcon
|
|
8
|
+
from PyQt6.QtWidgets import (
|
|
9
|
+
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, QLabel, QPushButton, QSlider,
|
|
10
|
+
QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGraphicsEllipseItem, QMessageBox, QScrollArea, QCheckBox, QDoubleSpinBox
|
|
11
|
+
)
|
|
12
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class BlemishOp:
|
|
20
|
+
x: int
|
|
21
|
+
y: int
|
|
22
|
+
radius: int
|
|
23
|
+
feather: float
|
|
24
|
+
opacity: float
|
|
25
|
+
channels: list[int]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Worker
|
|
30
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class _BBWorkerSignals(QObject):
|
|
33
|
+
finished = pyqtSignal(np.ndarray)
|
|
34
|
+
|
|
35
|
+
class _BlemishWorker(QRunnable):
|
|
36
|
+
def __init__(self, image: np.ndarray, x: int, y: int, radius: int, feather: float, opacity: float,
|
|
37
|
+
channels_to_process: list[int]):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.image = image.copy()
|
|
40
|
+
self.x, self.y = int(x), int(y)
|
|
41
|
+
self.radius = int(radius)
|
|
42
|
+
self.feather = float(feather)
|
|
43
|
+
self.opacity = float(opacity)
|
|
44
|
+
self.channels_to_process = channels_to_process
|
|
45
|
+
self.signals = _BBWorkerSignals()
|
|
46
|
+
|
|
47
|
+
@pyqtSlot()
|
|
48
|
+
def run(self):
|
|
49
|
+
out = self._remove_blemish(
|
|
50
|
+
self.image, self.x, self.y, self.radius, self.feather, self.opacity, self.channels_to_process
|
|
51
|
+
)
|
|
52
|
+
self.signals.finished.emit(out)
|
|
53
|
+
|
|
54
|
+
# ── the exact SASv2 logic (minor tidy) ────────────────────────────────────
|
|
55
|
+
def _remove_blemish(self, image, x, y, radius, feather, opacity, channels_to_process):
|
|
56
|
+
corrected_image = image.copy()
|
|
57
|
+
h, w = image.shape[:2]
|
|
58
|
+
|
|
59
|
+
# 6 neighbors
|
|
60
|
+
angles = [0, 60, 120, 180, 240, 300]
|
|
61
|
+
centers = []
|
|
62
|
+
for ang in angles:
|
|
63
|
+
r = math.radians(ang)
|
|
64
|
+
dx = int(math.cos(r) * (radius * 1.5))
|
|
65
|
+
dy = int(math.sin(r) * (radius * 1.5))
|
|
66
|
+
centers.append((x + dx, y + dy))
|
|
67
|
+
|
|
68
|
+
tgt_median = self._median_circle(image, x, y, radius, channels_to_process)
|
|
69
|
+
neigh_medians = [self._median_circle(image, cx, cy, radius, channels_to_process) for (cx, cy) in centers]
|
|
70
|
+
|
|
71
|
+
diffs = [abs(m - tgt_median) for m in neigh_medians]
|
|
72
|
+
idxs = np.argsort(diffs)[:3]
|
|
73
|
+
sel_centers = [centers[i] for i in idxs]
|
|
74
|
+
|
|
75
|
+
for c in channels_to_process:
|
|
76
|
+
for i in range(max(y - radius, 0), min(y + radius + 1, h)):
|
|
77
|
+
yi = i - y
|
|
78
|
+
for j in range(max(x - radius, 0), min(x + radius + 1, w)):
|
|
79
|
+
xj = j - x
|
|
80
|
+
dist = math.hypot(xj, yi)
|
|
81
|
+
if dist > radius:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
weight = 1.0 if feather <= 0 else max(0.0, min(1.0, (radius - dist) / (radius * feather)))
|
|
85
|
+
|
|
86
|
+
samples = []
|
|
87
|
+
for (cx, cy) in sel_centers:
|
|
88
|
+
sj = j + (cx - x)
|
|
89
|
+
si = i + (cy - y)
|
|
90
|
+
if 0 <= si < h and 0 <= sj < w:
|
|
91
|
+
if image.ndim == 2:
|
|
92
|
+
samples.append(image[si, sj])
|
|
93
|
+
elif image.ndim == 3 and image.shape[2] == 1:
|
|
94
|
+
samples.append(image[si, sj, 0])
|
|
95
|
+
elif image.ndim == 3 and c < image.shape[2]:
|
|
96
|
+
samples.append(image[si, sj, c])
|
|
97
|
+
|
|
98
|
+
if samples:
|
|
99
|
+
median_val = float(np.median(samples))
|
|
100
|
+
else:
|
|
101
|
+
if image.ndim == 2:
|
|
102
|
+
median_val = float(image[i, j])
|
|
103
|
+
elif image.ndim == 3 and image.shape[2] == 1:
|
|
104
|
+
median_val = float(image[i, j, 0])
|
|
105
|
+
else:
|
|
106
|
+
median_val = float(image[i, j, c])
|
|
107
|
+
|
|
108
|
+
if image.ndim == 2:
|
|
109
|
+
orig = float(image[i, j])
|
|
110
|
+
corrected_image[i, j] = (1 - opacity * weight) * orig + (opacity * weight) * median_val
|
|
111
|
+
elif image.ndim == 3 and image.shape[2] == 1:
|
|
112
|
+
orig = float(image[i, j, 0])
|
|
113
|
+
corrected_image[i, j, 0] = (1 - opacity * weight) * orig + (opacity * weight) * median_val
|
|
114
|
+
elif image.ndim == 3 and c < image.shape[2]:
|
|
115
|
+
orig = float(image[i, j, c])
|
|
116
|
+
corrected_image[i, j, c] = (1 - opacity * weight) * orig + (opacity * weight) * median_val
|
|
117
|
+
|
|
118
|
+
return corrected_image
|
|
119
|
+
|
|
120
|
+
def _median_circle(self, image, cx, cy, radius, channels):
|
|
121
|
+
vals = []
|
|
122
|
+
y0 = max(cy - radius, 0); y1 = min(cy + radius + 1, image.shape[0])
|
|
123
|
+
x0 = max(cx - radius, 0); x1 = min(cx + radius + 1, image.shape[1])
|
|
124
|
+
if y0 >= y1 or x0 >= x1:
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
for c in channels:
|
|
128
|
+
if image.ndim == 2:
|
|
129
|
+
roi = image[y0:y1, x0:x1]
|
|
130
|
+
elif image.ndim == 3:
|
|
131
|
+
if image.shape[2] == 1:
|
|
132
|
+
roi = image[y0:y1, x0:x1, 0]
|
|
133
|
+
elif c < image.shape[2]:
|
|
134
|
+
roi = image[y0:y1, x0:x1, c]
|
|
135
|
+
else:
|
|
136
|
+
continue
|
|
137
|
+
else:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
yy, xx = np.ogrid[:roi.shape[0], :roi.shape[1]]
|
|
141
|
+
mask = (xx - (cx - x0))**2 + (yy - (cy - y0))**2 <= radius**2
|
|
142
|
+
vals.extend(roi[mask].ravel())
|
|
143
|
+
|
|
144
|
+
return float(np.median(vals)) if len(vals) else 0.0
|
|
145
|
+
|
|
146
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
# Dialog
|
|
148
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
class BlemishBlasterDialogPro(QDialog):
|
|
151
|
+
"""
|
|
152
|
+
Interactive blemish remover (preview + click to heal) that writes back to the
|
|
153
|
+
provided document when 'Apply' is pressed.
|
|
154
|
+
"""
|
|
155
|
+
def __init__(self, parent, doc):
|
|
156
|
+
super().__init__(parent)
|
|
157
|
+
self.setWindowTitle("Blemish Blaster")
|
|
158
|
+
self.setMinimumSize(900, 650)
|
|
159
|
+
|
|
160
|
+
self._doc = doc
|
|
161
|
+
base = getattr(doc, "image", None)
|
|
162
|
+
if base is None:
|
|
163
|
+
raise RuntimeError("Document has no image.")
|
|
164
|
+
|
|
165
|
+
# normalize to float32 [0..1]
|
|
166
|
+
self._orig_shape = base.shape
|
|
167
|
+
self._orig_mono = (base.ndim == 2) or (base.ndim == 3 and base.shape[2] == 1)
|
|
168
|
+
|
|
169
|
+
img = np.asarray(base, dtype=np.float32)
|
|
170
|
+
if img.dtype.kind in "ui":
|
|
171
|
+
# Best-effort normalize
|
|
172
|
+
maxv = float(np.nanmax(img)) or 1.0
|
|
173
|
+
img = img / max(1.0, maxv)
|
|
174
|
+
img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
|
|
175
|
+
|
|
176
|
+
# display buffer is 3-channels for visualization
|
|
177
|
+
if img.ndim == 2:
|
|
178
|
+
img3 = np.repeat(img[:, :, None], 3, axis=2)
|
|
179
|
+
elif img.ndim == 3 and img.shape[2] == 1:
|
|
180
|
+
img3 = np.repeat(img, 3, axis=2)
|
|
181
|
+
elif img.ndim == 3 and img.shape[2] >= 3:
|
|
182
|
+
img3 = img[:, :, :3]
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError(f"Unsupported image shape: {img.shape}")
|
|
185
|
+
|
|
186
|
+
self._image = img3.copy() # linear, edited by worker
|
|
187
|
+
self._display = self._image.copy()
|
|
188
|
+
|
|
189
|
+
# ── Scene/View (unchanged) ─────────────────────────────────────────
|
|
190
|
+
self.scene = QGraphicsScene(self)
|
|
191
|
+
self.view = QGraphicsView(self.scene)
|
|
192
|
+
self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
193
|
+
self.pix = QGraphicsPixmapItem()
|
|
194
|
+
self.scene.addItem(self.pix)
|
|
195
|
+
|
|
196
|
+
self.circle = QGraphicsEllipseItem()
|
|
197
|
+
self.circle.setPen(QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine))
|
|
198
|
+
self.circle.setBrush(QBrush(Qt.BrushStyle.NoBrush))
|
|
199
|
+
self.circle.setVisible(False)
|
|
200
|
+
self.scene.addItem(self.circle)
|
|
201
|
+
|
|
202
|
+
# scroll container
|
|
203
|
+
self.scroll = QScrollArea(self)
|
|
204
|
+
self.scroll.setWidgetResizable(True)
|
|
205
|
+
self.scroll.setWidget(self.view)
|
|
206
|
+
|
|
207
|
+
# --- Zoom controls (buttons) ---------------------------------
|
|
208
|
+
# --- Zoom controls (standard themed toolbuttons) ---------------
|
|
209
|
+
self._zoom = 1.0 # initial zoom factor
|
|
210
|
+
|
|
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
|
+
|
|
216
|
+
# ── Controls
|
|
217
|
+
ctrls = QGroupBox("Controls")
|
|
218
|
+
form = QFormLayout(ctrls)
|
|
219
|
+
|
|
220
|
+
# existing sliders
|
|
221
|
+
self.s_radius = QSlider(Qt.Orientation.Horizontal); self.s_radius.setRange(1, 900); self.s_radius.setValue(12)
|
|
222
|
+
self.s_feather = QSlider(Qt.Orientation.Horizontal); self.s_feather.setRange(0, 100); self.s_feather.setValue(50)
|
|
223
|
+
self.s_opacity = QSlider(Qt.Orientation.Horizontal); self.s_opacity.setRange(0, 100); self.s_opacity.setValue(100)
|
|
224
|
+
form.addRow("Radius:", self.s_radius)
|
|
225
|
+
form.addRow("Feather:", self.s_feather)
|
|
226
|
+
form.addRow("Opacity:", self.s_opacity)
|
|
227
|
+
|
|
228
|
+
# --- PREVIEW AUTOSTRETCH (display only) ---
|
|
229
|
+
self.cb_autostretch = QCheckBox("Auto-stretch preview")
|
|
230
|
+
self.cb_autostretch.setChecked(True)
|
|
231
|
+
form.addRow(self.cb_autostretch)
|
|
232
|
+
|
|
233
|
+
self.s_target_median = QDoubleSpinBox()
|
|
234
|
+
self.s_target_median.setRange(0.01, 0.60)
|
|
235
|
+
self.s_target_median.setSingleStep(0.01)
|
|
236
|
+
self.s_target_median.setDecimals(3)
|
|
237
|
+
self.s_target_median.setValue(0.25)
|
|
238
|
+
form.addRow("Target median:", self.s_target_median)
|
|
239
|
+
|
|
240
|
+
self.cb_linked = QCheckBox("Linked color channels")
|
|
241
|
+
self.cb_linked.setChecked(True)
|
|
242
|
+
form.addRow(self.cb_linked)
|
|
243
|
+
|
|
244
|
+
# react to UI
|
|
245
|
+
self.cb_autostretch.toggled.connect(self._update_display_autostretch)
|
|
246
|
+
self.s_target_median.valueChanged.connect(self._update_display_autostretch)
|
|
247
|
+
self.cb_linked.toggled.connect(self._update_display_autostretch)
|
|
248
|
+
# (nice-to-have: disable fields when off)
|
|
249
|
+
self.cb_autostretch.toggled.connect(lambda on: (self.s_target_median.setEnabled(on),
|
|
250
|
+
self.cb_linked.setEnabled(on)))
|
|
251
|
+
|
|
252
|
+
# buttons / layout (unchanged)
|
|
253
|
+
bb = QHBoxLayout()
|
|
254
|
+
|
|
255
|
+
self.btn_undo = QPushButton("Undo")
|
|
256
|
+
self.btn_redo = QPushButton("Redo")
|
|
257
|
+
self.btn_apply = QPushButton("Apply to Document")
|
|
258
|
+
self.btn_close = QPushButton("Close")
|
|
259
|
+
|
|
260
|
+
self.btn_undo.setEnabled(False)
|
|
261
|
+
self.btn_redo.setEnabled(False)
|
|
262
|
+
|
|
263
|
+
bb.addStretch()
|
|
264
|
+
bb.addWidget(self.btn_undo)
|
|
265
|
+
bb.addWidget(self.btn_redo)
|
|
266
|
+
bb.addSpacing(12)
|
|
267
|
+
bb.addWidget(self.btn_apply)
|
|
268
|
+
bb.addWidget(self.btn_close)
|
|
269
|
+
|
|
270
|
+
main = QVBoxLayout(self)
|
|
271
|
+
main.addWidget(self.scroll)
|
|
272
|
+
zoom_bar = QHBoxLayout()
|
|
273
|
+
zoom_bar.addStretch()
|
|
274
|
+
zoom_bar.addWidget(self.btn_zoom_out)
|
|
275
|
+
zoom_bar.addWidget(self.btn_zoom_in)
|
|
276
|
+
zoom_bar.addWidget(self.btn_zoom_fit)
|
|
277
|
+
zoom_bar.addStretch()
|
|
278
|
+
main.addLayout(zoom_bar)
|
|
279
|
+
main.addWidget(ctrls)
|
|
280
|
+
main.addLayout(bb)
|
|
281
|
+
|
|
282
|
+
# behavior
|
|
283
|
+
self._threadpool = QThreadPool.globalInstance()
|
|
284
|
+
self.view.setMouseTracking(True)
|
|
285
|
+
self.view.viewport().installEventFilter(self)
|
|
286
|
+
|
|
287
|
+
# zoom buttons
|
|
288
|
+
self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
|
|
289
|
+
self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
|
|
290
|
+
self.btn_zoom_fit.clicked.connect(self._fit_view)
|
|
291
|
+
|
|
292
|
+
self.btn_apply.clicked.connect(self._commit_to_doc)
|
|
293
|
+
self.btn_close.clicked.connect(self.reject)
|
|
294
|
+
self.btn_undo.clicked.connect(self._undo_step)
|
|
295
|
+
self.btn_redo.clicked.connect(self._redo_step)
|
|
296
|
+
# undo/redo inside dialog (simple)
|
|
297
|
+
self._undo, self._redo = [], []
|
|
298
|
+
self._update_undo_redo_buttons()
|
|
299
|
+
|
|
300
|
+
self._update_display_autostretch()
|
|
301
|
+
self._fit_view()
|
|
302
|
+
|
|
303
|
+
# shortcuts
|
|
304
|
+
a_undo = QAction(self); a_undo.setShortcut(QKeySequence.StandardKey.Undo); a_undo.triggered.connect(self._undo_step)
|
|
305
|
+
a_redo = QAction(self); a_redo.setShortcut(QKeySequence.StandardKey.Redo); a_redo.triggered.connect(self._redo_step)
|
|
306
|
+
self.addAction(a_undo); self.addAction(a_redo)
|
|
307
|
+
|
|
308
|
+
def _update_undo_redo_buttons(self):
|
|
309
|
+
try:
|
|
310
|
+
self.btn_undo.setEnabled(len(self._undo) > 0)
|
|
311
|
+
self.btn_redo.setEnabled(len(self._redo) > 0)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _update_display_autostretch(self):
|
|
317
|
+
"""Rebuilds self._display from the current linear working image."""
|
|
318
|
+
src = self._image # linear data (HxWx3)
|
|
319
|
+
if not self.cb_autostretch.isChecked():
|
|
320
|
+
self._display = src.astype(np.float32, copy=False)
|
|
321
|
+
self._refresh_pix()
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
tm = float(self.s_target_median.value())
|
|
325
|
+
if not self._orig_mono:
|
|
326
|
+
# true color source
|
|
327
|
+
disp = stretch_color_image(src, target_median=tm, linked=self.cb_linked.isChecked(),
|
|
328
|
+
normalize=False, apply_curves=False)
|
|
329
|
+
else:
|
|
330
|
+
# original was mono; channels in src are identical
|
|
331
|
+
mono = src[..., 0]
|
|
332
|
+
mono_st = stretch_mono_image(mono, target_median=tm, normalize=False, apply_curves=False)
|
|
333
|
+
disp = np.stack([mono_st]*3, axis=-1)
|
|
334
|
+
|
|
335
|
+
self._display = disp.astype(np.float32, copy=False)
|
|
336
|
+
self._refresh_pix()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── Event filter for hover/click + wheel zoom
|
|
340
|
+
def eventFilter(self, src, ev):
|
|
341
|
+
if src is self.view.viewport():
|
|
342
|
+
if ev.type() == QEvent.Type.MouseMove:
|
|
343
|
+
pos = self.view.mapToScene(ev.position().toPoint())
|
|
344
|
+
r = self.s_radius.value()
|
|
345
|
+
self.circle.setRect(pos.x()-r, pos.y()-r, 2*r, 2*r)
|
|
346
|
+
self.circle.setVisible(True)
|
|
347
|
+
elif ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
348
|
+
pos = self.view.mapToScene(ev.position().toPoint())
|
|
349
|
+
self._heal_at(pos)
|
|
350
|
+
return True
|
|
351
|
+
elif ev.type() == QEvent.Type.Wheel:
|
|
352
|
+
self._wheel_zoom(ev)
|
|
353
|
+
return True
|
|
354
|
+
return super().eventFilter(src, ev)
|
|
355
|
+
|
|
356
|
+
# ── Heal logic
|
|
357
|
+
def _heal_at(self, scene_pos: QPointF):
|
|
358
|
+
x, y = int(round(scene_pos.x())), int(round(scene_pos.y()))
|
|
359
|
+
if not (0 <= x < self._image.shape[1] and 0 <= y < self._image.shape[0]):
|
|
360
|
+
return
|
|
361
|
+
radius = int(self.s_radius.value())
|
|
362
|
+
feather = float(self.s_feather.value()) / 100.0
|
|
363
|
+
opacity = float(self.s_opacity.value()) / 100.0
|
|
364
|
+
|
|
365
|
+
chans = [0, 1, 2] # we always run on the 3-channel display buffer
|
|
366
|
+
worker = _BlemishWorker(self._image, x, y, radius, feather, opacity, chans)
|
|
367
|
+
worker.signals.finished.connect(self._on_worker_done)
|
|
368
|
+
self.setEnabled(False)
|
|
369
|
+
self._threadpool.start(worker)
|
|
370
|
+
|
|
371
|
+
def _on_worker_done(self, corrected: np.ndarray):
|
|
372
|
+
self._undo.append(self._image.copy()); self._redo.clear()
|
|
373
|
+
self._image = corrected.astype(np.float32, copy=False)
|
|
374
|
+
self._display = self._image.copy()
|
|
375
|
+
self._update_display_autostretch()
|
|
376
|
+
self.setEnabled(True)
|
|
377
|
+
self._update_undo_redo_buttons()
|
|
378
|
+
|
|
379
|
+
# ── Zoom
|
|
380
|
+
# ── Zoom
|
|
381
|
+
def _wheel_zoom(self, ev: QWheelEvent):
|
|
382
|
+
step = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
|
|
383
|
+
self._set_zoom(self._zoom * step)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _set_zoom(self, z: float):
|
|
387
|
+
"""Clamp and apply zoom to the graphics view."""
|
|
388
|
+
z = float(max(0.05, min(4.0, z)))
|
|
389
|
+
if abs(z - self._zoom) < 1e-4:
|
|
390
|
+
return
|
|
391
|
+
self._zoom = z
|
|
392
|
+
self.view.resetTransform()
|
|
393
|
+
self.view.scale(self._zoom, self._zoom)
|
|
394
|
+
|
|
395
|
+
def _fit_view(self):
|
|
396
|
+
"""Fit the image nicely into the view without drifting."""
|
|
397
|
+
if self.pix is None or self.pix.pixmap().isNull():
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Make sure the scene rect matches the pixmap bounds
|
|
401
|
+
br = self.pix.boundingRect()
|
|
402
|
+
if br.isNull():
|
|
403
|
+
return
|
|
404
|
+
self.scene.setSceneRect(br)
|
|
405
|
+
|
|
406
|
+
# Reset any old transform and let QGraphicsView handle the fit
|
|
407
|
+
self.view.resetTransform()
|
|
408
|
+
self.view.fitInView(br, Qt.AspectRatioMode.KeepAspectRatio)
|
|
409
|
+
|
|
410
|
+
# Track the resulting uniform scale as our current zoom
|
|
411
|
+
t = self.view.transform()
|
|
412
|
+
self._zoom = t.m11()
|
|
413
|
+
|
|
414
|
+
# ── Undo/Redo
|
|
415
|
+
def _undo_step(self):
|
|
416
|
+
if not self._undo:
|
|
417
|
+
return
|
|
418
|
+
self._redo.append(self._image.copy())
|
|
419
|
+
self._image = self._undo.pop()
|
|
420
|
+
self._display = self._image.copy()
|
|
421
|
+
self._update_display_autostretch()
|
|
422
|
+
self._update_undo_redo_buttons()
|
|
423
|
+
|
|
424
|
+
def _redo_step(self):
|
|
425
|
+
if not self._redo:
|
|
426
|
+
return
|
|
427
|
+
self._undo.append(self._image.copy())
|
|
428
|
+
self._image = self._redo.pop()
|
|
429
|
+
self._display = self._image.copy()
|
|
430
|
+
self._update_display_autostretch()
|
|
431
|
+
self._update_undo_redo_buttons()
|
|
432
|
+
|
|
433
|
+
# ── Commit back to the document
|
|
434
|
+
def _commit_to_doc(self):
|
|
435
|
+
# convert back to original channels if needed
|
|
436
|
+
out = self._image
|
|
437
|
+
if self._orig_mono:
|
|
438
|
+
# collapse to single channel
|
|
439
|
+
mono = np.mean(out, axis=2, dtype=np.float32)
|
|
440
|
+
if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
|
|
441
|
+
mono = mono[:, :, None]
|
|
442
|
+
out = mono.astype(np.float32, copy=False)
|
|
443
|
+
else:
|
|
444
|
+
# ensure 3 channels
|
|
445
|
+
if out.ndim == 2:
|
|
446
|
+
out = np.repeat(out[:, :, None], 3, axis=2)
|
|
447
|
+
elif out.ndim == 3 and out.shape[2] >= 3:
|
|
448
|
+
out = out[:, :, :3]
|
|
449
|
+
|
|
450
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
451
|
+
|
|
452
|
+
# Try common doc APIs
|
|
453
|
+
applied = False
|
|
454
|
+
try:
|
|
455
|
+
if hasattr(self._doc, "set_image"):
|
|
456
|
+
self._doc.set_image(out, step_name="Blemish Blaster"); applied = True
|
|
457
|
+
elif hasattr(self._doc, "apply_numpy"):
|
|
458
|
+
self._doc.apply_numpy(out, step_name="Blemish Blaster"); applied = True
|
|
459
|
+
elif hasattr(self._doc, "image"):
|
|
460
|
+
self._doc.image = out; applied = True
|
|
461
|
+
except Exception as e:
|
|
462
|
+
QMessageBox.critical(self, "Blemish Blaster", f"Failed to write to document:\n{e}")
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
if applied and hasattr(self.parent(), "_refresh_active_view"):
|
|
466
|
+
try: self.parent()._refresh_active_view()
|
|
467
|
+
except Exception as e:
|
|
468
|
+
import logging
|
|
469
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
470
|
+
|
|
471
|
+
self.accept()
|
|
472
|
+
|
|
473
|
+
# ── display helpers
|
|
474
|
+
def _np_to_qpix(self, img: np.ndarray) -> QPixmap:
|
|
475
|
+
arr = np.ascontiguousarray(np.clip(img * 255.0, 0, 255).astype(np.uint8))
|
|
476
|
+
if arr.ndim == 2:
|
|
477
|
+
h, w = arr.shape
|
|
478
|
+
arr = np.repeat(arr[:, :, None], 3, axis=2)
|
|
479
|
+
qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
480
|
+
else:
|
|
481
|
+
h, w, _ = arr.shape
|
|
482
|
+
qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
483
|
+
return QPixmap.fromImage(qimg)
|
|
484
|
+
|
|
485
|
+
def _refresh_pix(self):
|
|
486
|
+
self.pix.setPixmap(self._np_to_qpix(self._display))
|
|
487
|
+
# auto-fit only on first paint; here just ensure the circle hides until move
|
|
488
|
+
self.circle.setVisible(False)
|