setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) 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/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1472 @@
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
+ self.setMinimumSize(800, 600)
353
+
354
+ self.doc_manager = doc_manager
355
+ self.sasp_data_path = sasp_data_path
356
+ self.user_custom_path = self._ensure_user_custom_fits()
357
+ self.current_image = None
358
+ self.current_header = None
359
+ self.orientation_label = QLabel(self.tr("Orientation: N/A"))
360
+ self.sasp_viewer_window = None
361
+ self.main_win = parent
362
+
363
+ # user custom file init … (unchanged)
364
+ # ...
365
+ self._reload_hdu_lists()
366
+ self.star_list = []
367
+ self._build_ui()
368
+ self.load_settings()
369
+
370
+ # persist combobox choices
371
+ self.r_filter_combo.currentIndexChanged.connect(self.save_r_filter_setting)
372
+ self.g_filter_combo.currentIndexChanged.connect(self.save_g_filter_setting)
373
+ self.b_filter_combo.currentIndexChanged.connect(self.save_b_filter_setting)
374
+ self.lp_filter_combo.currentIndexChanged.connect(self.save_lp_setting)
375
+ self.lp_filter_combo2.currentIndexChanged.connect(self.save_lp2_setting)
376
+ self.sens_combo.currentIndexChanged.connect(self.save_sensor_setting)
377
+ self.star_combo.currentIndexChanged.connect(self.save_star_setting)
378
+
379
+ self.grad_method = "poly3"
380
+ self.grad_method_combo.currentTextChanged.connect(lambda m: setattr(self, "grad_method", m))
381
+
382
+ # ── View plumbing ───────────────────────────────────────────────────
383
+ def _get_active_image_and_header(self):
384
+ doc = self.doc_manager.get_active_document()
385
+ if doc is None:
386
+ return None, None, None
387
+
388
+ img = doc.image
389
+ meta = doc.metadata or {}
390
+
391
+ # Prefer the normalized WCS header if present, then fall back
392
+ hdr = (
393
+ meta.get("wcs_header") or
394
+ meta.get("original_header") or
395
+ meta.get("header")
396
+ )
397
+
398
+ return img, hdr, meta
399
+
400
+
401
+ def _get_img_meta(self) -> Tuple[Optional[np.ndarray], dict]:
402
+ """Try a few common shapes to obtain image + metadata from the view."""
403
+ meta = {}
404
+ img = None
405
+ if hasattr(self.view, "get_image_and_metadata"):
406
+ try:
407
+ img, meta = self.view.get_image_and_metadata()
408
+ except Exception:
409
+ pass
410
+ if img is None and hasattr(self.view, "get_rgb_image"):
411
+ img = self.view.get_rgb_image()
412
+ if not meta and hasattr(self.view, "get_metadata"):
413
+ try:
414
+ meta = self.view.get_metadata() or {}
415
+ except Exception:
416
+ meta = {}
417
+ return img, (meta or {})
418
+
419
+ def _get_header(self):
420
+ header = None
421
+ if hasattr(self.view, "get_header"):
422
+ try:
423
+ header = self.view.get_header()
424
+ except Exception:
425
+ header = None
426
+ if header is None:
427
+ # fall back to metadata
428
+ _, meta = self._get_img_meta()
429
+ header = meta.get("original_header") or meta.get("header")
430
+ return header
431
+
432
+ def _push_image(self, img: np.ndarray, meta: Optional[dict], step_name: str):
433
+ """Send image back to the same current view."""
434
+ if hasattr(self.view, "set_rgb_image"):
435
+ self.view.set_rgb_image(img, meta or {}, step_name)
436
+ elif hasattr(self.view, "set_image"):
437
+ self.view.set_image(img, meta or {}, step_name=step_name)
438
+ elif hasattr(self.view, "update_image"):
439
+ self.view.update_image(img, meta or {}, step_name=step_name)
440
+ else:
441
+ # As a last resort, try attribute assignment (for custom apps)
442
+ if hasattr(self.view, "image"):
443
+ self.view.image = img
444
+ if hasattr(self.view, "metadata"):
445
+ self.view.metadata = meta or {}
446
+
447
+ # ── File prep ───────────────────────────────────────────────────────
448
+
449
+ def _ensure_user_custom_fits(self) -> str:
450
+ app_data = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
451
+ os.makedirs(app_data, exist_ok=True)
452
+ path = os.path.join(app_data, "usercustomcurves.fits")
453
+ if not os.path.exists(path):
454
+ fits.HDUList([fits.PrimaryHDU()]).writeto(path)
455
+ return path
456
+
457
+ # ── UI ──────────────────────────────────────────────────────────────
458
+
459
+ def _build_ui(self):
460
+ layout = QVBoxLayout(self)
461
+
462
+ row1 = QHBoxLayout(); layout.addLayout(row1)
463
+ self.fetch_stars_btn = QPushButton(self.tr("Step 1: Fetch Stars from Current View"))
464
+ f = self.fetch_stars_btn.font(); f.setBold(True); self.fetch_stars_btn.setFont(f)
465
+ self.fetch_stars_btn.clicked.connect(self.fetch_stars)
466
+ row1.addWidget(self.fetch_stars_btn)
467
+
468
+ self.open_sasp_btn = QPushButton(self.tr("Open SASP Viewer"))
469
+ self.open_sasp_btn.clicked.connect(self.open_sasp_viewer)
470
+ row1.addWidget(self.open_sasp_btn)
471
+
472
+ row1.addSpacing(20)
473
+ row1.addWidget(QLabel(self.tr("Select White Reference:")))
474
+ self.star_combo = QComboBox()
475
+ self.star_combo.addItem(self.tr("Vega (A0V)"), userData="A0V")
476
+ for sed in getattr(self, "sed_list", []):
477
+ if sed.upper() == "A0V": continue
478
+ self.star_combo.addItem(sed, userData=sed)
479
+ row1.addWidget(self.star_combo)
480
+ idx_g2v = self.star_combo.findData("G2V")
481
+ if idx_g2v >= 0: self.star_combo.setCurrentIndex(idx_g2v)
482
+
483
+ row2 = QHBoxLayout(); layout.addLayout(row2)
484
+ row2.addWidget(QLabel(self.tr("R Filter:")))
485
+ 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)
486
+ row2.addSpacing(20); row2.addWidget(QLabel(self.tr("G Filter:")))
487
+ 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)
488
+ row2.addSpacing(20); row2.addWidget(QLabel(self.tr("B Filter:")))
489
+ 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)
490
+
491
+ row3 = QHBoxLayout(); layout.addLayout(row3)
492
+ row3.addStretch()
493
+ row3.addWidget(QLabel(self.tr("Sensor (QE):")))
494
+ self.sens_combo = QComboBox(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list); row3.addWidget(self.sens_combo)
495
+ row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter1:")))
496
+ 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)
497
+ row3.addSpacing(20); row3.addWidget(QLabel(self.tr("LP/Cut Filter2:")))
498
+ 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)
499
+ row3.addStretch()
500
+
501
+ row4 = QHBoxLayout(); layout.addLayout(row4)
502
+ self.run_spcc_btn = QPushButton(self.tr("Step 2: Run Color Calibration"))
503
+ f2 = self.run_spcc_btn.font(); f2.setBold(True); self.run_spcc_btn.setFont(f2)
504
+ self.run_spcc_btn.clicked.connect(self.run_spcc)
505
+ row4.addWidget(self.run_spcc_btn)
506
+
507
+ self.neutralize_chk = QCheckBox(self.tr("Background Neutralization")); self.neutralize_chk.setChecked(True); row4.addWidget(self.neutralize_chk)
508
+
509
+ self.run_grad_btn = QPushButton(self.tr("Run Gradient Extraction (Beta)"))
510
+ f3 = self.run_grad_btn.font(); f3.setBold(True); self.run_grad_btn.setFont(f3)
511
+ self.run_grad_btn.clicked.connect(self.run_gradient_extraction)
512
+ row4.addWidget(self.run_grad_btn)
513
+
514
+ self.grad_method_combo = QComboBox(); self.grad_method_combo.addItems(["poly2","poly3","rbf"]); self.grad_method_combo.setCurrentText("poly3")
515
+ row4.addWidget(self.grad_method_combo)
516
+
517
+ row4.addSpacing(15)
518
+ row4.addWidget(QLabel(self.tr("Star detect σ:")))
519
+ self.sep_thr_spin = QSpinBox()
520
+ self.sep_thr_spin.setRange(2, 50) # should be enough
521
+ self.sep_thr_spin.setValue(5) # our current hardcoded value
522
+ self.sep_thr_spin.valueChanged.connect(self.save_sep_threshold_setting)
523
+ row4.addWidget(self.sep_thr_spin)
524
+
525
+ row4.addStretch()
526
+ self.add_curve_btn = QPushButton(self.tr("Add Custom Filter/Sensor Curve…"))
527
+ self.add_curve_btn.clicked.connect(self.add_custom_curve); row4.addWidget(self.add_curve_btn)
528
+ self.remove_curve_btn = QPushButton(self.tr("Remove Filter/Sensor Curve…"))
529
+ self.remove_curve_btn.clicked.connect(self.remove_custom_curve); row4.addWidget(self.remove_curve_btn)
530
+ row4.addStretch()
531
+ self.close_btn = QPushButton(self.tr("Close")); self.close_btn.clicked.connect(self.close); row4.addWidget(self.close_btn)
532
+
533
+ self.count_label = QLabel(""); layout.addWidget(self.count_label)
534
+
535
+ self.figure = Figure(figsize=(6, 4)); self.canvas = FigureCanvas(self.figure); self.canvas.setVisible(False); layout.addWidget(self.canvas, stretch=1)
536
+ self.reset_btn = QPushButton(self.tr("Reset View/Close")); self.reset_btn.clicked.connect(self.close); layout.addWidget(self.reset_btn)
537
+
538
+ # hide gradient controls by default (enable if you like)
539
+ self.run_grad_btn.hide(); self.grad_method_combo.hide()
540
+ layout.addWidget(self.orientation_label)
541
+
542
+ # ── Settings helpers ────────────────────────────────────────────────
543
+
544
+ def _reload_hdu_lists(self):
545
+ self.sed_list = []
546
+ with fits.open(self.sasp_data_path, mode="readonly", memmap=False) as base:
547
+ for hdu in base:
548
+ if isinstance(hdu, fits.BinTableHDU) and hdu.header.get("CTYPE","").upper()=="SED":
549
+ self.sed_list.append(hdu.header["EXTNAME"])
550
+
551
+ self.filter_list = []; self.sensor_list = []
552
+ for path in (self.sasp_data_path, self.user_custom_path):
553
+ with fits.open(path, mode="readonly", memmap=False) as hdul:
554
+ for hdu in hdul:
555
+ if not isinstance(hdu, fits.BinTableHDU): continue
556
+ c = hdu.header.get("CTYPE","").upper(); e = hdu.header.get("EXTNAME","")
557
+ if c=="FILTER": self.filter_list.append(e)
558
+ elif c=="SENSOR": self.sensor_list.append(e)
559
+ self.sed_list.sort(); self.filter_list.sort(); self.sensor_list.sort()
560
+
561
+ def load_settings(self):
562
+ s = QSettings()
563
+ def apply(cb, key):
564
+ val = s.value(key, "")
565
+ if val:
566
+ idx = cb.findText(val)
567
+ if idx != -1:
568
+ cb.setCurrentIndex(idx)
569
+
570
+ # existing stuff...
571
+ saved_star = QSettings().value("SFCC/WhiteReference", "")
572
+ if saved_star:
573
+ idx = self.star_combo.findText(saved_star)
574
+ if idx != -1:
575
+ self.star_combo.setCurrentIndex(idx)
576
+
577
+ apply(self.r_filter_combo, "SFCC/RFilter")
578
+ apply(self.g_filter_combo, "SFCC/GFilter")
579
+ apply(self.b_filter_combo, "SFCC/BFilter")
580
+ apply(self.sens_combo, "SFCC/Sensor")
581
+ apply(self.lp_filter_combo, "SFCC/LPFilter")
582
+ apply(self.lp_filter_combo2, "SFCC/LPFilter2")
583
+
584
+ # 👇 NEW: load SEP/star-detect threshold
585
+ sep_thr = int(s.value("SFCC/SEPThreshold", 5))
586
+ if hasattr(self, "sep_thr_spin"):
587
+ self.sep_thr_spin.setValue(sep_thr)
588
+ def save_sep_threshold_setting(self, v: int):
589
+ QSettings().setValue("SFCC/SEPThreshold", int(v))
590
+
591
+ def save_lp_setting(self, _): QSettings().setValue("SFCC/LPFilter", self.lp_filter_combo.currentText())
592
+ def save_lp2_setting(self, _): QSettings().setValue("SFCC/LPFilter2", self.lp_filter_combo2.currentText())
593
+ def save_star_setting(self, _): QSettings().setValue("SFCC/WhiteReference", self.star_combo.currentText())
594
+ def save_r_filter_setting(self, _): QSettings().setValue("SFCC/RFilter", self.r_filter_combo.currentText())
595
+ def save_g_filter_setting(self, _): QSettings().setValue("SFCC/GFilter", self.g_filter_combo.currentText())
596
+ def save_b_filter_setting(self, _): QSettings().setValue("SFCC/BFilter", self.b_filter_combo.currentText())
597
+ def save_sensor_setting(self, _): QSettings().setValue("SFCC/Sensor", self.sens_combo.currentText())
598
+
599
+ # ── Curve utilities ─────────────────────────────────────────────────
600
+
601
+ def interpolate_bad_points(self, wl, tr):
602
+ tr = tr.copy()
603
+ bad = (tr < 0.0) | (tr > 1.0)
604
+ good = ~bad
605
+ if not np.any(bad): return tr, np.array([], dtype=int)
606
+ if np.sum(good) < 2: raise RuntimeError("Not enough valid points to interpolate anomalies.")
607
+ tr_corr = tr.copy()
608
+ tr_corr[bad] = np.interp(wl[bad], wl[good], tr[good])
609
+ return tr_corr, np.where(bad)[0]
610
+
611
+ def smooth_curve(self, tr, window_size=5):
612
+ return medfilt(tr, kernel_size=window_size)
613
+
614
+ def get_calibration_points(self, rgb_img: np.ndarray):
615
+ print("\nClick three calibration points: BL (λmin,0), BR (λmax,0), TL (λmin,1)")
616
+ fig, ax = plt.subplots(figsize=(8, 5)); ax.imshow(rgb_img); ax.set_title(self.tr("Click 3 points, then close"))
617
+ pts = plt.ginput(3, timeout=-1); plt.close(fig)
618
+ if len(pts) != 3: raise RuntimeError(self.tr("Need exactly three clicks for calibration."))
619
+ return pts[0], pts[1], pts[2]
620
+
621
+ def build_transforms(self, px_bl, py_bl, px_br, py_br, px_tl, py_tl, λ_min, λ_max, resp_min, resp_max):
622
+ nm_per_px = (λ_max - λ_min) / (px_br - px_bl)
623
+ resp_per_px = (resp_max - resp_min) / (py_bl - py_tl)
624
+ def px_to_λ(px): return λ_min + (px - px_bl) * nm_per_px
625
+ def py_to_resp(py): return resp_max - (py - py_tl) * resp_per_px
626
+ return px_to_λ, py_to_resp
627
+
628
+ def extract_curve(self, gray_img, λ_mapper, resp_mapper, λ_min, λ_max, threshold=50):
629
+ H, W = gray_img.shape
630
+ data = []
631
+ for px in range(W):
632
+ col = gray_img[:, px]
633
+ py_min = int(np.argmin(col)); val_min = int(col[py_min])
634
+ if val_min < threshold:
635
+ lam = λ_mapper(px)
636
+ if λ_min <= lam <= λ_max:
637
+ data.append((lam, resp_mapper(py_min)))
638
+ if not data:
639
+ raise RuntimeError("No dark pixels found; raise threshold or adjust clicks.")
640
+ df = (pd.DataFrame(data, columns=["wavelength_nm", "response"])
641
+ .sort_values("wavelength_nm").reset_index(drop=True))
642
+ df = df[(df["wavelength_nm"] >= λ_min) & (df["wavelength_nm"] <= λ_max)].copy()
643
+ if df["wavelength_nm"].iloc[0] > λ_min:
644
+ df = pd.concat([pd.DataFrame([[λ_min, 0.0]], columns=["wavelength_nm", "response"]), df], ignore_index=True)
645
+ if df["wavelength_nm"].iloc[-1] < λ_max:
646
+ df = pd.concat([df, pd.DataFrame([[λ_max, 0.0]], columns=["wavelength_nm", "response"])], ignore_index=True)
647
+ return df.sort_values("wavelength_nm").reset_index(drop=True)
648
+
649
+ def _query_name_channel(self):
650
+ name_str, ok1 = QInputDialog.getText(self, self.tr("Curve Name"), self.tr("Enter curve name (EXTNAME):"))
651
+ if not (ok1 and name_str.strip()): return False, None, None
652
+ extname = name_str.strip().upper().replace(" ", "_")
653
+ ch_str, ok2 = QInputDialog.getText(self, self.tr("Channel"), self.tr("Enter channel (R,G,B or Q for sensor):"))
654
+ if not (ok2 and ch_str.strip()): return False, None, None
655
+ return True, extname, ch_str.strip().upper()
656
+
657
+ def _append_curve_hdu(self, wl_ang, tr_final, extname, ctype, origin):
658
+ col_wl = fits.Column(name="WAVELENGTH", format="E", unit="Angstrom", array=wl_ang.astype(np.float32))
659
+ col_tr = fits.Column(name="THROUGHPUT", format="E", unit="REL", array=tr_final.astype(np.float32))
660
+ new_hdu = fits.BinTableHDU.from_columns([col_wl, col_tr])
661
+ new_hdu.header["EXTNAME"] = extname
662
+ new_hdu.header["CTYPE"] = ctype
663
+ new_hdu.header["ORIGIN"] = origin
664
+ with fits.open(self.user_custom_path, mode="update", memmap=False) as hdul:
665
+ hdul.append(new_hdu); hdul.flush()
666
+
667
+ def add_custom_curve(self):
668
+ msg = QMessageBox(self); msg.setWindowTitle(self.tr("Add Custom Curve")); msg.setText(self.tr("Choose how to add the curve:"))
669
+ csv_btn = msg.addButton(self.tr("Import CSV"), QMessageBox.ButtonRole.AcceptRole)
670
+ img_btn = msg.addButton(self.tr("Digitize Image"), QMessageBox.ButtonRole.AcceptRole)
671
+ cancel_btn = msg.addButton(QMessageBox.StandardButton.Cancel)
672
+ msg.exec()
673
+ if msg.clickedButton() == csv_btn: self._import_curve_from_csv()
674
+ elif msg.clickedButton() == img_btn: self._digitize_curve_from_image()
675
+
676
+ def _import_curve_from_csv(self):
677
+ csv_path, _ = QFileDialog.getOpenFileName(self, self.tr("Select 2-column CSV (λ_nm, response)"), "", "CSV Files (*.csv);;All Files (*)")
678
+ if not csv_path: return
679
+ try:
680
+ df = (pd.read_csv(csv_path, comment="#", header=None).iloc[:, :2].dropna())
681
+ df.columns = ["wavelength_nm","response"]
682
+ wl_nm = df["wavelength_nm"].astype(float).to_numpy(); tp = df["response"].astype(float).to_numpy()
683
+ except ValueError:
684
+ try:
685
+ df = (pd.read_csv(csv_path, comment="#", header=0).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 Exception as e2:
689
+ QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e2)); return
690
+ except Exception as e:
691
+ QMessageBox.critical(self, self.tr("CSV Error"), self.tr("Could not read CSV:\n{0}").format(e)); return
692
+
693
+ ok, extname_base, channel_val = self._query_name_channel()
694
+ if not ok: return
695
+ wl_ang = (wl_nm * 10.0).astype(np.float32); tr_final = tp.astype(np.float32)
696
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"CSV:{os.path.basename(csv_path)}")
697
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
698
+ QMessageBox.information(self, self.tr("Done"), self.tr("CSV curve '{0}' added.").format(extname_base))
699
+
700
+ def _digitize_curve_from_image(self):
701
+ img_path_str, _ = QFileDialog.getOpenFileName(self, self.tr("Select Curve Image to Digitize"), "", "Images (*.png *.jpg *.jpeg *.bmp);;All Files (*)")
702
+ if not img_path_str: return
703
+ img_filename = os.path.basename(img_path_str)
704
+ try:
705
+ bgr = cv2.imread(img_path_str)
706
+ if bgr is None: raise RuntimeError(f"cv2.imread returned None for '{img_path_str}'")
707
+ rgb_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB); gray_img = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
708
+ except Exception as e:
709
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Could not load image:\n{0}").format(e)); return
710
+
711
+ try:
712
+ (px_bl, py_bl), (px_br, py_br), (px_tl, py_tl) = self.get_calibration_points(rgb_img)
713
+ except Exception as e:
714
+ QMessageBox.critical(self, self.tr("Digitization Error"), str(e)); return
715
+
716
+ λ_min_str, ok1 = QInputDialog.getText(self, self.tr("λ_min"), self.tr("Enter λ_min (in nm):"))
717
+ λ_max_str, ok2 = QInputDialog.getText(self, self.tr("λ_max"), self.tr("Enter λ_max (in nm):"))
718
+ if not (ok1 and ok2 and λ_min_str.strip() and λ_max_str.strip()): return
719
+ try:
720
+ λ_min = float(λ_min_str); λ_max = float(λ_max_str)
721
+ except ValueError:
722
+ QMessageBox.critical(self, self.tr("Input Error"), self.tr("λ_min and λ_max must be numbers.")); return
723
+
724
+ ok, extname_base, channel_val = self._query_name_channel()
725
+ if not ok: return
726
+
727
+ 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)
728
+ try:
729
+ df_curve = self.extract_curve(gray_img, px_to_λ, py_to_resp, λ_min, λ_max, threshold=50)
730
+ except Exception as e:
731
+ QMessageBox.critical(self, self.tr("Extraction Error"), str(e)); return
732
+
733
+ df_curve["wl_int"] = df_curve["wavelength_nm"].round().astype(int)
734
+ grp = (df_curve.groupby("wl_int")["response"].median().reset_index().sort_values("wl_int"))
735
+ wl = grp["wl_int"].to_numpy(dtype=int); tr = grp["response"].to_numpy(dtype=float)
736
+
737
+ try:
738
+ tr_corr, _ = self.interpolate_bad_points(wl, tr)
739
+ except Exception as e:
740
+ QMessageBox.critical(self, self.tr("Interpolation Error"), str(e)); return
741
+
742
+ tr_smoothed = self.smooth_curve(tr_corr, window_size=5)
743
+ wl_ang = (wl.astype(float) * 10.0).astype(np.float32); tr_final = tr_smoothed.astype(np.float32)
744
+ self._append_curve_hdu(wl_ang, tr_final, extname_base, "SENSOR" if channel_val=="Q" else "FILTER", f"UserDefined:{img_filename}")
745
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
746
+ QMessageBox.information(self, self.tr("Done"), self.tr("Added curve '{0}'.").format(extname_base))
747
+
748
+ def remove_custom_curve(self):
749
+ all_curves = self.filter_list + self.sensor_list
750
+ if not all_curves:
751
+ QMessageBox.information(self, self.tr("Remove Curve"), self.tr("No custom curves to remove.")); return
752
+ curve, ok = QInputDialog.getItem(self, self.tr("Remove Curve"), self.tr("Select a FILTER or SENSOR curve to delete:"), all_curves, 0, False)
753
+ if not ok or not curve: return
754
+ reply = QMessageBox.question(self, self.tr("Confirm Deletion"), self.tr("Delete '{0}'?").format(curve), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
755
+ if reply != QMessageBox.StandardButton.Yes: return
756
+
757
+ temp_path = self.user_custom_path + ".tmp"
758
+ try:
759
+ with fits.open(self.user_custom_path, memmap=False) as old_hdul:
760
+ new_hdus = []
761
+ for hdu in old_hdul:
762
+ if hdu is old_hdul[0]:
763
+ new_hdus.append(hdu.copy())
764
+ else:
765
+ if hdu.header.get("EXTNAME") != curve:
766
+ new_hdus.append(hdu.copy())
767
+ fits.HDUList(new_hdus).writeto(temp_path, overwrite=True)
768
+ os.replace(temp_path, self.user_custom_path)
769
+ except Exception as e:
770
+ if os.path.exists(temp_path): os.remove(temp_path)
771
+ QMessageBox.critical(self, "Write Error", f"Could not remove curve:\n{e}"); return
772
+
773
+ self._reload_hdu_lists(); self.refresh_filter_sensor_lists()
774
+ QMessageBox.information(self, self.tr("Removed"), self.tr("Deleted curve '{0}'.").format(curve))
775
+
776
+ def refresh_filter_sensor_lists(self):
777
+ self._reload_hdu_lists()
778
+ current_r = self.r_filter_combo.currentText()
779
+ current_g = self.g_filter_combo.currentText()
780
+ current_b = self.b_filter_combo.currentText()
781
+ current_s = self.sens_combo.currentText()
782
+ current_lp = self.lp_filter_combo.currentText()
783
+ current_lp2 = self.lp_filter_combo2.currentText()
784
+
785
+ for cb, lst, prev in [
786
+ (self.r_filter_combo, self.filter_list, current_r),
787
+ (self.g_filter_combo, self.filter_list, current_g),
788
+ (self.b_filter_combo, self.filter_list, current_b),
789
+ ]:
790
+ cb.clear(); cb.addItem("(None)"); cb.addItems(lst)
791
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
792
+
793
+ for cb, prev in [(self.lp_filter_combo, current_lp), (self.lp_filter_combo2, current_lp2)]:
794
+ cb.clear(); cb.addItem("(None)"); cb.addItems(self.filter_list)
795
+ idx = cb.findText(prev); cb.setCurrentIndex(idx if idx != -1 else 0)
796
+
797
+ self.sens_combo.clear(); self.sens_combo.addItem("(None)"); self.sens_combo.addItems(self.sensor_list)
798
+ idx = self.sens_combo.findText(current_s); self.sens_combo.setCurrentIndex(idx if idx != -1 else 0)
799
+
800
+ # ── WCS utilities ──────────────────────────────────────────────────
801
+ def initialize_wcs_from_header(self, header):
802
+ if header is None:
803
+ print("No FITS header available; cannot build WCS.")
804
+ return
805
+ try:
806
+ hdr = header.copy()
807
+
808
+ # --- normalize deprecated keywords ---
809
+ if "RADECSYS" in hdr and "RADESYS" not in hdr:
810
+ radesys_val = str(hdr["RADECSYS"]).strip()
811
+ hdr["RADESYS"] = radesys_val
812
+ try:
813
+ del hdr["RADECSYS"]
814
+ except Exception:
815
+ pass
816
+
817
+ alt_letters = {
818
+ k[-1]
819
+ for k in hdr.keys()
820
+ if re.match(r"^CTYPE[12][A-Z]$", k)
821
+ }
822
+ for a in alt_letters:
823
+ key = f"RADESYS{a}"
824
+ if key not in hdr:
825
+ hdr[key] = radesys_val
826
+
827
+ if "EPOCH" in hdr and "EQUINOX" not in hdr:
828
+ hdr["EQUINOX"] = hdr["EPOCH"]
829
+ try:
830
+ del hdr["EPOCH"]
831
+ except Exception:
832
+ pass
833
+
834
+ # IMPORTANT: use the normalized hdr, not the original header
835
+ self.wcs = WCS(hdr, naxis=2, relax=True)
836
+
837
+ psm = self.wcs.pixel_scale_matrix
838
+ self.pixscale = np.hypot(psm[0, 0], psm[1, 0]) * 3600.0
839
+ self.center_ra, self.center_dec = self.wcs.wcs.crval
840
+ self.wcs_header = self.wcs.to_header(relax=True)
841
+
842
+ # Orientation from normalized header
843
+ if "CROTA2" in hdr:
844
+ try:
845
+ self.orientation = float(hdr["CROTA2"])
846
+ except Exception:
847
+ self.orientation = None
848
+ else:
849
+ self.orientation = self.calculate_orientation(hdr)
850
+
851
+ if self.orientation is not None:
852
+ self.orientation_label.setText(f"Orientation: {self.orientation:.2f}°")
853
+ else:
854
+ self.orientation_label.setText("Orientation: N/A")
855
+
856
+ except Exception as e:
857
+ print("WCS initialization error:\n", e)
858
+
859
+
860
+ def calculate_orientation(self, header):
861
+ try:
862
+ cd1_1 = float(header.get("CD1_1", 0.0))
863
+ cd1_2 = float(header.get("CD1_2", 0.0))
864
+ return math.degrees(math.atan2(cd1_2, cd1_1))
865
+ except Exception:
866
+ return None
867
+
868
+ def calculate_ra_dec_from_pixel(self, x, y):
869
+ if not hasattr(self, "wcs"): return None, None
870
+ return self.wcs.all_pix2world(x, y, 0)
871
+
872
+ # ── Background neutralization ───────────────────────────────────────
873
+
874
+ def _neutralize_background(self, rgb_img: np.ndarray, patch_size: int = 50) -> np.ndarray:
875
+ img = rgb_img.copy()
876
+ h, w = img.shape[:2]
877
+ ph, pw = h // patch_size, w // patch_size
878
+ min_sum, best_med = np.inf, None
879
+ for i in range(patch_size):
880
+ for j in range(patch_size):
881
+ y0, x0 = i * ph, j * pw
882
+ patch = img[y0:min(y0+ph, h), x0:min(x0+pw, w), :]
883
+ med = np.median(patch, axis=(0, 1))
884
+ s = med.sum()
885
+ if s < min_sum:
886
+ min_sum, best_med = s, med
887
+ if best_med is None:
888
+ return img
889
+ target = float(best_med.mean()); eps = 1e-8
890
+ for c in range(3):
891
+ diff = float(best_med[c] - target)
892
+ if abs(diff) < eps: continue
893
+ img[..., c] = np.clip((img[..., c] - diff) / (1.0 - diff), 0.0, 1.0)
894
+ return img
895
+
896
+ # ── SIMBAD/Star fetch ──────────────────────────────────────────────
897
+
898
+ def fetch_stars(self):
899
+ # 0) Grab current image + header from the active document
900
+ img, hdr, _meta = self._get_active_image_and_header()
901
+ self.current_image = img
902
+ self.current_header = hdr
903
+
904
+ if self.current_header is None or self.current_image is None:
905
+ QMessageBox.warning(self, "No Plate Solution",
906
+ "Please plate-solve the active document first.")
907
+ return
908
+
909
+ # Pickles templates list (once)
910
+ if not hasattr(self, "pickles_templates"):
911
+ self.pickles_templates = []
912
+ for p in (self.user_custom_path, self.sasp_data_path):
913
+ try:
914
+ with fits.open(p) as hd:
915
+ for hdu in hd:
916
+ if (isinstance(hdu, fits.BinTableHDU)
917
+ and hdu.header.get("CTYPE", "").upper() == "SED"):
918
+ extname = hdu.header.get("EXTNAME", None)
919
+ if extname and extname not in self.pickles_templates:
920
+ self.pickles_templates.append(extname)
921
+ except Exception as e:
922
+ print(f"[fetch_stars] Could not load Pickles templates from {p}: {e}")
923
+
924
+ # Build WCS
925
+ try:
926
+ self.initialize_wcs_from_header(self.current_header)
927
+ except Exception:
928
+ QMessageBox.critical(self, "WCS Error", "Could not build a 2D WCS from header."); return
929
+
930
+ H, W = self.current_image.shape[:2]
931
+ pix = np.array([[W/2, H/2], [0,0], [W,0], [0,H], [W,H]])
932
+ try:
933
+ sky = self.wcs.all_pix2world(pix, 0)
934
+ except Exception as e:
935
+ QMessageBox.critical(self, "WCS Conversion Error", str(e)); return
936
+ center_sky = SkyCoord(ra=sky[0,0]*u.deg, dec=sky[0,1]*u.deg, frame="icrs")
937
+ corners_sky = SkyCoord(ra=sky[1:,0]*u.deg, dec=sky[1:,1]*u.deg, frame="icrs")
938
+ radius_deg = center_sky.separation(corners_sky).max().deg
939
+
940
+ # Simbad fields
941
+ Simbad.reset_votable_fields()
942
+ for attempt in range(1, 6):
943
+ try:
944
+ Simbad.add_votable_fields('sp', 'flux(B)', 'flux(V)', 'flux(R)')
945
+ break
946
+ except Exception:
947
+ QApplication.processEvents()
948
+ time.sleep(1.2)
949
+ Simbad.ROW_LIMIT = 10000
950
+
951
+ for attempt in range(1, 6):
952
+ try:
953
+ result = Simbad.query_region(center_sky, radius=radius_deg * u.deg)
954
+ break
955
+ except Exception as e:
956
+ self.count_label.setText(f"Attempt {attempt}/5 to query SIMBAD…")
957
+ QApplication.processEvents(); time.sleep(1.2)
958
+ result = None
959
+ if result is None or len(result) == 0:
960
+ QMessageBox.information(self, "No Stars", "SIMBAD returned zero objects in that region.")
961
+ self.star_list = []; self.star_combo.clear(); self.star_combo.addItem("Vega (A0V)", userData="A0V"); return
962
+
963
+ def infer_letter(bv):
964
+ if bv is None or (isinstance(bv, float) and np.isnan(bv)): return None
965
+ if bv < 0.00: return "B"
966
+ elif bv < 0.30: return "A"
967
+ elif bv < 0.58: return "F"
968
+ elif bv < 0.81: return "G"
969
+ elif bv < 1.40: return "K"
970
+ elif bv > 1.40: return "M"
971
+ else: return "U"
972
+
973
+ self.star_list = []; templates_for_hist = []
974
+ for row in result:
975
+ raw_sp = row['sp_type']
976
+ bmag, vmag, rmag = row['B'], row['V'], row['R']
977
+ ra_deg, dec_deg = float(row['ra']), float(row['dec'])
978
+ try:
979
+ sc = SkyCoord(ra=ra_deg*u.deg, dec=dec_deg*u.deg, frame="icrs")
980
+ except Exception:
981
+ continue
982
+
983
+ def _unmask_num(x):
984
+ try:
985
+ if x is None or np.ma.isMaskedArray(x) and np.ma.is_masked(x):
986
+ return None
987
+ return float(x)
988
+ except Exception:
989
+ return None
990
+
991
+ # inside your SIMBAD row loop:
992
+ bmag = _unmask_num(row['B'])
993
+ vmag = _unmask_num(row['V'])
994
+
995
+ sp_clean = None
996
+ if raw_sp and str(raw_sp).strip():
997
+ sp = str(raw_sp).strip().upper()
998
+ if not (sp.startswith("SN") or sp.startswith("KA")):
999
+ sp_clean = sp
1000
+ elif bmag is not None and vmag is not None:
1001
+ bv = bmag - vmag
1002
+ sp_clean = infer_letter(bv)
1003
+ if not sp_clean: continue
1004
+
1005
+ match_list = pickles_match_for_simbad(sp_clean, self.pickles_templates)
1006
+ best_template = match_list[0] if match_list else None
1007
+ xpix, ypix = self.wcs.all_world2pix(sc.ra.deg, sc.dec.deg, 0)
1008
+ if 0 <= xpix < W and 0 <= ypix < H:
1009
+ self.star_list.append({
1010
+ "ra": sc.ra.deg, "dec": sc.dec.deg, "sp_clean": sp_clean,
1011
+ "pickles_match": best_template, "x": xpix, "y": ypix,
1012
+ "Bmag": float(bmag) if bmag else None,
1013
+ "Vmag": float(vmag) if vmag else None,
1014
+ "Rmag": float(rmag) if rmag else None,
1015
+ })
1016
+ if best_template is not None: templates_for_hist.append(best_template)
1017
+
1018
+ self.figure.clf()
1019
+ if templates_for_hist:
1020
+ uniq, cnt = np.unique(templates_for_hist, return_counts=True)
1021
+ types_str = ", ".join(uniq)
1022
+ self.count_label.setText(f"Found {len(templates_for_hist)} stars; templates: {types_str}")
1023
+ ax = self.figure.add_subplot(111)
1024
+ ax.bar(uniq, cnt, edgecolor="black")
1025
+ ax.set_xlabel("Spectral Type"); ax.set_ylabel("Count"); ax.set_title("Spectral Distribution")
1026
+ ax.tick_params(axis='x', rotation=90); ax.grid(axis="y", linestyle="--", alpha=0.3)
1027
+ self.canvas.setVisible(True); self.canvas.draw()
1028
+ else:
1029
+ self.count_label.setText("Found 0 stars with Pickles matches.")
1030
+ self.canvas.setVisible(False); self.canvas.draw()
1031
+
1032
+ # ── Core SFCC ───────────────────────────────────────────────────────
1033
+
1034
+ def run_spcc(self):
1035
+ ref_sed_name = self.star_combo.currentData()
1036
+ r_filt = self.r_filter_combo.currentText()
1037
+ g_filt = self.g_filter_combo.currentText()
1038
+ b_filt = self.b_filter_combo.currentText()
1039
+ sens_name = self.sens_combo.currentText()
1040
+ lp_filt = self.lp_filter_combo.currentText()
1041
+ lp_filt2 = self.lp_filter_combo2.currentText()
1042
+
1043
+ if not ref_sed_name:
1044
+ QMessageBox.warning(self, "Error", "Select a reference spectral type (e.g. A0V)."); return
1045
+ if r_filt == "(None)" and g_filt == "(None)" and b_filt == "(None)":
1046
+ QMessageBox.warning(self, "Error", "Pick at least one of R, G or B filters."); return
1047
+ if sens_name == "(None)":
1048
+ QMessageBox.warning(self, "Error", "Select a sensor QE curve."); return
1049
+
1050
+ # -- Step 1A: get active image as float32 in [0..1]
1051
+ doc = self.doc_manager.get_active_document()
1052
+ if doc is None or doc.image is None:
1053
+ QMessageBox.critical(self, "Error", "No active document.")
1054
+ return
1055
+
1056
+ img = doc.image
1057
+ H, W = img.shape[:2]
1058
+ if img.ndim != 3 or img.shape[2] != 3:
1059
+ QMessageBox.critical(self, "Error", "Active document must be RGB (3 channels).")
1060
+ return
1061
+
1062
+ if img.dtype == np.uint8:
1063
+ base = img.astype(np.float32) / 255.0
1064
+ else:
1065
+ base = img.astype(np.float32, copy=True)
1066
+
1067
+ # pedestal removal
1068
+ base = np.clip(base - np.min(base, axis=(0,1)), 0.0, None)
1069
+ # light neutralization
1070
+ base = self._neutralize_background(base, patch_size=10)
1071
+
1072
+ # SEP on grayscale
1073
+ gray = np.mean(base, axis=2)
1074
+
1075
+ bkg = sep.Background(gray)
1076
+ data_sub = gray - bkg.back()
1077
+ err = bkg.globalrms
1078
+
1079
+ # 👇 get user threshold (default 5.0)
1080
+ if hasattr(self, "sep_thr_spin"):
1081
+ sep_sigma = float(self.sep_thr_spin.value())
1082
+ else:
1083
+ sep_sigma = 5.0
1084
+ self.count_label.setText(f"Detecting stars (SEP σ={sep_sigma:.1f})…"); QApplication.processEvents()
1085
+ sources = sep.extract(data_sub, sep_sigma, err=err)
1086
+
1087
+ MAX_SOURCES = 300_000
1088
+ if sources.size > MAX_SOURCES:
1089
+ QMessageBox.warning(
1090
+ self,
1091
+ "Too many detections",
1092
+ f"SEP found {sources.size:,} sources with σ={sep_sigma:.1f}.\n"
1093
+ f"Increase the threshold and rerun SFCC."
1094
+ )
1095
+ return
1096
+
1097
+ if sources.size == 0:
1098
+ QMessageBox.critical(self, "SEP Error", "SEP found no sources."); return
1099
+ r_fluxrad, _ = sep.flux_radius(gray, sources["x"], sources["y"], 2.0*sources["a"], 0.5, normflux=sources["flux"], subpix=5)
1100
+ mask = (r_fluxrad > .2) & (r_fluxrad <= 10); sources = sources[mask]
1101
+ if sources.size == 0:
1102
+ QMessageBox.critical(self, "SEP Error", "All SEP detections rejected by radius filter."); return
1103
+
1104
+ if not getattr(self, "star_list", None):
1105
+ QMessageBox.warning(self, "Error", "Fetch Stars (with WCS) before running SFCC."); return
1106
+
1107
+ raw_matches = []
1108
+ for i, star in enumerate(self.star_list):
1109
+ dx = sources["x"] - star["x"]; dy = sources["y"] - star["y"]
1110
+ j = np.argmin(dx*dx + dy*dy)
1111
+ if (dx[j]**2 + dy[j]**2) < 3.0**2:
1112
+ xi, yi = int(round(sources["x"][j])), int(round(sources["y"][j]))
1113
+ if 0 <= xi < W and 0 <= yi < H:
1114
+ raw_matches.append({"sim_index": i, "template": star.get("pickles_match") or star["sp_clean"], "x_pix": xi, "y_pix": yi})
1115
+ if not raw_matches:
1116
+ QMessageBox.warning(self, "No Matches", "No SIMBAD star matched to SEP detections."); return
1117
+
1118
+ wl_min, wl_max = 3000, 11000
1119
+ wl_grid = np.arange(wl_min, wl_max+1)
1120
+
1121
+ def load_curve(ext):
1122
+ for p in (self.user_custom_path, self.sasp_data_path):
1123
+ with fits.open(p) as hd:
1124
+ if ext in hd:
1125
+ d = hd[ext].data
1126
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1127
+ tp = d["THROUGHPUT"].astype(float)
1128
+ return wl, tp
1129
+ raise KeyError(f"Curve '{ext}' not found")
1130
+
1131
+ def load_sed(ext):
1132
+ for p in (self.user_custom_path, self.sasp_data_path):
1133
+ with fits.open(p) as hd:
1134
+ if ext in hd:
1135
+ d = hd[ext].data
1136
+ wl = _ensure_angstrom(d["WAVELENGTH"].astype(float))
1137
+ fl = d["FLUX"].astype(float)
1138
+ return wl, fl
1139
+ raise KeyError(f"SED '{ext}' not found")
1140
+
1141
+ interp = lambda wl_o, tp_o: np.interp(wl_grid, wl_o, tp_o, left=0., right=0.)
1142
+ T_R = interp(*load_curve(r_filt)) if r_filt!="(None)" else np.ones_like(wl_grid)
1143
+ T_G = interp(*load_curve(g_filt)) if g_filt!="(None)" else np.ones_like(wl_grid)
1144
+ T_B = interp(*load_curve(b_filt)) if b_filt!="(None)" else np.ones_like(wl_grid)
1145
+ QE = interp(*load_curve(sens_name)) if sens_name!="(None)" else np.ones_like(wl_grid)
1146
+ LP1 = interp(*load_curve(lp_filt)) if lp_filt != "(None)" else np.ones_like(wl_grid)
1147
+ LP2 = interp(*load_curve(lp_filt2)) if lp_filt2!= "(None)" else np.ones_like(wl_grid)
1148
+ LP = LP1 * LP2
1149
+ T_sys_R, T_sys_G, T_sys_B = T_R*QE*LP, T_G*QE*LP, T_B*QE*LP
1150
+
1151
+ wl_ref, fl_ref = load_sed(ref_sed_name)
1152
+ fr_i = np.interp(wl_grid, wl_ref, fl_ref, left=0., right=0.)
1153
+ S_ref_R = np.trapezoid(fr_i * T_sys_R, x=wl_grid)
1154
+ S_ref_G = np.trapezoid(fr_i * T_sys_G, x=wl_grid)
1155
+ S_ref_B = np.trapezoid(fr_i * T_sys_B, x=wl_grid)
1156
+
1157
+ diag_meas_RG, diag_exp_RG = [], []
1158
+ diag_meas_BG, diag_exp_BG = [], []
1159
+ enriched = []
1160
+
1161
+ # --- Optimization: Pre-calculate integrals for unique templates ---
1162
+ unique_simbad_types = set(m["template"] for m in raw_matches)
1163
+
1164
+ # Map simbad_type -> pickles_template_name
1165
+ simbad_to_pickles = {}
1166
+ pickles_templates_needed = set()
1167
+
1168
+ for sp in unique_simbad_types:
1169
+ cands = pickles_match_for_simbad(sp, getattr(self, "pickles_templates", []))
1170
+ if cands:
1171
+ pickles_name = cands[0]
1172
+ simbad_to_pickles[sp] = pickles_name
1173
+ pickles_templates_needed.add(pickles_name)
1174
+
1175
+ # Pre-calc integrals for each unique Pickles template
1176
+ # Cache structure: template_name -> (S_sr, S_sg, S_sb)
1177
+ template_integrals = {}
1178
+
1179
+ # Cache for load_sed to avoid re-reading even across different calls if desired,
1180
+ # but here we just optimize the loop.
1181
+
1182
+ for pname in pickles_templates_needed:
1183
+ try:
1184
+ wl_s, fl_s = load_sed(pname)
1185
+ fs_i = np.interp(wl_grid, wl_s, fl_s, left=0., right=0.)
1186
+
1187
+ S_sr = np.trapezoid(fs_i * T_sys_R, x=wl_grid)
1188
+ S_sg = np.trapezoid(fs_i * T_sys_G, x=wl_grid)
1189
+ S_sb = np.trapezoid(fs_i * T_sys_B, x=wl_grid)
1190
+
1191
+ template_integrals[pname] = (S_sr, S_sg, S_sb)
1192
+ except Exception as e:
1193
+ print(f"[SFCC] Warning: failed to load/integrate template {pname}: {e}")
1194
+
1195
+ # --- Main Match Loop ---
1196
+ for m in raw_matches:
1197
+ xi, yi, sp = m["x_pix"], m["y_pix"], m["template"]
1198
+ Rm = float(base[yi, xi, 0]); Gm = float(base[yi, xi, 1]); Bm = float(base[yi, xi, 2])
1199
+ if Gm <= 0: continue
1200
+
1201
+ # 1. Resolve Simbad -> Pickles
1202
+ pname = simbad_to_pickles.get(sp)
1203
+ if not pname: continue
1204
+
1205
+ # 2. Retrieve pre-calced integrals
1206
+ integrals = template_integrals.get(pname)
1207
+ if not integrals: continue
1208
+
1209
+ S_sr, S_sg, S_sb = integrals
1210
+
1211
+ if S_sg <= 0: continue
1212
+
1213
+ exp_RG = S_sr / S_sg; exp_BG = S_sb / S_sg
1214
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1215
+
1216
+ diag_meas_RG.append(meas_RG); diag_exp_RG.append(exp_RG)
1217
+ diag_meas_BG.append(meas_BG); diag_exp_BG.append(exp_BG)
1218
+
1219
+ enriched.append({
1220
+ **m, "R_meas": Rm, "G_meas": Gm, "B_meas": Bm,
1221
+ "S_star_R": S_sr, "S_star_G": S_sg, "S_star_B": S_sb,
1222
+ "exp_RG": exp_RG, "exp_BG": exp_BG
1223
+ })
1224
+
1225
+ self._last_matched = enriched
1226
+ diag_meas_RG = np.array(diag_meas_RG); diag_exp_RG = np.array(diag_exp_RG)
1227
+ diag_meas_BG = np.array(diag_meas_BG); diag_exp_BG = np.array(diag_exp_BG)
1228
+ if diag_meas_RG.size == 0 or diag_meas_BG.size == 0:
1229
+ QMessageBox.information(self, "No Valid Stars", "No stars with valid measured vs expected ratios."); return
1230
+ n_stars = diag_meas_RG.size
1231
+
1232
+ def rms_frac(pred, exp): return np.sqrt(np.mean(((pred/exp) - 1.0) ** 2))
1233
+ slope_only = lambda x, m: m*x
1234
+ affine = lambda x, m, b: m*x + b
1235
+ quad = lambda x, a, b, c: a*x**2 + b*x + c
1236
+
1237
+ denR = np.sum(diag_meas_RG**2); denB = np.sum(diag_meas_BG**2)
1238
+ mR_s = (np.sum(diag_meas_RG * diag_exp_RG) / denR) if denR > 0 else 1.0
1239
+ mB_s = (np.sum(diag_meas_BG * diag_exp_BG) / denB) if denB > 0 else 1.0
1240
+ 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)
1241
+
1242
+ 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]
1243
+ 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]
1244
+ 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)
1245
+
1246
+ aR_q, bR_q, cR_q = np.polyfit(diag_meas_RG, diag_exp_RG, 2)
1247
+ aB_q, bB_q, cB_q = np.polyfit(diag_meas_BG, diag_exp_BG, 2)
1248
+ 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)
1249
+
1250
+ idx = np.argmin([rms_s, rms_a, rms_q])
1251
+ if idx == 0: coeff_R, coeff_B, model_choice = (0, mR_s, 0), (0, mB_s, 0), "slope-only"
1252
+ elif idx == 1: coeff_R, coeff_B, model_choice = (0, mR_a, bR_a), (0, mB_a, bB_a), "affine"
1253
+ else: coeff_R, coeff_B, model_choice = (aR_q, bR_q, cR_q), (aB_q, bB_q, cB_q), "quadratic"
1254
+
1255
+ poly = lambda c, x: c[0]*x**2 + c[1]*x + c[2]
1256
+ self.figure.clf()
1257
+ #ax1 = self.figure.add_subplot(1, 3, 1); bins=20
1258
+ #ax1.hist(diag_meas_RG, bins=bins, alpha=.65, label="meas R/G", color="firebrick", edgecolor="black")
1259
+ #ax1.hist(diag_exp_RG, bins=bins, alpha=.55, label="exp R/G", color="salmon", edgecolor="black")
1260
+ #ax1.hist(diag_meas_BG, bins=bins, alpha=.65, label="meas B/G", color="royalblue", edgecolor="black")
1261
+ #ax1.hist(diag_exp_BG, bins=bins, alpha=.55, label="exp B/G", color="lightskyblue", edgecolor="black")
1262
+ #ax1.set_xlabel("Ratio (band / G)"); ax1.set_ylabel("Count"); ax1.set_title("Measured vs expected"); ax1.legend(fontsize=7, frameon=False)
1263
+
1264
+ res0_RG = (diag_meas_RG / diag_exp_RG) - 1.0
1265
+ res0_BG = (diag_meas_BG / diag_exp_BG) - 1.0
1266
+ res1_RG = (poly(coeff_R, diag_meas_RG) / diag_exp_RG) - 1.0
1267
+ res1_BG = (poly(coeff_B, diag_meas_BG) / diag_exp_BG) - 1.0
1268
+
1269
+ ymin = np.min(np.concatenate([res0_RG, res0_BG])); ymax = np.max(np.concatenate([res0_RG, res0_BG]))
1270
+ pad = 0.05 * (ymax - ymin) if ymax > ymin else 0.02; y_lim = (ymin - pad, ymax + pad)
1271
+ def shade(ax, yvals, color):
1272
+ q1, q3 = np.percentile(yvals, [25,75]); ax.axhspan(q1, q3, color=color, alpha=.10, zorder=0)
1273
+
1274
+ ax2 = self.figure.add_subplot(1, 2, 1)
1275
+ ax2.axhline(0, color="0.65", ls="--", lw=1); shade(ax2, res0_RG, "firebrick"); shade(ax2, res0_BG, "royalblue")
1276
+ ax2.scatter(diag_exp_RG, res0_RG, c="firebrick", marker="o", alpha=.7, label="R/G residual")
1277
+ ax2.scatter(diag_exp_BG, res0_BG, c="royalblue", marker="s", alpha=.7, label="B/G residual")
1278
+ ax2.set_ylim(*y_lim); ax2.set_xlabel("Expected (band/G)"); ax2.set_ylabel("Frac residual (meas/exp − 1)")
1279
+ ax2.set_title("Residuals • BEFORE"); ax2.legend(frameon=False, fontsize=7, loc="lower right")
1280
+
1281
+ ax3 = self.figure.add_subplot(1, 2, 2)
1282
+ ax3.axhline(0, color="0.65", ls="--", lw=1); shade(ax3, res1_RG, "firebrick"); shade(ax3, res1_BG, "royalblue")
1283
+ ax3.scatter(diag_exp_RG, res1_RG, c="firebrick", marker="o", alpha=.7)
1284
+ ax3.scatter(diag_exp_BG, res1_BG, c="royalblue", marker="s", alpha=.7)
1285
+ ax3.set_ylim(*y_lim); ax3.set_xlabel("Expected (band/G)"); ax3.set_ylabel("Frac residual (corrected/exp − 1)")
1286
+ ax3.set_title("Residuals • AFTER")
1287
+ self.canvas.setVisible(True); self.figure.tight_layout(w_pad=2.); self.canvas.draw()
1288
+
1289
+ self.count_label.setText("Applying SFCC color scales to image…"); QApplication.processEvents()
1290
+ if img.dtype == np.uint8: img_float = img.astype(np.float32) / 255.0
1291
+ else: img_float = img.astype(np.float32)
1292
+
1293
+ RG = img_float[..., 0] / np.maximum(img_float[..., 1], 1e-8)
1294
+ BG = img_float[..., 2] / np.maximum(img_float[..., 1], 1e-8)
1295
+ aR, bR, cR = coeff_R; aB, bB, cB = coeff_B
1296
+ RG_corr = aR*RG**2 + bR*RG + cR
1297
+ BG_corr = aB*BG**2 + bB*BG + cB
1298
+ calibrated = img_float.copy()
1299
+ calibrated[..., 0] = RG_corr * img_float[..., 1]
1300
+ calibrated[..., 2] = BG_corr * img_float[..., 1]
1301
+ calibrated = np.clip(calibrated, 0, 1)
1302
+
1303
+ if self.neutralize_chk.isChecked():
1304
+ calibrated = self._neutralize_background(calibrated, patch_size=10)
1305
+
1306
+ if img.dtype == np.uint8:
1307
+ calibrated = (np.clip(calibrated, 0, 1) * 255.0).astype(np.uint8)
1308
+ else:
1309
+ calibrated = np.clip(calibrated, 0, 1).astype(np.float32)
1310
+
1311
+ new_meta = dict(doc.metadata or {})
1312
+ new_meta.update({
1313
+ "SFCC_applied": True,
1314
+ "SFCC_timestamp": datetime.now().isoformat(),
1315
+ "SFCC_model": model_choice,
1316
+ "SFCC_coeff_R": [float(v) for v in coeff_R],
1317
+ "SFCC_coeff_B": [float(v) for v in coeff_B],
1318
+ })
1319
+
1320
+ self.doc_manager.update_active_document(
1321
+ calibrated,
1322
+ metadata=new_meta,
1323
+ step_name="SFCC Calibrated",
1324
+ doc=doc, # 👈 pin to the document we started from
1325
+ )
1326
+
1327
+ self.count_label.setText(f"Applied SFCC color calibration using {n_stars} stars")
1328
+ QApplication.processEvents()
1329
+
1330
+ def pretty(coeff): return coeff[0] + coeff[1] + coeff[2]
1331
+ ratio_R, ratio_B = pretty(coeff_R), pretty(coeff_B)
1332
+ QMessageBox.information(self, "SFCC Complete",
1333
+ f"Applied SFCC using {n_stars} stars\n"
1334
+ f"Model: {model_choice}\n"
1335
+ f"R ratio @ x=1: {ratio_R:.4f}\n"
1336
+ f"B ratio @ x=1: {ratio_B:.4f}\n"
1337
+ f"Background neutralisation: {'ON' if self.neutralize_chk.isChecked() else 'OFF'}")
1338
+
1339
+ self.current_image = calibrated # keep for gradient step
1340
+
1341
+ # ── Chromatic gradient (optional) ──────────────────────────────────
1342
+
1343
+ def run_gradient_extraction(self):
1344
+ if not getattr(self, "_last_matched", None):
1345
+ QMessageBox.warning(self, "No Star Matches", "Run colour calibration first.")
1346
+ return
1347
+
1348
+ doc = self.doc_manager.get_active_document()
1349
+ if doc is None or doc.image is None:
1350
+ QMessageBox.critical(self, "Error", "No active document.")
1351
+ return
1352
+
1353
+ img = doc.image
1354
+ if img.ndim != 3 or img.shape[2] != 3:
1355
+ QMessageBox.critical(self, "Error", "Active document must be RGB.")
1356
+ return
1357
+
1358
+ is_u8 = (img.dtype == np.uint8)
1359
+ img_f = img.astype(np.float32) / (255.0 if is_u8 else 1.0)
1360
+ H, W = img_f.shape[:2]
1361
+
1362
+ # Need star diagnostics from SPCC
1363
+ if not hasattr(self, "_last_matched") or not self._last_matched:
1364
+ QMessageBox.warning(self, "No Star Matches", "Run color calibration first."); return
1365
+
1366
+ down_fact = 4
1367
+ Hs, Ws = H // down_fact, W // down_fact
1368
+ small = cv2.resize(img_f, (Ws, Hs), interpolation=cv2.INTER_AREA)
1369
+
1370
+ pts, dRG, dBG = [], [], []
1371
+ eps, box = 1e-8, 3
1372
+ for st in self._last_matched:
1373
+ xs_full, ys_full = st["x_pix"], st["y_pix"]
1374
+ xs, ys = xs_full / down_fact, ys_full / down_fact
1375
+ xs_c, ys_c = int(round(xs)), int(round(ys))
1376
+ if not (0 <= xs_c < Ws and 0 <= ys_c < Hs): continue
1377
+ xsl = slice(max(0, xs_c-box), min(Ws, xs_c+box+1))
1378
+ ysl = slice(max(0, ys_c-box), min(Hs, ys_c+box+1))
1379
+ Rm = np.median(small[ysl, xsl, 0]); Gm = np.median(small[ysl, xsl, 1]); Bm = np.median(small[ysl, xsl, 2])
1380
+ if Gm <= 0: continue
1381
+ meas_RG = Rm / Gm; meas_BG = Bm / Gm
1382
+ exp_RG, exp_BG = st["exp_RG"], st["exp_BG"]
1383
+ if exp_RG is None or exp_BG is None: continue
1384
+ dm_RG = -2.5 * np.log10((meas_RG+eps)/(exp_RG+eps))
1385
+ dm_BG = -2.5 * np.log10((meas_BG+eps)/(exp_BG+eps))
1386
+ pts.append([xs, ys]); dRG.append(dm_RG); dBG.append(dm_BG)
1387
+
1388
+ pts = np.asarray(pts); dRG = np.asarray(dRG); dBG = np.asarray(dBG)
1389
+ if pts.shape[0] < 5:
1390
+ QMessageBox.warning(self, "Too Few Stars", "Need ≥5 stars after clipping."); return
1391
+
1392
+ def sclip(arr, p, s=2.5):
1393
+ m, sd = np.median(arr), np.std(arr); keep = np.abs(arr-m) < s*sd
1394
+ return p[keep], arr[keep]
1395
+
1396
+ ptsRG, dRG = sclip(dRG, pts); ptsBG, dBG = sclip(dBG, pts)
1397
+
1398
+ mode = getattr(self, "grad_method", "poly2")
1399
+ bgRG_s = compute_gradient_map(ptsRG, dRG, (Hs, Ws), method=mode)
1400
+ bgBG_s = compute_gradient_map(ptsBG, dBG, (Hs, Ws), method=mode)
1401
+
1402
+ for bg in (bgRG_s, bgBG_s):
1403
+ bg -= np.median(bg)
1404
+ peak = np.max(np.abs(bg))
1405
+ if peak > 0.2: bg *= 0.2/peak
1406
+
1407
+ bgRG = cv2.resize(bgRG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1408
+ bgBG = cv2.resize(bgBG_s, (W, H), interpolation=cv2.INTER_CUBIC)
1409
+
1410
+ scale_R = 10**(-0.4*bgRG); scale_B = 10**(-0.4*bgBG)
1411
+
1412
+ self.figure.clf()
1413
+ for i,(surf,lbl) in enumerate(((bgRG,"Δm R/G"),(bgBG,"Δm B/G"))):
1414
+ ax = self.figure.add_subplot(1,2,i+1)
1415
+ im = ax.imshow(surf, origin="lower", cmap="RdBu")
1416
+ ax.set_title(lbl); self.figure.colorbar(im, ax=ax)
1417
+ self.canvas.setVisible(True); self.figure.tight_layout(); self.canvas.draw()
1418
+
1419
+ corrected = img_f.copy()
1420
+ corrected[...,0] = np.clip(corrected[...,0] / scale_R, 0, 1.0)
1421
+ corrected[...,2] = np.clip(corrected[...,2] / scale_B, 0, 1.0)
1422
+ corrected = np.clip(corrected, 0, 1)
1423
+ if is_u8:
1424
+ corrected = (corrected * 255.0).astype(np.uint8)
1425
+ else:
1426
+ corrected = corrected.astype(np.float32)
1427
+
1428
+ new_meta = dict(doc.metadata or {})
1429
+ new_meta["ColourGradRemoved"] = True
1430
+
1431
+ self.doc_manager.update_active_document(
1432
+ corrected,
1433
+ metadata=new_meta,
1434
+ step_name="Colour-Gradient (star spectra, ¼-res fit)",
1435
+ doc=doc, # 👈 same idea
1436
+ )
1437
+ self.count_label.setText("Chromatic gradient removed ✓")
1438
+ QApplication.processEvents()
1439
+
1440
+ # ── Viewer, close ──────────────────────────────────────────────────
1441
+
1442
+ def open_sasp_viewer(self):
1443
+ if self.sasp_viewer_window is not None:
1444
+ if self.sasp_viewer_window.isVisible():
1445
+ self.sasp_viewer_window.raise_()
1446
+ else:
1447
+ self.sasp_viewer_window.show()
1448
+ return
1449
+
1450
+ self.sasp_viewer_window = SaspViewer(
1451
+ sasp_data_path=self.sasp_data_path,
1452
+ user_custom_path=self.user_custom_path
1453
+ )
1454
+ self.sasp_viewer_window.show()
1455
+ self.sasp_viewer_window.destroyed.connect(self._on_sasp_closed)
1456
+
1457
+ def _on_sasp_closed(self, _=None):
1458
+ # Called when the SaspViewer window is destroyed
1459
+ self.sasp_viewer_window = None
1460
+
1461
+ def closeEvent(self, event):
1462
+ super().closeEvent(event)
1463
+
1464
+
1465
+ # ──────────────────────────────────────────────────────────────────────────────
1466
+ # Helper to open the dialog from your app
1467
+ # ──────────────────────────────────────────────────────────────────────────────
1468
+
1469
+ def open_sfcc(doc_manager, sasp_data_path: str, parent=None) -> SFCCDialog:
1470
+ dlg = SFCCDialog(doc_manager=doc_manager, sasp_data_path=sasp_data_path, parent=parent)
1471
+ dlg.show()
1472
+ return dlg