setiastrosuitepro 1.6.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (394) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1593 @@
1
+ # pro/cosmicclarity.py
2
+ from __future__ import annotations
3
+ import os
4
+ import sys
5
+ import glob
6
+ import time
7
+ import tempfile
8
+ import uuid
9
+ import numpy as np
10
+
11
+ from PyQt6.QtCore import Qt, QTimer, QSettings, QThread, pyqtSignal, QFileSystemWatcher, QEvent
12
+ from PyQt6.QtGui import QIcon, QAction, QImage, QPixmap
13
+ from PyQt6.QtWidgets import (
14
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel, QPushButton,
15
+ QSlider, QCheckBox, QComboBox, QMessageBox, QWidget, QRadioButton, QProgressBar,
16
+ QTextEdit, QFileDialog, QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog
17
+ )
18
+ from PyQt6.QtCore import QProcess
19
+
20
+ # ---- bring in your image IO helpers ----
21
+ # Adjust these imports to your project structure if needed.
22
+ from setiastro.saspro.legacy.image_manager import load_image, save_image
23
+
24
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
25
+
26
+ # Import centralized preview dialog
27
+ from setiastro.saspro.widgets.preview_dialogs import ImagePreviewDialog
28
+
29
+ import shutil
30
+ import subprocess
31
+
32
+ # --- replace your _atomic_fsync_replace with this ---
33
+ def _atomic_fsync_replace(src_bytes_writer, final_path: str):
34
+ """
35
+ Write to a unique temp file next to final_path, fsync it, then atomically
36
+ replace final_path. src_bytes_writer(tmp_path) must CREATE tmp_path.
37
+ """
38
+ d = os.path.dirname(final_path) or "."
39
+ os.makedirs(d, exist_ok=True)
40
+
41
+ # Use same extension so writers (like your save_image) don't append a new one.
42
+ ext = os.path.splitext(final_path)[1] or ".tmp"
43
+ tmp_path = os.path.join(d, f".stage_{uuid.uuid4().hex}{ext}")
44
+
45
+ try:
46
+ # Let caller create/write the file at tmp_path
47
+ src_bytes_writer(tmp_path)
48
+
49
+ # Ensure written bytes are on disk
50
+ try:
51
+ with open(tmp_path, "rb", buffering=0) as f:
52
+ os.fsync(f.fileno())
53
+ except Exception:
54
+ # If a backend keeps the file open exclusively or doesn't support fsync,
55
+ # we still continue; replace() below is atomic on the same filesystem.
56
+ pass
57
+
58
+ # Promote atomically
59
+ os.replace(tmp_path, final_path)
60
+
61
+ # POSIX-only: best-effort directory entry fsync (Windows doesn't support this)
62
+ if os.name != "nt":
63
+ try:
64
+ dirfd = os.open(d, os.O_DIRECTORY)
65
+ try: os.fsync(dirfd)
66
+ finally: os.close(dirfd)
67
+ except Exception:
68
+ pass
69
+
70
+ finally:
71
+ # Cleanup if anything left behind
72
+ try:
73
+ if os.path.exists(tmp_path):
74
+ os.remove(tmp_path)
75
+ except Exception:
76
+ pass
77
+
78
+ def resolve_cosmic_root(parent=None) -> str:
79
+ s = QSettings()
80
+ root = s.value("paths/cosmic_clarity", "", type=str) or ""
81
+ if root and os.path.isdir(root):
82
+ return root
83
+
84
+ # Try common relatives to the app executable
85
+ appdir = os.path.dirname(os.path.abspath(sys.argv[0]))
86
+ candidates = [
87
+ appdir,
88
+ os.path.join(appdir, "cosmic_clarity"),
89
+ os.path.join(appdir, "CosmicClarity"),
90
+ os.path.dirname(appdir), # one up
91
+ ]
92
+ exe_names = {
93
+ "win": ["SetiAstroCosmicClarity.exe", "SetiAstroCosmicClarity_denoise.exe"],
94
+ "mac": ["SetiAstroCosmicClaritymac", "SetiAstroCosmicClarity_denoisemac"],
95
+ "nix": ["SetiAstroCosmicClarity", "SetiAstroCosmicClarity_denoise"],
96
+ }
97
+ key = "win" if os.name == "nt" else ("mac" if sys.platform=="darwin" else "nix")
98
+
99
+ for c in candidates:
100
+ if all(os.path.exists(os.path.join(c, name)) for name in exe_names[key]):
101
+ # ensure in/out exist
102
+ os.makedirs(os.path.join(c, "input"), exist_ok=True)
103
+ os.makedirs(os.path.join(c, "output"), exist_ok=True)
104
+ s.setValue("paths/cosmic_clarity", c); s.sync()
105
+ return c
106
+
107
+ # Prompt user once
108
+ QMessageBox.information(parent, "Cosmic Clarity",
109
+ "Please select your Cosmic Clarity folder (the one that contains the CC executables and input/output).")
110
+ folder = QFileDialog.getExistingDirectory(parent, "Select Cosmic Clarity Folder", "")
111
+ if folder:
112
+ s.setValue("paths/cosmic_clarity", folder); s.sync()
113
+ os.makedirs(os.path.join(folder, "input"), exist_ok=True)
114
+ os.makedirs(os.path.join(folder, "output"), exist_ok=True)
115
+ return folder
116
+ return "" # caller should handle "not set"
117
+
118
+ def _wait_stable_file(path: str, timeout_ms: int = 4000, poll_ms: int = 50) -> bool:
119
+ """Return True when path exists and its size doesn't change for 2 polls in a row."""
120
+ t0 = time.monotonic()
121
+ last = (-1, -1.0) # (size, mtime)
122
+ stable_count = 0
123
+ while (time.monotonic() - t0) * 1000 < timeout_ms:
124
+ try:
125
+ st = os.stat(path)
126
+ cur = (st.st_size, st.st_mtime)
127
+ if cur == last and st.st_size > 0:
128
+ stable_count += 1
129
+ if stable_count >= 2:
130
+ return True
131
+ else:
132
+ stable_count = 0
133
+ last = cur
134
+ except FileNotFoundError:
135
+ stable_count = 0
136
+ time.sleep(poll_ms / 1000.0)
137
+ return False
138
+
139
+
140
+ # =============================================================================
141
+ # Small helpers
142
+ # =============================================================================
143
+ def _satellite_exe_name() -> str:
144
+ base = "setiastrocosmicclarity_satellite"
145
+ return f"{base}.exe" if os.name == "nt" else base
146
+
147
+
148
+ def _get_cosmic_root_from_settings() -> str:
149
+ return resolve_cosmic_root(parent=None) # or pass self as parent
150
+
151
+ def _ensure_dirs(root: str):
152
+ os.makedirs(os.path.join(root, "input"), exist_ok=True)
153
+ os.makedirs(os.path.join(root, "output"), exist_ok=True)
154
+
155
+ _IMG_EXTS = ('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
156
+ '.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef',
157
+ '.jpg', '.jpeg')
158
+
159
+ def _purge_dir(path: str, *, prefix: str | None = None):
160
+ """Delete lingering image-like files in a folder. Safe: files only."""
161
+ try:
162
+ if not os.path.isdir(path):
163
+ return
164
+ for fn in os.listdir(path):
165
+ fp = os.path.join(path, fn)
166
+ if not os.path.isfile(fp):
167
+ continue
168
+ if prefix and not fn.startswith(prefix):
169
+ continue
170
+ if os.path.splitext(fn)[1].lower() in _IMG_EXTS:
171
+ try: os.remove(fp)
172
+ except Exception as e:
173
+ import logging
174
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
175
+ except Exception:
176
+ pass
177
+
178
+ def _purge_cc_io(root: str, *, clear_input: bool, clear_output: bool, prefix: str | None = None):
179
+ """Convenience to purge CC input/output dirs."""
180
+ try:
181
+ if clear_input:
182
+ _purge_dir(os.path.join(root, "input"), prefix=prefix)
183
+ if clear_output:
184
+ _purge_dir(os.path.join(root, "output"), prefix=prefix)
185
+ except Exception:
186
+ pass
187
+
188
+ def _platform_exe_names(mode: str) -> str:
189
+ """
190
+ Return executable filename for sharpen/denoise based on OS.
191
+ Matches SASv2 you pasted:
192
+ - Windows: SetiAstroCosmicClarity.exe / SetiAstroCosmicClarity_denoise.exe
193
+ - macOS : SetiAstroCosmicClaritymac / SetiAstroCosmicClarity_denoisemac
194
+ - Linux : SetiAstroCosmicClarity / SetiAstroCosmicClarity_denoise
195
+ """
196
+ is_win = os.name == "nt"
197
+ is_mac = sys.platform == "darwin"
198
+ if mode == "sharpen":
199
+ return "SetiAstroCosmicClarity.exe" if is_win else ("SetiAstroCosmicClaritymac" if is_mac else "SetiAstroCosmicClarity")
200
+ elif mode == "denoise":
201
+ return "SetiAstroCosmicClarity_denoise.exe" if is_win else ("SetiAstroCosmicClarity_denoisemac" if is_mac else "SetiAstroCosmicClarity_denoise")
202
+ elif mode == "superres":
203
+ # SASv2 used lowercase for superres on Windows
204
+ return "setiastrocosmicclarity_superres.exe" if is_win else "setiastrocosmicclarity_superres"
205
+ else:
206
+ return ""
207
+
208
+
209
+ # =============================================================================
210
+ # Wait UI
211
+ # =============================================================================
212
+ class WaitDialog(QDialog):
213
+ cancelled = pyqtSignal()
214
+ def __init__(self, title="Processing…", parent=None):
215
+ super().__init__(parent)
216
+ self.setWindowTitle(title)
217
+ v = QVBoxLayout(self)
218
+ self.lbl = QLabel("Processing, please wait…")
219
+ self.txt = QTextEdit(); self.txt.setReadOnly(True)
220
+ self.pb = QProgressBar(); self.pb.setRange(0, 100)
221
+ btn = QPushButton("Cancel"); btn.clicked.connect(self.cancelled.emit)
222
+ v.addWidget(self.lbl); v.addWidget(self.txt); v.addWidget(self.pb); v.addWidget(btn)
223
+ def append_output(self, line: str): self.txt.append(line)
224
+ def set_progress(self, p: int): self.pb.setValue(int(max(0, min(100, p))))
225
+
226
+
227
+ class WaitForFileWorker(QThread):
228
+ fileFound = pyqtSignal(str)
229
+ cancelled = pyqtSignal()
230
+ error = pyqtSignal(str)
231
+ def __init__(self, glob_pat: str, timeout_sec=1800, parent=None):
232
+ super().__init__(parent)
233
+ self._glob = glob_pat
234
+ self._timeout = timeout_sec
235
+ self._running = True
236
+ def run(self):
237
+ start = time.time()
238
+ while self._running and (time.time() - start < self._timeout):
239
+ m = glob.glob(self._glob)
240
+ if m:
241
+ self.fileFound.emit(m[0]); return
242
+ time.sleep(1)
243
+ if self._running: self.error.emit("Output file not found within timeout.")
244
+ else: self.cancelled.emit()
245
+ def stop(self): self._running = False
246
+
247
+
248
+ # =============================================================================
249
+ # Dialog
250
+ # =============================================================================
251
+ class CosmicClarityDialogPro(QDialog):
252
+ """
253
+ Pro port of SASv2 Cosmic Clarity panel:
254
+ • Modes: Sharpen, Denoise, Both, Super Resolution
255
+ • GPU toggle
256
+ • PSF, stellar/nonstellar amounts
257
+ • Denoise strengths/mode
258
+ • Super-res scale
259
+ • Apply target: overwrite / new view
260
+ Uses QSettings key: paths/cosmic_clarity
261
+ """
262
+ def __init__(self, parent, doc, icon: QIcon | None = None, *, headless: bool=False, bypass_guard: bool=False):
263
+ super().__init__(parent)
264
+ # Hard guard unless explicitly bypassed (used by preset runner)
265
+ if not bypass_guard and self._headless_guard_active():
266
+ # avoid any flash; never show
267
+ try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
268
+ except Exception as e:
269
+ import logging
270
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
271
+ QTimer.singleShot(0, self.reject)
272
+ return
273
+ self.setWindowTitle(self.tr("Cosmic Clarity"))
274
+ self.setWindowFlag(Qt.WindowType.Window, True)
275
+ self.setWindowModality(Qt.WindowModality.NonModal)
276
+ self.setModal(False)
277
+ try:
278
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
279
+ except Exception:
280
+ pass # older PyQt6 versions
281
+ if icon:
282
+ try: self.setWindowIcon(icon)
283
+ except Exception as e:
284
+ import logging
285
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
286
+
287
+ self.parent_ref = parent
288
+ self.doc = doc
289
+ self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
290
+ self.cosmic_root = _get_cosmic_root_from_settings()
291
+
292
+ v = QVBoxLayout(self)
293
+
294
+ # ---------------- Controls ----------------
295
+ grp = QGroupBox(self.tr("Parameters"))
296
+ grid = QGridLayout(grp)
297
+
298
+ # Mode
299
+ grid.addWidget(QLabel(self.tr("Mode:")), 0, 0)
300
+ self.cmb_mode = QComboBox()
301
+ self.cmb_mode.addItems(["Sharpen", "Denoise", "Both", "Super Resolution"])
302
+ self.cmb_mode.currentIndexChanged.connect(self._mode_changed)
303
+ grid.addWidget(self.cmb_mode, 0, 1, 1, 2)
304
+
305
+ # GPU
306
+ grid.addWidget(QLabel(self.tr("Use GPU:")), 1, 0)
307
+ self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems([self.tr("Yes"), self.tr("No")])
308
+ grid.addWidget(self.cmb_gpu, 1, 1)
309
+
310
+ # Sharpen block
311
+ self.lbl_sh_mode = QLabel("Sharpening Mode:")
312
+ self.cmb_sh_mode = QComboBox(); self.cmb_sh_mode.addItems(["Both", "Stellar Only", "Non-Stellar Only"])
313
+ grid.addWidget(self.lbl_sh_mode, 2, 0); grid.addWidget(self.cmb_sh_mode, 2, 1)
314
+
315
+ self.chk_sh_sep = QCheckBox("Sharpen RGB channels separately")
316
+ self.chk_sh_sep.setToolTip(
317
+ "Run the mono sharpening model independently on R, G, and B instead of a shared color model.\n"
318
+ "Use for difficult color data where channels need slightly different sharpening."
319
+ )
320
+ grid.addWidget(self.chk_sh_sep, 3, 0)
321
+
322
+ self.chk_auto_psf = QCheckBox("Auto Detect PSF"); self.chk_auto_psf.setChecked(True)
323
+ grid.addWidget(self.chk_auto_psf, 3, 1)
324
+
325
+ self.lbl_psf = QLabel("Non-Stellar PSF (1.0–8.0): 3.0")
326
+ self.sld_psf = QSlider(Qt.Orientation.Horizontal); self.sld_psf.setRange(10, 80); self.sld_psf.setValue(30)
327
+ self.sld_psf.valueChanged.connect(self._psf_label)
328
+ grid.addWidget(self.lbl_psf, 4, 0, 1, 2); grid.addWidget(self.sld_psf, 5, 0, 1, 3)
329
+
330
+ self.lbl_st_amt = QLabel("Stellar Amount (0–1): 0.50")
331
+ self.sld_st_amt = QSlider(Qt.Orientation.Horizontal); self.sld_st_amt.setRange(0, 100); self.sld_st_amt.setValue(50)
332
+
333
+ self.sld_st_amt.valueChanged.connect(self._on_st_amt)
334
+ grid.addWidget(self.lbl_st_amt, 6, 0, 1, 2); grid.addWidget(self.sld_st_amt, 7, 0, 1, 3)
335
+
336
+ self.lbl_nst_amt = QLabel("Non-Stellar Amount (0–1): 0.50")
337
+ self.sld_nst_amt = QSlider(Qt.Orientation.Horizontal); self.sld_nst_amt.setRange(0, 100); self.sld_nst_amt.setValue(50)
338
+
339
+ self.sld_nst_amt.valueChanged.connect(self._on_nst_amt)
340
+ grid.addWidget(self.lbl_nst_amt, 8, 0, 1, 2); grid.addWidget(self.sld_nst_amt, 9, 0, 1, 3)
341
+
342
+ # Denoise block
343
+ self.lbl_dn_lum = QLabel("Luminance Denoise (0–1): 0.50")
344
+ self.sld_dn_lum = QSlider(Qt.Orientation.Horizontal); self.sld_dn_lum.setRange(0, 100); self.sld_dn_lum.setValue(50)
345
+ self.sld_dn_lum.valueChanged.connect(lambda v: self.lbl_dn_lum.setText(f"Luminance Denoise (0–1): {v/100:.2f}"))
346
+ grid.addWidget(self.lbl_dn_lum, 10, 0, 1, 2); grid.addWidget(self.sld_dn_lum, 11, 0, 1, 3)
347
+
348
+ self.lbl_dn_col = QLabel("Color Denoise (0–1): 0.50")
349
+ self.sld_dn_col = QSlider(Qt.Orientation.Horizontal); self.sld_dn_col.setRange(0, 100); self.sld_dn_col.setValue(50)
350
+ self.sld_dn_col.valueChanged.connect(lambda v: self.lbl_dn_col.setText(f"Color Denoise (0–1): {v/100:.2f}"))
351
+ grid.addWidget(self.lbl_dn_col, 12, 0, 1, 2); grid.addWidget(self.sld_dn_col, 13, 0, 1, 3)
352
+
353
+ self.lbl_dn_mode = QLabel("Denoise Mode:")
354
+ self.cmb_dn_mode = QComboBox(); self.cmb_dn_mode.addItems(["full", "luminance"])
355
+ grid.addWidget(self.lbl_dn_mode, 14, 0); grid.addWidget(self.cmb_dn_mode, 14, 1)
356
+
357
+ self.chk_dn_sep = QCheckBox("Process RGB channels separately")
358
+ grid.addWidget(self.chk_dn_sep, 15, 1)
359
+
360
+ # Super-res
361
+ self.lbl_scale = QLabel("Scale Factor:")
362
+ self.cmb_scale = QComboBox(); self.cmb_scale.addItems(["2x", "3x", "4x"])
363
+ grid.addWidget(self.lbl_scale, 16, 0); grid.addWidget(self.cmb_scale, 16, 1)
364
+
365
+ # Apply target
366
+ grid.addWidget(QLabel("Apply to:"), 17, 0)
367
+ self.cmb_target = QComboBox(); self.cmb_target.addItems(["Overwrite active view", "Create new view"])
368
+ grid.addWidget(self.cmb_target, 17, 1, 1, 2)
369
+
370
+ v.addWidget(grp)
371
+
372
+ # Buttons
373
+ row = QHBoxLayout()
374
+ b_run = QPushButton(self.tr("Execute")); b_run.clicked.connect(self._run_main)
375
+ b_close = QPushButton(self.tr("Close")); b_close.clicked.connect(self.reject)
376
+ row.addStretch(1); row.addWidget(b_run); row.addWidget(b_close)
377
+ v.addLayout(row)
378
+
379
+ self._mode_changed() # set initial visibility
380
+
381
+ self._wait = None
382
+ self._wait_thread = None
383
+ self._proc = None
384
+
385
+ self._headless = bool(headless)
386
+ if self._headless:
387
+ # Don’t show the control panel; we’ll still exec() to run the event loop.
388
+ try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
389
+ except Exception as e:
390
+ import logging
391
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
392
+ self.resize(560, 540)
393
+
394
+ # ----- UI helpers -----
395
+ def _headless_guard_active(self) -> bool:
396
+ # 1) fast path: flags on the main window
397
+ try:
398
+ p = self.parent()
399
+ if p and (getattr(p, "_cosmicclarity_guard", False) or getattr(p, "_cosmicclarity_headless_running", False)):
400
+ return True
401
+ except Exception:
402
+ pass
403
+ # 2) cross-module path: QSettings flag set by the preset runner
404
+ try:
405
+ s = QSettings()
406
+ v = s.value("cc/headless_in_progress", False, type=bool)
407
+ return bool(v)
408
+ except Exception:
409
+ # fallback if type kwarg unsupported in some Qt builds
410
+ try:
411
+ return bool(QSettings().value("cc/headless_in_progress", False))
412
+ except Exception:
413
+ return False
414
+
415
+ # Never show if guard is active
416
+ def showEvent(self, e):
417
+ if self._headless_guard_active():
418
+ e.ignore()
419
+ QTimer.singleShot(0, self.reject)
420
+ return
421
+ return super().showEvent(e)
422
+
423
+ # Never exec if guard is active
424
+ def exec(self) -> int:
425
+ if self._headless_guard_active():
426
+ return 0
427
+ return super().exec()
428
+
429
+
430
+ def _on_st_amt(self, v: int): self.lbl_st_amt.setText(f"Stellar Amount (0–1): {v/100:.2f}")
431
+ def _on_nst_amt(self, v: int): self.lbl_nst_amt.setText(f"Non-Stellar Amount (0–1): {v/100:.2f}")
432
+
433
+ def _psf_label(self):
434
+ self.lbl_psf.setText(f"Non-Stellar PSF (1.0–8.0): {self.sld_psf.value()/10:.1f}")
435
+
436
+ def _mode_changed(self):
437
+ idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
438
+ # Sharpen controls visible if Sharpen or Both
439
+ show_sh = idx in (0, 2)
440
+ for w in (self.lbl_sh_mode, self.cmb_sh_mode, self.chk_sh_sep, self.chk_auto_psf, self.lbl_psf, self.sld_psf, self.lbl_st_amt, self.sld_st_amt, self.lbl_nst_amt, self.sld_nst_amt):
441
+ w.setVisible(show_sh)
442
+
443
+ # Denoise controls visible if Denoise or Both
444
+ show_dn = idx in (1, 2)
445
+ for w in (self.lbl_dn_lum, self.sld_dn_lum, self.lbl_dn_col, self.sld_dn_col, self.lbl_dn_mode, self.cmb_dn_mode, self.chk_dn_sep):
446
+ w.setVisible(show_dn)
447
+
448
+ # Super-res controls visible if Super-Res
449
+ show_sr = idx == 3
450
+ for w in (self.lbl_scale, self.cmb_scale):
451
+ w.setVisible(show_sr)
452
+
453
+ # GPU hidden for superres (matches your SASv2)
454
+ self.cmb_gpu.setVisible(not show_sr)
455
+ self.parentWidget()
456
+
457
+ # ----- Validation -----
458
+ def _validate_root(self) -> bool:
459
+ if not self.cosmic_root:
460
+ QMessageBox.warning(self, "Cosmic Clarity", "No Cosmic Clarity folder is set. Set it in Preferences (Settings).")
461
+ return False
462
+ # basic presence check (don’t force a specific exe here, we do that later)
463
+ if not os.path.isdir(self.cosmic_root):
464
+ QMessageBox.warning(self, "Cosmic Clarity", "The Cosmic Clarity folder in Settings doesn’t exist anymore.")
465
+ return False
466
+ return True
467
+
468
+ # ----- Execution -----
469
+ def _run_main(self):
470
+ if not self._validate_root():
471
+ return
472
+
473
+ # --- Register this run as "last action" for replay ---
474
+ try:
475
+ main = self.parent_ref or self.parent()
476
+ if main is not None:
477
+ preset = self.build_preset_from_ui()
478
+ payload = {
479
+ "cid": "cosmic_clarity",
480
+ "preset": preset,
481
+ # optional label for your UI if you use it
482
+ "label": f"Cosmic Clarity ({preset.get('mode', 'sharpen')})",
483
+ }
484
+
485
+ # Preferred: use the same helper you used for CLAHE / Morphology / PixelMath
486
+ if hasattr(main, "_set_last_headless_command"):
487
+ main._set_last_headless_command(payload)
488
+ else:
489
+ # Fallback: write directly if you're using a bare _last_headless_command dict
490
+ setattr(main, "_last_headless_command", payload)
491
+ if hasattr(main, "_update_replay_button"):
492
+ main._update_replay_button()
493
+ except Exception:
494
+ # Never let replay bookkeeping kill the effect itself
495
+ pass
496
+
497
+ _ensure_dirs(self.cosmic_root)
498
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=False)
499
+
500
+
501
+ # Determine queue of operations
502
+ mode_idx = self.cmb_mode.currentIndex()
503
+ if mode_idx == 3:
504
+ # Super-res path
505
+ self._run_superres(); return
506
+ elif mode_idx == 0:
507
+ ops = [("sharpen", "_sharpened")]
508
+ elif mode_idx == 1:
509
+ ops = [("denoise", "_denoised")]
510
+ else:
511
+ ops = [("sharpen", "_sharpened"), ("denoise", "_denoised")]
512
+
513
+ # Save current doc image to input
514
+ base = self._base_name()
515
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
516
+ try:
517
+ # Use atomic fsync
518
+ base = self._base_name()
519
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
520
+ arr = self.orig # already float32 [0..1]
521
+
522
+ def _writer(tmp_path):
523
+ # reuse your save_image impl to tmp
524
+ save_image(arr, tmp_path, "tiff", "32-bit floating point",
525
+ getattr(self.doc, "original_header", None),
526
+ getattr(self.doc, "is_mono", False))
527
+
528
+ try:
529
+ _atomic_fsync_replace(_writer, in_path)
530
+ except Exception as e:
531
+ print("Atomic save failed:", repr(e))
532
+ raise
533
+
534
+ # ensure stable on disk before launching
535
+ if not _wait_stable_file(in_path):
536
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to stage input TIFF (not stable on disk).")
537
+ return
538
+ except Exception as e:
539
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
540
+ return
541
+
542
+ # Run queue
543
+ self._op_queue = ops
544
+ self._current_input = in_path
545
+ self._run_next()
546
+
547
+ def _run_next(self):
548
+ if not self._op_queue:
549
+ # If we ever get here without more steps, we’re done.
550
+ self.accept()
551
+ return
552
+ mode, suffix = self._op_queue.pop(0)
553
+ exe_name = _platform_exe_names(mode)
554
+ exe_path = os.path.join(self.cosmic_root, exe_name)
555
+ if not os.path.exists(exe_path):
556
+ QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
557
+ return
558
+
559
+ # Build args (SASv2 flags mirrored)
560
+ args = []
561
+ if mode == "sharpen":
562
+ psf = self.sld_psf.value()/10.0
563
+ args += [
564
+ "--sharpening_mode", self.cmb_sh_mode.currentText(),
565
+ "--stellar_amount", f"{self.sld_st_amt.value()/100:.2f}",
566
+ "--nonstellar_strength", f"{psf:.1f}",
567
+ "--nonstellar_amount", f"{self.sld_nst_amt.value()/100:.2f}"
568
+ ]
569
+ # NEW: per-channel sharpen toggle
570
+ if self.chk_sh_sep.isChecked():
571
+ args.append("--sharpen_channels_separately")
572
+
573
+ if self.chk_auto_psf.isChecked():
574
+ args.append("--auto_detect_psf")
575
+ elif mode == "denoise":
576
+ args += ["--denoise_strength", f"{self.sld_dn_lum.value()/100:.2f}",
577
+ "--color_denoise_strength", f"{self.sld_dn_col.value()/100:.2f}",
578
+ "--denoise_mode", self.cmb_dn_mode.currentText()]
579
+ if self.chk_dn_sep.isChecked():
580
+ args.append("--separate_channels")
581
+
582
+ if self.cmb_gpu.currentText() == "No" and mode in ("sharpen","denoise"):
583
+ args.append("--disable_gpu")
584
+
585
+ # Run process
586
+ self._proc = QProcess(self)
587
+ self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
588
+ self._proc.setWorkingDirectory(self.cosmic_root) # <-- add this line
589
+
590
+ self._proc.readyReadStandardOutput.connect(self._read_proc_output_main)
591
+ from functools import partial
592
+ self._proc.finished.connect(partial(self._on_proc_finished, mode, suffix))
593
+ self._proc.setProgram(exe_path)
594
+ self._proc.setArguments(args)
595
+ self._proc.start()
596
+ if not self._proc.waitForStarted(3000):
597
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to start process.")
598
+ return
599
+
600
+ # Wait for output file
601
+ base = self._base_name()
602
+ out_glob = os.path.join(self.cosmic_root, "output", f"{base}{suffix}.*")
603
+ self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
604
+ self._wait.cancelled.connect(self._cancel_all)
605
+ self._wait.show()
606
+
607
+ self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
608
+ self._wait_thread.fileFound.connect(lambda path, mode=mode: self._on_output_file(path, mode))
609
+ self._wait_thread.error.connect(self._on_wait_error)
610
+ self._wait_thread.cancelled.connect(self._on_wait_cancel)
611
+ self._wait_thread.start()
612
+
613
+ def _read_proc_output_main(self):
614
+ self._read_proc_output(self._proc, which="main")
615
+
616
+ def _read_proc_output(self, proc: QProcess, which="main"):
617
+ out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
618
+ if not self._wait: return
619
+ for line in out.splitlines():
620
+ line = line.strip()
621
+ if not line: continue
622
+ if line.startswith("Progress:"):
623
+ try:
624
+ pct = float(line.split()[1].replace("%",""))
625
+ self._wait.set_progress(int(pct))
626
+ except Exception:
627
+ pass
628
+ else:
629
+ self._wait.append_output(line)
630
+ print(f"[CC] {line}")
631
+
632
+ def _on_proc_finished(self, mode, suffix, code, status):
633
+ if code != 0:
634
+ if self._wait: self._wait.append_output(f"Process exited with code {code}.")
635
+ # still let the file-watcher decide success/failure (some exes write before exit)
636
+
637
+ def _on_output_file(self, out_path: str, mode: str):
638
+ # stop waiting UI
639
+ if self._wait: self._wait.close(); self._wait = None
640
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
641
+
642
+ has_more = bool(self._op_queue)
643
+
644
+ # --- Optimization: Chained Execution Fast Path ---
645
+ # If we have more steps, skip the expensive load/display/save cycle.
646
+ # Just move the output file to be the input for the next step.
647
+ if has_more:
648
+ if not out_path or not os.path.exists(out_path):
649
+ QMessageBox.critical(self, "Cosmic Clarity", "Output file missing during chain execution.")
650
+ self._op_queue.clear()
651
+ return
652
+
653
+ base = self._base_name()
654
+ next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
655
+ prev_in = getattr(self, "_current_input", None)
656
+
657
+ try:
658
+ # Direct move/copy instead of decode+encode
659
+ if os.path.abspath(out_path) != os.path.abspath(next_in):
660
+ # Windows cannot atomic replace if target exists via os.rename usually,
661
+ # but shutil.move is generally robust.
662
+ # We remove target first to be sure.
663
+ if os.path.exists(next_in):
664
+ os.remove(next_in)
665
+ shutil.move(out_path, next_in)
666
+
667
+ # Ensure stability of the *new* input
668
+ if not _wait_stable_file(next_in):
669
+ QMessageBox.critical(self, "Cosmic Clarity", "Staged input for next step is unstable.")
670
+ self._op_queue.clear()
671
+ return
672
+
673
+ self._current_input = next_in
674
+
675
+ # Cleanup previous input if distinct
676
+ if prev_in and prev_in != next_in and os.path.exists(prev_in):
677
+ os.remove(prev_in)
678
+
679
+ except Exception as e:
680
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to stage next step:\n{e}")
681
+ self._op_queue.clear()
682
+ return
683
+
684
+ # Trigger next step immediately
685
+ QTimer.singleShot(50, self._run_next)
686
+ return
687
+
688
+ # --- Final Step (or Single Step): Load and Display ---
689
+ try:
690
+ img, hdr, bd, mono = load_image(out_path)
691
+ if img is None:
692
+ raise RuntimeError("Unable to load output image.")
693
+ except Exception as e:
694
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load output:\n{e}")
695
+ return
696
+
697
+ dest = img.astype(np.float32, copy=False)
698
+
699
+ # Apply to document
700
+ step_title = f"Cosmic Clarity – {mode.title()}"
701
+ create_new = (self.cmb_target.currentIndex() == 1)
702
+
703
+ if create_new:
704
+ ok = self._spawn_new_doc_from_numpy(dest, step_title)
705
+ if not ok:
706
+ self._apply_to_active(dest, step_title)
707
+ else:
708
+ self._apply_to_active(dest, step_title)
709
+
710
+ # Cleanup final output
711
+ if out_path and os.path.exists(out_path):
712
+ try: os.remove(out_path)
713
+ except OSError: pass
714
+
715
+ # Cleanup final input
716
+ prev_in = getattr(self, "_current_input", None)
717
+ if prev_in and os.path.exists(prev_in):
718
+ try: os.remove(prev_in)
719
+ except OSError: pass
720
+
721
+ # Final purge
722
+ try:
723
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
724
+ except Exception:
725
+ pass
726
+ self.accept()
727
+
728
+
729
+ def _on_wait_error(self, msg: str):
730
+ if self._wait: self._wait.close(); self._wait = None
731
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
732
+ QMessageBox.critical(self, "Cosmic Clarity", msg)
733
+
734
+ def _on_wait_cancel(self):
735
+ if self._wait: self._wait.close(); self._wait = None
736
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
737
+
738
+ def _cancel_all(self):
739
+ try:
740
+ if self._proc: self._proc.kill()
741
+ except Exception as e:
742
+ import logging
743
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
744
+ self._on_wait_cancel()
745
+
746
+ def _base_name(self) -> str:
747
+ fp = getattr(self.doc, "file_path", None)
748
+ if isinstance(fp, str) and fp:
749
+ return os.path.splitext(os.path.basename(fp))[0]
750
+ name = getattr(self.doc, "display_name", None)
751
+ if callable(name):
752
+ try:
753
+ n = name() or ""
754
+ if n:
755
+ return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
756
+ except Exception:
757
+ pass
758
+ return "image"
759
+
760
+
761
+ def _apply_to_active(self, arr: np.ndarray, step_title: str):
762
+ """Overwrite the active document image."""
763
+ if hasattr(self.doc, "set_image"):
764
+ self.doc.set_image(arr, step_name=step_title)
765
+ elif hasattr(self.doc, "apply_numpy"):
766
+ self.doc.apply_numpy(arr, step_name=step_title)
767
+ else:
768
+ self.doc.image = arr
769
+
770
+ def _spawn_new_doc_from_numpy(self, arr: np.ndarray, step_title: str) -> bool:
771
+ """Create a brand-new document + view from a numpy array. Returns True on success."""
772
+ mw = self.parent()
773
+ dm = getattr(mw, "docman", None)
774
+ if dm is None:
775
+ return False
776
+
777
+ # build a reasonable title and metadata
778
+ base_name = getattr(self.doc, "display_name", None)
779
+ base = base_name() if callable(base_name) else (base_name or "Image")
780
+ title = f"{base} [{step_title}]"
781
+
782
+ meta = {
783
+ "bit_depth": "32-bit floating point",
784
+ "is_mono": (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1),
785
+ "source": "Cosmic Clarity",
786
+ "original_header": getattr(self.doc, "original_header", None),
787
+ }
788
+
789
+ try:
790
+ new_doc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
791
+ if hasattr(mw, "_spawn_subwindow_for"): # same hook used in ABE
792
+ mw._spawn_subwindow_for(new_doc)
793
+ return True
794
+ except Exception as e:
795
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to create new view:\n{e}")
796
+ return False
797
+
798
+
799
+ # ----- Super-resolution -----
800
+ def _run_superres(self):
801
+ exe_name = _platform_exe_names("superres")
802
+ exe_path = os.path.join(self.cosmic_root, exe_name)
803
+ if not os.path.exists(exe_path):
804
+ QMessageBox.critical(self, "Cosmic Clarity", f"Super Resolution executable not found:\n{exe_path}")
805
+ return
806
+
807
+ _ensure_dirs(self.cosmic_root)
808
+ # 🔸 purge output too so any file that appears is from THIS run
809
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
810
+
811
+ base = self._base_name()
812
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
813
+ try:
814
+ save_image(self.orig, in_path, "tiff", "32-bit floating point",
815
+ getattr(self.doc, "original_header", None),
816
+ getattr(self.doc, "is_mono", False))
817
+ except Exception as e:
818
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
819
+ return
820
+ self._current_input = in_path
821
+
822
+ scale = int(self.cmb_scale.currentText().replace("x", ""))
823
+ # keep args as-is if your superres build expects explicit paths
824
+ args = [
825
+ "--input", in_path,
826
+ "--output_dir", os.path.join(self.cosmic_root, "output"),
827
+ "--scale", str(scale),
828
+ "--model_dir", self.cosmic_root
829
+ ]
830
+
831
+ self._proc = QProcess(self)
832
+ self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
833
+ self._proc.readyReadStandardOutput.connect(self._read_superres_output_main)
834
+ # finished handler not required; the file watcher drives success
835
+ self._proc.setProgram(exe_path)
836
+ self._proc.setArguments(args)
837
+ self._proc.start()
838
+ if not self._proc.waitForStarted(3000):
839
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to start Super Resolution process.")
840
+ return
841
+
842
+ self._wait = WaitDialog("Cosmic Clarity – Super Resolution", self)
843
+ self._wait.cancelled.connect(self._cancel_all)
844
+ self._wait.show()
845
+
846
+ # 🔸 Watch broadly; we purged output so the first file is from this run.
847
+ # We'll still re-pick the exact file in the slot for safety.
848
+ self._sr_base = base
849
+ self._sr_scale = scale
850
+ out_glob = os.path.join(self.cosmic_root, "output", "*.*")
851
+
852
+ self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
853
+ self._wait_thread.fileFound.connect(self._on_superres_file) # path arg is ignored; we reselect
854
+ self._wait_thread.error.connect(self._on_wait_error)
855
+ self._wait_thread.cancelled.connect(self._on_wait_cancel)
856
+ self._wait_thread.start()
857
+
858
+
859
+ def apply_preset(self, p: dict):
860
+ # Mode
861
+ mode = str(p.get("mode","sharpen")).lower()
862
+ self.cmb_mode.setCurrentIndex({"sharpen":0,"denoise":1,"both":2,"superres":3}.get(mode,0))
863
+ # GPU
864
+ self.cmb_gpu.setCurrentIndex(0 if p.get("gpu", True) else 1)
865
+ # Target
866
+ self.cmb_target.setCurrentIndex(1 if p.get("create_new_view", False) else 0)
867
+ # Sharpen
868
+ self.cmb_sh_mode.setCurrentText(p.get("sharpening_mode","Both"))
869
+ self.chk_auto_psf.setChecked(bool(p.get("auto_psf", True)))
870
+ self.sld_psf.setValue(int(max(10, min(80, round(float(p.get("nonstellar_psf",3.0))*10)))))
871
+ self.sld_st_amt.setValue(int(max(0, min(100, round(float(p.get("stellar_amount",0.5))*100)))))
872
+ self.sld_nst_amt.setValue(int(max(0, min(100, round(float(p.get("nonstellar_amount",0.5))*100)))))
873
+ # NEW: allow presets to opt into per-channel sharpen (still defaults off without a preset)
874
+ self.chk_sh_sep.setChecked(bool(p.get("sharpen_channels_separately", False)))
875
+
876
+ # Denoise
877
+ self.sld_dn_lum.setValue(int(max(0, min(100, round(float(p.get("denoise_luma",0.5))*100)))))
878
+ self.sld_dn_col.setValue(int(max(0, min(100, round(float(p.get("denoise_color",0.5))*100)))))
879
+ self.cmb_dn_mode.setCurrentText(str(p.get("denoise_mode","full")))
880
+ self.chk_dn_sep.setChecked(bool(p.get("separate_channels", False)))
881
+ # Super-Res
882
+ self.cmb_scale.setCurrentText(str(int(p.get("scale",2))))
883
+
884
+ def build_preset_from_ui(self) -> dict:
885
+ """Snapshot current UI state into a preset dict usable by headless runner / replay."""
886
+ idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
887
+ mode = {0: "sharpen", 1: "denoise", 2: "both", 3: "superres"}.get(idx, "sharpen")
888
+
889
+ preset: dict = {
890
+ "mode": mode,
891
+ "gpu": (self.cmb_gpu.currentIndex() == 0),
892
+ "create_new_view": (self.cmb_target.currentIndex() == 1),
893
+ }
894
+
895
+ # Sharpen / Both block
896
+ if mode in ("sharpen", "both"):
897
+ preset.update({
898
+ "sharpening_mode": self.cmb_sh_mode.currentText(),
899
+ "auto_psf": self.chk_auto_psf.isChecked(),
900
+ "nonstellar_psf": self.sld_psf.value() / 10.0, # slider 10–80 → 1.0–8.0
901
+ "stellar_amount": self.sld_st_amt.value() / 100.0, # 0–100 → 0–1
902
+ "nonstellar_amount": self.sld_nst_amt.value() / 100.0, # 0–100 → 0–1
903
+ "sharpen_channels_separately": self.chk_sh_sep.isChecked(),
904
+ })
905
+
906
+ # Denoise / Both block
907
+ if mode in ("denoise", "both"):
908
+ preset.update({
909
+ "denoise_luma": self.sld_dn_lum.value() / 100.0,
910
+ "denoise_color": self.sld_dn_col.value() / 100.0,
911
+ "denoise_mode": self.cmb_dn_mode.currentText(),
912
+ "separate_channels": self.chk_dn_sep.isChecked(),
913
+ })
914
+
915
+ # Super-res
916
+ if mode == "superres":
917
+ try:
918
+ scale_txt = self.cmb_scale.currentText()
919
+ # can be "2x" in the main dialog or just "2" in the preset dialog
920
+ scale_txt = scale_txt.replace("x", "")
921
+ preset["scale"] = int(scale_txt)
922
+ except Exception:
923
+ preset["scale"] = 2
924
+
925
+ return preset
926
+
927
+
928
+
929
+ def _read_superres_output_main(self):
930
+ self._read_superres_output(self._proc)
931
+
932
+ def _read_superres_output(self, proc: QProcess):
933
+ out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
934
+ if not self._wait: return
935
+ for line in out.splitlines():
936
+ if line.startswith("PROGRESS:") or line.startswith("Progress:"):
937
+ try:
938
+ tail = line.split(":",1)[1] if ":" in line else line.split()[1]
939
+ pct = int(float(tail.strip().replace("%","")))
940
+ self._wait.set_progress(pct)
941
+ except Exception:
942
+ pass
943
+ else:
944
+ self._wait.append_output(line)
945
+
946
+ def _pick_superres_output(self, base: str, scale: int) -> str | None:
947
+ """
948
+ Find the most plausible super-res output file. We try several common
949
+ name patterns, then fall back to the newest/largest file in the output dir.
950
+ """
951
+ out_dir = os.path.join(self.cosmic_root, "output")
952
+
953
+ def _best(paths: list[str]) -> str | None:
954
+ if not paths:
955
+ return None
956
+ # prefer bigger file; tie-break by newest mtime
957
+ paths.sort(key=lambda p: (os.path.getsize(p), os.path.getmtime(p)), reverse=True)
958
+ return paths[0]
959
+
960
+ # common patterns used by different builds
961
+ patterns = [
962
+ f"{base}_upscaled{scale}.*",
963
+ f"{base}_upscaled*.*",
964
+ f"{base}*upscal*.*",
965
+ f"{base}*superres*.*",
966
+ ]
967
+ for pat in patterns:
968
+ hit = _best(glob.glob(os.path.join(out_dir, pat)))
969
+ if hit:
970
+ return hit
971
+
972
+ # fallback: anything in output (we purge it first, so whatever appears is ours)
973
+ return _best(glob.glob(os.path.join(out_dir, "*.*")))
974
+
975
+
976
+ def _on_superres_file(self, _first_path_from_watcher: str):
977
+ # stop waiting UI
978
+ if self._wait: self._wait.close(); self._wait = None
979
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
980
+
981
+ # pick the actual output (robust to naming)
982
+ base = getattr(self, "_sr_base", self._base_name())
983
+ scale = int(getattr(self, "_sr_scale", int(self.cmb_scale.currentText().replace("x",""))))
984
+ out_path = self._pick_superres_output(base, scale)
985
+ if not out_path or not os.path.exists(out_path):
986
+ QMessageBox.critical(self, "Cosmic Clarity", "Super Resolution output file not found.")
987
+ return
988
+
989
+ try:
990
+ img, hdr, bd, mono = load_image(out_path)
991
+ if img is None:
992
+ raise RuntimeError("Unable to load output image.")
993
+ except Exception as e:
994
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load Super Resolution output:\n{e}")
995
+ return
996
+
997
+ dest = img.astype(np.float32, copy=False)
998
+ step_title = "Cosmic Clarity – Super Resolution"
999
+ create_new = (self.cmb_target.currentIndex() == 1)
1000
+
1001
+ if create_new:
1002
+ ok = self._spawn_new_doc_from_numpy(dest, step_title)
1003
+ if not ok:
1004
+ self._apply_to_active(dest, step_title)
1005
+ else:
1006
+ self._apply_to_active(dest, step_title)
1007
+
1008
+ # cleanup mirrors sharpen/denoise
1009
+ try:
1010
+ if getattr(self, "_current_input", None) and os.path.exists(self._current_input):
1011
+ os.remove(self._current_input)
1012
+ if os.path.exists(out_path):
1013
+ os.remove(out_path)
1014
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
1015
+ except Exception:
1016
+ pass
1017
+
1018
+ self.accept()
1019
+
1020
+
1021
+
1022
+ # =============================================================================
1023
+ # Satellite removal
1024
+ # =============================================================================
1025
+
1026
+
1027
+ class CosmicClaritySatelliteDialogPro(QDialog):
1028
+ """
1029
+ Pro dialog that mirrors SASv2 Cosmic Clarity Satellite tab:
1030
+ • Select input/output folders, live monitor, or batch process
1031
+ • GPU toggle, mode (full/luminance), clip trail, sensitivity, skip-save
1032
+ • Tree views for input/output with preview (autostretch + zoom)
1033
+ Uses QSettings key: paths/cosmic_clarity
1034
+ """
1035
+ def __init__(self, parent, doc=None, icon: QIcon | None = None):
1036
+ super().__init__(parent)
1037
+ self.setWindowTitle("Cosmic Clarity – Satellite Removal")
1038
+ self.setWindowFlag(Qt.WindowType.Window, True)
1039
+ self.setWindowModality(Qt.WindowModality.NonModal)
1040
+ self.setModal(False)
1041
+ if icon:
1042
+ try: self.setWindowIcon(icon)
1043
+ except Exception as e:
1044
+ import logging
1045
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1046
+
1047
+ self.settings = QSettings()
1048
+ self.cosmic_clarity_folder = self.settings.value("paths/cosmic_clarity", "", type=str) or ""
1049
+ self.input_folder = ""
1050
+ self.output_folder = ""
1051
+ self.sensitivity = 0.10 # 0.01–0.50
1052
+ self.doc = doc
1053
+
1054
+ self.file_watcher = QFileSystemWatcher()
1055
+ self.file_watcher.directoryChanged.connect(self._on_folder_changed)
1056
+
1057
+ self._sat_thread = None
1058
+ self._wait = None
1059
+
1060
+ self._build_ui()
1061
+
1062
+ # ---------- UI ----------
1063
+ def _build_ui(self):
1064
+ main = QHBoxLayout(self)
1065
+
1066
+ # Left controls
1067
+ left = QVBoxLayout()
1068
+
1069
+ # Input/Output folder chooser row
1070
+ row_io = QHBoxLayout()
1071
+ self.btn_in = QPushButton("Select Input Folder"); self.btn_in.clicked.connect(self._choose_input)
1072
+ self.btn_out = QPushButton("Select Output Folder"); self.btn_out.clicked.connect(self._choose_output)
1073
+ row_io.addWidget(self.btn_in); row_io.addWidget(self.btn_out)
1074
+ left.addLayout(row_io)
1075
+
1076
+ # GPU
1077
+ left.addWidget(QLabel("Use GPU Acceleration:"))
1078
+ self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems(["Yes", "No"])
1079
+ left.addWidget(self.cmb_gpu)
1080
+
1081
+ # Mode
1082
+ left.addWidget(QLabel("Satellite Removal Mode:"))
1083
+ self.cmb_mode = QComboBox(); self.cmb_mode.addItems(["Full", "Luminance"])
1084
+ left.addWidget(self.cmb_mode)
1085
+
1086
+ # Clip trail
1087
+ self.chk_clip = QCheckBox("Clip Satellite Trail to 0.000"); self.chk_clip.setChecked(True)
1088
+ left.addWidget(self.chk_clip)
1089
+
1090
+ # Sensitivity slider
1091
+ row_sens = QHBoxLayout()
1092
+ row_sens.addWidget(QLabel("Clipping Sensitivity (Lower = more aggressive):"))
1093
+ self.sld_sens = QSlider(Qt.Orientation.Horizontal)
1094
+ self.sld_sens.setRange(1, 50) # 0.01–0.50
1095
+ self.sld_sens.setValue(int(self.sensitivity * 100))
1096
+ self.sld_sens.setTickInterval(1)
1097
+ self.sld_sens.setTickPosition(QSlider.TickPosition.TicksBelow)
1098
+ self.sld_sens.valueChanged.connect(self._on_sens_change)
1099
+ row_sens.addWidget(self.sld_sens)
1100
+ self.lbl_sens_val = QLabel(f"{self.sensitivity:.2f}")
1101
+ row_sens.addWidget(self.lbl_sens_val)
1102
+ left.addLayout(row_sens)
1103
+
1104
+ # Skip save if no trail
1105
+ self.chk_skip = QCheckBox("Skip Save if No Satellite Trail Detected")
1106
+ self.chk_skip.setChecked(False)
1107
+ left.addWidget(self.chk_skip)
1108
+
1109
+ # Process row: single image / batch
1110
+ row_proc = QHBoxLayout()
1111
+ self.btn_single = QPushButton("Process Single Image"); self.btn_single.clicked.connect(self._process_single_image)
1112
+ self.btn_batch = QPushButton("Batch Process Input Folder"); self.btn_batch.clicked.connect(self._batch_process)
1113
+ row_proc.addWidget(self.btn_single); row_proc.addWidget(self.btn_batch)
1114
+ left.addLayout(row_proc)
1115
+
1116
+ # Live monitor
1117
+ self.btn_monitor = QPushButton("Live Monitor Input Folder"); self.btn_monitor.clicked.connect(self._live_monitor)
1118
+ left.addWidget(self.btn_monitor)
1119
+
1120
+ # Folder display + chooser for Cosmic Clarity root
1121
+ self.lbl_root = QLabel(f"Folder: {self.cosmic_clarity_folder or 'Not set'}")
1122
+ left.addWidget(self.lbl_root)
1123
+ self.btn_pick_root = QPushButton("Choose Cosmic Clarity Folder…"); self.btn_pick_root.clicked.connect(self._choose_root)
1124
+ left.addWidget(self.btn_pick_root)
1125
+
1126
+ left.addStretch(1)
1127
+
1128
+ # Right: trees
1129
+ right = QVBoxLayout()
1130
+ right.addWidget(QLabel("Input Folder Files:"))
1131
+ self.tree_in = QTreeWidget(); self.tree_in.setHeaderLabels(["Filename"])
1132
+ self.tree_in.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_in, is_input=True))
1133
+ self.tree_in.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1134
+ self.tree_in.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_in, pos, is_input=True))
1135
+ right.addWidget(self.tree_in)
1136
+
1137
+ right.addWidget(QLabel("Output Folder Files:"))
1138
+ self.tree_out = QTreeWidget(); self.tree_out.setHeaderLabels(["Filename"])
1139
+ self.tree_out.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_out, is_input=False))
1140
+ self.tree_out.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1141
+ self.tree_out.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_out, pos, is_input=False))
1142
+ right.addWidget(self.tree_out)
1143
+
1144
+ main.addLayout(left, 2)
1145
+ main.addLayout(right, 1)
1146
+
1147
+ self.resize(900, 600)
1148
+
1149
+ # ---------- Settings / root ----------
1150
+ def _choose_root(self):
1151
+ folder = QFileDialog.getExistingDirectory(self, "Select Cosmic Clarity Folder", self.cosmic_clarity_folder or "")
1152
+ if not folder: return
1153
+ self.cosmic_clarity_folder = folder
1154
+ self.settings.setValue("paths/cosmic_clarity", folder)
1155
+ self.lbl_root.setText(f"Folder: {folder}")
1156
+
1157
+ # ---------- IO folders ----------
1158
+ def _choose_input(self):
1159
+ folder = QFileDialog.getExistingDirectory(self, "Select Input Folder", self.input_folder or "")
1160
+ if not folder: return
1161
+ self.input_folder = folder
1162
+ self.btn_in.setText(f"Input: {os.path.basename(folder)}")
1163
+ self._watch(folder)
1164
+ self._refresh_tree(self.tree_in, folder)
1165
+
1166
+ def _choose_output(self):
1167
+ folder = QFileDialog.getExistingDirectory(self, "Select Output Folder", self.output_folder or "")
1168
+ if not folder: return
1169
+ self.output_folder = folder
1170
+ self.btn_out.setText(f"Output: {os.path.basename(folder)}")
1171
+ self._watch(folder)
1172
+ self._refresh_tree(self.tree_out, folder)
1173
+
1174
+ def _watch(self, folder):
1175
+ try:
1176
+ if folder and folder not in self.file_watcher.directories():
1177
+ self.file_watcher.addPath(folder)
1178
+ except Exception:
1179
+ pass
1180
+
1181
+ def _on_folder_changed(self, path):
1182
+ if path == self.input_folder:
1183
+ self._refresh_tree(self.tree_in, self.input_folder)
1184
+ elif path == self.output_folder:
1185
+ self._refresh_tree(self.tree_out, self.output_folder)
1186
+
1187
+ def _refresh_tree(self, tree: QTreeWidget, folder: str):
1188
+ tree.clear()
1189
+ if not folder or not os.path.isdir(folder): return
1190
+ for fn in sorted(os.listdir(folder)):
1191
+ if fn.lower().endswith(('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
1192
+ '.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef', '.jpg', '.jpeg')):
1193
+ QTreeWidgetItem(tree, [fn])
1194
+
1195
+ # ---------- Sensitivity ----------
1196
+ def _on_sens_change(self, v: int):
1197
+ self.sensitivity = v / 100.0
1198
+ self.lbl_sens_val.setText(f"{self.sensitivity:.2f}")
1199
+
1200
+ # ---------- Context menu ----------
1201
+ def _context_menu(self, tree: QTreeWidget, pos, is_input: bool):
1202
+ item = tree.itemAt(pos)
1203
+ if not item: return
1204
+ menu = QMenu(self)
1205
+ act_del = QAction("Delete File", self)
1206
+ act_ren = QAction("Rename File", self)
1207
+ act_del.triggered.connect(lambda: self._delete_file(tree, is_input))
1208
+ act_ren.triggered.connect(lambda: self._rename_file(tree, is_input))
1209
+ menu.addAction(act_del); menu.addAction(act_ren)
1210
+ menu.exec(tree.viewport().mapToGlobal(pos))
1211
+
1212
+ def _folder_of(self, is_input: bool) -> str:
1213
+ return self.input_folder if is_input else self.output_folder
1214
+
1215
+ def _delete_file(self, tree: QTreeWidget, is_input: bool):
1216
+ item = tree.currentItem()
1217
+ if not item: return
1218
+ folder = self._folder_of(is_input)
1219
+ fp = os.path.join(folder, item.text(0))
1220
+ if not os.path.exists(fp): return
1221
+ if QMessageBox.question(self, "Confirm Delete", f"Delete {item.text(0)}?",
1222
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1223
+ QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
1224
+ os.remove(fp)
1225
+ self._refresh_tree(tree, folder)
1226
+
1227
+ def _rename_file(self, tree: QTreeWidget, is_input: bool):
1228
+ item = tree.currentItem()
1229
+ if not item: return
1230
+ folder = self._folder_of(is_input)
1231
+ fp = os.path.join(folder, item.text(0))
1232
+ new, ok = QInputDialog.getText(self, "Rename File", "Enter new name:", text=item.text(0))
1233
+ if ok and new:
1234
+ np = os.path.join(folder, new)
1235
+ os.rename(fp, np)
1236
+ self._refresh_tree(tree, folder)
1237
+
1238
+ # ---------- Preview ----------
1239
+ def _preview_from_tree(self, tree: QTreeWidget, is_input: bool):
1240
+ item = tree.currentItem()
1241
+ if not item: return
1242
+ folder = self._folder_of(is_input)
1243
+ fp = os.path.join(folder, item.text(0))
1244
+ if not os.path.isfile(fp): return
1245
+ try:
1246
+ img, _, _, is_mono = load_image(fp)
1247
+ if img is None:
1248
+ QMessageBox.critical(self, "Error", "Failed to load image for preview.")
1249
+ return
1250
+ dlg = ImagePreviewDialog(img, is_mono=is_mono, parent=self)
1251
+ dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
1252
+ dlg.show()
1253
+ except Exception as e:
1254
+ QMessageBox.critical(self, "Error", f"Failed to preview image:\n{e}")
1255
+
1256
+ # ---------- Single image processing ----------
1257
+ def _process_single_image(self):
1258
+ # Gather possible open views
1259
+ views = self._collect_open_views()
1260
+
1261
+ # Decide source: view or file
1262
+ use_view = False
1263
+ if views:
1264
+ mb = QMessageBox(self)
1265
+ mb.setWindowTitle("Process Single Image")
1266
+ mb.setText("Choose the source to process:")
1267
+ btn_view = mb.addButton("Open View", QMessageBox.ButtonRole.AcceptRole)
1268
+ btn_file = mb.addButton("File on Disk", QMessageBox.ButtonRole.AcceptRole)
1269
+ mb.addButton(QMessageBox.StandardButton.Cancel)
1270
+ mb.exec()
1271
+ if mb.clickedButton() is btn_view:
1272
+ use_view = True
1273
+ elif mb.clickedButton() is None or mb.clickedButton() == mb.buttons()[-1]: # Cancel
1274
+ return
1275
+
1276
+ # --- Branch 1: Process an OPEN VIEW ---
1277
+ if use_view:
1278
+ # If multiple views, ask which one
1279
+ chosen_doc = None
1280
+ if len(views) == 1:
1281
+ chosen_doc = views[0][1]
1282
+ base_name = self._base_name_for_doc(chosen_doc)
1283
+ else:
1284
+ titles = [t for (t, _) in views]
1285
+ sel, ok = QInputDialog.getItem(self, "Select View", "Choose an open view:", titles, 0, False)
1286
+ if not ok:
1287
+ return
1288
+ idx = titles.index(sel)
1289
+ chosen_doc = views[idx][1]
1290
+ base_name = self._base_name_for_doc(chosen_doc)
1291
+
1292
+ # Stage image from the chosen view
1293
+ temp_in = self._create_temp_folder()
1294
+ temp_out = self._create_temp_folder()
1295
+ staged_in = os.path.join(temp_in, f"{base_name}.tif")
1296
+
1297
+ try:
1298
+ # 32-bit float TIFF like SASv2
1299
+ img = np.clip(np.asarray(chosen_doc.image, dtype=np.float32), 0.0, 1.0)
1300
+ save_image(
1301
+ img, staged_in,
1302
+ "tiff", "32-bit floating point",
1303
+ getattr(chosen_doc, "original_header", None),
1304
+ getattr(chosen_doc, "is_mono", False)
1305
+ )
1306
+ except Exception as e:
1307
+ QMessageBox.critical(self, "Error", f"Failed to stage view for processing:\n{e}")
1308
+ return
1309
+
1310
+ # Run satellite
1311
+ try:
1312
+ self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
1313
+ except Exception as e:
1314
+ QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
1315
+ return
1316
+
1317
+ # Pick up result and apply back to the view
1318
+ out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1319
+ if not out:
1320
+ # Likely --skip-save and no trail, or failure
1321
+ QMessageBox.information(self, "Satellite Removal", "No output produced (possibly no satellite trail detected).")
1322
+ else:
1323
+ out_path = out[0]
1324
+ try:
1325
+ result, hdr, bd, mono = load_image(out_path)
1326
+ if result is None:
1327
+ raise RuntimeError("Unable to load output image.")
1328
+ result = result.astype(np.float32, copy=False)
1329
+
1330
+ # Apply back to the chosen doc
1331
+ if hasattr(chosen_doc, "set_image"):
1332
+ chosen_doc.set_image(result, step_name="Cosmic Clarity – Satellite Removal")
1333
+ elif hasattr(chosen_doc, "apply_numpy"):
1334
+ chosen_doc.apply_numpy(result, step_name="Cosmic Clarity – Satellite Removal")
1335
+ else:
1336
+ chosen_doc.image = result
1337
+ except Exception as e:
1338
+ QMessageBox.critical(self, "Error", f"Failed to apply result to view:\n{e}")
1339
+ # fall through to cleanup
1340
+ finally:
1341
+ # Clean up temp files
1342
+ try:
1343
+ if os.path.exists(out_path): os.remove(out_path)
1344
+ except Exception:
1345
+ pass
1346
+
1347
+ # Clean up temp dirs
1348
+ try:
1349
+ shutil.rmtree(temp_in, ignore_errors=True)
1350
+ shutil.rmtree(temp_out, ignore_errors=True)
1351
+ except Exception:
1352
+ pass
1353
+
1354
+ return # done
1355
+
1356
+ # --- Branch 2: Process a FILE on disk ---
1357
+ file_path, _ = QFileDialog.getOpenFileName(
1358
+ self, "Select Image", "",
1359
+ "Image Files (*.png *.tif *.tiff *.fit *.fits *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef *.jpg *.jpeg)"
1360
+ )
1361
+ if not file_path:
1362
+ QMessageBox.warning(self, "Warning", "No file selected.")
1363
+ return
1364
+
1365
+ temp_in = self._create_temp_folder()
1366
+ temp_out = self._create_temp_folder()
1367
+ try:
1368
+ shutil.copy(file_path, temp_in)
1369
+ except Exception as e:
1370
+ QMessageBox.critical(self, "Error", f"Failed to stage input:\n{e}")
1371
+ return
1372
+
1373
+ try:
1374
+ self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
1375
+ except Exception as e:
1376
+ QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
1377
+ return
1378
+
1379
+ # Move output back next to original
1380
+ out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1381
+ if out:
1382
+ dst = os.path.join(os.path.dirname(file_path), os.path.basename(out[0]))
1383
+ try:
1384
+ shutil.move(out[0], dst)
1385
+ except Exception as e:
1386
+ QMessageBox.critical(self, "Error", f"Failed to save result:\n{e}")
1387
+ return
1388
+ QMessageBox.information(self, "Success", f"Processed image saved to:\n{dst}")
1389
+ else:
1390
+ QMessageBox.warning(self, "Warning", "No output file found.")
1391
+
1392
+ # Cleanup
1393
+ try:
1394
+ shutil.rmtree(temp_in, ignore_errors=True)
1395
+ shutil.rmtree(temp_out, ignore_errors=True)
1396
+ except Exception:
1397
+ pass
1398
+
1399
+ def _collect_open_views(self):
1400
+ """
1401
+ Return a list of (title, doc) for all open MDI views with an image.
1402
+ Includes self.doc if supplied and valid.
1403
+ """
1404
+ views = []
1405
+ # include self.doc first if valid
1406
+ if getattr(self, "doc", None) is not None and getattr(self.doc, "image", None) is not None:
1407
+ title = getattr(self.doc, "display_name", lambda: "Active View")()
1408
+ views.append((title, self.doc))
1409
+
1410
+ # try to enumerate MDI subwindows on the parent main window
1411
+ try:
1412
+ main = self.parent()
1413
+ mdi = getattr(main, "mdi", None)
1414
+ if mdi is not None:
1415
+ for sw in mdi.subWindowList():
1416
+ w = sw.widget()
1417
+ d = getattr(w, "document", None)
1418
+ if d is not None and getattr(d, "image", None) is not None:
1419
+ t = w.windowTitle() if hasattr(w, "windowTitle") else getattr(d, "display_name", lambda:"View")()
1420
+ # don’t duplicate self.doc if it’s the same object
1421
+ if not any(d is existing for _, existing in views):
1422
+ views.append((t, d))
1423
+ except Exception:
1424
+ pass
1425
+
1426
+ return views
1427
+
1428
+ def _base_name_for_doc(self, d):
1429
+ """Derive a simple basename for staging temp files from a document."""
1430
+ fp = getattr(d, "file_path", None)
1431
+ if isinstance(fp, str) and fp:
1432
+ return os.path.splitext(os.path.basename(fp))[0]
1433
+ name = getattr(d, "display_name", None)
1434
+ if callable(name):
1435
+ try:
1436
+ n = name() or ""
1437
+ if n:
1438
+ return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
1439
+ except Exception:
1440
+ pass
1441
+ return "image"
1442
+
1443
+
1444
+ # ---------- Batch ----------
1445
+ def _batch_process(self):
1446
+ if not self.input_folder or not self.output_folder:
1447
+ QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
1448
+ return
1449
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1450
+ if not os.path.exists(exe):
1451
+ QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
1452
+ return
1453
+
1454
+ cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=True, monitor=False)
1455
+ self._run_threaded(cmd, title="Satellite – Batch processing")
1456
+
1457
+ # ---------- Live monitor ----------
1458
+ def _live_monitor(self):
1459
+ if not self.input_folder or not self.output_folder:
1460
+ QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
1461
+ return
1462
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1463
+ if not os.path.exists(exe):
1464
+ QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
1465
+ return
1466
+
1467
+ cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=False, monitor=True)
1468
+ self.sld_sens.setEnabled(False)
1469
+ self._run_threaded(cmd, title="Satellite – Live monitoring", on_finish=lambda: self.sld_sens.setEnabled(True))
1470
+
1471
+ # ---------- Command / run ----------
1472
+ def _build_cmd(self, exe_path: str, in_dir: str, out_dir: str, *, batch: bool, monitor: bool):
1473
+ cmd = [
1474
+ exe_path,
1475
+ "--input", in_dir,
1476
+ "--output", out_dir,
1477
+ "--mode", self.cmb_mode.currentText().lower(),
1478
+ ]
1479
+ if self.cmb_gpu.currentText() == "Yes":
1480
+ cmd.append("--use-gpu")
1481
+ if self.chk_clip.isChecked():
1482
+ cmd.append("--clip-trail")
1483
+ else:
1484
+ cmd.append("--no-clip-trail")
1485
+ if self.chk_skip.isChecked():
1486
+ cmd.append("--skip-save")
1487
+ if batch:
1488
+ cmd.append("--batch")
1489
+ if monitor:
1490
+ cmd.append("--monitor")
1491
+ cmd += ["--sensitivity", f"{self.sensitivity}"]
1492
+ return cmd
1493
+
1494
+ def _run_threaded(self, cmd, title="Processing…", on_finish=None):
1495
+ # Wait dialog + threaded subprocess (mirrors SASv2 SatelliteProcessingThread)
1496
+ self._wait = WaitDialog(title, self)
1497
+ self._wait.show()
1498
+
1499
+ self._sat_thread = SatelliteProcessingThread(cmd)
1500
+ self._sat_thread.log_signal.connect(self._wait.append_output)
1501
+ self._sat_thread.finished_signal.connect(lambda: self._on_thread_finished(on_finish))
1502
+ self._wait.cancelled.connect(self._cancel_sat_thread)
1503
+ self._sat_thread.start()
1504
+
1505
+ def _cancel_sat_thread(self):
1506
+ if self._sat_thread:
1507
+ self._sat_thread.cancel()
1508
+ if self._wait:
1509
+ self._wait.close()
1510
+ self._wait = None
1511
+
1512
+ def _on_thread_finished(self, on_finish):
1513
+ if self._wait: self._wait.close(); self._wait = None
1514
+ if callable(on_finish):
1515
+ try: on_finish()
1516
+ except Exception as e:
1517
+ import logging
1518
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1519
+ QMessageBox.information(self, "Done", "Processing finished.")
1520
+
1521
+ def _run_satellite(self, *, input_dir: str, output_dir: str, live: bool):
1522
+ if not self.cosmic_clarity_folder:
1523
+ raise RuntimeError("Cosmic Clarity folder not set. Choose it in Preferences or with the button below.")
1524
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1525
+ if not os.path.exists(exe):
1526
+ raise FileNotFoundError(f"Executable not found: {exe}")
1527
+
1528
+ cmd = self._build_cmd(exe, input_dir, output_dir, batch=not live, monitor=live)
1529
+ print("Running command:", " ".join(cmd))
1530
+ subprocess.run(cmd, check=True)
1531
+
1532
+ # ---------- Utils ----------
1533
+ @staticmethod
1534
+ def _create_temp_folder(base="~"):
1535
+ user_dir = os.path.expanduser(base)
1536
+ temp_folder = os.path.join(user_dir, "CosmicClarityTemp")
1537
+ os.makedirs(temp_folder, exist_ok=True)
1538
+ return temp_folder
1539
+
1540
+
1541
+ class SatelliteProcessingThread(QThread):
1542
+ log_signal = pyqtSignal(str)
1543
+ finished_signal = pyqtSignal()
1544
+ def __init__(self, command):
1545
+ super().__init__()
1546
+ self.command = command
1547
+ self.process = None
1548
+
1549
+ def cancel(self):
1550
+ if self.process:
1551
+ try:
1552
+ self.process.kill()
1553
+ except Exception:
1554
+ pass
1555
+
1556
+ def run(self):
1557
+ try:
1558
+ self.log_signal.emit("Running command: " + " ".join(self.command))
1559
+ self.process = subprocess.Popen(
1560
+ self.command,
1561
+ stdout=subprocess.PIPE,
1562
+ stderr=subprocess.STDOUT,
1563
+ universal_newlines=True,
1564
+ text=True
1565
+ )
1566
+ # Read output to prevent deadlock
1567
+ for line in iter(self.process.stdout.readline, ""):
1568
+ if not line: break
1569
+ # Optional: emit log signal for verbose output?
1570
+ # The original code didn't log stdout, but blocked.
1571
+ # Let's just log it if we want, or consume it.
1572
+ # The prompt says "I think starnet stops but the window doesnt close"
1573
+ # so maybe verbose logging isn't the priority, but consuming stdout is mandatory.
1574
+ # However, the original code used subprocess.run which captures output if specified,
1575
+ # but it didn't specify capture_output=True or stdout/stderr args in the snippet I saw?
1576
+ # Wait, let's check the snippet I saw earlier for SatelliteProcessingThread.
1577
+ pass
1578
+
1579
+ # Close stdout to ensure cleanup
1580
+ if self.process.stdout:
1581
+ self.process.stdout.close()
1582
+
1583
+ rc = self.process.wait()
1584
+ if rc == 0:
1585
+ self.log_signal.emit("Processing complete.")
1586
+ else:
1587
+ self.log_signal.emit(f"Processing failed with code {rc}")
1588
+
1589
+ except Exception as e:
1590
+ self.log_signal.emit(f"Unexpected error: {e}")
1591
+ finally:
1592
+ self.process = None
1593
+ self.finished_signal.emit()