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.
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,681 @@
1
+ # pro/tools/star_spikes.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt
6
+ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSplitter, QSizePolicy, QWidget, QApplication,
7
+ QFormLayout, QGroupBox, QDoubleSpinBox, QSpinBox,
8
+ QMessageBox, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem)
9
+
10
+ from PyQt6.QtGui import QPixmap, QImage, QPainter
11
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
12
+
13
+ # deps
14
+ try:
15
+ import sep
16
+ except Exception as _e_sep:
17
+ sep = None
18
+ try:
19
+ import cv2
20
+ except Exception as _e_cv2:
21
+ cv2 = None
22
+ try:
23
+ from scipy.ndimage import gaussian_filter
24
+ import scipy.ndimage as ndi
25
+ except Exception as _e_scipy:
26
+ gaussian_filter = None
27
+ ndi = None
28
+
29
+ class PreviewView(QGraphicsView):
30
+ def __init__(self, *a, **k):
31
+ super().__init__(*a, **k)
32
+ # drag to pan
33
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
34
+ # zoom toward mouse
35
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
36
+ # when the view resizes, keep the scene centered
37
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
38
+ # nicer defaults
39
+ self.setRenderHints(self.renderHints() | QPainter.RenderHint.SmoothPixmapTransform)
40
+ self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.SmartViewportUpdate)
41
+
42
+ class StarSpikesDialogPro(QDialog):
43
+ WARN_LIMIT = 1000
44
+ MAX_AUTO_RETRIES = 2
45
+
46
+ def __init__(self, parent=None, doc_manager=None,
47
+ initial_doc=None,
48
+ jwstpupil_path: str | None = None,
49
+ aperture_help_path: str | None = None,
50
+ spinner_path: str | None = None):
51
+ super().__init__(parent)
52
+ self.setWindowTitle("Diffraction Spikes")
53
+ self.docman = doc_manager
54
+ self.doc = initial_doc or (self.docman.get_active_document() if self.docman else None)
55
+ self.jwstpupil_path = jwstpupil_path
56
+ self.aperture_help_path = aperture_help_path
57
+
58
+ self.final_image = None
59
+ self._img_src = None # float32, 2D or 3D, [0..1]
60
+
61
+ # defaults (aligned to your SASv2 tool)
62
+ self.advanced = {
63
+ "flux_max": 300.0, "bscale_min": 10.0, "bscale_max": 30.0,
64
+ "shrink_min": 1.0, "shrink_max": 5.0, "detect_thresh": 5.0,
65
+ }
66
+
67
+ self._build_ui()
68
+ self._load_active_image()
69
+
70
+ # ---------- UI ----------
71
+ def _build_ui(self):
72
+ # top-level splitter: controls (left) | preview (right)
73
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
74
+ splitter.setChildrenCollapsible(False)
75
+
76
+ # ----- LEFT: controls panel (stacked groups) -----
77
+ left = QWidget()
78
+ left_v = QVBoxLayout(left)
79
+ left_v.setContentsMargins(10, 10, 10, 10)
80
+ left_v.setSpacing(10)
81
+
82
+ def dspin(lo, hi, step, val, decimals=2):
83
+ sp = QDoubleSpinBox()
84
+ sp.setRange(lo, hi)
85
+ sp.setSingleStep(step)
86
+ sp.setDecimals(decimals)
87
+ sp.setValue(val)
88
+ sp.setMaximumWidth(140)
89
+ return sp
90
+
91
+ def ispin(lo, hi, step, val):
92
+ sp = QSpinBox()
93
+ sp.setRange(lo, hi)
94
+ sp.setSingleStep(step)
95
+ sp.setValue(val)
96
+ sp.setMaximumWidth(140)
97
+ return sp
98
+
99
+ # --- Group: Star Detection ---
100
+ grp_detect = QGroupBox("Star Detection")
101
+ f_detect = QFormLayout(grp_detect)
102
+ f_detect.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
103
+ f_detect.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
104
+
105
+ self.flux_min = dspin(0.0, 999999.0, 10.0, 30.0, decimals=1)
106
+ self.detect_thresh = dspin(0.5, 50.0, 0.5, float(self.advanced.get("detect_thresh", 5.0)), decimals=2)
107
+ self.detect_thresh.setToolTip("σ threshold for SEP detection (higher = fewer stars).")
108
+ # keep self.advanced in sync if user edits
109
+ self.detect_thresh.valueChanged.connect(lambda v: self.advanced.__setitem__("detect_thresh", float(v)))
110
+
111
+ f_detect.addRow("Flux Min:", self.flux_min)
112
+ f_detect.addRow("Detection Threshold (σ):", self.detect_thresh)
113
+
114
+ # --- Group: Aperture (Geometry) ---
115
+ grp_ap = QGroupBox("Aperture")
116
+ f_ap = QFormLayout(grp_ap)
117
+ f_ap.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
118
+ f_ap.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
119
+
120
+ self.pupil_jwst = QPushButton("Circular")
121
+ self.pupil_jwst.setCheckable(True)
122
+ self.pupil_jwst.setChecked(False)
123
+ self.pupil_jwst.toggled.connect(lambda on: self._toggle_pupil(on))
124
+ self.pupil_jwst.setToolTip("Toggle between circular aperture and JWST pupil image.")
125
+ self.pupil_jwst.setStyleSheet("""
126
+ QPushButton { min-width: 72px; max-width: 72px; min-height: 28px; max-height: 28px;
127
+ border-radius: 14px; background:#ccc; border:1px solid #999;}
128
+ QPushButton:checked { background:#66bb6a; }
129
+ """)
130
+ f_ap.addRow("Aperture Type:", self.pupil_jwst)
131
+
132
+ self.radius = dspin(1.0, 512.0, 1.0, 128.0, decimals=1)
133
+ self.obstruction = dspin(0.0, 0.99, 0.01, 0.2, decimals=2)
134
+ self.num_vanes = ispin(2, 8, 1, 4)
135
+ self.vane_width = dspin(0.0, 50.0, 0.5, 4.0, decimals=2)
136
+ self.rotation = dspin(0.0, 360.0, 1.0, 0.0, decimals=1)
137
+
138
+ f_ap.addRow("Pupil Radius:", self.radius)
139
+ f_ap.addRow("Obstruction:", self.obstruction)
140
+ f_ap.addRow("Number of Vanes:", self.num_vanes)
141
+ f_ap.addRow("Vane Width:", self.vane_width)
142
+ f_ap.addRow("Rotation (deg):", self.rotation)
143
+
144
+ # --- Group: PSF & Synthesis ---
145
+ grp_psf = QGroupBox("PSF & Synthesis")
146
+ f_psf = QFormLayout(grp_psf)
147
+ f_psf.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
148
+ f_psf.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
149
+
150
+ self.color_boost = dspin(0.1, 10.0, 0.1, 1.5, decimals=2)
151
+ self.blur_sigma = dspin(0.1, 10.0, 0.1, 2.0, decimals=2)
152
+
153
+ f_psf.addRow("Spike Boost:", self.color_boost)
154
+ f_psf.addRow("PSF Blur Sigma:", self.blur_sigma)
155
+
156
+ # --- Actions ---
157
+ row_actions = QHBoxLayout()
158
+ row_actions.setSpacing(8)
159
+ self.btn_run = QPushButton("Generate Spikes")
160
+ self.btn_run.clicked.connect(self._run)
161
+ self.btn_apply = QPushButton("Apply to Active Document")
162
+ self.btn_apply.clicked.connect(self._apply_to_doc)
163
+ self.btn_apply.setEnabled(False)
164
+ self.btn_help = QPushButton("Aperture Help")
165
+ self.btn_help.clicked.connect(self._show_help)
166
+ row_actions.addWidget(self.btn_run)
167
+ row_actions.addWidget(self.btn_apply)
168
+ row_actions.addWidget(self.btn_help)
169
+ row_actions.addStretch(1)
170
+
171
+ # --- Status ---
172
+ self.status = QLabel("Ready")
173
+ self.status.setAlignment(Qt.AlignmentFlag.AlignCenter)
174
+ self.status.setWordWrap(True)
175
+
176
+ # assemble left panel
177
+ left_v.addWidget(grp_detect)
178
+ left_v.addWidget(grp_ap)
179
+ left_v.addWidget(grp_psf)
180
+ left_v.addLayout(row_actions)
181
+ left_v.addWidget(self.status)
182
+ left_v.addStretch(1)
183
+
184
+ splitter.addWidget(left)
185
+
186
+ # ----- RIGHT: preview panel -----
187
+ right = QWidget()
188
+ right_v = QVBoxLayout(right)
189
+
190
+ # zoom toolbar
191
+ zrow = QHBoxLayout()
192
+ self.btn_zoom_in = QPushButton("Zoom In")
193
+ self.btn_zoom_out = QPushButton("Zoom Out")
194
+ self.btn_fit = QPushButton("Fit to Preview")
195
+ self.btn_zoom_in.clicked.connect(self._zoom_in)
196
+ self.btn_zoom_out.clicked.connect(self._zoom_out)
197
+ self.btn_fit.clicked.connect(self._fit_to_preview)
198
+ zrow.addWidget(self.btn_zoom_in)
199
+ zrow.addWidget(self.btn_zoom_out)
200
+ zrow.addWidget(self.btn_fit)
201
+ zrow.addStretch(1)
202
+ right_v.addLayout(zrow)
203
+
204
+ # graphics scene/view
205
+ self.scene = QGraphicsScene()
206
+ self.view = PreviewView()
207
+ self.view.setScene(self.scene)
208
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
209
+ self.view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
210
+ self.view.setMinimumSize(600, 450)
211
+ self.pix = QGraphicsPixmapItem()
212
+ self.scene.addItem(self.pix)
213
+ right_v.addWidget(self.view, 1)
214
+
215
+ splitter.addWidget(right)
216
+
217
+ # make preview side bigger by default
218
+ splitter.setStretchFactor(0, 0) # left
219
+ splitter.setStretchFactor(1, 1) # right
220
+ splitter.setSizes([360, 900])
221
+
222
+ # top-level layout contains just the splitter
223
+ root = QVBoxLayout(self)
224
+ root.addWidget(splitter)
225
+
226
+ # init pupil visibility
227
+ self._toggle_pupil(False)
228
+
229
+ # zoom state
230
+ self._zoom = 1.0
231
+ self._fit_mode = True # start fitted
232
+
233
+ def _toggle_pupil(self, jwst: bool):
234
+ self.pupil_jwst.setText("JWST" if jwst else "Circular")
235
+ # hide circular-only params when JWST pupil is used
236
+ for w in (self.num_vanes, self.vane_width, self.obstruction, self.radius):
237
+ w.setVisible(not jwst)
238
+
239
+ # ---------- data/preset ----------
240
+ def _load_active_image(self):
241
+ if not self.doc or getattr(self.doc, "image", None) is None:
242
+ self.status.setText("No active image.")
243
+ return
244
+ arr = np.asarray(self.doc.image)
245
+ if arr.dtype != np.float32:
246
+ arr = arr.astype(np.float32, copy=False)
247
+ # strip alpha
248
+ if arr.ndim == 3 and arr.shape[2] == 4:
249
+ arr = arr[..., :3]
250
+ # keep within [0..1] for the math we use
251
+ if np.issubdtype(arr.dtype, np.floating):
252
+ mx = float(arr.max()) if arr.size else 1.0
253
+ if mx > 1.0:
254
+ arr = arr / (65535.0 if mx > 5.0 else mx)
255
+ self._img_src = np.clip(arr, 0.0, 1.0)
256
+
257
+ def apply_preset(self, p: dict):
258
+ if not p:
259
+ return
260
+ self.flux_min.setValue(float(p.get("flux_min", self.flux_min.value())))
261
+ self.advanced["flux_max"] = float(p.get("flux_max", self.advanced["flux_max"]))
262
+ self.advanced["bscale_min"] = float(p.get("bscale_min", self.advanced["bscale_min"]))
263
+ self.advanced["bscale_max"] = float(p.get("bscale_max", self.advanced["bscale_max"]))
264
+ self.advanced["shrink_min"] = float(p.get("shrink_min", self.advanced["shrink_min"]))
265
+ self.advanced["shrink_max"] = float(p.get("shrink_max", self.advanced["shrink_max"]))
266
+ self.advanced["detect_thresh"] = float(p.get("detect_thresh", self.advanced["detect_thresh"]))
267
+ self.detect_thresh.setValue(float(self.advanced["detect_thresh"])) # reflect in UI
268
+ self.radius.setValue(float(p.get("radius", self.radius.value())))
269
+ self.obstruction.setValue(float(p.get("obstruction", self.obstruction.value())))
270
+ self.num_vanes.setValue(int(p.get("num_vanes", self.num_vanes.value())))
271
+ self.vane_width.setValue(float(p.get("vane_width", self.vane_width.value())))
272
+ self.rotation.setValue(float(p.get("rotation", self.rotation.value())))
273
+ self.color_boost.setValue(float(p.get("color_boost", self.color_boost.value())))
274
+ self.blur_sigma.setValue(float(p.get("blur_sigma", self.blur_sigma.value())))
275
+ self.pupil_jwst.setChecked(bool(p.get("jwst", self.pupil_jwst.isChecked())))
276
+
277
+ # ---------- core ----------
278
+ def _run(self):
279
+ if self._img_src is None:
280
+ self._load_active_image()
281
+ if self._img_src is None:
282
+ QMessageBox.information(self, "Diffraction Spikes", "No active image.")
283
+ return
284
+
285
+ # deps check
286
+ if sep is None:
287
+ QMessageBox.critical(self, "Missing Dependency", "python-sep is required for star detection.")
288
+ return
289
+ if gaussian_filter is None or ndi is None:
290
+ QMessageBox.critical(self, "Missing Dependency", "scipy.ndimage is required.")
291
+ return
292
+
293
+ self.status.setText("Detecting stars…")
294
+ QApplication.processEvents()
295
+ img = self._img_src
296
+ # un-stretch via midtones(0.95) for detection
297
+ if img.ndim == 3:
298
+ lin = img.copy()
299
+ for c in range(3):
300
+ lin[..., c] = self._midtones_m(lin[..., c], 0.95)
301
+ base = 0.2126*lin[...,0] + 0.7152*lin[...,1] + 0.0722*lin[...,2]
302
+ else:
303
+ lin = self._midtones_m(img, 0.95)
304
+ base = lin
305
+
306
+ # initial detection
307
+ thresh = float(self.detect_thresh.value())
308
+ stars = self._detect_stars(base,
309
+ threshold=thresh,
310
+ flux_min=self.flux_min.value(),
311
+ size_min=1.0)
312
+
313
+ # interactive guardrail for dense fields
314
+ tries = 0
315
+ while len(stars) > self.WARN_LIMIT and tries < self.MAX_AUTO_RETRIES:
316
+ suggested = min(50.0, max(thresh + 1.0,
317
+ thresh * (len(stars) / float(self.WARN_LIMIT))**0.5))
318
+ msg = QMessageBox(self)
319
+ msg.setWindowTitle("Too Many Stars Detected")
320
+ msg.setIcon(QMessageBox.Icon.Warning)
321
+ msg.setText(f"{len(stars)} stars detected (limit {self.WARN_LIMIT}).\n"
322
+ "Increase the detection threshold to reduce clutter?")
323
+ raise_btn = msg.addButton(f"Raise to σ={suggested:.2f}", QMessageBox.ButtonRole.AcceptRole)
324
+ cont_btn = msg.addButton("Continue Anyway", QMessageBox.ButtonRole.DestructiveRole)
325
+ cancel_btn= msg.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
326
+ msg.setDefaultButton(raise_btn)
327
+ msg.exec()
328
+
329
+ clicked = msg.clickedButton()
330
+ if clicked is raise_btn:
331
+ thresh = suggested
332
+ self.detect_thresh.setValue(thresh) # reflect in UI
333
+ self.status.setText(f"Re-detecting stars at σ={thresh:.2f}…")
334
+ QApplication.processEvents()
335
+ stars = self._detect_stars(base,
336
+ threshold=thresh,
337
+ flux_min=self.flux_min.value(),
338
+ size_min=1.0)
339
+ tries += 1
340
+ continue
341
+ elif clicked is cont_btn:
342
+ break
343
+ else: # cancel
344
+ self.status.setText("Cancelled.")
345
+ return
346
+
347
+ if len(stars) == 0:
348
+ self.status.setText("No stars found.")
349
+ QMessageBox.information(self, "Diffraction Spikes", "No stars found above flux_min.")
350
+ return
351
+
352
+ self.status.setText(f"Building pupil/PSFs… ({len(stars)} stars)")
353
+ QApplication.processEvents()
354
+ if self.pupil_jwst.isChecked():
355
+ if cv2 is None or not self.jwstpupil_path:
356
+ QMessageBox.critical(self, "Missing JWST Pupil",
357
+ "OpenCV not available or JWST pupil image path missing.")
358
+ return
359
+ pupil = self._load_pupil_from_png(self.jwstpupil_path, size=1024, rotation=self.rotation.value())
360
+ else:
361
+ pupil = self._make_pupil(size=1024,
362
+ radius=self.radius.value(),
363
+ obstruction=self.obstruction.value(),
364
+ vane_width=self.vane_width.value(),
365
+ num_vanes=self.num_vanes.value(),
366
+ rotation=self.rotation.value())
367
+
368
+ psf_r = self._simulate_psf(pupil, wavelength_scale=1.15, blur_sigma=self.blur_sigma.value())
369
+ psf_g = self._simulate_psf(pupil, wavelength_scale=1.00, blur_sigma=self.blur_sigma.value())
370
+ psf_b = self._simulate_psf(pupil, wavelength_scale=0.85, blur_sigma=self.blur_sigma.value())
371
+
372
+ self.status.setText("Synthesizing spikes…")
373
+ QApplication.processEvents()
374
+ H, W = img.shape[:2]
375
+ canvas = np.zeros((H, W, 3), dtype=np.float32)
376
+
377
+ flux_max = self.advanced["flux_max"]
378
+ bscale_min = self.advanced["bscale_min"]
379
+ bscale_max = self.advanced["bscale_max"]
380
+ shrink_min = self.advanced["shrink_min"]
381
+ shrink_max = self.advanced["shrink_max"]
382
+ color_boost = self.color_boost.value()
383
+
384
+ from concurrent.futures import ThreadPoolExecutor, as_completed
385
+ def star_runner(x, y, flux, a, b):
386
+ brightness = np.clip(np.log1p(flux)/8.0, 0.1, 3.0)
387
+ tile_size = int(256 + brightness*20)
388
+ tile_size = min(tile_size, 768)
389
+ tile_size += tile_size % 2
390
+ pad = tile_size // 2
391
+ if not (pad <= x < W - pad and pad <= y < H - pad):
392
+ return None
393
+
394
+ r_ratio, g_ratio, b_ratio = self._measure_star_color(img, x, y, sampling_radius=3)
395
+ tile_r = self._extract_center_tile(psf_r, tile_size) * brightness * r_ratio * color_boost
396
+ tile_g = self._extract_center_tile(psf_g, tile_size) * brightness * g_ratio * color_boost
397
+ tile_b = self._extract_center_tile(psf_b, tile_size) * brightness * b_ratio * color_boost
398
+
399
+ b_scale, s_factor = self._boost_shrink_from_flux(flux, self.flux_min.value(), flux_max,
400
+ bscale_min, bscale_max, shrink_min, shrink_max)
401
+ final_r = self._shrink_and_boost(tile_r, b_scale, s_factor)
402
+ final_g = self._shrink_and_boost(tile_g, b_scale, s_factor)
403
+ final_b = self._shrink_and_boost(tile_b, b_scale, s_factor)
404
+
405
+ new_size = final_r.shape[0]
406
+ pad_new = new_size // 2
407
+ y0, y1 = y - pad_new, y - pad_new + new_size
408
+ x0, x1 = x - pad_new, x - pad_new + new_size
409
+ if (y0 < 0 or y1 > H or x0 < 0 or x1 > W):
410
+ return None
411
+
412
+ part = np.zeros((H, W, 3), dtype=np.float32)
413
+ part[y0:y1, x0:x1, 0] = final_r
414
+ part[y0:y1, x0:x1, 1] = final_g
415
+ part[y0:y1, x0:x1, 2] = final_b
416
+ return part
417
+
418
+ with ThreadPoolExecutor() as ex:
419
+ futs = [ex.submit(star_runner, *s) for s in stars]
420
+ for f in as_completed(futs):
421
+ part = f.result()
422
+ if part is not None:
423
+ canvas += part
424
+
425
+ self.status.setText("Compositing…")
426
+ QApplication.processEvents()
427
+ if lin.ndim == 3:
428
+ spiked_lin = np.clip(lin + canvas, 0, 1)
429
+ else:
430
+ spikes_mono = 0.2126*canvas[...,0] + 0.7152*canvas[...,1] + 0.0722*canvas[...,2]
431
+ spiked_lin = np.clip(lin + spikes_mono, 0, 1)
432
+
433
+ # protect by active mask (document-level)
434
+ if spiked_lin.ndim == 3:
435
+ spiked_final = np.empty_like(spiked_lin)
436
+ for c in range(3):
437
+ spiked_final[..., c] = self._midtones_m(spiked_lin[..., c], 0.05)
438
+ else:
439
+ spiked_final = self._midtones_m(spiked_lin, 0.05)
440
+
441
+ # ---- apply mask AFTER full processing ----
442
+ m = self._active_mask_array(self.doc)
443
+ if m is not None:
444
+ if spiked_final.ndim == 3 and m.ndim == 2:
445
+ m = m[..., None]
446
+
447
+ # white = apply effect, black = protect original
448
+ final = np.clip(spiked_final * m + img * (1.0 - m), 0.0, 1.0)
449
+ else:
450
+ final = spiked_final
451
+
452
+ self.final_image = final
453
+ self._update_preview(final)
454
+ self.btn_apply.setEnabled(True)
455
+ self.status.setText("Done.")
456
+
457
+ def _apply_to_doc(self):
458
+ if self.final_image is None:
459
+ QMessageBox.information(self, "Diffraction Spikes", "Nothing to apply yet.")
460
+ return
461
+ if not self.docman:
462
+ QMessageBox.warning(self, "No DocManager", "DocManager not available.")
463
+ return
464
+ self.docman.apply_edit_to_active(self.final_image, step_name="Diffraction Spikes")
465
+ self.status.setText("Applied to active document.")
466
+ # keep dialog open so user can tweak more if desired
467
+
468
+ # ---------- helpers ----------
469
+ def _update_preview(self, arr):
470
+ arr8 = np.clip(arr, 0, 1)
471
+ arr8 = (arr8 * 255.0).astype(np.uint8)
472
+ if arr8.ndim == 2:
473
+ h, w = arr8.shape
474
+ qimg = QImage(arr8.data, w, h, w, QImage.Format.Format_Grayscale8)
475
+ else:
476
+ h, w, _ = arr8.shape
477
+ qimg = QImage(arr8.data, w, h, 3*w, QImage.Format.Format_RGB888)
478
+ self.pix.setPixmap(QPixmap.fromImage(qimg))
479
+ self.scene.setSceneRect(self.pix.boundingRect())
480
+ # keep current zoom mode
481
+ self._apply_zoom()
482
+
483
+ def _show_help(self):
484
+ if not self.aperture_help_path:
485
+ QMessageBox.information(self, "Aperture Help", "No help image configured.")
486
+ return
487
+ pm = QPixmap(self.aperture_help_path)
488
+ if pm.isNull():
489
+ QMessageBox.critical(self, "Aperture Help", "Failed to load help image.")
490
+ return
491
+ dlg = QDialog(self)
492
+ dlg.setWindowTitle("Aperture Help")
493
+ v = QVBoxLayout(dlg)
494
+ lab = QLabel()
495
+ lab.setPixmap(pm)
496
+ lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
497
+ v.addWidget(lab)
498
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
499
+ dlg.resize(480, 480)
500
+ dlg.show()
501
+
502
+ # ----- math from SASv2, adapted -----
503
+ @staticmethod
504
+ def _midtones_m(x, m):
505
+ x = np.clip(x, 0.0, 1.0).astype(np.float32)
506
+ out = np.zeros_like(x, dtype=np.float32)
507
+ mask0 = (x == 0); out[mask0] = 0.0
508
+ mask1 = (x == 1); out[mask1] = 1.0
509
+ eps = 1e-7
510
+ maskm = (np.abs(x - m) < eps); out[maskm] = 0.5
511
+ mask_oth = ~(mask0 | mask1 | maskm)
512
+ xm = x[mask_oth]
513
+ num = (m - 1.0)*xm
514
+ den = (2.0*m - 1.0)*xm - m
515
+ out[mask_oth] = np.clip(num/(den+1e-12),0,1)
516
+ return out
517
+
518
+ def _make_pupil(self, size=512, radius=100, obstruction=0.3, vane_width=2, num_vanes=4, rotation=0):
519
+ y, x = np.indices((size, size)) - size // 2
520
+ r = np.sqrt(x**2 + y**2)
521
+ pupil = (r <= radius).astype(np.float32)
522
+ pupil[r < radius * obstruction] = 0.0
523
+ if num_vanes >= 2:
524
+ rot = np.deg2rad(rotation)
525
+ for angle in np.linspace(0, np.pi, num_vanes, endpoint=False) + rot:
526
+ xp = x * np.cos(angle) + y * np.sin(angle)
527
+ vane = np.abs(xp) < vane_width
528
+ pupil[vane] = 0.0
529
+ return pupil
530
+
531
+ def _load_pupil_from_png(self, filepath, size=1024, rotation=0.0):
532
+ img = cv2.imread(filepath, cv2.IMREAD_GRAYSCALE)
533
+ if img is None:
534
+ raise ValueError(f"Failed to load image from {filepath}")
535
+ img = img.astype(np.float32) / 255.0
536
+ if img.shape != (size, size):
537
+ img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
538
+ if abs(rotation) > 1e-3:
539
+ center = (size // 2, size // 2)
540
+ M = cv2.getRotationMatrix2D(center, rotation, 1.0)
541
+ img = cv2.warpAffine(img, M, (size, size), flags=cv2.INTER_LINEAR,
542
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
543
+ return img
544
+
545
+ def _simulate_psf(self, pupil, wavelength_scale=1.0, blur_sigma=1.0):
546
+ sp = gaussian_filter(pupil, sigma=0.1 * wavelength_scale)
547
+ fft = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(sp)))
548
+ intensity = np.abs(fft)**2
549
+ intensity /= (intensity.max() + 1e-8)
550
+ blurred = gaussian_filter(intensity, sigma=blur_sigma)
551
+ psf = blurred / max(blurred.max(), 1e-8)
552
+ if wavelength_scale != 1.0:
553
+ psf = ndi.zoom(psf, zoom=wavelength_scale, order=1)
554
+ psf /= psf.max() + 1e-12
555
+ return psf
556
+
557
+ @staticmethod
558
+ def _extract_center_tile(psf, tile_size):
559
+ c = psf.shape[0]//2
560
+ h = tile_size//2
561
+ y0 = max(0, c-h); x0 = max(0, c-h)
562
+ y1 = y0 + tile_size; x1 = x0 + tile_size
563
+ cropped = psf[y0:y1, x0:x1]
564
+ if cropped.shape != (tile_size, tile_size):
565
+ out = np.zeros((tile_size, tile_size), dtype=np.float32)
566
+ ph, pw = cropped.shape
567
+ out[:ph, :pw] = cropped
568
+ return out
569
+ return cropped
570
+
571
+ @staticmethod
572
+ def _detect_stars(image, threshold=5.0, flux_min=30.0, size_min=1.0):
573
+ data = image.astype(np.float32)
574
+ bkg = sep.Background(data)
575
+ data_sub = data - bkg.back()
576
+ err_val = bkg.globalrms
577
+ try:
578
+ objects = sep.extract(data_sub, threshold, err=err_val)
579
+ except Exception as e:
580
+ if "internal pixel buffer full" in str(e):
581
+ QMessageBox.warning(None, "Star Detection Failed",
582
+ "Star detection failed: internal pixel buffer full.\n"
583
+ "Increase detection threshold or minimum flux.")
584
+ else:
585
+ QMessageBox.critical(None, "Star Detection Failed", str(e))
586
+ return []
587
+ stars = []
588
+ for obj in objects:
589
+ flux = obj['flux']; a = obj['a']; b = obj['b']
590
+ if flux >= flux_min and max(a,b) >= size_min:
591
+ stars.append((int(obj['x']), int(obj['y']), float(flux), float(a), float(b)))
592
+ return stars
593
+
594
+ @staticmethod
595
+ def _shrink_and_boost(tile, brightness_scale=2.0, shrink_factor=1.5):
596
+ tile = np.clip(tile * float(brightness_scale), 0.0, 1.0)
597
+ in_sz = tile.shape[0]
598
+ out_sz = int(in_sz // float(shrink_factor))
599
+ out_sz += out_sz % 2
600
+ if out_sz <= 0: out_sz = 2
601
+ z = out_sz / float(in_sz)
602
+ return np.clip(ndi.zoom(tile, z, order=1), 0.0, 1.0)
603
+
604
+ @staticmethod
605
+ def _boost_shrink_from_flux(flux, flux_min, flux_max, bmin, bmax, smin, smax):
606
+ f = np.clip(flux, flux_min, flux_max)
607
+ alpha = 0.0 if flux_max <= flux_min else (f - flux_min) / (flux_max - flux_min)
608
+ bscale = bmin + alpha * (bmax - bmin)
609
+ shrink = smax - alpha * (smax - smin)
610
+ return float(bscale), float(shrink)
611
+
612
+ @staticmethod
613
+ def _measure_star_color(img_color, x, y, sampling_radius=20):
614
+ if img_color.ndim == 2:
615
+ return (1.0, 1.0, 1.0)
616
+ H, W, C = img_color.shape
617
+ if C != 3:
618
+ return (1.0, 1.0, 1.0)
619
+ x0 = max(0, int(x - sampling_radius)); x1 = min(W, int(x + sampling_radius + 1))
620
+ y0 = max(0, int(y - sampling_radius)); y1 = min(H, int(y + sampling_radius + 1))
621
+ if x1 <= x0 or y1 <= y0:
622
+ return (1.0, 1.0, 1.0)
623
+ patch = img_color[y0:y1, x0:x1, :]
624
+ mean_col = np.mean(patch, axis=(0, 1))
625
+ mx = float(np.max(mean_col))
626
+ if mx < 1e-9:
627
+ return (1.0, 1.0, 1.0)
628
+ return (float(mean_col[0]/mx), float(mean_col[1]/mx), float(mean_col[2]/mx))
629
+
630
+ @staticmethod
631
+ def _active_mask_array(doc) -> np.ndarray | None:
632
+ if not doc:
633
+ return None
634
+ mid = getattr(doc, "active_mask_id", None)
635
+ if not mid:
636
+ return None
637
+ masks = getattr(doc, "masks", {}) or {}
638
+ layer = masks.get(mid)
639
+ data = getattr(layer, "data", None) if layer is not None else None
640
+ if data is None:
641
+ return None
642
+ a = np.asarray(data)
643
+ if a.ndim == 3 and a.shape[2] == 1:
644
+ a = a[..., 0]
645
+ if a.ndim != 2:
646
+ return None
647
+ a = a.astype(np.float32, copy=False)
648
+ a = np.clip(a, 0.0, 1.0)
649
+ # keep original where mask == 1.0 (protection mask semantics)
650
+ return a
651
+
652
+ def _apply_zoom(self):
653
+ if self._fit_mode:
654
+ self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
655
+ return
656
+ self.view.resetTransform()
657
+ self.view.scale(self._zoom, self._zoom)
658
+
659
+ def _zoom_in(self):
660
+ if self.pix.pixmap().isNull():
661
+ return
662
+ if self._fit_mode:
663
+ self._fit_mode = False
664
+ self._zoom = 1.0
665
+ self._zoom = min(self._zoom * 1.25, 20.0)
666
+ self._apply_zoom()
667
+
668
+ def _zoom_out(self):
669
+ if self.pix.pixmap().isNull():
670
+ return
671
+ if self._fit_mode:
672
+ self._fit_mode = False
673
+ self._zoom = 1.0
674
+ self._zoom = max(self._zoom / 1.25, 0.05)
675
+ self._apply_zoom()
676
+
677
+ def _fit_to_preview(self):
678
+ if self.pix.pixmap().isNull():
679
+ return
680
+ self._fit_mode = True
681
+ self._apply_zoom()