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.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. 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)