setiastrosuitepro 1.6.1__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 (342) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1716 @@
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, self.tr("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(self.tr("Fit"), self)
296
+ self.act_1to1 = QAction(self.tr("1:1"), self)
297
+ self.act_zoom_in = QAction(self.tr("Zoom In"), self)
298
+ self.act_zoom_out = QAction(self.tr("Zoom Out"), self)
299
+ self.act_push = QAction(self.tr("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, self.tr("Pushed"), self.tr("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(self.tr("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(self.tr(
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(self.tr("No reference image selected"))
417
+ self.ref_button = QPushButton(self.tr("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(self.tr("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
+ self.tr("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(self.tr("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
+ self.tr("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(self.tr("Preprocessing progress: 0 / 0"), self)
459
+ self.search_progress_label = QLabel(self.tr("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(self.tr("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(self.tr("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(self.tr("Anomaly Detection Threshold: {0:.2f}").format(threshold))
488
+
489
+ def selectReferenceImage(self):
490
+ file_path, _ = QFileDialog.getOpenFileName(self, self.tr("Select Reference Image"), "",
491
+ self.tr("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, self.tr("Select Search Images"), "",
498
+ self.tr("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(self.tr("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(self.tr("Running Cosmetic Correction..."))
512
+ QApplication.processEvents()
513
+ self.runCosmeticCorrectionIfNeeded()
514
+
515
+ self.status_label.setText(self.tr("Preprocessing images..."))
516
+ QApplication.processEvents()
517
+ self.preprocessImages()
518
+
519
+ self.status_label.setText(self.tr("Analyzing anomalies..."))
520
+ QApplication.processEvents()
521
+ self.runSearch()
522
+
523
+ self.status_label.setText(self.tr("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(self.tr("Cosmetic Correction: {0}/{1} => {2}").format(
538
+ idx+1, len(self.parameters['searchImagePaths']), os.path.basename(image_path)
539
+ ))
540
+ QApplication.processEvents()
541
+
542
+ img, header, bit_depth, is_mono = load_image(image_path)
543
+ if img is None:
544
+ print(f"Unable to load image: {image_path}")
545
+ continue
546
+
547
+ # Numba correction
548
+ corrected = bulk_cosmetic_correction_numba(
549
+ img,
550
+ hot_sigma=5.0,
551
+ cold_sigma=5.0,
552
+ window_size=3
553
+ )
554
+ self.cosmetic_images[image_path] = corrected
555
+ print(f"Cosmetic correction (Numba) applied to: {image_path}")
556
+
557
+ except Exception as e:
558
+ print(f"Error in cosmetic correction for {image_path}: {e}")
559
+
560
+
561
+ def preprocessImages(self):
562
+ # Update status label for reference image
563
+ self.status_label.setText(self.tr("Preprocessing reference image..."))
564
+ print("[Preprocessing] Preprocessing reference image...")
565
+ QApplication.processEvents()
566
+
567
+ ref_path = self.parameters["referenceImagePath"]
568
+ if not ref_path:
569
+ QMessageBox.warning(self, self.tr("Error"), self.tr("No reference image selected."))
570
+ return
571
+
572
+ try:
573
+ # --- Load reference with metadata so we can grab header / XISF info ---
574
+ ref_res = load_image(ref_path, return_metadata=True)
575
+ if not ref_res or ref_res[0] is None:
576
+ raise ValueError("load_image() returned no data for reference image.")
577
+
578
+ ref_img, header, bit_depth, is_mono, meta = ref_res
579
+
580
+ # Prefer synthesized FITS header from meta if present
581
+ self.ref_header = meta.get("fits_header", header) if isinstance(meta, dict) else header
582
+
583
+ # Try to build WCS directly from header (if it already has one).
584
+ try:
585
+ self.ref_wcs = WCS(self.ref_header)
586
+ except Exception:
587
+ self.ref_wcs = None
588
+
589
+ # --- Derive mid-exposure JD ---
590
+ self.ref_jd = None
591
+
592
+ # 1) XISF-aware path: use FITSKeywords (DATE-OBS + EXPOSURE/EXPTIME)
593
+ if isinstance(meta, dict):
594
+ ensure_jd_from_xisf_meta(meta)
595
+ jd_val = meta.get("jd", None)
596
+ if jd_val is not None:
597
+ self.ref_jd = float(jd_val)
598
+
599
+ # 2) FITS-style fallback from header (for non-XISF, or if XISF path failed)
600
+ if self.ref_jd is None and isinstance(self.ref_header, (dict, Header)):
601
+ try:
602
+ date_obs = self.ref_header.get("DATE-OBS")
603
+ exptime = float(
604
+ self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
605
+ )
606
+ if date_obs:
607
+ t = Time(str(date_obs), scale="utc")
608
+ # mid-exposure
609
+ t_mid = t + (exptime / 2.0) * u.s
610
+ self.ref_jd = float(t_mid.tt.jd)
611
+ except Exception:
612
+ self.ref_jd = None
613
+
614
+ print(f"[Preprocessing] ref JD={self.ref_jd!r}")
615
+ print("[Preprocessing] (Minor-body prediction is now manual only.)")
616
+
617
+ # --- Background neutralization + ABE + stretch for reference ---
618
+ debug_prefix_ref = os.path.splitext(ref_path)[0] + "_debug_ref"
619
+
620
+ self.status_label.setText(
621
+ "Applying background neutralization & ABE on reference..."
622
+ )
623
+ QApplication.processEvents()
624
+
625
+ ref_processed = self.preprocessImage(ref_img, debug_prefix=debug_prefix_ref)
626
+ self.preprocessed_reference = ref_processed
627
+ self.preprocess_progress_label.setText(
628
+ self.tr("Preprocessing reference image... Done.")
629
+ )
630
+
631
+ except Exception as e:
632
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to preprocess reference image: {0}").format(e))
633
+ return
634
+
635
+ # --- Preprocess search images ---
636
+ self.preprocessed_search = []
637
+ search_paths = self.parameters["searchImagePaths"]
638
+ total = len(search_paths)
639
+
640
+ for i, path in enumerate(search_paths):
641
+ try:
642
+ self.status_label.setText(
643
+ self.tr("Preprocessing search image {0}/{1} => {2}").format(
644
+ i+1, total, os.path.basename(path)
645
+ )
646
+ )
647
+ QApplication.processEvents()
648
+
649
+ debug_prefix_search = os.path.splitext(path)[0] + f"_debug_search_{i+1}"
650
+
651
+ if hasattr(self, "cosmetic_images") and path in self.cosmetic_images:
652
+ img = self.cosmetic_images[path]
653
+ else:
654
+ img, header, bit_depth, is_mono = load_image(path)
655
+
656
+ processed = self.preprocessImage(img, debug_prefix=debug_prefix_search)
657
+ self.preprocessed_search.append({"path": path, "image": processed})
658
+
659
+ self.preprocess_progress_label.setText(
660
+ self.tr("Preprocessing image {0} of {1}... Done.").format(i+1, total)
661
+ )
662
+ QApplication.processEvents()
663
+
664
+ except Exception as e:
665
+ print(f"Failed to preprocess {path}: {e}")
666
+
667
+ self.status_label.setText(self.tr("All search images preprocessed."))
668
+ QApplication.processEvents()
669
+
670
+ def _ensure_wcs(self, ref_path: str):
671
+ """
672
+ Ensure we have a WCS (and, if possible, JD) for the reference frame.
673
+ This does NOT do any minor-body catalog work.
674
+ """
675
+ # If we already have a WCS and header, don't re-solve.
676
+ if self.ref_wcs is not None and self.ref_header is not None:
677
+ return
678
+
679
+ try:
680
+ image_data, original_header, bit_depth, is_mono = load_image(ref_path)
681
+ except Exception as e:
682
+ print(f"[SupernovaHunter] Failed to load reference image for plate solve: {e}")
683
+ self.ref_wcs = None
684
+ return
685
+
686
+ if image_data is None:
687
+ print("[SupernovaHunter] Reference image is unsupported or unreadable for plate solve.")
688
+ self.ref_wcs = None
689
+ return
690
+
691
+ # Seed header from original_header (dict/Header/etc.)
692
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
693
+
694
+ # Acquisition base for merge (strip any existing WCS)
695
+ acq_base: Header | None = None
696
+ if isinstance(seed_h, Header):
697
+ acq_base = _strip_wcs_keys(seed_h)
698
+
699
+ # Run the same solver core used by PlateSolverDialog
700
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
701
+ if not ok:
702
+ print(f"[SupernovaHunter] Plate solve failed for {ref_path}: {res}")
703
+ self.ref_wcs = None
704
+ return
705
+
706
+ solver_hdr: Header = res if isinstance(res, Header) else Header()
707
+
708
+ # Merge solver WCS into acquisition header
709
+ if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
710
+ hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
711
+ else:
712
+ hdr_final = solver_hdr
713
+
714
+ self.ref_header = hdr_final
715
+ try:
716
+ self.ref_wcs = WCS(hdr_final)
717
+ except Exception as e:
718
+ print("[SupernovaHunter] WCS build failed after plate solve:", e)
719
+ self.ref_wcs = None
720
+
721
+ # If we still lack JD, try to derive it from the header
722
+ if self.ref_jd is None and isinstance(self.ref_header, Header):
723
+ try:
724
+ date_obs = self.ref_header.get("DATE-OBS")
725
+ exptime = float(
726
+ self.ref_header.get("EXPTIME", self.ref_header.get("EXPOSURE", 0.0))
727
+ )
728
+ if date_obs:
729
+ t = Time(str(date_obs), scale="utc")
730
+ t_mid = t + (exptime / 2.0) * u.s
731
+ self.ref_jd = float(t_mid.tt.jd)
732
+ except Exception:
733
+ pass
734
+
735
+ def _prompt_minor_body_limits(self) -> bool:
736
+ """
737
+ Modal dialog to configure minor-body search limits.
738
+
739
+ Returns True if the user pressed OK (and updates self.* attributes),
740
+ False if they cancelled.
741
+ """
742
+ dlg = QDialog(self)
743
+ dlg.setWindowTitle(self.tr("Minor-body Search Limits"))
744
+ layout = QVBoxLayout(dlg)
745
+
746
+ row_layout = QGridLayout()
747
+ layout.addLayout(row_layout)
748
+
749
+ # Defaults / existing values
750
+ ast_H_default = getattr(self, "minor_H_ast_max", 9.0)
751
+ com_H_default = getattr(self, "minor_H_com_max", 10.0)
752
+ ast_max_default = getattr(self, "minor_ast_max_count", 5000)
753
+ com_max_default = getattr(self, "minor_com_max_count", 1000)
754
+
755
+ # Time offset in *hours* now; if old days-based attr exists, convert.
756
+ if hasattr(self, "minor_time_offset_hours"):
757
+ dt_default = float(self.minor_time_offset_hours)
758
+ else:
759
+ dt_default = float(getattr(self, "minor_time_offset_days", 0.0)) * 24.0
760
+
761
+ # Row 0: Asteroids
762
+ row_layout.addWidget(QLabel("Asteroid H ≤"), 0, 0)
763
+ ast_H_spin = QDoubleSpinBox(dlg)
764
+ ast_H_spin.setDecimals(1)
765
+ ast_H_spin.setRange(-5.0, 40.0)
766
+ ast_H_spin.setSingleStep(0.1)
767
+ ast_H_spin.setValue(ast_H_default)
768
+ row_layout.addWidget(ast_H_spin, 0, 1)
769
+
770
+ row_layout.addWidget(QLabel("Max asteroid"), 0, 2)
771
+ ast_max_spin = QSpinBox(dlg)
772
+ ast_max_spin.setRange(1, 2000000)
773
+ ast_max_spin.setValue(ast_max_default)
774
+ row_layout.addWidget(ast_max_spin, 0, 3)
775
+
776
+ # Row 1: Comets
777
+ row_layout.addWidget(QLabel("Comet H ≤"), 1, 0)
778
+ com_H_spin = QDoubleSpinBox(dlg)
779
+ com_H_spin.setDecimals(1)
780
+ com_H_spin.setRange(-5.0, 40.0)
781
+ com_H_spin.setSingleStep(0.1)
782
+ com_H_spin.setValue(com_H_default)
783
+ row_layout.addWidget(com_H_spin, 1, 1)
784
+
785
+ row_layout.addWidget(QLabel("Max comet"), 1, 2)
786
+ com_max_spin = QSpinBox(dlg)
787
+ com_max_spin.setRange(1, 200000)
788
+ com_max_spin.setValue(com_max_default)
789
+ row_layout.addWidget(com_max_spin, 1, 3)
790
+
791
+ # Row 2: Time offset (hours)
792
+ row_layout.addWidget(QLabel("Time offset (hours)"), 2, 0)
793
+ dt_spin = QDoubleSpinBox(dlg)
794
+ dt_spin.setDecimals(1)
795
+ dt_spin.setRange(-72.0, 72.0) # ±3 days in hours
796
+ dt_spin.setSingleStep(1.0)
797
+ dt_spin.setValue(dt_default)
798
+ row_layout.addWidget(dt_spin, 2, 1, 1, 3)
799
+
800
+ # Buttons
801
+ btn_row = QHBoxLayout()
802
+ layout.addLayout(btn_row)
803
+ btn_row.addStretch(1)
804
+ ok_btn = QPushButton("OK", dlg)
805
+ cancel_btn = QPushButton("Cancel", dlg)
806
+ btn_row.addWidget(ok_btn)
807
+ btn_row.addWidget(cancel_btn)
808
+
809
+ def on_ok():
810
+ self.minor_H_ast_max = float(ast_H_spin.value())
811
+ self.minor_H_com_max = float(com_H_spin.value())
812
+ self.minor_ast_max_count = int(ast_max_spin.value())
813
+ self.minor_com_max_count = int(com_max_spin.value())
814
+ hours = float(dt_spin.value())
815
+ self.minor_time_offset_hours = hours
816
+ # backward compat if anything still reads the old name:
817
+ self.minor_time_offset_days = hours / 24.0
818
+ dlg.accept()
819
+
820
+ def on_cancel():
821
+ dlg.reject()
822
+
823
+ ok_btn.clicked.connect(on_ok)
824
+ cancel_btn.clicked.connect(on_cancel)
825
+
826
+ return dlg.exec() == QDialog.DialogCode.Accepted
827
+
828
+ def _on_minor_body_progress(self, pct: int, msg: str):
829
+ self.status_label.setText(msg)
830
+ if hasattr(self, "minor_progress"):
831
+ self.minor_progress.setVisible(True)
832
+ self.minor_progress.setValue(int(pct))
833
+ QApplication.processEvents()
834
+
835
+ def _on_minor_body_finished(self, bodies: list, error: str):
836
+ if hasattr(self, "minor_progress"):
837
+ # show as done, then hide
838
+ self.minor_progress.setValue(100 if not error else 0)
839
+ self.minor_progress.setVisible(False)
840
+ if error:
841
+ print("[MinorBodies] prediction failed:", error)
842
+ QMessageBox.critical(
843
+ self,
844
+ self.tr("Minor-body Search"),
845
+ self.tr("Minor-body prediction failed:\n{0}").format(error)
846
+ )
847
+ self.status_label.setText(self.tr("Minor-body search failed."))
848
+ return
849
+
850
+ self.predicted_minor_bodies = bodies or []
851
+
852
+ if not self.predicted_minor_bodies:
853
+ self.status_label.setText(
854
+ self.tr("Minor-body search complete: no catalogued objects in this field "
855
+ "for the current magnitude limits.")
856
+ )
857
+ QMessageBox.information(
858
+ self,
859
+ self.tr("Minor-body Search"),
860
+ self.tr("No catalogued minor bodies (within the configured magnitude limits) "
861
+ "were found in this field.")
862
+ )
863
+ return
864
+
865
+ self.status_label.setText(
866
+ self.tr("Minor-body search complete: {0} objects in field.").format(len(self.predicted_minor_bodies))
867
+ )
868
+ QApplication.processEvents()
869
+
870
+ # Now cross-match on the UI thread if we already have anomalies
871
+ try:
872
+ if self.anomalyData:
873
+ print(f"[MinorBodies] cross-matching anomalies to "
874
+ f"{len(self.predicted_minor_bodies)} predicted bodies...")
875
+ self._match_anomalies_to_minor_bodies(
876
+ self.predicted_minor_bodies,
877
+ search_radius_arcsec=60.0
878
+ )
879
+ self.showDetailedResultsDialog(self.anomalyData)
880
+ else:
881
+ QMessageBox.information(
882
+ self,
883
+ self.tr("Minor-body Search"),
884
+ self.tr("Minor bodies in field have been computed.\n\n"
885
+ "Run the anomaly search (Process) to cross-match detections "
886
+ "against the predicted objects.")
887
+ )
888
+ except Exception as e:
889
+ print("[MinorBodies] cross-match failed:", e)
890
+
891
+
892
+ def runMinorBodySearch(self):
893
+ """
894
+ Optional, slow step:
895
+ - Ensure we have WCS + JD for the reference frame (plate-solve if needed).
896
+ - Ask the user for H limits / max counts.
897
+ - Query the minor-body catalog and compute predicted objects in the FOV.
898
+ - Cross-match with existing anomalies (if any) and refresh the summary dialog.
899
+ """
900
+ ref_path = self.parameters.get("referenceImagePath") or ""
901
+ if not ref_path:
902
+ QMessageBox.warning(
903
+ self,
904
+ self.tr("Minor-body Search"),
905
+ self.tr("No reference image selected.\n\n"
906
+ "Please select a reference image and run Process first.")
907
+ )
908
+ return
909
+
910
+ if self.preprocessed_reference is None:
911
+ QMessageBox.warning(
912
+ self,
913
+ self.tr("Minor-body Search"),
914
+ self.tr("Reference image has not been preprocessed yet.\n\n"
915
+ "Please click 'Process' before running the minor-body search.")
916
+ )
917
+ return
918
+
919
+ if self.settings is None:
920
+ QMessageBox.warning(
921
+ self,
922
+ self.tr("Minor-body Search"),
923
+ self.tr("Settings object is not available; cannot locate the minor-body database path.")
924
+ )
925
+ return
926
+
927
+ # Configure limits (H, max counts, time offset)
928
+ if not self._prompt_minor_body_limits():
929
+ # user cancelled
930
+ return
931
+
932
+ # Step 1: Ensure WCS (plate-solve if necessary)
933
+ self.status_label.setText("Minor-body search: solving plate / ensuring WCS...")
934
+ QApplication.processEvents()
935
+
936
+ self._ensure_wcs(ref_path)
937
+
938
+ if self.ref_wcs is None:
939
+ QMessageBox.warning(
940
+ self,
941
+ "Minor-body Search",
942
+ "No valid WCS (astrometric solution) is available for the reference image.\n\n"
943
+ "Minor-body prediction requires a solved WCS."
944
+ )
945
+ self.status_label.setText("Minor-body search aborted: no WCS.")
946
+ return
947
+
948
+ # Ensure we have JD (time of observation) for ephemerides
949
+ if self.ref_jd is None:
950
+ QMessageBox.warning(
951
+ self,
952
+ "Minor-body Search",
953
+ "No valid observation time (JD) is available for the reference image.\n\n"
954
+ "Minor-body prediction requires DATE-OBS/EXPTIME or equivalent."
955
+ )
956
+ self.status_label.setText("Minor-body search aborted: no JD.")
957
+ return
958
+
959
+ # Optional observatory site
960
+ try:
961
+ print("[MinorBodies] fetching observatory site from settings...")
962
+ lat = self.settings.value("site/latitude_deg", None, type=float)
963
+ lon = self.settings.value("site/longitude_deg", None, type=float)
964
+ elev = self.settings.value("site/elevation_m", 0.0, type=float)
965
+ if lat is not None and lon is not None:
966
+ self.ref_site = (lat, lon, elev)
967
+ else:
968
+ self.ref_site = None
969
+ except Exception as e:
970
+ print("[MinorBodies] failed to fetch observatory site from settings:", e)
971
+ self.ref_site = None
972
+
973
+ # JD adjusted by time offset (hours → days)
974
+ offset_hours = getattr(self, "minor_time_offset_hours", 0.0)
975
+ jd_for_calc = self.ref_jd + (offset_hours / 24.0)
976
+
977
+ # Kick off the heavy catalog + ephemeris work in a background thread
978
+ self.status_label.setText(
979
+ "Minor-body search: starting background catalog query..."
980
+ )
981
+ QApplication.processEvents()
982
+ if hasattr(self, "minor_progress"):
983
+ self.minor_progress.setVisible(True)
984
+ self.minor_progress.setValue(0)
985
+
986
+ self._mb_thread = QThread(self)
987
+ self._mb_worker = MinorBodyWorker(self, jd_for_calc)
988
+ self._mb_worker.moveToThread(self._mb_thread)
989
+
990
+ # Wire up thread lifecycle
991
+ self._mb_thread.started.connect(self._mb_worker.run)
992
+ self._mb_worker.progress.connect(self._on_minor_body_progress)
993
+ self._mb_worker.finished.connect(self._on_minor_body_finished)
994
+ self._mb_worker.finished.connect(self._mb_thread.quit)
995
+ self._mb_worker.finished.connect(self._mb_worker.deleteLater)
996
+ self._mb_thread.finished.connect(self._mb_thread.deleteLater)
997
+
998
+ self._mb_thread.start()
999
+
1000
+ def _get_predicted_minor_bodies_for_field(
1001
+ self,
1002
+ H_ast_max: float,
1003
+ H_com_max: float,
1004
+ jd: float | None = None,
1005
+ progress_cb=None,
1006
+ ):
1007
+ """
1008
+ Return a list of predicted minor bodies in the current ref image FOV
1009
+ at 'jd' (or self.ref_jd if jd is None), with pixel coords.
1010
+ """
1011
+ # Need WCS and an image
1012
+ if self.ref_wcs is None or self.preprocessed_reference is None:
1013
+ return []
1014
+
1015
+ def emit(pct, msg):
1016
+ if progress_cb is not None:
1017
+ try:
1018
+ progress_cb(int(pct), msg)
1019
+ except TypeError:
1020
+ # fallback if callback only wants a message
1021
+ progress_cb(msg)
1022
+
1023
+
1024
+ # Resolve JD: explicit first, then self.ref_jd
1025
+ if jd is None:
1026
+ jd = self.ref_jd
1027
+ if jd is None:
1028
+ return []
1029
+
1030
+ if self.settings is None:
1031
+ print("[MinorBodies] settings object is None; cannot resolve DB path.")
1032
+ return []
1033
+
1034
+ # Per-type max counts with safe defaults
1035
+ ast_limit = getattr(self, "minor_ast_max_count", 50000)
1036
+ com_limit = getattr(self, "minor_com_max_count", 5000)
1037
+
1038
+ # 1) open DB (reuse WIMI’s ensure logic)
1039
+ emit(5, "Minor-body search: opening minor-body database...")
1040
+ try:
1041
+ data_dir = Path(
1042
+ self.settings.value("wimi/minorbody_data_dir", "", type=str)
1043
+ or os.path.join(os.path.expanduser("~"), ".saspro_minor_bodies")
1044
+ )
1045
+ db_path, manifest = mbc.ensure_minor_body_db(data_dir)
1046
+ catalog = mbc.MinorBodyCatalog(db_path)
1047
+ except Exception as e:
1048
+ print("[MinorBodies] could not open DB:", e)
1049
+ emit(100, "Minor-body search: failed to open database.")
1050
+ return []
1051
+
1052
+ try:
1053
+ emit(20, "Minor-body search: selecting bright asteroids/comets...")
1054
+ ast_df = catalog.get_bright_asteroids(H_max=H_ast_max, limit=ast_limit)
1055
+ com_df = catalog.get_bright_comets(H_max=H_com_max, limit=com_limit)
1056
+
1057
+ emit(40, "Minor-body search: computing asteroid positions...")
1058
+ ast_pos = catalog.compute_positions_skyfield(
1059
+ ast_df,
1060
+ jd,
1061
+ topocentric=self.ref_site,
1062
+ debug=False,
1063
+ )
1064
+ emit(60, "Minor-body search: computing comet positions...")
1065
+ com_pos = catalog.compute_positions_skyfield(
1066
+ com_df,
1067
+ jd,
1068
+ topocentric=self.ref_site,
1069
+ debug=False,
1070
+ )
1071
+
1072
+ emit(80, "Minor-body search: projecting onto image pixels...")
1073
+
1074
+ # 4) map RA/Dec -> pixel with ref WCS, and drop those outside FOV
1075
+ h, w = self.preprocessed_reference.shape[:2]
1076
+ bodies = []
1077
+ for src, kind, df in (
1078
+ (ast_pos, "asteroid", ast_df),
1079
+ (com_pos, "comet", com_df),
1080
+ ):
1081
+ df_by_name = {row["designation"]: row for _, row in df.iterrows()}
1082
+ for row in src:
1083
+ ra = row["ra_deg"]
1084
+ dec = row["dec_deg"]
1085
+ x, y = self.ref_wcs.world_to_pixel_values(ra, dec)
1086
+ if 0 <= x < w and 0 <= y < h:
1087
+ base = df_by_name.get(row["designation"], {})
1088
+ bodies.append({
1089
+ "designation": row["designation"],
1090
+ "kind": kind,
1091
+ "ra_deg": ra,
1092
+ "dec_deg": dec,
1093
+ "x": float(x),
1094
+ "y": float(y),
1095
+ "H": float(base.get("magnitude_H", np.nan)),
1096
+ "distance_au": row.get("distance_au", np.nan),
1097
+ })
1098
+ emit(100, "Minor-body search: finished computing positions.")
1099
+ return bodies
1100
+ finally:
1101
+ try:
1102
+ catalog.close()
1103
+ except Exception:
1104
+ pass
1105
+
1106
+
1107
+ def preprocessImage(self, img, debug_prefix=None):
1108
+ """
1109
+ Runs the full preprocessing chain on a single image:
1110
+ 1. Background Neutralization
1111
+ 2. Automatic Background Extraction (ABE)
1112
+ 3. Pixel-math stretching
1113
+
1114
+ Optionally saves debug images if debug_prefix is provided.
1115
+ """
1116
+
1117
+
1118
+ # --- Step 1: Background Neutralization ---
1119
+ if img.ndim == 3 and img.shape[2] == 3:
1120
+ h, w, _ = img.shape
1121
+ sample_x = int(w * 0.45)
1122
+ sample_y = int(h * 0.45)
1123
+ sample_w = max(1, int(w * 0.1))
1124
+ sample_h = max(1, int(h * 0.1))
1125
+ sample_region = img[sample_y:sample_y+sample_h, sample_x:sample_x+sample_w, :]
1126
+ medians = np.median(sample_region, axis=(0, 1))
1127
+ average_median = np.mean(medians)
1128
+ neutralized = img.copy()
1129
+ for c in range(3):
1130
+ diff = medians[c] - average_median
1131
+ numerator = neutralized[:, :, c] - diff
1132
+ denominator = 1.0 - diff
1133
+ if abs(denominator) < 1e-8:
1134
+ denominator = 1e-8
1135
+ neutralized[:, :, c] = np.clip(numerator / denominator, 0, 1)
1136
+ else:
1137
+ neutralized = img
1138
+
1139
+
1140
+ # --- Step 2: Automatic Background Extraction (ABE) ---
1141
+ pgr = PolyGradientRemoval(
1142
+ neutralized,
1143
+ poly_degree=2, # or pass in a user choice
1144
+ downsample_scale=4,
1145
+ num_sample_points=100
1146
+ )
1147
+ abe = pgr.process() # returns final polynomial-corrected image in original domain
1148
+
1149
+
1150
+ # --- Step 3: Pixel Math Stretch ---
1151
+ stretched = self.pixel_math_stretch(abe)
1152
+
1153
+ return stretched
1154
+
1155
+
1156
+
1157
+ def pixel_math_stretch(self, image):
1158
+ """
1159
+ Replaces the old pixel math stretch logic by using the existing
1160
+ stretch_mono_image or stretch_color_image methods.
1161
+ """
1162
+ # Choose a target median (the default you’ve used elsewhere is often 0.25)
1163
+ target_median = 0.25
1164
+
1165
+ # Check if the image is mono or color
1166
+ if image.ndim == 2 or (image.ndim == 3 and image.shape[2] == 1):
1167
+ # Treat it as mono
1168
+ stretched = stretch_mono_image(
1169
+ image.squeeze(), # squeeze in case it's (H,W,1)
1170
+ target_median=target_median,
1171
+ normalize=False, # Adjust if you want normalization
1172
+ apply_curves=False,
1173
+ curves_boost=0.0
1174
+ )
1175
+ # If it was (H,W,1), replicate to 3 channels (optional)
1176
+ # or just keep it mono if you prefer
1177
+ # For now, replicate to 3 channels:
1178
+ stretched = np.stack([stretched]*3, axis=-1)
1179
+ else:
1180
+ # Full-color image
1181
+ stretched = stretch_color_image(
1182
+ image,
1183
+ target_median=target_median,
1184
+ linked=False, # or False if you want per-channel stretches
1185
+ normalize=False,
1186
+ apply_curves=False,
1187
+ curves_boost=0.0
1188
+ )
1189
+
1190
+ return np.clip(stretched, 0, 1)
1191
+
1192
+ def runSearch(self):
1193
+ if self.preprocessed_reference is None:
1194
+ QMessageBox.warning(self, "Error", "Reference image not preprocessed.")
1195
+ return
1196
+ if not self.preprocessed_search:
1197
+ QMessageBox.warning(self, "Error", "No search images preprocessed.")
1198
+ return
1199
+
1200
+ ref_gray = self.to_grayscale(self.preprocessed_reference)
1201
+
1202
+ self.anomalyData = []
1203
+ total = len(self.preprocessed_search)
1204
+ for i, search_dict in enumerate(self.preprocessed_search):
1205
+ search_img = search_dict["image"]
1206
+ search_gray = self.to_grayscale(search_img)
1207
+
1208
+ diff_img = self.subtractImagesOnce(search_gray, ref_gray)
1209
+ anomalies = self.detectAnomaliesConnected(
1210
+ diff_img,
1211
+ threshold=self.parameters["threshold"],
1212
+ )
1213
+
1214
+ self.anomalyData.append({
1215
+ "imageName": os.path.basename(search_dict["path"]),
1216
+ "anomalyCount": len(anomalies),
1217
+ "anomalies": anomalies,
1218
+ })
1219
+
1220
+ self.search_progress_label.setText(f"Processing image {i+1} of {total}...")
1221
+ QApplication.processEvents()
1222
+
1223
+ self.search_progress_label.setText("Search for anomalies complete.")
1224
+
1225
+ # Minor-body cross-match (optional)
1226
+ try:
1227
+ bodies = getattr(self, "predicted_minor_bodies", None)
1228
+ if bodies:
1229
+ print(f"[MinorBodies] cross-matching anomalies to {len(bodies)} predicted bodies...")
1230
+ self._match_anomalies_to_minor_bodies(bodies, search_radius_arcsec=60.0)
1231
+ except Exception as e:
1232
+ print("[MinorBodies] cross-match failed:", e)
1233
+
1234
+ # Show text-based summary & tree
1235
+ self.showDetailedResultsDialog(self.anomalyData)
1236
+ self.showAnomalyListDialog()
1237
+
1238
+ def showAnomalyListDialog(self):
1239
+ """
1240
+ Build a QDialog with a QTreeWidget listing each image and its anomaly count.
1241
+ Double-clicking an item will open a non-modal preview.
1242
+ """
1243
+ if not self.anomalyData:
1244
+ QMessageBox.information(self, "Info", "No anomalies or no images processed.")
1245
+ return
1246
+
1247
+ dialog = QDialog(self)
1248
+ dialog.setWindowTitle("Anomaly Results")
1249
+
1250
+ layout = QVBoxLayout(dialog)
1251
+
1252
+ self.anomaly_tree = QTreeWidget(dialog)
1253
+ self.anomaly_tree.setColumnCount(2)
1254
+ self.anomaly_tree.setHeaderLabels(["Image", "Anomaly Count"])
1255
+ layout.addWidget(self.anomaly_tree)
1256
+
1257
+ # Populate the tree
1258
+ for i, data in enumerate(self.anomalyData):
1259
+ item = QTreeWidgetItem([
1260
+ data["imageName"],
1261
+ str(data["anomalyCount"])
1262
+ ])
1263
+ # Store an index or reference so we know which image to open
1264
+ item.setData(0, Qt.ItemDataRole.UserRole, i)
1265
+ self.anomaly_tree.addTopLevelItem(item)
1266
+
1267
+ # Connect double-click
1268
+ self.anomaly_tree.itemDoubleClicked.connect(self.onAnomalyItemDoubleClicked)
1269
+
1270
+ dialog.setLayout(layout)
1271
+ dialog.resize(300, 200)
1272
+ dialog.show() # non-modal, so the user can keep using the main window
1273
+
1274
+ def onAnomalyItemDoubleClicked(self, item, column):
1275
+ idx = item.data(0, Qt.ItemDataRole.UserRole)
1276
+ if idx is None:
1277
+ return
1278
+
1279
+ anomalies = self.anomalyData[idx]["anomalies"]
1280
+ image_name = self.anomalyData[idx]["imageName"]
1281
+
1282
+ entry = self.preprocessed_search[idx]
1283
+ search_img = entry["image"] # stretched float [0..1]
1284
+ source_path = entry["path"] # original file path
1285
+
1286
+ # Show zoomable preview with overlays, remembering which file it came from
1287
+ self.showAnomaliesOnImage(
1288
+ search_img,
1289
+ anomalies,
1290
+ window_title=f"Anomalies in {image_name}",
1291
+ source_path=source_path,
1292
+ )
1293
+
1294
+
1295
+ def _match_anomalies_to_minor_bodies(self, bodies, search_radius_arcsec=20.0):
1296
+ """
1297
+ For each anomaly, compute center pixel and find
1298
+ all predicted minor bodies within search_radius_arcsec.
1299
+
1300
+ Adds:
1301
+ - anomaly["matched_bodies"] = [body, ...]
1302
+ - anomaly["matched_body"] = closest body or None
1303
+ """
1304
+ if self.ref_wcs is None or not bodies:
1305
+ return
1306
+
1307
+ # search radius in pixels — crude average plate scale from WCS
1308
+ try:
1309
+ cd = self.ref_wcs.pixel_scale_matrix # 2x2
1310
+ from numpy.linalg import det
1311
+ deg_per_pix = np.sqrt(abs(det(cd)))
1312
+ arcsec_per_pix = deg_per_pix * 3600.0
1313
+ except Exception:
1314
+ arcsec_per_pix = 1.0 # fallback
1315
+
1316
+ pix_radius = search_radius_arcsec / arcsec_per_pix
1317
+
1318
+ for entry in self.anomalyData:
1319
+ for anomaly in entry["anomalies"]:
1320
+ cx = 0.5 * (anomaly["minX"] + anomaly["maxX"])
1321
+ cy = 0.5 * (anomaly["minY"] + anomaly["maxY"])
1322
+
1323
+ matches = []
1324
+ for body in bodies:
1325
+ dx = body["x"] - cx
1326
+ dy = body["y"] - cy
1327
+ r_pix = np.hypot(dx, dy)
1328
+ if r_pix <= pix_radius:
1329
+ matches.append((r_pix, body))
1330
+
1331
+ if matches:
1332
+ matches.sort(key=lambda t: t[0])
1333
+ anomaly["matched_body"] = matches[0][1]
1334
+ anomaly["matched_bodies"] = [b for _, b in matches]
1335
+ else:
1336
+ anomaly["matched_body"] = None
1337
+ anomaly["matched_bodies"] = []
1338
+
1339
+
1340
+ def draw_bounding_boxes_on_stretched(self,
1341
+ stretched_image: np.ndarray,
1342
+ anomalies: list
1343
+ ) -> np.ndarray:
1344
+ """
1345
+ 1) Convert 'stretched_image' [0..1] -> [0..255] 8-bit color
1346
+ 2) Draw red rectangles for each anomaly in 'anomalies'.
1347
+ Each anomaly is assumed to have keys: minX, minY, maxX, maxY
1348
+ 3) Return the 8-bit color image (H,W,3).
1349
+ """
1350
+ # Ensure 3 channels
1351
+ if stretched_image.ndim == 2:
1352
+ stretched_3ch = np.stack([stretched_image]*3, axis=-1)
1353
+ elif stretched_image.ndim == 3 and stretched_image.shape[2] == 1:
1354
+ stretched_3ch = np.concatenate([stretched_image]*3, axis=2)
1355
+ else:
1356
+ stretched_3ch = stretched_image
1357
+
1358
+ # Convert float [0..1] => uint8 [0..255]
1359
+ img_bgr = (stretched_3ch * 255).clip(0,255).astype(np.uint8)
1360
+
1361
+ # Define the margin
1362
+ margin = 15
1363
+
1364
+ # Draw red boxes in BGR color = (0, 0, 255)
1365
+ for anomaly in anomalies:
1366
+ x1, y1 = anomaly["minX"], anomaly["minY"]
1367
+ x2, y2 = anomaly["maxX"], anomaly["maxY"]
1368
+
1369
+ # Expand the bounding box by a 10-pixel margin
1370
+ x1_exp = x1 - margin
1371
+ y1_exp = y1 - margin
1372
+ x2_exp = x2 + margin
1373
+ y2_exp = y2 + margin
1374
+ cv2.rectangle(img_bgr, (x1_exp, y1_exp), (x2_exp, y2_exp), color=(0, 0, 255), thickness=5)
1375
+
1376
+ return img_bgr
1377
+
1378
+
1379
+ def subtractImagesOnce(self, search_img, ref_img, debug_prefix=None):
1380
+ result = search_img - ref_img
1381
+ result = np.clip(result, 0, 1) # apply the clip
1382
+ return result
1383
+
1384
+ def debug_save_image(self, image, prefix="debug", step_name="step", ext=".tif"):
1385
+ """
1386
+ Saves 'image' to disk for debugging.
1387
+ - 'prefix' can be a directory path or prefix for your debug images.
1388
+ - 'step_name' is appended to the filename to indicate which step.
1389
+ - 'ext' could be '.tif', '.png', or another format you support.
1390
+
1391
+ This example uses your 'save_image' function from earlier or can
1392
+ directly use tiff.imwrite or similar.
1393
+ """
1394
+
1395
+ # Ensure the image is float32 in [0..1] before saving
1396
+ image = image.astype(np.float32, copy=False)
1397
+
1398
+ # Build debug filename
1399
+ filename = f"{prefix}_{step_name}{ext}"
1400
+
1401
+ # E.g., if you have a global 'save_image' function:
1402
+ save_image(
1403
+ image,
1404
+ filename,
1405
+ original_format="tif", # or "png", "fits", etc.
1406
+ bit_depth="16-bit"
1407
+ )
1408
+ print(f"[DEBUG] Saved {step_name} => {filename}")
1409
+
1410
+ def to_grayscale(self, image):
1411
+ """
1412
+ Converts an image to grayscale by averaging channels if needed.
1413
+ If the image is already 2D, return it as is.
1414
+ """
1415
+ if image.ndim == 2:
1416
+ # Already grayscale
1417
+ return image
1418
+ elif image.ndim == 3 and image.shape[2] == 3:
1419
+ # Average the three channels
1420
+ return np.mean(image, axis=2)
1421
+ elif image.ndim == 3 and image.shape[2] == 1:
1422
+ # Squeeze out that single channel
1423
+ return image[:, :, 0]
1424
+ else:
1425
+ raise ValueError(f"Unsupported image shape for grayscale: {image.shape}")
1426
+
1427
+ def detectAnomaliesConnected(self, diff_img: np.ndarray, threshold: float = 0.1):
1428
+ """
1429
+ 1) Build mask = diff_img > threshold.
1430
+ 2) Optionally skip 5% border by zeroing out that region in the mask.
1431
+ 3) connectedComponentsWithStats => bounding boxes.
1432
+ 4) Filter by min_area, etc.
1433
+ 5) Return a list of anomalies, each with minX, minY, maxX, maxY, area.
1434
+ """
1435
+ h, w = diff_img.shape
1436
+
1437
+ # 1) Create the mask
1438
+ mask = (diff_img > threshold).astype(np.uint8)
1439
+
1440
+ # 2) Skip 5% border (optional)
1441
+ border_x = int(0.05 * w)
1442
+ border_y = int(0.05 * h)
1443
+ mask[:border_y, :] = 0
1444
+ mask[h - border_y:, :] = 0
1445
+ mask[:, :border_x] = 0
1446
+ mask[:, w - border_x:] = 0
1447
+
1448
+ # 3) connectedComponentsWithStats => label each region
1449
+ # connectivity=8 => 8-way adjacency
1450
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
1451
+
1452
+ # stats[i] = [x, y, width, height, area], for i in [1..num_labels-1]
1453
+ # label_id=0 => background
1454
+
1455
+ anomalies = []
1456
+ for label_id in range(1, num_labels):
1457
+ x, y, width_, height_, area_ = stats[label_id]
1458
+
1459
+ # bounding box corners
1460
+ minX = x
1461
+ minY = y
1462
+ maxX = x + width_ - 1
1463
+ maxY = y + height_ - 1
1464
+
1465
+ # 4) Filter out tiny or huge areas if you want:
1466
+ # e.g., skip anything <4x4 => area<16
1467
+ if area_ < 25:
1468
+ continue
1469
+ # e.g., skip bounding boxes bigger than 40 in either dimension if you want
1470
+ if width_ > 200 or height_ > 200:
1471
+ continue
1472
+
1473
+ anomalies.append({
1474
+ "minX": minX,
1475
+ "minY": minY,
1476
+ "maxX": maxX,
1477
+ "maxY": maxY,
1478
+ "area": area_
1479
+ })
1480
+
1481
+ return anomalies
1482
+
1483
+
1484
+ def showDetailedResultsDialog(self, anomalyData):
1485
+ dialog = QDialog(self)
1486
+ dialog.setWindowTitle("Anomaly Detection Results")
1487
+ layout = QVBoxLayout(dialog)
1488
+ text_edit = QTextEdit(dialog)
1489
+ text_edit.setReadOnly(True)
1490
+ result_text = "Detailed Anomaly Results:\n\n"
1491
+
1492
+ for data in anomalyData:
1493
+ result_text += f"Image: {data['imageName']}\nAnomalies: {data['anomalyCount']}\n"
1494
+ for group in data["anomalies"]:
1495
+ result_text += (
1496
+ f" Group Bounding Box: "
1497
+ f"Top-Left ({group['minX']}, {group['minY']}), "
1498
+ f"Bottom-Right ({group['maxX']}, {group['maxY']})\n"
1499
+ )
1500
+ mbs = group.get("matched_bodies") or []
1501
+ if mbs:
1502
+ result_text += " → Candidate matches:\n"
1503
+ for mb in mbs:
1504
+ H_str = (
1505
+ f"{mb['H']:.1f}"
1506
+ if np.isfinite(mb.get("H", np.nan))
1507
+ else "?"
1508
+ )
1509
+ result_text += (
1510
+ f" - {mb['kind']} {mb['designation']} "
1511
+ f"(H={H_str})\n"
1512
+ )
1513
+ # if no matches, leave as a pure candidate box
1514
+ result_text += "\n"
1515
+
1516
+ text_edit.setText(result_text)
1517
+ layout.addWidget(text_edit)
1518
+ dialog.setLayout(layout)
1519
+ dialog.show()
1520
+
1521
+
1522
+ def showAnomaliesOnImage(
1523
+ self,
1524
+ image: np.ndarray,
1525
+ anomalies: list,
1526
+ window_title: str = "Anomalies",
1527
+ source_path: str | None = None,
1528
+ ):
1529
+ """
1530
+ Shows a zoomable, pannable preview. CTRL+wheel zoom, buttons for fit/1:1.
1531
+ Pushing emits a signal you can wire to your main UI.
1532
+ """
1533
+ # Ensure 3-ch so we can draw boxes
1534
+ if image.ndim == 2:
1535
+ img3 = np.stack([image]*3, axis=-1)
1536
+ elif image.ndim == 3 and image.shape[2] == 1:
1537
+ img3 = np.concatenate([image]*3, axis=2)
1538
+ else:
1539
+ img3 = image
1540
+
1541
+ # Make a copy in uint8 RGB for overlays
1542
+ if img3.dtype != np.uint8:
1543
+ img_u8 = (np.clip(img3, 0, 1) * 255).astype(np.uint8)
1544
+ else:
1545
+ img_u8 = img3.copy()
1546
+
1547
+ margin = 10
1548
+ h, w = img_u8.shape[:2]
1549
+ for a in anomalies:
1550
+ x1, y1, x2, y2 = a["minX"], a["minY"], a["maxX"], a["maxY"]
1551
+ x1 = max(0, x1 - margin); y1 = max(0, y1 - margin)
1552
+ x2 = min(w - 1, x2 + margin); y2 = min(h - 1, y2 + margin)
1553
+
1554
+ mbs = a.get("matched_bodies") or []
1555
+ if mbs:
1556
+ # anomalies with known bodies -> green box
1557
+ color = (0, 255, 0)
1558
+ else:
1559
+ # pure candidates -> red box
1560
+ color = (255, 0, 0)
1561
+
1562
+ cv2.rectangle(img_u8, (x1, y1), (x2, y2), color=color, thickness=5)
1563
+
1564
+ # NEW: overlay all predicted minor bodies as circles
1565
+ bodies = getattr(self, "predicted_minor_bodies", None)
1566
+ if bodies:
1567
+ for body in bodies:
1568
+ x = int(round(body["x"]))
1569
+ y = int(round(body["y"]))
1570
+ if 0 <= x < w and 0 <= y < h:
1571
+ # yellow circle so it stands out from red/green boxes
1572
+ cv2.circle(img_u8, (x, y), 8, (255, 255, 0), thickness=2)
1573
+
1574
+
1575
+ # Launch preview window
1576
+ icon = None
1577
+ try:
1578
+ if hasattr(self, "supernova_path") and self.supernova_path:
1579
+ icon = QIcon(self.supernova_path)
1580
+ except Exception:
1581
+ pass
1582
+
1583
+ prev = ImagePreviewWindow(
1584
+ img_u8, # anomaly-marked display image
1585
+ title=window_title,
1586
+ parent=self,
1587
+ icon=icon,
1588
+ source_path=source_path, # original file path
1589
+ )
1590
+ prev.pushed.connect(self._handle_preview_push)
1591
+ prev.minorBodySearchRequested.connect(self._on_preview_minor_body_search)
1592
+ prev.show() # non-modal
1593
+
1594
+ def _on_preview_minor_body_search(self):
1595
+ """
1596
+ Called when the user clicks 'Check Catalogued Minor Bodies in Field'
1597
+ on any anomaly preview window.
1598
+ """
1599
+ self.runMinorBodySearch()
1600
+
1601
+
1602
+ def _handle_preview_push(self, np_img, title: str):
1603
+ """
1604
+ Take the anomaly preview (np_img) and push it into SASpro as a *new*
1605
+ document by reusing *all* metadata/header information returned by
1606
+ load_image() for the source file, and only swapping the image array.
1607
+ """
1608
+ if not self.doc_manager:
1609
+ QMessageBox.warning(
1610
+ self,
1611
+ "No DocManager",
1612
+ "No document manager is available to push the preview."
1613
+ )
1614
+ return
1615
+
1616
+ # Which preview window emitted the signal? Grab its source_path.
1617
+ src_path = None
1618
+ sender = self.sender()
1619
+ if isinstance(sender, ImagePreviewWindow):
1620
+ src_path = getattr(sender, "_source_path", None)
1621
+
1622
+ if not src_path:
1623
+ QMessageBox.warning(
1624
+ self,
1625
+ "No Source File",
1626
+ "Could not determine the original file for this preview.\n"
1627
+ "Push to New View requires the original image path."
1628
+ )
1629
+ return
1630
+
1631
+ # Re-load the ORIGINAL file so we get the full tuple:
1632
+ # image, original_header, bit_depth, is_mono, meta
1633
+ try:
1634
+ res = load_image(src_path, return_metadata=True)
1635
+ except Exception as e:
1636
+ QMessageBox.critical(
1637
+ self,
1638
+ "Load Error",
1639
+ f"Failed to load original image:\n{e}"
1640
+ )
1641
+ return
1642
+
1643
+ if not res or res[0] is None:
1644
+ QMessageBox.critical(
1645
+ self,
1646
+ "Load Error",
1647
+ "Could not read original image data from disk."
1648
+ )
1649
+ return
1650
+
1651
+ orig_img, original_header, bit_depth, is_mono, meta = res
1652
+
1653
+ # Ensure meta is a dict we can stuff things into
1654
+ if not isinstance(meta, dict):
1655
+ meta = {}
1656
+
1657
+ # Keep ALL of the original pieces:
1658
+ # - store the original header explicitly if not already present
1659
+ meta.setdefault("fits_header", original_header)
1660
+ meta.setdefault("original_header", original_header)
1661
+ meta.setdefault("bit_depth", bit_depth)
1662
+ meta.setdefault("is_mono", is_mono)
1663
+ meta.setdefault("source_path", src_path)
1664
+
1665
+ # Give the new doc a nice display name
1666
+ meta["display_name"] = title
1667
+
1668
+ # Our preview image (with boxes). Normalize to float32 [0,1].
1669
+ img = np.asarray(np_img, copy=False)
1670
+ if img.dtype != np.float32:
1671
+ img = img.astype(np.float32, copy=False)
1672
+
1673
+ # If it looks like 0–255 data, rescale to 0–1
1674
+ if img.max() > 1.01 or img.min() < -0.01:
1675
+ img = np.clip(img, 0, 255) / 255.0
1676
+
1677
+ # Finally: create the new document using the preview pixels
1678
+ # but with *all* original metadata/header intact.
1679
+ self.doc_manager.create_document(
1680
+ image=img,
1681
+ metadata=meta,
1682
+ name=title,
1683
+ )
1684
+
1685
+
1686
+ def newInstance(self):
1687
+ # Reset parameters and UI elements for a new run
1688
+ self.parameters = {
1689
+ "referenceImagePath": "",
1690
+ "searchImagePaths": [],
1691
+ "threshold": 0.10
1692
+ }
1693
+
1694
+ self.ref_line_edit.clear()
1695
+ self.search_list.clear()
1696
+ self.cosmetic_checkbox.setChecked(False)
1697
+ self.thresh_slider.setValue(10)
1698
+
1699
+ self.preprocess_progress_label.setText("Preprocessing progress: 0 / 0")
1700
+ self.search_progress_label.setText("Processing progress: 0 / 0")
1701
+ self.status_label.setText("Status: Idle")
1702
+
1703
+ # Image + results state
1704
+ self.preprocessed_reference = None
1705
+ self.preprocessed_search = []
1706
+ self.anomalyData = []
1707
+
1708
+ # WCS / timing / minor-body state
1709
+ self.ref_header = None
1710
+ self.ref_wcs = None
1711
+ self.ref_jd = None
1712
+ self.ref_site = None
1713
+ self.predicted_minor_bodies = None
1714
+
1715
+ QMessageBox.information(self, "New Instance", "Reset for a new instance.")
1716
+