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,1530 @@
1
+ # sfcc.py
2
+ # SASpro Spectral Flux Color Calibration (SFCC) β€” "current view" integration
3
+ # - Expects a "view adapter" you provide that exposes:
4
+ # get_rgb_image() -> np.ndarray (H,W,3), uint8 or float32 in [0,1]
5
+ # get_metadata() -> dict (optional; may return {})
6
+ # get_header() -> astropy.io.fits.Header or dict (optional but needed for WCS features)
7
+ # set_rgb_image(img: np.ndarray, metadata: dict | None = None, step_name: str | None = None) -> None
8
+ # If your adapter names differ, tweak _get_img_meta/_get_header/_push_image below (they already try a few fallbacks).
9
+ #
10
+ # - Call open_sfcc(view_adapter, sasp_data_path) to show the dialog.
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import cv2
17
+ import math
18
+ import time
19
+ from datetime import datetime
20
+ from typing import List, Tuple, Optional
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ # ── SciPy bits
26
+ from scipy.interpolate import RBFInterpolator, interp1d
27
+ from scipy.signal import medfilt
28
+
29
+ # ── Astropy / Astroquery
30
+ from astropy.io import fits
31
+ from astropy.wcs import WCS
32
+ import astropy.units as u
33
+ from astropy.coordinates import SkyCoord
34
+ from astroquery.simbad import Simbad
35
+
36
+ # ── SEP (Source Extractor)
37
+ import sep
38
+
39
+ # ── Matplotlib backend for Qt
40
+ from matplotlib.figure import Figure
41
+ from matplotlib import pyplot as plt
42
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
43
+
44
+ from PyQt6.QtCore import (Qt, QPoint, QRect, QMimeData, QSettings, QByteArray,
45
+ QDataStream, QIODevice, QEvent, QStandardPaths)
46
+ from PyQt6.QtGui import (QAction, QDrag, QIcon, QMouseEvent, QPixmap, QKeyEvent)
47
+ from PyQt6.QtWidgets import (QToolBar, QWidget, QToolButton, QMenu, QApplication, QVBoxLayout, QHBoxLayout, QComboBox, QGroupBox, QGridLayout, QDoubleSpinBox, QSpinBox,
48
+ QInputDialog, QMessageBox, QDialog, QFileDialog,
49
+ QFormLayout, QDialogButtonBox, QDoubleSpinBox, QCheckBox, QLabel, QRubberBand, QRadioButton, QMainWindow, QPushButton)
50
+
51
+
52
+ # ──────────────────────────────────────────────────────────────────────────────
53
+ # Utilities
54
+ # ──────────────────────────────────────────────────────────────────────────────
55
+
56
+ # --- Debug/guards -----------------------------------------------------
57
+ def _debug_probe_channels(img: np.ndarray, label="input"):
58
+ assert img.ndim == 3 and img.shape[2] == 3, f"[SFCC] {label}: not RGB"
59
+ f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
60
+ means = [float(f[...,i].mean()) for i in range(3)]
61
+ stds = [float(f[...,i].std()) for i in range(3)]
62
+ rg = float(np.corrcoef(f[...,0].ravel(), f[...,1].ravel())[0,1])
63
+ rb = float(np.corrcoef(f[...,0].ravel(), f[...,2].ravel())[0,1])
64
+ gb = float(np.corrcoef(f[...,1].ravel(), f[...,2].ravel())[0,1])
65
+ print(f"[SFCC] {label}: mean={means}, std={stds}, corr(R,G)={rg:.5f}, corr(R,B)={rb:.5f}, corr(G,B)={gb:.5f}")
66
+ return rg, rb, gb
67
+
68
+ def _maybe_bgr_to_rgb(img: np.ndarray) -> np.ndarray:
69
+ # Heuristic: if channel-2 is consistently brightest in highlights and ch-0 the dimmest β†’ likely BGR.
70
+ f = img.astype(np.float32) / (255.0 if img.dtype == np.uint8 else 1.0)
71
+ lum = np.mean(f, axis=2)
72
+ thr = np.quantile(lum, 0.95)
73
+ m0 = f[...,0][lum >= thr].mean() if np.any(lum >= thr) else f[...,0].mean()
74
+ m1 = f[...,1][lum >= thr].mean() if np.any(lum >= thr) else f[...,1].mean()
75
+ m2 = f[...,2][lum >= thr].mean() if np.any(lum >= thr) else f[...,2].mean()
76
+ if (m2 > m1 >= m0) and (m2 - m0 > 0.02):
77
+ print("[SFCC] Heuristic suggests BGR input β†’ converting to RGB")
78
+ return img[..., ::-1]
79
+ return img
80
+
81
+ def _ensure_angstrom(wl: np.ndarray) -> np.ndarray:
82
+ """If wavelengths look like nm (β‰ˆ300–1100), convert to Γ…."""
83
+ med = float(np.median(wl))
84
+ return wl * 10.0 if 250.0 <= med <= 2000.0 else wl
85
+
86
+
87
+ def pickles_match_for_simbad(simbad_sp: str, available_extnames: List[str]) -> List[str]:
88
+ sp = simbad_sp.strip().upper()
89
+ if not sp:
90
+ return []
91
+ m = re.match(r"^([OBAFGKMLT])(\d?)(I{1,3}|IV|V)?", sp)
92
+ if not m:
93
+ return []
94
+ letter_class = m.group(1)
95
+ digit_part = m.group(2)
96
+ lum_part = m.group(3)
97
+ subclass = int(digit_part) if digit_part != "" else None
98
+
99
+ def parse_pickles_extname(ext: str):
100
+ ext = ext.strip().upper()
101
+ m2 = re.match(r"^([OBAFGKMLT])(\d+)(I{1,3}|IV|V)$", ext)
102
+ if not m2:
103
+ return None, None, None
104
+ return m2.group(1), int(m2.group(2)), m2.group(3)
105
+
106
+ parsed_templates = []
107
+ for ext in available_extnames:
108
+ l2, d2, L2 = parse_pickles_extname(ext)
109
+ if l2 is not None:
110
+ parsed_templates.append((ext, l2, d2, L2))
111
+
112
+ # Exact
113
+ if subclass is not None and lum_part is not None:
114
+ target = f"{letter_class}{subclass}{lum_part}"
115
+ if target in available_extnames:
116
+ return [target]
117
+
118
+ # Same letter (+same lum if we have it)
119
+ same_letter_and_lum = []
120
+ same_letter_any_lum = []
121
+ for (ext, l2, d2, L2) in parsed_templates:
122
+ if l2 != letter_class:
123
+ continue
124
+ if lum_part is not None and L2 == lum_part:
125
+ same_letter_and_lum.append((ext, d2))
126
+ else:
127
+ same_letter_any_lum.append((ext, d2))
128
+
129
+ def pick_nearest(candidates: List[Tuple[str, int]], target: int) -> List[str]:
130
+ if not candidates or target is None:
131
+ return []
132
+ arr = np.abs(np.array([d for _, d in candidates]) - target)
133
+ mind = np.min(arr)
134
+ return [candidates[i][0] for i in np.where(arr == mind)[0]]
135
+
136
+ if subclass is not None and lum_part is not None:
137
+ if same_letter_and_lum:
138
+ return pick_nearest(same_letter_and_lum, subclass)
139
+ if same_letter_any_lum:
140
+ return pick_nearest(same_letter_any_lum, subclass)
141
+
142
+ if subclass is not None and lum_part is None:
143
+ if same_letter_any_lum:
144
+ return pick_nearest(same_letter_any_lum, subclass)
145
+
146
+ if subclass is None and lum_part is None:
147
+ return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
148
+
149
+ if subclass is None and lum_part is not None:
150
+ cands = [ (ext, d2) for (ext, l2, d2, L2) in parsed_templates if l2 == letter_class and L2 == lum_part ]
151
+ if cands:
152
+ return sorted([ext for (ext, _) in cands])
153
+ return sorted([ext for (ext, l2, _, _) in parsed_templates if l2 == letter_class])
154
+
155
+ return []
156
+
157
+
158
+ def compute_gradient_map(sources, delta_flux, shape, method="poly2"):
159
+ H, W = shape
160
+ xs, ys = sources[:, 0], sources[:, 1]
161
+
162
+ if method == "poly2":
163
+ A = np.vstack([np.ones_like(xs), xs, ys, xs**2, xs*ys, ys**2]).T
164
+ coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
165
+ YY, XX = np.mgrid[0:H, 0:W]
166
+ return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
167
+ + coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2)
168
+
169
+ elif method == "poly3":
170
+ A = np.vstack([
171
+ np.ones_like(xs), xs, ys,
172
+ xs**2, xs*ys, ys**2,
173
+ xs**3, xs**2*ys, xs*ys**2, ys**3
174
+ ]).T
175
+ coeffs, *_ = np.linalg.lstsq(A, delta_flux, rcond=None)
176
+ YY, XX = np.mgrid[0:H, 0:W]
177
+ return (coeffs[0] + coeffs[1]*XX + coeffs[2]*YY
178
+ + coeffs[3]*XX**2 + coeffs[4]*XX*YY + coeffs[5]*YY**2
179
+ + coeffs[6]*XX**3 + coeffs[7]*XX**2*YY + coeffs[8]*XX*YY**2 + coeffs[9]*YY**3)
180
+
181
+ elif method == "rbf":
182
+ pts = np.vstack([xs, ys]).T
183
+ rbfi = RBFInterpolator(pts, delta_flux, kernel="thin_plate_spline", smoothing=1.0)
184
+ YY, XX = np.mgrid[0:H, 0:W]
185
+ grid_pts = np.vstack([XX.ravel(), YY.ravel()]).T
186
+ return rbfi(grid_pts).reshape(H, W)
187
+
188
+ else:
189
+ raise ValueError("method must be one of 'poly2','poly3','rbf'")
190
+
191
+
192
+ # ──────────────────────────────────────────────────────────────────────────────
193
+ # Simple responses viewer (unchanged core logic; useful for diagnostics)
194
+ # ──────────────────────────────────────────────────────────────────────────────
195
+ class SaspViewer(QMainWindow):
196
+ def __init__(self, sasp_data_path: str, user_custom_path: str):
197
+ super().__init__()
198
+ self.setWindowTitle(self.tr("SASP Viewer (Pickles + RGB Responses)"))
199
+
200
+ self.base_hdul = fits.open(sasp_data_path, mode="readonly", memmap=False)
201
+ self.custom_hdul = fits.open(user_custom_path, mode="readonly", memmap=False)
202
+
203
+ self.pickles_templates = []
204
+ self.filter_list = []
205
+ self.sensor_list = []
206
+ for hdul in (self.custom_hdul, self.base_hdul):
207
+ for hdu in hdul:
208
+ if not isinstance(hdu, fits.BinTableHDU): continue
209
+ c = hdu.header.get("CTYPE","").upper()
210
+ e = hdu.header.get("EXTNAME","")
211
+ if c == "SED": self.pickles_templates.append(e)
212
+ elif c == "FILTER": self.filter_list.append(e)
213
+ elif c == "SENSOR": self.sensor_list.append(e)
214
+
215
+ for lst in (self.pickles_templates, self.filter_list, self.sensor_list):
216
+ lst.sort()
217
+ self.rgb_filter_choices = ["(None)"] + self.filter_list
218
+
219
+ central = QWidget(); self.setCentralWidget(central)
220
+ vbox = QVBoxLayout(); central.setLayout(vbox)
221
+
222
+ row = QHBoxLayout(); vbox.addLayout(row)
223
+ row.addWidget(QLabel(self.tr("Star Template:")))
224
+ self.star_combo = QComboBox(); self.star_combo.addItems(self.pickles_templates); row.addWidget(self.star_combo)
225
+ row.addWidget(QLabel(self.tr("R-Filter:")))
226
+ self.r_filter_combo = QComboBox(); self.r_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.r_filter_combo)
227
+ row.addWidget(QLabel(self.tr("G-Filter:")))
228
+ self.g_filter_combo = QComboBox(); self.g_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.g_filter_combo)
229
+ row.addWidget(QLabel(self.tr("B-Filter:")))
230
+ self.b_filter_combo = QComboBox(); self.b_filter_combo.addItems(self.rgb_filter_choices); row.addWidget(self.b_filter_combo)
231
+
232
+ row2 = QHBoxLayout(); vbox.addLayout(row2)
233
+ row2.addWidget(QLabel(self.tr("LP/Cut Filter1:")))
234
+ self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo)
235
+ row2.addWidget(QLabel(self.tr("LP/Cut Filter2:")))
236
+ self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItems(self.rgb_filter_choices); row2.addWidget(self.lp_filter_combo2)
237
+ row2.addSpacing(20); row2.addWidget(QLabel(self.tr("Sensor (QE):")))
238
+ self.sens_combo = QComboBox(); self.sens_combo.addItems(self.sensor_list); row2.addWidget(self.sens_combo)
239
+
240
+ self.plot_btn = QPushButton(self.tr("Plot")); self.plot_btn.clicked.connect(self.update_plot); row.addWidget(self.plot_btn)
241
+
242
+ self.figure = Figure(figsize=(9, 6)); self.canvas = FigureCanvas(self.figure); vbox.addWidget(self.canvas)
243
+ self.update_plot()
244
+
245
+ def closeEvent(self, event):
246
+ self.base_hdul.close(); self.custom_hdul.close()
247
+ super().closeEvent(event)
248
+
249
+ def load_any(self, extname, field):
250
+ for hdul in (self.custom_hdul, self.base_hdul):
251
+ if extname in hdul:
252
+ return hdul[extname].data[field].astype(float)
253
+ raise KeyError(f"Extension '{extname}' not found")
254
+
255
+ def update_plot(self):
256
+ star_ext = self.star_combo.currentText()
257
+ r_filt = self.r_filter_combo.currentText()
258
+ g_filt = self.g_filter_combo.currentText()
259
+ b_filt = self.b_filter_combo.currentText()
260
+ sens_ext = self.sens_combo.currentText()
261
+ lp_ext1 = self.lp_filter_combo.currentText()
262
+ lp_ext2 = self.lp_filter_combo2.currentText()
263
+
264
+ wl_star = self.load_any(star_ext, "WAVELENGTH")
265
+ fl_star = self.load_any(star_ext, "FLUX")
266
+ wl_sens = self.load_any(sens_ext, "WAVELENGTH")
267
+ qe_sens = self.load_any(sens_ext, "THROUGHPUT")
268
+
269
+ wl_min, wl_max = 1150.0, 10620.0
270
+ common_wl = np.arange(wl_min, wl_max + 1.0, 1.0)
271
+
272
+ sed_interp = interp1d(wl_star, fl_star, kind="linear", bounds_error=False, fill_value=0.0)
273
+ sens_interp = interp1d(wl_sens, qe_sens, kind="linear", bounds_error=False, fill_value=0.0)
274
+ fl_common = sed_interp(common_wl)
275
+ sens_common = sens_interp(common_wl)
276
+
277
+ rgb_data = {}
278
+ for color, filt_name in (("red", r_filt), ("green", g_filt), ("blue", b_filt)):
279
+ if filt_name == "(None)":
280
+ rgb_data[color] = None; continue
281
+
282
+ wl_filt = self.load_any(filt_name, "WAVELENGTH")
283
+ tr_filt = self.load_any(filt_name, "THROUGHPUT")
284
+ filt_common = interp1d(wl_filt, tr_filt, bounds_error=False, fill_value=0.0)(common_wl)
285
+
286
+ def lp_curve(ext):
287
+ if ext == "(None)": return np.ones_like(common_wl)
288
+ wl_lp = self.load_any(ext, "WAVELENGTH"); tr_lp = self.load_any(ext, "THROUGHPUT")
289
+ return interp1d(wl_lp, tr_lp, bounds_error=False, fill_value=0.0)(common_wl)
290
+
291
+ T_LP = lp_curve(lp_ext1) * lp_curve(lp_ext2)
292
+ T_sys = filt_common * sens_common * T_LP
293
+ resp = fl_common * T_sys
294
+
295
+ rgb_data[color] = {"filter_name": filt_name, "T_sys": T_sys, "response": resp}
296
+
297
+ mag_texts = []
298
+ if "A0V" in self.pickles_templates:
299
+ wl_veg = self.load_any("A0V", "WAVELENGTH")
300
+ fl_veg = self.load_any("A0V", "FLUX")
301
+ fl_veg_c = interp1d(wl_veg, fl_veg, kind="linear", bounds_error=False, fill_value=0.0)(common_wl)
302
+ for color in ("red","green","blue"):
303
+ data = rgb_data[color]
304
+ if data is not None:
305
+ S_star = np.trapezoid(data["response"], x=common_wl)
306
+ S_veg = np.trapezoid(fl_veg_c * data["T_sys"], x=common_wl)
307
+ if S_veg>0 and S_star>0:
308
+ mag = -2.5 * np.log10(S_star / S_veg)
309
+ mag_texts.append(f"{color[0].upper()}β†’{data['filter_name']}: {mag:.2f}")
310
+ else:
311
+ mag_texts.append(f"{color[0].upper()}β†’{data['filter_name']}: N/A")
312
+ title_text = " | ".join(mag_texts) if mag_texts else self.tr("No channels selected")
313
+
314
+ self.figure.clf()
315
+ ax1 = self.figure.add_subplot(111)
316
+ ax1.plot(common_wl, fl_common, color="black", linewidth=1, label=f"{star_ext} SED")
317
+ for color, data in rgb_data.items():
318
+ if data is not None:
319
+ ax1.plot(common_wl, data["response"], color="gold", linewidth=1.5, label=self.tr("{0} Response").format(color.upper()))
320
+ ax1.set_xlim(wl_min, wl_max); ax1.set_xlabel(self.tr("Wavelength (Γ…)"))
321
+ ax1.set_ylabel(self.tr("Flux (erg s⁻¹ cm⁻² Å⁻¹)"), color="black"); ax1.tick_params(axis="y", labelcolor="black")
322
+
323
+ ax2 = ax1.twinx()
324
+ ax2.set_ylabel(self.tr("Relative Throughput"), color="red"); ax2.tick_params(axis="y", labelcolor="red"); ax2.set_ylim(0.0, 1.0)
325
+ if rgb_data["red"] is not None: ax2.plot(common_wl, rgb_data["red"]["T_sys"], color="red", linestyle="--", linewidth=1, label=self.tr("R filterΓ—QE"))
326
+ if rgb_data["green"] is not None: ax2.plot(common_wl, rgb_data["green"]["T_sys"], color="green", linestyle="--", linewidth=1, label=self.tr("G filterΓ—QE"))
327
+ if rgb_data["blue"] is not None: ax2.plot(common_wl, rgb_data["blue"]["T_sys"], color="blue", linestyle="--", linewidth=1, label=self.tr("B filterΓ—QE"))
328
+
329
+ ax1.grid(True, which="both", linestyle="--", alpha=0.3); self.figure.suptitle(title_text, fontsize=10)
330
+ lines1, labels1 = ax1.get_legend_handles_labels(); lines2, labels2 = ax2.get_legend_handles_labels()
331
+ ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper right")
332
+ self.canvas.draw()
333
+
334
+
335
+ # ──────────────────────────────────────────────────────────────────────────────
336
+ # SFCC Dialog (rewired for "current view")
337
+ # ──────────────────────────────────────────────────────────────────────────────
338
+ class SFCCDialog(QDialog):
339
+ """
340
+ Spectral Flux Color Calibration dialog, adapted for SASpro's current view.
341
+ Pass a 'view' adapter providing:
342
+ - get_rgb_image(), set_rgb_image(...)
343
+ - get_metadata() [optional]
344
+ - get_header() [preferred for WCS; else we look in metadata]
345
+ """
346
+ def __init__(self, doc_manager, sasp_data_path, parent=None):
347
+ super().__init__(parent)
348
+ self.setWindowTitle(self.tr("Spectral Flux Color Calibration"))
349
+ self.setWindowFlag(Qt.WindowType.Window, True)
350
+ self.setWindowModality(Qt.WindowModality.NonModal)
351
+ self.setModal(False)
352
+ try:
353
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
354
+ except Exception:
355
+ pass # older PyQt6 versions
356
+ self.setMinimumSize(800, 600)
357
+
358
+ self.doc_manager = doc_manager
359
+ self.sasp_data_path = sasp_data_path
360
+ self.user_custom_path = self._ensure_user_custom_fits()
361
+ self.current_image = None
362
+ self.current_header = None
363
+ self.orientation_label = QLabel(self.tr("Orientation: N/A"))
364
+ self.sasp_viewer_window = None
365
+ self.main_win = parent
366
+
367
+ # user custom file init … (unchanged)
368
+ # ...
369
+ self._reload_hdu_lists()
370
+ self.star_list = []
371
+ self._build_ui()
372
+ self.load_settings()
373
+
374
+ # persist combobox choices
375
+ self.r_filter_combo.currentIndexChanged.connect(self.save_r_filter_setting)
376
+ self.g_filter_combo.currentIndexChanged.connect(self.save_g_filter_setting)
377
+ self.b_filter_combo.currentIndexChanged.connect(self.save_b_filter_setting)
378
+ self.lp_filter_combo.currentIndexChanged.connect(self.save_lp_setting)
379
+ self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
380
+ self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
381
+ self.star_combo.currentIndexChanged.connect(self.save_star_setting)
382
+ self.finished.connect(lambda *_: self._cleanup())
383
+
384
+ self.grad_method = "poly3"
385
+ self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
386
+
387
+ # ── View plumbing ───────────────────────────────────────────────────
388
+ def _get_active_image_and_header(self):
389
+ doc = self.doc_manager.get_active_document()
390
+ if doc is None:
391
+ return None, None, None
392
+
393
+ img = doc.image
394
+ meta = doc.metadata or {}
395
+
396
+ # Prefer the normalized WCS header if present, then fall back
397
+ hdr = (
398
+ meta.get("wcs_header") or
399
+ meta.get("original_header") or
400
+ meta.get("header")
401
+ )
402
+
403
+ return img, hdr, meta
404
+
405
+
406
+ def _get_img_meta(self) -> Tuple[Optional[np.ndarray], dict]:
407
+ """Try a few common shapes to obtain image + metadata from the view."""
408
+ meta = {}
409
+ img = None
410
+ if hasattr(self.view, "get_image_and_metadata"):
411
+ try:
412
+ img, meta = self.view.get_image_and_metadata()
413
+ except Exception:
414
+ pass
415
+ if img is None and hasattr(self.view, "get_rgb_image"):
416
+ img = self.view.get_rgb_image()
417
+ if not meta and hasattr(self.view, "get_metadata"):
418
+ try:
419
+ meta = self.view.get_metadata() or {}
420
+ except Exception:
421
+ meta = {}
422
+ return img, (meta or {})
423
+
424
+ def _get_header(self):
425
+ header = None
426
+ if hasattr(self.view, "get_header"):
427
+ try:
428
+ header = self.view.get_header()
429
+ except Exception:
430
+ header = None
431
+ if header is None:
432
+ # fall back to metadata
433
+ _, meta = self._get_img_meta()
434
+ header = meta.get("original_header") or meta.get("header")
435
+ return header
436
+
437
+ def _push_image(self, img: np.ndarray, meta: Optional[dict], step_name: str):
438
+ """Send image back to the same current view."""
439
+ if hasattr(self.view, "set_rgb_image"):
440
+ self.view.set_rgb_image(img, meta or {}, step_name)
441
+ elif hasattr(self.view, "set_image"):
442
+ self.view.set_image(img, meta or {}, step_name=step_name)
443
+ elif hasattr(self.view, "update_image"):
444
+ self.view.update_image(img, meta or {}, step_name=step_name)
445
+ else:
446
+ # As a last resort, try attribute assignment (for custom apps)
447
+ if hasattr(self.view, "image"):
448
+ self.view.image = img
449
+ if hasattr(self.view, "metadata"):
450
+ self.view.metadata = meta or {}
451
+
452
+ # ── File prep ───────────────────────────────────────────────────────
453
+
454
+ def _ensure_user_custom_fits(self) -> str:
455
+ app_data = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
456
+ os.makedirs(app_data, exist_ok=True)
457
+ path = os.path.join(app_data, "usercustomcurves.fits")
458
+ if not os.path.exists(path):
459
+ fits.HDUList([fits.PrimaryHDU()]).writeto(path)
460
+ return path
461
+
462
+ # ── UI ──────────────────────────────────────────────────────────────
463
+
464
+ def _build_ui(self):
465
+ layout = QVBoxLayout(self)
466
+
467
+ row1 = QHBoxLayout(); layout.addLayout(row1)
468
+ self.fetch_stars_btn = QPushButton(self.tr("Step 1: Fetch Stars from Current View"))
469
+ f = self.fetch_stars_btn.font(); f.setBold(True); self.fetch_stars_btn.setFont(f)
470
+ self.fetch_stars_btn.clicked.connect(self.fetch_stars)
471
+ row1.addWidget(self.fetch_stars_btn)
472
+
473
+ self.open_sasp_btn = QPushButton(self.tr("Open SASP Viewer"))
474
+ self.open_sasp_btn.clicked.connect(self.open_sasp_viewer)
475
+ row1.addWidget(self.open_sasp_btn)
476
+
477
+ row1.addSpacing(20)
478
+ row1.addWidget(QLabel(self.tr("Select White Reference:")))
479
+ self.star_combo = QComboBox()
480
+ self.star_combo.addItem(self.tr("Vega (A0V)"), userData="A0V")
481
+ for sed in getattr(self, "sed_list", []):
482
+ if sed.upper() == "A0V": continue
483
+ self.star_combo.addItem(sed, userData=sed)
484
+ row1.addWidget(self.star_combo)
485
+ idx_g2v = self.star_combo.findData("G2V")
486
+ if idx_g2v >= 0: self.star_combo.setCurrentIndex(idx_g2v)
487
+
488
+ row2 = QHBoxLayout(); layout.addLayout(row2)
489
+ row2.addWidget(QLabel(self.tr("R Filter:")))
490
+ self.r_filter_combo = QComboBox(); self.r_filter_combo.addItem("(None)"); self.r_filter_combo.addItems(self.filter_list); row2.addWidget(self.r_filter_combo)
491
+ row2.addSpacing(20); row2.addWidget(QLabel(self.tr("G Filter:")))
492
+ self.g_filter_combo = QComboBox(); self.g_filter_combo.addItem("(None)"); self.g_filter_combo.addItems(self.filter_list); row2.addWidget(self.g_filter_combo)
493
+ row2.addSpacing(20); row2.addWidget(QLabel(self.tr("B Filter:")))
494
+ self.b_filter_combo = QComboBox(); self.b_filter_combo.addItem("(None)"); self.b_filter_combo.addItems(self.filter_list); row2.addWidget(self.b_filter_combo)
495
+
496
+ row3 = QHBoxLayout(); layout.addLayout(row3)
497
+ row3.addStretch()
498
+ row3.addWidget(QLabel(self.tr("Sensor (QE):")))
499
+ self.sens_combo = QComboBox(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list); row3.addWidget(self.sens_combo)
500
+ row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter1:")))
501
+ self.lp_filter_combo = QComboBox(); self.lp_filter_combo.addItem("(None)"); self.lp_filter_combo.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo)
502
+ row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter2:")))
503
+ self.lp_filter_combo2 = QComboBox(); self.lp_filter_combo2.addItem("(None)"); self.lp_filter_combo2.addItems(self.filter_list); row3.addWidget(self.lp_filter_combo2)
504
+ row3.addStretch()
505
+
506
+ row4 = QHBoxLayout(); layout.addLayout(row4)
507
+ self.run_spcc_btn = QPushButton(self.tr("Step 2: Run Color Calibration"))
508
+ f2 = self.run_spcc_btn.font(); f2.setBold(True); self.run_spcc_btn.setFont(f2)
509
+ self.run_spcc_btn.clicked.connect(self.run_spcc)
510
+ row4.addWidget(self.run_spcc_btn)
511
+
512
+ self.neutralize_chk = QCheckBox(self.tr("Background Neutralization")); self.neutralize_chk.setChecked(True); row4.addWidget(self.neutralize_chk)
513
+
514
+ self.run_grad_btn = QPushButton(self.tr("Run Gradient Extraction (Beta)"))
515
+ f3 = self.run_grad_btn.font(); f3.setBold(True); self.run_grad_btn.setFont(f3)
516
+ self.run_grad_btn.clicked.connect(self.run_gradient_extraction)
517
+ row4.addWidget(self.run_grad_btn)
518
+
519
+ self.grad_method_combo = QComboBox(); self.grad_method_combo.addItems(["poly2","poly3","rbf"]); self.grad_method_combo.setCurrentText("poly3")
520
+ row4.addWidget(self.grad_method_combo)
521
+
522
+ row4.addSpacing(15)
523
+ row4.addWidget(QLabel(self.tr("Star detect Οƒ:")))
524
+ self.sep_thr_spin = QSpinBox()
525
+ self.sep_thr_spin.setRange(2, 50) # should be enough
526
+ self.sep_thr_spin.setValue(5) # our current hardcoded value
527
+ self.sep_thr_spin.valueChanged.connect(self.save_sep_threshold_setting)
528
+ row4.addWidget(self.sep_thr_spin)
529
+
530
+ row4.addStretch()
531
+ self.add_curve_btn = QPushButton(self.tr("Add Custom Filter/Sensor Curve…"))
532
+ self.add_curve_btn.clicked.connect(self.add_custom_curve); row4.addWidget(self.add_curve_btn)
533
+ self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
534
+ self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
535
+ row4.addStretch()
536
+ self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.reject); row4.addWidget(self.close_btn)
537
+
538
+ self.count_label = QLabel(""); layout.addWidget(self.count_label)
539
+
540
+ self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
541
+ self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.reject); layout.addWidget(self.reset_btn)
542
+
543
+ # hide gradient controls by default (enable if you like)
544
+ self.run_grad_btn.hide(); self.grad_method_combo.hide()
545
+ layout.addWidget(self.orientation_label)
546
+
547
+ # ── Settings helpers ────────────────────────────────────────────────
548
+
549
+ def _reload_hdu_lists(self):
550
+ self.sed_list = []
551
+ with fits.open(self.sasp_data_path, mode="readonly", memmap=False) as base:
552
+ for hdu in base:
553
+ if isinstance(hdu, fits.BinTableHDU) and hdu.header.get("CTYPE","").upper()=="SED":
554
+ self.sed_list.append(hdu.header["EXTNAME"])
555
+
556
+ self.filter_list = []; self.sensor_list = []
557
+ for path in (self.sasp_data_path, self.user_custom_path):
558
+ with fits.open(path, mode="readonly", memmap=False) as hdul:
559
+ for hdu in hdul:
560
+ if not isinstance(hdu, fits.BinTableHDU): continue
561
+ c = hdu.header.get("CTYPE","").upper(); e = hdu.header.get("EXTNAME","")
562
+ if c=="FILTER": self.filter_list.append(e)
563
+ elif c=="SENSOR": self.sensor_list.append(e)
564
+ self.sed_list.sort(); self.filter_list.sort(); self.sensor_list.sort()
565
+
566
+ def load_settings(self):
567
+ s = QSettings()
568
+ def apply(cb, key):
569
+ val = s.value(key, "")
570
+ if val:
571
+ idx = cb.findText(val)
572
+ if idx != -1:
573
+ cb.setCurrentIndex(idx)
574
+
575
+ # existing stuff...
576
+ saved_star = QSettings().value("SFCC/WhiteReference", "")
577
+ if saved_star:
578
+ idx = self.star_combo.findText(saved_star)
579
+ if idx != -1:
580
+ self.star_combo.setCurrentIndex(idx)
581
+
582
+ apply(self.r_filter_combo, "SFCC/RFilter")
583
+ apply(self.g_filter_combo, "SFCC/GFilter")
584
+ apply(self.b_filter_combo, "SFCC/BFilter")
585
+ apply(self.sens_combo, "SFCC/Sensor")
586
+ apply(self.lp_filter_combo, "SFCC/LPFilter")
587
+ apply(self.lp_filter_combo2, "SFCC/LPFilter2")
588
+
589
+ # πŸ‘‡ NEW: load SEP/star-detect threshold
590
+ sep_thr = int(s.value("SFCC/SEPThreshold", 5))
591
+ if hasattr(self, "sep_thr_spin"):
592
+ self.sep_thr_spin.setValue(sep_thr)
593
+ def save_sep_threshold_setting(self, v: int):
594
+ QSettings().setValue("SFCC/SEPThreshold", int(v))
595
+
596
+ def save_lp_setting(self, _): QSettings().setValue("SFCC/LPFilter", self.lp_filter_combo.currentText())
597
+ def save_lp2_setting(self, _): QSettings().setValue("SFCC/LPFilter2", self.lp_filter_combo2.currentText())
598
+ def save_star_setting(self, _): QSettings().setValue("SFCC/WhiteReference", self.star_combo.currentText())
599
+ def save_r_filter_setting(self, _): QSettings().setValue("SFCC/RFilter", self.r_filter_combo.currentText())
600
+ def save_g_filter_setting(self, _): QSettings().setValue("SFCC/GFilter", self.g_filter_combo.currentText())
601
+ def save_b_filter_setting(self, _): QSettings().setValue("SFCC/BFilter", self.b_filter_combo.currentText())
602
+ def save_sensor_setting(self, _): QSettings().setValue("SFCC/Sensor", self.sens_combo.currentText())
603
+
604
+ # ── Curve utilities ─────────────────────────────────────────────────
605
+
606
+ def interpolate_bad_points(self, wl, tr):
607
+ tr = tr.copy()
608
+ bad = (tr < 0.0) | (tr > 1.0)
609
+ good = ~bad
610
+ if not np.any(bad): return tr, np.array([], dtype=int)
611
+ if np.sum(good) < 2: raise RuntimeError("Not enough valid points to interpolate anomalies.")
612
+ tr_corr = tr.copy()
613
+ tr_corr[bad] = np.interp(wl[bad], wl[good], tr[good])
614
+ return tr_corr, np.where(bad)[0]
615
+
616
+ def smooth_curve(self, tr, window_size=5):
617
+ return medfilt(tr, kernel_size=window_size)
618
+
619
+ def get_calibration_points(self, rgb_img: np.ndarray):
620
+ print("\nClick three calibration points: BL (Ξ»min,0), BR (Ξ»max,0), TL (Ξ»min,1)")
621
+ fig, ax = plt.subplots(figsize=(8, 5)); ax.imshow(rgb_img); ax.set_title(self.tr("Click 3 points, then close"))
622
+ pts = plt.ginput(3, timeout=-1); plt.close(fig)
623
+ if len(pts) != 3: raise RuntimeError(self.tr("Need exactly three clicks for calibration."))
624
+ return pts[0], pts[1], pts[2]
625
+
626
+ def build_transforms(self, px_bl, py_bl, px_br, py_br, px_tl, py_tl, Ξ»_min, Ξ»_max, resp_min, resp_max):
627
+ nm_per_px = (Ξ»_max - Ξ»_min) / (px_br - px_bl)
628
+ resp_per_px = (resp_max - resp_min) / (py_bl - py_tl)
629
+ def px_to_Ξ»(px): return Ξ»_min + (px - px_bl) * nm_per_px
630
+ def py_to_resp(py): return resp_max - (py - py_tl) * resp_per_px
631
+ return px_to_Ξ», py_to_resp
632
+
633
+ def extract_curve(self, gray_img, Ξ»_mapper, resp_mapper, Ξ»_min, Ξ»_max, threshold=50):
634
+ H, W = gray_img.shape
635
+ data = []
636
+ for px in range(W):
637
+ col = gray_img[:, px]
638
+ py_min = int(np.argmin(col)); val_min = int(col[py_min])
639
+ if val_min < threshold:
640
+ lam = Ξ»_mapper(px)
641
+ if Ξ»_min <= lam <= Ξ»_max:
642
+ data.append((lam, resp_mapper(py_min)))
643
+ if not data:
644
+ raise RuntimeError("No dark pixels found; raise threshold or adjust clicks.")
645
+ df = (pd.DataFrame(data, columns=["wavelength_nm", "response"])
646
+ .sort_values("wavelength_nm").reset_index(drop=True))
647
+ df = df[(df["wavelength_nm"] >= Ξ»_min) & (df["wavelength_nm"] <= Ξ»_max)].copy()
648
+ if df["wavelength_nm"].iloc[0] > Ξ»_min:
649
+ df = pd.concat([pd.DataFrame([[Ξ»_min, 0.0]], columns=["wavelength_nm", "response"]), df], ignore_index=True)
650
+ if df["wavelength_nm"].iloc[-1] < Ξ»_max:
651
+ df = pd.concat([df, pd.DataFrame([[Ξ»_max, 0.0]], columns=["wavelength_nm", "response"])], ignore_index=True)
652
+ return df.sort_values("wavelength_nm").reset_index(drop=True)
653
+
654
+ def _query_name_channel(self):
655
+ name_str, ok1 = QInputDialog.getText(self, self.tr("Curve Name"), self.tr("Enter curve name (EXTNAME):"))
656
+ if not (ok1 and name_str.strip()): return False, None, None
657
+ extname = name_str.strip().upper().replace(" ", "_")
658
+ ch_str, ok2 = QInputDialog.getText(self, self.tr("Channel"), self.tr("Enter channel (R,G,B or Q for sensor):"))
659
+ if not (ok2 and ch_str.strip()): return False, None, None
660
+ return True, extname, ch_str.strip().upper()
661
+
662
+ def _append_curve_hdu(self, wl_ang, tr_final, extname, ctype, origin):
663
+ col_wl = fits.Column(name="WAVELENGTH", format="E", unit="Angstrom", array=wl_ang.astype(np.float32))
664
+ col_tr = fits.Column(name="THROUGHPUT", format="E", unit="REL", array=tr_final.astype(np.float32))
665
+ new_hdu = fits.BinTableHDU.from_columns([col_wl, col_tr])
666
+ new_hdu.header["EXTNAME"] = extname
667
+ new_hdu.header["CTYPE"] = ctype
668
+ new_hdu.header["ORIGIN"] = origin
669
+ with fits.open(self.user_custom_path, mode="update", memmap=False) as hdul:
670
+ hdul.append(new_hdu); hdul.flush()
671
+
672
+ def add_custom_curve(self):
673
+ msg = QMessageBox(self); msg.setWindowTitle(self.tr("Add Custom Curve")); msg.setText(self.tr("Choose how to add the curve:"))
674
+ csv_btn = msg.addButton(self.tr("Import CSV"), QMessageBox.ButtonRole.AcceptRole)
675
+ img_btn = msg.addButton(self.tr("Digitize Image"), QMessageBox.ButtonRole.AcceptRole)
676
+ cancel_btn = msg.addButton(QMessageBox.StandardButton.Cancel)
677
+ msg.exec()
678
+ if msg.clickedButton() == csv_btn: self._import_curve_from_csv()
679
+ elif msg.clickedButton() == img_btn: self._digitize_curve_from_image()
680
+
681
+ def _import_curve_from_csv(self):
682
+ csv_path, _ = QFileDialog.getOpenFileName(self, self.tr("Select 2-column CSV (Ξ»_nm, response)"), "", "CSV Files (*.csv);;All Files (*)")
683
+ if not csv_path: return
684
+ try:
685
+ df = (pd.read_csv(csv_path, comment="#", header=None).iloc[:, :2].dropna())
686
+ df.columns = ["wavelength_nm","response"]
687
+ wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
688
+ except ValueError:
689
+ try:
690
+ df = (pd.read_csv(csv_path, comment="#", header=0).iloc[:, :2].dropna())
691
+ df.columns = ["wavelength_nm","response"]
692
+ wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
693
+ except Exception as e2:
694
+ QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e2)); return
695
+ except Exception as e:
696
+ QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e)); return
697
+
698
+ ok, extname_base, channel_val = self._query_name_channel()
699
+ if not ok: return
700
+ wl_ang = (wl_nm * 10.0).astype(np.float32); tr_final = tp.astype(np.float32)
701
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"CSV:{os.path.basename(csv_path)}")
702
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
703
+ QMessageBox.information(self, self.tr("Done"), self.tr("CSV curve '{0}' added.").format(extname_base))
704
+
705
+ def _digitize_curve_from_image(self):
706
+ img_path_str, _ = QFileDialog.getOpenFileName(self, self.tr("Select Curve Image to Digitize"), "", "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)")
707
+ if not img_path_str: return
708
+ img_filename = os.path.basename(img_path_str)
709
+ try:
710
+ bgr = cv2.imread(img_path_str)
711
+ if bgr is None: raise RuntimeError(f"cv2.imread returned None for '{img_path_str}'")
712
+ rgb_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB); gray_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
713
+ except Exception as e:
714
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Could not load image:\n{0}").format(e)); return
715
+
716
+ try:
717
+ (px_bl, py_bl), (px_br, py_br), (px_tl, py_tl) = self.get_calibration_points(rgb_img)
718
+ except Exception as e:
719
+ QMessageBox.critical(self, self.tr("Digitization Error"), str(e)); return
720
+
721
+ Ξ»_min_str, ok1 = QInputDialog.getText(self, self.tr("Ξ»_min"), self.tr("Enter Ξ»_min (in nm):"))
722
+ Ξ»_max_str, ok2 = QInputDialog.getText(self, self.tr("Ξ»_max"), self.tr("Enter Ξ»_max (in nm):"))
723
+ if not (ok1 and ok2 and Ξ»_min_str.strip() and Ξ»_max_str.strip()): return
724
+ try:
725
+ Ξ»_min = float(Ξ»_min_str); Ξ»_max = float(Ξ»_max_str)
726
+ except ValueError:
727
+ QMessageBox.critical(self, self.tr("Input Error"), self.tr("Ξ»_min and Ξ»_max must be numbers.")); return
728
+
729
+ ok, extname_base, channel_val = self._query_name_channel()
730
+ if not ok: return
731
+
732
+ px_to_Ξ», py_to_resp = self.build_transforms(px_bl, py_bl, px_br, py_br, px_tl, py_tl, Ξ»_min, Ξ»_max, 0.0, 1.0)
733
+ try:
734
+ df_curve = self.extract_curve(gray_img, px_to_Ξ», py_to_resp, Ξ»_min, Ξ»_max, threshold=50)
735
+ except Exception as e:
736
+ QMessageBox.critical(self, self.tr("Extraction Error"), str(e)); return
737
+
738
+ df_curve["wl_int"] = df_curve["wavelength_nm"].round().astype(int)
739
+ grp = (df_curve.groupby("wl_int")["response"].median().reset_index().sort_values("wl_int"))
740
+ wl = grp["wl_int"].to_numpy(dtype=int); tr = grp["response"].to_numpy(dtype=float)
741
+
742
+ try:
743
+ tr_corr, _ = self.interpolate_bad_points(wl, tr)
744
+ except Exception as e:
745
+ QMessageBox.critical(self, self.tr("Interpolation Error"), str(e)); return
746
+
747
+ tr_smoothed = self.smooth_curve(tr_corr, window_size=5)
748
+ wl_ang = (wl.astype(float) * 10.0).astype(np.float32); tr_final = tr_smoothed.astype(np.float32)
749
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"UserDefined:{img_filename}")
750
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
751
+ QMessageBox.information(self, self.tr("Done"), self.tr("Added curve '{0}'.").format(extname_base))
752
+
753
+ def remove_custom_curve(self):
754
+ all_curves = self.filter_list + self.sensor_list
755
+ if not all_curves:
756
+ QMessageBox.information(self, self.tr("Remove Curve"), self.tr("No custom curves to remove.")); return
757
+ curve, ok = QInputDialog.getItem(self, self.tr("Remove Curve"), self.tr("Select a FILTER or SENSOR curve to delete:"), all_curves, 0, False)
758
+ if not ok or not curve: return
759
+ reply = QMessageBox.question(self, self.tr("Confirm Deletion"), self.tr("Delete '{0}'?").format(curve), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
760
+ if reply != QMessageBox.StandardButton.Yes: return
761
+
762
+ temp_path = self.user_custom_path + ".tmp"
763
+ try:
764
+ with fits.open(self.user_custom_path, memmap=False) as old_hdul:
765
+ new_hdus = []
766
+ for hdu in old_hdul:
767
+ if hdu is old_hdul[0]:
768
+ new_hdus.append(hdu.copy())
769
+ else:
770
+ if hdu.header.get("EXTNAME") != curve:
771
+ new_hdus.append(hdu.copy())
772
+ fits.HDUList(new_hdus).writeto(temp_path, overwrite=True)
773
+ os.replace(temp_path, self.user_custom_path)
774
+ except Exception as e:
775
+ if os.path.exists(temp_path): os.remove(temp_path)
776
+ QMessageBox.critical(self, "Write Error", f"Could not remove curve:\n{e}"); return
777
+
778
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
779
+ QMessageBox.information(self, self.tr("Removed"), self.tr("Deleted curve '{0}'.").format(curve))
780
+
781
+ def refresh_filter_sensor_lists(self):
782
+ self._reload_hdu_lists()
783
+ current_r = self.r_filter_combo.currentText()
784
+ current_g = self.g_filter_combo.currentText()
785
+ current_b = self.b_filter_combo.currentText()
786
+ current_s = self.sens_combo.currentText()
787
+ current_lp = self.lp_filter_combo.currentText()
788
+ current_lp2 = self.lp_filter_combo2.currentText()
789
+
790
+ for cb, lst, prev in [
791
+ (self.r_filter_combo, self.filter_list, current_r),
792
+ (self.g_filter_combo, self.filter_list, current_g),
793
+ (self.b_filter_combo, self.filter_list, current_b),
794
+ ]:
795
+ cb.clear(); cb.addItem("(None)"); cb.addItems(lst)
796
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
797
+
798
+ for cb, prev in [(self.lp_filter_combo, current_lp), (self.lp_filter_combo2, current_lp2)]:
799
+ cb.clear(); cb.addItem("(None)"); cb.addItems(self.filter_list)
800
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
801
+
802
+ self.sens_combo.clear(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list)
803
+ idx = self.sens_combo.findText(current_s); self.sens_combo.setCurrentIndex(idx if idx != -1 else 0)
804
+
805
+ # ── WCS utilities ──────────────────────────────────────────────────
806
+ def initialize_wcs_from_header(self, header):
807
+ if header is None:
808
+ print("No FITS header available; cannot build WCS.")
809
+ return
810
+ try:
811
+ hdr = header.copy()
812
+
813
+ # --- normalize deprecated keywords ---
814
+ if "RADECSYS" in hdr and "RADESYS" not in hdr:
815
+ radesys_val = str(hdr["RADECSYS"]).strip()
816
+ hdr["RADESYS"] = radesys_val
817
+ try:
818
+ del hdr["RADECSYS"]
819
+ except Exception:
820
+ pass
821
+
822
+ alt_letters = {
823
+ k[-1]
824
+ for k in hdr.keys()
825
+ if re.match(r"^CTYPE[12][A-Z]$", k)
826
+ }
827
+ for a in alt_letters:
828
+ key = f"RADESYS{a}"
829
+ if key not in hdr:
830
+ hdr[key] = radesys_val
831
+
832
+ if "EPOCH" in hdr and "EQUINOX" not in hdr:
833
+ hdr["EQUINOX"] = hdr["EPOCH"]
834
+ try:
835
+ del hdr["EPOCH"]
836
+ except Exception:
837
+ pass
838
+
839
+ # IMPORTANT: use the normalized hdr, not the original header
840
+ self.wcs = WCS(hdr, naxis=2, relax=True)
841
+
842
+ psm = self.wcs.pixel_scale_matrix
843
+ self.pixscale = np.hypot(psm[0, 0], psm[1, 0]) * 3600.0
844
+ self.center_ra, self.center_dec = self.wcs.wcs.crval
845
+ self.wcs_header = self.wcs.to_header(relax=True)
846
+
847
+ # Orientation from normalized header
848
+ if "CROTA2" in hdr:
849
+ try:
850
+ self.orientation = float(hdr["CROTA2"])
851
+ except Exception:
852
+ self.orientation = None
853
+ else:
854
+ self.orientation = self.calculate_orientation(hdr)
855
+
856
+ if self.orientation is not None:
857
+ self.orientation_label.setText(f"Orientation: {self.orientation:.2f}Β°")
858
+ else:
859
+ self.orientation_label.setText("Orientation: N/A")
860
+
861
+ except Exception as e:
862
+ print("WCS initialization error:\n", e)
863
+
864
+
865
+ def calculate_orientation(self, header):
866
+ try:
867
+ cd1_1 = float(header.get("CD1_1", 0.0))
868
+ cd1_2 = float(header.get("CD1_2", 0.0))
869
+ return math.degrees(math.atan2(cd1_2, cd1_1))
870
+ except Exception:
871
+ return None
872
+
873
+ def calculate_ra_dec_from_pixel(self, x, y):
874
+ if not hasattr(self, "wcs"): return None, None
875
+ return self.wcs.all_pix2world(x, y, 0)
876
+
877
+ # ── Background neutralization ───────────────────────────────────────
878
+
879
+ def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
880
+ img = rgb_img.copy()
881
+ h, w = img.shape[:2]
882
+ ph, pw = h // patch_size, w // patch_size
883
+ min_sum, best_med = np.inf, None
884
+ for i in range(patch_size):
885
+ for j in range(patch_size):
886
+ y0, x0 = i * ph, j * pw
887
+ patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
888
+ med = np.median(patch, axis=(0, 1))
889
+ s = med.sum()
890
+ if s < min_sum:
891
+ min_sum, best_med = s, med
892
+ if best_med is None:
893
+ return img
894
+ target = float(best_med.mean()); eps = 1e-8
895
+ for c in range(3):
896
+ diff = float(best_med[c] - target)
897
+ if abs(diff) < eps: continue
898
+ img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
899
+ return img
900
+
901
+ # ── SIMBAD/Star fetch ──────────────────────────────────────────────
902
+
903
+ def fetch_stars(self):
904
+ # 0) Grab current image + header from the active document
905
+ img, hdr, _meta = self._get_active_image_and_header()
906
+ self.current_image = img
907
+ self.current_header = hdr
908
+
909
+ if self.current_header is None or self.current_image is None:
910
+ QMessageBox.warning(self, "No Plate Solution",
911
+ "Please plate-solve the active document first.")
912
+ return
913
+
914
+ # Pickles templates list (once)
915
+ if not hasattr(self, "pickles_templates"):
916
+ self.pickles_templates = []
917
+ for p in (self.user_custom_path, self.sasp_data_path):
918
+ try:
919
+ with fits.open(p) as hd:
920
+ for hdu in hd:
921
+ if (isinstance(hdu, fits.BinTableHDU)
922
+ and hdu.header.get("CTYPE", "").upper() == "SED"):
923
+ extname = hdu.header.get("EXTNAME", None)
924
+ if extname and extname not in self.pickles_templates:
925
+ self.pickles_templates.append(extname)
926
+ except Exception as e:
927
+ print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
928
+
929
+ # Build WCS
930
+ try:
931
+ self.initialize_wcs_from_header(self.current_header)
932
+ except Exception:
933
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
934
+
935
+ H, W = self.current_image.shape[:2]
936
+ pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
937
+ try:
938
+ sky = self.wcs.all_pix2world(pix, 0)
939
+ except Exception as e:
940
+ QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
941
+ center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
942
+ corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
943
+ radius_deg = center_sky.separation(corners_sky).max().deg
944
+
945
+ # Simbad fields
946
+ Simbad.reset_votable_fields()
947
+ for attempt in range(1, 6):
948
+ try:
949
+ Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
950
+ break
951
+ except Exception:
952
+ QApplication.processEvents()
953
+ time.sleep(1.2)
954
+ Simbad.ROW_LIMIT = 10000
955
+
956
+ for attempt in range(1, 6):
957
+ try:
958
+ result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
959
+ break
960
+ except Exception as e:
961
+ self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
962
+ QApplication.processEvents(); time.sleep(1.2)
963
+ result = None
964
+ if result is None or len(result) == 0:
965
+ QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
966
+ self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
967
+
968
+ def infer_letter(bv):
969
+ if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
970
+ if bv < 0.00: return "B"
971
+ elif bv < 0.30: return "A"
972
+ elif bv < 0.58: return "F"
973
+ elif bv < 0.81: return "G"
974
+ elif bv < 1.40: return "K"
975
+ elif bv > 1.40: return "M"
976
+ else: return "U"
977
+
978
+ self.star_list = []; templates_for_hist = []
979
+ for row in result:
980
+ raw_sp = row['sp_type']
981
+ bmag, vmag, rmag = row['B'], row['V'], row['R']
982
+ ra_deg, dec_deg = float(row['ra']), float(row['dec'])
983
+ try:
984
+ sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
985
+ except Exception:
986
+ continue
987
+
988
+ def _unmask_num(x):
989
+ try:
990
+ if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
991
+ return None
992
+ return float(x)
993
+ except Exception:
994
+ return None
995
+
996
+ # inside your SIMBAD row loop:
997
+ bmag = _unmask_num(row['B'])
998
+ vmag = _unmask_num(row['V'])
999
+
1000
+ sp_clean = None
1001
+ if raw_sp and str(raw_sp).strip():
1002
+ sp = str(raw_sp).strip().upper()
1003
+ if not (sp.startswith("SN") or sp.startswith("KA")):
1004
+ sp_clean = sp
1005
+ elif bmag is not None and vmag is not None:
1006
+ bv = bmag - vmag
1007
+ sp_clean = infer_letter(bv)
1008
+ if not sp_clean: continue
1009
+
1010
+ match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1011
+ best_template = match_list[0] if match_list else None
1012
+ xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
1013
+ if 0 <= xpix < W and 0 <= ypix < H:
1014
+ self.star_list.append({
1015
+ "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1016
+ "pickles_match": best_template, "x": xpix, "y": ypix,
1017
+ "Bmag": float(bmag) if bmag else None,
1018
+ "Vmag": float(vmag) if vmag else None,
1019
+ "Rmag": float(rmag) if rmag else None,
1020
+ })
1021
+ if best_template is not None: templates_for_hist.append(best_template)
1022
+
1023
+ self.figure.clf()
1024
+ if templates_for_hist:
1025
+ uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1026
+ types_str = ", ".join(uniq)
1027
+ self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1028
+ ax = self.figure.add_subplot(111)
1029
+ ax.bar(uniq, cnt, edgecolor="black")
1030
+ ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1031
+ ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1032
+ self.canvas.setVisible(True); self.canvas.draw()
1033
+ else:
1034
+ self.count_label.setText("Found 0 stars with Pickles matches.")
1035
+ self.canvas.setVisible(False); self.canvas.draw()
1036
+
1037
+ # ── Core SFCC ───────────────────────────────────────────────────────
1038
+
1039
+ def run_spcc(self):
1040
+ ref_sed_name = self.star_combo.currentData()
1041
+ r_filt = self.r_filter_combo.currentText()
1042
+ g_filt = self.g_filter_combo.currentText()
1043
+ b_filt = self.b_filter_combo.currentText()
1044
+ sens_name = self.sens_combo.currentText()
1045
+ lp_filt = self.lp_filter_combo.currentText()
1046
+ lp_filt2 = self.lp_filter_combo2.currentText()
1047
+
1048
+ if not ref_sed_name:
1049
+ QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
1050
+ if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
1051
+ QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters."); return
1052
+ if sens_name == "(None)":
1053
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1054
+
1055
+ # -- Step 1A: get active image as float32 in [0..1]
1056
+ doc = self.doc_manager.get_active_document()
1057
+ if doc is None or doc.image is None:
1058
+ QMessageBox.critical(self, "Error", "No active document.")
1059
+ return
1060
+
1061
+ img = doc.image
1062
+ H, W = img.shape[:2]
1063
+ if img.ndim != 3 or img.shape[2] != 3:
1064
+ QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1065
+ return
1066
+
1067
+ if img.dtype == np.uint8:
1068
+ base = img.astype(np.float32) / 255.0
1069
+ else:
1070
+ base = img.astype(np.float32, copy=True)
1071
+
1072
+ # pedestal removal
1073
+ base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
1074
+ # light neutralization
1075
+ base = self._neutralize_background(base, patch_size=10)
1076
+
1077
+ # SEP on grayscale
1078
+ gray = np.mean(base, axis=2)
1079
+
1080
+ bkg = sep.Background(gray)
1081
+ data_sub = gray - bkg.back()
1082
+ err = bkg.globalrms
1083
+
1084
+ # πŸ‘‡ get user threshold (default 5.0)
1085
+ if hasattr(self, "sep_thr_spin"):
1086
+ sep_sigma = float(self.sep_thr_spin.value())
1087
+ else:
1088
+ sep_sigma = 5.0
1089
+ self.count_label.setText(f"Detecting stars (SEP Οƒ={sep_sigma:.1f})…"); QApplication.processEvents()
1090
+ sources = sep.extract(data_sub, sep_sigma, err=err)
1091
+
1092
+ MAX_SOURCES = 300_000
1093
+ if sources.size > MAX_SOURCES:
1094
+ QMessageBox.warning(
1095
+ self,
1096
+ "Too many detections",
1097
+ f"SEP found {sources.size:,} sources with Οƒ={sep_sigma:.1f}.\n"
1098
+ f"Increase the threshold and rerun SFCC."
1099
+ )
1100
+ return
1101
+
1102
+ if sources.size == 0:
1103
+ QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
1104
+ r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
1105
+ mask = (r_fluxrad > .2) & (r_fluxrad <= 10); sources = sources[mask]
1106
+ if sources.size == 0:
1107
+ QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
1108
+
1109
+ if not getattr(self, "star_list", None):
1110
+ QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
1111
+
1112
+ raw_matches = []
1113
+ for i, star in enumerate(self.star_list):
1114
+ dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
1115
+ j = np.argmin(dx*dx + dy*dy)
1116
+ if (dx[j]**2 + dy[j]**2) < 3.0**2:
1117
+ xi, yi = int(round(sources["x"][j])), int(round(sources["y"][j]))
1118
+ if 0 <= xi < W and 0 <= yi < H:
1119
+ raw_matches.append({"sim_index": i, "template": star.get("pickles_match") or star["sp_clean"], "x_pix": xi, "y_pix": yi})
1120
+ if not raw_matches:
1121
+ QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
1122
+
1123
+ wl_min, wl_max = 3000, 11000
1124
+ wl_grid = np.arange(wl_min, wl_max+1)
1125
+
1126
+ def load_curve(ext):
1127
+ for p in (self.user_custom_path, self.sasp_data_path):
1128
+ with fits.open(p) as hd:
1129
+ if ext in hd:
1130
+ d = hd[ext].data
1131
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1132
+ tp = d["THROUGHPUT"].astype(float)
1133
+ return wl, tp
1134
+ raise KeyError(f"Curve '{ext}' not found")
1135
+
1136
+ def load_sed(ext):
1137
+ for p in (self.user_custom_path, self.sasp_data_path):
1138
+ with fits.open(p) as hd:
1139
+ if ext in hd:
1140
+ d = hd[ext].data
1141
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1142
+ fl = d["FLUX"].astype(float)
1143
+ return wl, fl
1144
+ raise KeyError(f"SED '{ext}' not found")
1145
+
1146
+ interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
1147
+ T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
1148
+ T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
1149
+ T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
1150
+ QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
1151
+ LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1152
+ LP2 = interp(*load_curve(lp_filt2)) if lp_filt2!= "(None)" else np.ones_like(wl_grid)
1153
+ LP = LP1 * LP2
1154
+ T_sys_R, T_sys_G, T_sys_B = T_R*QE*LP, T_G*QE*LP, T_B*QE*LP
1155
+
1156
+ wl_ref, fl_ref = load_sed(ref_sed_name)
1157
+ fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
1158
+ S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1159
+ S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1160
+ S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
1161
+
1162
+ diag_meas_RG, diag_exp_RG = [], []
1163
+ diag_meas_BG, diag_exp_BG = [], []
1164
+ enriched = []
1165
+
1166
+ # --- Optimization: Pre-calculate integrals for unique templates ---
1167
+ unique_simbad_types = set(m["template"] for m in raw_matches)
1168
+
1169
+ # Map simbad_type -> pickles_template_name
1170
+ simbad_to_pickles = {}
1171
+ pickles_templates_needed = set()
1172
+
1173
+ for sp in unique_simbad_types:
1174
+ cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1175
+ if cands:
1176
+ pickles_name = cands[0]
1177
+ simbad_to_pickles[sp] = pickles_name
1178
+ pickles_templates_needed.add(pickles_name)
1179
+
1180
+ # Pre-calc integrals for each unique Pickles template
1181
+ # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1182
+ template_integrals = {}
1183
+
1184
+ # Cache for load_sed to avoid re-reading even across different calls if desired,
1185
+ # but here we just optimize the loop.
1186
+
1187
+ for pname in pickles_templates_needed:
1188
+ try:
1189
+ wl_s, fl_s = load_sed(pname)
1190
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1191
+
1192
+ S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1193
+ S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1194
+ S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1195
+
1196
+ template_integrals[pname] = (S_sr, S_sg, S_sb)
1197
+ except Exception as e:
1198
+ print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1199
+
1200
+ # --- Main Match Loop ---
1201
+ for m in raw_matches:
1202
+ xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1203
+ Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1204
+ if Gm <= 0: continue
1205
+
1206
+ # 1. Resolve Simbad -> Pickles
1207
+ pname = simbad_to_pickles.get(sp)
1208
+ if not pname: continue
1209
+
1210
+ # 2. Retrieve pre-calced integrals
1211
+ integrals = template_integrals.get(pname)
1212
+ if not integrals: continue
1213
+
1214
+ S_sr, S_sg, S_sb = integrals
1215
+
1216
+ if S_sg <= 0: continue
1217
+
1218
+ exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1219
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1220
+
1221
+ diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1222
+ diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1223
+
1224
+ enriched.append({
1225
+ **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1226
+ "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1227
+ "exp_RG": exp_RG, "exp_BG": exp_BG
1228
+ })
1229
+
1230
+ self._last_matched = enriched
1231
+ diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1232
+ diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
1233
+ if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
1234
+ QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
1235
+ n_stars = diag_meas_RG.size
1236
+
1237
+ def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
1238
+ slope_only = lambda x, m: m*x
1239
+ affine = lambda x, m, b: m*x + b
1240
+ quad = lambda x, a, b, c: a*x**2 + b*x + c
1241
+
1242
+ denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
1243
+ mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
1244
+ mB_s = (np.sum(diag_meas_BG * diag_exp_BG) / denB) if denB > 0 else 1.0
1245
+ rms_s = rms_frac(slope_only(diag_meas_RG, mR_s), diag_exp_RG) + rms_frac(slope_only(diag_meas_BG, mB_s), diag_exp_BG)
1246
+
1247
+ mR_a, bR_a = np.linalg.lstsq(np.vstack([diag_meas_RG, np.ones_like(diag_meas_RG)]).T, diag_exp_RG, rcond=None)[0]
1248
+ mB_a, bB_a = np.linalg.lstsq(np.vstack([diag_meas_BG, np.ones_like(diag_meas_BG)]).T, diag_exp_BG, rcond=None)[0]
1249
+ rms_a = rms_frac(affine(diag_meas_RG, mR_a, bR_a), diag_exp_RG) + rms_frac(affine(diag_meas_BG, mB_a, bB_a), diag_exp_BG)
1250
+
1251
+ aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1252
+ aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1253
+ rms_q = rms_frac(quad(diag_meas_RG, aR_q, bR_q, cR_q), diag_exp_RG) + rms_frac(quad(diag_meas_BG, aB_q, bB_q, cB_q), diag_exp_BG)
1254
+
1255
+ idx = np.argmin([rms_s, rms_a, rms_q])
1256
+ if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
1257
+ elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
1258
+ else: coeff_R, coeff_B, model_choice = (aR_q, bR_q, cR_q), (aB_q, bB_q, cB_q), "quadratic"
1259
+
1260
+ poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1261
+ self.figure.clf()
1262
+ #ax1 = self.figure.add_subplot(1, 3, 1); bins=20
1263
+ #ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
1264
+ #ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
1265
+ #ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
1266
+ #ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
1267
+ #ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
1268
+
1269
+ res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1270
+ res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1271
+ res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1272
+ res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1273
+
1274
+ ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
1275
+ pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02; y_lim = (ymin - pad, ymax + pad)
1276
+ def shade(ax, yvals, color):
1277
+ q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
1278
+
1279
+ ax2 = self.figure.add_subplot(1, 2, 1)
1280
+ ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1281
+ ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
1282
+ ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
1283
+ ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp βˆ’ 1)")
1284
+ ax2.set_title("Residuals β€’ BEFORE"); ax2.legend(frameon=False, fontsize=7, loc="lower right")
1285
+
1286
+ ax3 = self.figure.add_subplot(1, 2, 2)
1287
+ ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1288
+ ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
1289
+ ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
1290
+ ax3.set_ylim(*y_lim); ax3.set_xlabel("Expected (band/G)"); ax3.set_ylabel("Frac residual (corrected/exp βˆ’ 1)")
1291
+ ax3.set_title("Residuals β€’ AFTER")
1292
+ self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
1293
+
1294
+ self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
1295
+ if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
1296
+ else: img_float = img.astype(np.float32)
1297
+
1298
+ RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
1299
+ BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
1300
+ aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
1301
+ RG_corr = aR*RG**2 + bR*RG + cR
1302
+ BG_corr = aB*BG**2 + bB*BG + cB
1303
+ calibrated = img_float.copy()
1304
+ calibrated[..., 0] = RG_corr * img_float[..., 1]
1305
+ calibrated[..., 2] = BG_corr * img_float[..., 1]
1306
+ calibrated = np.clip(calibrated, 0, 1)
1307
+
1308
+ if self.neutralize_chk.isChecked():
1309
+ calibrated = self._neutralize_background(calibrated, patch_size=10)
1310
+
1311
+ if img.dtype == np.uint8:
1312
+ calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
1313
+ else:
1314
+ calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1315
+
1316
+ new_meta = dict(doc.metadata or {})
1317
+ new_meta.update({
1318
+ "SFCC_applied": True,
1319
+ "SFCC_timestamp": datetime.now().isoformat(),
1320
+ "SFCC_model": model_choice,
1321
+ "SFCC_coeff_R": [float(v) for v in coeff_R],
1322
+ "SFCC_coeff_B": [float(v) for v in coeff_B],
1323
+ })
1324
+
1325
+ self.doc_manager.update_active_document(
1326
+ calibrated,
1327
+ metadata=new_meta,
1328
+ step_name="SFCC Calibrated",
1329
+ doc=doc, # πŸ‘ˆ pin to the document we started from
1330
+ )
1331
+
1332
+ self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1333
+ QApplication.processEvents()
1334
+
1335
+ def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
1336
+ ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
1337
+ QMessageBox.information(self, "SFCC Complete",
1338
+ f"Applied SFCC using {n_stars} stars\n"
1339
+ f"Model: {model_choice}\n"
1340
+ f"R ratio @ x=1: {ratio_R:.4f}\n"
1341
+ f"B ratio @ x=1: {ratio_B:.4f}\n"
1342
+ f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}")
1343
+
1344
+ self.current_image = calibrated # keep for gradient step
1345
+
1346
+ # ── Chromatic gradient (optional) ──────────────────────────────────
1347
+
1348
+ def run_gradient_extraction(self):
1349
+ if not getattr(self, "_last_matched", None):
1350
+ QMessageBox.warning(self, "No Star Matches", "Run colour calibration first.")
1351
+ return
1352
+
1353
+ doc = self.doc_manager.get_active_document()
1354
+ if doc is None or doc.image is None:
1355
+ QMessageBox.critical(self, "Error", "No active document.")
1356
+ return
1357
+
1358
+ img = doc.image
1359
+ if img.ndim != 3 or img.shape[2] != 3:
1360
+ QMessageBox.critical(self, "Error", "Active document must be RGB.")
1361
+ return
1362
+
1363
+ is_u8 = (img.dtype == np.uint8)
1364
+ img_f = img.astype(np.float32) / (255.0 if is_u8 else 1.0)
1365
+ H, W = img_f.shape[:2]
1366
+
1367
+ # Need star diagnostics from SPCC
1368
+ if not hasattr(self, "_last_matched") or not self._last_matched:
1369
+ QMessageBox.warning(self, "No Star Matches", "Run color calibration first."); return
1370
+
1371
+ down_fact = 4
1372
+ Hs, Ws = H // down_fact, W // down_fact
1373
+ small = cv2.resize(img_f, (Ws, Hs), interpolation=cv2.INTER_AREA)
1374
+
1375
+ pts, dRG, dBG = [], [], []
1376
+ eps, box = 1e-8, 3
1377
+ for st in self._last_matched:
1378
+ xs_full, ys_full = st["x_pix"], st["y_pix"]
1379
+ xs, ys = xs_full / down_fact, ys_full / down_fact
1380
+ xs_c, ys_c = int(round(xs)), int(round(ys))
1381
+ if not (0 <= xs_c < Ws and 0 <= ys_c < Hs): continue
1382
+ xsl = slice(max(0, xs_c-box), min(Ws, xs_c+box+1))
1383
+ ysl = slice(max(0, ys_c-box), min(Hs, ys_c+box+1))
1384
+ Rm = np.median(small[ysl, xsl, 0]); Gm = np.median(small[ysl, xsl, 1]); Bm = np.median(small[ysl, xsl, 2])
1385
+ if Gm <= 0: continue
1386
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1387
+ exp_RG, exp_BG = st["exp_RG"], st["exp_BG"]
1388
+ if exp_RG is None or exp_BG is None: continue
1389
+ dm_RG = -2.5 * np.log10((meas_RG+eps)/(exp_RG+eps))
1390
+ dm_BG = -2.5 * np.log10((meas_BG+eps)/(exp_BG+eps))
1391
+ pts.append([xs, ys]); dRG.append(dm_RG); dBG.append(dm_BG)
1392
+
1393
+ pts = np.asarray(pts); dRG = np.asarray(dRG); dBG = np.asarray(dBG)
1394
+ if pts.shape[0] < 5:
1395
+ QMessageBox.warning(self, "Too Few Stars", "Need β‰₯5 stars after clipping."); return
1396
+
1397
+ def sclip(arr, p, s=2.5):
1398
+ m, sd = np.median(arr), np.std(arr); keep = np.abs(arr-m) < s*sd
1399
+ return p[keep], arr[keep]
1400
+
1401
+ ptsRG, dRG = sclip(dRG, pts); ptsBG, dBG = sclip(dBG, pts)
1402
+
1403
+ mode = getattr(self, "grad_method", "poly2")
1404
+ bgRG_s = compute_gradient_map(ptsRG, dRG, (Hs, Ws), method=mode)
1405
+ bgBG_s = compute_gradient_map(ptsBG, dBG, (Hs, Ws), method=mode)
1406
+
1407
+ for bg in (bgRG_s, bgBG_s):
1408
+ bg -= np.median(bg)
1409
+ peak = np.max(np.abs(bg))
1410
+ if peak > 0.2: bg *= 0.2/peak
1411
+
1412
+ bgRG = cv2.resize(bgRG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1413
+ bgBG = cv2.resize(bgBG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1414
+
1415
+ scale_R = 10**(-0.4*bgRG); scale_B = 10**(-0.4*bgBG)
1416
+
1417
+ self.figure.clf()
1418
+ for i,(surf,lbl) in enumerate(((bgRG,"Ξ”m R/G"),(bgBG,"Ξ”m B/G"))):
1419
+ ax = self.figure.add_subplot(1,2,i+1)
1420
+ im = ax.imshow(surf, origin="lower", cmap="RdBu")
1421
+ ax.set_title(lbl); self.figure.colorbar(im, ax=ax)
1422
+ self.canvas.setVisible(True); self.figure.tight_layout(); self.canvas.draw()
1423
+
1424
+ corrected = img_f.copy()
1425
+ corrected[...,0] = np.clip(corrected[...,0] / scale_R, 0, 1.0)
1426
+ corrected[...,2] = np.clip(corrected[...,2] / scale_B, 0, 1.0)
1427
+ corrected = np.clip(corrected, 0, 1)
1428
+ if is_u8:
1429
+ corrected = (corrected * 255.0).astype(np.uint8)
1430
+ else:
1431
+ corrected = corrected.astype(np.float32)
1432
+
1433
+ new_meta = dict(doc.metadata or {})
1434
+ new_meta["ColourGradRemoved"] = True
1435
+
1436
+ self.doc_manager.update_active_document(
1437
+ corrected,
1438
+ metadata=new_meta,
1439
+ step_name="Colour-Gradient (star spectra, ΒΌ-res fit)",
1440
+ doc=doc, # πŸ‘ˆ same idea
1441
+ )
1442
+ self.count_label.setText("Chromatic gradient removed βœ“")
1443
+ QApplication.processEvents()
1444
+
1445
+ # ── Viewer, close ──────────────────────────────────────────────────
1446
+
1447
+ def open_sasp_viewer(self):
1448
+ if self.sasp_viewer_window is not None:
1449
+ if self.sasp_viewer_window.isVisible():
1450
+ self.sasp_viewer_window.raise_()
1451
+ else:
1452
+ self.sasp_viewer_window.show()
1453
+ return
1454
+
1455
+ self.sasp_viewer_window = SaspViewer(
1456
+ sasp_data_path=self.sasp_data_path,
1457
+ user_custom_path=self.user_custom_path
1458
+ )
1459
+ self.sasp_viewer_window.show()
1460
+ self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1461
+
1462
+ def _cleanup(self):
1463
+ # 1) Close/cleanup child window (SaspViewer)
1464
+ try:
1465
+ if getattr(self, "sasp_viewer_window", None) is not None:
1466
+ try:
1467
+ self.sasp_viewer_window.destroyed.disconnect(self._on_sasp_closed)
1468
+ except Exception:
1469
+ pass
1470
+ try:
1471
+ self.sasp_viewer_window.close()
1472
+ except Exception:
1473
+ pass
1474
+ self.sasp_viewer_window = None
1475
+ except Exception:
1476
+ pass
1477
+
1478
+ # 2) Disconnect any long-lived external signals (add these if/when used)
1479
+ # Example patterns:
1480
+ try:
1481
+ self.doc_manager.activeDocumentChanged.disconnect(self._on_active_doc_changed)
1482
+ except Exception:
1483
+ pass
1484
+ try:
1485
+ self.main_win.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1486
+ except Exception:
1487
+ pass
1488
+
1489
+ # 3) Release large caches/refs (important since dialog may not be deleted)
1490
+ try:
1491
+ self.current_image = None
1492
+ self.current_header = None
1493
+ self.star_list = []
1494
+ self._last_matched = []
1495
+ if hasattr(self, "wcs"):
1496
+ self.wcs = None
1497
+ if hasattr(self, "wcs_header"):
1498
+ self.wcs_header = None
1499
+ except Exception:
1500
+ pass
1501
+
1502
+ # 4) Matplotlib cleanup
1503
+ try:
1504
+ if getattr(self, "figure", None) is not None:
1505
+ self.figure.clf()
1506
+ if getattr(self, "canvas", None) is not None:
1507
+ self.canvas.setVisible(False)
1508
+ self.canvas.draw_idle()
1509
+ except Exception:
1510
+ pass
1511
+
1512
+
1513
+ def _on_sasp_closed(self, _=None):
1514
+ # Called when the SaspViewer window is destroyed
1515
+ self.sasp_viewer_window = None
1516
+ self._cleanup()
1517
+
1518
+ def closeEvent(self, event):
1519
+ self._cleanup()
1520
+ super().closeEvent(event)
1521
+
1522
+
1523
+ # ──────────────────────────────────────────────────────────────────────────────
1524
+ # Helper to open the dialog from your app
1525
+ # ──────────────────────────────────────────────────────────────────────────────
1526
+
1527
+ def open_sfcc(doc_manager, sasp_data_path: str, parent=None) -> SFCCDialog:
1528
+ dlg = SFCCDialog(doc_manager=doc_manager, sasp_data_path=sasp_data_path, parent=parent)
1529
+ dlg.show()
1530
+ return dlg