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,1620 @@
1
+ # pro/continuum_subtract.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+ import time # NEW
6
+ import glob # NEW
7
+ import subprocess # NEW
8
+ # Optional deps used by the processing threads
9
+ try:
10
+ import cv2
11
+ except Exception:
12
+ cv2 = None
13
+
14
+ try:
15
+ import pywt
16
+ except Exception:
17
+ pywt = None
18
+
19
+ from PyQt6.QtCore import (
20
+ Qt, QSize, QPoint, QEvent, QThread, pyqtSignal, QTimer,
21
+ QCoreApplication
22
+ )
23
+ from PyQt6.QtWidgets import (
24
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
25
+ QGroupBox, QScrollArea, QDialog, QInputDialog, QFileDialog,
26
+ QMessageBox, QCheckBox, QApplication, QMainWindow, QCheckBox
27
+ )
28
+ from PyQt6.QtGui import (
29
+ QPixmap, QImage, QCursor, QWheelEvent
30
+ )
31
+
32
+ # register QImage for cross-thread signals
33
+ #qRegisterMetaType(QImage)
34
+
35
+ from .doc_manager import ImageDocument # add this import
36
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image, save_image as legacy_save_image # CHANGED
37
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
38
+ from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
39
+ from setiastro.saspro.legacy.numba_utils import apply_curves_numba
40
+ from setiastro.saspro.cosmicclarity_preset import _cosmic_root, _platform_exe_names
41
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
42
+
43
+
44
+ def apply_curves_adjustment(image, target_median, curves_boost):
45
+ """
46
+ Original signature unchanged, but now uses a Numba helper
47
+ to do the pixel-by-pixel interpolation.
48
+
49
+ 'image' can be 2D (H,W) or 3D (H,W,3).
50
+ """
51
+ # Build the curve array as before
52
+ curve = [
53
+ [0.0, 0.0],
54
+ [0.5 * target_median, 0.5 * target_median],
55
+ [target_median, target_median],
56
+ [
57
+ (1/4 * (1 - target_median) + target_median),
58
+ np.power((1/4 * (1 - target_median) + target_median), (1 - curves_boost))
59
+ ],
60
+ [
61
+ (3/4 * (1 - target_median) + target_median),
62
+ np.power(np.power((3/4 * (1 - target_median) + target_median), (1 - curves_boost)), (1 - curves_boost))
63
+ ],
64
+ [1.0, 1.0]
65
+ ]
66
+ # Convert to arrays
67
+ xvals = np.array([p[0] for p in curve], dtype=np.float32)
68
+ yvals = np.array([p[1] for p in curve], dtype=np.float32)
69
+
70
+ # Ensure 'image' is float32
71
+ image_32 = image.astype(np.float32, copy=False)
72
+
73
+ # Now apply the piecewise linear function in Numba
74
+ adjusted_image = apply_curves_numba(image_32, xvals, yvals)
75
+ return adjusted_image
76
+
77
+ class ContinuumSubtractTab(QWidget):
78
+ def __init__(self, doc_manager, document=None, parent=None):
79
+ super().__init__(parent)
80
+ self.parent_window = parent
81
+ self.doc_manager = doc_manager
82
+ self.initUI()
83
+ self._threads = []
84
+ # — initialize every loadable image to None —
85
+ self.ha_image = None
86
+ self.sii_image = None
87
+ self.oiii_image = None
88
+ self.red_image = None
89
+ self.green_image = None
90
+ self.osc_image = None
91
+ # NEW: composite HaO3 / S2O3 "source" images (optional)
92
+ self.hao3_image = None
93
+ self.s2o3_image = None
94
+ self.hao3_starless_image = None
95
+ self.s2o3_starless_image = None
96
+
97
+ # NEW: OIII components extracted from composites (for averaging)
98
+ self._o3_from_hao3 = None
99
+ self._o3_from_s2o3 = None
100
+ self._o3_from_hao3_starless = None
101
+ self._o3_from_s2o3_starless = None
102
+ self.filename = None
103
+ self.is_mono = True
104
+ self.combined_image = None
105
+ self.processing_thread = None
106
+ self.original_header = None
107
+ self._clickable_images = {}
108
+ try:
109
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
110
+ except Exception:
111
+ pass # older PyQt6 versions
112
+
113
+ def initUI(self):
114
+ self.spinnerLabel = QLabel("") # starts empty
115
+ self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
116
+ self.spinnerLabel.setStyleSheet("color:#999; font-style:italic;")
117
+ self.spinnerLabel.hide()
118
+
119
+ # images (starless)
120
+ self.ha_starless_image = None
121
+ self.sii_starless_image = None
122
+ self.oiii_starless_image = None
123
+ self.red_starless_image = None
124
+ self.green_starless_image = None
125
+ self.osc_starless_image = None
126
+
127
+ # status
128
+ self.statusLabel = QLabel("")
129
+ self.statusLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
130
+
131
+ main_layout = QVBoxLayout() # overall vertical: columns + bottom row
132
+ columns_layout = QHBoxLayout() # holds the three groups
133
+
134
+ # — NB group —
135
+ nb_group = QGroupBox(self.tr("Narrowband Filters"))
136
+ nb_l = QVBoxLayout()
137
+ for name, attr in [("Ha","ha"), ("SII","sii"), ("OIII","oiii")]:
138
+ # starry
139
+ btn = QPushButton(f"Load {name}")
140
+ lbl = QLabel(f"No {name}")
141
+ setattr(self, f"{attr}Button", btn)
142
+ setattr(self, f"{attr}Label", lbl)
143
+ btn.clicked.connect(lambda _, n=name: self.loadImage(n))
144
+ nb_l.addWidget(btn); nb_l.addWidget(lbl)
145
+
146
+ # starless
147
+ btn_sl = QPushButton(f"Load {name} (Starless)")
148
+ lbl_sl = QLabel(f"No {name} (starless)")
149
+ setattr(self, f"{attr}StarlessButton", btn_sl)
150
+ setattr(self, f"{attr}StarlessLabel", lbl_sl)
151
+ btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
152
+ nb_l.addWidget(btn_sl); nb_l.addWidget(lbl_sl)
153
+
154
+ for name, attr in [("HaO3", "hao3"), ("S2O3", "s2o3")]:
155
+ # starry
156
+ btn = QPushButton(f"Load {name}")
157
+ lbl = QLabel(f"No {name}")
158
+ setattr(self, f"{attr}Button", btn)
159
+ setattr(self, f"{attr}Label", lbl)
160
+ btn.clicked.connect(lambda _, n=name: self.loadImage(n))
161
+ nb_l.addWidget(btn); nb_l.addWidget(lbl)
162
+
163
+ # starless
164
+ btn_sl = QPushButton(f"Load {name} (Starless)")
165
+ lbl_sl = QLabel(f"No {name} (starless)")
166
+ setattr(self, f"{attr}StarlessButton", btn_sl)
167
+ setattr(self, f"{attr}StarlessLabel", lbl_sl)
168
+ btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
169
+ nb_l.addWidget(btn_sl); nb_l.addWidget(lbl_sl)
170
+
171
+ # user controls
172
+ self.linear_output_checkbox = QCheckBox(self.tr("Output Linear Image Only"))
173
+ nb_l.addWidget(self.linear_output_checkbox)
174
+ self.denoise_checkbox = QCheckBox(self.tr("Denoise continuum result with Cosmic Clarity (0.9)")) # NEW
175
+ self.denoise_checkbox.setToolTip(
176
+ "Runs Cosmic Clarity denoise on the linear continuum-subtracted image "
177
+ "before any non-linear stretch."
178
+ ) # NEW
179
+ self.denoise_checkbox.setChecked(True)
180
+ nb_l.addWidget(self.denoise_checkbox)
181
+ # ---- Advanced (collapsed) ----
182
+ # defaults used elsewhere
183
+ self.threshold_value = 5.0
184
+ self.q_factor = 0.80
185
+ self.summary_gamma = 0.6 # gamma < 1.0 brightens summary previews
186
+
187
+ # header row with toggle button
188
+ adv_hdr = QHBoxLayout()
189
+ self.advanced_btn = QPushButton("Advanced ▸")
190
+ self.advanced_btn.setCheckable(False)
191
+ self.advanced_btn.setFlat(True)
192
+ self.advanced_btn.clicked.connect(self._toggle_advanced)
193
+ adv_hdr.addWidget(self.advanced_btn, stretch=0)
194
+ adv_hdr.addStretch(1)
195
+ nb_l.addLayout(adv_hdr)
196
+
197
+ # panel that will be shown/hidden
198
+ self.advanced_panel = QWidget()
199
+ adv_l = QVBoxLayout(self.advanced_panel)
200
+ adv_l.setContentsMargins(12, 0, 0, 0) # small indent
201
+
202
+ # WB threshold control (UI)
203
+ thr_row = QHBoxLayout()
204
+ self.threshold_label = QLabel(f"WB star detect threshold: {self.threshold_value:.1f}")
205
+ self.threshold_btn = QPushButton("Change…")
206
+ self.threshold_btn.clicked.connect(self._change_threshold)
207
+ thr_row.addWidget(self.threshold_label)
208
+ thr_row.addWidget(self.threshold_btn)
209
+ adv_l.addLayout(thr_row)
210
+
211
+ # Q factor control (UI)
212
+ q_row = QHBoxLayout()
213
+ self.q_label = QLabel(f"Continuum Q factor: {self.q_factor:.2f}")
214
+ self.q_btn = QPushButton("Change…")
215
+ self.q_btn.clicked.connect(self._change_q)
216
+ q_row.addWidget(self.q_label)
217
+ q_row.addWidget(self.q_btn)
218
+ adv_l.addLayout(q_row)
219
+
220
+
221
+
222
+ self.advanced_panel.setVisible(False) # start hidden
223
+ nb_l.addWidget(self.advanced_panel)
224
+
225
+ nb_l.addStretch(1)
226
+
227
+ self.clear_button = QPushButton(self.tr("Clear Loaded Images"))
228
+ self.clear_button.clicked.connect(self.clear_loaded_images)
229
+ nb_l.addWidget(self.clear_button)
230
+ nb_group.setLayout(nb_l)
231
+
232
+ # — Continuum group —
233
+ cont_group = QGroupBox(self.tr("Continuum Sources"))
234
+ cont_l = QVBoxLayout()
235
+ for name, attr in [("Red","red"), ("Green","green"), ("OSC","osc")]:
236
+ btn = QPushButton(f"Load {name}")
237
+ lbl = QLabel(f"No {name}")
238
+ setattr(self, f"{attr}Button", btn)
239
+ setattr(self, f"{attr}Label", lbl)
240
+ btn.clicked.connect(lambda _, n=name: self.loadImage(n))
241
+ cont_l.addWidget(btn); cont_l.addWidget(lbl)
242
+
243
+ btn_sl = QPushButton(f"Load {name} (Starless)")
244
+ lbl_sl = QLabel(f"No {name} (starless)")
245
+ setattr(self, f"{attr}StarlessButton", btn_sl)
246
+ setattr(self, f"{attr}StarlessLabel", lbl_sl)
247
+ btn_sl.clicked.connect(lambda _, n=f"{name} (Starless)": self.loadImage(n))
248
+ cont_l.addWidget(btn_sl); cont_l.addWidget(lbl_sl)
249
+
250
+ cont_l.addStretch(1)
251
+ cont_group.setLayout(cont_l)
252
+
253
+ # — White balance diagnostics —
254
+ wb_group = QGroupBox(self.tr("Star-Based WB"))
255
+ self.wb_l = QVBoxLayout()
256
+ self.wb_l.setAlignment(Qt.AlignmentFlag.AlignTop)
257
+ wb_group.setLayout(self.wb_l)
258
+
259
+ # put it in a scroll area so many entries won't overflow
260
+ wb_scroll = QScrollArea()
261
+ wb_scroll.setWidgetResizable(True)
262
+ wb_container = QWidget()
263
+ wb_container.setLayout(self.wb_l)
264
+ wb_scroll.setWidget(wb_container)
265
+
266
+ # assemble columns
267
+ columns_layout.addWidget(nb_group, 1)
268
+ columns_layout.addWidget(cont_group, 1)
269
+ columns_layout.addWidget(wb_scroll, 2)
270
+
271
+ # — Bottom row: Execute & status —
272
+ bottom_layout = QHBoxLayout()
273
+ self.execute_button = QPushButton(self.tr("Execute"))
274
+ self.execute_button.clicked.connect(self.startContinuumSubtraction)
275
+ bottom_layout.addWidget(self.execute_button, stretch=1)
276
+ bottom_layout.addWidget(self.spinnerLabel, stretch=1)
277
+ bottom_layout.addWidget(self.statusLabel, stretch=3)
278
+
279
+ # put it all together
280
+ main_layout.addLayout(columns_layout)
281
+ main_layout.addLayout(bottom_layout)
282
+
283
+ self.setLayout(main_layout)
284
+ self.installEventFilter(self)
285
+
286
+ def _toggle_advanced(self):
287
+ show = not self.advanced_panel.isVisible()
288
+ self.advanced_panel.setVisible(show)
289
+ self.advanced_btn.setText("Advanced ▾" if show else "Advanced ▸")
290
+
291
+ def _change_q(self):
292
+ val, ok = QInputDialog.getDouble(
293
+ self,
294
+ "Continuum Q Factor",
295
+ "Q (scale of broadband subtraction, typical 0.6–1.0):",
296
+ self.q_factor,
297
+ 0.10, 2.00, 2 # min, max, decimals
298
+ )
299
+ if ok:
300
+ self.q_factor = float(val)
301
+ self.q_label.setText(f"Continuum Q factor: {self.q_factor:.2f}")
302
+
303
+ def _change_threshold(self):
304
+ val, ok = QInputDialog.getDouble(
305
+ self,
306
+ "WB Threshold",
307
+ "Sigma threshold for star detection:",
308
+ self.threshold_value,
309
+ 0.5, 50.0, 1 # min, max, decimals
310
+ )
311
+ if ok:
312
+ self.threshold_value = float(val)
313
+ self.threshold_label.setText(f"WB star detect threshold: {self.threshold_value:.1f}")
314
+
315
+ def _main_window(self) -> QMainWindow | None:
316
+ # 1) explicit parent the tool may have been created with
317
+ mw = self.parent_window
318
+ if mw and hasattr(mw, "mdi"):
319
+ return mw
320
+ # 2) walk up the parent chain
321
+ p = self.parent()
322
+ while p is not None:
323
+ if hasattr(p, "mdi"):
324
+ return p # main window
325
+ p = p.parent()
326
+ # 3) search top-level widgets
327
+ for w in QApplication.topLevelWidgets():
328
+ if hasattr(w, "mdi"):
329
+ return w
330
+ return None
331
+
332
+ def _iter_open_docs(self):
333
+ """Yield (doc, title) for all open subwindows."""
334
+ mw = self._main_window()
335
+ if not mw or not hasattr(mw, "mdi"):
336
+ return []
337
+ out = []
338
+ for sw in mw.mdi.subWindowList():
339
+ w = sw.widget()
340
+ d = getattr(w, "document", None)
341
+ if d is not None:
342
+ out.append((d, sw.windowTitle()))
343
+ return out
344
+
345
+
346
+ def refresh(self):
347
+ if self.image_manager:
348
+ # You might have a way to retrieve the current image and metadata.
349
+ # For example, if your image_manager stores the current image,
350
+ # you could do something like:
351
+ return
352
+
353
+
354
+
355
+ def clear_loaded_images(self):
356
+ for attr in (
357
+ "ha_image","sii_image","oiii_image","red_image","green_image","osc_image",
358
+ "ha_starless_image","sii_starless_image","oiii_starless_image",
359
+ "red_starless_image","green_starless_image","osc_starless_image",
360
+ # NEW composite attrs
361
+ "hao3_image","s2o3_image",
362
+ "hao3_starless_image","s2o3_starless_image"
363
+ ):
364
+ setattr(self, attr, None)
365
+
366
+ # Reset NB labels
367
+ self.haLabel.setText("No Ha")
368
+ self.siiLabel.setText("No SII")
369
+ self.oiiiLabel.setText("No OIII")
370
+ # NEW composite labels
371
+ self.hao3Label.setText("No HaO3")
372
+ self.s2o3Label.setText("No S2O3")
373
+
374
+ # Reset continuum labels
375
+ self.redLabel.setText("No Red")
376
+ self.greenLabel.setText("No Green")
377
+ self.oscLabel.setText("No OSC")
378
+
379
+ self.haStarlessLabel.setText("No Ha (starless)")
380
+ self.siiStarlessLabel.setText("No SII (starless)")
381
+ self.oiiiStarlessLabel.setText("No OIII (starless)")
382
+ self.redStarlessLabel.setText("No Red (starless)")
383
+ self.greenStarlessLabel.setText("No Green (starless)")
384
+ self.oscStarlessLabel.setText("No OSC (starless)")
385
+
386
+ # NEW: clear OIII-from-composite caches
387
+ self._o3_from_hao3 = None
388
+ self._o3_from_s2o3 = None
389
+ self._o3_from_hao3_starless = None
390
+ self._o3_from_s2o3_starless = None
391
+
392
+ self.combined_image = None
393
+ self.statusLabel.setText("All loaded images cleared.")
394
+
395
+
396
+ def loadImage(self, channel: str):
397
+ """
398
+ Prompt the user to load either from file or from ImageManager slots,
399
+ for the given channel ("Ha", "SII", "OIII", "Red", "Green", "OSC").
400
+ """
401
+ source, ok = QInputDialog.getItem(
402
+ self, f"Select {channel} Image Source", "Load image from:",
403
+ ["From View", "From File"], editable=False
404
+ )
405
+ if not ok:
406
+ return
407
+
408
+ if source == "From File":
409
+ result = self.loadImageFromFile(channel)
410
+ else:
411
+ result = self.loadImageFromView(channel)
412
+
413
+ if not result:
414
+ return
415
+
416
+ image, header, bit_depth, is_mono, name_or_path = result
417
+
418
+ # Use view title if we got one; if it's a real path, show just the basename
419
+ label_text = str(name_or_path) if name_or_path else "From View"
420
+
421
+
422
+ try:
423
+ if isinstance(name_or_path, str) and os.path.isabs(name_or_path):
424
+ label_text = os.path.basename(name_or_path)
425
+ except Exception:
426
+ pass
427
+
428
+ is_starless = "(Starless)" in channel
429
+ base = channel.replace(" (Starless)", "")
430
+
431
+ if base == "Ha":
432
+ if is_starless:
433
+ self.ha_starless_image = image
434
+ self.haStarlessLabel.setText(label_text)
435
+ else:
436
+ self.ha_image = image
437
+ self.haLabel.setText(label_text)
438
+
439
+ elif base == "SII":
440
+ if is_starless:
441
+ self.sii_starless_image = image
442
+ self.siiStarlessLabel.setText(label_text)
443
+ else:
444
+ self.sii_image = image
445
+ self.siiLabel.setText(label_text)
446
+
447
+ elif base == "OIII":
448
+ if is_starless:
449
+ self.oiii_starless_image = image
450
+ self.oiiiStarlessLabel.setText(label_text)
451
+ else:
452
+ self.oiii_image = image
453
+ self.oiiiLabel.setText(label_text)
454
+
455
+ elif base == "Red":
456
+ if is_starless:
457
+ self.red_starless_image = image
458
+ self.redStarlessLabel.setText(label_text)
459
+ else:
460
+ self.red_image = image
461
+ self.redLabel.setText(label_text)
462
+
463
+ elif base == "Green":
464
+ if is_starless:
465
+ self.green_starless_image = image
466
+ self.greenStarlessLabel.setText(label_text)
467
+ else:
468
+ self.green_image = image
469
+ self.greenLabel.setText(label_text)
470
+
471
+ elif base == "OSC":
472
+ if is_starless:
473
+ self.osc_starless_image = image
474
+ self.oscStarlessLabel.setText(label_text)
475
+ else:
476
+ self.osc_image = image
477
+ self.oscLabel.setText(label_text)
478
+
479
+ # NEW: HaO3 composite → Ha (R), OIII (G)
480
+ elif base == "HaO3":
481
+ # keep the full composite for reference
482
+ if is_starless:
483
+ self.hao3_starless_image = image
484
+ self.hao3StarlessLabel.setText(label_text)
485
+ else:
486
+ self.hao3_image = image
487
+ self.hao3Label.setText(label_text)
488
+
489
+ if not (isinstance(image, np.ndarray) and image.ndim == 3 and image.shape[2] >= 2):
490
+ QMessageBox.warning(
491
+ self,
492
+ "HaO3 Load",
493
+ "HaO3 expects a 3-channel color image (R=Ha, G=OIII). "
494
+ "Loaded image is not 3-channel; cannot extract Ha/OIII."
495
+ )
496
+ return
497
+
498
+ img32 = image.astype(np.float32, copy=False)
499
+ ha_from_r = img32[..., 0]
500
+ o3_from_g = img32[..., 1]
501
+
502
+ if is_starless:
503
+ self.ha_starless_image = ha_from_r
504
+ self.haStarlessLabel.setText(label_text + " [R → Ha (starless)]")
505
+ self._o3_from_hao3_starless = o3_from_g
506
+ self._update_oiii_from_composites(starless=True)
507
+ else:
508
+ self.ha_image = ha_from_r
509
+ self.haLabel.setText(label_text + " [R → Ha]")
510
+ self._o3_from_hao3 = o3_from_g
511
+ self._update_oiii_from_composites(starless=False)
512
+
513
+ # NEW: S2O3 composite → SII (R), OIII (G)
514
+ elif base == "S2O3":
515
+ # keep the full composite for reference
516
+ if is_starless:
517
+ self.s2o3_starless_image = image
518
+ self.s2o3StarlessLabel.setText(label_text)
519
+ else:
520
+ self.s2o3_image = image
521
+ self.s2o3Label.setText(label_text)
522
+
523
+ if not (isinstance(image, np.ndarray) and image.ndim == 3 and image.shape[2] >= 2):
524
+ QMessageBox.warning(
525
+ self,
526
+ "S2O3 Load",
527
+ "S2O3 expects a 3-channel color image (R=SII, G=OIII). "
528
+ "Loaded image is not 3-channel; cannot extract SII/OIII."
529
+ )
530
+ return
531
+
532
+ img32 = image.astype(np.float32, copy=False)
533
+ s2_from_r = img32[..., 0]
534
+ o3_from_g = img32[..., 1]
535
+
536
+ if is_starless:
537
+ self.sii_starless_image = s2_from_r
538
+ self.siiStarlessLabel.setText(label_text + " [R → SII (starless)]")
539
+ self._o3_from_s2o3_starless = o3_from_g
540
+ self._update_oiii_from_composites(starless=True)
541
+ else:
542
+ self.sii_image = s2_from_r
543
+ self.siiLabel.setText(label_text + " [R → SII]")
544
+ self._o3_from_s2o3 = o3_from_g
545
+ self._update_oiii_from_composites(starless=False)
546
+
547
+ else:
548
+ QMessageBox.critical(self, "Error", f"Unknown channel '{channel}'.")
549
+ return
550
+
551
+
552
+ # Store header and mono-flag for later saving
553
+ self.original_header = header
554
+ self.is_mono = is_mono
555
+
556
+ # --- NEW: helper to combine OIII from HaO3 and S2O3 ---
557
+ def _update_oiii_from_composites(self, starless: bool):
558
+ """
559
+ Average all available composite-derived green channels into a single OIII NB image.
560
+ Only averages HaO3+S2O3 (does NOT touch any manually loaded OIII).
561
+ """
562
+ sources = []
563
+ labels = []
564
+
565
+ if starless:
566
+ if self._o3_from_hao3_starless is not None:
567
+ sources.append(self._o3_from_hao3_starless)
568
+ labels.append("HaO3")
569
+ if self._o3_from_s2o3_starless is not None:
570
+ sources.append(self._o3_from_s2o3_starless)
571
+ labels.append("S2O3")
572
+ else:
573
+ if self._o3_from_hao3 is not None:
574
+ sources.append(self._o3_from_hao3)
575
+ labels.append("HaO3")
576
+ if self._o3_from_s2o3 is not None:
577
+ sources.append(self._o3_from_s2o3)
578
+ labels.append("S2O3")
579
+
580
+ if not sources:
581
+ return
582
+
583
+ try:
584
+ combo = np.mean(np.stack(sources, axis=0), axis=0).astype(np.float32, copy=False)
585
+ except ValueError:
586
+ # shape mismatch – fall back to last one
587
+ combo = sources[-1]
588
+
589
+ if starless:
590
+ self.oiii_starless_image = combo
591
+ base_label = "OIII from " + "+".join(labels) + " (starless)"
592
+ self.oiiiStarlessLabel.setText(base_label)
593
+ else:
594
+ self.oiii_image = combo
595
+ base_label = "OIII from " + "+".join(labels)
596
+ self.oiiiLabel.setText(base_label)
597
+
598
+
599
+ def _collect_open_documents(self):
600
+ # kept for compatibility with callers; returns only docs
601
+ return [d for d, _ in self._iter_open_docs()]
602
+
603
+ def _select_document_via_dropdown(self, title: str):
604
+ items = self._iter_open_docs()
605
+ if not items:
606
+ QMessageBox.information(self, f"Select View — {title}", "No open views/documents found.")
607
+ return None
608
+
609
+ # default to active view if present
610
+ mw = self._main_window()
611
+ active_doc = None
612
+ if mw and mw.mdi.activeSubWindow():
613
+ active_doc = getattr(mw.mdi.activeSubWindow().widget(), "document", None)
614
+
615
+ if len(items) == 1:
616
+ return items[0][0]
617
+
618
+ names = [t for _, t in items]
619
+ default_index = next((i for i, (d, _) in enumerate(items) if d is active_doc), 0)
620
+
621
+ choice, ok = QInputDialog.getItem(
622
+ self, f"Select View — {title}", "Choose:", names, default_index, False
623
+ )
624
+ if not ok:
625
+ return None
626
+ return items[names.index(choice)][0]
627
+
628
+ def _image_from_doc(self, doc):
629
+ """(np.ndarray, header, bit_depth, is_mono, file_path) from an ImageDocument."""
630
+ arr = getattr(doc, "image", None)
631
+ if arr is None:
632
+ QMessageBox.warning(self, "No image", "Selected view has no image.")
633
+ return None
634
+ meta = getattr(doc, "metadata", {}) or {}
635
+ header = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
636
+ bit_depth = meta.get("bit_depth", "Unknown")
637
+ is_mono = False
638
+ try:
639
+ import numpy as np
640
+ is_mono = isinstance(arr, np.ndarray) and (arr.ndim == 2 or (arr.ndim == 3 and arr.shape[2] == 1))
641
+ except Exception:
642
+ pass
643
+ return arr, header, bit_depth, is_mono, meta.get("file_path")
644
+
645
+ def loadImageFromView(self, channel: str):
646
+ doc = self._select_document_via_dropdown(channel)
647
+ if not doc:
648
+ return None
649
+ res = self._image_from_doc(doc)
650
+ if not res:
651
+ return None
652
+
653
+ img, header, bit_depth, is_mono, _ = res
654
+
655
+ # Build a human-friendly name for the label (view/subwindow title)
656
+ title = ""
657
+ try:
658
+ title = doc.display_name()
659
+ except Exception:
660
+ mw = self._main_window()
661
+ if mw and mw.mdi.activeSubWindow():
662
+ title = mw.mdi.activeSubWindow().windowTitle()
663
+
664
+ # Return with the "path" field set to the title so the caller can label it
665
+ return img, header, bit_depth, is_mono, title
666
+
667
+
668
+ def loadImageFromFile(self, channel: str):
669
+ file_filter = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
670
+ path, _ = QFileDialog.getOpenFileName(self, f"Select {channel} Image", "", file_filter)
671
+ if not path:
672
+ return None
673
+ try:
674
+ image, header, bit_depth, is_mono = legacy_load_image(path) # ← use the alias
675
+ except Exception as e:
676
+ QMessageBox.critical(self, "Error", f"Failed to load {channel} image:\n{e}")
677
+ return None
678
+ return image, header, bit_depth, is_mono, path
679
+
680
+ def loadImageFromSlot(self, channel: str):
681
+ """
682
+ Prompt the user to pick one of the ImageManager’s slots (using custom names if defined)
683
+ and load that image.
684
+ """
685
+ if not self.image_manager:
686
+ QMessageBox.critical(self, "Error", "ImageManager is not initialized. Cannot load image from slot.")
687
+ return None
688
+
689
+ # Look up the main window’s custom slot names
690
+ main_win = getattr(self, "parent_window", None) or self.window()
691
+ slot_names = getattr(main_win, "slot_names", {})
692
+
693
+ # Build the list of display names (zero-based)
694
+ display_names = [
695
+ slot_names.get(i, f"Slot {i}")
696
+ for i in range(self.image_manager.max_slots)
697
+ ]
698
+
699
+ # Ask the user to choose one
700
+ choice, ok = QInputDialog.getItem(
701
+ self,
702
+ f"Select Slot for {channel}",
703
+ "Choose a slot:",
704
+ display_names,
705
+ 0,
706
+ False
707
+ )
708
+ if not ok or not choice:
709
+ return None
710
+
711
+ # Map back to the numeric index
712
+ idx = display_names.index(choice)
713
+
714
+ # Retrieve the image and metadata
715
+ img = self.image_manager._images.get(idx)
716
+ if img is None:
717
+ QMessageBox.warning(self, "Empty Slot", f"{choice} is empty.")
718
+ return None
719
+
720
+ meta = self.image_manager._metadata.get(idx, {})
721
+ return (
722
+ img,
723
+ meta.get("original_header"),
724
+ meta.get("bit_depth", "Unknown"),
725
+ meta.get("is_mono", False),
726
+ meta.get("file_path", None)
727
+ )
728
+
729
+
730
+ def startContinuumSubtraction(self):
731
+ # STARRED (with stars) continuum channels
732
+ cont_red_starry = self.red_image if self.red_image is not None else (self.osc_image[..., 0] if self.osc_image is not None else None)
733
+ cont_green_starry = self.green_image if self.green_image is not None else (self.osc_image[..., 1] if self.osc_image is not None else None)
734
+
735
+ # STARLESS continuum channels
736
+ cont_red_starless = self.red_starless_image if self.red_starless_image is not None else (self.osc_starless_image[..., 0] if self.osc_starless_image is not None else None)
737
+ cont_green_starless = self.green_starless_image if self.green_starless_image is not None else (self.osc_starless_image[..., 1] if self.osc_starless_image is not None else None)
738
+
739
+ # Build tasks per NB filter
740
+ pairs = []
741
+ def add_pair(name, nb_starry, cont_starry, nb_starless, cont_starless):
742
+ has_starry = (nb_starry is not None and cont_starry is not None)
743
+ has_starless = (nb_starless is not None and cont_starless is not None)
744
+ if has_starry or has_starless:
745
+ pairs.append({
746
+ "name": name,
747
+ "nb": nb_starry,
748
+ "cont": cont_starry,
749
+ "nb_sl": nb_starless,
750
+ "cont_sl": cont_starless,
751
+ "starless_only": (has_starless and not has_starry),
752
+ })
753
+
754
+ add_pair("Ha", self.ha_image, cont_red_starry, self.ha_starless_image, cont_red_starless)
755
+ add_pair("SII", self.sii_image, cont_red_starry, self.sii_starless_image, cont_red_starless)
756
+ add_pair("OIII", self.oiii_image, cont_green_starry, self.oiii_starless_image, cont_green_starless)
757
+
758
+ if not pairs:
759
+ self.statusLabel.setText("Load at least one NB + matching continuum channel (or OSC).")
760
+ return
761
+ mw = self._main_window()
762
+ cosmic_root = _cosmic_root(mw) if mw is not None else "" # NEW
763
+ denoise_linear = self.denoise_checkbox.isChecked() # NEW
764
+ self.showSpinner()
765
+ self._threads = []
766
+ self._results = []
767
+ self._pushed_results = False
768
+ self._pending = 0
769
+
770
+ # How many result signals do we expect in total?
771
+ self._expected_results = sum(
772
+ (1 if p["nb"] is not None and p["cont"] is not None else 0) +
773
+ (1 if p["nb_sl"] is not None and p["cont_sl"] is not None else 0)
774
+ for p in pairs
775
+ )
776
+
777
+ for p in pairs:
778
+ t = ContinuumProcessingThread(
779
+ p["nb"], p["cont"], self.linear_output_checkbox.isChecked(),
780
+ starless_nb=p["nb_sl"], starless_cont=p["cont_sl"], starless_only=p["starless_only"],
781
+ threshold=self.threshold_value, summary_gamma=self.summary_gamma, q_factor=self.q_factor,
782
+ cosmic_root=cosmic_root, denoise_linear=denoise_linear # NEW
783
+ )
784
+ name = p["name"] # avoid late binding in lambdas
785
+
786
+ if p["nb"] is not None and p["cont"] is not None:
787
+ self._pending += 1
788
+ t.processing_complete.connect(
789
+ lambda img, stars, overlay, raw, after, n=f"{name} (starry)":
790
+ self._onOneResult(n, img, stars, overlay, raw, after)
791
+ )
792
+
793
+ if p["nb_sl"] is not None and p["cont_sl"] is not None:
794
+ self._pending += 1
795
+ t.processing_complete_starless.connect(
796
+ lambda img, stars, overlay, raw, after, n=f"{name} (starless)":
797
+ self._onOneResult(n, img, stars, overlay, raw, after)
798
+ )
799
+
800
+ t.status_update.connect(self.update_status_label)
801
+ self._threads.append(t)
802
+ t.start()
803
+
804
+
805
+ def _onOneResult(self, filt, img, star_count, overlay_qimg, raw_pixels, after_pixels):
806
+ # stash for later slot‐pushing
807
+ self._results.append({
808
+ "filter": filt,
809
+ "image": img,
810
+ "stars": star_count,
811
+ "overlay": overlay_qimg,
812
+ "raw": raw_pixels,
813
+ "after": after_pixels
814
+ })
815
+
816
+ # ---------- thumbnails / diagnostics ----------
817
+ make_scatter = (
818
+ isinstance(raw_pixels, np.ndarray) and
819
+ raw_pixels.ndim == 2 and raw_pixels.shape[1] >= 2 and
820
+ raw_pixels.shape[0] >= 3 and
821
+ (cv2 is not None)
822
+ )
823
+
824
+ if make_scatter:
825
+ nb_flux = raw_pixels[:, 0].astype(np.float32, copy=False)
826
+ cont_flux = raw_pixels[:, 1].astype(np.float32, copy=False)
827
+
828
+ h, w = 200, 200
829
+ scatter_img = np.ones((h, w, 3), np.uint8) * 255
830
+
831
+ # 1) best-fit NB ≈ m·BB + c
832
+ try:
833
+ m, c = np.polyfit(cont_flux, nb_flux, 1)
834
+ x0f, y0f = 0.0, c
835
+ x1f, y1f = 1.0, m + c
836
+ y0f = float(np.clip(y0f, 0.0, 1.0))
837
+ y1f = float(np.clip(y1f, 0.0, 1.0))
838
+ x0 = int(x0f * (w - 1)); y0 = int((1 - y0f) * (h - 1))
839
+ x1 = int(x1f * (w - 1)); y1 = int((1 - y1f) * (h - 1))
840
+ cv2.line(scatter_img, (x0, y0), (x1, y1), (0, 0, 255), 2) # red line (BGR)
841
+ except Exception:
842
+ pass
843
+
844
+ # 2) points
845
+ xs = (np.clip(cont_flux, 0, 1) * (w - 1)).astype(int)
846
+ ys = ((1 - np.clip(nb_flux, 0, 1)) * (h - 1)).astype(int)
847
+ for x, y in zip(xs, ys):
848
+ if 0 <= x < w and 0 <= y < h:
849
+ cv2.circle(scatter_img, (x, y), 2, (255, 0, 0), -1) # blue points (BGR)
850
+
851
+ # axes
852
+ cv2.line(scatter_img, (0, h - 1), (w - 1, h - 1), (0, 0, 0), 1)
853
+ cv2.line(scatter_img, (0, 0), (0, h - 1), (0, 0, 0), 1)
854
+
855
+ # labels
856
+ font = cv2.FONT_HERSHEY_SIMPLEX
857
+ ((tw, _), _) = cv2.getTextSize("BB Flux", font, 0.5, 1)
858
+ cv2.putText(scatter_img, "BB Flux", ((w - tw) // 2, h - 5), font, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
859
+ for i, ch in enumerate("NB Flux"):
860
+ cv2.putText(scatter_img, ch, (2, 15 + i*15), font, 0.5, (0, 0, 0), 1, cv2.LINE_AA)
861
+
862
+ qscatter = QImage(scatter_img.data, w, h, 3*w, QImage.Format.Format_RGB888).copy()
863
+ scatter_pix = QPixmap.fromImage(qscatter)
864
+
865
+ # overlay thumbnail (always)
866
+ thumb_pix = QPixmap.fromImage(overlay_qimg).scaled(
867
+ 200, 200,
868
+ Qt.AspectRatioMode.KeepAspectRatio,
869
+ Qt.TransformationMode.SmoothTransformation
870
+ )
871
+
872
+ # assemble entry row
873
+ entry = QWidget()
874
+ elay = QHBoxLayout(entry)
875
+ elay.addWidget(QLabel(f"{filt}: {star_count} stars"))
876
+
877
+ if make_scatter:
878
+ scatter_label = QLabel()
879
+ scatter_label.setPixmap(scatter_pix)
880
+ scatter_label.setCursor(Qt.CursorShape.PointingHandCursor)
881
+ elay.addWidget(scatter_label)
882
+ self._clickable_images[scatter_label] = scatter_pix
883
+ scatter_label.installEventFilter(self)
884
+
885
+ overlay_label = QLabel()
886
+ overlay_label.setPixmap(thumb_pix)
887
+ overlay_label.setCursor(Qt.CursorShape.PointingHandCursor)
888
+ elay.addWidget(overlay_label)
889
+ self._clickable_images[overlay_label] = QPixmap.fromImage(overlay_qimg)
890
+ overlay_label.installEventFilter(self)
891
+
892
+ elay.addStretch(1)
893
+ entry.setLayout(elay)
894
+ self.wb_l.addWidget(entry)
895
+
896
+ # ---------- call _pushResultsToDocs exactly once ----------
897
+ if (not getattr(self, "_pushed_results", False)
898
+ and len(self._results) == getattr(self, "_expected_results", 0)):
899
+ self._pushed_results = True
900
+ self.hideSpinner()
901
+ self._pushResultsToDocs(self._results)
902
+
903
+
904
+ def eventFilter(self, source, event):
905
+ # catch mouse releases on any of our clickable labels
906
+ if event.type() == QEvent.Type.MouseButtonRelease and source in self._clickable_images:
907
+ pix = self._clickable_images[source]
908
+ self._showEnlarged(pix)
909
+ return True
910
+ return super().eventFilter(source, event)
911
+
912
+ def _showEnlarged(self, pixmap: QPixmap):
913
+ """
914
+ Detail View dialog with themed zoom controls, autostretch, and 'push to document'.
915
+ Uses ZoomableGraphicsView + QGraphicsScene (consistent with CLAHE).
916
+ """
917
+ from PyQt6.QtCore import Qt
918
+ from PyQt6.QtGui import QImage, QPixmap
919
+ from PyQt6.QtWidgets import (
920
+ QDialog, QVBoxLayout, QHBoxLayout, QMessageBox,
921
+ QLabel, QPushButton, QGraphicsScene, QGraphicsPixmapItem
922
+ )
923
+
924
+
925
+ from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
926
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
927
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
928
+
929
+ # ---------- helpers ----------
930
+ def _float01_from_qimage(qimg: QImage) -> np.ndarray:
931
+ """QImage -> float32 [0..1]. Handles Grayscale8 and RGB888; converts others to RGB888."""
932
+ if qimg is None or qimg.isNull():
933
+ return np.zeros((1, 1), dtype=np.float32)
934
+
935
+ fmt = qimg.format()
936
+ if fmt not in (QImage.Format.Format_Grayscale8, QImage.Format.Format_RGB888):
937
+ qimg = qimg.convertToFormat(QImage.Format.Format_RGB888)
938
+ fmt = QImage.Format.Format_RGB888
939
+
940
+ h = qimg.height()
941
+ w = qimg.width()
942
+ bpl = qimg.bytesPerLine()
943
+
944
+ ptr = qimg.bits()
945
+ ptr.setsize(h * bpl)
946
+ buf = np.frombuffer(ptr, dtype=np.uint8).reshape((h, bpl))
947
+
948
+ if fmt == QImage.Format.Format_Grayscale8:
949
+ return (buf[:, :w].astype(np.float32) / 255.0).clip(0.0, 1.0)
950
+
951
+ # RGB888
952
+ rgb = buf[:, :w * 3].reshape((h, w, 3)).astype(np.float32) / 255.0
953
+ return rgb.clip(0.0, 1.0)
954
+
955
+ def _qimage_from_float01(arr: np.ndarray) -> QImage:
956
+ """float32 [0..1] -> QImage (RGB888 or Grayscale8), deep-copied."""
957
+ a = np.clip(np.asarray(arr, dtype=np.float32), 0.0, 1.0)
958
+
959
+ if a.ndim == 2:
960
+ u8 = (a * 255.0 + 0.5).astype(np.uint8, copy=False)
961
+ h, w = u8.shape
962
+ q = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
963
+ return q.copy()
964
+
965
+ if a.ndim == 3 and a.shape[2] == 1:
966
+ return _qimage_from_float01(a[..., 0])
967
+
968
+ if a.ndim == 3 and a.shape[2] == 3:
969
+ u8 = (a * 255.0 + 0.5).astype(np.uint8, copy=False)
970
+ h, w, _ = u8.shape
971
+ q = QImage(u8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
972
+ return q.copy()
973
+
974
+ # fallback
975
+ raise ValueError(f"Unexpected image shape: {a.shape}")
976
+
977
+ def _pixmap_from_float01(arr: np.ndarray) -> QPixmap:
978
+ return QPixmap.fromImage(_qimage_from_float01(arr))
979
+
980
+ # ---------- dialog ----------
981
+ dlg = QDialog(self)
982
+ dlg.setWindowTitle("Detail View")
983
+ dlg.resize(980, 820)
984
+
985
+ outer = QVBoxLayout(dlg)
986
+
987
+ # Convert input pixmap -> float01 working buffer
988
+ try:
989
+ base_qimg = pixmap.toImage()
990
+ current_arr = _float01_from_qimage(base_qimg)
991
+ except Exception:
992
+ current_arr = np.zeros((1, 1), dtype=np.float32)
993
+
994
+ # Ensure "display" is always RGB for the pixmap item (GraphicsView)
995
+ def _ensure_rgb(a: np.ndarray) -> np.ndarray:
996
+ a = np.asarray(a, dtype=np.float32)
997
+ if a.ndim == 2:
998
+ return np.stack([a, a, a], axis=-1)
999
+ if a.ndim == 3 and a.shape[2] == 1:
1000
+ return np.repeat(a, 3, axis=2)
1001
+ return a
1002
+
1003
+ # Graphics view
1004
+ scene = QGraphicsScene(dlg)
1005
+ view = ZoomableGraphicsView(scene)
1006
+ view.setAlignment(Qt.AlignmentFlag.AlignCenter)
1007
+
1008
+ pix_item = QGraphicsPixmapItem()
1009
+ scene.addItem(pix_item)
1010
+ outer.addWidget(view, stretch=1)
1011
+
1012
+ def _set_scene_from_arr(arr01: np.ndarray):
1013
+ rgb = _ensure_rgb(arr01)
1014
+ pm = _pixmap_from_float01(rgb)
1015
+ pix_item.setPixmap(pm)
1016
+ scene.setSceneRect(pix_item.boundingRect())
1017
+
1018
+ _set_scene_from_arr(current_arr)
1019
+
1020
+ # Toolbar row (themed zoom helper)
1021
+ row = QHBoxLayout()
1022
+
1023
+ btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
1024
+ btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
1025
+ btn_zoom_1to1 = themed_toolbtn("zoom-original", "1:1 (100%)")
1026
+ btn_zoom_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
1027
+
1028
+ btn_zoom_out.clicked.connect(view.zoom_out)
1029
+ btn_zoom_in.clicked.connect(view.zoom_in)
1030
+ btn_zoom_1to1.clicked.connect(view.one_to_one if hasattr(view, "one_to_one") else (lambda: None))
1031
+ btn_zoom_fit.clicked.connect(lambda: view.fit_to_item(pix_item))
1032
+
1033
+ row.addWidget(btn_zoom_out)
1034
+ row.addWidget(btn_zoom_in)
1035
+ row.addWidget(btn_zoom_1to1)
1036
+ row.addWidget(btn_zoom_fit)
1037
+
1038
+ row.addStretch(1)
1039
+
1040
+ btn_autostretch = QPushButton("Autostretch")
1041
+ row.addWidget(btn_autostretch)
1042
+
1043
+ btn_push = QPushButton("Push to New Document")
1044
+ row.addWidget(btn_push)
1045
+
1046
+ btn_close = QPushButton("Close")
1047
+ row.addWidget(btn_close)
1048
+
1049
+ outer.addLayout(row)
1050
+
1051
+ # Actions
1052
+ def _do_autostretch():
1053
+ nonlocal current_arr
1054
+ try:
1055
+ a = np.asarray(current_arr, dtype=np.float32)
1056
+
1057
+ # Autostretch in-place, respecting mono vs RGB
1058
+ if a.ndim == 2:
1059
+ stretched = stretch_mono_image(a, target_median=0.25)
1060
+ current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
1061
+ elif a.ndim == 3 and a.shape[2] == 1:
1062
+ stretched = stretch_mono_image(a[..., 0], target_median=0.25)
1063
+ current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
1064
+ else:
1065
+ stretched = stretch_color_image(a, target_median=0.25, linked=False)
1066
+ current_arr = np.clip(stretched, 0.0, 1.0).astype(np.float32, copy=False)
1067
+
1068
+ _set_scene_from_arr(current_arr)
1069
+ # keep current zoom, just refresh pixmap
1070
+ except Exception as e:
1071
+ QMessageBox.warning(dlg, "Detail View", f"Autostretch failed:\n{e}")
1072
+
1073
+ def _do_push_to_doc():
1074
+ dm = getattr(self, "doc_manager", None)
1075
+ mw = self._main_window() if hasattr(self, "_main_window") else None
1076
+
1077
+ if dm is None or mw is None or not hasattr(mw, "_spawn_subwindow_for"):
1078
+ QMessageBox.critical(dlg, "Detail View", "Cannot create document: missing DocManager or MainWindow.")
1079
+ return
1080
+
1081
+ try:
1082
+ img = np.asarray(current_arr, dtype=np.float32)
1083
+
1084
+ # Preserve mono where appropriate
1085
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
1086
+
1087
+ counter = getattr(self, "_detail_doc_counter", 0) + 1
1088
+ self._detail_doc_counter = counter
1089
+ name = f"DetailView_{counter}"
1090
+
1091
+ meta = {
1092
+ "display_name": name,
1093
+ "file_path": name,
1094
+ "bit_depth": "32-bit floating point",
1095
+ "is_mono": bool(is_mono),
1096
+ "original_header": getattr(self, "original_header", None),
1097
+ "source": "Continuum Subtract — Detail View",
1098
+ }
1099
+
1100
+ doc = dm.create_document(img, metadata=meta, name=name)
1101
+ mw._spawn_subwindow_for(doc)
1102
+
1103
+ try:
1104
+ if hasattr(self, "statusLabel") and self.statusLabel is not None:
1105
+ self.statusLabel.setText(f"Pushed detail view → '{name}'.")
1106
+ except Exception:
1107
+ pass
1108
+
1109
+ except Exception as e:
1110
+ QMessageBox.critical(dlg, "Detail View", f"Failed to create document:\n{e}")
1111
+
1112
+ btn_autostretch.clicked.connect(_do_autostretch)
1113
+ btn_push.clicked.connect(_do_push_to_doc)
1114
+ btn_close.clicked.connect(dlg.accept)
1115
+
1116
+ # Initial fit
1117
+ try:
1118
+ view.fit_to_item(pix_item)
1119
+ except Exception:
1120
+ pass
1121
+
1122
+ dlg.exec()
1123
+
1124
+
1125
+ def _onThreadFinished(self):
1126
+ self._pending -= 1
1127
+ if self._pending == 0:
1128
+ self.hideSpinner()
1129
+ self._pushResultsToDocs(self._results)
1130
+
1131
+ def _pushResultsToDocs(self, results):
1132
+ dm = getattr(self, "doc_manager", None)
1133
+ mw = self._main_window()
1134
+ if dm is None or mw is None or not hasattr(mw, "_spawn_subwindow_for"):
1135
+ QMessageBox.critical(self, "Continuum Subtract",
1136
+ "Cannot create documents: missing DocManager or MainWindow.")
1137
+ return
1138
+
1139
+ created = 0
1140
+ for entry in results:
1141
+ filt = entry["filter"]
1142
+ img = np.asarray(entry["image"], dtype=np.float32) # keep everything float32
1143
+ name = f"{filt}_ContSub"
1144
+
1145
+ meta = {
1146
+ "display_name": name, # nice title in the UI
1147
+ "file_path": name, # placeholder path until user saves
1148
+ "bit_depth": "32-bit floating point",
1149
+ "is_mono": (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1)),
1150
+ "original_header": self.original_header,
1151
+ "source": "Continuum Subtract",
1152
+ }
1153
+
1154
+ try:
1155
+ # create a proper ImageDocument and register it
1156
+ doc = dm.create_document(img, metadata=meta, name=name)
1157
+ # show it as an MDI subwindow
1158
+ mw._spawn_subwindow_for(doc)
1159
+ created += 1
1160
+ except Exception as e:
1161
+ QMessageBox.critical(self, "Continuum Subtract",
1162
+ f"Failed to create document '{name}':\n{e}")
1163
+
1164
+ self.statusLabel.setText(f"Created {created} document(s).")
1165
+
1166
+
1167
+ def _onThreadFinished(self):
1168
+ self._pending -= 1
1169
+ # do NOT push here if you already push in _onOneResult
1170
+
1171
+ def update_status_label(self, message):
1172
+ self.statusLabel.setText(message)
1173
+
1174
+ def showSpinner(self):
1175
+ self.spinnerLabel.setText("Processing…")
1176
+ self.spinnerLabel.show()
1177
+ if hasattr(self, "execute_button"):
1178
+ self.execute_button.setEnabled(False)
1179
+
1180
+ def hideSpinner(self):
1181
+ self.spinnerLabel.hide()
1182
+ self.spinnerLabel.clear()
1183
+ if hasattr(self, "execute_button"):
1184
+ self.execute_button.setEnabled(True)
1185
+
1186
+
1187
+
1188
+ class ContinuumProcessingThread(QThread):
1189
+ processing_complete = pyqtSignal(np.ndarray, int, QImage, np.ndarray, np.ndarray)
1190
+ processing_complete_starless = pyqtSignal(np.ndarray, int, QImage, np.ndarray, np.ndarray)
1191
+ status_update = pyqtSignal(str)
1192
+
1193
+ def __init__(self, nb_image, continuum_image, output_linear, *,
1194
+ starless_nb=None, starless_cont=None, starless_only=False,
1195
+ threshold: float = 5.0, summary_gamma: float = 0.6, q_factor: float = 0.8,
1196
+ cosmic_root: str = "", denoise_linear: bool = False): # NEW params
1197
+ super().__init__()
1198
+ self.nb_image = nb_image
1199
+ self.continuum_image = continuum_image
1200
+ self.output_linear = output_linear
1201
+ self.starless_nb = starless_nb
1202
+ self.starless_cont = starless_cont
1203
+ self.starless_only = starless_only
1204
+ self.background_reference = None
1205
+ self._recipe = None # learned from starry pass
1206
+
1207
+ # user knobs
1208
+ self.threshold = float(threshold)
1209
+ self.summary_gamma = float(summary_gamma)
1210
+ self.q_factor = float(q_factor)
1211
+
1212
+ # NEW: Cosmic Clarity integration
1213
+ self.cosmic_root = (cosmic_root or "").strip()
1214
+ self.denoise_linear = bool(denoise_linear)
1215
+
1216
+ # ---------- small helpers ----------
1217
+ @staticmethod
1218
+ def _to_mono(img):
1219
+ a = np.asarray(img)
1220
+ if a.ndim == 3:
1221
+ if a.shape[2] == 3:
1222
+ return a[..., 0] # use R channel for NB/cont slots when color
1223
+ if a.shape[2] == 1:
1224
+ return a[..., 0]
1225
+ return a
1226
+
1227
+ @staticmethod
1228
+ def _as_rgb(nb, cont):
1229
+ r = np.asarray(nb, dtype=np.float32)
1230
+ g = np.asarray(cont, dtype=np.float32)
1231
+ if r.ndim == 3: r = r[..., 0]
1232
+ if g.ndim == 3: g = g[..., 0]
1233
+ if r.dtype.kind in "ui":
1234
+ r = r / (255.0 if r.dtype == np.uint8 else 65535.0)
1235
+ if g.dtype.kind in "ui":
1236
+ g = g / (255.0 if g.dtype == np.uint8 else 65535.0)
1237
+ b = g
1238
+ return np.stack([r, g, b], axis=-1).astype(np.float32, copy=False)
1239
+
1240
+ @staticmethod
1241
+ def _fit_ab(x, y):
1242
+ x = x.reshape(-1).astype(np.float32)
1243
+ y = y.reshape(-1).astype(np.float32)
1244
+ N = min(x.size, 100_000)
1245
+ if x.size > N:
1246
+ idx = np.random.choice(x.size, N, replace=False)
1247
+ x = x[idx]; y = y[idx]
1248
+ A = np.vstack([x, np.ones_like(x)]).T
1249
+ a, b = np.linalg.lstsq(A, y, rcond=None)[0]
1250
+ return float(a), float(b)
1251
+
1252
+ @staticmethod
1253
+ def _qimage_from_uint8(rgb_uint8: np.ndarray) -> QImage:
1254
+ """Create a deep-copied QImage from an HxWx3 uint8 array."""
1255
+ h, w = rgb_uint8.shape[:2]
1256
+ return QImage(rgb_uint8.data, w, h, 3*w, QImage.Format.Format_RGB888).copy()
1257
+
1258
+ @staticmethod
1259
+ def _nonlinear_finalize(lin_img: np.ndarray) -> np.ndarray:
1260
+ """Stretch → subtract pedestal → curves, returned as float32 in [0,1]."""
1261
+ target_median = 0.25
1262
+ stretched = stretch_color_image(lin_img, target_median, True, False)
1263
+ final = stretched - 0.7 * np.median(stretched)
1264
+ final = np.clip(final, 0, 1)
1265
+ return apply_curves_adjustment(final, np.median(final), 0.5).astype(np.float32, copy=False)
1266
+
1267
+ # ---------- BG neutral: return pedestals (no in-place surprises) ----------
1268
+ def _compute_bg_pedestal(self, rgb):
1269
+ height, width, _ = rgb.shape
1270
+ num_boxes, box_size, iterations = 200, 25, 25
1271
+
1272
+ boxes = [(np.random.randint(0, height - box_size),
1273
+ np.random.randint(0, width - box_size)) for _ in range(num_boxes)]
1274
+ best = np.full(num_boxes, np.inf, dtype=np.float32)
1275
+
1276
+ for _ in range(iterations):
1277
+ for i, (y, x) in enumerate(boxes):
1278
+ if y + box_size <= height and x + box_size <= width:
1279
+ patch = rgb[y:y+box_size, x:x+box_size]
1280
+ med = np.median(patch) if patch.size else np.inf
1281
+ best[i] = min(best[i], med)
1282
+ sv = []
1283
+ for dy in (-1, 0, 1):
1284
+ for dx in (-1, 0, 1):
1285
+ yy, xx = y + dy*box_size, x + dx*box_size
1286
+ if 0 <= yy < height - box_size and 0 <= xx < width - box_size:
1287
+ p2 = rgb[yy:yy+box_size, xx:xx+box_size]
1288
+ if p2.size:
1289
+ sv.append(np.median(p2))
1290
+ if sv:
1291
+ k = int(np.argmin(sv))
1292
+ y += (k // 3 - 1) * box_size
1293
+ x += (k % 3 - 1) * box_size
1294
+ boxes[i] = (y, x)
1295
+
1296
+ # pick darkest
1297
+ darkest = np.inf; ref = None
1298
+ for y, x in boxes:
1299
+ if y + box_size <= height and x + box_size <= width:
1300
+ patch = rgb[y:y+box_size, x:x+box_size]
1301
+ med = np.median(patch) if patch.size else np.inf
1302
+ if med < darkest:
1303
+ darkest, ref = med, patch
1304
+
1305
+ ped = np.zeros(3, dtype=np.float32)
1306
+ if ref is not None:
1307
+ self.background_reference = np.median(ref.reshape(-1, 3), axis=0)
1308
+ chan_meds = np.median(rgb, axis=(0, 1))
1309
+ # pedestal to lift channels toward their own median
1310
+ ped = np.maximum(0.0, chan_meds - self.background_reference)
1311
+
1312
+ # specifically lift G/B if below R reference
1313
+ r_ref = float(self.background_reference[0])
1314
+ for ch in (1, 2):
1315
+ if self.background_reference[ch] < r_ref:
1316
+ ped[ch] += (r_ref - self.background_reference[ch])
1317
+ return ped
1318
+
1319
+ @staticmethod
1320
+ def _apply_pedestal(rgb, ped):
1321
+ return np.clip(rgb + ped.reshape(1,1,3), 0.0, 1.0)
1322
+
1323
+ @staticmethod
1324
+ def _normalize_red_to_green(rgb):
1325
+ r = rgb[...,0]; g = rgb[...,1]
1326
+ mad_r = float(np.mean(np.abs(r - np.mean(r))))
1327
+ mad_g = float(np.mean(np.abs(g - np.mean(g))))
1328
+ med_r = float(np.median(r))
1329
+ med_g = float(np.median(g))
1330
+ g_gain = (mad_g / max(mad_r, 1e-9))
1331
+ g_offs = (-g_gain * med_r + med_g)
1332
+ rgb2 = rgb.copy()
1333
+ rgb2[...,0] = np.clip(r * g_gain + g_offs, 0.0, 1.0)
1334
+ return rgb2, g_gain, g_offs
1335
+
1336
+ def _linear_subtract(self, rgb, Q, green_median):
1337
+ r = rgb[...,0]; g = rgb[...,1]
1338
+ return np.clip(r - Q * (g - green_median), 0.0, 1.0)
1339
+
1340
+ # ---------- main ----------
1341
+ def run(self):
1342
+ try:
1343
+ # STARLESS-ONLY early exit
1344
+ if (self.nb_image is None or self.continuum_image is None) and self.starless_only:
1345
+ self._run_starless_only()
1346
+ return
1347
+
1348
+ recipe = None
1349
+
1350
+ # ----- starry pass (learn recipe) -----
1351
+ if self.nb_image is not None and self.continuum_image is not None:
1352
+ rgb = self._as_rgb(self.nb_image, self.continuum_image)
1353
+
1354
+ self.status_update.emit("Performing background neutralization...")
1355
+ ped = self._compute_bg_pedestal(rgb)
1356
+ rgb = self._apply_pedestal(rgb, ped)
1357
+
1358
+ self.status_update.emit("Normalizing red to green…")
1359
+ rgb, g_gain, g_offs = self._normalize_red_to_green(rgb)
1360
+
1361
+ self.status_update.emit("Performing star-based white balance…")
1362
+ balanced_rgb, star_count, star_overlay, raw_star_pixels, after_star_pixels = \
1363
+ apply_star_based_white_balance(
1364
+ rgb, threshold=self.threshold, autostretch=False,
1365
+ reuse_cached_sources=True, return_star_colors=True
1366
+ )
1367
+
1368
+ # per-channel affine fit to reproduce WB later
1369
+ wb_a = np.zeros(3, np.float32)
1370
+ wb_b = np.zeros(3, np.float32)
1371
+ for c in range(3):
1372
+ a, b = self._fit_ab(rgb[..., c], balanced_rgb[..., c])
1373
+ wb_a[c], wb_b[c] = a, b
1374
+
1375
+ green_med = float(np.median(balanced_rgb[..., 1]))
1376
+ Q = self.q_factor
1377
+ linear_image = self._linear_subtract(balanced_rgb, Q, green_med)
1378
+ if self.denoise_linear and self.cosmic_root:
1379
+ self.status_update.emit("Denoising continuum-subtracted image (Cosmic Clarity)…")
1380
+ linear_image = self._denoise_linear_image(linear_image)
1381
+ # --- NEW: gamma brighten overlay for the summary ---
1382
+ g = max(self.summary_gamma, 1e-6)
1383
+ overlay_gamma = np.power(np.clip(star_overlay, 0.0, 1.0), g)
1384
+ overlay_uint8 = (overlay_gamma * 255).astype(np.uint8)
1385
+ qimg = self._qimage_from_uint8(overlay_uint8)
1386
+
1387
+ if self.output_linear:
1388
+ self.processing_complete.emit(
1389
+ np.clip(linear_image, 0, 1), int(star_count), qimg,
1390
+ np.array(raw_star_pixels), np.array(after_star_pixels)
1391
+ )
1392
+ else:
1393
+ self.status_update.emit("Linear → Non-linear stretch…")
1394
+ target_median = 0.25
1395
+ stretched = stretch_color_image(linear_image, target_median, True, False)
1396
+ final = stretched - 0.7 * np.median(stretched)
1397
+ final = np.clip(final, 0, 1)
1398
+ final = apply_curves_adjustment(final, np.median(final), 0.5)
1399
+ self.processing_complete.emit(
1400
+ final.astype(np.float32, copy=False),
1401
+ int(star_count), qimg,
1402
+ np.array(raw_star_pixels), np.array(after_star_pixels)
1403
+ )
1404
+
1405
+ # learned recipe + fit data (reused for starless)
1406
+ recipe = {
1407
+ "pedestal": ped,
1408
+ "rnorm_gain": g_gain,
1409
+ "rnorm_offs": g_offs,
1410
+ "wb_a": wb_a,
1411
+ "wb_b": wb_b,
1412
+ "Q": Q,
1413
+ "green_median": green_med,
1414
+
1415
+ # store raw overlay + star stats for reuse
1416
+ "overlay_uint8": overlay_uint8,
1417
+ "fit_star_count": int(star_count),
1418
+ "fit_raw": np.array(raw_star_pixels, copy=True),
1419
+ "fit_after": np.array(after_star_pixels, copy=True),
1420
+ }
1421
+
1422
+ # ----- starless paired pass (apply recipe) -----
1423
+ if self.starless_nb is not None and self.starless_cont is not None:
1424
+ if recipe is not None:
1425
+ rgb = self._as_rgb(self.starless_nb, self.starless_cont)
1426
+ # apply starry recipe exactly
1427
+ rgb = self._apply_pedestal(rgb, recipe["pedestal"])
1428
+ r = rgb[..., 0]
1429
+ rgb[..., 0] = np.clip(r * recipe["rnorm_gain"] + recipe["rnorm_offs"], 0.0, 1.0)
1430
+ for c in range(3):
1431
+ rgb[..., c] = np.clip(rgb[..., c] * recipe["wb_a"][c] + recipe["wb_b"][c], 0.0, 1.0)
1432
+
1433
+ lin = self._linear_subtract(rgb, recipe["Q"], recipe["green_median"])
1434
+ if self.denoise_linear and self.cosmic_root:
1435
+ self.status_update.emit("Denoising starless continuum-subtracted image (Cosmic Clarity)…")
1436
+ lin = self._denoise_linear_image(lin)
1437
+ # reuse gamma-bright overlay & fit info from the starry pass
1438
+ # rebuild overlay & make fresh copies of arrays for the starless emit
1439
+ overlay_uint8 = np.array(recipe["overlay_uint8"], copy=True)
1440
+ fit_qimg = self._qimage_from_uint8(overlay_uint8)
1441
+ fit_count = int(recipe["fit_star_count"])
1442
+ fit_raw = np.array(recipe["fit_raw"], copy=True)
1443
+ fit_after = np.array(recipe["fit_after"], copy=True)
1444
+
1445
+ if self.output_linear:
1446
+ self.processing_complete_starless.emit(
1447
+ np.clip(lin, 0, 1), fit_count, fit_qimg, fit_raw, fit_after
1448
+ )
1449
+ else:
1450
+ self.status_update.emit("Linear → Non-linear stretch (starless)…")
1451
+ target_median = 0.25
1452
+ stretched = stretch_color_image(lin, target_median, True, False)
1453
+ final = stretched - 0.7 * np.median(stretched)
1454
+ final = np.clip(final, 0, 1)
1455
+ final = apply_curves_adjustment(final, np.median(final), 0.5)
1456
+ self.processing_complete_starless.emit(
1457
+ final.astype(np.float32, copy=False),
1458
+ fit_count, fit_qimg, fit_raw, fit_after
1459
+ )
1460
+
1461
+ elif self.starless_only:
1462
+ pass # handled in _run_starless_only
1463
+ except Exception as e:
1464
+ try:
1465
+ self.status_update.emit(f"Continuum subtraction failed: {e}")
1466
+ except Exception:
1467
+ pass
1468
+
1469
+ # ----- starless-only path (no WB; same math you had) -----
1470
+ def _run_starless_only(self):
1471
+ rgb = self._as_rgb(self.starless_nb, self.starless_cont)
1472
+
1473
+ self.status_update.emit("Performing background neutralization…")
1474
+ ped = self._compute_bg_pedestal(rgb)
1475
+ rgb = self._apply_pedestal(rgb, ped)
1476
+
1477
+ self.status_update.emit("Normalizing red to green…")
1478
+ rgb, _, _ = self._normalize_red_to_green(rgb)
1479
+
1480
+ green_med = float(np.median(rgb[..., 1]))
1481
+ lin = self._linear_subtract(rgb, 0.9, green_med)
1482
+ if self.denoise_linear and self.cosmic_root:
1483
+ self.status_update.emit("Denoising starless continuum-subtracted image (Cosmic Clarity)…")
1484
+ lin = self._denoise_linear_image(lin)
1485
+ # Blank overlay & empty star lists (no star detection in starless-only path)
1486
+ h, w = lin.shape[:2]
1487
+ blank = np.zeros((h, w, 3), np.uint8)
1488
+ qimg = self._qimage_from_uint8(blank)
1489
+ empty = np.empty((0, 2), float)
1490
+
1491
+ if self.output_linear:
1492
+ self.processing_complete_starless.emit(np.clip(lin, 0, 1), 0, qimg, empty, empty)
1493
+ return
1494
+
1495
+ self.status_update.emit("Linear → Non-linear stretch…")
1496
+ final = self._nonlinear_finalize(lin)
1497
+ self.processing_complete_starless.emit(final, 0, qimg, empty, empty)
1498
+
1499
+ def _denoise_linear_image(self, img: np.ndarray) -> np.ndarray:
1500
+ """
1501
+ Run Cosmic Clarity denoise on a [0,1] float image and return the result.
1502
+ If anything fails (no path, no exe, timeout, etc.), returns the input.
1503
+ """
1504
+ try:
1505
+ if img is None:
1506
+ return img
1507
+
1508
+ root = self.cosmic_root
1509
+ if not root:
1510
+ # No configured CC path; silently skip
1511
+ return img
1512
+
1513
+ exe_name = _platform_exe_names("denoise")
1514
+ if not exe_name:
1515
+ return img
1516
+
1517
+ exe = os.path.join(root, exe_name)
1518
+ if not os.path.exists(exe):
1519
+ # Executable missing; skip denoise
1520
+ return img
1521
+
1522
+ in_dir = os.path.join(root, "input")
1523
+ out_dir = os.path.join(root, "output")
1524
+ os.makedirs(in_dir, exist_ok=True)
1525
+ os.makedirs(out_dir, exist_ok=True)
1526
+
1527
+ # Unique base name to avoid collisions with other threads
1528
+ base = f"contsub_{os.getpid()}_{id(self)}_{int(time.time() * 1000)}"
1529
+ in_path = os.path.join(in_dir, f"{base}.tif")
1530
+
1531
+ # Stage image as 32-bit float TIFF in [0,1]
1532
+ arr = np.clip(np.asarray(img, dtype=np.float32), 0.0, 1.0)
1533
+ is_mono = not (arr.ndim == 3 and arr.shape[2] == 3)
1534
+ legacy_save_image(
1535
+ arr,
1536
+ in_path,
1537
+ "tiff",
1538
+ "32-bit floating point",
1539
+ None, # no header needed
1540
+ is_mono
1541
+ )
1542
+
1543
+ # Build denoise args (fixed strength 0.9 for both luma + color, full mode)
1544
+ args = [
1545
+ "--denoise_strength", "0.90",
1546
+ "--color_denoise_strength", "0.90",
1547
+ "--denoise_mode", "full",
1548
+ ]
1549
+
1550
+ proc = subprocess.Popen(
1551
+ [exe] + args,
1552
+ cwd=root,
1553
+ stdout=subprocess.PIPE,
1554
+ stderr=subprocess.STDOUT,
1555
+ text=True,
1556
+ universal_newlines=True,
1557
+ )
1558
+
1559
+ # Consume output (optional: could parse progress)
1560
+ if proc.stdout is not None:
1561
+ for line in proc.stdout:
1562
+ line = (line or "").strip()
1563
+ if not line:
1564
+ continue
1565
+ # We could parse "Progress:" here if desired.
1566
+
1567
+ rc = proc.wait()
1568
+ if rc != 0:
1569
+ return img
1570
+
1571
+ # Cosmic Clarity will create something like base_* in the output folder
1572
+ pattern = os.path.join(out_dir, f"{base}_*.*")
1573
+ out_path = self._wait_for_cc_output(pattern)
1574
+ if not out_path:
1575
+ return img
1576
+
1577
+ out_img, _, _, _ = legacy_load_image(out_path)
1578
+ if out_img is None:
1579
+ return img
1580
+
1581
+ result = np.clip(np.asarray(out_img, dtype=np.float32), 0.0, 1.0)
1582
+
1583
+ # Cleanup temp files
1584
+ for path in (in_path, out_path):
1585
+ try:
1586
+ if path and os.path.exists(path):
1587
+ os.remove(path)
1588
+ except Exception:
1589
+ pass
1590
+
1591
+ return result
1592
+
1593
+ except Exception as e:
1594
+ try:
1595
+ self.status_update.emit(f"Cosmic Clarity denoise failed: {e}")
1596
+ except Exception:
1597
+ pass
1598
+ return img
1599
+
1600
+ def _wait_for_cc_output(self, pattern: str, timeout: float = 1800.0, poll: float = 0.25) -> str:
1601
+ """
1602
+ Wait for a CC output file matching glob `pattern`. Returns most recent path or "" on timeout.
1603
+ """
1604
+ t0 = time.time()
1605
+ last = ""
1606
+ while time.time() - t0 < timeout:
1607
+ matches = glob.glob(pattern)
1608
+ if matches:
1609
+ try:
1610
+ matches.sort(key=lambda p: os.path.getmtime(p), reverse=True)
1611
+ except Exception:
1612
+ matches.sort()
1613
+ last = matches[0]
1614
+ try:
1615
+ if os.path.getsize(last) > 0:
1616
+ return last
1617
+ except Exception:
1618
+ return last
1619
+ time.sleep(poll)
1620
+ return ""