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