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,1397 @@
1
+ # pro/convo.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import math
6
+ import numpy as np
7
+ from typing import Optional, Tuple
8
+ from functools import lru_cache
9
+ from concurrent.futures import ThreadPoolExecutor
10
+
11
+ # ── SciPy / scikit-image
12
+ from scipy.signal import fftconvolve
13
+ from scipy.ndimage import laplace
14
+ from numpy.fft import fft2, ifft2, ifftshift
15
+
16
+ from skimage.restoration import denoise_tv_chambolle, denoise_bilateral
17
+ from skimage.color import rgb2lab, lab2rgb
18
+ from skimage.util import img_as_float32
19
+ from skimage.transform import warp, AffineTransform
20
+
21
+ # ── Qt
22
+ from PyQt6.QtCore import Qt, pyqtSignal
23
+ from PyQt6.QtGui import QDoubleValidator, QImage, QPainter, QPen, QColor, QIcon, QPixmap
24
+ from PyQt6.QtWidgets import (
25
+ QApplication, QMessageBox,
26
+ QDialog, QHBoxLayout, QVBoxLayout, QFrame, QLabel, QSlider, QLineEdit,
27
+ QFormLayout, QTabWidget, QComboBox, QCheckBox, QPushButton, QToolButton,
28
+ QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QFileDialog, QWidget,
29
+ QSpinBox
30
+ )
31
+ import cv2
32
+ # Optional FITS export
33
+ from astropy.io import fits
34
+
35
+ import sep # PSF estimator
36
+
37
+ # Import centralized widgets
38
+ from setiastro.saspro.widgets.spinboxes import CustomSpinBox
39
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
40
+
41
+
42
+ # --- GraphicsView with Shift+Click LS center + optional scene ctor -----------
43
+ class InteractiveGraphicsView(QGraphicsView):
44
+ def __init__(self, scene: QGraphicsScene | None = None, parent=None):
45
+ super().__init__(parent)
46
+ if scene is not None:
47
+ self.setScene(scene)
48
+ self.ls_center: Optional[Tuple[float, float]] = None
49
+ self.cross_items = []
50
+
51
+ def mousePressEvent(self, event):
52
+ if (event.modifiers() & Qt.KeyboardModifier.ShiftModifier) and event.button() == Qt.MouseButton.LeftButton:
53
+ scene_pt = self.mapToScene(event.position().toPoint())
54
+ x, y = scene_pt.x(), scene_pt.y()
55
+ self.ls_center = (x, y)
56
+ self._draw_crosshair_at(x, y)
57
+ return
58
+ super().mousePressEvent(event)
59
+
60
+ def _draw_crosshair_at(self, x: float, y: float):
61
+ for item in self.cross_items:
62
+ self.scene().removeItem(item)
63
+ self.cross_items.clear()
64
+ size = 10
65
+ pen = QPen(QColor(255, 0, 0), 2)
66
+ hline = self.scene().addLine(x - size, y, x + size, y, pen)
67
+ vline = self.scene().addLine(x, y - size, x, y + size, pen)
68
+ self.cross_items.extend([hline, vline])
69
+
70
+
71
+ class FloatSliderWithEdit(QWidget):
72
+ """
73
+ Integer slider + float line edit, mapped by fixed step; emits valueChanged(float)
74
+ """
75
+ valueChanged = pyqtSignal(float)
76
+
77
+ def __init__(self, *, minimum: float, maximum: float, step: float, initial: float, suffix: str = "", parent=None):
78
+ super().__init__(parent)
79
+ self._min = minimum
80
+ self._max = maximum
81
+ self._step = step
82
+ self._suffix = suffix
83
+ self._factor = 1.0 / step
84
+ self._int_min = int(round(minimum * self._factor))
85
+ self._int_max = int(round(maximum * self._factor))
86
+
87
+ layout = QHBoxLayout(self)
88
+ layout.setContentsMargins(0, 0, 0, 0)
89
+
90
+ self.slider = QSlider(Qt.Orientation.Horizontal, self)
91
+ self.slider.setRange(self._int_min, self._int_max)
92
+ layout.addWidget(self.slider, stretch=1)
93
+
94
+ self.edit = QLineEdit(self)
95
+ self.edit.setFixedWidth(60)
96
+ validator = QDoubleValidator(minimum, maximum, int(abs(np.log10(step))), self)
97
+ validator.setNotation(QDoubleValidator.Notation.StandardNotation)
98
+ self.edit.setValidator(validator)
99
+ layout.addWidget(self.edit)
100
+
101
+ self.setValue(initial)
102
+ self.slider.valueChanged.connect(self._on_slider_changed)
103
+ self.edit.editingFinished.connect(self._on_edit_finished)
104
+
105
+ def _on_slider_changed(self, int_val: int):
106
+ f = int_val / self._factor
107
+ f = min(max(f, self._min), self._max)
108
+ text = f"{f:.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
109
+ self.edit.blockSignals(True)
110
+ self.edit.setText(text)
111
+ self.edit.blockSignals(False)
112
+ self.valueChanged.emit(f)
113
+
114
+ def _on_edit_finished(self):
115
+ txt = self.edit.text().rstrip(self._suffix)
116
+ try:
117
+ f = float(txt)
118
+ except ValueError:
119
+ f = self.slider.value() / self._factor
120
+ f = min(max(f, self._min), self._max)
121
+ int_val = int(round(f * self._factor))
122
+ self.slider.blockSignals(True)
123
+ self.slider.setValue(int_val)
124
+ self.slider.blockSignals(False)
125
+
126
+ def value(self) -> float:
127
+ return self.slider.value() / self._factor
128
+
129
+ def setValue(self, f: float):
130
+ f = min(max(f, self._min), self._max)
131
+ int_val = int(round(f * self._factor))
132
+ self.slider.blockSignals(True)
133
+ self.slider.setValue(int_val)
134
+ self.slider.blockSignals(False)
135
+ s = f"{(int_val / self._factor):.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
136
+ self.edit.setText(s)
137
+ self.valueChanged.emit(int_val / self._factor)
138
+
139
+
140
+ # ============= Convo/Deconvo dialog (DocManager-powered) =====================
141
+ class ConvoDeconvoDialog(QDialog):
142
+ """
143
+ SASpro version: takes a DocManager, no ImageManager dependency.
144
+ """
145
+ def __init__(self, doc_manager, parent=None, doc=None):
146
+ super().__init__(parent)
147
+ self.doc_manager = doc_manager
148
+ self._main = parent # keep a ref to the main window (has _active_doc + signal)
149
+ self._doc_override = doc # ← explicit doc (ROI or full) from the MDI
150
+
151
+ # Only follow global active-doc changes if we *weren't* given a doc
152
+ if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
153
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
154
+
155
+ self.setWindowTitle("Convolution / Deconvolution")
156
+ self.resize(1000, 650)
157
+ self._use_custom_psf = False
158
+ self._custom_psf: Optional[np.ndarray] = None
159
+ self._last_stellar_psf: Optional[np.ndarray] = None
160
+ self._original_image: Optional[np.ndarray] = None
161
+ self._preview_result: Optional[np.ndarray] = None
162
+ self._auto_fit = False
163
+ self._load_original_on_show = True
164
+
165
+ # ── Layout: left controls / right preview
166
+ main_layout = QHBoxLayout(self)
167
+ # Left
168
+ left_panel = QFrame(); left_panel.setFrameShape(QFrame.Shape.StyledPanel); left_panel.setFixedWidth(350)
169
+ left_layout = QVBoxLayout(left_panel); main_layout.addWidget(left_panel)
170
+ # Right
171
+ preview_panel = QFrame(); preview_layout = QVBoxLayout(preview_panel); main_layout.addWidget(preview_panel, stretch=1)
172
+
173
+ # Tabs
174
+ self.tabs = QTabWidget(); left_layout.addWidget(self.tabs)
175
+ self.deconv_param_stack: dict[str, QWidget] = {}
176
+ self._build_convolution_tab()
177
+ self._build_deconvolution_tab()
178
+ self._build_psf_estimator_tab()
179
+ self._build_tv_denoise_tab()
180
+
181
+ # PSF preview chip
182
+ self.conv_psf_label = QLabel(); self.conv_psf_label.setFixedSize(64, 64)
183
+ self.conv_psf_label.setStyleSheet("border: 1px solid #888;")
184
+ left_layout.addWidget(self.conv_psf_label, alignment=Qt.AlignmentFlag.AlignHCenter)
185
+
186
+ # Strength
187
+ self.strength_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=1.0, suffix="")
188
+ srow = QHBoxLayout(); srow.addWidget(QLabel("Strength:")); srow.addWidget(self.strength_slider)
189
+ left_layout.addLayout(srow)
190
+
191
+ # Buttons
192
+ row1 = QHBoxLayout()
193
+ self.preview_btn = QPushButton("Preview")
194
+ self.undo_btn = QPushButton("Undo")
195
+ self.close_btn = QPushButton("Close")
196
+ row1.addWidget(self.preview_btn); row1.addWidget(self.undo_btn)
197
+ left_layout.addLayout(row1)
198
+
199
+ row2 = QHBoxLayout()
200
+ self.push_btn = QPushButton("Push")
201
+ row2.addWidget(self.push_btn); row2.addWidget(self.close_btn)
202
+ left_layout.addLayout(row2)
203
+
204
+ left_layout.addStretch()
205
+ self.rl_status_label = QLabel(""); self.rl_status_label.setStyleSheet("color:#fff;background:#333;padding:4px;")
206
+ self.rl_status_label.setFixedHeight(24)
207
+ left_layout.addWidget(self.rl_status_label)
208
+
209
+ # Zoom & Preview
210
+ zrow = QHBoxLayout(); zrow.addStretch()
211
+ self.zoom_in_btn = QToolButton(); self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in")); self.zoom_in_btn.setToolTip("Zoom In")
212
+ self.zoom_out_btn= QToolButton(); self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out")); self.zoom_out_btn.setToolTip("Zoom Out")
213
+ self.fit_btn = QToolButton(); self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best")); self.fit_btn.setToolTip("Fit to Preview")
214
+ zrow.addWidget(self.zoom_in_btn); zrow.addWidget(self.zoom_out_btn); zrow.addWidget(self.fit_btn)
215
+ preview_layout.addLayout(zrow)
216
+
217
+ self.scene = QGraphicsScene()
218
+ self.view = InteractiveGraphicsView(self.scene)
219
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
220
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
221
+ self.pixmap_item = QGraphicsPixmapItem(); self.scene.addItem(self.pixmap_item)
222
+ preview_layout.addWidget(self.view)
223
+
224
+ # Signals
225
+ self.preview_btn.clicked.connect(self._on_preview)
226
+ self.undo_btn.clicked.connect(self._on_undo)
227
+ self.push_btn.clicked.connect(self._on_push_to_doc)
228
+ self.close_btn.clicked.connect(self.close)
229
+
230
+ self.zoom_in_btn.clicked.connect(self.zoom_in)
231
+ self.zoom_out_btn.clicked.connect(self.zoom_out)
232
+ self.fit_btn.clicked.connect(self._on_fit_clicked)
233
+
234
+ self.tabs.currentChanged.connect(self._update_psf_preview)
235
+ self.deconv_algo_combo.currentTextChanged.connect(self._update_psf_preview)
236
+
237
+ self.sep_run_button.clicked.connect(self._on_run_sep)
238
+ self.sep_use_button.clicked.connect(self._on_use_stellar_psf)
239
+ self.sep_save_button.clicked.connect(self._on_save_stellar_psf)
240
+
241
+ for s in (self.conv_radius_slider, self.conv_shape_slider, self.conv_aspect_slider, self.conv_rotation_slider):
242
+ s.valueChanged.connect(self._update_psf_preview)
243
+ for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
244
+ s.valueChanged.connect(self._update_psf_preview)
245
+
246
+ self._update_psf_preview()
247
+
248
+ def _active_doc(self):
249
+ # 1) If we were given a specific doc (ROI or full), always use that.
250
+ if getattr(self, "_doc_override", None) is not None:
251
+ return self._doc_override
252
+
253
+ # 2) Otherwise fall back to the MDI's notion of active
254
+ if self._main is not None and hasattr(self._main, "_active_doc") and callable(self._main._active_doc):
255
+ try:
256
+ return self._main._active_doc()
257
+ except Exception:
258
+ pass
259
+
260
+ # 3) Last resort: DocManager's active doc
261
+ if hasattr(self.doc_manager, "get_active_document"):
262
+ return self.doc_manager.get_active_document()
263
+
264
+ return None
265
+
266
+
267
+ def _on_active_doc_changed(self, doc):
268
+ # If this dialog is bound to a specific doc (ROI/full), ignore global changes
269
+ if getattr(self, "_doc_override", None) is not None:
270
+ return
271
+
272
+ img = getattr(doc, "image", None)
273
+ self._preview_result = None
274
+ self._original_image = img.copy() if isinstance(img, np.ndarray) else None
275
+ if self._original_image is not None:
276
+ self._auto_fit = True
277
+ self._display_in_view(self._original_image)
278
+
279
+
280
+ # ---------------- DocManager IO helpers ----------------
281
+ def _get_active_image_and_meta(self) -> tuple[Optional[np.ndarray], dict]:
282
+ doc = self._active_doc()
283
+ if doc is None or getattr(doc, "image", None) is None:
284
+ return None, {}
285
+ return doc.image, (getattr(doc, "metadata", {}) or {})
286
+
287
+ # ---------------- Qt life-cycle ----------------
288
+ def showEvent(self, ev):
289
+ super().showEvent(ev)
290
+ self._preview_result = None
291
+ if self._load_original_on_show:
292
+ img, _ = self._get_active_image_and_meta()
293
+ if img is not None:
294
+ self._original_image = img.copy()
295
+ self._auto_fit = True
296
+ self._display_in_view(img)
297
+ self._load_original_on_show = False
298
+ self.conv_psf_label.clear()
299
+ self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
300
+ self._update_psf_preview()
301
+
302
+ def closeEvent(self, ev):
303
+ # Clear state so next open starts fresh
304
+ if hasattr(self.view, "ls_center"):
305
+ self.view.ls_center = None
306
+ self._original_image = None
307
+ self._preview_result = None
308
+ self._last_stellar_psf = None
309
+ self._custom_psf = None
310
+ self._use_custom_psf = False
311
+ self.conv_psf_label.clear() if hasattr(self, "conv_psf_label") else None
312
+ self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
313
+ self.rl_status_label.setText("") if hasattr(self, "rl_status_label") else None
314
+ self.custom_psf_bar.setVisible(False) if hasattr(self, "custom_psf_bar") else None
315
+ super().closeEvent(ev)
316
+
317
+ # ---------------- Build tabs ----------------
318
+ def _build_convolution_tab(self):
319
+ conv_tab = QWidget()
320
+ layout = QVBoxLayout(conv_tab)
321
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
322
+
323
+ self.conv_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=200.0, step=0.1, initial=5.0, suffix=" px")
324
+ form.addRow("Radius:", self.conv_radius_slider)
325
+
326
+ self.conv_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
327
+ form.addRow("Kurtosis (σ):", self.conv_shape_slider)
328
+
329
+ self.conv_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
330
+ form.addRow("Aspect Ratio:", self.conv_aspect_slider)
331
+
332
+ self.conv_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
333
+ form.addRow("Rotation:", self.conv_rotation_slider)
334
+
335
+ layout.addLayout(form); layout.addStretch()
336
+ self.tabs.addTab(conv_tab, "Convolution")
337
+
338
+ def _build_deconvolution_tab(self):
339
+ deconv_tab = QWidget()
340
+ outer_layout = QVBoxLayout(deconv_tab)
341
+
342
+ # Algo row
343
+ algo_layout = QHBoxLayout()
344
+ algo_layout.addWidget(QLabel("Algorithm:"))
345
+ self.deconv_algo_combo = QComboBox()
346
+ self.deconv_algo_combo.addItems(["Richardson-Lucy", "Wiener", "Larson-Sekanina", "Van Cittert"])
347
+ self.deconv_algo_combo.currentTextChanged.connect(self._on_deconv_algo_changed)
348
+ algo_layout.addWidget(self.deconv_algo_combo); algo_layout.addStretch()
349
+ outer_layout.addLayout(algo_layout)
350
+
351
+ # PSF sliders (shared for RL/Wiener)
352
+ self.psf_param_group = QWidget()
353
+ psf_group_layout = QFormLayout(self.psf_param_group)
354
+ psf_group_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
355
+
356
+ self.rl_psf_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=100.0, step=0.1, initial=3.0, suffix=" px")
357
+ psf_group_layout.addRow("PSF Radius:", self.rl_psf_radius_slider)
358
+ self.rl_psf_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
359
+ psf_group_layout.addRow("PSF Kurtosis (σ):", self.rl_psf_shape_slider)
360
+ self.rl_psf_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
361
+ psf_group_layout.addRow("PSF Aspect Ratio:", self.rl_psf_aspect_slider)
362
+ self.rl_psf_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
363
+ psf_group_layout.addRow("PSF Rotation:", self.rl_psf_rotation_slider)
364
+ outer_layout.addWidget(self.psf_param_group)
365
+ self.psf_param_group.setVisible(self.deconv_algo_combo.currentText() in ("Richardson-Lucy", "Wiener"))
366
+
367
+ # “Using Stellar PSF” bar
368
+ self.custom_psf_bar = QWidget()
369
+ bar_layout = QHBoxLayout(self.custom_psf_bar); bar_layout.setContentsMargins(0, 0, 0, 0); bar_layout.setSpacing(4)
370
+ self.rl_custom_label = QLabel("Using Stellar PSF")
371
+ self.rl_custom_label.setStyleSheet("color:#fff;background-color:#007acc;padding:2px;")
372
+ self.rl_custom_label.setVisible(False)
373
+ self.rl_disable_custom_btn = QPushButton("Disable Stellar PSF")
374
+ self.rl_disable_custom_btn.setToolTip("Revert to PSF sliders")
375
+ self.rl_disable_custom_btn.setVisible(False)
376
+ self.rl_disable_custom_btn.clicked.connect(self._clear_custom_psf_flag)
377
+ bar_layout.addWidget(self.rl_custom_label); bar_layout.addWidget(self.rl_disable_custom_btn); bar_layout.addStretch()
378
+ outer_layout.addWidget(self.custom_psf_bar)
379
+ self.custom_psf_bar.setVisible(False)
380
+
381
+ # Stacked parameter panels
382
+ self.deconv_param_stack.clear()
383
+ self.deconv_stack_container = QWidget(); self.deconv_stack_layout = QVBoxLayout(self.deconv_stack_container)
384
+
385
+ # RL
386
+ rl_widget = QWidget()
387
+ rl_form = QFormLayout(rl_widget); rl_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
388
+ self.rl_iterations_slider = FloatSliderWithEdit(minimum=1.0, maximum=100.0, step=1.0, initial=30.0, suffix="")
389
+ rl_form.addRow("Iterations:", self.rl_iterations_slider)
390
+ self.rl_reg_combo = QComboBox(); self.rl_reg_combo.addItems(["None (Plain R–L)", "Tikhonov (L2)", "Total Variation (TV)"])
391
+ rl_form.addRow("Regularization:", self.rl_reg_combo)
392
+ self.rl_clip_checkbox = QCheckBox("Enable de‐ring"); self.rl_clip_checkbox.setChecked(True)
393
+ rl_form.addRow("", self.rl_clip_checkbox)
394
+ self.rl_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.rl_luminance_only_checkbox.setChecked(True)
395
+ self.rl_luminance_only_checkbox.setToolTip("If checked and the image is color, RL runs only on the L* channel.")
396
+ rl_form.addRow("", self.rl_luminance_only_checkbox)
397
+ rl_widget.setLayout(rl_form)
398
+ self.deconv_param_stack["Richardson-Lucy"] = rl_widget
399
+
400
+ # Wiener
401
+ wiener_widget = QWidget(); wiener_layout = QVBoxLayout(wiener_widget)
402
+ wiener_form = QFormLayout(); wiener_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
403
+ self.wiener_nsr_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.001, initial=0.01, suffix="")
404
+ wiener_form.addRow("Noise/Signal (λ):", self.wiener_nsr_slider)
405
+ self.wiener_reg_combo = QComboBox(); self.wiener_reg_combo.addItems(["None (Classical Wiener)", "Tikhonov (L2)"])
406
+ wiener_form.addRow("Regularization:", self.wiener_reg_combo)
407
+ self.wiener_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.wiener_luminance_only_checkbox.setChecked(True)
408
+ self.wiener_luminance_only_checkbox.setToolTip("If checked and the image is color, Wiener runs only on the L* channel.")
409
+ wiener_form.addRow("", self.wiener_luminance_only_checkbox)
410
+ self.wiener_dering_checkbox = QCheckBox("Enable de-ring"); self.wiener_dering_checkbox.setChecked(True)
411
+ self.wiener_dering_checkbox.setToolTip("Applies a single bilateral pass after Wiener deconvolution")
412
+ wiener_form.addRow("", self.wiener_dering_checkbox)
413
+ wiener_layout.addLayout(wiener_form)
414
+ self.deconv_param_stack["Wiener"] = wiener_widget
415
+
416
+ # Larson–Sekanina
417
+ ls_widget = QWidget(); ls_form = QFormLayout(ls_widget); ls_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
418
+ self.ls_radial_slider = FloatSliderWithEdit(minimum=0.0, maximum=50.0, step=0.1, initial=0.0, suffix=" px")
419
+ self.ls_angular_slider = FloatSliderWithEdit(minimum=0.1, maximum=360.0, step=0.1, initial=1.0, suffix="°")
420
+ self.ls_operator_combo = QComboBox(); self.ls_operator_combo.addItems(["Divide", "Subtract"])
421
+ self.ls_blend_combo = QComboBox(); self.ls_blend_combo.addItems(["SoftLight", "Screen"])
422
+ ls_form.addRow("Radial Step (px):", self.ls_radial_slider)
423
+ ls_form.addRow("Angular Step (°):", self.ls_angular_slider)
424
+ ls_form.addRow("LS Operator:", self.ls_operator_combo)
425
+ ls_form.addRow("Blend Mode:", self.ls_blend_combo)
426
+ self.ls_operator_combo.currentTextChanged.connect(self._on_ls_operator_changed)
427
+ self.deconv_param_stack["Larson-Sekanina"] = ls_widget
428
+
429
+ # Van Cittert
430
+ vc_widget = QWidget(); vc_form = QFormLayout(vc_widget); vc_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
431
+ self.vc_iterations_slider = FloatSliderWithEdit(minimum=1, maximum=1000, step=1, initial=10, suffix="")
432
+ self.vc_relax_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.0, suffix="")
433
+ vc_form.addRow("Iterations:", self.vc_iterations_slider)
434
+ vc_form.addRow("Relaxation (0–1):", self.vc_relax_slider)
435
+ self.deconv_param_stack["Van Cittert"] = vc_widget
436
+
437
+ # Add all panels (hidden initially)
438
+ for widget in self.deconv_param_stack.values():
439
+ widget.setVisible(False)
440
+ self.deconv_stack_layout.addWidget(widget)
441
+
442
+ first_algo = self.deconv_algo_combo.currentText()
443
+ if first_algo in self.deconv_param_stack:
444
+ self.deconv_param_stack[first_algo].setVisible(True)
445
+
446
+ outer_layout.addWidget(self.deconv_stack_container)
447
+ outer_layout.addStretch()
448
+ self.tabs.addTab(deconv_tab, "Deconvolution")
449
+
450
+ # Clear “custom PSF” if sliders change
451
+ for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
452
+ s.valueChanged.connect(self._clear_custom_psf_flag)
453
+
454
+ def _build_psf_estimator_tab(self):
455
+ psf_tab = QWidget(); layout = QVBoxLayout(psf_tab)
456
+
457
+ h_image = QHBoxLayout()
458
+ h_image.addWidget(QLabel("Image for PSF Estimate:"))
459
+ self.sep_image_label = QLabel("(Current Active Image)")
460
+ h_image.addWidget(self.sep_image_label); layout.addLayout(h_image)
461
+
462
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
463
+ self.sep_threshold_slider = FloatSliderWithEdit(minimum=1.0, maximum=5.0, step=0.1, initial=2.5, suffix=" σ")
464
+ form.addRow("Detection σ:", self.sep_threshold_slider)
465
+ self.sep_minarea_spin = CustomSpinBox(minimum=1, maximum=100, initial=5, step=1)
466
+ form.addRow("Min Area (px²):", self.sep_minarea_spin)
467
+ self.sep_sat_slider = FloatSliderWithEdit(minimum=1000, maximum=100000, step=500, initial=50000, suffix=" ADU")
468
+ form.addRow("Saturation Cutoff:", self.sep_sat_slider)
469
+ self.sep_maxstars_spin = CustomSpinBox(minimum=1, maximum=500, initial=50, step=1)
470
+ form.addRow("Max Stars:", self.sep_maxstars_spin)
471
+ self.sep_stamp_spin = CustomSpinBox(minimum=5, maximum=50, initial=15, step=1)
472
+ form.addRow("Half‐Width (px):", self.sep_stamp_spin)
473
+ layout.addLayout(form)
474
+
475
+ h_buttons = QHBoxLayout()
476
+ self.sep_run_button = QPushButton("Run SEP Extraction")
477
+ self.sep_save_button = QPushButton("Save PSF…")
478
+ self.sep_use_button = QPushButton("Use as Current PSF")
479
+ h_buttons.addWidget(self.sep_run_button); h_buttons.addWidget(self.sep_save_button); h_buttons.addWidget(self.sep_use_button)
480
+ layout.addLayout(h_buttons)
481
+
482
+ self.psf_estimate_title = QLabel("Estimated PSF (64×64):")
483
+ layout.addWidget(self.psf_estimate_title, alignment=Qt.AlignmentFlag.AlignLeft)
484
+ self.sep_psf_preview = QLabel(); self.sep_psf_preview.setFixedSize(64, 64)
485
+ self.sep_psf_preview.setStyleSheet("border: 1px solid #888;")
486
+ layout.addWidget(self.sep_psf_preview, alignment=Qt.AlignmentFlag.AlignHCenter)
487
+
488
+ layout.addStretch()
489
+ self.tabs.addTab(psf_tab, "PSF Estimator")
490
+
491
+ def _build_tv_denoise_tab(self):
492
+ tvd_tab = QWidget(); layout = QVBoxLayout(tvd_tab)
493
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
494
+ self.tv_weight_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.1, suffix="")
495
+ form.addRow("TV Weight:", self.tv_weight_slider)
496
+ self.tv_iter_slider = FloatSliderWithEdit(minimum=1, maximum=100, step=1, initial=10, suffix="")
497
+ form.addRow("Max Iterations:", self.tv_iter_slider)
498
+ self.tv_multichannel_checkbox = QCheckBox("Multi‐channel"); self.tv_multichannel_checkbox.setChecked(True)
499
+ self.tv_multichannel_checkbox.setToolTip("If checked and the image is color, run TV on all channels jointly")
500
+ form.addRow("", self.tv_multichannel_checkbox)
501
+ layout.addLayout(form); layout.addStretch()
502
+ self.tabs.addTab(tvd_tab, "TV Denoise")
503
+
504
+ # ---------------- UI reactions ----------------
505
+ def _on_deconv_algo_changed(self, selected: str):
506
+ for w in self.deconv_param_stack.values():
507
+ w.setVisible(False)
508
+ if selected in self.deconv_param_stack:
509
+ self.deconv_param_stack[selected].setVisible(True)
510
+
511
+ # Show/hide PSF sliders & bar
512
+ on_psf_algo = selected in ("Richardson-Lucy", "Wiener")
513
+ self.psf_param_group.setVisible(on_psf_algo)
514
+ self.custom_psf_bar.setVisible(on_psf_algo and self._use_custom_psf and (self._custom_psf is not None))
515
+
516
+ def _on_ls_operator_changed(self, op_text: str):
517
+ self.ls_blend_combo.setCurrentText("SoftLight" if op_text == "Divide" else "Screen")
518
+
519
+ def _make_psf_pixmap(self, radius, kurtosis, aspect, rotation_deg) -> QPixmap:
520
+ psf = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation_deg)
521
+ h, w = psf.shape
522
+ img8 = ((psf / psf.max()) * 255.0).astype(np.uint8) if psf.max() > 0 else psf.astype(np.uint8)
523
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
524
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
525
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
526
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
527
+ return final
528
+
529
+ def _make_stellar_psf_pixmap(self, psf_kernel: np.ndarray) -> QPixmap:
530
+ h, w = psf_kernel.shape
531
+ img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
532
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
533
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
534
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
535
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
536
+ return final
537
+
538
+ def _update_psf_preview(self):
539
+ current_tab = self.tabs.tabText(self.tabs.currentIndex())
540
+ algo = getattr(self, "deconv_algo_combo", None)
541
+ algo_text = algo.currentText() if algo is not None else ""
542
+
543
+ if current_tab == "Convolution":
544
+ r, k, a, rot = (self.conv_radius_slider.value(), self.conv_shape_slider.value(),
545
+ self.conv_aspect_slider.value(), self.conv_rotation_slider.value())
546
+ self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
547
+ elif current_tab == "Deconvolution" and algo_text in ("Richardson-Lucy", "Wiener"):
548
+ if self._use_custom_psf and (self._custom_psf is not None):
549
+ self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
550
+ else:
551
+ r, k, a, rot = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
552
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
553
+ self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
554
+ else:
555
+ self.conv_psf_label.clear()
556
+
557
+ # ---------------- Mask helper (from active document) ----------------
558
+ def _active_mask_array_from_active_doc(self) -> np.ndarray | None:
559
+ """
560
+ Read the active mask from the active document:
561
+ doc.active_mask_id -> doc.masks[mid].data
562
+ Return a 2-D float32 mask in [0..1], or None.
563
+ """
564
+ try:
565
+ doc = self._active_doc()
566
+ if doc is None:
567
+ return None
568
+ mid = getattr(doc, "active_mask_id", None)
569
+ if not mid:
570
+ return None
571
+ masks = getattr(doc, "masks", {}) or {}
572
+ layer = masks.get(mid)
573
+ data = getattr(layer, "data", None) if layer is not None else None
574
+ if data is None:
575
+ return None
576
+
577
+ m = np.asarray(data)
578
+ # If RGB(A) mask, convert to gray
579
+ if m.ndim == 3:
580
+ if cv2 is not None:
581
+ m = cv2.cvtColor(m, cv2.COLOR_BGR2GRAY)
582
+ else:
583
+ m = m.mean(axis=2)
584
+
585
+ m = m.astype(np.float32, copy=False)
586
+ if m.max() > 1.0:
587
+ m /= 255.0
588
+ return np.clip(m, 0.0, 1.0)
589
+ except Exception:
590
+ return None
591
+
592
+
593
+ def _resize_mask_nearest(self, mask2d: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
594
+ """Resize 2-D mask to (H, W) using nearest neighbor."""
595
+ H, W = target_hw
596
+ if mask2d.shape == (H, W):
597
+ return mask2d
598
+ if cv2 is not None:
599
+ return cv2.resize(mask2d, (W, H), interpolation=cv2.INTER_NEAREST).astype(np.float32, copy=False)
600
+ # NumPy fallback NN
601
+ yi = (np.linspace(0, mask2d.shape[0] - 1, H)).astype(np.int32)
602
+ xi = (np.linspace(0, mask2d.shape[1] - 1, W)).astype(np.int32)
603
+ return mask2d[yi][:, xi].astype(np.float32, copy=False)
604
+
605
+
606
+ def _get_active_mask_from_doc(self, target_shape) -> np.ndarray | None:
607
+ """
608
+ Return mask resized to `target_shape`; broadcast to channels if needed.
609
+ """
610
+ m = self._active_mask_array_from_active_doc()
611
+ if m is None:
612
+ return None
613
+
614
+ H, W = target_shape[:2]
615
+ m = self._resize_mask_nearest(m, (H, W))
616
+
617
+ # If the processed image is RGB, expand mask to 3 channels
618
+ if len(target_shape) == 3 and m.ndim == 2:
619
+ m = np.repeat(m[:, :, None], target_shape[2], axis=2)
620
+
621
+ return np.clip(m.astype(np.float32, copy=False), 0.0, 1.0)
622
+
623
+ # ---------------- Core actions ----------------
624
+ def _on_preview(self):
625
+ doc = self._active_doc()
626
+ if hasattr(self.doc_manager, "set_active_document"):
627
+ self.doc_manager.set_active_document(doc)
628
+ img, _ = self._get_active_image_and_meta()
629
+ if img is None:
630
+ self._show_message("No active image to process.")
631
+ return
632
+
633
+ if self._original_image is None:
634
+ self._original_image = img.copy()
635
+
636
+ current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
637
+
638
+ if current_tab_name == "Convolution":
639
+ radius = self.conv_radius_slider.value()
640
+ kurtosis= self.conv_shape_slider.value()
641
+ aspect = self.conv_aspect_slider.value()
642
+ rotation= self.conv_rotation_slider.value()
643
+ psf_kernel = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation).astype(np.float32)
644
+ processed = self._convolve_color(img, psf_kernel)
645
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
646
+
647
+ elif current_tab_name == "Deconvolution":
648
+ algo = self.deconv_algo_combo.currentText()
649
+ if algo == "Richardson-Lucy":
650
+ iters = int(round(self.rl_iterations_slider.value()))
651
+ reg_type = self.rl_reg_combo.currentText()
652
+ pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
653
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
654
+ psf_kernel = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
655
+ clip_flag = self.rl_clip_checkbox.isChecked()
656
+
657
+ if self.rl_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
658
+ lab = rgb2lab(img.astype(np.float32))
659
+ L = (lab[:, :, 0] / 100.0).astype(np.float32)
660
+ deconv_L = self._richardson_lucy_color(L, psf_kernel, iterations=iters, reg_type=reg_type, clip_flag=clip_flag)
661
+ lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
662
+ rgb_deconv = lab2rgb(lab.astype(np.float32))
663
+ processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
664
+ else:
665
+ processed = self._richardson_lucy_color(img.astype(np.float32), psf_kernel, iters, reg_type, clip_flag)
666
+
667
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
668
+
669
+ elif algo == "Wiener":
670
+ if self._use_custom_psf and (self._custom_psf is not None):
671
+ small_psf = self._custom_psf.astype(np.float32)
672
+ else:
673
+ pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
674
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
675
+ small_psf = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
676
+
677
+ nsr = self.wiener_nsr_slider.value()
678
+ reg_type = "Wiener" if self.wiener_reg_combo.currentText() == "None (Classical Wiener)" else "Tikhonov"
679
+ do_dering = self.wiener_dering_checkbox.isChecked()
680
+
681
+ if self.wiener_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
682
+ lab = rgb2lab(img.astype(np.float32))
683
+ L = (lab[:, :, 0] / 100.0).astype(np.float32)
684
+ deconv_L = self._wiener_deconv_with_kernel(L, small_psf, nsr, reg_type, do_dering)
685
+ lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
686
+ rgb_deconv = lab2rgb(lab.astype(np.float32))
687
+ processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
688
+ else:
689
+ processed = self._wiener_deconv_with_kernel(img, small_psf, nsr, reg_type, do_dering)
690
+ processed = np.clip(processed, 0.0, 1.0)
691
+
692
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
693
+
694
+ elif algo == "Larson-Sekanina":
695
+ if not hasattr(self.view, "ls_center") or self.view.ls_center is None:
696
+ QMessageBox.information(self, "Hold Shift + Click",
697
+ "To choose a Larson–Sekanina center, hold Shift and click on the preview.")
698
+ return
699
+
700
+ center = self.view.ls_center
701
+ rstep = self.ls_radial_slider.value()
702
+ astep = self.ls_angular_slider.value()
703
+ operator = self.ls_operator_combo.currentText()
704
+ blend_mode = self.ls_blend_combo.currentText()
705
+
706
+ B = larson_sekanina(image=img, center=center, radial_step=rstep, angular_step_deg=astep, operator=operator)
707
+ A = img
708
+ if A.ndim == 3 and A.shape[2] == 3:
709
+ B_rgb, A_rgb = np.repeat(B[:, :, None], 3, axis=2), A
710
+ else:
711
+ B_rgb, A_rgb = B[..., None], A[..., None]
712
+ C = (A_rgb + B_rgb - (A_rgb * B_rgb)) if blend_mode == "Screen" else ((1 - 2 * B_rgb) * (A_rgb**2) + 2 * B_rgb * A_rgb)
713
+ processed = np.clip(C, 0.0, 1.0)
714
+ processed = processed[..., 0] if img.ndim == 2 else processed
715
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
716
+
717
+ elif algo == "Van Cittert":
718
+ iters2 = self.vc_iterations_slider.value()
719
+ relax = self.vc_relax_slider.value()
720
+ if img.ndim == 3 and img.shape[2] == 3:
721
+ chans = [van_cittert_deconv(img[:, :, c], iters2, relax) for c in range(3)]
722
+ processed = np.stack(chans, axis=2)
723
+ else:
724
+ processed = van_cittert_deconv(img, iters2, relax)
725
+ processed = np.clip(processed, 0.0, 1.0)
726
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
727
+
728
+ else:
729
+ self._show_message("Unknown deconvolution algorithm")
730
+ return
731
+
732
+ elif current_tab_name == "TV Denoise":
733
+ weight = self.tv_weight_slider.value()
734
+ max_iter = int(self.tv_iter_slider.value())
735
+ multichannel = self.tv_multichannel_checkbox.isChecked()
736
+
737
+ if img.ndim == 3 and multichannel:
738
+ processed = denoise_tv_chambolle(img.astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=-1).astype(np.float32)
739
+ else:
740
+ if img.ndim == 3 and img.shape[2] == 3:
741
+ channels_out = [
742
+ denoise_tv_chambolle(img[:, :, c].astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
743
+ for c in range(3)
744
+ ]
745
+ processed = np.stack(channels_out, axis=2)
746
+ else:
747
+ gray = img.astype(np.float32) if img.ndim == 2 else img
748
+ processed = denoise_tv_chambolle(gray, weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
749
+
750
+ processed = np.clip(processed, 0.0, 1.0)
751
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
752
+
753
+ else:
754
+ self._show_message("Unknown tab")
755
+ return
756
+
757
+ # Masked blend if an active mask exists
758
+ mask = self._get_active_mask_from_doc(processed.shape)
759
+ if mask is not None:
760
+ if processed.ndim == 3 and mask.ndim == 2:
761
+ mask = mask[..., None]
762
+ final_result = np.clip(processed * mask + self._original_image * (1.0 - mask), 0.0, 1.0)
763
+ else:
764
+ final_result = processed
765
+
766
+ self._preview_result = final_result
767
+ self._display_in_view(final_result)
768
+
769
+ def _on_undo(self):
770
+ if self._original_image is not None:
771
+ self._preview_result = None
772
+ self._display_in_view(self._original_image)
773
+ else:
774
+ self._show_message("Nothing to undo.")
775
+
776
+ def _build_replay_preset(self) -> dict | None:
777
+ """
778
+ Capture the current UI state as a preset-style dict so Replay Last Action
779
+ can re-run the same Convo/Deconvo/TV operation on another document.
780
+ Matches the schema used by ConvoPresetDialog.result_dict().
781
+ """
782
+ current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
783
+ strength = float(self.strength_slider.value())
784
+
785
+ # ── Convolution tab ─────────────────────────────────────────────
786
+ if current_tab_name == "Convolution":
787
+ return {
788
+ "op": "convolution",
789
+ "radius": float(self.conv_radius_slider.value()),
790
+ "kurtosis": float(self.conv_shape_slider.value()),
791
+ "aspect": float(self.conv_aspect_slider.value()),
792
+ "rotation": float(self.conv_rotation_slider.value()),
793
+ "strength": strength,
794
+ }
795
+
796
+ # ── Deconvolution tab ───────────────────────────────────────────
797
+ if current_tab_name == "Deconvolution":
798
+ algo = self.deconv_algo_combo.currentText()
799
+ p: dict[str, object] = {
800
+ "op": "deconvolution",
801
+ "algo": algo,
802
+ # RL/Wiener PSF params
803
+ "psf_radius": float(self.rl_psf_radius_slider.value()),
804
+ "psf_kurtosis": float(self.rl_psf_shape_slider.value()),
805
+ "psf_aspect": float(self.rl_psf_aspect_slider.value()),
806
+ "psf_rotation": float(self.rl_psf_rotation_slider.value()),
807
+ # RL options
808
+ "rl_iter": float(self.rl_iterations_slider.value()),
809
+ "rl_reg": self.rl_reg_combo.currentText(),
810
+ "rl_dering": bool(self.rl_clip_checkbox.isChecked()),
811
+ "luminance_only": bool(self.rl_luminance_only_checkbox.isChecked()),
812
+ # Wiener options
813
+ "wiener_nsr": float(self.wiener_nsr_slider.value()),
814
+ "wiener_reg": self.wiener_reg_combo.currentText(),
815
+ "wiener_dering": bool(self.wiener_dering_checkbox.isChecked()),
816
+ # Larson–Sekanina options
817
+ "ls_rstep": float(self.ls_radial_slider.value()),
818
+ "ls_astep": float(self.ls_angular_slider.value()),
819
+ "ls_operator": self.ls_operator_combo.currentText(),
820
+ "ls_blend": self.ls_blend_combo.currentText(),
821
+ # Van Cittert options
822
+ "vc_iter": float(self.vc_iterations_slider.value()),
823
+ "vc_relax": float(self.vc_relax_slider.value()),
824
+ # Global blend strength
825
+ "strength": strength,
826
+ }
827
+
828
+ # If user actually picked an LS center, preserve it for replay.
829
+ # Interactive view stores (x,y). apply_convo_via_preset expects [cx, cy].
830
+ if hasattr(self.view, "ls_center") and self.view.ls_center is not None:
831
+ cx, cy = self.view.ls_center # (x, y)
832
+ p["center"] = [float(cx), float(cy)]
833
+
834
+ return p
835
+
836
+ # ── TV Denoise tab ──────────────────────────────────────────────
837
+ if current_tab_name == "TV Denoise":
838
+ return {
839
+ "op": "tv",
840
+ "tv_weight": float(self.tv_weight_slider.value()),
841
+ "tv_iter": int(round(float(self.tv_iter_slider.value()))),
842
+ "tv_multichannel": bool(self.tv_multichannel_checkbox.isChecked()),
843
+ "strength": strength,
844
+ }
845
+
846
+ return None
847
+
848
+
849
+ def _on_push_to_doc(self):
850
+ doc = self._active_doc()
851
+ if doc is None:
852
+ QMessageBox.warning(self, "No Document", "No active document to push into.")
853
+ return
854
+
855
+ if self._preview_result is None:
856
+ QMessageBox.warning(self, "No Preview", "No preview to push. Click Preview first.")
857
+ return
858
+
859
+ # Grab current metadata from this specific doc
860
+ _, meta = self._get_active_image_and_meta()
861
+ new_meta = dict(meta)
862
+ new_meta["source"] = "ConvoDeconvo"
863
+
864
+ try:
865
+ if hasattr(doc, "apply_edit"):
866
+ # ⭐ Preferred: update this exact Document (ROI or full) so all views update
867
+ doc.apply_edit(
868
+ self._preview_result.copy(),
869
+ metadata=new_meta,
870
+ step_name="Convo/Deconvo",
871
+ )
872
+ else:
873
+ # Fallback for older paths: go through DocManager active-doc API
874
+ if hasattr(self.doc_manager, "set_active_document"):
875
+ self.doc_manager.set_active_document(doc)
876
+ self.doc_manager.update_active_document(
877
+ self._preview_result.copy(),
878
+ metadata=new_meta,
879
+ step_name="Convo/Deconvo",
880
+ )
881
+ except Exception as e:
882
+ QMessageBox.critical(self, "Push failed", str(e))
883
+ return
884
+
885
+ # Make the pushed image the new baseline so you can iterate
886
+ img_after, _ = self._get_active_image_and_meta()
887
+ if img_after is not None:
888
+ self._original_image = img_after.copy()
889
+ self._preview_result = None
890
+ self._display_in_view(self._original_image)
891
+
892
+ # 🔴 Replay wiring (unchanged, just moved under try/except)
893
+ try:
894
+ if self._main is not None:
895
+ preset = self._build_replay_preset()
896
+ if preset:
897
+ self._main._last_headless_command = {
898
+ "cid": "convo",
899
+ "preset": preset,
900
+ }
901
+ if hasattr(self._main, "_log"):
902
+ op = preset.get("op", "convolution")
903
+ self._main._log(f"Replay: stored Convo/Deconvo ({op}) from dialog.")
904
+ except Exception:
905
+ # Replay wiring should never break the actual push
906
+ pass
907
+
908
+ QMessageBox.information(self, "Pushed", "Result committed to the active document.")
909
+
910
+
911
+
912
+ # ---------------- Utils ----------------
913
+ def _show_message(self, text: str):
914
+ self.scene.clear()
915
+ self.pixmap_item = QGraphicsPixmapItem()
916
+ self.scene.addItem(self.pixmap_item)
917
+ self.view.resetTransform()
918
+ temp_label = QLabel(text); temp_label.setStyleSheet("color: white; background-color: #222;"); temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
919
+ pixmap = temp_label.grab()
920
+ self.pixmap_item.setPixmap(pixmap)
921
+ self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
922
+
923
+ def _convolve_color(self, image: np.ndarray, psf_kernel: np.ndarray) -> np.ndarray:
924
+ """
925
+ Convolve image with psf_kernel using reflect padding so we don't get
926
+ dark borders from zero–padding. Returns same H×W (and channels) as input.
927
+ """
928
+ if image is None or psf_kernel is None:
929
+ return image
930
+
931
+ img = image.astype(np.float32, copy=False)
932
+ kh, kw = psf_kernel.shape
933
+ pad_y = kh // 2
934
+ pad_x = kw // 2
935
+
936
+ def _conv_single_channel(im2d: np.ndarray) -> np.ndarray:
937
+ if pad_y or pad_x:
938
+ padded = np.pad(
939
+ im2d,
940
+ ((pad_y, pad_y), (pad_x, pad_x)),
941
+ mode="reflect"
942
+ )
943
+ else:
944
+ padded = im2d
945
+
946
+ conv_full = fftconvolve(padded, psf_kernel, mode="same")
947
+
948
+ if pad_y or pad_x:
949
+ conv = conv_full[pad_y:-pad_y or None, pad_x:-pad_x or None]
950
+ else:
951
+ conv = conv_full
952
+
953
+ return conv.astype(np.float32)
954
+
955
+ if img.ndim == 2:
956
+ out = _conv_single_channel(img)
957
+ elif img.ndim == 3 and img.shape[2] == 3:
958
+ chans = [_conv_single_channel(img[:, :, c]) for c in range(3)]
959
+ out = np.stack(chans, axis=2)
960
+ else:
961
+ # Unknown layout; just return a copy to be safe
962
+ return img.copy()
963
+
964
+ # PSF is normalized, but clamp just in case of numeric noise
965
+ return np.clip(out, 0.0, 1.0)
966
+
967
+
968
+ def _richardson_lucy_color(self, image: np.ndarray, psf_kernel: np.ndarray, iterations: int,
969
+ reg_type: str = "None (Plain R–L)", clip_flag: bool = True) -> np.ndarray:
970
+ iters = int(round(iterations))
971
+ psf = psf_kernel.astype(np.float32)
972
+
973
+ def _deconv_2d_parallel(gray: np.ndarray) -> np.ndarray:
974
+ H, W = gray.shape
975
+ psf_h, psf_w = psf.shape
976
+ half_psf = max(psf_h, psf_w) // 2
977
+ extra = 15
978
+ pad = half_psf + extra
979
+ overlap = pad
980
+
981
+ n_cores = min((os.cpu_count() or 1), H)
982
+ tile_h = math.ceil(H / n_cores)
983
+ tile_ranges = []
984
+ for i in range(n_cores):
985
+ y0 = i * tile_h; y1 = min((i + 1) * tile_h, H)
986
+ if y0 >= H: break
987
+ tile_ranges.append((y0, y1))
988
+
989
+ accum_image = np.zeros((H, W), dtype=np.float32)
990
+ accum_weight = np.zeros((H, W), dtype=np.float32)
991
+
992
+ def _build_vertical_ramp(L: int, ov: int) -> np.ndarray:
993
+ w = np.ones(L, dtype=np.float32)
994
+ if ov <= 0: return w
995
+ if 2 * ov >= L:
996
+ for i in range(L):
997
+ w[i] = 1.0 - abs((i - (L - 1) / 2) / ((L - 1) / 2))
998
+ return w
999
+ for i in range(ov):
1000
+ w[i] = (i + 1) / float(ov)
1001
+ w[L - 1 - i] = (i + 1) / float(ov)
1002
+ return w
1003
+
1004
+ tile_inputs = []
1005
+ for idx, (y0, y1) in enumerate(tile_ranges):
1006
+ y0_ext = max(0, y0 - overlap); y1_ext = min(H, y1 + overlap)
1007
+ core_tile = gray[y0_ext:y1_ext, :]
1008
+ padded = np.pad(core_tile, ((pad, pad), (pad, pad)), mode="reflect")
1009
+ L_ext = y1_ext - y0_ext
1010
+ tile_inputs.append((idx, padded, psf, iters, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext))
1011
+
1012
+ results = [None] * len(tile_inputs)
1013
+ max_workers = min(len(tile_inputs), os.cpu_count() or 1)
1014
+ if max_workers < 1:
1015
+ max_workers = 1
1016
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1017
+ for tile_index, deconv_ext in executor.map(_rl_tile_process_reg, tile_inputs):
1018
+ results[tile_index] = deconv_ext
1019
+
1020
+ for idx, (y0, y1) in enumerate(tile_ranges):
1021
+ (_, _, _, _, _, _, _, y0_ext, y1_ext, L_ext) = tile_inputs[idx]
1022
+ deconv_ext = results[idx]
1023
+ w = _build_vertical_ramp(L_ext, overlap)
1024
+ w2d = np.broadcast_to(w[:, None], (L_ext, W)).astype(np.float32)
1025
+ accum_image[y0_ext:y1_ext, :] += deconv_ext * w2d
1026
+ accum_weight[y0_ext:y1_ext, :] += w2d
1027
+
1028
+ final_deconv = np.zeros_like(accum_image, dtype=np.float32)
1029
+ nz = accum_weight > 0
1030
+ final_deconv[nz] = accum_image[nz] / accum_weight[nz]
1031
+ return final_deconv
1032
+
1033
+ if image.ndim == 2:
1034
+ self.rl_status_label.setText(f"Running RL for {iters} iterations"); QApplication.processEvents()
1035
+ deconv = _deconv_2d_parallel(image.astype(np.float32))
1036
+ self.rl_status_label.setText(""); QApplication.processEvents()
1037
+ return np.clip(deconv, 0.0, 1.0)
1038
+ elif image.ndim == 3 and image.shape[2] == 3:
1039
+ outs = []
1040
+ for c in range(3):
1041
+ self.rl_status_label.setText(f"Running RL on ch {c+1} for {iters} iterations"); QApplication.processEvents()
1042
+ outs.append(np.clip(_deconv_2d_parallel(image[:, :, c].astype(np.float32)), 0.0, 1.0))
1043
+ self.rl_status_label.setText(""); QApplication.processEvents()
1044
+ return np.stack(outs, axis=2)
1045
+ else:
1046
+ return image.copy()
1047
+
1048
+ def _wiener_deconv_with_kernel(self, image: np.ndarray, small_psf: np.ndarray, nsr: float,
1049
+ reg_type: str, do_dering: bool) -> np.ndarray:
1050
+ def _deconv_gray(im2d: np.ndarray, do_dering_flag: bool) -> np.ndarray:
1051
+ H, W = im2d.shape
1052
+ psf_h, psf_w = small_psf.shape
1053
+ Hpsf = np.zeros((H, W), dtype=np.float32)
1054
+ cy, cx = H // 2, W // 2
1055
+ y0 = cy - psf_h // 2; x0 = cx - psf_w // 2
1056
+ Hpsf[y0:y0+psf_h, x0:x0+psf_w] = small_psf
1057
+ H_f = fft2(ifftshift(Hpsf)); H_f_conj = np.conj(H_f); mag2 = np.abs(H_f) ** 2
1058
+ K = nsr * nsr if reg_type == "Tikhonov" else nsr
1059
+ Wf = H_f_conj / (mag2 + K)
1060
+ deconv = np.real(ifft2(Wf * fft2(im2d))).astype(np.float32)
1061
+ if do_dering_flag:
1062
+ deconv = denoise_bilateral(deconv, sigma_color=0.08, sigma_spatial=1)
1063
+ return deconv.clip(0.0, 1.0)
1064
+
1065
+ if image.ndim == 2:
1066
+ return _deconv_gray(image.astype(np.float32), do_dering)
1067
+ elif image.ndim == 3 and image.shape[2] == 3:
1068
+ return np.stack([_deconv_gray(image[:, :, c].astype(np.float32), do_dering) for c in range(3)], axis=2)
1069
+ else:
1070
+ return image.copy()
1071
+
1072
+ def _display_in_view(self, array: np.ndarray):
1073
+ arr = array.copy()
1074
+ if arr.dtype in (np.float32, np.float64):
1075
+ arr = np.clip(arr, 0.0, 1.0); arr8 = (arr * 255).astype(np.uint8)
1076
+ elif arr.dtype == np.uint16:
1077
+ arr8 = (np.clip(arr, 0, 65535) // 257).astype(np.uint8)
1078
+ elif arr.dtype == np.uint8:
1079
+ arr8 = arr
1080
+ else:
1081
+ mn, mx = arr.min(), arr.max()
1082
+ arr8 = ((arr - mn) / (mx - mn) * 255).astype(np.uint8) if mx > mn else np.zeros_like(arr, dtype=np.uint8)
1083
+
1084
+ h, w = arr8.shape[:2]
1085
+ if arr8.ndim == 2:
1086
+ fmt = QImage.Format.Format_Grayscale8; bytespp = w
1087
+ else:
1088
+ fmt = QImage.Format.Format_RGB888; bytespp = 3 * w
1089
+
1090
+ qimg = QImage(arr8.data, w, h, bytespp, fmt)
1091
+ self.pixmap_item.setPixmap(QPixmap.fromImage(qimg))
1092
+ self.scene.setSceneRect(0, 0, w, h)
1093
+
1094
+ if self._auto_fit:
1095
+ self.view.resetTransform()
1096
+ self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1097
+ self._auto_fit = False
1098
+
1099
+ def zoom_in(self): self.view.scale(1.2, 1.2)
1100
+ def zoom_out(self): self.view.scale(1/1.2, 1/1.2)
1101
+
1102
+ def _on_fit_clicked(self):
1103
+ self._auto_fit = True
1104
+ if self._preview_result is not None:
1105
+ self._display_in_view(self._preview_result)
1106
+ elif self._original_image is not None:
1107
+ self._display_in_view(self._original_image)
1108
+
1109
+ # ---------------- SEP PSF estimator ----------------
1110
+ def _on_run_sep(self):
1111
+ img, _ = self._get_active_image_and_meta()
1112
+ if img is None:
1113
+ QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
1114
+ return
1115
+ img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
1116
+
1117
+ sigma = self.sep_threshold_slider.value()
1118
+ minarea = self.sep_minarea_spin.value
1119
+ sat = self.sep_sat_slider.value()
1120
+ maxstars= self.sep_maxstars_spin.value
1121
+ half_w = self.sep_stamp_spin.value
1122
+
1123
+ try:
1124
+ psf_kernel = estimate_psf_from_image(
1125
+ image_array=img_gray,
1126
+ threshold_sigma=sigma,
1127
+ min_area=minarea,
1128
+ saturation_limit=sat,
1129
+ max_stars=maxstars,
1130
+ stamp_half_width=half_w
1131
+ )
1132
+ except RuntimeError as e:
1133
+ QMessageBox.critical(self, "PSF Error", str(e)); return
1134
+
1135
+ self._last_stellar_psf = psf_kernel
1136
+ self._show_stellar_psf_preview(psf_kernel)
1137
+
1138
+ def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
1139
+ h, w = psf_kernel.shape
1140
+ img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
1141
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
1142
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
1143
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
1144
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
1145
+ self.sep_psf_preview.setPixmap(final)
1146
+
1147
+ def _on_use_stellar_psf(self):
1148
+ if self._last_stellar_psf is None:
1149
+ QMessageBox.warning(self, "No PSF", "Run SEP extraction first.")
1150
+ return
1151
+ self._custom_psf = self._last_stellar_psf.copy()
1152
+ self._use_custom_psf = True
1153
+ self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
1154
+ self.deconv_algo_combo.setCurrentText("Richardson-Lucy")
1155
+ self.rl_custom_label.setVisible(True)
1156
+ self.rl_disable_custom_btn.setVisible(True)
1157
+ self.custom_psf_bar.setVisible(True)
1158
+ QMessageBox.information(self, "PSF Selected", "Stellar PSF is now active for Richardson–Lucy.")
1159
+
1160
+ def _clear_custom_psf_flag(self, _=None):
1161
+ if self._use_custom_psf:
1162
+ self._use_custom_psf = False
1163
+ self._custom_psf = None
1164
+ self.rl_custom_label.setVisible(False)
1165
+ self.rl_disable_custom_btn.setVisible(False)
1166
+ self.custom_psf_bar.setVisible(False)
1167
+
1168
+ def _on_save_stellar_psf(self):
1169
+ if self._last_stellar_psf is None:
1170
+ QMessageBox.warning(self, "No PSF", "Run SEP extraction before saving.")
1171
+ return
1172
+
1173
+ path, _ = QFileDialog.getSaveFileName(
1174
+ self,
1175
+ "Save PSF as...",
1176
+ "",
1177
+ "TIFF (*.tif);;FITS (*.fits)"
1178
+ )
1179
+ if not path:
1180
+ return
1181
+
1182
+ ext = path.lower().split('.')[-1]
1183
+
1184
+ if ext == 'fits':
1185
+ fits.PrimaryHDU(self._last_stellar_psf.astype(np.float32)).writeto(path, overwrite=True)
1186
+
1187
+ elif ext in ('tif', 'tiff'):
1188
+ import tifffile
1189
+ tifffile.imwrite(path, self._last_stellar_psf.astype(np.float32))
1190
+
1191
+ else:
1192
+ QMessageBox.warning(self, "Invalid Extension", "Please choose .fits or .tif.")
1193
+ return
1194
+
1195
+ QMessageBox.information(self, "Saved", f"PSF saved to:\n{path}")
1196
+
1197
+
1198
+
1199
+ # ─────────────────────────────────────────────────────────────────────────────
1200
+ def estimate_psf_from_image(image_array: np.ndarray,
1201
+ threshold_sigma: float,
1202
+ min_area: int,
1203
+ saturation_limit: float,
1204
+ max_stars: int,
1205
+ stamp_half_width: int) -> np.ndarray:
1206
+ data = image_array.astype(np.float32)
1207
+ bkg = sep.Background(data)
1208
+ bkg_sub = data - bkg.back()
1209
+ sources = sep.extract(bkg_sub, thresh=threshold_sigma, err=bkg.globalrms, minarea=min_area)
1210
+ if len(sources) == 0:
1211
+ raise RuntimeError(f"No sources found with SEP threshold = {threshold_sigma:.1f} σ.")
1212
+
1213
+ valid_sources = [s for s in sources if s['peak'] < saturation_limit]
1214
+ if len(valid_sources) == 0:
1215
+ raise RuntimeError(f"All detected sources exceed saturation limit {int(saturation_limit)}.")
1216
+
1217
+ valid_sources.sort(key=lambda s: s['peak'], reverse=True)
1218
+ selected = valid_sources[:max_stars]
1219
+
1220
+ w = stamp_half_width
1221
+ ksize = 2*w + 1
1222
+ psf_sum = np.zeros((ksize, ksize), dtype=np.float32)
1223
+ count = 0
1224
+
1225
+ H, W = data.shape[:2]
1226
+ for src in selected:
1227
+ xi = int(round(src['x'])); yi = int(round(src['y']))
1228
+ y0, y1 = yi - w, yi + w + 1
1229
+ x0, x1 = xi - w, xi + w + 1
1230
+ if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
1231
+ continue
1232
+ stamp = bkg_sub[y0:y1, x0:x1].astype(np.float32)
1233
+ total_flux = float(np.sum(stamp))
1234
+ if total_flux <= 0:
1235
+ continue
1236
+ psf_sum += (stamp / total_flux)
1237
+ count += 1
1238
+
1239
+ if count == 0:
1240
+ raise RuntimeError("No valid postage stamps extracted (all were off-edge or zero).")
1241
+
1242
+ psf_kernel = (psf_sum / count).astype(np.float32)
1243
+ total = float(psf_kernel.sum())
1244
+ if total > 0:
1245
+ psf_kernel /= total
1246
+ else:
1247
+ psf_kernel[:] = 0; psf_kernel[w, w] = 1.0
1248
+ return psf_kernel
1249
+
1250
+
1251
+ # ─────────────────────────────────────────────────────────────────────────────
1252
+ @lru_cache(maxsize=64)
1253
+ def make_elliptical_gaussian_psf(radius: float, kurtosis: float, aspect: float, rotation_deg: float) -> np.ndarray:
1254
+ """Generate elliptical Gaussian PSF kernel. Results are cached."""
1255
+ sigma_x = radius
1256
+ sigma_y = radius / max(aspect, 1e-8)
1257
+
1258
+ size = int(np.ceil(6 * sigma_x))
1259
+ size = size + 1 if size % 2 == 0 else size
1260
+ half = size // 2
1261
+
1262
+ xs = np.linspace(-half, half, size)
1263
+ ys = np.linspace(-half, half, size)
1264
+ xv, yv = np.meshgrid(xs, ys)
1265
+
1266
+ theta = np.deg2rad(rotation_deg)
1267
+ cos_t, sin_t = np.cos(theta), np.sin(theta)
1268
+ x_rot = cos_t * xv + sin_t * yv
1269
+ y_rot = -sin_t * xv + cos_t * yv
1270
+
1271
+ beta = kurtosis
1272
+ squared_sum = (x_rot / max(sigma_x, 1e-8))**2 + (y_rot / max(sigma_y, 1e-8))**2
1273
+ psf = np.exp(-(squared_sum ** beta))
1274
+ total = psf.sum()
1275
+ return (psf / total).astype(np.float32) if total != 0 else np.zeros_like(psf, dtype=np.float32)
1276
+
1277
+
1278
+ def _rl_tile_process_reg(tile_and_meta: Tuple[int, np.ndarray]) -> Tuple[int, np.ndarray]:
1279
+ (tile_index, padded_tile, psf, num_iter, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext) = tile_and_meta
1280
+ alpha_L2 = 0.01
1281
+ alpha_tv = 0.01
1282
+ f = np.clip(padded_tile.astype(np.float32), 1e-8, None)
1283
+ psf_flipped = psf[::-1, ::-1]
1284
+
1285
+ for _ in range(num_iter):
1286
+ estimate_blurred = fftconvolve(f, psf, mode="same")
1287
+ ratio = padded_tile / (estimate_blurred + 1e-8)
1288
+ correction = fftconvolve(ratio, psf_flipped, mode="same")
1289
+ f = f * correction
1290
+ if reg_type == "Tikhonov (L2)":
1291
+ f = f - alpha_L2 * laplace(f)
1292
+ elif reg_type == "Total Variation (TV)":
1293
+ f = denoise_tv_chambolle(f, weight=alpha_tv, channel_axis=None).astype(np.float32)
1294
+ f = np.clip(f, 0.0, 1.0)
1295
+
1296
+ if clip_flag:
1297
+ f = denoise_bilateral(f, sigma_color=0.08, sigma_spatial=1).astype(np.float32)
1298
+
1299
+ full_h, full_w = f.shape
1300
+ Wcore = full_w - 2 * pad
1301
+ deconv_core = f[pad: pad + L_ext, pad: pad + Wcore].astype(np.float32)
1302
+ return (tile_index, deconv_core)
1303
+
1304
+
1305
+ # ─────────────────────────────────────────────────────────────────────────────
1306
+ def van_cittert_deconv(image: np.ndarray, iterations: int, relaxation: float) -> np.ndarray:
1307
+ sigma = 3.0
1308
+ size = int(np.ceil(6 * sigma)); size = size + 1 if size % 2 == 0 else size
1309
+ xs = np.linspace(-size//2, size//2, size)
1310
+ kernel_1d = np.exp(-(xs**2) / (2*sigma**2)); kernel_1d = kernel_1d / kernel_1d.sum()
1311
+ psf = np.outer(kernel_1d, kernel_1d).astype(np.float32)
1312
+
1313
+ f = image.copy().astype(np.float32)
1314
+ for _ in range(iterations):
1315
+ conv = fftconvolve(f, psf, mode="same")
1316
+ f = f + relaxation * (image.astype(np.float32) - conv)
1317
+ return np.clip(f, 0.0, 1.0)
1318
+
1319
+
1320
+ def rotate_about_center(image: np.ndarray, angle_deg: float, center: Tuple[float, float]) -> np.ndarray:
1321
+ img_f = img_as_float32(image)
1322
+ H, W = img_f.shape[:2]
1323
+ y0, x0 = center
1324
+ theta = np.deg2rad(angle_deg)
1325
+ cos_t, sin_t = np.cos(theta), np.sin(theta)
1326
+ tx = x0 - ( x0 * cos_t - y0 * sin_t )
1327
+ ty = y0 - ( x0 * sin_t + y0 * cos_t )
1328
+ M3 = np.array([[ cos_t, -sin_t, tx ],
1329
+ [ sin_t, cos_t, ty ],
1330
+ [ 0.0 , 0.0 , 1.0 ]], dtype=np.float32)
1331
+ tform = AffineTransform(matrix=np.linalg.inv(M3))
1332
+ rotated = warp(img_f, inverse_map=tform, order=1, mode='constant', cval=0.0, preserve_range=True)
1333
+ return rotated.astype(np.float32)
1334
+
1335
+
1336
+ def _bilinear_interpolate_gray(gray: np.ndarray, y_coords: np.ndarray, x_coords: np.ndarray, cval: float = 0.0) -> np.ndarray:
1337
+ H, W = gray.shape
1338
+ x0 = np.floor(x_coords).astype(int); x1 = x0 + 1
1339
+ y0 = np.floor(y_coords).astype(int); y1 = y0 + 1
1340
+ dx = x_coords - x0; dy = y_coords - y0
1341
+ x0c = np.clip(x0, 0, W - 1); x1c = np.clip(x1, 0, W - 1)
1342
+ y0c = np.clip(y0, 0, H - 1); y1c = np.clip(y1, 0, H - 1)
1343
+ Ia = gray[y0c, x0c]; Ib = gray[y0c, x1c]; Ic = gray[y1c, x0c]; Id = gray[y1c, x1c]
1344
+ wa = (1 - dx) * (1 - dy); wb = dx * (1 - dy); wc = (1 - dx) * dy; wd = dx * dy
1345
+ interp = (Ia * wa) + (Ib * wb) + (Ic * wc) + (Id * wd)
1346
+ oob = (x_coords < 0) | (x_coords >= W) | (y_coords < 0) | (y_coords >= H)
1347
+ interp[oob] = cval
1348
+ return interp.astype(np.float32)
1349
+
1350
+
1351
+ def larson_sekanina(image: np.ndarray, center: Tuple[float, float], radial_step: Optional[float],
1352
+ angular_step_deg: float, operator: str = "Divide") -> np.ndarray:
1353
+ if image.dtype != np.float32:
1354
+ raise ValueError("larson_sekanina: input must be float32 in [0..1]")
1355
+ if image.ndim == 3 and image.shape[2] == 3:
1356
+ from skimage.color import rgb2gray
1357
+ gray = rgb2gray(image)
1358
+ else:
1359
+ gray = image
1360
+
1361
+ H, W = gray.shape
1362
+ y0, x0 = center
1363
+ dtheta = (angular_step_deg / 180.0) * np.pi
1364
+
1365
+ ys = np.arange(H, dtype=np.float32)[:, None]
1366
+ xs = np.arange(W, dtype=np.float32)[None, :]
1367
+ dy = np.broadcast_to(ys - y0, (H, W))
1368
+ dx = np.broadcast_to(xs - x0, (H, W))
1369
+ r = np.sqrt(dx*dx + dy*dy)
1370
+ theta = np.arctan2(dy, dx); theta[theta < 0] += 2*np.pi
1371
+
1372
+ r2 = r if (radial_step is None or radial_step <= 0) else (r + radial_step)
1373
+ theta2 = (theta + dtheta) % (2*np.pi)
1374
+
1375
+ x2 = x0 + r2 * np.cos(theta2)
1376
+ y2 = y0 + r2 * np.sin(theta2)
1377
+
1378
+ J = _bilinear_interpolate_gray(gray, y2.ravel(), x2.ravel(), cval=0.0).reshape(H, W)
1379
+
1380
+ if operator == "Divide":
1381
+ eps = 1e-6
1382
+ med = np.median(J) if np.median(J) > 0 else 1e-6
1383
+ B = np.clip(gray * (med / (J + eps)), 0.0, 1.0)
1384
+ else:
1385
+ diff = gray - J
1386
+ B = np.clip(diff, 0.0, None)
1387
+ maxv = B.max()
1388
+ B = (B / maxv) if maxv > 0 else np.zeros_like(B)
1389
+
1390
+ return B.astype(np.float32)
1391
+
1392
+
1393
+ # Optional helper to open like SFCC:
1394
+ def open_convo_deconvo(doc_manager, parent=None, doc=None) -> ConvoDeconvoDialog:
1395
+ dlg = ConvoDeconvoDialog(doc_manager=doc_manager, parent=parent, doc=doc)
1396
+ dlg.show()
1397
+ return dlg