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