setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1599 @@
1
+ # pro/remove_stars.py
2
+ from __future__ import annotations
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import stat
7
+ import tempfile
8
+ import numpy as np
9
+
10
+ from PyQt6.QtCore import QThread, pyqtSignal
11
+ from PyQt6.QtWidgets import (
12
+ QInputDialog, QMessageBox, QFileDialog,
13
+ QDialog, QVBoxLayout, QTextEdit, QPushButton,
14
+ QLabel, QComboBox, QCheckBox, QSpinBox, QFormLayout, QDialogButtonBox, QWidget, QHBoxLayout
15
+ )
16
+
17
+ # use your legacy I/O functions (as requested)
18
+ from setiastro.saspro.legacy.image_manager import save_image, load_image
19
+ import glob
20
+ try:
21
+ import cv2
22
+ except Exception:
23
+ cv2 = None
24
+
25
+ # Shared utilities
26
+ from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
27
+
28
+ _MAD_NORM = 1.4826
29
+
30
+ # --------- deterministic, invertible stretch used for StarNet ----------
31
+ # ---------- Siril-like MTF (linked) pre-stretch for StarNet ----------
32
+ def _robust_peak_sigma(gray: np.ndarray) -> tuple[float, float]:
33
+ gray = gray.astype(np.float32, copy=False)
34
+ med = float(np.median(gray))
35
+ mad = float(np.median(np.abs(gray - med)))
36
+ sigma = 1.4826 * mad if mad > 0 else float(gray.std())
37
+ # optional: refine "peak" as histogram mode around median
38
+ try:
39
+ hist, edges = np.histogram(gray, bins=2048, range=(gray.min(), gray.max()))
40
+ peak = float(0.5 * (edges[np.argmax(hist)] + edges[np.argmax(hist)+1]))
41
+ except Exception:
42
+ peak = med
43
+ return peak, max(sigma, 1e-8)
44
+
45
+ def _mtf_apply(x: np.ndarray, shadows: float, midtones: float, highlights: float) -> np.ndarray:
46
+ # x in [0, +], returns [0..1]ish given s,h
47
+ s, m, h = float(shadows), float(midtones), float(highlights)
48
+ denom = max(h - s, 1e-8)
49
+ xp = (x - s) / denom
50
+ # clamp xp to avoid crazy values
51
+ xp = np.clip(xp, 0.0, 1.0)
52
+ num = (m - 1.0) * xp
53
+ den = ((2.0 * m - 1.0) * xp) - m
54
+ y = np.divide(num, den, out=np.zeros_like(xp, dtype=np.float32), where=np.abs(den) > 1e-12)
55
+ return np.clip(y, 0.0, 1.0).astype(np.float32, copy=False)
56
+
57
+ def _mtf_inverse(y: np.ndarray, shadows: float, midtones: float, highlights: float) -> np.ndarray:
58
+ """
59
+ Pseudoinverse of MTF, matching Siril's MTF_pseudoinverse() implementation.
60
+
61
+ C reference:
62
+
63
+ float MTF_pseudoinverse(float y, struct mtf_params params) {
64
+ return ((((params.shadows + params.highlights) * params.midtones
65
+ - params.shadows) * y - params.shadows * params.midtones
66
+ + params.shadows)
67
+ / ((2 * params.midtones - 1) * y - params.midtones + 1));
68
+ }
69
+ """
70
+ s = float(shadows)
71
+ m = float(midtones)
72
+ h = float(highlights)
73
+
74
+ yp = np.clip(y.astype(np.float32, copy=False), 0.0, 1.0)
75
+
76
+ num = (((s + h) * m - s) * yp - s * m + s)
77
+ den = (2.0 * m - 1.0) * yp - m + 1.0
78
+
79
+ x = np.divide(
80
+ num,
81
+ den,
82
+ out=np.full_like(yp, s, dtype=np.float32), # fallback ~shadows if denom≈0
83
+ where=np.abs(den) > 1e-12
84
+ )
85
+
86
+ # Clamp back into [s, h] and then [0,1] for safety
87
+ x = np.clip(x, s, h)
88
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
89
+
90
+ def _mtf_params_linked(img_rgb01: np.ndarray, shadowclip_sigma: float = -2.8, targetbg: float = 0.25):
91
+ """
92
+ Compute linked (single) MTF parameters for RGB image in [0..1].
93
+ Returns dict(s=..., m=..., h=...).
94
+ """
95
+ # luminance proxy for stats
96
+ if img_rgb01.ndim == 2:
97
+ gray = img_rgb01
98
+ else:
99
+ gray = img_rgb01.mean(axis=2)
100
+ peak, sigma = _robust_peak_sigma(gray)
101
+ s = peak + shadowclip_sigma * sigma
102
+ # keep [0..1) with margin
103
+ s = float(np.clip(s, gray.min(), max(gray.max() - 1e-6, 0.0)))
104
+ h = 1.0 # Siril effectively normalizes to <=1 before 16-bit TIFF
105
+ # solve for midtones m so that mtf(xp(peak)) = targetbg
106
+ x = (peak - s) / max(h - s, 1e-8)
107
+ x = float(np.clip(x, 1e-6, 1.0 - 1e-6))
108
+ y = float(np.clip(targetbg, 1e-6, 1.0 - 1e-6))
109
+ denom = (2.0 * y * x) - y - x
110
+ m = (x * (y - 1.0)) / denom if abs(denom) > 1e-12 else 0.5
111
+ m = float(np.clip(m, 1e-4, 1.0 - 1e-4))
112
+ return {"s": s, "m": m, "h": h}
113
+
114
+ def _apply_mtf_linked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
115
+ if img_rgb01.ndim == 2:
116
+ img_rgb01 = np.stack([img_rgb01]*3, axis=-1)
117
+ y = np.empty_like(img_rgb01, dtype=np.float32)
118
+ for c in range(3):
119
+ y[..., c] = _mtf_apply(img_rgb01[..., c], p["s"], p["m"], p["h"])
120
+ return np.clip(y, 0.0, 1.0)
121
+
122
+ def _invert_mtf_linked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
123
+ y = np.empty_like(img_rgb01, dtype=np.float32)
124
+ for c in range(3):
125
+ y[..., c] = _mtf_inverse(img_rgb01[..., c], p["s"], p["m"], p["h"])
126
+ return y
127
+
128
+
129
+ def _mtf_params_unlinked(img_rgb01: np.ndarray,
130
+ shadows_clipping: float = -2.8,
131
+ targetbg: float = 0.25) -> dict:
132
+ """
133
+ Siril-style per-channel MTF parameter estimation, matching
134
+ find_unlinked_midtones_balance_default() / find_unlinked_midtones_balance().
135
+
136
+ Works on float32 data assumed in [0,1].
137
+ Returns dict with arrays: {'s': (C,), 'm': (C,), 'h': (C,)}.
138
+ """
139
+ """
140
+ Siril-style per-channel MTF parameter estimation, matching
141
+ find_unlinked_midtones_balance_default() / find_unlinked_midtones_balance().
142
+
143
+ Works on float32 data assumed in [0,1].
144
+ Returns dict with arrays: {'s': (C,), 'm': (C,), 'h': (C,)}.
145
+ """
146
+ x = np.asarray(img_rgb01, dtype=np.float32)
147
+
148
+ # Analyze input shape to handle mono efficiently
149
+ if x.ndim == 2:
150
+ # (H, W) -> treat as single channel
151
+ x_in = x[..., None] # Virtual 3D (H,W,1)
152
+ C_in = 1
153
+ elif x.ndim == 3 and x.shape[2] == 1:
154
+ x_in = x
155
+ C_in = 1
156
+ else:
157
+ x_in = x
158
+ C_in = x.shape[2]
159
+
160
+ # Vectorized stats calculation on actual data only
161
+ med = np.median(x_in, axis=(0, 1)).astype(np.float32) # shape (C_in,)
162
+
163
+ # MAD requires centered abs diff
164
+ diff = np.abs(x_in - med.reshape(1, 1, C_in))
165
+ mad_raw = np.median(diff, axis=(0, 1)).astype(np.float32) # shape (C_in,)
166
+
167
+ mad = mad_raw * _MAD_NORM
168
+ mad[mad == 0] = 0.001
169
+
170
+ inverted_flags = (med > 0.5)
171
+ # If mono, we just check the one channel. If RGB, we check all.
172
+ # Logic below assumes we return 3-channel params s,m,h even for mono input (broadcasted).
173
+
174
+ # To match original behavior which always returned 3-element arrays for s,m,h:
175
+ # We will compute s_in, m_in, h_in for the input channels, then broadcast to 3.
176
+
177
+ s_in = np.zeros(C_in, dtype=np.float32)
178
+ m_in = np.zeros(C_in, dtype=np.float32)
179
+ h_in = np.zeros(C_in, dtype=np.float32)
180
+
181
+ # We iterate C_in times (1 or 3)
182
+ for c in range(C_in):
183
+ is_inv = inverted_flags[c]
184
+ md = med[c]
185
+ md_dev = mad[c]
186
+
187
+ if not is_inv:
188
+ # Normal
189
+ c0 = max(md + shadows_clipping * md_dev, 0.0)
190
+ m2 = md - c0
191
+
192
+ s_in[c] = c0
193
+ m_in[c] = float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
194
+ h_in[c] = 1.0
195
+ else:
196
+ # Inverted
197
+ c1 = min(md - shadows_clipping * md_dev, 1.0)
198
+ m2 = c1 - md
199
+
200
+ s_in[c] = 0.0
201
+ m_in[c] = 1.0 - float(_mtf_scalar(m2, targetbg, 0.0, 1.0))
202
+ h_in[c] = c1
203
+
204
+ # Broadcast to 3 channels if needed
205
+ if C_in == 1:
206
+ s = np.repeat(s_in, 3)
207
+ m = np.repeat(m_in, 3)
208
+ h = np.repeat(h_in, 3)
209
+ else:
210
+ s = s_in
211
+ m = m_in
212
+ h = h_in
213
+
214
+ return {"s": s, "m": m, "h": h}
215
+
216
+ def _mtf_scalar(x: float, m: float, lo: float = 0.0, hi: float = 1.0) -> float:
217
+ """
218
+ Scalar midtones transfer function matching the PixInsight / Siril spec.
219
+
220
+ For x in [lo, hi], rescale to [0,1] and apply:
221
+
222
+ M(x; m) = (m - 1) * xp / ((2*m - 1)*xp - m)
223
+
224
+ with the special cases x<=lo -> 0, x>=hi -> 1.
225
+ """
226
+ # clamp to the input domain
227
+ if x <= lo:
228
+ return 0.0
229
+ if x >= hi:
230
+ return 1.0
231
+
232
+ denom_range = hi - lo
233
+ if abs(denom_range) < 1e-12:
234
+ return 0.0
235
+
236
+ xp = (x - lo) / denom_range # normalized x in [0,1]
237
+
238
+ num = (m - 1.0) * xp
239
+ den = (2.0 * m - 1.0) * xp - m
240
+
241
+ if abs(den) < 1e-12:
242
+ # the spec says M(m; m) = 0.5, but if we ever hit this numerically
243
+ # just return 0.5 as a safe fallback
244
+ return 0.5
245
+
246
+ y = num / den
247
+ # clamp to [0,1] as PI/Siril do
248
+ if y < 0.0:
249
+ y = 0.0
250
+ elif y > 1.0:
251
+ y = 1.0
252
+ return float(y)
253
+
254
+
255
+ def _apply_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
256
+ """
257
+ Apply per-channel MTF exactly. p from _mtf_params_unlinked.
258
+ """
259
+ x = np.asarray(img_rgb01, dtype=np.float32)
260
+ if x.ndim == 2:
261
+ x = np.stack([x]*3, axis=-1)
262
+ elif x.ndim == 3 and x.shape[2] == 1:
263
+ x = np.repeat(x, 3, axis=2)
264
+
265
+ out = np.empty_like(x, dtype=np.float32)
266
+ for c in range(x.shape[2]):
267
+ out[..., c] = _mtf_apply(x[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
268
+ return np.clip(out, 0.0, 1.0)
269
+
270
+
271
+ def _invert_mtf_unlinked_rgb(img_rgb01: np.ndarray, p: dict) -> np.ndarray:
272
+ """
273
+ Exact analytic inverse per channel (uses same s/m/h arrays).
274
+ """
275
+ y = np.asarray(img_rgb01, dtype=np.float32)
276
+ if y.ndim == 2:
277
+ y = np.stack([y]*3, axis=-1)
278
+ elif y.ndim == 3 and y.shape[2] == 1:
279
+ y = np.repeat(y, 3, axis=2)
280
+
281
+ out = np.empty_like(y, dtype=np.float32)
282
+ for c in range(y.shape[2]):
283
+ out[..., c] = _mtf_inverse(y[..., c], float(p["s"][c]), float(p["m"][c]), float(p["h"][c]))
284
+ return np.clip(out, 0.0, 1.0)
285
+ # ------------------------------------------------------------
286
+ # Settings helper
287
+ # ------------------------------------------------------------
288
+ def _get_setting_any(settings, keys: tuple[str, ...], default: str = "") -> str:
289
+ if not settings:
290
+ return default
291
+ for k in keys:
292
+ try:
293
+ v = settings.value(k, "", type=str)
294
+ except Exception:
295
+ v = settings.value(k, "")
296
+ if isinstance(v, str) and v.strip():
297
+ return v.strip()
298
+ return default
299
+
300
+
301
+ # ================== HEADLESS, ARRAY-IN → STARLESS-ARRAY-OUT ==================
302
+
303
+ def starnet_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="comet") -> np.ndarray:
304
+ """
305
+ Siril-style MTF round-trip for 32-bit data:
306
+
307
+ 1) Normalize to [0,1] (preserving overall scale separately)
308
+ 2) Compute unlinked MTF params per channel (Siril auto-stretch)
309
+ 3) Apply unlinked MTF -> 16-bit TIFF for StarNet
310
+ 4) StarNet -> read starless 16-bit TIFF
311
+ 5) Apply per-channel MTF pseudoinverse with SAME params
312
+ 6) Restore original scale if >1.0
313
+ """
314
+ import os
315
+ import platform
316
+ import subprocess
317
+ import numpy as np
318
+
319
+ # save_image / load_image / _get_setting_any assumed available
320
+ arr = np.asarray(arr_rgb01, dtype=np.float32)
321
+ was_single = (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1)
322
+
323
+ exe = _get_setting_any(settings, ("starnet/exe_path", "paths/starnet"), "")
324
+ if not exe or not os.path.exists(exe):
325
+ raise RuntimeError("StarNet executable not configured (settings 'paths/starnet').")
326
+
327
+ workdir = os.path.dirname(exe) or os.getcwd()
328
+ in_path = os.path.join(workdir, f"{tmp_prefix}_in.tif")
329
+ out_path = os.path.join(workdir, f"{tmp_prefix}_out.tif")
330
+
331
+ # --- Normalize input shape (virtual) and safe values ---
332
+ x_in = np.asarray(arr, dtype=np.float32)
333
+
334
+ # If (H,W,1), collapse to (H,W) so mono flows cleanly
335
+ if x_in.ndim == 3 and x_in.shape[2] == 1:
336
+ x_in = x_in[..., 0]
337
+
338
+ # sanitize
339
+ x_in = np.nan_to_num(x_in, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32, copy=False)
340
+
341
+ # Preserve original numeric scale if users pass >1.0
342
+ xmax = float(np.max(x_in)) if x_in.size else 1.0
343
+ scale_factor = xmax if xmax > 1.01 else 1.0
344
+
345
+ xin = (x_in / scale_factor) if scale_factor > 1.0 else x_in
346
+ xin = np.clip(xin, 0.0, 1.0)
347
+
348
+ # --- Siril-style unlinked MTF params + pre-stretch ---
349
+ mtf_params = _mtf_params_unlinked(xin, shadows_clipping=-2.8, targetbg=0.25)
350
+ x_for_starnet = _apply_mtf_unlinked_rgb(xin, mtf_params).astype(np.float32, copy=False)
351
+
352
+
353
+ # --- Write 16-bit TIFF for StarNet ---
354
+ save_image(
355
+ x_for_starnet, in_path,
356
+ original_format="tif", bit_depth="16-bit",
357
+ original_header=None, is_mono=False, image_meta=None, file_meta=None
358
+ )
359
+
360
+ # --- Run StarNet ---
361
+ exe_name = os.path.basename(exe).lower()
362
+ if platform.system() in ("Windows", "Linux"):
363
+ cmd = [exe, in_path, out_path, "256"]
364
+ else:
365
+ cmd = [exe, "--input", in_path, "--output", out_path] if "starnet2" in exe_name else [exe, in_path, out_path]
366
+
367
+ rc = subprocess.call(cmd, cwd=workdir)
368
+ if rc != 0 or not os.path.exists(out_path):
369
+ _safe_rm(in_path); _safe_rm(out_path)
370
+ raise RuntimeError(f"StarNet failed rc={rc}")
371
+
372
+ starless_s, _, _, _ = load_image(out_path)
373
+ _safe_rm(in_path); _safe_rm(out_path)
374
+
375
+ if starless_s.ndim == 2:
376
+ starless_s = np.stack([starless_s] * 3, axis=-1)
377
+ elif starless_s.ndim == 3 and starless_s.shape[2] == 1:
378
+ starless_s = np.repeat(starless_s, 3, axis=2)
379
+ starless_s = np.clip(starless_s.astype(np.float32, copy=False), 0.0, 1.0)
380
+
381
+ # --- Apply Siril-style pseudoinverse MTF with SAME params ---
382
+ starless_lin01 = _invert_mtf_unlinked_rgb(starless_s, mtf_params)
383
+
384
+ # Restore original scale if we normalized earlier
385
+ if scale_factor > 1.0:
386
+ starless_lin01 *= scale_factor
387
+
388
+ result = np.clip(starless_lin01, 0.0, 1.0).astype(np.float32, copy=False)
389
+
390
+ # If the source was mono, return mono
391
+ if was_single and result.ndim == 3:
392
+ result = result.mean(axis=2)
393
+
394
+ return result
395
+
396
+
397
+ def darkstar_starless_from_array(arr_rgb01: np.ndarray, settings, *, tmp_prefix="comet",
398
+ disable_gpu=False, mode="unscreen", stride=512) -> np.ndarray:
399
+ """
400
+ Save arr -> run DarkStar -> load starless -> return starless RGB float32 [0..1].
401
+ """
402
+ exe, base = _resolve_darkstar_exe(type("dummy", (), {"settings": settings}) )
403
+ if not exe or not base:
404
+ raise RuntimeError("Cosmic Clarity DarkStar executable not configured.")
405
+ arr = np.asarray(arr_rgb01, dtype=np.float32)
406
+ was_single = (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1)
407
+ input_dir = os.path.join(base, "input")
408
+ output_dir = os.path.join(base, "output")
409
+ os.makedirs(input_dir, exist_ok=True)
410
+ os.makedirs(output_dir, exist_ok=True)
411
+ _purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
412
+
413
+ in_path = os.path.join(input_dir, f"{tmp_prefix}_in.tif")
414
+ save_image(
415
+ arr, in_path,
416
+ original_format="tif", bit_depth="32-bit floating point",
417
+ original_header=None, is_mono=was_single, image_meta=None, file_meta=None
418
+ )
419
+
420
+ args = []
421
+ if disable_gpu: args.append("--disable_gpu")
422
+ args += ["--star_removal_mode", mode, "--chunk_size", str(int(stride))]
423
+ import subprocess
424
+ rc = subprocess.call([exe] + args, cwd=output_dir)
425
+ if rc != 0:
426
+ _safe_rm(in_path); raise RuntimeError(f"DarkStar failed rc={rc}")
427
+
428
+ starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
429
+ starless, _, _, _ = load_image(starless_path)
430
+ if starless is None:
431
+ _safe_rm(in_path); raise RuntimeError("DarkStar produced no starless image.")
432
+ if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
433
+ starless = np.stack([starless] * 3, axis=-1)
434
+ starless = np.clip(starless.astype(np.float32, copy=False), 0.0, 1.0)
435
+
436
+ # If the source was mono, collapse back to single channel
437
+ if was_single and starless.ndim == 3:
438
+ starless = starless.mean(axis=2)
439
+
440
+ # cleanup typical outputs
441
+ _purge_darkstar_io(base, prefix="imagetoremovestars", clear_input=True, clear_output=True)
442
+ return starless
443
+
444
+
445
+ # ------------------------------------------------------------
446
+ # Public entry
447
+ # ------------------------------------------------------------
448
+ def remove_stars(main, target_doc=None):
449
+ # block interactive UI during/just-after a headless preset run
450
+ if getattr(main, "_remove_stars_headless_running", False):
451
+ return
452
+ if getattr(main, "_remove_stars_guard", False):
453
+ return
454
+
455
+ tool, ok = QInputDialog.getItem(
456
+ main, "Select Star Removal Tool", "Choose a tool:",
457
+ ["StarNet", "CosmicClarityDarkStar"], 0, False
458
+ )
459
+ if not ok:
460
+ return
461
+
462
+ # explicit doc wins; otherwise fall back to _active_doc
463
+ doc = target_doc
464
+ if doc is None:
465
+ doc = getattr(main, "_active_doc", None)
466
+ if callable(doc):
467
+ doc = doc()
468
+
469
+ if doc is None or getattr(doc, "image", None) is None:
470
+ QMessageBox.warning(main, "No Image", "Please load an image before removing stars.")
471
+ return
472
+
473
+ if tool == "CosmicClarityDarkStar":
474
+ _run_darkstar(main, doc)
475
+ else:
476
+ _run_starnet(main, doc)
477
+
478
+
479
+
480
+
481
+ def _first_nonzero_bp_per_channel(img3: np.ndarray) -> np.ndarray:
482
+ """Per-channel minimum positive sample (0 if none)."""
483
+ bps = np.zeros(3, dtype=np.float32)
484
+ for c in range(3):
485
+ ch = img3[..., c].reshape(-1)
486
+ pos = ch[ch > 0.0]
487
+ bps[c] = float(pos.min()) if pos.size else 0.0
488
+ return bps
489
+
490
+
491
+ def _prepare_statstretch_input_for_starnet(img_rgb01: np.ndarray) -> tuple[np.ndarray, dict]:
492
+ """
493
+ Build the input to StarNet using your statistical stretch flow:
494
+ • record per-channel first-nonzero blackpoints
495
+ • subtract pedestals
496
+ • record per-channel medians
497
+ • unlinked statistical stretch to target 0.25
498
+ Returns: (stretched_for_starnet_01, meta_dict)
499
+ """
500
+ import numpy as np
501
+ from setiastro.saspro.imageops.stretch import stretch_color_image
502
+
503
+ x = np.asarray(img_rgb01, dtype=np.float32)
504
+ if x.ndim == 2:
505
+ x = np.stack([x]*3, axis=-1)
506
+ elif x.ndim == 3 and x.shape[2] == 1:
507
+ x = np.repeat(x, 3, axis=2)
508
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
509
+ x = np.clip(x, 0.0, 1.0)
510
+
511
+ # per-channel pedestal
512
+ bp = _first_nonzero_bp_per_channel(x)
513
+ xin_ped = np.clip(x - bp.reshape((1, 1, 3)), 0.0, 1.0)
514
+
515
+ # per-channel medians (after pedestal removal)
516
+ m0 = np.array([float(np.median(xin_ped[..., c])) for c in range(3)], dtype=np.float32)
517
+
518
+ # unlinked stat-stretch to 0.25
519
+ x_for_starnet = stretch_color_image(
520
+ xin_ped, target_median=0.25, linked=False,
521
+ normalize=False, apply_curves=False, curves_boost=0.0
522
+ ).astype(np.float32, copy=False)
523
+
524
+ meta = {
525
+ "statstretch": True,
526
+ "bp": bp, # pedestals we subtracted (in 0..1 domain)
527
+ "m0": m0, # per-channel original medians (post-pedestal)
528
+ }
529
+ return x_for_starnet, meta
530
+
531
+
532
+ def _inverse_statstretch_from_starless(starless_s01: np.ndarray, meta: dict) -> np.ndarray:
533
+ """
534
+ Inverse of the stat-stretch prep:
535
+ • per-channel stretch back to each original median m0[c]
536
+ • add back the saved pedestal bp[c]
537
+ Returns starless in 0..1 domain (float32).
538
+ """
539
+ import numpy as np
540
+ from setiastro.saspro.imageops.stretch import stretch_mono_image
541
+
542
+ s = np.asarray(starless_s01, dtype=np.float32)
543
+ if s.ndim == 2:
544
+ s = np.stack([s]*3, axis=-1)
545
+ elif s.ndim == 3 and s.shape[2] == 1:
546
+ s = np.repeat(s, 3, axis=2)
547
+ s = np.clip(s, 0.0, 1.0)
548
+
549
+ bp = np.asarray(meta.get("bp"), dtype=np.float32).reshape((1, 1, 3))
550
+ m0 = np.asarray(meta.get("m0"), dtype=np.float32)
551
+
552
+ out = np.empty_like(s, dtype=np.float32)
553
+ for c in range(3):
554
+ out[..., c] = stretch_mono_image(
555
+ s[..., c], target_median=float(m0[c]),
556
+ normalize=False, apply_curves=False, curves_boost=0.0
557
+ )
558
+
559
+ out = out + bp
560
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
561
+
562
+
563
+ # ------------------------------------------------------------
564
+ # StarNet (SASv2-like: 16-bit TIFF in StarNet folder)
565
+ # ------------------------------------------------------------
566
+ def _run_starnet(main, doc):
567
+ import os
568
+ import platform
569
+ import numpy as np
570
+ from PyQt6.QtWidgets import QFileDialog, QMessageBox
571
+
572
+ # --- Resolve StarNet exe, persist in settings
573
+ exe = _get_setting_any(getattr(main, "settings", None),
574
+ ("starnet/exe_path", "paths/starnet"), "")
575
+ if not exe or not os.path.exists(exe):
576
+ exe_path, _ = QFileDialog.getOpenFileName(main, "Select StarNet Executable", "", "Executable Files (*)")
577
+ if not exe_path:
578
+ return
579
+ exe = exe_path
580
+ s = getattr(main, "settings", None)
581
+ if s:
582
+ s.setValue("starnet/exe_path", exe)
583
+ s.setValue("paths/starnet", exe)
584
+
585
+ if platform.system() in ("Darwin", "Linux"):
586
+ _ensure_exec_bit(exe)
587
+
588
+ sysname = platform.system()
589
+ if sysname not in ("Windows", "Darwin", "Linux"):
590
+ QMessageBox.critical(main, "Unsupported OS",
591
+ f"The current operating system '{sysname}' is not supported.")
592
+ return
593
+
594
+ # --- Ask linearity (SASv2 behavior)
595
+ reply = QMessageBox.question(
596
+ main, "Image Linearity", "Is the current image linear?",
597
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
598
+ QMessageBox.StandardButton.No
599
+ )
600
+ is_linear = (reply == QMessageBox.StandardButton.Yes)
601
+ did_stretch = is_linear
602
+ try:
603
+ main._last_remove_stars_params = {
604
+ "engine": "StarNet",
605
+ "is_linear": bool(is_linear),
606
+ "did_stretch": bool(did_stretch),
607
+ "label": "Remove Stars (StarNet)",
608
+ }
609
+ except Exception:
610
+ pass
611
+ # 🔁 Record headless command for Replay Last
612
+ try:
613
+ main._last_headless_command = {
614
+ "command_id": "remove_stars",
615
+ "preset": {
616
+ "tool": "starnet",
617
+ "linear": bool(is_linear),
618
+ },
619
+ }
620
+ if hasattr(main, "_log"):
621
+ main._log(
622
+ f"[Replay] Recorded remove_stars (StarNet, linear="
623
+ f"{'yes' if is_linear else 'no'})"
624
+ )
625
+ except Exception:
626
+ pass
627
+ # --- Ensure RGB float32 in safe range (without expanding yet)
628
+ # Starnet needs RGB eventually, but we can compute stats/normalization on mono
629
+ src = np.asarray(doc.image)
630
+ if src.ndim == 3 and src.shape[2] == 1:
631
+ # standardizing shape is cheap
632
+ processing_image = src[..., 0]
633
+ else:
634
+ processing_image = src
635
+
636
+ processing_image = np.nan_to_num(processing_image.astype(np.float32, copy=False),
637
+ nan=0.0, posinf=0.0, neginf=0.0)
638
+
639
+ # --- Scale normalization if >1.0
640
+ scale_factor = float(np.max(processing_image))
641
+ if scale_factor > 1.0:
642
+ processing_norm = processing_image / scale_factor
643
+ else:
644
+ processing_norm = processing_image
645
+
646
+ # --- Build input/output paths
647
+ starnet_dir = os.path.dirname(exe) or os.getcwd()
648
+ input_image_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
649
+ output_image_path = os.path.join(starnet_dir, "starless.tif")
650
+
651
+ # --- Prepare input for StarNet (Siril-style MTF pre-stretch for linear data) ---
652
+ img_for_starnet = processing_norm
653
+ if is_linear:
654
+ # Siril-style unlinked MTF params from linear normalized image
655
+ mtf_params = _mtf_params_unlinked(processing_norm, shadows_clipping=-2.8, targetbg=0.25)
656
+ img_for_starnet = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
657
+
658
+ # 🔐 Stash EXACT params for inverse step later
659
+ try:
660
+ setattr(main, "_starnet_stat_meta", {
661
+ "scheme": "siril_mtf",
662
+ "s": np.asarray(mtf_params["s"], dtype=np.float32),
663
+ "m": np.asarray(mtf_params["m"], dtype=np.float32),
664
+ "h": np.asarray(mtf_params["h"], dtype=np.float32),
665
+ "scale": float(scale_factor),
666
+ })
667
+ except Exception:
668
+ pass
669
+ else:
670
+ # non-linear: do not try to invert any pre-stretch later
671
+ if hasattr(main, "_starnet_stat_meta"):
672
+ delattr(main, "_starnet_stat_meta")
673
+
674
+
675
+ # --- Write TIFF for StarNet
676
+ try:
677
+ save_image(img_for_starnet, input_image_path,
678
+ original_format="tif", bit_depth="16-bit",
679
+ original_header=None, is_mono=False, image_meta=None, file_meta=None)
680
+ except Exception as e:
681
+ QMessageBox.critical(main, "StarNet", f"Failed to write input TIFF:\n{e}")
682
+ return
683
+
684
+ # --- Launch StarNet in a worker (keeps your progress dialog)
685
+ exe_name = os.path.basename(exe).lower()
686
+ if sysname in ("Windows", "Linux"):
687
+ command = [exe, input_image_path, output_image_path, "256"]
688
+ else: # macOS
689
+ if "starnet2" in exe_name:
690
+ command = [exe, "--input", input_image_path, "--output", output_image_path]
691
+ else:
692
+ command = [exe, input_image_path, output_image_path]
693
+
694
+ dlg = _ProcDialog(main, title="StarNet Progress")
695
+ thr = _ProcThread(command, cwd=starnet_dir)
696
+ thr.output_signal.connect(dlg.append_text)
697
+
698
+ # Capture everything we need in the closure for finish handler
699
+ thr.finished_signal.connect(
700
+ lambda rc, ds=did_stretch: _on_starnet_finished(
701
+ main, doc, rc, dlg, input_image_path, output_image_path, ds
702
+ )
703
+ )
704
+ dlg.cancel_button.clicked.connect(thr.cancel)
705
+
706
+ dlg.show()
707
+ thr.start()
708
+ dlg.exec()
709
+
710
+
711
+ def _on_starnet_finished(main, doc, return_code, dialog, input_path, output_path, did_stretch):
712
+ import os
713
+ import numpy as np
714
+ from PyQt6.QtWidgets import QMessageBox
715
+ from setiastro.saspro.imageops.stretch import stretch_mono_image # used for statistical inverse
716
+
717
+ def _first_nonzero_bp_per_channel(img3: np.ndarray) -> np.ndarray:
718
+ bps = np.zeros(3, dtype=np.float32)
719
+ for c in range(3):
720
+ ch = img3[..., c].reshape(-1)
721
+ pos = ch[ch > 0.0]
722
+ bps[c] = float(pos.min()) if pos.size else 0.0
723
+ return bps
724
+
725
+ dialog.append_text(f"\nProcess finished with return code {return_code}.\n")
726
+ if return_code != 0:
727
+ QMessageBox.critical(main, "StarNet Error", f"StarNet failed with return code {return_code}.")
728
+ _safe_rm(input_path); _safe_rm(output_path)
729
+ dialog.close()
730
+ return
731
+
732
+ if not os.path.exists(output_path):
733
+ QMessageBox.critical(main, "StarNet Error", "Starless image was not created.")
734
+ _safe_rm(input_path)
735
+ dialog.close()
736
+ return
737
+
738
+ dialog.append_text(f"Starless image found at {output_path}. Loading image...\n")
739
+ starless_rgb, _, _, _ = load_image(output_path)
740
+ _safe_rm(input_path); _safe_rm(output_path)
741
+
742
+ if starless_rgb is None:
743
+ QMessageBox.critical(main, "StarNet Error", "Failed to load starless image.")
744
+ dialog.close()
745
+ return
746
+
747
+ # ensure 3ch float32 in [0..1]
748
+ if starless_rgb.ndim == 2:
749
+ starless_rgb = np.stack([starless_rgb] * 3, axis=-1)
750
+ elif starless_rgb.ndim == 3 and starless_rgb.shape[2] == 1:
751
+ starless_rgb = np.repeat(starless_rgb, 3, axis=2)
752
+ starless_rgb = np.clip(starless_rgb.astype(np.float32, copy=False), 0.0, 1.0)
753
+
754
+ # original image (from the doc) as 3ch float32, track if it was mono
755
+ orig = np.asarray(doc.image)
756
+ if orig.ndim == 2:
757
+ original_rgb = np.stack([orig] * 3, axis=-1)
758
+ orig_was_mono = True
759
+ elif orig.ndim == 3 and orig.shape[2] == 1:
760
+ original_rgb = np.repeat(orig, 3, axis=2)
761
+ orig_was_mono = True
762
+ else:
763
+ original_rgb = orig
764
+ orig_was_mono = False
765
+ original_rgb = original_rgb.astype(np.float32, copy=False)
766
+
767
+
768
+ # ---- Inversion back to the document’s domain ----
769
+ if did_stretch:
770
+ # Prefer the new Siril-style MTF meta if present
771
+ meta = getattr(main, "_starnet_stat_meta", None)
772
+ mtf_params_legacy = getattr(main, "_starnet_last_mtf_params", None)
773
+
774
+ if isinstance(meta, dict) and meta.get("scheme") == "siril_mtf":
775
+ dialog.append_text("Unstretching (Siril-style MTF pseudoinverse)...\n")
776
+ try:
777
+ s_vec = np.asarray(meta.get("s"), dtype=np.float32)
778
+ m_vec = np.asarray(meta.get("m"), dtype=np.float32)
779
+ h_vec = np.asarray(meta.get("h"), dtype=np.float32)
780
+ scale_factor = float(meta.get("scale", 1.0))
781
+
782
+ p = {"s": s_vec, "m": m_vec, "h": h_vec}
783
+ inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
784
+
785
+ if scale_factor > 1.0:
786
+ inv = inv * scale_factor
787
+
788
+ starless_rgb = np.clip(inv, 0.0, 1.0)
789
+ except Exception as e:
790
+ dialog.append_text(f"⚠️ Siril-style MTF inverse failed: {e}\n")
791
+
792
+ elif isinstance(meta, dict) and meta.get("scheme") == "statstretch":
793
+ # Back-compat: statistical round-trip with bp/m0
794
+ dialog.append_text("Unstretching (statistical inverse w/ original BP/M0)...\n")
795
+
796
+ bp_vec = np.asarray(meta.get("bp"), dtype=np.float32)
797
+ m0_vec = np.asarray(meta.get("m0"), dtype=np.float32)
798
+ scale_factor = float(meta.get("scale", 1.0))
799
+
800
+ inv = np.empty_like(starless_rgb, dtype=np.float32)
801
+ for c in range(3):
802
+ inv[..., c] = stretch_mono_image(
803
+ starless_rgb[..., c],
804
+ target_median=float(m0_vec[c]),
805
+ normalize=False, apply_curves=False, curves_boost=0.0
806
+ )
807
+
808
+ inv += bp_vec.reshape((1, 1, 3))
809
+ inv = np.clip(inv, 0.0, 1.0)
810
+ if scale_factor > 1.0:
811
+ inv *= scale_factor
812
+ starless_rgb = np.clip(inv, 0.0, 1.0)
813
+
814
+ elif mtf_params_legacy:
815
+ # Very old MTF path (linked, single triple) – keep for safety
816
+ dialog.append_text("Unstretching (legacy MTF inverse)...\n")
817
+ try:
818
+ starless_rgb = _invert_mtf_linked_rgb(starless_rgb, mtf_params_legacy)
819
+ sc = float(mtf_params_legacy.get("scale", 1.0))
820
+ if sc > 1.0:
821
+ starless_rgb = starless_rgb * sc
822
+ except Exception as e:
823
+ dialog.append_text(f"⚠️ Legacy MTF inverse failed: {e}\n")
824
+ starless_rgb = np.clip(starless_rgb, 0.0, 1.0)
825
+
826
+ # Clean up stashed meta so it can't leak to future ops
827
+ try:
828
+ if hasattr(main, "_starnet_stat_meta"):
829
+ delattr(main, "_starnet_stat_meta")
830
+ except Exception:
831
+ pass
832
+
833
+
834
+
835
+ # ---- Stars-Only = original − starless (linear-domain diff) ----
836
+ dialog.append_text("Generating stars-only image...\n")
837
+ stars_only = np.clip(original_rgb - starless_rgb, 0.0, 1.0)
838
+
839
+ # apply active mask (doc-based)
840
+ m3 = _active_mask3_from_doc(doc, stars_only.shape[1], stars_only.shape[0])
841
+ if m3 is not None:
842
+ stars_only *= m3
843
+ dialog.append_text("✅ Applied active mask to the stars-only image.\n")
844
+ else:
845
+ dialog.append_text("ℹ️ No active mask for stars-only; skipping.\n")
846
+
847
+ # If the original doc was mono, return a mono stars-only image
848
+ if orig_was_mono:
849
+ stars_to_push = stars_only.mean(axis=2).astype(np.float32, copy=False)
850
+ else:
851
+ stars_to_push = stars_only
852
+
853
+ # push Stars-Only as new document with suffix _stars
854
+ _push_as_new_doc(main, doc, stars_to_push, title_suffix="_stars", source="Stars-Only (StarNet)")
855
+ dialog.append_text("Stars-only image pushed.\n")
856
+
857
+ # mask-blend starless with original using active mask, then overwrite current view
858
+ dialog.append_text("Preparing to update current view with starless (mask-blend)...\n")
859
+ final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
860
+
861
+ # If the original doc was mono, collapse back to single-channel
862
+ if orig_was_mono:
863
+ final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
864
+ else:
865
+ final_to_apply = final_starless.astype(np.float32, copy=False)
866
+
867
+ try:
868
+ meta = {
869
+ "step_name": "Stars Removed",
870
+ "bit_depth": "32-bit floating point",
871
+ "is_mono": bool(orig_was_mono),
872
+ }
873
+
874
+ # 🔹 Attach replay-last metadata
875
+ rp = getattr(main, "_last_remove_stars_params", None)
876
+ if isinstance(rp, dict):
877
+ replay_params = dict(rp) # shallow copy so we don't mutate the stored one
878
+ else:
879
+ replay_params = {
880
+ "engine": "StarNet",
881
+ "is_linear": bool(did_stretch),
882
+ "did_stretch": bool(did_stretch),
883
+ "label": "Remove Stars (StarNet)",
884
+ }
885
+
886
+ replay_params.setdefault("engine", "StarNet")
887
+ replay_params.setdefault("label", "Remove Stars (StarNet)")
888
+
889
+ meta["replay_last"] = {
890
+ "op": "remove_stars",
891
+ "params": replay_params,
892
+ }
893
+
894
+ # Clean up the stash so it can't leak to the next unrelated op
895
+ try:
896
+ if hasattr(main, "_last_remove_stars_params"):
897
+ delattr(main, "_last_remove_stars_params")
898
+ except Exception:
899
+ pass
900
+
901
+ doc.apply_edit(
902
+ final_to_apply,
903
+ metadata=meta,
904
+ step_name="Stars Removed"
905
+ )
906
+ if hasattr(main, "_log"):
907
+ main._log("Stars Removed (StarNet)")
908
+ except Exception as e:
909
+ QMessageBox.critical(main, "StarNet Error", f"Failed to apply starless result:\n{e}")
910
+
911
+ dialog.append_text("Temporary files cleaned up.\n")
912
+ dialog.close()
913
+
914
+
915
+
916
+ # ------------------------------------------------------------
917
+ # CosmicClarityDarkStar
918
+ # ------------------------------------------------------------
919
+ def _run_darkstar(main, doc):
920
+ exe, base = _resolve_darkstar_exe(main)
921
+ if not exe or not base:
922
+ QMessageBox.critical(main, "Cosmic Clarity Folder Error",
923
+ "Cosmic Clarity Dark Star executable not set.")
924
+ return
925
+
926
+ # --- Input/output folders per SASv2 ---
927
+ input_dir = os.path.join(base, "input")
928
+ output_dir = os.path.join(base, "output")
929
+ os.makedirs(input_dir, exist_ok=True)
930
+ os.makedirs(output_dir, exist_ok=True)
931
+ _purge_darkstar_io(base, prefix=None, clear_input=True, clear_output=True)
932
+
933
+ # --- Config dialog (same as before) ---
934
+ cfg = DarkStarConfigDialog(main)
935
+ if not cfg.exec():
936
+ return
937
+ params = cfg.get_values()
938
+ disable_gpu = params["disable_gpu"]
939
+ mode = params["mode"] # "unscreen" or "additive"
940
+ show_extracted_stars = params["show_extracted_stars"]
941
+ stride = params["stride"] # 64..1024, default 512
942
+
943
+ # 🔹 Ask if image is linear (so we know whether to MTF-prestretch)
944
+ reply = QMessageBox.question(
945
+ main, "Image Linearity", "Is the current image linear?",
946
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
947
+ QMessageBox.StandardButton.Yes
948
+ )
949
+ is_linear = (reply == QMessageBox.StandardButton.Yes)
950
+ did_prestretch = is_linear
951
+
952
+ # 🔹 Stash parameters for replay-last
953
+ try:
954
+ main._last_remove_stars_params = {
955
+ "engine": "CosmicClarityDarkStar",
956
+ "disable_gpu": bool(disable_gpu),
957
+ "mode": mode,
958
+ "show_extracted_stars": bool(show_extracted_stars),
959
+ "stride": int(stride),
960
+ "is_linear": bool(is_linear),
961
+ "did_prestretch": bool(did_prestretch),
962
+ "label": "Remove Stars (DarkStar)",
963
+ }
964
+ except Exception:
965
+ pass
966
+
967
+ # 🔁 Record headless command for Replay Last
968
+ try:
969
+ main._last_headless_command = {
970
+ "command_id": "remove_stars",
971
+ "preset": {
972
+ "tool": "darkstar",
973
+ "disable_gpu": bool(disable_gpu),
974
+ "mode": mode,
975
+ "show_extracted_stars": bool(show_extracted_stars),
976
+ "stride": int(stride),
977
+ "is_linear": bool(is_linear),
978
+ "did_prestretch": bool(did_prestretch),
979
+ },
980
+ }
981
+ if hasattr(main, "_log"):
982
+ main._log(
983
+ "[Replay] Recorded remove_stars (DarkStar, "
984
+ f"mode={mode}, stride={int(stride)}, "
985
+ f"gpu={'off' if disable_gpu else 'on'}, "
986
+ f"stars={'on' if show_extracted_stars else 'off'}, "
987
+ f"linear={'yes' if is_linear else 'no'})"
988
+ )
989
+ except Exception:
990
+ pass
991
+
992
+ # --- Build processing image (RGB float32, normalized) ---
993
+ # DarkStar needs RGB, but we can delay expansion until save
994
+ src = np.asarray(doc.image)
995
+ if src.ndim == 3 and src.shape[2] == 1:
996
+ processing_image = src[..., 0]
997
+ else:
998
+ processing_image = src
999
+
1000
+ processing_image = np.nan_to_num(
1001
+ processing_image.astype(np.float32, copy=False),
1002
+ nan=0.0, posinf=0.0, neginf=0.0
1003
+ )
1004
+
1005
+ scale_factor = float(np.max(processing_image)) if processing_image.size else 1.0
1006
+ if scale_factor > 1.0:
1007
+ processing_norm = processing_image / scale_factor
1008
+ else:
1009
+ processing_norm = processing_image
1010
+ processing_norm = np.clip(processing_norm, 0.0, 1.0)
1011
+
1012
+ # --- Optional Siril-style MTF pre-stretch for linear data ---
1013
+ img_for_darkstar = processing_norm
1014
+ if is_linear:
1015
+ try:
1016
+ mtf_params = _mtf_params_unlinked(
1017
+ processing_norm,
1018
+ shadows_clipping=-2.8,
1019
+ targetbg=0.25
1020
+ )
1021
+ img_for_darkstar = _apply_mtf_unlinked_rgb(processing_norm, mtf_params)
1022
+
1023
+ # 🔐 Stash EXACT params for inverse step later
1024
+ setattr(main, "_darkstar_mtf_meta", {
1025
+ "s": np.asarray(mtf_params["s"], dtype=np.float32),
1026
+ "m": np.asarray(mtf_params["m"], dtype=np.float32),
1027
+ "h": np.asarray(mtf_params["h"], dtype=np.float32),
1028
+ "scale": float(scale_factor),
1029
+ })
1030
+ if hasattr(main, "_log"):
1031
+ main._log("[DarkStar] Applying Siril-style MTF pre-stretch for linear image.")
1032
+ except Exception as e:
1033
+ # If anything goes wrong, fall back to un-stretched normalized image
1034
+ img_for_darkstar = processing_norm
1035
+ try:
1036
+ if hasattr(main, "_darkstar_mtf_meta"):
1037
+ delattr(main, "_darkstar_mtf_meta")
1038
+ except Exception:
1039
+ pass
1040
+ if hasattr(main, "_log"):
1041
+ main._log(f"[DarkStar] MTF pre-stretch failed, using normalized image only: {e}")
1042
+ else:
1043
+ # Non-linear: don't store any pre-stretch meta
1044
+ try:
1045
+ if hasattr(main, "_darkstar_mtf_meta"):
1046
+ delattr(main, "_darkstar_mtf_meta")
1047
+ except Exception:
1048
+ pass
1049
+
1050
+ # --- Save pre-stretched image as 32-bit float TIFF for DarkStar ---
1051
+ in_path = os.path.join(input_dir, "imagetoremovestars.tif")
1052
+ try:
1053
+ # Check if we need to expand on-the-fly for DarkStar (it expects RGB input)
1054
+ # If img_for_darkstar is mono, save_image might save mono.
1055
+ # "is_mono=False" flag to save_image hints we want RGB.
1056
+ # If the array is 2D, save_image might still save mono unless we feed it 3D.
1057
+ # For safety with DarkStar, we create the 3D view now if needed.
1058
+
1059
+ to_save = img_for_darkstar
1060
+ if to_save.ndim == 2:
1061
+ to_save = np.stack([to_save]*3, axis=-1)
1062
+ elif to_save.ndim == 3 and to_save.shape[2] == 1:
1063
+ to_save = np.repeat(to_save, 3, axis=2)
1064
+
1065
+ save_image(
1066
+ to_save,
1067
+ in_path,
1068
+ original_format="tif",
1069
+ bit_depth="32-bit floating point",
1070
+ original_header=None,
1071
+ is_mono=False, # we always send RGB to DarkStar
1072
+ image_meta=None,
1073
+ file_meta=None
1074
+ )
1075
+ except Exception as e:
1076
+ QMessageBox.critical(main, "Cosmic Clarity", f"Failed to write input TIFF:\n{e}")
1077
+ return
1078
+
1079
+ # --- Build CLI exactly like SASv2 (using --chunk_size, not chunk_size) ---
1080
+ args = []
1081
+ if disable_gpu:
1082
+ args.append("--disable_gpu")
1083
+ args += ["--star_removal_mode", mode]
1084
+ if show_extracted_stars:
1085
+ args.append("--show_extracted_stars")
1086
+ args += ["--chunk_size", str(stride)]
1087
+
1088
+ command = [exe] + args
1089
+
1090
+ dlg = _ProcDialog(main, title="CosmicClarityDarkStar Progress")
1091
+ thr = _ProcThread(command, cwd=output_dir)
1092
+ thr.output_signal.connect(dlg.append_text)
1093
+ thr.finished_signal.connect(
1094
+ lambda rc, base=base, ds=did_prestretch: _on_darkstar_finished(
1095
+ main, doc, rc, dlg, in_path, output_dir, base, ds
1096
+ )
1097
+ )
1098
+ dlg.cancel_button.clicked.connect(thr.cancel)
1099
+
1100
+ dlg.show()
1101
+ thr.start()
1102
+ dlg.exec()
1103
+
1104
+
1105
+
1106
+
1107
+ def _resolve_darkstar_exe(main):
1108
+ """
1109
+ Return (exe_path, base_folder) or (None, None) on cancel/error.
1110
+ Accepts either a folder (stored) or a direct executable path.
1111
+ Saves the folder back to QSettings under 'paths/cosmic_clarity'.
1112
+ """
1113
+ settings = getattr(main, "settings", None)
1114
+ raw = _get_setting_any(settings, ("paths/cosmic_clarity", "cosmic_clarity_folder"), "")
1115
+
1116
+ def _platform_exe_name():
1117
+ return "setiastrocosmicclarity_darkstar.exe" if platform.system() == "Windows" \
1118
+ else "setiastrocosmicclarity_darkstar"
1119
+
1120
+ exe_name = _platform_exe_name()
1121
+
1122
+ exe_path = None
1123
+ base_folder = None
1124
+
1125
+ if raw:
1126
+ if os.path.isfile(raw):
1127
+ # user stored the executable path directly
1128
+ exe_path = raw
1129
+ base_folder = os.path.dirname(raw)
1130
+ elif os.path.isdir(raw):
1131
+ # user stored the parent folder
1132
+ base_folder = raw
1133
+ exe_path = os.path.join(base_folder, exe_name)
1134
+
1135
+ # if missing or invalid, let user pick the executable directly
1136
+ if not exe_path or not os.path.exists(exe_path):
1137
+ picked, _ = QFileDialog.getOpenFileName(main, "Select CosmicClarityDarkStar Executable", "", "Executable Files (*)")
1138
+ if not picked:
1139
+ return None, None
1140
+ exe_path = picked
1141
+ base_folder = os.path.dirname(picked)
1142
+
1143
+ # ensure exec bit on POSIX
1144
+ if platform.system() in ("Darwin", "Linux"):
1145
+ _ensure_exec_bit(exe_path)
1146
+
1147
+ # persist folder (not the exe) to the canonical key
1148
+ if settings:
1149
+ settings.setValue("paths/cosmic_clarity", base_folder)
1150
+ settings.sync()
1151
+
1152
+ return exe_path, base_folder
1153
+
1154
+
1155
+ def _on_darkstar_finished(main, doc, return_code, dialog, in_path, output_dir, base_folder, did_prestretch):
1156
+ dialog.append_text(f"\nProcess finished with return code {return_code}.\n")
1157
+ if return_code != 0:
1158
+ QMessageBox.critical(main, "CosmicClarityDarkStar Error",
1159
+ f"CosmicClarityDarkStar failed with return code {return_code}.")
1160
+ _safe_rm(in_path); dialog.close(); return
1161
+
1162
+ starless_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
1163
+ if not os.path.exists(starless_path):
1164
+ QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Starless image was not created.")
1165
+ _safe_rm(in_path); dialog.close(); return
1166
+
1167
+ dialog.append_text(f"Loading starless image from {starless_path}...\n")
1168
+ starless, _, _, _ = load_image(starless_path)
1169
+ if starless is None:
1170
+ QMessageBox.critical(main, "CosmicClarityDarkStar Error", "Failed to load starless image.")
1171
+ _safe_rm(in_path); dialog.close(); return
1172
+
1173
+ if starless.ndim == 2 or (starless.ndim == 3 and starless.shape[2] == 1):
1174
+ starless_rgb = np.stack([starless] * 3, axis=-1)
1175
+ else:
1176
+ starless_rgb = starless
1177
+ starless_rgb = starless_rgb.astype(np.float32, copy=False)
1178
+
1179
+ src = np.asarray(doc.image)
1180
+ if src.ndim == 2:
1181
+ original_rgb = np.stack([src] * 3, axis=-1)
1182
+ orig_was_mono = True
1183
+ elif src.ndim == 3 and src.shape[2] == 1:
1184
+ original_rgb = np.repeat(src, 3, axis=2)
1185
+ orig_was_mono = True
1186
+ else:
1187
+ original_rgb = src
1188
+ orig_was_mono = False
1189
+ original_rgb = original_rgb.astype(np.float32, copy=False)
1190
+
1191
+ # --- Undo the MTF pre-stretch (if we did one) ---
1192
+ if did_prestretch:
1193
+ meta = getattr(main, "_darkstar_mtf_meta", None)
1194
+ if isinstance(meta, dict):
1195
+ dialog.append_text("Unstretching starless result (DarkStar MTF inverse)...\n")
1196
+ try:
1197
+ s_vec = np.asarray(meta.get("s"), dtype=np.float32)
1198
+ m_vec = np.asarray(meta.get("m"), dtype=np.float32)
1199
+ h_vec = np.asarray(meta.get("h"), dtype=np.float32)
1200
+ scale = float(meta.get("scale", 1.0))
1201
+
1202
+ p = {"s": s_vec, "m": m_vec, "h": h_vec}
1203
+ inv = _invert_mtf_unlinked_rgb(starless_rgb, p)
1204
+
1205
+ if scale > 1.0:
1206
+ inv *= scale
1207
+
1208
+ starless_rgb = np.clip(inv, 0.0, 1.0)
1209
+ except Exception as e:
1210
+ dialog.append_text(f"⚠️ DarkStar MTF inverse failed: {e}\n")
1211
+
1212
+ # Clean up pre-stretch meta so it can't leak into another op
1213
+ try:
1214
+ if hasattr(main, "_darkstar_mtf_meta"):
1215
+ delattr(main, "_darkstar_mtf_meta")
1216
+ except Exception:
1217
+ pass
1218
+
1219
+ # --- stars-only optional push (as before) ---
1220
+ stars_path = os.path.join(output_dir, "imagetoremovestars_stars_only.tif")
1221
+ if os.path.exists(stars_path):
1222
+ dialog.append_text(f"Loading stars-only image from {stars_path}...\n")
1223
+ stars_only, _, _, _ = load_image(stars_path)
1224
+ if stars_only is not None:
1225
+ if stars_only.ndim == 2 or (stars_only.ndim == 3 and stars_only.shape[2] == 1):
1226
+ stars_only = np.stack([stars_only] * 3, axis=-1)
1227
+ stars_only = stars_only.astype(np.float32, copy=False)
1228
+ m3 = _active_mask3_from_doc(doc, stars_only.shape[1], stars_only.shape[0])
1229
+ if m3 is not None:
1230
+ stars_only *= m3
1231
+ dialog.append_text("✅ Applied active mask to stars-only image.\n")
1232
+ else:
1233
+ dialog.append_text("ℹ️ Mask not active for stars-only; skipping.\n")
1234
+
1235
+ # If the original doc was mono, collapse stars-only back to single channel
1236
+ if orig_was_mono:
1237
+ stars_to_push = stars_only.mean(axis=2).astype(np.float32, copy=False)
1238
+ else:
1239
+ stars_to_push = stars_only
1240
+
1241
+ _push_as_new_doc(main, doc, stars_to_push, title_suffix="_stars", source="Stars-Only (DarkStar)")
1242
+ else:
1243
+ dialog.append_text("Failed to load stars-only image.\n")
1244
+ else:
1245
+ dialog.append_text("No stars-only image generated.\n")
1246
+
1247
+ # --- Mask-blend starless → overwrite current doc (in original domain) ---
1248
+ dialog.append_text("Mask-blending starless image before update...\n")
1249
+ final_starless = _mask_blend_with_doc_mask(doc, starless_rgb, original_rgb)
1250
+
1251
+ # If the original doc was mono, collapse back to single-channel
1252
+ if orig_was_mono:
1253
+ final_to_apply = final_starless.mean(axis=2).astype(np.float32, copy=False)
1254
+ else:
1255
+ final_to_apply = final_starless.astype(np.float32, copy=False)
1256
+
1257
+ try:
1258
+ meta = {
1259
+ "step_name": "Stars Removed",
1260
+ "bit_depth": "32-bit floating point",
1261
+ "is_mono": bool(orig_was_mono),
1262
+ }
1263
+
1264
+ # 🔹 Attach replay-last metadata
1265
+ rp = getattr(main, "_last_remove_stars_params", None)
1266
+ if isinstance(rp, dict):
1267
+ replay_params = dict(rp)
1268
+ else:
1269
+ replay_params = {
1270
+ "engine": "CosmicClarityDarkStar",
1271
+ "label": "Remove Stars (DarkStar)",
1272
+ }
1273
+
1274
+ replay_params.setdefault("engine", "CosmicClarityDarkStar")
1275
+ replay_params.setdefault("label", "Remove Stars (DarkStar)")
1276
+
1277
+ meta["replay_last"] = {
1278
+ "op": "remove_stars",
1279
+ "params": replay_params,
1280
+ }
1281
+
1282
+ # Clean up stash
1283
+ try:
1284
+ if hasattr(main, "_last_remove_stars_params"):
1285
+ delattr(main, "_last_remove_stars_params")
1286
+ except Exception:
1287
+ pass
1288
+
1289
+ doc.apply_edit(
1290
+ final_to_apply,
1291
+ metadata=meta,
1292
+ step_name="Stars Removed"
1293
+ )
1294
+ if hasattr(main, "_log"):
1295
+ main._log("Stars Removed (DarkStar)")
1296
+ except Exception as e:
1297
+ QMessageBox.critical(main, "CosmicClarityDarkStar", f"Failed to apply result:\n{e}")
1298
+
1299
+ # --- cleanup ---
1300
+ try:
1301
+ _safe_rm(in_path)
1302
+ _safe_rm(starless_path)
1303
+ _safe_rm(os.path.join(output_dir, "imagetoremovestars_stars_only.tif"))
1304
+
1305
+ # 🔸 Final sweep: nuke any imagetoremovestars* leftovers in both dirs
1306
+ base_folder = os.path.dirname(output_dir) # <-- derive CC base from output_dir
1307
+ _purge_darkstar_io(base_folder, prefix="imagetoremovestars", clear_input=True, clear_output=True)
1308
+
1309
+ dialog.append_text("Temporary files cleaned up.\n")
1310
+ except Exception as e:
1311
+ dialog.append_text(f"Cleanup error: {e}\n")
1312
+
1313
+ dialog.close()
1314
+
1315
+
1316
+ # ------------------------------------------------------------
1317
+ # Mask helpers (doc-centric)
1318
+ # ------------------------------------------------------------
1319
+ # _active_mask_array_from_doc is now imported from setiastro.saspro.widgets.image_utils
1320
+
1321
+
1322
+ def _active_mask3_from_doc(doc, w, h) -> np.ndarray | None:
1323
+ """Return 3-channel mask resized to (h,w) if a doc-level mask exists; else None."""
1324
+ m = _active_mask_array_from_doc(doc)
1325
+ if m is None:
1326
+ return None
1327
+ if m.shape != (h, w):
1328
+ if cv2 is not None:
1329
+ m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
1330
+ else:
1331
+ yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
1332
+ xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
1333
+ m = m[yi][:, xi]
1334
+ return np.repeat(m[:, :, None], 3, axis=2).astype(np.float32, copy=False)
1335
+
1336
+
1337
+ def _mask_blend_with_doc_mask(doc, starless_rgb: np.ndarray, original_rgb: np.ndarray) -> np.ndarray:
1338
+ """Blend using mask from doc if present: result = starless*m + original*(1-m)."""
1339
+ m = _active_mask_array_from_doc(doc)
1340
+ if m is None:
1341
+ return starless_rgb
1342
+ h, w = starless_rgb.shape[:2]
1343
+ if m.shape != (h, w):
1344
+ if cv2 is not None:
1345
+ m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
1346
+ else:
1347
+ yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
1348
+ xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
1349
+ m = m[yi][:, xi]
1350
+ m3 = np.repeat(m[:, :, None], 3, axis=2)
1351
+ return np.clip(starless_rgb * m3 + original_rgb * (1.0 - m3), 0.0, 1.0).astype(np.float32, copy=False)
1352
+
1353
+
1354
+ def _derive_view_base_title(main, doc) -> str:
1355
+ """
1356
+ Prefer the active view's title (respecting per-view rename/override),
1357
+ fallback to the document display name, then to doc.name, and finally 'Image'.
1358
+ Also strips any decorations (mask glyph, 'Active View:' prefix) if available.
1359
+ """
1360
+ # 1) Ask main for a subwindow for this document, if it exposes a helper
1361
+ try:
1362
+ if hasattr(main, "_subwindow_for_document"):
1363
+ sw = main._subwindow_for_document(doc)
1364
+ if sw:
1365
+ w = sw.widget() if hasattr(sw, "widget") else sw
1366
+ # Preferred: view's effective title (includes per-view override)
1367
+ if hasattr(w, "_effective_title"):
1368
+ t = w._effective_title() or ""
1369
+ else:
1370
+ t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
1371
+ if hasattr(w, "_strip_decorations"):
1372
+ t, _ = w._strip_decorations(t)
1373
+ if t.strip():
1374
+ return t.strip()
1375
+ except Exception:
1376
+ pass
1377
+
1378
+ # 2) Try scanning MDI for a subwindow whose widget holds this document
1379
+ try:
1380
+ mdi = (getattr(main, "mdi_area", None)
1381
+ or getattr(main, "mdiArea", None)
1382
+ or getattr(main, "mdi", None))
1383
+ if mdi and hasattr(mdi, "subWindowList"):
1384
+ for sw in mdi.subWindowList():
1385
+ w = sw.widget()
1386
+ if getattr(w, "document", None) is doc:
1387
+ t = sw.windowTitle() if hasattr(sw, "windowTitle") else ""
1388
+ if hasattr(w, "_strip_decorations"):
1389
+ t, _ = w._strip_decorations(t)
1390
+ if t.strip():
1391
+ return t.strip()
1392
+ except Exception:
1393
+ pass
1394
+
1395
+ # 3) Fallback to document's display name (then name, then generic)
1396
+ try:
1397
+ if hasattr(doc, "display_name"):
1398
+ t = doc.display_name()
1399
+ if t and t.strip():
1400
+ return t.strip()
1401
+ except Exception:
1402
+ pass
1403
+ return (getattr(doc, "name", "") or "Image").strip()
1404
+
1405
+
1406
+ # ------------------------------------------------------------
1407
+ # New document helper
1408
+ # ------------------------------------------------------------
1409
+ def _push_as_new_doc(main, doc, arr: np.ndarray, title_suffix="_stars", source="Stars-Only"):
1410
+ dm = getattr(main, "docman", None)
1411
+ if not dm or not hasattr(dm, "open_array"):
1412
+ return
1413
+ try:
1414
+ # Use the current view's title if available (respects per-view rename)
1415
+ base = _derive_view_base_title(main, doc)
1416
+
1417
+ # avoid double-suffix if user already named it with the suffix
1418
+ if title_suffix and base.endswith(title_suffix):
1419
+ title = base
1420
+ else:
1421
+ title = f"{base}{title_suffix}"
1422
+
1423
+ meta = {
1424
+ "bit_depth": "32-bit floating point",
1425
+ "is_mono": (arr.ndim == 2),
1426
+ "source": source,
1427
+ }
1428
+ newdoc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
1429
+ if hasattr(main, "_spawn_subwindow_for"):
1430
+ main._spawn_subwindow_for(newdoc)
1431
+ except Exception:
1432
+ pass
1433
+
1434
+
1435
+
1436
+ # ------------------------------------------------------------
1437
+ # Utilities
1438
+ # ------------------------------------------------------------
1439
+ def _ensure_exec_bit(path: str):
1440
+ if platform.system() == "Windows":
1441
+ return
1442
+ try:
1443
+ st = os.stat(path)
1444
+ os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
1445
+ except Exception:
1446
+ pass
1447
+
1448
+
1449
+ def _safe_rm(p):
1450
+ try:
1451
+ if p and os.path.exists(p):
1452
+ os.remove(p)
1453
+ except Exception:
1454
+ pass
1455
+
1456
+ def _safe_rm_globs(patterns: list[str]):
1457
+ for pat in patterns:
1458
+ try:
1459
+ for fp in glob.glob(pat):
1460
+ _safe_rm(fp)
1461
+ except Exception:
1462
+ pass
1463
+
1464
+ def _purge_darkstar_io(base_folder: str, *, prefix: str | None = None, clear_input=True, clear_output=True):
1465
+ """Delete old image-like files from CC DarkStar input/output."""
1466
+ try:
1467
+ inp = os.path.join(base_folder, "input")
1468
+ out = os.path.join(base_folder, "output")
1469
+ if clear_input and os.path.isdir(inp):
1470
+ for fn in os.listdir(inp):
1471
+ fp = os.path.join(inp, fn)
1472
+ if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
1473
+ _safe_rm(fp)
1474
+ if clear_output and os.path.isdir(out):
1475
+ for fn in os.listdir(out):
1476
+ fp = os.path.join(out, fn)
1477
+ if os.path.isfile(fp) and (prefix is None or fn.startswith(prefix)):
1478
+ _safe_rm(fp)
1479
+ except Exception:
1480
+ pass
1481
+
1482
+
1483
+ # ------------------------------------------------------------
1484
+ # Proc runner & dialog (merged stdout/stderr)
1485
+ # ------------------------------------------------------------
1486
+ class _ProcThread(QThread):
1487
+ output_signal = pyqtSignal(str)
1488
+ finished_signal = pyqtSignal(int)
1489
+
1490
+ def __init__(self, command: list[str], cwd: str | None = None, parent=None):
1491
+ super().__init__(parent)
1492
+ self.command = command
1493
+ self.cwd = cwd
1494
+ self.process = None
1495
+
1496
+ def cancel(self):
1497
+ """Request the subprocess to stop."""
1498
+ if self.process:
1499
+ try:
1500
+ self.process.kill()
1501
+ except Exception:
1502
+ pass
1503
+
1504
+
1505
+ def run(self):
1506
+ import subprocess
1507
+ import os
1508
+ env = os.environ.copy()
1509
+ for k in ("PYTHONHOME","PYTHONPATH","DYLD_LIBRARY_PATH","DYLD_FALLBACK_LIBRARY_PATH","PYTHONEXECUTABLE"):
1510
+ env.pop(k, None)
1511
+ rc = -1
1512
+ try:
1513
+ self.process = subprocess.Popen(
1514
+ self.command, cwd=self.cwd,
1515
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1516
+ universal_newlines=True, text=True, start_new_session=True, env=env
1517
+ )
1518
+ for line in iter(self.process.stdout.readline, ""):
1519
+ if not line: break
1520
+ self.output_signal.emit(line.rstrip())
1521
+ try:
1522
+ self.process.stdout.close()
1523
+ except Exception:
1524
+ pass
1525
+ rc = self.process.wait()
1526
+ except Exception as e:
1527
+ self.output_signal.emit(str(e))
1528
+ rc = -1
1529
+ finally:
1530
+ self.process = None
1531
+ self.finished_signal.emit(rc)
1532
+
1533
+
1534
+ class _ProcDialog(QDialog):
1535
+ def __init__(self, parent, title="Process"):
1536
+ super().__init__(parent)
1537
+ self.setWindowTitle(title)
1538
+ self.setMinimumSize(600, 420)
1539
+ lay = QVBoxLayout(self)
1540
+ self.text = QTextEdit(self); self.text.setReadOnly(True)
1541
+ lay.addWidget(self.text)
1542
+ self.cancel_button = QPushButton("Cancel", self)
1543
+ lay.addWidget(self.cancel_button)
1544
+
1545
+ def append_text(self, s: str):
1546
+ try:
1547
+ self.text.append(s)
1548
+ except Exception:
1549
+ pass
1550
+
1551
+
1552
+ class DarkStarConfigDialog(QDialog):
1553
+ """
1554
+ SASv2-style config UI:
1555
+ - Disable GPU: Yes/No (default No)
1556
+ - Star Removal Mode: unscreen | additive (default unscreen)
1557
+ - Show Extracted Stars: Yes/No (default No)
1558
+ - Stride (powers of 2): 64,128,256,512,1024 (default 512)
1559
+ """
1560
+ def __init__(self, parent=None):
1561
+ super().__init__(parent)
1562
+ self.setWindowTitle("CosmicClarity Dark Star Settings")
1563
+
1564
+ self.chk_disable_gpu = QCheckBox("Disable GPU")
1565
+ self.chk_disable_gpu.setChecked(False) # default No (unchecked)
1566
+
1567
+ self.cmb_mode = QComboBox()
1568
+ self.cmb_mode.addItems(["unscreen", "additive"])
1569
+ self.cmb_mode.setCurrentText("unscreen")
1570
+
1571
+ self.chk_show_stars = QCheckBox("Show Extracted Stars")
1572
+ self.chk_show_stars.setChecked(True)
1573
+
1574
+ self.cmb_stride = QComboBox()
1575
+ for v in (64, 128, 256, 512, 1024):
1576
+ self.cmb_stride.addItem(str(v), v)
1577
+ self.cmb_stride.setCurrentText("512") # default 512
1578
+
1579
+ form = QFormLayout()
1580
+ form.addRow("Star Removal Mode:", self.cmb_mode)
1581
+ form.addRow("Stride (power of two):", self.cmb_stride)
1582
+ form.addRow("", self.chk_disable_gpu)
1583
+ form.addRow("", self.chk_show_stars)
1584
+
1585
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1586
+ btns.accepted.connect(self.accept)
1587
+ btns.rejected.connect(self.reject)
1588
+
1589
+ layout = QVBoxLayout(self)
1590
+ layout.addLayout(form)
1591
+ layout.addWidget(btns)
1592
+
1593
+ def get_values(self):
1594
+ return {
1595
+ "disable_gpu": self.chk_disable_gpu.isChecked(),
1596
+ "mode": self.cmb_mode.currentText(),
1597
+ "show_extracted_stars": self.chk_show_stars.isChecked(),
1598
+ "stride": int(self.cmb_stride.currentData()),
1599
+ }