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,1589 @@
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
+ if icon:
278
+ try: self.setWindowIcon(icon)
279
+ except Exception as e:
280
+ import logging
281
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
282
+
283
+ self.parent_ref = parent
284
+ self.doc = doc
285
+ self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
286
+ self.cosmic_root = _get_cosmic_root_from_settings()
287
+
288
+ v = QVBoxLayout(self)
289
+
290
+ # ---------------- Controls ----------------
291
+ grp = QGroupBox(self.tr("Parameters"))
292
+ grid = QGridLayout(grp)
293
+
294
+ # Mode
295
+ grid.addWidget(QLabel(self.tr("Mode:")), 0, 0)
296
+ self.cmb_mode = QComboBox()
297
+ self.cmb_mode.addItems(["Sharpen", "Denoise", "Both", "Super Resolution"])
298
+ self.cmb_mode.currentIndexChanged.connect(self._mode_changed)
299
+ grid.addWidget(self.cmb_mode, 0, 1, 1, 2)
300
+
301
+ # GPU
302
+ grid.addWidget(QLabel(self.tr("Use GPU:")), 1, 0)
303
+ self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems([self.tr("Yes"), self.tr("No")])
304
+ grid.addWidget(self.cmb_gpu, 1, 1)
305
+
306
+ # Sharpen block
307
+ self.lbl_sh_mode = QLabel("Sharpening Mode:")
308
+ self.cmb_sh_mode = QComboBox(); self.cmb_sh_mode.addItems(["Both", "Stellar Only", "Non-Stellar Only"])
309
+ grid.addWidget(self.lbl_sh_mode, 2, 0); grid.addWidget(self.cmb_sh_mode, 2, 1)
310
+
311
+ self.chk_sh_sep = QCheckBox("Sharpen RGB channels separately")
312
+ self.chk_sh_sep.setToolTip(
313
+ "Run the mono sharpening model independently on R, G, and B instead of a shared color model.\n"
314
+ "Use for difficult color data where channels need slightly different sharpening."
315
+ )
316
+ grid.addWidget(self.chk_sh_sep, 3, 0)
317
+
318
+ self.chk_auto_psf = QCheckBox("Auto Detect PSF"); self.chk_auto_psf.setChecked(True)
319
+ grid.addWidget(self.chk_auto_psf, 3, 1)
320
+
321
+ self.lbl_psf = QLabel("Non-Stellar PSF (1.0–8.0): 3.0")
322
+ self.sld_psf = QSlider(Qt.Orientation.Horizontal); self.sld_psf.setRange(10, 80); self.sld_psf.setValue(30)
323
+ self.sld_psf.valueChanged.connect(self._psf_label)
324
+ grid.addWidget(self.lbl_psf, 4, 0, 1, 2); grid.addWidget(self.sld_psf, 5, 0, 1, 3)
325
+
326
+ self.lbl_st_amt = QLabel("Stellar Amount (0–1): 0.50")
327
+ self.sld_st_amt = QSlider(Qt.Orientation.Horizontal); self.sld_st_amt.setRange(0, 100); self.sld_st_amt.setValue(50)
328
+
329
+ self.sld_st_amt.valueChanged.connect(self._on_st_amt)
330
+ grid.addWidget(self.lbl_st_amt, 6, 0, 1, 2); grid.addWidget(self.sld_st_amt, 7, 0, 1, 3)
331
+
332
+ self.lbl_nst_amt = QLabel("Non-Stellar Amount (0–1): 0.50")
333
+ self.sld_nst_amt = QSlider(Qt.Orientation.Horizontal); self.sld_nst_amt.setRange(0, 100); self.sld_nst_amt.setValue(50)
334
+
335
+ self.sld_nst_amt.valueChanged.connect(self._on_nst_amt)
336
+ grid.addWidget(self.lbl_nst_amt, 8, 0, 1, 2); grid.addWidget(self.sld_nst_amt, 9, 0, 1, 3)
337
+
338
+ # Denoise block
339
+ self.lbl_dn_lum = QLabel("Luminance Denoise (0–1): 0.50")
340
+ self.sld_dn_lum = QSlider(Qt.Orientation.Horizontal); self.sld_dn_lum.setRange(0, 100); self.sld_dn_lum.setValue(50)
341
+ self.sld_dn_lum.valueChanged.connect(lambda v: self.lbl_dn_lum.setText(f"Luminance Denoise (0–1): {v/100:.2f}"))
342
+ grid.addWidget(self.lbl_dn_lum, 10, 0, 1, 2); grid.addWidget(self.sld_dn_lum, 11, 0, 1, 3)
343
+
344
+ self.lbl_dn_col = QLabel("Color Denoise (0–1): 0.50")
345
+ self.sld_dn_col = QSlider(Qt.Orientation.Horizontal); self.sld_dn_col.setRange(0, 100); self.sld_dn_col.setValue(50)
346
+ self.sld_dn_col.valueChanged.connect(lambda v: self.lbl_dn_col.setText(f"Color Denoise (0–1): {v/100:.2f}"))
347
+ grid.addWidget(self.lbl_dn_col, 12, 0, 1, 2); grid.addWidget(self.sld_dn_col, 13, 0, 1, 3)
348
+
349
+ self.lbl_dn_mode = QLabel("Denoise Mode:")
350
+ self.cmb_dn_mode = QComboBox(); self.cmb_dn_mode.addItems(["full", "luminance"])
351
+ grid.addWidget(self.lbl_dn_mode, 14, 0); grid.addWidget(self.cmb_dn_mode, 14, 1)
352
+
353
+ self.chk_dn_sep = QCheckBox("Process RGB channels separately")
354
+ grid.addWidget(self.chk_dn_sep, 15, 1)
355
+
356
+ # Super-res
357
+ self.lbl_scale = QLabel("Scale Factor:")
358
+ self.cmb_scale = QComboBox(); self.cmb_scale.addItems(["2x", "3x", "4x"])
359
+ grid.addWidget(self.lbl_scale, 16, 0); grid.addWidget(self.cmb_scale, 16, 1)
360
+
361
+ # Apply target
362
+ grid.addWidget(QLabel("Apply to:"), 17, 0)
363
+ self.cmb_target = QComboBox(); self.cmb_target.addItems(["Overwrite active view", "Create new view"])
364
+ grid.addWidget(self.cmb_target, 17, 1, 1, 2)
365
+
366
+ v.addWidget(grp)
367
+
368
+ # Buttons
369
+ row = QHBoxLayout()
370
+ b_run = QPushButton(self.tr("Execute")); b_run.clicked.connect(self._run_main)
371
+ b_close = QPushButton(self.tr("Close")); b_close.clicked.connect(self.reject)
372
+ row.addStretch(1); row.addWidget(b_run); row.addWidget(b_close)
373
+ v.addLayout(row)
374
+
375
+ self._mode_changed() # set initial visibility
376
+
377
+ self._wait = None
378
+ self._wait_thread = None
379
+ self._proc = None
380
+
381
+ self._headless = bool(headless)
382
+ if self._headless:
383
+ # Don’t show the control panel; we’ll still exec() to run the event loop.
384
+ try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
385
+ except Exception as e:
386
+ import logging
387
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
388
+ self.resize(560, 540)
389
+
390
+ # ----- UI helpers -----
391
+ def _headless_guard_active(self) -> bool:
392
+ # 1) fast path: flags on the main window
393
+ try:
394
+ p = self.parent()
395
+ if p and (getattr(p, "_cosmicclarity_guard", False) or getattr(p, "_cosmicclarity_headless_running", False)):
396
+ return True
397
+ except Exception:
398
+ pass
399
+ # 2) cross-module path: QSettings flag set by the preset runner
400
+ try:
401
+ s = QSettings()
402
+ v = s.value("cc/headless_in_progress", False, type=bool)
403
+ return bool(v)
404
+ except Exception:
405
+ # fallback if type kwarg unsupported in some Qt builds
406
+ try:
407
+ return bool(QSettings().value("cc/headless_in_progress", False))
408
+ except Exception:
409
+ return False
410
+
411
+ # Never show if guard is active
412
+ def showEvent(self, e):
413
+ if self._headless_guard_active():
414
+ e.ignore()
415
+ QTimer.singleShot(0, self.reject)
416
+ return
417
+ return super().showEvent(e)
418
+
419
+ # Never exec if guard is active
420
+ def exec(self) -> int:
421
+ if self._headless_guard_active():
422
+ return 0
423
+ return super().exec()
424
+
425
+
426
+ def _on_st_amt(self, v: int): self.lbl_st_amt.setText(f"Stellar Amount (0–1): {v/100:.2f}")
427
+ def _on_nst_amt(self, v: int): self.lbl_nst_amt.setText(f"Non-Stellar Amount (0–1): {v/100:.2f}")
428
+
429
+ def _psf_label(self):
430
+ self.lbl_psf.setText(f"Non-Stellar PSF (1.0–8.0): {self.sld_psf.value()/10:.1f}")
431
+
432
+ def _mode_changed(self):
433
+ idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
434
+ # Sharpen controls visible if Sharpen or Both
435
+ show_sh = idx in (0, 2)
436
+ 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):
437
+ w.setVisible(show_sh)
438
+
439
+ # Denoise controls visible if Denoise or Both
440
+ show_dn = idx in (1, 2)
441
+ 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):
442
+ w.setVisible(show_dn)
443
+
444
+ # Super-res controls visible if Super-Res
445
+ show_sr = idx == 3
446
+ for w in (self.lbl_scale, self.cmb_scale):
447
+ w.setVisible(show_sr)
448
+
449
+ # GPU hidden for superres (matches your SASv2)
450
+ self.cmb_gpu.setVisible(not show_sr)
451
+ self.parentWidget()
452
+
453
+ # ----- Validation -----
454
+ def _validate_root(self) -> bool:
455
+ if not self.cosmic_root:
456
+ QMessageBox.warning(self, "Cosmic Clarity", "No Cosmic Clarity folder is set. Set it in Preferences (Settings).")
457
+ return False
458
+ # basic presence check (don’t force a specific exe here, we do that later)
459
+ if not os.path.isdir(self.cosmic_root):
460
+ QMessageBox.warning(self, "Cosmic Clarity", "The Cosmic Clarity folder in Settings doesn’t exist anymore.")
461
+ return False
462
+ return True
463
+
464
+ # ----- Execution -----
465
+ def _run_main(self):
466
+ if not self._validate_root():
467
+ return
468
+
469
+ # --- Register this run as "last action" for replay ---
470
+ try:
471
+ main = self.parent_ref or self.parent()
472
+ if main is not None:
473
+ preset = self.build_preset_from_ui()
474
+ payload = {
475
+ "cid": "cosmic_clarity",
476
+ "preset": preset,
477
+ # optional label for your UI if you use it
478
+ "label": f"Cosmic Clarity ({preset.get('mode', 'sharpen')})",
479
+ }
480
+
481
+ # Preferred: use the same helper you used for CLAHE / Morphology / PixelMath
482
+ if hasattr(main, "_set_last_headless_command"):
483
+ main._set_last_headless_command(payload)
484
+ else:
485
+ # Fallback: write directly if you're using a bare _last_headless_command dict
486
+ setattr(main, "_last_headless_command", payload)
487
+ if hasattr(main, "_update_replay_button"):
488
+ main._update_replay_button()
489
+ except Exception:
490
+ # Never let replay bookkeeping kill the effect itself
491
+ pass
492
+
493
+ _ensure_dirs(self.cosmic_root)
494
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=False)
495
+
496
+
497
+ # Determine queue of operations
498
+ mode_idx = self.cmb_mode.currentIndex()
499
+ if mode_idx == 3:
500
+ # Super-res path
501
+ self._run_superres(); return
502
+ elif mode_idx == 0:
503
+ ops = [("sharpen", "_sharpened")]
504
+ elif mode_idx == 1:
505
+ ops = [("denoise", "_denoised")]
506
+ else:
507
+ ops = [("sharpen", "_sharpened"), ("denoise", "_denoised")]
508
+
509
+ # Save current doc image to input
510
+ base = self._base_name()
511
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
512
+ try:
513
+ # Use atomic fsync
514
+ base = self._base_name()
515
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
516
+ arr = self.orig # already float32 [0..1]
517
+
518
+ def _writer(tmp_path):
519
+ # reuse your save_image impl to tmp
520
+ save_image(arr, tmp_path, "tiff", "32-bit floating point",
521
+ getattr(self.doc, "original_header", None),
522
+ getattr(self.doc, "is_mono", False))
523
+
524
+ try:
525
+ _atomic_fsync_replace(_writer, in_path)
526
+ except Exception as e:
527
+ print("Atomic save failed:", repr(e))
528
+ raise
529
+
530
+ # ensure stable on disk before launching
531
+ if not _wait_stable_file(in_path):
532
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to stage input TIFF (not stable on disk).")
533
+ return
534
+ except Exception as e:
535
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
536
+ return
537
+
538
+ # Run queue
539
+ self._op_queue = ops
540
+ self._current_input = in_path
541
+ self._run_next()
542
+
543
+ def _run_next(self):
544
+ if not self._op_queue:
545
+ # If we ever get here without more steps, we’re done.
546
+ self.accept()
547
+ return
548
+ mode, suffix = self._op_queue.pop(0)
549
+ exe_name = _platform_exe_names(mode)
550
+ exe_path = os.path.join(self.cosmic_root, exe_name)
551
+ if not os.path.exists(exe_path):
552
+ QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
553
+ return
554
+
555
+ # Build args (SASv2 flags mirrored)
556
+ args = []
557
+ if mode == "sharpen":
558
+ psf = self.sld_psf.value()/10.0
559
+ args += [
560
+ "--sharpening_mode", self.cmb_sh_mode.currentText(),
561
+ "--stellar_amount", f"{self.sld_st_amt.value()/100:.2f}",
562
+ "--nonstellar_strength", f"{psf:.1f}",
563
+ "--nonstellar_amount", f"{self.sld_nst_amt.value()/100:.2f}"
564
+ ]
565
+ # NEW: per-channel sharpen toggle
566
+ if self.chk_sh_sep.isChecked():
567
+ args.append("--sharpen_channels_separately")
568
+
569
+ if self.chk_auto_psf.isChecked():
570
+ args.append("--auto_detect_psf")
571
+ elif mode == "denoise":
572
+ args += ["--denoise_strength", f"{self.sld_dn_lum.value()/100:.2f}",
573
+ "--color_denoise_strength", f"{self.sld_dn_col.value()/100:.2f}",
574
+ "--denoise_mode", self.cmb_dn_mode.currentText()]
575
+ if self.chk_dn_sep.isChecked():
576
+ args.append("--separate_channels")
577
+
578
+ if self.cmb_gpu.currentText() == "No" and mode in ("sharpen","denoise"):
579
+ args.append("--disable_gpu")
580
+
581
+ # Run process
582
+ self._proc = QProcess(self)
583
+ self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
584
+ self._proc.setWorkingDirectory(self.cosmic_root) # <-- add this line
585
+
586
+ self._proc.readyReadStandardOutput.connect(self._read_proc_output_main)
587
+ from functools import partial
588
+ self._proc.finished.connect(partial(self._on_proc_finished, mode, suffix))
589
+ self._proc.setProgram(exe_path)
590
+ self._proc.setArguments(args)
591
+ self._proc.start()
592
+ if not self._proc.waitForStarted(3000):
593
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to start process.")
594
+ return
595
+
596
+ # Wait for output file
597
+ base = self._base_name()
598
+ out_glob = os.path.join(self.cosmic_root, "output", f"{base}{suffix}.*")
599
+ self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
600
+ self._wait.cancelled.connect(self._cancel_all)
601
+ self._wait.show()
602
+
603
+ self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
604
+ self._wait_thread.fileFound.connect(lambda path, mode=mode: self._on_output_file(path, mode))
605
+ self._wait_thread.error.connect(self._on_wait_error)
606
+ self._wait_thread.cancelled.connect(self._on_wait_cancel)
607
+ self._wait_thread.start()
608
+
609
+ def _read_proc_output_main(self):
610
+ self._read_proc_output(self._proc, which="main")
611
+
612
+ def _read_proc_output(self, proc: QProcess, which="main"):
613
+ out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
614
+ if not self._wait: return
615
+ for line in out.splitlines():
616
+ line = line.strip()
617
+ if not line: continue
618
+ if line.startswith("Progress:"):
619
+ try:
620
+ pct = float(line.split()[1].replace("%",""))
621
+ self._wait.set_progress(int(pct))
622
+ except Exception:
623
+ pass
624
+ else:
625
+ self._wait.append_output(line)
626
+ print(f"[CC] {line}")
627
+
628
+ def _on_proc_finished(self, mode, suffix, code, status):
629
+ if code != 0:
630
+ if self._wait: self._wait.append_output(f"Process exited with code {code}.")
631
+ # still let the file-watcher decide success/failure (some exes write before exit)
632
+
633
+ def _on_output_file(self, out_path: str, mode: str):
634
+ # stop waiting UI
635
+ if self._wait: self._wait.close(); self._wait = None
636
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
637
+
638
+ has_more = bool(self._op_queue)
639
+
640
+ # --- Optimization: Chained Execution Fast Path ---
641
+ # If we have more steps, skip the expensive load/display/save cycle.
642
+ # Just move the output file to be the input for the next step.
643
+ if has_more:
644
+ if not out_path or not os.path.exists(out_path):
645
+ QMessageBox.critical(self, "Cosmic Clarity", "Output file missing during chain execution.")
646
+ self._op_queue.clear()
647
+ return
648
+
649
+ base = self._base_name()
650
+ next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
651
+ prev_in = getattr(self, "_current_input", None)
652
+
653
+ try:
654
+ # Direct move/copy instead of decode+encode
655
+ if os.path.abspath(out_path) != os.path.abspath(next_in):
656
+ # Windows cannot atomic replace if target exists via os.rename usually,
657
+ # but shutil.move is generally robust.
658
+ # We remove target first to be sure.
659
+ if os.path.exists(next_in):
660
+ os.remove(next_in)
661
+ shutil.move(out_path, next_in)
662
+
663
+ # Ensure stability of the *new* input
664
+ if not _wait_stable_file(next_in):
665
+ QMessageBox.critical(self, "Cosmic Clarity", "Staged input for next step is unstable.")
666
+ self._op_queue.clear()
667
+ return
668
+
669
+ self._current_input = next_in
670
+
671
+ # Cleanup previous input if distinct
672
+ if prev_in and prev_in != next_in and os.path.exists(prev_in):
673
+ os.remove(prev_in)
674
+
675
+ except Exception as e:
676
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to stage next step:\n{e}")
677
+ self._op_queue.clear()
678
+ return
679
+
680
+ # Trigger next step immediately
681
+ QTimer.singleShot(50, self._run_next)
682
+ return
683
+
684
+ # --- Final Step (or Single Step): Load and Display ---
685
+ try:
686
+ img, hdr, bd, mono = load_image(out_path)
687
+ if img is None:
688
+ raise RuntimeError("Unable to load output image.")
689
+ except Exception as e:
690
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load output:\n{e}")
691
+ return
692
+
693
+ dest = img.astype(np.float32, copy=False)
694
+
695
+ # Apply to document
696
+ step_title = f"Cosmic Clarity – {mode.title()}"
697
+ create_new = (self.cmb_target.currentIndex() == 1)
698
+
699
+ if create_new:
700
+ ok = self._spawn_new_doc_from_numpy(dest, step_title)
701
+ if not ok:
702
+ self._apply_to_active(dest, step_title)
703
+ else:
704
+ self._apply_to_active(dest, step_title)
705
+
706
+ # Cleanup final output
707
+ if out_path and os.path.exists(out_path):
708
+ try: os.remove(out_path)
709
+ except OSError: pass
710
+
711
+ # Cleanup final input
712
+ prev_in = getattr(self, "_current_input", None)
713
+ if prev_in and os.path.exists(prev_in):
714
+ try: os.remove(prev_in)
715
+ except OSError: pass
716
+
717
+ # Final purge
718
+ try:
719
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
720
+ except Exception:
721
+ pass
722
+ self.accept()
723
+
724
+
725
+ def _on_wait_error(self, msg: str):
726
+ if self._wait: self._wait.close(); self._wait = None
727
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
728
+ QMessageBox.critical(self, "Cosmic Clarity", msg)
729
+
730
+ def _on_wait_cancel(self):
731
+ if self._wait: self._wait.close(); self._wait = None
732
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
733
+
734
+ def _cancel_all(self):
735
+ try:
736
+ if self._proc: self._proc.kill()
737
+ except Exception as e:
738
+ import logging
739
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
740
+ self._on_wait_cancel()
741
+
742
+ def _base_name(self) -> str:
743
+ fp = getattr(self.doc, "file_path", None)
744
+ if isinstance(fp, str) and fp:
745
+ return os.path.splitext(os.path.basename(fp))[0]
746
+ name = getattr(self.doc, "display_name", None)
747
+ if callable(name):
748
+ try:
749
+ n = name() or ""
750
+ if n:
751
+ return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
752
+ except Exception:
753
+ pass
754
+ return "image"
755
+
756
+
757
+ def _apply_to_active(self, arr: np.ndarray, step_title: str):
758
+ """Overwrite the active document image."""
759
+ if hasattr(self.doc, "set_image"):
760
+ self.doc.set_image(arr, step_name=step_title)
761
+ elif hasattr(self.doc, "apply_numpy"):
762
+ self.doc.apply_numpy(arr, step_name=step_title)
763
+ else:
764
+ self.doc.image = arr
765
+
766
+ def _spawn_new_doc_from_numpy(self, arr: np.ndarray, step_title: str) -> bool:
767
+ """Create a brand-new document + view from a numpy array. Returns True on success."""
768
+ mw = self.parent()
769
+ dm = getattr(mw, "docman", None)
770
+ if dm is None:
771
+ return False
772
+
773
+ # build a reasonable title and metadata
774
+ base_name = getattr(self.doc, "display_name", None)
775
+ base = base_name() if callable(base_name) else (base_name or "Image")
776
+ title = f"{base} [{step_title}]"
777
+
778
+ meta = {
779
+ "bit_depth": "32-bit floating point",
780
+ "is_mono": (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1),
781
+ "source": "Cosmic Clarity",
782
+ "original_header": getattr(self.doc, "original_header", None),
783
+ }
784
+
785
+ try:
786
+ new_doc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
787
+ if hasattr(mw, "_spawn_subwindow_for"): # same hook used in ABE
788
+ mw._spawn_subwindow_for(new_doc)
789
+ return True
790
+ except Exception as e:
791
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to create new view:\n{e}")
792
+ return False
793
+
794
+
795
+ # ----- Super-resolution -----
796
+ def _run_superres(self):
797
+ exe_name = _platform_exe_names("superres")
798
+ exe_path = os.path.join(self.cosmic_root, exe_name)
799
+ if not os.path.exists(exe_path):
800
+ QMessageBox.critical(self, "Cosmic Clarity", f"Super Resolution executable not found:\n{exe_path}")
801
+ return
802
+
803
+ _ensure_dirs(self.cosmic_root)
804
+ # 🔸 purge output too so any file that appears is from THIS run
805
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
806
+
807
+ base = self._base_name()
808
+ in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
809
+ try:
810
+ save_image(self.orig, in_path, "tiff", "32-bit floating point",
811
+ getattr(self.doc, "original_header", None),
812
+ getattr(self.doc, "is_mono", False))
813
+ except Exception as e:
814
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
815
+ return
816
+ self._current_input = in_path
817
+
818
+ scale = int(self.cmb_scale.currentText().replace("x", ""))
819
+ # keep args as-is if your superres build expects explicit paths
820
+ args = [
821
+ "--input", in_path,
822
+ "--output_dir", os.path.join(self.cosmic_root, "output"),
823
+ "--scale", str(scale),
824
+ "--model_dir", self.cosmic_root
825
+ ]
826
+
827
+ self._proc = QProcess(self)
828
+ self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
829
+ self._proc.readyReadStandardOutput.connect(self._read_superres_output_main)
830
+ # finished handler not required; the file watcher drives success
831
+ self._proc.setProgram(exe_path)
832
+ self._proc.setArguments(args)
833
+ self._proc.start()
834
+ if not self._proc.waitForStarted(3000):
835
+ QMessageBox.critical(self, "Cosmic Clarity", "Failed to start Super Resolution process.")
836
+ return
837
+
838
+ self._wait = WaitDialog("Cosmic Clarity – Super Resolution", self)
839
+ self._wait.cancelled.connect(self._cancel_all)
840
+ self._wait.show()
841
+
842
+ # 🔸 Watch broadly; we purged output so the first file is from this run.
843
+ # We'll still re-pick the exact file in the slot for safety.
844
+ self._sr_base = base
845
+ self._sr_scale = scale
846
+ out_glob = os.path.join(self.cosmic_root, "output", "*.*")
847
+
848
+ self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
849
+ self._wait_thread.fileFound.connect(self._on_superres_file) # path arg is ignored; we reselect
850
+ self._wait_thread.error.connect(self._on_wait_error)
851
+ self._wait_thread.cancelled.connect(self._on_wait_cancel)
852
+ self._wait_thread.start()
853
+
854
+
855
+ def apply_preset(self, p: dict):
856
+ # Mode
857
+ mode = str(p.get("mode","sharpen")).lower()
858
+ self.cmb_mode.setCurrentIndex({"sharpen":0,"denoise":1,"both":2,"superres":3}.get(mode,0))
859
+ # GPU
860
+ self.cmb_gpu.setCurrentIndex(0 if p.get("gpu", True) else 1)
861
+ # Target
862
+ self.cmb_target.setCurrentIndex(1 if p.get("create_new_view", False) else 0)
863
+ # Sharpen
864
+ self.cmb_sh_mode.setCurrentText(p.get("sharpening_mode","Both"))
865
+ self.chk_auto_psf.setChecked(bool(p.get("auto_psf", True)))
866
+ self.sld_psf.setValue(int(max(10, min(80, round(float(p.get("nonstellar_psf",3.0))*10)))))
867
+ self.sld_st_amt.setValue(int(max(0, min(100, round(float(p.get("stellar_amount",0.5))*100)))))
868
+ self.sld_nst_amt.setValue(int(max(0, min(100, round(float(p.get("nonstellar_amount",0.5))*100)))))
869
+ # NEW: allow presets to opt into per-channel sharpen (still defaults off without a preset)
870
+ self.chk_sh_sep.setChecked(bool(p.get("sharpen_channels_separately", False)))
871
+
872
+ # Denoise
873
+ self.sld_dn_lum.setValue(int(max(0, min(100, round(float(p.get("denoise_luma",0.5))*100)))))
874
+ self.sld_dn_col.setValue(int(max(0, min(100, round(float(p.get("denoise_color",0.5))*100)))))
875
+ self.cmb_dn_mode.setCurrentText(str(p.get("denoise_mode","full")))
876
+ self.chk_dn_sep.setChecked(bool(p.get("separate_channels", False)))
877
+ # Super-Res
878
+ self.cmb_scale.setCurrentText(str(int(p.get("scale",2))))
879
+
880
+ def build_preset_from_ui(self) -> dict:
881
+ """Snapshot current UI state into a preset dict usable by headless runner / replay."""
882
+ idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
883
+ mode = {0: "sharpen", 1: "denoise", 2: "both", 3: "superres"}.get(idx, "sharpen")
884
+
885
+ preset: dict = {
886
+ "mode": mode,
887
+ "gpu": (self.cmb_gpu.currentIndex() == 0),
888
+ "create_new_view": (self.cmb_target.currentIndex() == 1),
889
+ }
890
+
891
+ # Sharpen / Both block
892
+ if mode in ("sharpen", "both"):
893
+ preset.update({
894
+ "sharpening_mode": self.cmb_sh_mode.currentText(),
895
+ "auto_psf": self.chk_auto_psf.isChecked(),
896
+ "nonstellar_psf": self.sld_psf.value() / 10.0, # slider 10–80 → 1.0–8.0
897
+ "stellar_amount": self.sld_st_amt.value() / 100.0, # 0–100 → 0–1
898
+ "nonstellar_amount": self.sld_nst_amt.value() / 100.0, # 0–100 → 0–1
899
+ "sharpen_channels_separately": self.chk_sh_sep.isChecked(),
900
+ })
901
+
902
+ # Denoise / Both block
903
+ if mode in ("denoise", "both"):
904
+ preset.update({
905
+ "denoise_luma": self.sld_dn_lum.value() / 100.0,
906
+ "denoise_color": self.sld_dn_col.value() / 100.0,
907
+ "denoise_mode": self.cmb_dn_mode.currentText(),
908
+ "separate_channels": self.chk_dn_sep.isChecked(),
909
+ })
910
+
911
+ # Super-res
912
+ if mode == "superres":
913
+ try:
914
+ scale_txt = self.cmb_scale.currentText()
915
+ # can be "2x" in the main dialog or just "2" in the preset dialog
916
+ scale_txt = scale_txt.replace("x", "")
917
+ preset["scale"] = int(scale_txt)
918
+ except Exception:
919
+ preset["scale"] = 2
920
+
921
+ return preset
922
+
923
+
924
+
925
+ def _read_superres_output_main(self):
926
+ self._read_superres_output(self._proc)
927
+
928
+ def _read_superres_output(self, proc: QProcess):
929
+ out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
930
+ if not self._wait: return
931
+ for line in out.splitlines():
932
+ if line.startswith("PROGRESS:") or line.startswith("Progress:"):
933
+ try:
934
+ tail = line.split(":",1)[1] if ":" in line else line.split()[1]
935
+ pct = int(float(tail.strip().replace("%","")))
936
+ self._wait.set_progress(pct)
937
+ except Exception:
938
+ pass
939
+ else:
940
+ self._wait.append_output(line)
941
+
942
+ def _pick_superres_output(self, base: str, scale: int) -> str | None:
943
+ """
944
+ Find the most plausible super-res output file. We try several common
945
+ name patterns, then fall back to the newest/largest file in the output dir.
946
+ """
947
+ out_dir = os.path.join(self.cosmic_root, "output")
948
+
949
+ def _best(paths: list[str]) -> str | None:
950
+ if not paths:
951
+ return None
952
+ # prefer bigger file; tie-break by newest mtime
953
+ paths.sort(key=lambda p: (os.path.getsize(p), os.path.getmtime(p)), reverse=True)
954
+ return paths[0]
955
+
956
+ # common patterns used by different builds
957
+ patterns = [
958
+ f"{base}_upscaled{scale}.*",
959
+ f"{base}_upscaled*.*",
960
+ f"{base}*upscal*.*",
961
+ f"{base}*superres*.*",
962
+ ]
963
+ for pat in patterns:
964
+ hit = _best(glob.glob(os.path.join(out_dir, pat)))
965
+ if hit:
966
+ return hit
967
+
968
+ # fallback: anything in output (we purge it first, so whatever appears is ours)
969
+ return _best(glob.glob(os.path.join(out_dir, "*.*")))
970
+
971
+
972
+ def _on_superres_file(self, _first_path_from_watcher: str):
973
+ # stop waiting UI
974
+ if self._wait: self._wait.close(); self._wait = None
975
+ if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
976
+
977
+ # pick the actual output (robust to naming)
978
+ base = getattr(self, "_sr_base", self._base_name())
979
+ scale = int(getattr(self, "_sr_scale", int(self.cmb_scale.currentText().replace("x",""))))
980
+ out_path = self._pick_superres_output(base, scale)
981
+ if not out_path or not os.path.exists(out_path):
982
+ QMessageBox.critical(self, "Cosmic Clarity", "Super Resolution output file not found.")
983
+ return
984
+
985
+ try:
986
+ img, hdr, bd, mono = load_image(out_path)
987
+ if img is None:
988
+ raise RuntimeError("Unable to load output image.")
989
+ except Exception as e:
990
+ QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load Super Resolution output:\n{e}")
991
+ return
992
+
993
+ dest = img.astype(np.float32, copy=False)
994
+ step_title = "Cosmic Clarity – Super Resolution"
995
+ create_new = (self.cmb_target.currentIndex() == 1)
996
+
997
+ if create_new:
998
+ ok = self._spawn_new_doc_from_numpy(dest, step_title)
999
+ if not ok:
1000
+ self._apply_to_active(dest, step_title)
1001
+ else:
1002
+ self._apply_to_active(dest, step_title)
1003
+
1004
+ # cleanup mirrors sharpen/denoise
1005
+ try:
1006
+ if getattr(self, "_current_input", None) and os.path.exists(self._current_input):
1007
+ os.remove(self._current_input)
1008
+ if os.path.exists(out_path):
1009
+ os.remove(out_path)
1010
+ _purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
1011
+ except Exception:
1012
+ pass
1013
+
1014
+ self.accept()
1015
+
1016
+
1017
+
1018
+ # =============================================================================
1019
+ # Satellite removal
1020
+ # =============================================================================
1021
+
1022
+
1023
+ class CosmicClaritySatelliteDialogPro(QDialog):
1024
+ """
1025
+ Pro dialog that mirrors SASv2 Cosmic Clarity Satellite tab:
1026
+ • Select input/output folders, live monitor, or batch process
1027
+ • GPU toggle, mode (full/luminance), clip trail, sensitivity, skip-save
1028
+ • Tree views for input/output with preview (autostretch + zoom)
1029
+ Uses QSettings key: paths/cosmic_clarity
1030
+ """
1031
+ def __init__(self, parent, doc=None, icon: QIcon | None = None):
1032
+ super().__init__(parent)
1033
+ self.setWindowTitle("Cosmic Clarity – Satellite Removal")
1034
+ self.setWindowFlag(Qt.WindowType.Window, True)
1035
+ self.setWindowModality(Qt.WindowModality.NonModal)
1036
+ self.setModal(False)
1037
+ if icon:
1038
+ try: self.setWindowIcon(icon)
1039
+ except Exception as e:
1040
+ import logging
1041
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1042
+
1043
+ self.settings = QSettings()
1044
+ self.cosmic_clarity_folder = self.settings.value("paths/cosmic_clarity", "", type=str) or ""
1045
+ self.input_folder = ""
1046
+ self.output_folder = ""
1047
+ self.sensitivity = 0.10 # 0.01–0.50
1048
+ self.doc = doc
1049
+
1050
+ self.file_watcher = QFileSystemWatcher()
1051
+ self.file_watcher.directoryChanged.connect(self._on_folder_changed)
1052
+
1053
+ self._sat_thread = None
1054
+ self._wait = None
1055
+
1056
+ self._build_ui()
1057
+
1058
+ # ---------- UI ----------
1059
+ def _build_ui(self):
1060
+ main = QHBoxLayout(self)
1061
+
1062
+ # Left controls
1063
+ left = QVBoxLayout()
1064
+
1065
+ # Input/Output folder chooser row
1066
+ row_io = QHBoxLayout()
1067
+ self.btn_in = QPushButton("Select Input Folder"); self.btn_in.clicked.connect(self._choose_input)
1068
+ self.btn_out = QPushButton("Select Output Folder"); self.btn_out.clicked.connect(self._choose_output)
1069
+ row_io.addWidget(self.btn_in); row_io.addWidget(self.btn_out)
1070
+ left.addLayout(row_io)
1071
+
1072
+ # GPU
1073
+ left.addWidget(QLabel("Use GPU Acceleration:"))
1074
+ self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems(["Yes", "No"])
1075
+ left.addWidget(self.cmb_gpu)
1076
+
1077
+ # Mode
1078
+ left.addWidget(QLabel("Satellite Removal Mode:"))
1079
+ self.cmb_mode = QComboBox(); self.cmb_mode.addItems(["Full", "Luminance"])
1080
+ left.addWidget(self.cmb_mode)
1081
+
1082
+ # Clip trail
1083
+ self.chk_clip = QCheckBox("Clip Satellite Trail to 0.000"); self.chk_clip.setChecked(True)
1084
+ left.addWidget(self.chk_clip)
1085
+
1086
+ # Sensitivity slider
1087
+ row_sens = QHBoxLayout()
1088
+ row_sens.addWidget(QLabel("Clipping Sensitivity (Lower = more aggressive):"))
1089
+ self.sld_sens = QSlider(Qt.Orientation.Horizontal)
1090
+ self.sld_sens.setRange(1, 50) # 0.01–0.50
1091
+ self.sld_sens.setValue(int(self.sensitivity * 100))
1092
+ self.sld_sens.setTickInterval(1)
1093
+ self.sld_sens.setTickPosition(QSlider.TickPosition.TicksBelow)
1094
+ self.sld_sens.valueChanged.connect(self._on_sens_change)
1095
+ row_sens.addWidget(self.sld_sens)
1096
+ self.lbl_sens_val = QLabel(f"{self.sensitivity:.2f}")
1097
+ row_sens.addWidget(self.lbl_sens_val)
1098
+ left.addLayout(row_sens)
1099
+
1100
+ # Skip save if no trail
1101
+ self.chk_skip = QCheckBox("Skip Save if No Satellite Trail Detected")
1102
+ self.chk_skip.setChecked(False)
1103
+ left.addWidget(self.chk_skip)
1104
+
1105
+ # Process row: single image / batch
1106
+ row_proc = QHBoxLayout()
1107
+ self.btn_single = QPushButton("Process Single Image"); self.btn_single.clicked.connect(self._process_single_image)
1108
+ self.btn_batch = QPushButton("Batch Process Input Folder"); self.btn_batch.clicked.connect(self._batch_process)
1109
+ row_proc.addWidget(self.btn_single); row_proc.addWidget(self.btn_batch)
1110
+ left.addLayout(row_proc)
1111
+
1112
+ # Live monitor
1113
+ self.btn_monitor = QPushButton("Live Monitor Input Folder"); self.btn_monitor.clicked.connect(self._live_monitor)
1114
+ left.addWidget(self.btn_monitor)
1115
+
1116
+ # Folder display + chooser for Cosmic Clarity root
1117
+ self.lbl_root = QLabel(f"Folder: {self.cosmic_clarity_folder or 'Not set'}")
1118
+ left.addWidget(self.lbl_root)
1119
+ self.btn_pick_root = QPushButton("Choose Cosmic Clarity Folder…"); self.btn_pick_root.clicked.connect(self._choose_root)
1120
+ left.addWidget(self.btn_pick_root)
1121
+
1122
+ left.addStretch(1)
1123
+
1124
+ # Right: trees
1125
+ right = QVBoxLayout()
1126
+ right.addWidget(QLabel("Input Folder Files:"))
1127
+ self.tree_in = QTreeWidget(); self.tree_in.setHeaderLabels(["Filename"])
1128
+ self.tree_in.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_in, is_input=True))
1129
+ self.tree_in.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1130
+ self.tree_in.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_in, pos, is_input=True))
1131
+ right.addWidget(self.tree_in)
1132
+
1133
+ right.addWidget(QLabel("Output Folder Files:"))
1134
+ self.tree_out = QTreeWidget(); self.tree_out.setHeaderLabels(["Filename"])
1135
+ self.tree_out.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_out, is_input=False))
1136
+ self.tree_out.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1137
+ self.tree_out.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_out, pos, is_input=False))
1138
+ right.addWidget(self.tree_out)
1139
+
1140
+ main.addLayout(left, 2)
1141
+ main.addLayout(right, 1)
1142
+
1143
+ self.resize(900, 600)
1144
+
1145
+ # ---------- Settings / root ----------
1146
+ def _choose_root(self):
1147
+ folder = QFileDialog.getExistingDirectory(self, "Select Cosmic Clarity Folder", self.cosmic_clarity_folder or "")
1148
+ if not folder: return
1149
+ self.cosmic_clarity_folder = folder
1150
+ self.settings.setValue("paths/cosmic_clarity", folder)
1151
+ self.lbl_root.setText(f"Folder: {folder}")
1152
+
1153
+ # ---------- IO folders ----------
1154
+ def _choose_input(self):
1155
+ folder = QFileDialog.getExistingDirectory(self, "Select Input Folder", self.input_folder or "")
1156
+ if not folder: return
1157
+ self.input_folder = folder
1158
+ self.btn_in.setText(f"Input: {os.path.basename(folder)}")
1159
+ self._watch(folder)
1160
+ self._refresh_tree(self.tree_in, folder)
1161
+
1162
+ def _choose_output(self):
1163
+ folder = QFileDialog.getExistingDirectory(self, "Select Output Folder", self.output_folder or "")
1164
+ if not folder: return
1165
+ self.output_folder = folder
1166
+ self.btn_out.setText(f"Output: {os.path.basename(folder)}")
1167
+ self._watch(folder)
1168
+ self._refresh_tree(self.tree_out, folder)
1169
+
1170
+ def _watch(self, folder):
1171
+ try:
1172
+ if folder and folder not in self.file_watcher.directories():
1173
+ self.file_watcher.addPath(folder)
1174
+ except Exception:
1175
+ pass
1176
+
1177
+ def _on_folder_changed(self, path):
1178
+ if path == self.input_folder:
1179
+ self._refresh_tree(self.tree_in, self.input_folder)
1180
+ elif path == self.output_folder:
1181
+ self._refresh_tree(self.tree_out, self.output_folder)
1182
+
1183
+ def _refresh_tree(self, tree: QTreeWidget, folder: str):
1184
+ tree.clear()
1185
+ if not folder or not os.path.isdir(folder): return
1186
+ for fn in sorted(os.listdir(folder)):
1187
+ if fn.lower().endswith(('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
1188
+ '.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef', '.jpg', '.jpeg')):
1189
+ QTreeWidgetItem(tree, [fn])
1190
+
1191
+ # ---------- Sensitivity ----------
1192
+ def _on_sens_change(self, v: int):
1193
+ self.sensitivity = v / 100.0
1194
+ self.lbl_sens_val.setText(f"{self.sensitivity:.2f}")
1195
+
1196
+ # ---------- Context menu ----------
1197
+ def _context_menu(self, tree: QTreeWidget, pos, is_input: bool):
1198
+ item = tree.itemAt(pos)
1199
+ if not item: return
1200
+ menu = QMenu(self)
1201
+ act_del = QAction("Delete File", self)
1202
+ act_ren = QAction("Rename File", self)
1203
+ act_del.triggered.connect(lambda: self._delete_file(tree, is_input))
1204
+ act_ren.triggered.connect(lambda: self._rename_file(tree, is_input))
1205
+ menu.addAction(act_del); menu.addAction(act_ren)
1206
+ menu.exec(tree.viewport().mapToGlobal(pos))
1207
+
1208
+ def _folder_of(self, is_input: bool) -> str:
1209
+ return self.input_folder if is_input else self.output_folder
1210
+
1211
+ def _delete_file(self, tree: QTreeWidget, is_input: bool):
1212
+ item = tree.currentItem()
1213
+ if not item: return
1214
+ folder = self._folder_of(is_input)
1215
+ fp = os.path.join(folder, item.text(0))
1216
+ if not os.path.exists(fp): return
1217
+ if QMessageBox.question(self, "Confirm Delete", f"Delete {item.text(0)}?",
1218
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1219
+ QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
1220
+ os.remove(fp)
1221
+ self._refresh_tree(tree, folder)
1222
+
1223
+ def _rename_file(self, tree: QTreeWidget, is_input: bool):
1224
+ item = tree.currentItem()
1225
+ if not item: return
1226
+ folder = self._folder_of(is_input)
1227
+ fp = os.path.join(folder, item.text(0))
1228
+ new, ok = QInputDialog.getText(self, "Rename File", "Enter new name:", text=item.text(0))
1229
+ if ok and new:
1230
+ np = os.path.join(folder, new)
1231
+ os.rename(fp, np)
1232
+ self._refresh_tree(tree, folder)
1233
+
1234
+ # ---------- Preview ----------
1235
+ def _preview_from_tree(self, tree: QTreeWidget, is_input: bool):
1236
+ item = tree.currentItem()
1237
+ if not item: return
1238
+ folder = self._folder_of(is_input)
1239
+ fp = os.path.join(folder, item.text(0))
1240
+ if not os.path.isfile(fp): return
1241
+ try:
1242
+ img, _, _, is_mono = load_image(fp)
1243
+ if img is None:
1244
+ QMessageBox.critical(self, "Error", "Failed to load image for preview.")
1245
+ return
1246
+ dlg = ImagePreviewDialog(img, is_mono=is_mono, parent=self)
1247
+ dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
1248
+ dlg.show()
1249
+ except Exception as e:
1250
+ QMessageBox.critical(self, "Error", f"Failed to preview image:\n{e}")
1251
+
1252
+ # ---------- Single image processing ----------
1253
+ def _process_single_image(self):
1254
+ # Gather possible open views
1255
+ views = self._collect_open_views()
1256
+
1257
+ # Decide source: view or file
1258
+ use_view = False
1259
+ if views:
1260
+ mb = QMessageBox(self)
1261
+ mb.setWindowTitle("Process Single Image")
1262
+ mb.setText("Choose the source to process:")
1263
+ btn_view = mb.addButton("Open View", QMessageBox.ButtonRole.AcceptRole)
1264
+ btn_file = mb.addButton("File on Disk", QMessageBox.ButtonRole.AcceptRole)
1265
+ mb.addButton(QMessageBox.StandardButton.Cancel)
1266
+ mb.exec()
1267
+ if mb.clickedButton() is btn_view:
1268
+ use_view = True
1269
+ elif mb.clickedButton() is None or mb.clickedButton() == mb.buttons()[-1]: # Cancel
1270
+ return
1271
+
1272
+ # --- Branch 1: Process an OPEN VIEW ---
1273
+ if use_view:
1274
+ # If multiple views, ask which one
1275
+ chosen_doc = None
1276
+ if len(views) == 1:
1277
+ chosen_doc = views[0][1]
1278
+ base_name = self._base_name_for_doc(chosen_doc)
1279
+ else:
1280
+ titles = [t for (t, _) in views]
1281
+ sel, ok = QInputDialog.getItem(self, "Select View", "Choose an open view:", titles, 0, False)
1282
+ if not ok:
1283
+ return
1284
+ idx = titles.index(sel)
1285
+ chosen_doc = views[idx][1]
1286
+ base_name = self._base_name_for_doc(chosen_doc)
1287
+
1288
+ # Stage image from the chosen view
1289
+ temp_in = self._create_temp_folder()
1290
+ temp_out = self._create_temp_folder()
1291
+ staged_in = os.path.join(temp_in, f"{base_name}.tif")
1292
+
1293
+ try:
1294
+ # 32-bit float TIFF like SASv2
1295
+ img = np.clip(np.asarray(chosen_doc.image, dtype=np.float32), 0.0, 1.0)
1296
+ save_image(
1297
+ img, staged_in,
1298
+ "tiff", "32-bit floating point",
1299
+ getattr(chosen_doc, "original_header", None),
1300
+ getattr(chosen_doc, "is_mono", False)
1301
+ )
1302
+ except Exception as e:
1303
+ QMessageBox.critical(self, "Error", f"Failed to stage view for processing:\n{e}")
1304
+ return
1305
+
1306
+ # Run satellite
1307
+ try:
1308
+ self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
1309
+ except Exception as e:
1310
+ QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
1311
+ return
1312
+
1313
+ # Pick up result and apply back to the view
1314
+ out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1315
+ if not out:
1316
+ # Likely --skip-save and no trail, or failure
1317
+ QMessageBox.information(self, "Satellite Removal", "No output produced (possibly no satellite trail detected).")
1318
+ else:
1319
+ out_path = out[0]
1320
+ try:
1321
+ result, hdr, bd, mono = load_image(out_path)
1322
+ if result is None:
1323
+ raise RuntimeError("Unable to load output image.")
1324
+ result = result.astype(np.float32, copy=False)
1325
+
1326
+ # Apply back to the chosen doc
1327
+ if hasattr(chosen_doc, "set_image"):
1328
+ chosen_doc.set_image(result, step_name="Cosmic Clarity – Satellite Removal")
1329
+ elif hasattr(chosen_doc, "apply_numpy"):
1330
+ chosen_doc.apply_numpy(result, step_name="Cosmic Clarity – Satellite Removal")
1331
+ else:
1332
+ chosen_doc.image = result
1333
+ except Exception as e:
1334
+ QMessageBox.critical(self, "Error", f"Failed to apply result to view:\n{e}")
1335
+ # fall through to cleanup
1336
+ finally:
1337
+ # Clean up temp files
1338
+ try:
1339
+ if os.path.exists(out_path): os.remove(out_path)
1340
+ except Exception:
1341
+ pass
1342
+
1343
+ # Clean up temp dirs
1344
+ try:
1345
+ shutil.rmtree(temp_in, ignore_errors=True)
1346
+ shutil.rmtree(temp_out, ignore_errors=True)
1347
+ except Exception:
1348
+ pass
1349
+
1350
+ return # done
1351
+
1352
+ # --- Branch 2: Process a FILE on disk ---
1353
+ file_path, _ = QFileDialog.getOpenFileName(
1354
+ self, "Select Image", "",
1355
+ "Image Files (*.png *.tif *.tiff *.fit *.fits *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef *.jpg *.jpeg)"
1356
+ )
1357
+ if not file_path:
1358
+ QMessageBox.warning(self, "Warning", "No file selected.")
1359
+ return
1360
+
1361
+ temp_in = self._create_temp_folder()
1362
+ temp_out = self._create_temp_folder()
1363
+ try:
1364
+ shutil.copy(file_path, temp_in)
1365
+ except Exception as e:
1366
+ QMessageBox.critical(self, "Error", f"Failed to stage input:\n{e}")
1367
+ return
1368
+
1369
+ try:
1370
+ self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
1371
+ except Exception as e:
1372
+ QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
1373
+ return
1374
+
1375
+ # Move output back next to original
1376
+ out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
1377
+ if out:
1378
+ dst = os.path.join(os.path.dirname(file_path), os.path.basename(out[0]))
1379
+ try:
1380
+ shutil.move(out[0], dst)
1381
+ except Exception as e:
1382
+ QMessageBox.critical(self, "Error", f"Failed to save result:\n{e}")
1383
+ return
1384
+ QMessageBox.information(self, "Success", f"Processed image saved to:\n{dst}")
1385
+ else:
1386
+ QMessageBox.warning(self, "Warning", "No output file found.")
1387
+
1388
+ # Cleanup
1389
+ try:
1390
+ shutil.rmtree(temp_in, ignore_errors=True)
1391
+ shutil.rmtree(temp_out, ignore_errors=True)
1392
+ except Exception:
1393
+ pass
1394
+
1395
+ def _collect_open_views(self):
1396
+ """
1397
+ Return a list of (title, doc) for all open MDI views with an image.
1398
+ Includes self.doc if supplied and valid.
1399
+ """
1400
+ views = []
1401
+ # include self.doc first if valid
1402
+ if getattr(self, "doc", None) is not None and getattr(self.doc, "image", None) is not None:
1403
+ title = getattr(self.doc, "display_name", lambda: "Active View")()
1404
+ views.append((title, self.doc))
1405
+
1406
+ # try to enumerate MDI subwindows on the parent main window
1407
+ try:
1408
+ main = self.parent()
1409
+ mdi = getattr(main, "mdi", None)
1410
+ if mdi is not None:
1411
+ for sw in mdi.subWindowList():
1412
+ w = sw.widget()
1413
+ d = getattr(w, "document", None)
1414
+ if d is not None and getattr(d, "image", None) is not None:
1415
+ t = w.windowTitle() if hasattr(w, "windowTitle") else getattr(d, "display_name", lambda:"View")()
1416
+ # don’t duplicate self.doc if it’s the same object
1417
+ if not any(d is existing for _, existing in views):
1418
+ views.append((t, d))
1419
+ except Exception:
1420
+ pass
1421
+
1422
+ return views
1423
+
1424
+ def _base_name_for_doc(self, d):
1425
+ """Derive a simple basename for staging temp files from a document."""
1426
+ fp = getattr(d, "file_path", None)
1427
+ if isinstance(fp, str) and fp:
1428
+ return os.path.splitext(os.path.basename(fp))[0]
1429
+ name = getattr(d, "display_name", None)
1430
+ if callable(name):
1431
+ try:
1432
+ n = name() or ""
1433
+ if n:
1434
+ return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
1435
+ except Exception:
1436
+ pass
1437
+ return "image"
1438
+
1439
+
1440
+ # ---------- Batch ----------
1441
+ def _batch_process(self):
1442
+ if not self.input_folder or not self.output_folder:
1443
+ QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
1444
+ return
1445
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1446
+ if not os.path.exists(exe):
1447
+ QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
1448
+ return
1449
+
1450
+ cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=True, monitor=False)
1451
+ self._run_threaded(cmd, title="Satellite – Batch processing")
1452
+
1453
+ # ---------- Live monitor ----------
1454
+ def _live_monitor(self):
1455
+ if not self.input_folder or not self.output_folder:
1456
+ QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
1457
+ return
1458
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1459
+ if not os.path.exists(exe):
1460
+ QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
1461
+ return
1462
+
1463
+ cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=False, monitor=True)
1464
+ self.sld_sens.setEnabled(False)
1465
+ self._run_threaded(cmd, title="Satellite – Live monitoring", on_finish=lambda: self.sld_sens.setEnabled(True))
1466
+
1467
+ # ---------- Command / run ----------
1468
+ def _build_cmd(self, exe_path: str, in_dir: str, out_dir: str, *, batch: bool, monitor: bool):
1469
+ cmd = [
1470
+ exe_path,
1471
+ "--input", in_dir,
1472
+ "--output", out_dir,
1473
+ "--mode", self.cmb_mode.currentText().lower(),
1474
+ ]
1475
+ if self.cmb_gpu.currentText() == "Yes":
1476
+ cmd.append("--use-gpu")
1477
+ if self.chk_clip.isChecked():
1478
+ cmd.append("--clip-trail")
1479
+ else:
1480
+ cmd.append("--no-clip-trail")
1481
+ if self.chk_skip.isChecked():
1482
+ cmd.append("--skip-save")
1483
+ if batch:
1484
+ cmd.append("--batch")
1485
+ if monitor:
1486
+ cmd.append("--monitor")
1487
+ cmd += ["--sensitivity", f"{self.sensitivity}"]
1488
+ return cmd
1489
+
1490
+ def _run_threaded(self, cmd, title="Processing…", on_finish=None):
1491
+ # Wait dialog + threaded subprocess (mirrors SASv2 SatelliteProcessingThread)
1492
+ self._wait = WaitDialog(title, self)
1493
+ self._wait.show()
1494
+
1495
+ self._sat_thread = SatelliteProcessingThread(cmd)
1496
+ self._sat_thread.log_signal.connect(self._wait.append_output)
1497
+ self._sat_thread.finished_signal.connect(lambda: self._on_thread_finished(on_finish))
1498
+ self._wait.cancelled.connect(self._cancel_sat_thread)
1499
+ self._sat_thread.start()
1500
+
1501
+ def _cancel_sat_thread(self):
1502
+ if self._sat_thread:
1503
+ self._sat_thread.cancel()
1504
+ if self._wait:
1505
+ self._wait.close()
1506
+ self._wait = None
1507
+
1508
+ def _on_thread_finished(self, on_finish):
1509
+ if self._wait: self._wait.close(); self._wait = None
1510
+ if callable(on_finish):
1511
+ try: on_finish()
1512
+ except Exception as e:
1513
+ import logging
1514
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1515
+ QMessageBox.information(self, "Done", "Processing finished.")
1516
+
1517
+ def _run_satellite(self, *, input_dir: str, output_dir: str, live: bool):
1518
+ if not self.cosmic_clarity_folder:
1519
+ raise RuntimeError("Cosmic Clarity folder not set. Choose it in Preferences or with the button below.")
1520
+ exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
1521
+ if not os.path.exists(exe):
1522
+ raise FileNotFoundError(f"Executable not found: {exe}")
1523
+
1524
+ cmd = self._build_cmd(exe, input_dir, output_dir, batch=not live, monitor=live)
1525
+ print("Running command:", " ".join(cmd))
1526
+ subprocess.run(cmd, check=True)
1527
+
1528
+ # ---------- Utils ----------
1529
+ @staticmethod
1530
+ def _create_temp_folder(base="~"):
1531
+ user_dir = os.path.expanduser(base)
1532
+ temp_folder = os.path.join(user_dir, "CosmicClarityTemp")
1533
+ os.makedirs(temp_folder, exist_ok=True)
1534
+ return temp_folder
1535
+
1536
+
1537
+ class SatelliteProcessingThread(QThread):
1538
+ log_signal = pyqtSignal(str)
1539
+ finished_signal = pyqtSignal()
1540
+ def __init__(self, command):
1541
+ super().__init__()
1542
+ self.command = command
1543
+ self.process = None
1544
+
1545
+ def cancel(self):
1546
+ if self.process:
1547
+ try:
1548
+ self.process.kill()
1549
+ except Exception:
1550
+ pass
1551
+
1552
+ def run(self):
1553
+ try:
1554
+ self.log_signal.emit("Running command: " + " ".join(self.command))
1555
+ self.process = subprocess.Popen(
1556
+ self.command,
1557
+ stdout=subprocess.PIPE,
1558
+ stderr=subprocess.STDOUT,
1559
+ universal_newlines=True,
1560
+ text=True
1561
+ )
1562
+ # Read output to prevent deadlock
1563
+ for line in iter(self.process.stdout.readline, ""):
1564
+ if not line: break
1565
+ # Optional: emit log signal for verbose output?
1566
+ # The original code didn't log stdout, but blocked.
1567
+ # Let's just log it if we want, or consume it.
1568
+ # The prompt says "I think starnet stops but the window doesnt close"
1569
+ # so maybe verbose logging isn't the priority, but consuming stdout is mandatory.
1570
+ # However, the original code used subprocess.run which captures output if specified,
1571
+ # but it didn't specify capture_output=True or stdout/stderr args in the snippet I saw?
1572
+ # Wait, let's check the snippet I saw earlier for SatelliteProcessingThread.
1573
+ pass
1574
+
1575
+ # Close stdout to ensure cleanup
1576
+ if self.process.stdout:
1577
+ self.process.stdout.close()
1578
+
1579
+ rc = self.process.wait()
1580
+ if rc == 0:
1581
+ self.log_signal.emit("Processing complete.")
1582
+ else:
1583
+ self.log_signal.emit(f"Processing failed with code {rc}")
1584
+
1585
+ except Exception as e:
1586
+ self.log_signal.emit(f"Unexpected error: {e}")
1587
+ finally:
1588
+ self.process = None
1589
+ self.finished_signal.emit()