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,1712 @@
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ from PyQt6.QtWidgets import (
5
+ QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEdit, QPushButton, QFileDialog,
6
+ QListWidget, QSlider, QCheckBox, QMessageBox, QTextEdit, QDialog, QApplication,
7
+ QTreeWidget, QTreeWidgetItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QGridLayout,
8
+ QToolBar, QSizePolicy, QSpinBox, QDoubleSpinBox, QProgressBar
9
+ )
10
+ from PyQt6.QtGui import QImage, QPixmap, QIcon, QPainter, QAction, QTransform, QCursor
11
+ from PyQt6.QtCore import Qt, pyqtSignal, QRectF, QPointF, QTimer, QThread, QObject
12
+
13
+
14
+ from pathlib import Path
15
+ import tempfile
16
+
17
+ from astropy.wcs import WCS
18
+ from astropy.time import Time
19
+ from astropy import units as u
20
+ from astropy.io import fits
21
+ from astropy.io.fits import Header
22
+
23
+ from setiastro.saspro.legacy.image_manager import load_image, save_image
24
+ from setiastro.saspro.legacy.numba_utils import bulk_cosmetic_correction_numba
25
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
26
+ from setiastro.saspro.star_alignment import PolyGradientRemoval
27
+ from pro import minorbodycatalog as mbc
28
+ from setiastro.saspro.plate_solver import PlateSolverDialog as PlateSolver
29
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
30
+
31
+ from setiastro.saspro.plate_solver import (
32
+ _solve_numpy_with_fallback,
33
+ _as_header,
34
+ _strip_wcs_keys,
35
+ _merge_wcs_into_base_header,
36
+ )
37
+
38
+ def _xisf_kw_value(xisf_meta: dict, key: str, default=None):
39
+ """
40
+ Return the first 'value' for FITSKeywords[key] from a XISF meta dict.
41
+
42
+ xisf_meta: the dict stored in doc.metadata["xisf_meta"]
43
+ """
44
+ if not xisf_meta:
45
+ return default
46
+
47
+ fk = xisf_meta.get("FITSKeywords", {})
48
+ if key not in fk:
49
+ return default
50
+
51
+ entry = fk[key]
52
+ # In your sample, it's a list of {"value": "...", "comment": "..."}
53
+ if isinstance(entry, list) and entry:
54
+ v = entry[0].get("value", default)
55
+ elif isinstance(entry, dict):
56
+ v = entry.get("value", default)
57
+ else:
58
+ v = entry
59
+ return v
60
+
61
+ def ensure_jd_from_xisf_meta(meta: dict) -> None:
62
+ """
63
+ If this document came from a XISF and we haven't stored a JD yet,
64
+ derive JD / MJD from XISF FITSKeywords (DATE-OBS + EXPOSURE).
65
+
66
+ Safe no-op if anything is missing.
67
+ """
68
+ # Already have it? Don't overwrite.
69
+ if "jd" in meta and np.isfinite(meta["jd"]):
70
+ return
71
+
72
+ xisf_meta = meta.get("xisf_meta")
73
+ if not isinstance(xisf_meta, dict):
74
+ return
75
+
76
+ # 1) Get UTC observation timestamp and exposure
77
+ date_obs = _xisf_kw_value(xisf_meta, "DATE-OBS")
78
+ if not date_obs:
79
+ # Optional fallback to local time if you *really* want:
80
+ # date_obs = _xisf_kw_value(xisf_meta, "DATE-LOC")
81
+ return
82
+
83
+ exp_str = (_xisf_kw_value(xisf_meta, "EXPOSURE") or
84
+ _xisf_kw_value(xisf_meta, "EXPTIME"))
85
+ exposure = None
86
+ if exp_str is not None:
87
+ try:
88
+ exposure = float(exp_str)
89
+ except Exception:
90
+ exposure = None
91
+
92
+ # 2) Parse the date string → Time
93
+ # SGP / PI are emitting ISO8601 with fractional seconds: 2024-04-22T06:58:08.4217144
94
+ try:
95
+ t = Time(date_obs, format="isot", scale="utc")
96
+ except Exception:
97
+ # Last-resort: let astropy guess; if that fails, bail out
98
+ try:
99
+ t = Time(date_obs, scale="utc")
100
+ except Exception:
101
+ return
102
+
103
+ # 3) Move to mid-exposure if we know the exposure length
104
+ if exposure and exposure > 0:
105
+ t = t + 0.5 * exposure * u.s
106
+
107
+ # 4) Store JD/MJD for later minor-body prediction
108
+ meta["jd"] = float(t.jd)
109
+ meta["mjd"] = float(t.mjd)
110
+ # Optional: keep a cleaned-up timestamp string too
111
+ meta.setdefault("date_obs", t.isot)
112
+
113
+ def _numpy_to_qimage(img: np.ndarray) -> QImage:
114
+ """
115
+ Accepts:
116
+ - float32/float64 in [0..1], mono or RGB
117
+ - uint8 mono/RGB
118
+ Returns QImage (RGB888 or Grayscale8).
119
+ """
120
+ if img is None:
121
+ return QImage()
122
+
123
+ # Normalize dtype
124
+ if img.dtype != np.uint8:
125
+ img = (np.clip(img, 0, 1) * 255.0).astype(np.uint8)
126
+
127
+ if img.ndim == 2:
128
+ h, w = img.shape
129
+ return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_Grayscale8)
130
+ elif img.ndim == 3:
131
+ h, w, c = img.shape
132
+ if c == 3:
133
+ # assume RGB
134
+ return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888)
135
+ elif c == 4:
136
+ return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGBA8888)
137
+ else:
138
+ # collapse/expand as needed
139
+ if c == 1:
140
+ img = np.repeat(img, 3, axis=2)
141
+ h, w, _ = img.shape
142
+ return QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888)
143
+ # fallback empty
144
+ return QImage()
145
+
146
+ class MinorBodyWorker(QObject):
147
+ """
148
+ Runs the heavy minor-body prediction in a background thread.
149
+ Does NOT touch any widgets directly.
150
+ """
151
+ finished = pyqtSignal(list, str) # (bodies, error_message or "")
152
+ progress = pyqtSignal(int, str) # (percent, message)
153
+
154
+ def __init__(self, owner, jd_for_calc: float):
155
+ super().__init__()
156
+ self._owner = owner # SupernovaAsteroidHunterDialog
157
+ self._jd = jd_for_calc
158
+
159
+ def run(self):
160
+ try:
161
+ # Kick off with a low percentage
162
+ self.progress.emit(0, "Minor-body search: preparing catalog query...")
163
+ bodies = self._owner._get_predicted_minor_bodies_for_field(
164
+ H_ast_max=self._owner.minor_H_ast_max,
165
+ H_com_max=self._owner.minor_H_com_max,
166
+ jd=self._jd,
167
+ progress_cb=self.progress.emit, # pass our signal as callback
168
+ )
169
+ if bodies is None:
170
+ bodies = []
171
+ self.finished.emit(bodies, "")
172
+ except Exception as e:
173
+ self.finished.emit([], str(e))
174
+
175
+ class ZoomableImageView(QGraphicsView):
176
+ zoomChanged = pyqtSignal(float) # emits current scale (1.0 = 100%)
177
+
178
+ def __init__(self, parent=None):
179
+ super().__init__(parent)
180
+ self.setScene(QGraphicsScene(self))
181
+ self._pix = QGraphicsPixmapItem()
182
+ self.scene().addItem(self._pix)
183
+ self.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
184
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
185
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
186
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
187
+ self._fit_mode = False
188
+ self._scale = 1.0
189
+
190
+ def set_image(self, np_img_rgb_or_gray_uint8_or_float):
191
+ qimg = _numpy_to_qimage(np_img_rgb_or_gray_uint8_or_float)
192
+ pix = QPixmap.fromImage(qimg)
193
+ self._pix.setPixmap(pix)
194
+ self.scene().setSceneRect(QRectF(pix.rect()))
195
+ self.reset_view()
196
+
197
+ def reset_view(self):
198
+ self._fit_mode = False
199
+ self._scale = 1.0
200
+ self.setTransform(QTransform())
201
+ self.centerOn(self._pix)
202
+ self.zoomChanged.emit(self._scale)
203
+
204
+ def fit_to_view(self):
205
+ if self._pix.pixmap().isNull():
206
+ return
207
+ self._fit_mode = True
208
+ self.setTransform(QTransform())
209
+ self.fitInView(self._pix, Qt.AspectRatioMode.KeepAspectRatio)
210
+ # derive scale from transform.m11
211
+ self._scale = self.transform().m11()
212
+ self.zoomChanged.emit(self._scale)
213
+
214
+ def set_1to1(self):
215
+ self._fit_mode = False
216
+ self.setTransform(QTransform())
217
+ self._scale = 1.0
218
+ self.zoomChanged.emit(self._scale)
219
+
220
+ def zoom(self, factor: float, anchor_pos: QPointF | None = None):
221
+ if self._pix.pixmap().isNull():
222
+ return
223
+ self._fit_mode = False
224
+ # clamp
225
+ new_scale = self._scale * factor
226
+ new_scale = max(0.05, min(32.0, new_scale))
227
+ factor = new_scale / self._scale
228
+ if abs(factor - 1.0) < 1e-6:
229
+ return
230
+
231
+ # zoom around cursor
232
+ if anchor_pos is not None:
233
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
234
+ else:
235
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
236
+
237
+ self.scale(factor, factor)
238
+ self._scale = new_scale
239
+ self.zoomChanged.emit(self._scale)
240
+
241
+ # --- input handling ---
242
+ def wheelEvent(self, event):
243
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
244
+ delta = event.angleDelta().y()
245
+ step = 1.25 if delta > 0 else 0.8
246
+ self.zoom(step, anchor_pos=event.position())
247
+ event.accept()
248
+ else:
249
+ super().wheelEvent(event)
250
+
251
+ def mousePressEvent(self, event):
252
+ if event.button() == Qt.MouseButton.LeftButton:
253
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
254
+ self.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
255
+ super().mousePressEvent(event)
256
+
257
+ def mouseReleaseEvent(self, event):
258
+ super().mouseReleaseEvent(event)
259
+ self.setDragMode(QGraphicsView.DragMode.NoDrag)
260
+ self.viewport().unsetCursor()
261
+
262
+ def resizeEvent(self, event):
263
+ super().resizeEvent(event)
264
+ if self._fit_mode and not self._pix.pixmap().isNull():
265
+ # keep image fitted when the window is resized
266
+ # (doesn't steal state if user switched to manual zoom)
267
+ self.fit_to_view()
268
+
269
+ class ImagePreviewWindow(QDialog):
270
+ pushed = pyqtSignal(object, str) # (numpy_image, title)
271
+ minorBodySearchRequested = pyqtSignal() # emitted when user clicks MB button
272
+
273
+ def __init__(
274
+ self,
275
+ np_img_rgb_or_gray,
276
+ title="Preview",
277
+ parent=None,
278
+ icon: QIcon | None = None,
279
+ source_path: str | None = None,
280
+ ):
281
+ super().__init__(parent)
282
+ self.setWindowTitle(title)
283
+ if icon:
284
+ self.setWindowIcon(icon)
285
+
286
+ # This is the anomaly-marked image we want to push
287
+ self._original = np_img_rgb_or_gray
288
+ # Remember where it came from so we can re-load metadata
289
+ self._source_path = source_path
290
+
291
+ lay = QVBoxLayout(self)
292
+
293
+ # toolbar
294
+ tb = QToolBar(self)
295
+ self.act_fit = QAction("Fit", self)
296
+ self.act_1to1 = QAction("1:1", self)
297
+ self.act_zoom_in = QAction("Zoom In", self)
298
+ self.act_zoom_out = QAction("Zoom Out", self)
299
+ self.act_push = QAction("Push to New View", self)
300
+ # self.act_minor = QAction("Check Catalogued Minor Bodies in Field", self)
301
+
302
+ self.act_zoom_in.setShortcut("Ctrl++")
303
+ self.act_zoom_out.setShortcut("Ctrl+-")
304
+ self.act_fit.setShortcut("F")
305
+ self.act_1to1.setShortcut("1")
306
+
307
+ tb.addAction(self.act_fit)
308
+ tb.addAction(self.act_1to1)
309
+ tb.addSeparator()
310
+ tb.addAction(self.act_zoom_in)
311
+ tb.addAction(self.act_zoom_out)
312
+ tb.addSeparator()
313
+ tb.addAction(self.act_push)
314
+ # tb.addSeparator()
315
+ # tb.addAction(self.act_minor)
316
+
317
+ spacer = QWidget()
318
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
319
+ tb.addWidget(spacer)
320
+ self._zoom_label = QLabel("100%")
321
+ tb.addWidget(self._zoom_label)
322
+
323
+ lay.addWidget(tb)
324
+
325
+ self.view = ZoomableImageView(self)
326
+ lay.addWidget(self.view)
327
+ self.view.set_image(np_img_rgb_or_gray)
328
+ self.view.zoomChanged.connect(self._on_zoom_changed)
329
+
330
+ self.act_fit.triggered.connect(self.view.fit_to_view)
331
+ self.act_1to1.triggered.connect(self.view.set_1to1)
332
+ self.act_zoom_in.triggered.connect(lambda: self.view.zoom(1.25))
333
+ self.act_zoom_out.triggered.connect(lambda: self.view.zoom(0.8))
334
+ self.act_push.triggered.connect(self._on_push)
335
+ # self.act_minor.triggered.connect(self._on_minor_body_search)
336
+
337
+ self.view.fit_to_view()
338
+ self.resize(900, 700)
339
+
340
+ def _on_zoom_changed(self, s: float):
341
+ self._zoom_label.setText(f"{round(s*100)}%")
342
+
343
+ def _on_push(self):
344
+ # Emit the anomaly-marked image
345
+ self.pushed.emit(self._original, self.windowTitle())
346
+ QMessageBox.information(self, "Pushed", "New View Created.")
347
+
348
+
349
+ def _on_minor_body_search(self):
350
+ # Just emit a signal; the parent dialog will handle the heavy lifting.
351
+ self.minorBodySearchRequested.emit()
352
+
353
+ def showEvent(self, e):
354
+ super().showEvent(e)
355
+ # Defer one tick so the view has its final size
356
+ QTimer.singleShot(0, self.view.fit_to_view)
357
+
358
+
359
+ class SupernovaAsteroidHunterDialog(QDialog):
360
+ def __init__(self, parent=None, settings=None,
361
+ image_manager=None, doc_manager=None,
362
+ supernova_path=None, wrench_path=None, spinner_path=None):
363
+ super().__init__(parent)
364
+ self.setWindowTitle("Supernova / Asteroid Hunter")
365
+ if supernova_path:
366
+ self.setWindowIcon(QIcon(supernova_path))
367
+ # keep icon path for previews
368
+ self.supernova_path = supernova_path
369
+
370
+ self.settings = settings
371
+ self.image_manager = image_manager
372
+ self.doc_manager = doc_manager
373
+
374
+ # one layout for the dialog
375
+ self.setLayout(QVBoxLayout())
376
+
377
+ # state
378
+ self.parameters = {
379
+ "referenceImagePath": "",
380
+ "searchImagePaths": [],
381
+ "threshold": 0.10
382
+ }
383
+ self.preprocessed_reference = None
384
+ self.preprocessed_search = []
385
+ self.anomalyData = []
386
+
387
+ # WCS / timing / minor bodies
388
+ self.ref_header = None
389
+ self.ref_wcs = None
390
+ self.ref_jd = None
391
+ self.ref_site = None # you can fill this from settings later
392
+ self.predicted_minor_bodies = None
393
+
394
+ # default H limits for minor bodies (you can later expose via UI)
395
+ self.minor_H_ast_max = 20.0
396
+ self.minor_H_com_max = 15.0
397
+ self.minor_ast_max_count = 50000
398
+ self.minor_com_max_count = 5000
399
+ self.minor_time_offset_hours = 0.0
400
+ self.initUI()
401
+ self.resize(900, 700)
402
+
403
+ def initUI(self):
404
+ layout = self.layout()
405
+
406
+ # Instruction Label
407
+ instructions = QLabel(
408
+ "Select the reference image and search images. "
409
+ "Then click Process to hunt for anomalies."
410
+ )
411
+ layout.addWidget(instructions)
412
+
413
+ # --- Reference Image Selection ---
414
+ ref_layout = QHBoxLayout()
415
+ self.ref_line_edit = QLineEdit(self)
416
+ self.ref_line_edit.setPlaceholderText("No reference image selected")
417
+ self.ref_button = QPushButton("Select Reference Image", self)
418
+ self.ref_button.clicked.connect(self.selectReferenceImage)
419
+ ref_layout.addWidget(self.ref_line_edit)
420
+ ref_layout.addWidget(self.ref_button)
421
+ layout.addLayout(ref_layout)
422
+
423
+ # --- Search Images Selection ---
424
+ search_layout = QHBoxLayout()
425
+ self.search_list = QListWidget(self)
426
+ self.search_button = QPushButton("Select Search Images", self)
427
+ self.search_button.clicked.connect(self.selectSearchImages)
428
+ search_layout.addWidget(self.search_list)
429
+ search_layout.addWidget(self.search_button)
430
+ layout.addLayout(search_layout)
431
+
432
+ # --- Cosmetic Correction Checkbox ---
433
+ self.cosmetic_checkbox = QCheckBox(
434
+ "Apply Cosmetic Correction before Preprocessing", self
435
+ )
436
+ layout.addWidget(self.cosmetic_checkbox)
437
+
438
+ # --- Threshold Slider ---
439
+ thresh_layout = QHBoxLayout()
440
+ self.thresh_label = QLabel("Anomaly Detection Threshold: 0.10", self)
441
+ self.thresh_slider = QSlider(Qt.Orientation.Horizontal, self)
442
+ self.thresh_slider.setMinimum(1)
443
+ self.thresh_slider.setMaximum(50) # Represents 0.01 to 0.50
444
+ self.thresh_slider.setValue(10) # 10 => 0.10 threshold
445
+ self.thresh_slider.valueChanged.connect(self.updateThreshold)
446
+ thresh_layout.addWidget(self.thresh_label)
447
+ thresh_layout.addWidget(self.thresh_slider)
448
+ layout.addLayout(thresh_layout)
449
+
450
+ # --- Process Button ---
451
+ self.process_button = QPushButton(
452
+ "Process (Cosmetic Correction, Preprocess, and Search)", self
453
+ )
454
+ self.process_button.clicked.connect(self.process)
455
+ layout.addWidget(self.process_button)
456
+
457
+ # --- Progress Labels ---
458
+ self.preprocess_progress_label = QLabel("Preprocessing progress: 0 / 0", self)
459
+ self.search_progress_label = QLabel("Processing progress: 0 / 0", self)
460
+ layout.addWidget(self.preprocess_progress_label)
461
+ layout.addWidget(self.search_progress_label)
462
+
463
+ # -- Status label --
464
+ self.status_label = QLabel("Status: Idle", self)
465
+ layout.addWidget(self.status_label)
466
+
467
+ # Minor-body progress bar (hidden by default)
468
+ self.minor_progress = QProgressBar(self)
469
+ self.minor_progress.setRange(0, 100)
470
+ self.minor_progress.setValue(0)
471
+ self.minor_progress.setVisible(False)
472
+ layout.addWidget(self.minor_progress)
473
+
474
+ # --- New Instance Button ---
475
+ self.new_instance_button = QPushButton("New Instance", self)
476
+ self.new_instance_button.clicked.connect(self.newInstance)
477
+ layout.addWidget(self.new_instance_button)
478
+
479
+ self.setLayout(layout)
480
+ self.setWindowTitle("Supernova/Asteroid Hunter")
481
+
482
+
483
+
484
+ def updateThreshold(self, value):
485
+ threshold = value / 100.0 # e.g. slider value 10 becomes 0.10
486
+ self.parameters["threshold"] = threshold
487
+ self.thresh_label.setText(f"Anomaly Detection Threshold: {threshold:.2f}")
488
+
489
+ def selectReferenceImage(self):
490
+ file_path, _ = QFileDialog.getOpenFileName(self, "Select Reference Image", "",
491
+ "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)")
492
+ if file_path:
493
+ self.parameters["referenceImagePath"] = file_path
494
+ self.ref_line_edit.setText(os.path.basename(file_path))
495
+
496
+ def selectSearchImages(self):
497
+ file_paths, _ = QFileDialog.getOpenFileNames(self, "Select Search Images", "",
498
+ "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)")
499
+ if file_paths:
500
+ self.parameters["searchImagePaths"] = file_paths
501
+ self.search_list.clear()
502
+ for path in file_paths:
503
+ self.search_list.addItem(os.path.basename(path))
504
+
505
+ def process(self):
506
+ self.status_label.setText("Process started...")
507
+ QApplication.processEvents()
508
+
509
+ # If cosmetic correction is enabled, run it first
510
+ if self.cosmetic_checkbox.isChecked():
511
+ self.status_label.setText("Running Cosmetic Correction...")
512
+ QApplication.processEvents()
513
+ self.runCosmeticCorrectionIfNeeded()
514
+
515
+ self.status_label.setText("Preprocessing images...")
516
+ QApplication.processEvents()
517
+ self.preprocessImages()
518
+
519
+ self.status_label.setText("Analyzing anomalies...")
520
+ QApplication.processEvents()
521
+ self.runSearch()
522
+
523
+ self.status_label.setText("Process complete.")
524
+ QApplication.processEvents()
525
+
526
+
527
+ def runCosmeticCorrectionIfNeeded(self):
528
+ """
529
+ Runs cosmetic correction on each search image...
530
+ """
531
+ # Dictionary to hold corrected images
532
+ self.cosmetic_images = {}
533
+
534
+ for idx, image_path in enumerate(self.parameters["searchImagePaths"]):
535
+ try:
536
+ # Update status label to show which image is being handled
537
+ self.status_label.setText(f"Cosmetic Correction: {idx+1}/{len(self.parameters['searchImagePaths'])} => {os.path.basename(image_path)}")
538
+ QApplication.processEvents()
539
+
540
+ img, header, bit_depth, is_mono = load_image(image_path)
541
+ if img is None:
542
+ print(f"Unable to load image: {image_path}")
543
+ continue
544
+
545
+ # Numba correction
546
+ corrected = bulk_cosmetic_correction_numba(
547
+ img,
548
+ hot_sigma=5.0,
549
+ cold_sigma=5.0,
550
+ window_size=3
551
+ )
552
+ self.cosmetic_images[image_path] = corrected
553
+ print(f"Cosmetic correction (Numba) applied to: {image_path}")
554
+
555
+ except Exception as e:
556
+ print(f"Error in cosmetic correction for {image_path}: {e}")
557
+
558
+
559
+ def preprocessImages(self):
560
+ # Update status label for reference image
561
+ self.status_label.setText("Preprocessing reference image...")
562
+ print("[Preprocessing] Preprocessing reference image...")
563
+ QApplication.processEvents()
564
+
565
+ ref_path = self.parameters["referenceImagePath"]
566
+ if not ref_path:
567
+ QMessageBox.warning(self, "Error", "No reference image selected.")
568
+ return
569
+
570
+ try:
571
+ # --- Load reference with metadata so we can grab header / XISF info ---
572
+ ref_res = load_image(ref_path, return_metadata=True)
573
+ if not ref_res or ref_res[0] is None:
574
+ raise ValueError("load_image() returned no data for reference image.")
575
+
576
+ ref_img, header, bit_depth, is_mono, meta = ref_res
577
+
578
+ # Prefer synthesized FITS header from meta if present
579
+ self.ref_header = meta.get("fits_header", header) if isinstance(meta, dict) else header
580
+
581
+ # Try to build WCS directly from header (if it already has one).
582
+ try:
583
+ self.ref_wcs = WCS(self.ref_header)
584
+ except Exception:
585
+ self.ref_wcs = None
586
+
587
+ # --- Derive mid-exposure JD ---
588
+ self.ref_jd = None
589
+
590
+ # 1) XISF-aware path: use FITSKeywords (DATE-OBS + EXPOSURE/EXPTIME)
591
+ if isinstance(meta, dict):
592
+ ensure_jd_from_xisf_meta(meta)
593
+ jd_val = meta.get("jd", None)
594
+ if jd_val is not None:
595
+ self.ref_jd = float(jd_val)
596
+
597
+ # 2) FITS-style fallback from header (for non-XISF, or if XISF path failed)
598
+ if self.ref_jd is None and isinstance(self.ref_header, (dict, Header)):
599
+ try:
600
+ date_obs = self.ref_header.get("DATE-OBS")
601
+ exptime = float(
602
+ self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
603
+ )
604
+ if date_obs:
605
+ t = Time(str(date_obs), scale="utc")
606
+ # mid-exposure
607
+ t_mid = t + (exptime / 2.0) * u.s
608
+ self.ref_jd = float(t_mid.tt.jd)
609
+ except Exception:
610
+ self.ref_jd = None
611
+
612
+ print(f"[Preprocessing] ref JD={self.ref_jd!r}")
613
+ print("[Preprocessing] (Minor-body prediction is now manual only.)")
614
+
615
+ # --- Background neutralization + ABE + stretch for reference ---
616
+ debug_prefix_ref = os.path.splitext(ref_path)[0] + "_debug_ref"
617
+
618
+ self.status_label.setText(
619
+ "Applying background neutralization & ABE on reference..."
620
+ )
621
+ QApplication.processEvents()
622
+
623
+ ref_processed = self.preprocessImage(ref_img, debug_prefix=debug_prefix_ref)
624
+ self.preprocessed_reference = ref_processed
625
+ self.preprocess_progress_label.setText(
626
+ "Preprocessing reference image... Done."
627
+ )
628
+
629
+ except Exception as e:
630
+ QMessageBox.critical(self, "Error", f"Failed to preprocess reference image: {e}")
631
+ return
632
+
633
+ # --- Preprocess search images ---
634
+ self.preprocessed_search = []
635
+ search_paths = self.parameters["searchImagePaths"]
636
+ total = len(search_paths)
637
+
638
+ for i, path in enumerate(search_paths):
639
+ try:
640
+ self.status_label.setText(
641
+ f"Preprocessing search image {i+1}/{total} => {os.path.basename(path)}"
642
+ )
643
+ QApplication.processEvents()
644
+
645
+ debug_prefix_search = os.path.splitext(path)[0] + f"_debug_search_{i+1}"
646
+
647
+ if hasattr(self, "cosmetic_images") and path in self.cosmetic_images:
648
+ img = self.cosmetic_images[path]
649
+ else:
650
+ img, header, bit_depth, is_mono = load_image(path)
651
+
652
+ processed = self.preprocessImage(img, debug_prefix=debug_prefix_search)
653
+ self.preprocessed_search.append({"path": path, "image": processed})
654
+
655
+ self.preprocess_progress_label.setText(
656
+ f"Preprocessing image {i+1} of {total}... Done."
657
+ )
658
+ QApplication.processEvents()
659
+
660
+ except Exception as e:
661
+ print(f"Failed to preprocess {path}: {e}")
662
+
663
+ self.status_label.setText("All search images preprocessed.")
664
+ QApplication.processEvents()
665
+
666
+ def _ensure_wcs(self, ref_path: str):
667
+ """
668
+ Ensure we have a WCS (and, if possible, JD) for the reference frame.
669
+ This does NOT do any minor-body catalog work.
670
+ """
671
+ # If we already have a WCS and header, don't re-solve.
672
+ if self.ref_wcs is not None and self.ref_header is not None:
673
+ return
674
+
675
+ try:
676
+ image_data, original_header, bit_depth, is_mono = load_image(ref_path)
677
+ except Exception as e:
678
+ print(f"[SupernovaHunter] Failed to load reference image for plate solve: {e}")
679
+ self.ref_wcs = None
680
+ return
681
+
682
+ if image_data is None:
683
+ print("[SupernovaHunter] Reference image is unsupported or unreadable for plate solve.")
684
+ self.ref_wcs = None
685
+ return
686
+
687
+ # Seed header from original_header (dict/Header/etc.)
688
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
689
+
690
+ # Acquisition base for merge (strip any existing WCS)
691
+ acq_base: Header | None = None
692
+ if isinstance(seed_h, Header):
693
+ acq_base = _strip_wcs_keys(seed_h)
694
+
695
+ # Run the same solver core used by PlateSolverDialog
696
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
697
+ if not ok:
698
+ print(f"[SupernovaHunter] Plate solve failed for {ref_path}: {res}")
699
+ self.ref_wcs = None
700
+ return
701
+
702
+ solver_hdr: Header = res if isinstance(res, Header) else Header()
703
+
704
+ # Merge solver WCS into acquisition header
705
+ if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
706
+ hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
707
+ else:
708
+ hdr_final = solver_hdr
709
+
710
+ self.ref_header = hdr_final
711
+ try:
712
+ self.ref_wcs = WCS(hdr_final)
713
+ except Exception as e:
714
+ print("[SupernovaHunter] WCS build failed after plate solve:", e)
715
+ self.ref_wcs = None
716
+
717
+ # If we still lack JD, try to derive it from the header
718
+ if self.ref_jd is None and isinstance(self.ref_header, Header):
719
+ try:
720
+ date_obs = self.ref_header.get("DATE-OBS")
721
+ exptime = float(
722
+ self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
723
+ )
724
+ if date_obs:
725
+ t = Time(str(date_obs), scale="utc")
726
+ t_mid = t + (exptime / 2.0) * u.s
727
+ self.ref_jd = float(t_mid.tt.jd)
728
+ except Exception:
729
+ pass
730
+
731
+ def _prompt_minor_body_limits(self) -> bool:
732
+ """
733
+ Modal dialog to configure minor-body search limits.
734
+
735
+ Returns True if the user pressed OK (and updates self.* attributes),
736
+ False if they cancelled.
737
+ """
738
+ dlg = QDialog(self)
739
+ dlg.setWindowTitle("Minor-body Search Limits")
740
+ layout = QVBoxLayout(dlg)
741
+
742
+ row_layout = QGridLayout()
743
+ layout.addLayout(row_layout)
744
+
745
+ # Defaults / existing values
746
+ ast_H_default = getattr(self, "minor_H_ast_max", 9.0)
747
+ com_H_default = getattr(self, "minor_H_com_max", 10.0)
748
+ ast_max_default = getattr(self, "minor_ast_max_count", 5000)
749
+ com_max_default = getattr(self, "minor_com_max_count", 1000)
750
+
751
+ # Time offset in *hours* now; if old days-based attr exists, convert.
752
+ if hasattr(self, "minor_time_offset_hours"):
753
+ dt_default = float(self.minor_time_offset_hours)
754
+ else:
755
+ dt_default = float(getattr(self, "minor_time_offset_days", 0.0)) * 24.0
756
+
757
+ # Row 0: Asteroids
758
+ row_layout.addWidget(QLabel("Asteroid H ≤"), 0, 0)
759
+ ast_H_spin = QDoubleSpinBox(dlg)
760
+ ast_H_spin.setDecimals(1)
761
+ ast_H_spin.setRange(-5.0, 40.0)
762
+ ast_H_spin.setSingleStep(0.1)
763
+ ast_H_spin.setValue(ast_H_default)
764
+ row_layout.addWidget(ast_H_spin, 0, 1)
765
+
766
+ row_layout.addWidget(QLabel("Max asteroid"), 0, 2)
767
+ ast_max_spin = QSpinBox(dlg)
768
+ ast_max_spin.setRange(1, 2000000)
769
+ ast_max_spin.setValue(ast_max_default)
770
+ row_layout.addWidget(ast_max_spin, 0, 3)
771
+
772
+ # Row 1: Comets
773
+ row_layout.addWidget(QLabel("Comet H ≤"), 1, 0)
774
+ com_H_spin = QDoubleSpinBox(dlg)
775
+ com_H_spin.setDecimals(1)
776
+ com_H_spin.setRange(-5.0, 40.0)
777
+ com_H_spin.setSingleStep(0.1)
778
+ com_H_spin.setValue(com_H_default)
779
+ row_layout.addWidget(com_H_spin, 1, 1)
780
+
781
+ row_layout.addWidget(QLabel("Max comet"), 1, 2)
782
+ com_max_spin = QSpinBox(dlg)
783
+ com_max_spin.setRange(1, 200000)
784
+ com_max_spin.setValue(com_max_default)
785
+ row_layout.addWidget(com_max_spin, 1, 3)
786
+
787
+ # Row 2: Time offset (hours)
788
+ row_layout.addWidget(QLabel("Time offset (hours)"), 2, 0)
789
+ dt_spin = QDoubleSpinBox(dlg)
790
+ dt_spin.setDecimals(1)
791
+ dt_spin.setRange(-72.0, 72.0) # ±3 days in hours
792
+ dt_spin.setSingleStep(1.0)
793
+ dt_spin.setValue(dt_default)
794
+ row_layout.addWidget(dt_spin, 2, 1, 1, 3)
795
+
796
+ # Buttons
797
+ btn_row = QHBoxLayout()
798
+ layout.addLayout(btn_row)
799
+ btn_row.addStretch(1)
800
+ ok_btn = QPushButton("OK", dlg)
801
+ cancel_btn = QPushButton("Cancel", dlg)
802
+ btn_row.addWidget(ok_btn)
803
+ btn_row.addWidget(cancel_btn)
804
+
805
+ def on_ok():
806
+ self.minor_H_ast_max = float(ast_H_spin.value())
807
+ self.minor_H_com_max = float(com_H_spin.value())
808
+ self.minor_ast_max_count = int(ast_max_spin.value())
809
+ self.minor_com_max_count = int(com_max_spin.value())
810
+ hours = float(dt_spin.value())
811
+ self.minor_time_offset_hours = hours
812
+ # backward compat if anything still reads the old name:
813
+ self.minor_time_offset_days = hours / 24.0
814
+ dlg.accept()
815
+
816
+ def on_cancel():
817
+ dlg.reject()
818
+
819
+ ok_btn.clicked.connect(on_ok)
820
+ cancel_btn.clicked.connect(on_cancel)
821
+
822
+ return dlg.exec() == QDialog.DialogCode.Accepted
823
+
824
+ def _on_minor_body_progress(self, pct: int, msg: str):
825
+ self.status_label.setText(msg)
826
+ if hasattr(self, "minor_progress"):
827
+ self.minor_progress.setVisible(True)
828
+ self.minor_progress.setValue(int(pct))
829
+ QApplication.processEvents()
830
+
831
+ def _on_minor_body_finished(self, bodies: list, error: str):
832
+ if hasattr(self, "minor_progress"):
833
+ # show as done, then hide
834
+ self.minor_progress.setValue(100 if not error else 0)
835
+ self.minor_progress.setVisible(False)
836
+ if error:
837
+ print("[MinorBodies] prediction failed:", error)
838
+ QMessageBox.critical(
839
+ self,
840
+ "Minor-body Search",
841
+ f"Minor-body prediction failed:\n{error}"
842
+ )
843
+ self.status_label.setText("Minor-body search failed.")
844
+ return
845
+
846
+ self.predicted_minor_bodies = bodies or []
847
+
848
+ if not self.predicted_minor_bodies:
849
+ self.status_label.setText(
850
+ "Minor-body search complete: no catalogued objects in this field "
851
+ "for the current magnitude limits."
852
+ )
853
+ QMessageBox.information(
854
+ self,
855
+ "Minor-body Search",
856
+ "No catalogued minor bodies (within the configured magnitude limits) "
857
+ "were found in this field."
858
+ )
859
+ return
860
+
861
+ self.status_label.setText(
862
+ f"Minor-body search complete: {len(self.predicted_minor_bodies)} objects in field."
863
+ )
864
+ QApplication.processEvents()
865
+
866
+ # Now cross-match on the UI thread if we already have anomalies
867
+ try:
868
+ if self.anomalyData:
869
+ print(f"[MinorBodies] cross-matching anomalies to "
870
+ f"{len(self.predicted_minor_bodies)} predicted bodies...")
871
+ self._match_anomalies_to_minor_bodies(
872
+ self.predicted_minor_bodies,
873
+ search_radius_arcsec=60.0
874
+ )
875
+ self.showDetailedResultsDialog(self.anomalyData)
876
+ else:
877
+ QMessageBox.information(
878
+ self,
879
+ "Minor-body Search",
880
+ "Minor bodies in field have been computed.\n\n"
881
+ "Run the anomaly search (Process) to cross-match detections "
882
+ "against the predicted objects."
883
+ )
884
+ except Exception as e:
885
+ print("[MinorBodies] cross-match failed:", e)
886
+
887
+
888
+ def runMinorBodySearch(self):
889
+ """
890
+ Optional, slow step:
891
+ - Ensure we have WCS + JD for the reference frame (plate-solve if needed).
892
+ - Ask the user for H limits / max counts.
893
+ - Query the minor-body catalog and compute predicted objects in the FOV.
894
+ - Cross-match with existing anomalies (if any) and refresh the summary dialog.
895
+ """
896
+ ref_path = self.parameters.get("referenceImagePath") or ""
897
+ if not ref_path:
898
+ QMessageBox.warning(
899
+ self,
900
+ "Minor-body Search",
901
+ "No reference image selected.\n\n"
902
+ "Please select a reference image and run Process first."
903
+ )
904
+ return
905
+
906
+ if self.preprocessed_reference is None:
907
+ QMessageBox.warning(
908
+ self,
909
+ "Minor-body Search",
910
+ "Reference image has not been preprocessed yet.\n\n"
911
+ "Please click 'Process' before running the minor-body search."
912
+ )
913
+ return
914
+
915
+ if self.settings is None:
916
+ QMessageBox.warning(
917
+ self,
918
+ "Minor-body Search",
919
+ "Settings object is not available; cannot locate the minor-body database path."
920
+ )
921
+ return
922
+
923
+ # Configure limits (H, max counts, time offset)
924
+ if not self._prompt_minor_body_limits():
925
+ # user cancelled
926
+ return
927
+
928
+ # Step 1: Ensure WCS (plate-solve if necessary)
929
+ self.status_label.setText("Minor-body search: solving plate / ensuring WCS...")
930
+ QApplication.processEvents()
931
+
932
+ self._ensure_wcs(ref_path)
933
+
934
+ if self.ref_wcs is None:
935
+ QMessageBox.warning(
936
+ self,
937
+ "Minor-body Search",
938
+ "No valid WCS (astrometric solution) is available for the reference image.\n\n"
939
+ "Minor-body prediction requires a solved WCS."
940
+ )
941
+ self.status_label.setText("Minor-body search aborted: no WCS.")
942
+ return
943
+
944
+ # Ensure we have JD (time of observation) for ephemerides
945
+ if self.ref_jd is None:
946
+ QMessageBox.warning(
947
+ self,
948
+ "Minor-body Search",
949
+ "No valid observation time (JD) is available for the reference image.\n\n"
950
+ "Minor-body prediction requires DATE-OBS/EXPTIME or equivalent."
951
+ )
952
+ self.status_label.setText("Minor-body search aborted: no JD.")
953
+ return
954
+
955
+ # Optional observatory site
956
+ try:
957
+ print("[MinorBodies] fetching observatory site from settings...")
958
+ lat = self.settings.value("site/latitude_deg", None, type=float)
959
+ lon = self.settings.value("site/longitude_deg", None, type=float)
960
+ elev = self.settings.value("site/elevation_m", 0.0, type=float)
961
+ if lat is not None and lon is not None:
962
+ self.ref_site = (lat, lon, elev)
963
+ else:
964
+ self.ref_site = None
965
+ except Exception as e:
966
+ print("[MinorBodies] failed to fetch observatory site from settings:", e)
967
+ self.ref_site = None
968
+
969
+ # JD adjusted by time offset (hours → days)
970
+ offset_hours = getattr(self, "minor_time_offset_hours", 0.0)
971
+ jd_for_calc = self.ref_jd + (offset_hours / 24.0)
972
+
973
+ # Kick off the heavy catalog + ephemeris work in a background thread
974
+ self.status_label.setText(
975
+ "Minor-body search: starting background catalog query..."
976
+ )
977
+ QApplication.processEvents()
978
+ if hasattr(self, "minor_progress"):
979
+ self.minor_progress.setVisible(True)
980
+ self.minor_progress.setValue(0)
981
+
982
+ self._mb_thread = QThread(self)
983
+ self._mb_worker = MinorBodyWorker(self, jd_for_calc)
984
+ self._mb_worker.moveToThread(self._mb_thread)
985
+
986
+ # Wire up thread lifecycle
987
+ self._mb_thread.started.connect(self._mb_worker.run)
988
+ self._mb_worker.progress.connect(self._on_minor_body_progress)
989
+ self._mb_worker.finished.connect(self._on_minor_body_finished)
990
+ self._mb_worker.finished.connect(self._mb_thread.quit)
991
+ self._mb_worker.finished.connect(self._mb_worker.deleteLater)
992
+ self._mb_thread.finished.connect(self._mb_thread.deleteLater)
993
+
994
+ self._mb_thread.start()
995
+
996
+ def _get_predicted_minor_bodies_for_field(
997
+ self,
998
+ H_ast_max: float,
999
+ H_com_max: float,
1000
+ jd: float | None = None,
1001
+ progress_cb=None,
1002
+ ):
1003
+ """
1004
+ Return a list of predicted minor bodies in the current ref image FOV
1005
+ at 'jd' (or self.ref_jd if jd is None), with pixel coords.
1006
+ """
1007
+ # Need WCS and an image
1008
+ if self.ref_wcs is None or self.preprocessed_reference is None:
1009
+ return []
1010
+
1011
+ def emit(pct, msg):
1012
+ if progress_cb is not None:
1013
+ try:
1014
+ progress_cb(int(pct), msg)
1015
+ except TypeError:
1016
+ # fallback if callback only wants a message
1017
+ progress_cb(msg)
1018
+
1019
+
1020
+ # Resolve JD: explicit first, then self.ref_jd
1021
+ if jd is None:
1022
+ jd = self.ref_jd
1023
+ if jd is None:
1024
+ return []
1025
+
1026
+ if self.settings is None:
1027
+ print("[MinorBodies] settings object is None; cannot resolve DB path.")
1028
+ return []
1029
+
1030
+ # Per-type max counts with safe defaults
1031
+ ast_limit = getattr(self, "minor_ast_max_count", 50000)
1032
+ com_limit = getattr(self, "minor_com_max_count", 5000)
1033
+
1034
+ # 1) open DB (reuse WIMI’s ensure logic)
1035
+ emit(5, "Minor-body search: opening minor-body database...")
1036
+ try:
1037
+ data_dir = Path(
1038
+ self.settings.value("wimi/minorbody_data_dir", "", type=str)
1039
+ or os.path.join(os.path.expanduser("~"), ".saspro_minor_bodies")
1040
+ )
1041
+ db_path, manifest = mbc.ensure_minor_body_db(data_dir)
1042
+ catalog = mbc.MinorBodyCatalog(db_path)
1043
+ except Exception as e:
1044
+ print("[MinorBodies] could not open DB:", e)
1045
+ emit(100, "Minor-body search: failed to open database.")
1046
+ return []
1047
+
1048
+ try:
1049
+ emit(20, "Minor-body search: selecting bright asteroids/comets...")
1050
+ ast_df = catalog.get_bright_asteroids(H_max=H_ast_max, limit=ast_limit)
1051
+ com_df = catalog.get_bright_comets(H_max=H_com_max, limit=com_limit)
1052
+
1053
+ emit(40, "Minor-body search: computing asteroid positions...")
1054
+ ast_pos = catalog.compute_positions_skyfield(
1055
+ ast_df,
1056
+ jd,
1057
+ topocentric=self.ref_site,
1058
+ debug=False,
1059
+ )
1060
+ emit(60, "Minor-body search: computing comet positions...")
1061
+ com_pos = catalog.compute_positions_skyfield(
1062
+ com_df,
1063
+ jd,
1064
+ topocentric=self.ref_site,
1065
+ debug=False,
1066
+ )
1067
+
1068
+ emit(80, "Minor-body search: projecting onto image pixels...")
1069
+
1070
+ # 4) map RA/Dec -> pixel with ref WCS, and drop those outside FOV
1071
+ h, w = self.preprocessed_reference.shape[:2]
1072
+ bodies = []
1073
+ for src, kind, df in (
1074
+ (ast_pos, "asteroid", ast_df),
1075
+ (com_pos, "comet", com_df),
1076
+ ):
1077
+ df_by_name = {row["designation"]: row for _, row in df.iterrows()}
1078
+ for row in src:
1079
+ ra = row["ra_deg"]
1080
+ dec = row["dec_deg"]
1081
+ x, y = self.ref_wcs.world_to_pixel_values(ra, dec)
1082
+ if 0 <= x < w and 0 <= y < h:
1083
+ base = df_by_name.get(row["designation"], {})
1084
+ bodies.append({
1085
+ "designation": row["designation"],
1086
+ "kind": kind,
1087
+ "ra_deg": ra,
1088
+ "dec_deg": dec,
1089
+ "x": float(x),
1090
+ "y": float(y),
1091
+ "H": float(base.get("magnitude_H", np.nan)),
1092
+ "distance_au": row.get("distance_au", np.nan),
1093
+ })
1094
+ emit(100, "Minor-body search: finished computing positions.")
1095
+ return bodies
1096
+ finally:
1097
+ try:
1098
+ catalog.close()
1099
+ except Exception:
1100
+ pass
1101
+
1102
+
1103
+ def preprocessImage(self, img, debug_prefix=None):
1104
+ """
1105
+ Runs the full preprocessing chain on a single image:
1106
+ 1. Background Neutralization
1107
+ 2. Automatic Background Extraction (ABE)
1108
+ 3. Pixel-math stretching
1109
+
1110
+ Optionally saves debug images if debug_prefix is provided.
1111
+ """
1112
+
1113
+
1114
+ # --- Step 1: Background Neutralization ---
1115
+ if img.ndim == 3 and img.shape[2] == 3:
1116
+ h, w, _ = img.shape
1117
+ sample_x = int(w * 0.45)
1118
+ sample_y = int(h * 0.45)
1119
+ sample_w = max(1, int(w * 0.1))
1120
+ sample_h = max(1, int(h * 0.1))
1121
+ sample_region = img[sample_y:sample_y+sample_h, sample_x:sample_x+sample_w, :]
1122
+ medians = np.median(sample_region, axis=(0, 1))
1123
+ average_median = np.mean(medians)
1124
+ neutralized = img.copy()
1125
+ for c in range(3):
1126
+ diff = medians[c] - average_median
1127
+ numerator = neutralized[:, :, c] - diff
1128
+ denominator = 1.0 - diff
1129
+ if abs(denominator) < 1e-8:
1130
+ denominator = 1e-8
1131
+ neutralized[:, :, c] = np.clip(numerator / denominator, 0, 1)
1132
+ else:
1133
+ neutralized = img
1134
+
1135
+
1136
+ # --- Step 2: Automatic Background Extraction (ABE) ---
1137
+ pgr = PolyGradientRemoval(
1138
+ neutralized,
1139
+ poly_degree=2, # or pass in a user choice
1140
+ downsample_scale=4,
1141
+ num_sample_points=100
1142
+ )
1143
+ abe = pgr.process() # returns final polynomial-corrected image in original domain
1144
+
1145
+
1146
+ # --- Step 3: Pixel Math Stretch ---
1147
+ stretched = self.pixel_math_stretch(abe)
1148
+
1149
+ return stretched
1150
+
1151
+
1152
+
1153
+ def pixel_math_stretch(self, image):
1154
+ """
1155
+ Replaces the old pixel math stretch logic by using the existing
1156
+ stretch_mono_image or stretch_color_image methods.
1157
+ """
1158
+ # Choose a target median (the default you’ve used elsewhere is often 0.25)
1159
+ target_median = 0.25
1160
+
1161
+ # Check if the image is mono or color
1162
+ if image.ndim == 2 or (image.ndim == 3 and image.shape[2] == 1):
1163
+ # Treat it as mono
1164
+ stretched = stretch_mono_image(
1165
+ image.squeeze(), # squeeze in case it's (H,W,1)
1166
+ target_median=target_median,
1167
+ normalize=False, # Adjust if you want normalization
1168
+ apply_curves=False,
1169
+ curves_boost=0.0
1170
+ )
1171
+ # If it was (H,W,1), replicate to 3 channels (optional)
1172
+ # or just keep it mono if you prefer
1173
+ # For now, replicate to 3 channels:
1174
+ stretched = np.stack([stretched]*3, axis=-1)
1175
+ else:
1176
+ # Full-color image
1177
+ stretched = stretch_color_image(
1178
+ image,
1179
+ target_median=target_median,
1180
+ linked=False, # or False if you want per-channel stretches
1181
+ normalize=False,
1182
+ apply_curves=False,
1183
+ curves_boost=0.0
1184
+ )
1185
+
1186
+ return np.clip(stretched, 0, 1)
1187
+
1188
+ def runSearch(self):
1189
+ if self.preprocessed_reference is None:
1190
+ QMessageBox.warning(self, "Error", "Reference image not preprocessed.")
1191
+ return
1192
+ if not self.preprocessed_search:
1193
+ QMessageBox.warning(self, "Error", "No search images preprocessed.")
1194
+ return
1195
+
1196
+ ref_gray = self.to_grayscale(self.preprocessed_reference)
1197
+
1198
+ self.anomalyData = []
1199
+ total = len(self.preprocessed_search)
1200
+ for i, search_dict in enumerate(self.preprocessed_search):
1201
+ search_img = search_dict["image"]
1202
+ search_gray = self.to_grayscale(search_img)
1203
+
1204
+ diff_img = self.subtractImagesOnce(search_gray, ref_gray)
1205
+ anomalies = self.detectAnomaliesConnected(
1206
+ diff_img,
1207
+ threshold=self.parameters["threshold"],
1208
+ )
1209
+
1210
+ self.anomalyData.append({
1211
+ "imageName": os.path.basename(search_dict["path"]),
1212
+ "anomalyCount": len(anomalies),
1213
+ "anomalies": anomalies,
1214
+ })
1215
+
1216
+ self.search_progress_label.setText(f"Processing image {i+1} of {total}...")
1217
+ QApplication.processEvents()
1218
+
1219
+ self.search_progress_label.setText("Search for anomalies complete.")
1220
+
1221
+ # Minor-body cross-match (optional)
1222
+ try:
1223
+ bodies = getattr(self, "predicted_minor_bodies", None)
1224
+ if bodies:
1225
+ print(f"[MinorBodies] cross-matching anomalies to {len(bodies)} predicted bodies...")
1226
+ self._match_anomalies_to_minor_bodies(bodies, search_radius_arcsec=60.0)
1227
+ except Exception as e:
1228
+ print("[MinorBodies] cross-match failed:", e)
1229
+
1230
+ # Show text-based summary & tree
1231
+ self.showDetailedResultsDialog(self.anomalyData)
1232
+ self.showAnomalyListDialog()
1233
+
1234
+ def showAnomalyListDialog(self):
1235
+ """
1236
+ Build a QDialog with a QTreeWidget listing each image and its anomaly count.
1237
+ Double-clicking an item will open a non-modal preview.
1238
+ """
1239
+ if not self.anomalyData:
1240
+ QMessageBox.information(self, "Info", "No anomalies or no images processed.")
1241
+ return
1242
+
1243
+ dialog = QDialog(self)
1244
+ dialog.setWindowTitle("Anomaly Results")
1245
+
1246
+ layout = QVBoxLayout(dialog)
1247
+
1248
+ self.anomaly_tree = QTreeWidget(dialog)
1249
+ self.anomaly_tree.setColumnCount(2)
1250
+ self.anomaly_tree.setHeaderLabels(["Image", "Anomaly Count"])
1251
+ layout.addWidget(self.anomaly_tree)
1252
+
1253
+ # Populate the tree
1254
+ for i, data in enumerate(self.anomalyData):
1255
+ item = QTreeWidgetItem([
1256
+ data["imageName"],
1257
+ str(data["anomalyCount"])
1258
+ ])
1259
+ # Store an index or reference so we know which image to open
1260
+ item.setData(0, Qt.ItemDataRole.UserRole, i)
1261
+ self.anomaly_tree.addTopLevelItem(item)
1262
+
1263
+ # Connect double-click
1264
+ self.anomaly_tree.itemDoubleClicked.connect(self.onAnomalyItemDoubleClicked)
1265
+
1266
+ dialog.setLayout(layout)
1267
+ dialog.resize(300, 200)
1268
+ dialog.show() # non-modal, so the user can keep using the main window
1269
+
1270
+ def onAnomalyItemDoubleClicked(self, item, column):
1271
+ idx = item.data(0, Qt.ItemDataRole.UserRole)
1272
+ if idx is None:
1273
+ return
1274
+
1275
+ anomalies = self.anomalyData[idx]["anomalies"]
1276
+ image_name = self.anomalyData[idx]["imageName"]
1277
+
1278
+ entry = self.preprocessed_search[idx]
1279
+ search_img = entry["image"] # stretched float [0..1]
1280
+ source_path = entry["path"] # original file path
1281
+
1282
+ # Show zoomable preview with overlays, remembering which file it came from
1283
+ self.showAnomaliesOnImage(
1284
+ search_img,
1285
+ anomalies,
1286
+ window_title=f"Anomalies in {image_name}",
1287
+ source_path=source_path,
1288
+ )
1289
+
1290
+
1291
+ def _match_anomalies_to_minor_bodies(self, bodies, search_radius_arcsec=20.0):
1292
+ """
1293
+ For each anomaly, compute center pixel and find
1294
+ all predicted minor bodies within search_radius_arcsec.
1295
+
1296
+ Adds:
1297
+ - anomaly["matched_bodies"] = [body, ...]
1298
+ - anomaly["matched_body"] = closest body or None
1299
+ """
1300
+ if self.ref_wcs is None or not bodies:
1301
+ return
1302
+
1303
+ # search radius in pixels — crude average plate scale from WCS
1304
+ try:
1305
+ cd = self.ref_wcs.pixel_scale_matrix # 2x2
1306
+ from numpy.linalg import det
1307
+ deg_per_pix = np.sqrt(abs(det(cd)))
1308
+ arcsec_per_pix = deg_per_pix * 3600.0
1309
+ except Exception:
1310
+ arcsec_per_pix = 1.0 # fallback
1311
+
1312
+ pix_radius = search_radius_arcsec / arcsec_per_pix
1313
+
1314
+ for entry in self.anomalyData:
1315
+ for anomaly in entry["anomalies"]:
1316
+ cx = 0.5 * (anomaly["minX"] + anomaly["maxX"])
1317
+ cy = 0.5 * (anomaly["minY"] + anomaly["maxY"])
1318
+
1319
+ matches = []
1320
+ for body in bodies:
1321
+ dx = body["x"] - cx
1322
+ dy = body["y"] - cy
1323
+ r_pix = np.hypot(dx, dy)
1324
+ if r_pix <= pix_radius:
1325
+ matches.append((r_pix, body))
1326
+
1327
+ if matches:
1328
+ matches.sort(key=lambda t: t[0])
1329
+ anomaly["matched_body"] = matches[0][1]
1330
+ anomaly["matched_bodies"] = [b for _, b in matches]
1331
+ else:
1332
+ anomaly["matched_body"] = None
1333
+ anomaly["matched_bodies"] = []
1334
+
1335
+
1336
+ def draw_bounding_boxes_on_stretched(self,
1337
+ stretched_image: np.ndarray,
1338
+ anomalies: list
1339
+ ) -> np.ndarray:
1340
+ """
1341
+ 1) Convert 'stretched_image' [0..1] -> [0..255] 8-bit color
1342
+ 2) Draw red rectangles for each anomaly in 'anomalies'.
1343
+ Each anomaly is assumed to have keys: minX, minY, maxX, maxY
1344
+ 3) Return the 8-bit color image (H,W,3).
1345
+ """
1346
+ # Ensure 3 channels
1347
+ if stretched_image.ndim == 2:
1348
+ stretched_3ch = np.stack([stretched_image]*3, axis=-1)
1349
+ elif stretched_image.ndim == 3 and stretched_image.shape[2] == 1:
1350
+ stretched_3ch = np.concatenate([stretched_image]*3, axis=2)
1351
+ else:
1352
+ stretched_3ch = stretched_image
1353
+
1354
+ # Convert float [0..1] => uint8 [0..255]
1355
+ img_bgr = (stretched_3ch * 255).clip(0,255).astype(np.uint8)
1356
+
1357
+ # Define the margin
1358
+ margin = 15
1359
+
1360
+ # Draw red boxes in BGR color = (0, 0, 255)
1361
+ for anomaly in anomalies:
1362
+ x1, y1 = anomaly["minX"], anomaly["minY"]
1363
+ x2, y2 = anomaly["maxX"], anomaly["maxY"]
1364
+
1365
+ # Expand the bounding box by a 10-pixel margin
1366
+ x1_exp = x1 - margin
1367
+ y1_exp = y1 - margin
1368
+ x2_exp = x2 + margin
1369
+ y2_exp = y2 + margin
1370
+ cv2.rectangle(img_bgr, (x1_exp, y1_exp), (x2_exp, y2_exp), color=(0, 0, 255), thickness=5)
1371
+
1372
+ return img_bgr
1373
+
1374
+
1375
+ def subtractImagesOnce(self, search_img, ref_img, debug_prefix=None):
1376
+ result = search_img - ref_img
1377
+ result = np.clip(result, 0, 1) # apply the clip
1378
+ return result
1379
+
1380
+ def debug_save_image(self, image, prefix="debug", step_name="step", ext=".tif"):
1381
+ """
1382
+ Saves 'image' to disk for debugging.
1383
+ - 'prefix' can be a directory path or prefix for your debug images.
1384
+ - 'step_name' is appended to the filename to indicate which step.
1385
+ - 'ext' could be '.tif', '.png', or another format you support.
1386
+
1387
+ This example uses your 'save_image' function from earlier or can
1388
+ directly use tiff.imwrite or similar.
1389
+ """
1390
+
1391
+ # Ensure the image is float32 in [0..1] before saving
1392
+ image = image.astype(np.float32, copy=False)
1393
+
1394
+ # Build debug filename
1395
+ filename = f"{prefix}_{step_name}{ext}"
1396
+
1397
+ # E.g., if you have a global 'save_image' function:
1398
+ save_image(
1399
+ image,
1400
+ filename,
1401
+ original_format="tif", # or "png", "fits", etc.
1402
+ bit_depth="16-bit"
1403
+ )
1404
+ print(f"[DEBUG] Saved {step_name} => {filename}")
1405
+
1406
+ def to_grayscale(self, image):
1407
+ """
1408
+ Converts an image to grayscale by averaging channels if needed.
1409
+ If the image is already 2D, return it as is.
1410
+ """
1411
+ if image.ndim == 2:
1412
+ # Already grayscale
1413
+ return image
1414
+ elif image.ndim == 3 and image.shape[2] == 3:
1415
+ # Average the three channels
1416
+ return np.mean(image, axis=2)
1417
+ elif image.ndim == 3 and image.shape[2] == 1:
1418
+ # Squeeze out that single channel
1419
+ return image[:, :, 0]
1420
+ else:
1421
+ raise ValueError(f"Unsupported image shape for grayscale: {image.shape}")
1422
+
1423
+ def detectAnomaliesConnected(self, diff_img: np.ndarray, threshold: float = 0.1):
1424
+ """
1425
+ 1) Build mask = diff_img > threshold.
1426
+ 2) Optionally skip 5% border by zeroing out that region in the mask.
1427
+ 3) connectedComponentsWithStats => bounding boxes.
1428
+ 4) Filter by min_area, etc.
1429
+ 5) Return a list of anomalies, each with minX, minY, maxX, maxY, area.
1430
+ """
1431
+ h, w = diff_img.shape
1432
+
1433
+ # 1) Create the mask
1434
+ mask = (diff_img > threshold).astype(np.uint8)
1435
+
1436
+ # 2) Skip 5% border (optional)
1437
+ border_x = int(0.05 * w)
1438
+ border_y = int(0.05 * h)
1439
+ mask[:border_y, :] = 0
1440
+ mask[h - border_y:, :] = 0
1441
+ mask[:, :border_x] = 0
1442
+ mask[:, w - border_x:] = 0
1443
+
1444
+ # 3) connectedComponentsWithStats => label each region
1445
+ # connectivity=8 => 8-way adjacency
1446
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
1447
+
1448
+ # stats[i] = [x, y, width, height, area], for i in [1..num_labels-1]
1449
+ # label_id=0 => background
1450
+
1451
+ anomalies = []
1452
+ for label_id in range(1, num_labels):
1453
+ x, y, width_, height_, area_ = stats[label_id]
1454
+
1455
+ # bounding box corners
1456
+ minX = x
1457
+ minY = y
1458
+ maxX = x + width_ - 1
1459
+ maxY = y + height_ - 1
1460
+
1461
+ # 4) Filter out tiny or huge areas if you want:
1462
+ # e.g., skip anything <4x4 => area<16
1463
+ if area_ < 25:
1464
+ continue
1465
+ # e.g., skip bounding boxes bigger than 40 in either dimension if you want
1466
+ if width_ > 200 or height_ > 200:
1467
+ continue
1468
+
1469
+ anomalies.append({
1470
+ "minX": minX,
1471
+ "minY": minY,
1472
+ "maxX": maxX,
1473
+ "maxY": maxY,
1474
+ "area": area_
1475
+ })
1476
+
1477
+ return anomalies
1478
+
1479
+
1480
+ def showDetailedResultsDialog(self, anomalyData):
1481
+ dialog = QDialog(self)
1482
+ dialog.setWindowTitle("Anomaly Detection Results")
1483
+ layout = QVBoxLayout(dialog)
1484
+ text_edit = QTextEdit(dialog)
1485
+ text_edit.setReadOnly(True)
1486
+ result_text = "Detailed Anomaly Results:\n\n"
1487
+
1488
+ for data in anomalyData:
1489
+ result_text += f"Image: {data['imageName']}\nAnomalies: {data['anomalyCount']}\n"
1490
+ for group in data["anomalies"]:
1491
+ result_text += (
1492
+ f" Group Bounding Box: "
1493
+ f"Top-Left ({group['minX']}, {group['minY']}), "
1494
+ f"Bottom-Right ({group['maxX']}, {group['maxY']})\n"
1495
+ )
1496
+ mbs = group.get("matched_bodies") or []
1497
+ if mbs:
1498
+ result_text += " → Candidate matches:\n"
1499
+ for mb in mbs:
1500
+ H_str = (
1501
+ f"{mb['H']:.1f}"
1502
+ if np.isfinite(mb.get("H", np.nan))
1503
+ else "?"
1504
+ )
1505
+ result_text += (
1506
+ f" - {mb['kind']} {mb['designation']} "
1507
+ f"(H={H_str})\n"
1508
+ )
1509
+ # if no matches, leave as a pure candidate box
1510
+ result_text += "\n"
1511
+
1512
+ text_edit.setText(result_text)
1513
+ layout.addWidget(text_edit)
1514
+ dialog.setLayout(layout)
1515
+ dialog.show()
1516
+
1517
+
1518
+ def showAnomaliesOnImage(
1519
+ self,
1520
+ image: np.ndarray,
1521
+ anomalies: list,
1522
+ window_title: str = "Anomalies",
1523
+ source_path: str | None = None,
1524
+ ):
1525
+ """
1526
+ Shows a zoomable, pannable preview. CTRL+wheel zoom, buttons for fit/1:1.
1527
+ Pushing emits a signal you can wire to your main UI.
1528
+ """
1529
+ # Ensure 3-ch so we can draw boxes
1530
+ if image.ndim == 2:
1531
+ img3 = np.stack([image]*3, axis=-1)
1532
+ elif image.ndim == 3 and image.shape[2] == 1:
1533
+ img3 = np.concatenate([image]*3, axis=2)
1534
+ else:
1535
+ img3 = image
1536
+
1537
+ # Make a copy in uint8 RGB for overlays
1538
+ if img3.dtype != np.uint8:
1539
+ img_u8 = (np.clip(img3, 0, 1) * 255).astype(np.uint8)
1540
+ else:
1541
+ img_u8 = img3.copy()
1542
+
1543
+ margin = 10
1544
+ h, w = img_u8.shape[:2]
1545
+ for a in anomalies:
1546
+ x1, y1, x2, y2 = a["minX"], a["minY"], a["maxX"], a["maxY"]
1547
+ x1 = max(0, x1 - margin); y1 = max(0, y1 - margin)
1548
+ x2 = min(w - 1, x2 + margin); y2 = min(h - 1, y2 + margin)
1549
+
1550
+ mbs = a.get("matched_bodies") or []
1551
+ if mbs:
1552
+ # anomalies with known bodies -> green box
1553
+ color = (0, 255, 0)
1554
+ else:
1555
+ # pure candidates -> red box
1556
+ color = (255, 0, 0)
1557
+
1558
+ cv2.rectangle(img_u8, (x1, y1), (x2, y2), color=color, thickness=5)
1559
+
1560
+ # NEW: overlay all predicted minor bodies as circles
1561
+ bodies = getattr(self, "predicted_minor_bodies", None)
1562
+ if bodies:
1563
+ for body in bodies:
1564
+ x = int(round(body["x"]))
1565
+ y = int(round(body["y"]))
1566
+ if 0 <= x < w and 0 <= y < h:
1567
+ # yellow circle so it stands out from red/green boxes
1568
+ cv2.circle(img_u8, (x, y), 8, (255, 255, 0), thickness=2)
1569
+
1570
+
1571
+ # Launch preview window
1572
+ icon = None
1573
+ try:
1574
+ if hasattr(self, "supernova_path") and self.supernova_path:
1575
+ icon = QIcon(self.supernova_path)
1576
+ except Exception:
1577
+ pass
1578
+
1579
+ prev = ImagePreviewWindow(
1580
+ img_u8, # anomaly-marked display image
1581
+ title=window_title,
1582
+ parent=self,
1583
+ icon=icon,
1584
+ source_path=source_path, # original file path
1585
+ )
1586
+ prev.pushed.connect(self._handle_preview_push)
1587
+ prev.minorBodySearchRequested.connect(self._on_preview_minor_body_search)
1588
+ prev.show() # non-modal
1589
+
1590
+ def _on_preview_minor_body_search(self):
1591
+ """
1592
+ Called when the user clicks 'Check Catalogued Minor Bodies in Field'
1593
+ on any anomaly preview window.
1594
+ """
1595
+ self.runMinorBodySearch()
1596
+
1597
+
1598
+ def _handle_preview_push(self, np_img, title: str):
1599
+ """
1600
+ Take the anomaly preview (np_img) and push it into SASpro as a *new*
1601
+ document by reusing *all* metadata/header information returned by
1602
+ load_image() for the source file, and only swapping the image array.
1603
+ """
1604
+ if not self.doc_manager:
1605
+ QMessageBox.warning(
1606
+ self,
1607
+ "No DocManager",
1608
+ "No document manager is available to push the preview."
1609
+ )
1610
+ return
1611
+
1612
+ # Which preview window emitted the signal? Grab its source_path.
1613
+ src_path = None
1614
+ sender = self.sender()
1615
+ if isinstance(sender, ImagePreviewWindow):
1616
+ src_path = getattr(sender, "_source_path", None)
1617
+
1618
+ if not src_path:
1619
+ QMessageBox.warning(
1620
+ self,
1621
+ "No Source File",
1622
+ "Could not determine the original file for this preview.\n"
1623
+ "Push to New View requires the original image path."
1624
+ )
1625
+ return
1626
+
1627
+ # Re-load the ORIGINAL file so we get the full tuple:
1628
+ # image, original_header, bit_depth, is_mono, meta
1629
+ try:
1630
+ res = load_image(src_path, return_metadata=True)
1631
+ except Exception as e:
1632
+ QMessageBox.critical(
1633
+ self,
1634
+ "Load Error",
1635
+ f"Failed to load original image:\n{e}"
1636
+ )
1637
+ return
1638
+
1639
+ if not res or res[0] is None:
1640
+ QMessageBox.critical(
1641
+ self,
1642
+ "Load Error",
1643
+ "Could not read original image data from disk."
1644
+ )
1645
+ return
1646
+
1647
+ orig_img, original_header, bit_depth, is_mono, meta = res
1648
+
1649
+ # Ensure meta is a dict we can stuff things into
1650
+ if not isinstance(meta, dict):
1651
+ meta = {}
1652
+
1653
+ # Keep ALL of the original pieces:
1654
+ # - store the original header explicitly if not already present
1655
+ meta.setdefault("fits_header", original_header)
1656
+ meta.setdefault("original_header", original_header)
1657
+ meta.setdefault("bit_depth", bit_depth)
1658
+ meta.setdefault("is_mono", is_mono)
1659
+ meta.setdefault("source_path", src_path)
1660
+
1661
+ # Give the new doc a nice display name
1662
+ meta["display_name"] = title
1663
+
1664
+ # Our preview image (with boxes). Normalize to float32 [0,1].
1665
+ img = np.asarray(np_img, copy=False)
1666
+ if img.dtype != np.float32:
1667
+ img = img.astype(np.float32, copy=False)
1668
+
1669
+ # If it looks like 0–255 data, rescale to 0–1
1670
+ if img.max() > 1.01 or img.min() < -0.01:
1671
+ img = np.clip(img, 0, 255) / 255.0
1672
+
1673
+ # Finally: create the new document using the preview pixels
1674
+ # but with *all* original metadata/header intact.
1675
+ self.doc_manager.create_document(
1676
+ image=img,
1677
+ metadata=meta,
1678
+ name=title,
1679
+ )
1680
+
1681
+
1682
+ def newInstance(self):
1683
+ # Reset parameters and UI elements for a new run
1684
+ self.parameters = {
1685
+ "referenceImagePath": "",
1686
+ "searchImagePaths": [],
1687
+ "threshold": 0.10
1688
+ }
1689
+
1690
+ self.ref_line_edit.clear()
1691
+ self.search_list.clear()
1692
+ self.cosmetic_checkbox.setChecked(False)
1693
+ self.thresh_slider.setValue(10)
1694
+
1695
+ self.preprocess_progress_label.setText("Preprocessing progress: 0 / 0")
1696
+ self.search_progress_label.setText("Processing progress: 0 / 0")
1697
+ self.status_label.setText("Status: Idle")
1698
+
1699
+ # Image + results state
1700
+ self.preprocessed_reference = None
1701
+ self.preprocessed_search = []
1702
+ self.anomalyData = []
1703
+
1704
+ # WCS / timing / minor-body state
1705
+ self.ref_header = None
1706
+ self.ref_wcs = None
1707
+ self.ref_jd = None
1708
+ self.ref_site = None
1709
+ self.predicted_minor_bodies = None
1710
+
1711
+ QMessageBox.information(self, "New Instance", "Reset for a new instance.")
1712
+