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