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