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,1377 @@
1
+ # pro/comet_stacking.py
2
+ from __future__ import annotations
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import subprocess
7
+ import shutil
8
+ import math
9
+ import numpy as np
10
+ import cv2
11
+ from typing import List, Dict, Tuple, Optional
12
+ from functools import lru_cache
13
+ from astropy.io import fits
14
+ from astropy.stats import sigma_clipped_stats
15
+ import sep
16
+ from setiastro.saspro.remove_stars import (
17
+ _get_setting_any,
18
+ _mtf_params_linked, _apply_mtf_linked_rgb, _invert_mtf_linked_rgb,
19
+ _resolve_darkstar_exe, _ensure_exec_bit, _purge_darkstar_io
20
+
21
+ )
22
+ from setiastro.saspro.legacy.image_manager import (load_image, save_image)
23
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
24
+
25
+ def _blackpoint_nonzero(img_norm: np.ndarray, p: float = 0.1) -> float:
26
+ """Scalar blackpoint from non-zero pixels across all channels (linked).
27
+ p in [0..100]: small percentile to resist outliers; use 0 for strict min."""
28
+ x = img_norm
29
+ if x.ndim == 3 and x.shape[2] == 3:
30
+ nz = np.any(x > 0.0, axis=2) # keep pixels where any channel has signal
31
+ vals = x[nz] # shape (N,3) → flatten to scalar pool
32
+ else:
33
+ vals = x[x > 0.0]
34
+ if vals.size == 0:
35
+ return float(np.min(x)) # fallback (all zeros?)
36
+ if p <= 0.0:
37
+ return float(np.min(vals))
38
+ return float(np.percentile(vals, p))
39
+
40
+ def _float01_to_u16(x: np.ndarray) -> np.ndarray:
41
+ x = np.clip(np.asarray(x, dtype=np.float32), 0.0, 1.0)
42
+ return (x * 65535.0 + 0.5).astype(np.uint16, copy=False)
43
+
44
+ def _u16_to_float01(x: np.ndarray) -> np.ndarray:
45
+ x = np.asarray(x)
46
+ dt = x.dtype
47
+
48
+ # Exact uint16 → normalize
49
+ if dt == np.uint16:
50
+ return (x.astype(np.float32) / 65535.0)
51
+
52
+ # TIFF/FITS readers sometimes return float32 0..65535
53
+ if dt in (np.float32, np.float64):
54
+ mx = float(np.nanmax(x)) if x.size else 0.0
55
+ if mx > 1.01: # looks like 0..65535
56
+ return (x.astype(np.float32) / 65535.0)
57
+ # already 0..1 (or very close) → just clip for safety
58
+ return np.clip(x.astype(np.float32), 0.0, 1.0)
59
+
60
+ # Be forgiving with 8-bit
61
+ if dt == np.uint8:
62
+ return (x.astype(np.float32) / 255.0)
63
+
64
+ # Fallback: assume 16-bit range
65
+ return (x.astype(np.float32) / 65535.0)
66
+
67
+ # comet_stacking.py (or wherever this lives)
68
+
69
+ def starnet_starless_pair_from_array(
70
+ src_rgb01,
71
+ settings,
72
+ *,
73
+ is_linear: bool,
74
+ debug_save_dir: str | None = None,
75
+ debug_tag: str | None = None,
76
+ core_mask: np.ndarray | None = None, # <-- added (keyword-only)
77
+ ):
78
+ """
79
+ Standalone-like StarNet path using our imageops stretch:
80
+ - if linear: stretch (per-channel) with 0.25 -> StarNet
81
+ - then: pseudo-linear "unstretch" both orig & starless with 0.05
82
+ This avoids linked-MTF chroma issues and keeps both branches consistent.
83
+ """
84
+
85
+ exe = _get_setting_any(settings, ("starnet/exe_path", "paths/starnet"), "")
86
+ if not exe or not os.path.exists(exe):
87
+ raise RuntimeError("StarNet executable path is not configured.")
88
+ _ensure_exec_bit(exe)
89
+
90
+ # -------- normalize & shape: float32 [0..1], keep note if mono ----------
91
+ x = np.asarray(src_rgb01, dtype=np.float32)
92
+ was_mono = (x.ndim == 2) or (x.ndim == 3 and x.shape[2] == 1)
93
+ if x.ndim == 2:
94
+ x3 = np.stack([x]*3, axis=-1)
95
+ elif x.ndim == 3 and x.shape[2] == 1:
96
+ x3 = np.repeat(x, 3, axis=2)
97
+ else:
98
+ x3 = x
99
+ x3 = np.nan_to_num(x3, nan=0.0, posinf=0.0, neginf=0.0)
100
+
101
+ # -------- pre-StarNet stretch (per channel), only if the data are linear ----------
102
+ if is_linear:
103
+ # channel-wise stretch to avoid red cast; your funcs expect [0..1]
104
+ pre = stretch_color_image(x3, 0.25, False, False, False)
105
+
106
+ else:
107
+ pre = x3 # already non-linear; pass through
108
+
109
+ # -------- StarNet I/O (write float->16b TIFF; read back float) ----------
110
+ starnet_dir = os.path.dirname(exe) or os.getcwd()
111
+ in_path = os.path.join(starnet_dir, "imagetoremovestars.tif")
112
+ out_path = os.path.join(starnet_dir, "starless.tif")
113
+
114
+ save_image(pre, in_path, original_format="tif", bit_depth="16-bit",
115
+ original_header=None, is_mono=False, image_meta=None, file_meta=None)
116
+
117
+ exe_name = os.path.basename(exe).lower()
118
+ if os.name == "nt" or sys.platform.startswith(("linux","linux2")):
119
+ cmd = [exe, in_path, out_path, "256"]
120
+ else:
121
+ cmd = [exe, "--input", in_path, "--output", out_path] if "starnet2" in exe_name else [exe, in_path, out_path]
122
+
123
+ rc = subprocess.call(cmd, cwd=starnet_dir)
124
+ if rc != 0 or not os.path.exists(out_path):
125
+ try: os.remove(in_path)
126
+ except Exception as e:
127
+ import logging
128
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
129
+ raise RuntimeError(f"StarNet failed (rc={rc}).")
130
+
131
+ starless_pre, _, _, _ = load_image(out_path)
132
+ try:
133
+ os.remove(in_path); os.remove(out_path)
134
+ except Exception:
135
+ pass
136
+
137
+ if starless_pre.ndim == 2:
138
+ starless_pre = np.stack([starless_pre]*3, axis=-1)
139
+ elif starless_pre.ndim == 3 and starless_pre.shape[2] == 1:
140
+ starless_pre = np.repeat(starless_pre, 3, axis=2)
141
+ starless_pre = starless_pre.astype(np.float32, copy=False)
142
+
143
+ # ---- mask-protect in the SAME (stretched) domain as pre/starless_pre ----
144
+ if core_mask is not None:
145
+ m = np.clip(core_mask.astype(np.float32), 0.0, 1.0)
146
+ m3 = np.repeat(m[..., None], 3, axis=2)
147
+ protected_stretched = starless_pre * (1.0 - m3) + pre * m3
148
+ else:
149
+ protected_stretched = starless_pre
150
+
151
+ # -------- “unstretch” → shared pseudo-linear space (once, after blend) ----------
152
+ if is_linear:
153
+ protected_unstretch = stretch_color_image(
154
+ protected_stretched, 0.05, False, False, False
155
+ )
156
+ else:
157
+ protected_unstretch = protected_stretched
158
+
159
+ protected_unstretch = np.clip(
160
+ protected_unstretch.astype(np.float32, copy=False), 0.0, 1.0
161
+ )
162
+ return protected_unstretch, protected_unstretch
163
+
164
+
165
+
166
+ def darkstar_starless_from_array(src_rgb01: np.ndarray, settings, **_ignored) -> np.ndarray:
167
+ """
168
+ Headless CosmicClarity DarkStar run for a single RGB frame.
169
+ Returns starless RGB in [0..1]. Uses CC’s input/output folders.
170
+ """
171
+ # normalize channels
172
+ img = src_rgb01.astype(np.float32, copy=False)
173
+ if img.ndim == 2: img = np.stack([img]*3, axis=-1)
174
+ if img.ndim == 3 and img.shape[2] == 1: img = np.repeat(img, 3, axis=2)
175
+
176
+ # resolve exe and base folder
177
+ exe, base = _resolve_darkstar_exe(type("Dummy", (), {"settings": settings})())
178
+ if not exe or not base:
179
+ raise RuntimeError("Cosmic Clarity DarkStar executable path is not set.")
180
+
181
+ _ensure_exec_bit(exe)
182
+
183
+ input_dir = os.path.join(base, "input")
184
+ output_dir = os.path.join(base, "output")
185
+ os.makedirs(input_dir, exist_ok=True)
186
+ os.makedirs(output_dir, exist_ok=True)
187
+
188
+ # purge any prior files (safe; scoped to imagetoremovestars*)
189
+ _purge_darkstar_io(base, prefix="imagetoremovestars", clear_input=True, clear_output=True)
190
+
191
+ in_path = os.path.join(input_dir, "imagetoremovestars.tif")
192
+ out_path = os.path.join(output_dir, "imagetoremovestars_starless.tif")
193
+
194
+ # save input as float32 TIFF
195
+ save_image(img, in_path, original_format="tif", bit_depth="32-bit floating point",
196
+ original_header=None, is_mono=False, image_meta=None, file_meta=None)
197
+
198
+ # build command (SASv2 parity): default unscreen, show extracted stars off, stride 512
199
+ cmd = [exe, "--star_removal_mode", "unscreen", "--chunk_size", "512"]
200
+
201
+ rc = subprocess.call(cmd, cwd=output_dir)
202
+ if rc != 0 or not os.path.exists(out_path):
203
+ try: os.remove(in_path)
204
+ except Exception as e:
205
+ import logging
206
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
207
+ raise RuntimeError(f"DarkStar failed (rc={rc}).")
208
+
209
+ starless, _, _, _ = load_image(out_path)
210
+ # cleanup
211
+ try:
212
+ os.remove(in_path)
213
+ os.remove(out_path)
214
+ _purge_darkstar_io(base, prefix="imagetoremovestars", clear_input=True, clear_output=True)
215
+ except Exception:
216
+ pass
217
+
218
+ if starless is None:
219
+ raise RuntimeError("DarkStar produced no output.")
220
+
221
+ if starless.ndim == 2: starless = np.stack([starless]*3, axis=-1)
222
+ if starless.shape[2] == 1: starless = np.repeat(starless, 3, axis=2)
223
+ return np.clip(starless.astype(np.float32, copy=False), 0.0, 1.0)
224
+
225
+ # ---------- small helpers ----------
226
+ def _inv_affine_2x3(M: np.ndarray) -> np.ndarray:
227
+ """Invert a 2x3 affine matrix [[a,b,tx],[c,d,ty]] → [[a',b',tx'],[c',d',ty']]."""
228
+ A = np.asarray(M, dtype=np.float64).reshape(2,3)
229
+ a,b,tx = A[0]; c,d,ty = A[1]
230
+ det = a*d - b*c
231
+ if abs(det) < 1e-12:
232
+ raise ValueError("Affine matrix not invertible")
233
+ inv = np.array([[ d, -b, 0.0],
234
+ [-c, a, 0.0]], dtype=np.float64) / det
235
+ # new translation = - inv * t
236
+ inv[:,2] = -inv[:,:2] @ np.array([tx, ty], dtype=np.float64)
237
+ return inv.astype(np.float32)
238
+
239
+ def _to_luma(img: np.ndarray) -> np.ndarray:
240
+ if img.ndim == 2: return img.astype(np.float32, copy=False)
241
+ if img.ndim == 3 and img.shape[-1] == 3:
242
+ r,g,b = img[...,0], img[...,1], img[...,2]
243
+ return (0.2126*r + 0.7152*g + 0.0722*b).astype(np.float32, copy=False)
244
+ if img.ndim == 3 and img.shape[-1] == 1:
245
+ return img[...,0].astype(np.float32, copy=False)
246
+ return img.astype(np.float32, copy=False)
247
+
248
+ def _robust_centroid(img: np.ndarray, seed_xy: Optional[Tuple[float,float]]=None, r=40) -> Optional[Tuple[float,float]]:
249
+ """Find a compact bright blob near seed using SEP; fallback to image max."""
250
+ L = _to_luma(img)
251
+ H,W = L.shape
252
+ if seed_xy:
253
+ x0,y0 = int(round(seed_xy[0])), int(round(seed_xy[1]))
254
+ x1,x2 = max(0,x0-r), min(W, x0+r+1)
255
+ y1,y2 = max(0,y0-r), min(H, y0+r+1)
256
+ roi = L[y1:y2, x1:x2]
257
+ if roi.size >= 16:
258
+ bkg = np.median(roi)
259
+ try:
260
+ sep.set_extract_pixstack(int(1e6))
261
+ objs, seg = sep.extract(roi - bkg, thresh=2.0*np.std(roi), minarea=8, filter_type='matched')
262
+ if len(objs):
263
+ # pick highest peak
264
+ k = int(np.argmax([o['peak'] for o in objs]))
265
+ cx = float(objs[k]['x']) + x1
266
+ cy = float(objs[k]['y']) + y1
267
+ return (cx, cy)
268
+ except Exception:
269
+ pass
270
+ # fallback: global maximum
271
+ j = int(np.argmax(L))
272
+ cy, cx = divmod(j, W)
273
+ return (float(cx), float(cy))
274
+
275
+ def _star_suppress(L: np.ndarray) -> np.ndarray:
276
+ """Down-weight stellar pinpoints so big fuzzy cores win."""
277
+ small = cv2.GaussianBlur(L, (0, 0), 1.6).astype(np.float32)
278
+ thr = np.percentile(small, 99.7)
279
+ mask = small > thr # very bright, compact stuff
280
+ out = L.astype(np.float32, copy=True)
281
+ out[mask] *= 0.35 # damp stars; keep coma
282
+ return out
283
+
284
+ def _log_big_blob(L: np.ndarray, sigmas: list[float]) -> tuple[float, float, float]:
285
+ """
286
+ Pick the strongest bright blob across multiple scales using LoG-like response.
287
+ Returns (cx, cy, sigma_used).
288
+ """
289
+ H, W = L.shape
290
+ best_val, best_xy, best_s = -1e9, (W*0.5, H*0.5), sigmas[0]
291
+ for s in sigmas:
292
+ g = cv2.GaussianBlur(L, (0, 0), s)
293
+ lap = cv2.Laplacian(g, cv2.CV_32F, ksize=3)
294
+ resp = (-lap) * (s * s) # scale-normalized: favor larger bright blobs
295
+ hi = np.percentile(resp, 99.95)
296
+ resp = np.clip(resp, -1e9, hi)
297
+ j = int(np.argmax(resp))
298
+ cy, cx = divmod(j, W)
299
+ v = resp[cy, cx]
300
+ if v > best_val:
301
+ best_val, best_xy, best_s = float(v), (float(cx), float(cy)), float(s)
302
+ return best_xy[0], best_xy[1], best_s
303
+
304
+
305
+ # --- NEW helpers ---
306
+ def _luma_gauss(img: np.ndarray, sigma: float=3.0) -> np.ndarray:
307
+ L = _to_luma(img)
308
+ return cv2.GaussianBlur(L, (0,0), sigmaX=sigma, sigmaY=sigma).astype(np.float32, copy=False)
309
+
310
+ def _crop_bounds(cx, cy, half, W, H):
311
+ x1 = max(0, int(round(cx - half)))
312
+ y1 = max(0, int(round(cy - half)))
313
+ x2 = min(W, int(round(cx + half)))
314
+ y2 = min(H, int(round(cy + half)))
315
+ return x1, y1, x2, y2
316
+
317
+ def _norm_patch(p: np.ndarray) -> np.ndarray:
318
+ m = np.median(p)
319
+ s = np.std(p)
320
+ if s < 1e-6: s = 1e-6
321
+ return ((p - m) / s).astype(np.float32, copy=False)
322
+
323
+ def _minmax_time_key(fp: str) -> float:
324
+ # Try FITS DATE-OBS; fallback to file mtime. Lower is earlier.
325
+ try:
326
+ hdr = fits.getheader(fp, 0)
327
+ t = hdr.get("DATE-OBS") or hdr.get("DATE")
328
+ if t:
329
+ # robust parse: YYYY-MM-DDThh:mm:ss[.sss][Z]
330
+ from datetime import datetime
331
+ for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y/%m/%d %H:%M:%S"):
332
+ try:
333
+ return datetime.strptime(t.replace("Z",""), fmt).timestamp()
334
+ except Exception:
335
+ pass
336
+ except Exception:
337
+ pass
338
+ try:
339
+ return os.path.getmtime(fp)
340
+ except Exception:
341
+ return 0.0
342
+
343
+ def _predict(prev_xy: Tuple[float,float], prev2_xy: Optional[Tuple[float,float]]) -> Tuple[float,float]:
344
+ if prev2_xy is None:
345
+ return prev_xy
346
+ vx = prev_xy[0] - prev2_xy[0]
347
+ vy = prev_xy[1] - prev2_xy[1]
348
+ return (prev_xy[0] + vx, prev_xy[1] + vy)
349
+
350
+ # --- NEW per-frame star masks (optional, safer than warping) ---
351
+ def build_star_masks_per_frame(file_list: List[str], sigma: float=3.5, dilate_px: int=2, status_cb=None) -> Dict[str, np.ndarray]:
352
+ log = status_cb or (lambda *_: None)
353
+ masks = {}
354
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dilate_px+1, 2*dilate_px+1)) if dilate_px>0 else None
355
+ for fp in file_list:
356
+ img, _, _, _ = load_image(fp)
357
+ if img is None:
358
+ log(f" ⚠️ mask: failed to load {os.path.basename(fp)}");
359
+ continue
360
+ L = _to_luma(img)
361
+ bkg, _, std = sigma_clipped_stats(L, sigma=3.0, maxiters=5)
362
+ m = (L > (bkg + sigma*std)).astype(np.uint8)
363
+ if k is not None:
364
+ m = cv2.dilate(m, k)
365
+ masks[fp] = (m > 0)
366
+ log(f" ◦ star mask made for {os.path.basename(fp)}")
367
+ return masks
368
+
369
+ @lru_cache(maxsize=32)
370
+ def _directional_gaussian_kernel(long_px: int, sig_long: float,
371
+ sig_cross: float, angle_deg: float) -> np.ndarray:
372
+ """
373
+ Anisotropic Gaussian (elongated) rotated to `angle_deg`.
374
+ long_px controls kernel size along the tail axis.
375
+ Results are cached for reuse.
376
+ """
377
+ long_px = max(21, int(long_px) | 1)
378
+ half = long_px // 2
379
+ yy, xx = np.mgrid[-half:half+1, -half:half+1].astype(np.float32)
380
+ # rotate coords
381
+ th = np.deg2rad(angle_deg)
382
+ xr = np.cos(th)*xx + np.sin(th)*yy # along-tail
383
+ yr = -np.sin(th)*xx + np.cos(th)*yy # cross-tail
384
+ g = np.exp(-0.5*( (xr/sig_long)**2 + (yr/sig_cross)**2 ))
385
+ g /= g.sum()
386
+ return g.astype(np.float32)
387
+
388
+ def _anisotropic_feather(mask_bin: np.ndarray,
389
+ angle_deg: float,
390
+ feather_long: float,
391
+ feather_cross: float) -> np.ndarray:
392
+ """
393
+ Feather with different falloff along vs. across tail by convolving
394
+ the binary mask with an elongated Gaussian oriented at angle_deg.
395
+ """
396
+ k = _directional_gaussian_kernel(
397
+ long_px=int(max(31, 6*max(feather_long, feather_cross))),
398
+ sig_long=float(max(1.0, feather_long/2.5)),
399
+ sig_cross=float(max(1.0, feather_cross/2.5)),
400
+ angle_deg=angle_deg
401
+ )
402
+ soft = cv2.filter2D(mask_bin.astype(np.float32), -1, k, borderType=cv2.BORDER_REPLICATE)
403
+ return np.clip(soft, 0.0, 1.0).astype(np.float32)
404
+
405
+ def _tail_response(L: np.ndarray, angle_deg: float,
406
+ bg_sigma: float = 30.0,
407
+ hp_sigma: float = 2.0,
408
+ long_px: int = 181,
409
+ sig_long: float = 40.0,
410
+ sig_cross: float = 3.0) -> np.ndarray:
411
+ """
412
+ Build a smooth tail-likelihood map: high-pass -> directional blur
413
+ (elongated Gaussian) -> normalize to [0,1].
414
+ """
415
+ # remove large-scale gradient, keep positive high-pass
416
+ low = cv2.GaussianBlur(L, (0,0), bg_sigma)
417
+ hp = L - low
418
+ hp = cv2.GaussianBlur(hp, (0,0), hp_sigma)
419
+ hp[hp < 0] = 0.0
420
+ k = _directional_gaussian_kernel(long_px, sig_long, sig_cross, angle_deg)
421
+ resp = cv2.filter2D(hp, -1, k, borderType=cv2.BORDER_REFLECT)
422
+ # robust scale
423
+ p1, p99 = np.percentile(resp, (1.0, 99.7))
424
+ if p99 <= p1:
425
+ return np.zeros_like(resp, np.float32)
426
+ return np.clip((resp - p1) / (p99 - p1), 0.0, 1.0).astype(np.float32)
427
+
428
+ # At top of file (or near other imports)
429
+ from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
430
+
431
+ def _ensure_rgb_float01(x: np.ndarray) -> np.ndarray:
432
+ x = np.asarray(x)
433
+ if x.ndim == 2:
434
+ x = np.stack([x]*3, axis=-1)
435
+ elif x.ndim == 3 and x.shape[2] == 1:
436
+ x = np.repeat(x, 3, axis=2)
437
+ x = x.astype(np.float32, copy=False)
438
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
439
+ return np.clip(x, 0.0, 1.0)
440
+
441
+ def _ensure_mono_float01(x: np.ndarray) -> np.ndarray:
442
+ x = np.asarray(x)
443
+ if x.ndim == 3 and x.shape[2] == 3:
444
+ x = x.mean(axis=2)
445
+ elif x.ndim == 3 and x.shape[2] == 1:
446
+ x = x[..., 0]
447
+ x = x.astype(np.float32, copy=False)
448
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
449
+ return np.clip(x, 0.0, 1.0)
450
+
451
+ def blend_screen_stretched(
452
+ comet_only: np.ndarray,
453
+ stars_only: np.ndarray,
454
+ *,
455
+ stretch_pct: float = 0.05, # use 5% like you requested
456
+ mix: float = 1.0, # 0..1, scales the comet contribution in the screen
457
+ ) -> np.ndarray:
458
+ """
459
+ Display-stretch both inputs with your imageops stretch, then screen blend:
460
+ screen(A,B) = A + B - A*B
461
+ We apply 'mix' only to the comet term: out = screen(mix*A, B).
462
+
463
+ Returns float32 [0..1], RGB if any input is RGB, otherwise mono.
464
+ """
465
+
466
+ A = np.asarray(comet_only)
467
+ B = np.asarray(stars_only)
468
+
469
+ is_rgb = (A.ndim == 3 and A.shape[-1] == 3) or (B.ndim == 3 and B.shape[-1] == 3)
470
+
471
+ # 1) normalize/rgb-mono handling
472
+ if is_rgb:
473
+ A = _ensure_rgb_float01(A)
474
+ B = _ensure_rgb_float01(B)
475
+ # 2) stretch each with your display stretch (no links, no extra ops)
476
+ A_s = stretch_color_image(A, stretch_pct, False, False, False).astype(np.float32, copy=False)
477
+ B_s = stretch_color_image(B, stretch_pct, False, False, False).astype(np.float32, copy=False)
478
+ else:
479
+ A = _ensure_mono_float01(A)
480
+ B = _ensure_mono_float01(B)
481
+ A_s = stretch_mono_image(A, stretch_pct, False, False).astype(np.float32, copy=False)
482
+ B_s = stretch_mono_image(B, stretch_pct, False, False).astype(np.float32, copy=False)
483
+
484
+ # 3) screen blend with comet mix:
485
+ # screen(mix*A, B) = B + mix*A - (mix*A)*B
486
+ mix = float(np.clip(mix, 0.0, 1.0))
487
+ out_s = B_s + mix*A_s - (mix*A_s)*B_s
488
+
489
+ return np.clip(out_s, 0.0, 1.0).astype(np.float32, copy=False)
490
+
491
+
492
+ # --- REPLACE measure_comet_positions with this version ---
493
+ def measure_comet_positions(
494
+ file_list: List[str],
495
+ seeds: Optional[Dict[str, Tuple[float,float]]] = None,
496
+ status_cb=None,
497
+ *,
498
+ tpl_half: int = 28,
499
+ blur_sigma: float = 3.5,
500
+ max_step_px: float = 45.0,
501
+ min_search_px: float = 16.0,
502
+ max_search_px: float = 80.0,
503
+ score_floor: float = 0.35,
504
+ gamma_pow: float = 0.6,
505
+ refine_r: int = 12,
506
+ adapt_tpl_alpha: float = 0.12
507
+ ) -> Dict[str, Tuple[float,float]]:
508
+ """
509
+ Track the comet by template matching on blurred luma.
510
+ Frames are processed in temporal order (DATE-OBS; fallback mtime).
511
+
512
+ Now with a SECOND PASS local refinement that mirrors the Comet preview “Auto” button.
513
+ """
514
+ log = status_cb or (lambda *_: None)
515
+
516
+ # -------- PASS 1: existing template-matching pipeline (unchanged) --------
517
+ ordered = sorted(list(file_list), key=_minmax_time_key)
518
+ out: Dict[str, Tuple[float,float]] = {}
519
+ prev_xy: Optional[Tuple[float,float]] = None
520
+ prev2_xy: Optional[Tuple[float,float]] = None
521
+ tpl: Optional[np.ndarray] = None
522
+ tpl_hw = int(tpl_half)
523
+
524
+ # Seed selection logic (unchanged)
525
+ seed_idx = 0
526
+ if seeds:
527
+ for i, f in enumerate(ordered):
528
+ if f in seeds:
529
+ seed_idx = i
530
+ break
531
+
532
+ for i, fp in enumerate(ordered):
533
+ img, hdr, _, _ = load_image(fp)
534
+ if img is None:
535
+ log(f"⚠️ measure: failed to load {fp}")
536
+ continue
537
+
538
+ # blurred luma + gamma for detection
539
+ L = _luma_gauss(img, sigma=blur_sigma) # float32
540
+ G = _gamma_stretch(L, gamma=gamma_pow) # [0..1]
541
+ H, W = G.shape
542
+
543
+ if tpl is None:
544
+ # choose seed
545
+ if seeds and fp in seeds:
546
+ cx, cy = seeds[fp]
547
+ elif seeds:
548
+ for f in ordered:
549
+ if f in seeds: cx, cy = seeds[f]; break
550
+ else:
551
+ j = int(np.argmax(G)); cy, cx = divmod(j, W)
552
+
553
+ # keep user/global seed as the first output; refine subpixel on original luma (gamma’d)
554
+ L0g = _gamma_stretch(_to_luma(img), gamma=gamma_pow)
555
+ cx, cy = _refine_centroid(L0g, float(cx), float(cy), r=refine_r)
556
+
557
+ x1,y1,x2,y2 = _crop_bounds(cx, cy, tpl_half, W, H)
558
+ tpl = _norm_patch(G[y1:y2, x1:x2])
559
+ prev_xy = (float(cx), float(cy))
560
+ out[fp] = prev_xy
561
+ log(f" ◦ seed @ {os.path.basename(fp)} → ({prev_xy[0]:.2f},{prev_xy[1]:.2f}) [template {tpl.shape[1]}×{tpl.shape[0]}]")
562
+ continue
563
+
564
+ # prediction & adaptive search window
565
+ guess = _predict(prev_xy, prev2_xy)
566
+ if prev2_xy is None:
567
+ sr = max(min_search_px, 0.5*max_step_px)
568
+ else:
569
+ mv = math.hypot(prev_xy[0]-prev2_xy[0], prev_xy[1]-prev2_xy[1])
570
+ sr = np.clip(1.5*mv, min_search_px, max_search_px)
571
+
572
+ # ensure search ≥ template
573
+ min_half_needed = 0.5 * max(tpl.shape[1], tpl.shape[0]) + 1.0
574
+ sr = max(sr, min_half_needed)
575
+
576
+ # crop and match
577
+ x1, y1, x2, y2 = _bounds_with_min_size(guess[0], guess[1], sr, W, H,
578
+ min_w=tpl.shape[1], min_h=tpl.shape[0])
579
+ search = _norm_patch(G[y1:y2, x1:x2])
580
+ res = cv2.matchTemplate(search, tpl, cv2.TM_CCOEFF_NORMED)
581
+ _, score, _, loc = cv2.minMaxLoc(res)
582
+ px = x1 + loc[0] + tpl.shape[1]*0.5
583
+ py = y1 + loc[1] + tpl.shape[0]*0.5
584
+
585
+ step = math.hypot(px - prev_xy[0], py - prev_xy[1])
586
+ ok = (score >= score_floor) and (step <= max_step_px)
587
+
588
+ if not ok:
589
+ # one wider search
590
+ x1b, y1b, x2b, y2b = _bounds_with_min_size(guess[0], guess[1], max_search_px, W, H,
591
+ min_w=tpl.shape[1], min_h=tpl.shape[0])
592
+ search2 = _norm_patch(G[y1b:y2b, x1b:x2b])
593
+ res2 = cv2.matchTemplate(search2, tpl, cv2.TM_CCOEFF_NORMED)
594
+ _, score2, _, loc2 = cv2.minMaxLoc(res2)
595
+ px2 = x1b + loc2[0] + tpl.shape[1]*0.5
596
+ py2 = y1b + loc2[1] + tpl.shape[0]*0.5
597
+ step2 = math.hypot(px2 - prev_xy[0], py2 - prev_xy[1])
598
+ if (score2 > score) and (step2 <= max_step_px*1.2):
599
+ px, py, score, step = px2, py2, score2, step2
600
+ ok = (score >= 0.30)
601
+
602
+ if not ok:
603
+ px, py = _predict(prev_xy, prev2_xy)
604
+ px = float(np.clip(px, 0, W-1)); py = float(np.clip(py, 0, H-1))
605
+ log(f" ◦ {os.path.basename(fp)} fallback → ({px:.2f},{py:.2f})")
606
+ else:
607
+ # subpixel refine on original luma (gamma’d)
608
+ L0 = _to_luma(img)
609
+ L0g = _gamma_stretch(L0, gamma=gamma_pow)
610
+ px, py = _refine_centroid(L0g, px, py, r=refine_r)
611
+ log(f" ◦ {os.path.basename(fp)} match={score:.3f} step={step:.1f}px → ({px:.2f},{py:.2f})")
612
+
613
+ # gentle template adaptation
614
+ x1t, y1t, x2t, y2t = _crop_bounds(px, py, tpl_half, W, H)
615
+ new_tpl = _norm_patch(G[y1t:y2t, x1t:x2t])
616
+ if new_tpl.shape == tpl.shape:
617
+ tpl = (1.0 - adapt_tpl_alpha) * tpl + adapt_tpl_alpha * new_tpl
618
+
619
+ out[fp] = (px, py)
620
+ prev2_xy, prev_xy = prev_xy, (px, py)
621
+
622
+ # light smoothing (unchanged)
623
+ if len(out) >= 5:
624
+ ordered_xy = [out[f] for f in ordered]
625
+ xs = np.array([p[0] for p in ordered_xy], dtype=np.float64)
626
+ ys = np.array([p[1] for p in ordered_xy], dtype=np.float64)
627
+ def _smooth(v):
628
+ s = v.copy()
629
+ for k in range(2, len(v)-2):
630
+ s[k] = (-3*v[k-2] + 12*v[k-1] + 17*v[k] + 12*v[k+1] - 3*v[k+2]) / 35.0
631
+ return s
632
+ xs, ys = _smooth(xs), _smooth(ys)
633
+ for f, x, y in zip(ordered, xs, ys):
634
+ out[f] = (float(x), float(y))
635
+
636
+ # -------- PASS 2: local “Auto” refinement around first-pass XY --------
637
+ # Mirrors the dialog’s Auto: star-suppress → multi-scale LoG peak → gamma → subpixel refine
638
+ hint = max(4.0, blur_sigma) # reuse blur as the size hint
639
+ sigmas = [0.6*hint, 0.9*hint, 1.3*hint, 1.8*hint, 2.4*hint]
640
+ local_half = int(max(24, 3.0*hint)) # tight local window
641
+
642
+ for fp in ordered:
643
+ if fp not in out:
644
+ continue
645
+ img, _, _, _ = load_image(fp)
646
+ if img is None:
647
+ continue
648
+ Lfull = _to_luma(img).astype(np.float32)
649
+ cx0, cy0 = out[fp]
650
+ x1, y1, x2, y2 = _crop_bounds(cx0, cy0, local_half, Lfull.shape[1], Lfull.shape[0])
651
+
652
+ # star-suppressed local area + LoG peak
653
+ Ls = _star_suppress(Lfull[y1:y2, x1:x2])
654
+ cx, cy, used = _log_big_blob(Ls, sigmas)
655
+ cx += x1; cy += y1
656
+
657
+ # gamma + subpixel refine on the full-luma gamma space
658
+ gL = _gamma_stretch(Lfull, gamma=gamma_pow)
659
+ cx, cy = _refine_centroid(gL, float(cx), float(cy), r=max(refine_r, int(used)))
660
+
661
+ out[fp] = (float(cx), float(cy))
662
+
663
+ # light re-smoothing (keeps trajectories silky)
664
+ if len(out) >= 5:
665
+ ordered_xy = [out[f] for f in ordered]
666
+ xs = np.array([p[0] for p in ordered_xy], dtype=np.float64)
667
+ ys = np.array([p[1] for p in ordered_xy], dtype=np.float64)
668
+ def _smooth(v):
669
+ s = v.copy()
670
+ for k in range(2, len(v)-2):
671
+ s[k] = (-3*v[k-2] + 12*v[k-1] + 17*v[k] + 12*v[k+1] - 3*v[k+2]) / 35.0
672
+ return s
673
+ xs, ys = _smooth(xs), _smooth(ys)
674
+ for f, x, y in zip(ordered, xs, ys):
675
+ out[f] = (float(x), float(y))
676
+
677
+ return out
678
+
679
+
680
+ def _bounds_with_min_size(cx, cy, half, W, H, min_w, min_h):
681
+ # Start from requested half-size
682
+ half = max(half, 1.0)
683
+ # First pass crop
684
+ x1 = int(round(cx - half)); y1 = int(round(cy - half))
685
+ x2 = int(round(cx + half)); y2 = int(round(cy + half))
686
+ # Clamp to image
687
+ x1 = max(0, x1); y1 = max(0, y1)
688
+ x2 = min(W, x2); y2 = min(H, y2)
689
+
690
+ # Ensure minimum width/height by expanding/shift-in if needed
691
+ cur_w = x2 - x1; cur_h = y2 - y1
692
+ need_w = max(0, int(min_w - cur_w))
693
+ need_h = max(0, int(min_h - cur_h))
694
+
695
+ # Expand symmetrically where possible; otherwise shift inward from edges
696
+ if need_w > 0:
697
+ x1 = max(0, x1 - need_w // 2)
698
+ x2 = min(W, x2 + (need_w - (x1 > 0 and (need_w // 2))))
699
+ # If still short, push entirely to one side
700
+ if (x2 - x1) < min_w:
701
+ if x1 == 0: x2 = min(W, min_w)
702
+ if x2 == W: x1 = max(0, W - min_w)
703
+
704
+ if need_h > 0:
705
+ y1 = max(0, y1 - need_h // 2)
706
+ y2 = min(H, y2 + (need_h - (y1 > 0 and (need_h // 2))))
707
+ if (y2 - y1) < min_h:
708
+ if y1 == 0: y2 = min(H, min_h)
709
+ if y2 == H: y1 = max(0, H - min_h)
710
+
711
+ # Final clamp/sanity
712
+ x1 = max(0, min(x1, W))
713
+ x2 = max(0, min(x2, W))
714
+ y1 = max(0, min(y1, H))
715
+ y2 = max(0, min(y2, H))
716
+ return x1, y1, x2, y2
717
+
718
+
719
+ def build_star_masks_from_ref(ref_path: str,
720
+ ref_star_thresh_sigma: float,
721
+ inv_transforms: Dict[str, np.ndarray],
722
+ dilate_px: int = 2,
723
+ status_cb=None) -> Dict[str, np.ndarray]:
724
+ """Detect stars in ref, then warp mask back to each frame using inverse affine."""
725
+ log = status_cb or (lambda *_: None)
726
+ ref_img, hdr, _, _ = load_image(ref_path)
727
+ L = _to_luma(ref_img)
728
+ bkg, _, std = sigma_clipped_stats(L, sigma=3.0, maxiters=5)
729
+ thresh = bkg + ref_star_thresh_sigma * std
730
+ mask_ref = (L > thresh).astype(np.uint8)
731
+ if dilate_px > 0:
732
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dilate_px+1, 2*dilate_px+1))
733
+ mask_ref = cv2.dilate(mask_ref, k)
734
+
735
+ H, W = L.shape
736
+ masks = {}
737
+ for f, Minv in inv_transforms.items():
738
+ m = cv2.warpAffine(mask_ref, Minv, (W, H),
739
+ flags=cv2.INTER_NEAREST, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
740
+ masks[f] = m.astype(bool, copy=False)
741
+ log(f" ◦ star mask warped for {os.path.basename(f)}")
742
+ return masks
743
+
744
+ def _shift_to_comet(img: np.ndarray, xy: Tuple[float,float], ref_xy: Tuple[float,float]) -> np.ndarray:
745
+ """Translate image so comet xy → ref_xy (subpixel)."""
746
+ dx = ref_xy[0] - xy[0]
747
+ dy = ref_xy[1] - xy[1]
748
+ M = np.array([[1.0, 0.0, dx], [0.0, 1.0, dy]], dtype=np.float32)
749
+ H, W = img.shape[:2]
750
+ interp = cv2.INTER_LANCZOS4
751
+ if img.ndim == 2:
752
+ return cv2.warpAffine(img, M, (W, H), flags=interp, borderMode=cv2.BORDER_REFLECT)
753
+ # 3-channel
754
+ ch = [cv2.warpAffine(img[...,c], M, (W, H), flags=interp, borderMode=cv2.BORDER_REFLECT) for c in range(img.shape[-1])]
755
+ return np.stack(ch, axis=-1)
756
+
757
+ def stack_comet_aligned(file_list: List[str],
758
+ comet_xy: Dict[str, Tuple[float,float]],
759
+ star_masks: Optional[Dict[str, np.ndarray]] = None,
760
+ reducer: str = "biweight",
761
+ status_cb=None,
762
+ *,
763
+ settings=None,
764
+ enable_star_removal: bool = False,
765
+ star_removal_tool: str = "StarNet",
766
+ core_r_px: float = 22.0,
767
+ core_soft_px: float = 6.0,
768
+ frames_are_linear: bool = True) -> np.ndarray:
769
+ """
770
+ If enable_star_removal=True, each comet-aligned frame has stars removed
771
+ with the chosen tool and nucleus protected by a soft circular mask.
772
+ """
773
+ log = status_cb or (lambda *_: None)
774
+ ref_xy = comet_xy[file_list[0]]
775
+
776
+ accum = []
777
+ core_mask_cache = None
778
+
779
+ for fp in file_list:
780
+ img, hdr, _, _ = load_image(fp)
781
+ if img is None: continue
782
+
783
+ shifted = _shift_to_comet(img, comet_xy[fp], ref_xy).astype(np.float32)
784
+
785
+ if enable_star_removal:
786
+ h, w = shifted.shape[:2]
787
+ if core_mask_cache is None:
788
+ # mask centered at ref_xy after shifting (all frames share this center now)
789
+ core_mask_cache = _protect_core_mask(h, w, ref_xy[0], ref_xy[1], core_r_px, core_soft_px)
790
+ shifted = _starless_frame_for_comet(
791
+ shifted, star_removal_tool, settings,
792
+ is_linear=frames_are_linear, core_mask=core_mask_cache
793
+ )
794
+ # after removal, star_masks are usually unnecessary; ignore them
795
+ else:
796
+ # keep your existing optional masks if not removing stars
797
+ if star_masks and fp in star_masks:
798
+ m = star_masks[fp]
799
+ if shifted.ndim == 2:
800
+ shifted[m] = np.nan
801
+ else:
802
+ for c in range(shifted.shape[-1]): shifted[...,c][m] = np.nan
803
+
804
+ accum.append(shifted)
805
+
806
+ if not accum:
807
+ raise RuntimeError("No valid frames for comet stacking")
808
+
809
+ stack = np.stack(accum, axis=0)
810
+
811
+ # same reducer as before
812
+ if reducer == "median":
813
+ out = np.nanmedian(stack, axis=0)
814
+ else:
815
+ med = np.nanmedian(stack, axis=0)
816
+ mad = np.nanmedian(np.abs(stack - med), axis=0) + 1e-8
817
+ k = 3.0
818
+ lo, hi = med - k*1.4826*mad, med + k*1.4826*mad
819
+ clipped = np.clip(stack, lo, hi)
820
+ out = np.nanmean(clipped, axis=0)
821
+ return out.astype(np.float32, copy=False)
822
+
823
+ def make_comet_mask(comet_only: np.ndarray, feather_px: int=24) -> np.ndarray:
824
+ L = _to_luma(comet_only)
825
+ bkg, _, std = sigma_clipped_stats(L, sigma=3.0, maxiters=5)
826
+ m = (L > (bkg + 1.2*std)).astype(np.uint8)
827
+ # binary close + distance feather
828
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7,7))
829
+ m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, k, iterations=1)
830
+ # feather via distance transform
831
+ inv = 1 - m
832
+ dist = cv2.distanceTransform(inv, cv2.DIST_L2, 5)
833
+ mask = np.clip(1.0 - dist / max(1, feather_px), 0.0, 1.0)
834
+ return mask.astype(np.float32)
835
+
836
+
837
+
838
+ # --- estimate global streak angle from comet motion (deg) ---
839
+ def _estimate_streak_angle(comet_xy: dict[str, tuple[float,float]]) -> float:
840
+ if not comet_xy or len(comet_xy) < 2:
841
+ return 0.0
842
+ # order by time-ish from filename sort (good enough here)
843
+ ks = sorted(comet_xy.keys())
844
+ x0, y0 = comet_xy[ks[0]]
845
+ x1, y1 = comet_xy[ks[-1]]
846
+ # stars streak opposite comet motion; angle in image coords
847
+ ang = math.degrees(math.atan2(y0 - y1, x0 - x1)) # y down
848
+ return ang
849
+
850
+ def _line_kernel(length: int, angle_deg: float) -> np.ndarray:
851
+ """Thin line (1px) dilated to ~3px width; rotated to angle."""
852
+ length = max(3, int(length))
853
+ w = 3
854
+ k = np.zeros((length, length), np.uint8)
855
+ cv2.line(k, (0, length//2), (length-1, length//2), 1, 1)
856
+ M = cv2.getRotationMatrix2D((length/2-0.5, length/2-0.5), angle_deg, 1.0)
857
+ rsz = cv2.warpAffine(k*255, M, (length, length), flags=cv2.INTER_NEAREST)
858
+ if w > 1:
859
+ rsz = cv2.dilate(rsz, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(w,w)))
860
+ return (rsz > 0).astype(np.uint8)
861
+
862
+ def _streak_mask_directional(comet_only: np.ndarray,
863
+ angle_deg: float,
864
+ hp_sigma: float = 2.0,
865
+ bg_sigma: float = 15.0,
866
+ th_sigma: float = 3.0,
867
+ line_len: int = 19,
868
+ grow_px: int = 2) -> np.ndarray:
869
+ """
870
+ Detect elongated bright streaks roughly along 'angle_deg'.
871
+ Returns boolean mask (H,W) where True = streak.
872
+ """
873
+ L = _to_luma(comet_only).astype(np.float32)
874
+ # high-pass: remove large-scale coma/tail
875
+ low = cv2.GaussianBlur(L, (0,0), bg_sigma)
876
+ hp = cv2.GaussianBlur(L - low, (0,0), hp_sigma)
877
+
878
+ # robust threshold via MAD
879
+ med = np.median(hp)
880
+ mad = np.median(np.abs(hp - med)) + 1e-6
881
+ z = (hp - med) / (1.4826 * mad)
882
+ m0 = (z > th_sigma).astype(np.uint8)
883
+
884
+ # directional opening to keep long, aligned features; suppress compact bits
885
+ kline = _line_kernel(line_len, angle_deg)
886
+ opened = cv2.morphologyEx(m0, cv2.MORPH_OPEN, kline)
887
+
888
+ # small cleanups
889
+ if grow_px > 0:
890
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*grow_px+1, 2*grow_px+1))
891
+ opened = cv2.dilate(opened, k)
892
+ opened = cv2.morphologyEx(opened, cv2.MORPH_CLOSE,
893
+ cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5,5)))
894
+ return opened.astype(bool)
895
+
896
+ def _comet_mask_smart(comet_only: np.ndarray,
897
+ feather_px: int,
898
+ exclude_mask: np.ndarray | None = None,
899
+ sigma_k: float = 1.2) -> np.ndarray:
900
+ """
901
+ Stronger comet mask: threshold broad coma/tail, remove star streaks,
902
+ then feather edges by distance.
903
+ """
904
+ L = _to_luma(comet_only).astype(np.float32)
905
+ bkg, _, std = sigma_clipped_stats(L, sigma=3.0, maxiters=5)
906
+ base = (L > (bkg + sigma_k * std)).astype(np.uint8)
907
+
908
+ # clean & expand a bit so tail isn’t holey
909
+ base = cv2.morphologyEx(base, cv2.MORPH_CLOSE,
910
+ cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(9,9)), iterations=1)
911
+ base = cv2.dilate(base, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(5,5)), iterations=1)
912
+
913
+ if exclude_mask is not None:
914
+ base[exclude_mask] = 0
915
+
916
+ # feather
917
+ inv = 1 - base
918
+ dist = cv2.distanceTransform(inv, cv2.DIST_L2, 5)
919
+ mask = np.clip(1.0 - dist / max(1, float(feather_px)), 0.0, 1.0)
920
+ return mask.astype(np.float32)
921
+
922
+ def make_comet_mask_anisotropic(comet_only: np.ndarray,
923
+ angle_deg: float,
924
+ *,
925
+ core_k: float = 1.2,
926
+ tail_boost: float = 0.7,
927
+ exclude_streaks: np.ndarray | None = None,
928
+ feather_long: float = 90.0,
929
+ feather_cross: float = 18.0) -> np.ndarray:
930
+ """
931
+ Tail-aware comet matte:
932
+ 1) core/inner coma via sigma threshold,
933
+ 2) add a directional tail likelihood,
934
+ 3) remove star streaks,
935
+ 4) anisotropic feather along tail.
936
+ """
937
+ L = _to_luma(comet_only).astype(np.float32)
938
+ bkg, _, std = sigma_clipped_stats(L, sigma=3.0, maxiters=5)
939
+ core = (L > (bkg + core_k*std)).astype(np.uint8)
940
+
941
+ # grow core a touch so it’s not holey around nucleus
942
+ core = cv2.morphologyEx(core, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(7,7)))
943
+ core = cv2.dilate(core, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(3,3)), 1)
944
+
945
+ # directional tail map ∈ [0,1]; boost then clamp
946
+ tail = _tail_response(L, angle_deg=angle_deg)
947
+ tail = np.clip(tail * float(tail_boost), 0.0, 1.0)
948
+
949
+ # combine: binarize core strongly, add soft tail
950
+ m0 = np.clip(core.astype(np.float32) * 1.0 + tail * (1.0 - core.astype(np.float32)), 0.0, 1.0)
951
+
952
+ # remove linear star streaks if provided
953
+ if exclude_streaks is not None:
954
+ m0[exclude_streaks] = 0.0
955
+
956
+ # hard floor to keep nucleus fully in
957
+ m_bin = (m0 > 0.15).astype(np.uint8)
958
+
959
+ # anisotropic feather (stretches along tail, tight across)
960
+ matte = _anisotropic_feather(m_bin, angle_deg=angle_deg,
961
+ feather_long=feather_long,
962
+ feather_cross=feather_cross)
963
+ return np.clip(matte, 0.0, 1.0).astype(np.float32)
964
+
965
+ def blend_comet_stars(
966
+ comet_only: np.ndarray,
967
+ stars_only: np.ndarray,
968
+ feather_px: int = 24, # kept for compatibility; now used as cross-feather
969
+ mix: float = 1.0,
970
+ *,
971
+ comet_xy: dict[str, tuple[float,float]] | None = None
972
+ ) -> np.ndarray:
973
+ """
974
+ Tail-aware blend. Uses directional matte instead of radial blob.
975
+ `feather_px` controls *cross-tail* softness; along-tail uses a longer value automatically.
976
+ """
977
+ A = np.asarray(comet_only, dtype=np.float32)
978
+ B = np.asarray(stars_only, dtype=np.float32)
979
+
980
+ # channel harmonization
981
+ ch = 3 if ((A.ndim==3 and A.shape[-1]==3) or (B.ndim==3 and B.shape[-1]==3)) else 1
982
+ if ch == 3:
983
+ if A.ndim == 2: A = np.repeat(A[...,None], 3, axis=2)
984
+ if B.ndim == 2: B = np.repeat(B[...,None], 3, axis=2)
985
+ else:
986
+ if A.ndim == 3 and A.shape[-1] == 1: A = A[...,0]
987
+ if B.ndim == 3 and B.shape[-1] == 1: B = B[...,0]
988
+
989
+ angle = _estimate_streak_angle(comet_xy) if comet_xy else 0.0
990
+ # streak mask (same as before)
991
+ S = _streak_mask_directional(A, angle_deg=angle)
992
+
993
+ # anisotropic comet matte
994
+ M2D = make_comet_mask_anisotropic(
995
+ A, angle_deg=angle,
996
+ core_k=1.2, tail_boost=0.9,
997
+ exclude_streaks=S,
998
+ feather_long=max(70.0, 4.5*feather_px), # long feather down the tail
999
+ feather_cross=float(feather_px) # tight across the tail
1000
+ )
1001
+ M2D *= float(mix)
1002
+
1003
+ if ch == 3:
1004
+ M = np.repeat(M2D[...,None], 3, axis=2)
1005
+ else:
1006
+ M = M2D
1007
+
1008
+ out = A * M + B * (1.0 - M)
1009
+ return out.astype(np.float32, copy=False)
1010
+
1011
+
1012
+
1013
+ time_key = _minmax_time_key
1014
+
1015
+
1016
+ def _protect_core_mask(h: int, w: int, cx: float, cy: float, r: float, soft: float) -> np.ndarray:
1017
+ """
1018
+ Radial soft mask centered at (cx,cy): 1 near core (protected), 0 far.
1019
+ r = hard radius, soft = feather (pixels).
1020
+ Returns 2D float32 [0..1].
1021
+ """
1022
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
1023
+ d = np.hypot(xx - float(cx), yy - float(cy))
1024
+ m = np.clip((r + soft - d) / max(1e-6, soft), 0.0, 1.0)
1025
+ return m.astype(np.float32)
1026
+
1027
+ def _starless_frame_for_comet(img: np.ndarray,
1028
+ tool: str,
1029
+ settings,
1030
+ *,
1031
+ is_linear: bool,
1032
+ core_mask: np.ndarray) -> np.ndarray:
1033
+ """
1034
+ Run selected remover on a single frame and protect the nucleus with core_mask (H,W).
1035
+ Returns RGB float32 [0..1] starless, with nucleus restored from original.
1036
+ """
1037
+ # ensure RGB float32 [0..1]
1038
+ if img.ndim == 2: src = np.stack([img]*3, axis=-1).astype(np.float32)
1039
+ elif img.ndim == 3 and img.shape[2] == 1: src = np.repeat(img, 3, axis=2).astype(np.float32)
1040
+ else: src = img.astype(np.float32, copy=False)
1041
+
1042
+ # run
1043
+ if tool == "CosmicClarityDarkStar":
1044
+ # DarkStar returns in the same domain we fed in.
1045
+ base_for_mask = src
1046
+ starless = darkstar_starless_from_array(src, settings)
1047
+
1048
+ # protect nucleus (blend original back where mask=1), in *current* domain
1049
+ m = core_mask.astype(np.float32)
1050
+ m3 = np.repeat(m[..., None], 3, axis=2)
1051
+ protected = starless * (1.0 - m3) + base_for_mask * m3
1052
+ return np.clip(protected, 0.0, 1.0)
1053
+
1054
+ else:
1055
+ # StarNet path: do mask-blend inside the function (in its stretched domain)
1056
+ protected, _ = starnet_starless_pair_from_array(
1057
+ src, settings, is_linear=is_linear, core_mask=core_mask # NOTE: keyword arg
1058
+ )
1059
+ return np.clip(protected, 0.0, 1.0)
1060
+
1061
+
1062
+ def _gamma_stretch(x: np.ndarray, gamma: float = 0.6,
1063
+ lo_pct: float = 1.0, hi_pct: float = 99.7) -> np.ndarray:
1064
+ """
1065
+ Percentile-clip → normalize to [0,1] → power-law gamma → back to float32.
1066
+ gamma < 1 brightens midtones (good for faint coma).
1067
+ """
1068
+ x = np.asarray(x, dtype=np.float32)
1069
+ lo = np.percentile(x, lo_pct)
1070
+ hi = np.percentile(x, hi_pct)
1071
+ if hi <= lo:
1072
+ return x # degenerate; skip
1073
+ y = np.clip((x - lo) / (hi - lo), 0.0, 1.0)
1074
+ y = np.power(y, gamma, dtype=np.float32)
1075
+ return y
1076
+
1077
+ def _refine_centroid(L: np.ndarray, px: float, py: float, r: int = 12) -> Tuple[float, float]:
1078
+ """
1079
+ Subpixel refinement around (px,py) using an intensity-weighted centroid
1080
+ on a small ROI after subtracting a robust local background.
1081
+ """
1082
+ H, W = L.shape
1083
+ x1 = max(0, int(round(px - r))); x2 = min(W, int(round(px + r + 1)))
1084
+ y1 = max(0, int(round(py - r))); y2 = min(H, int(round(py + r + 1)))
1085
+ roi = L[y1:y2, x1:x2].astype(np.float32, copy=False)
1086
+ if roi.size < 16:
1087
+ return px, py
1088
+
1089
+ m = np.median(roi)
1090
+ s = np.std(roi)
1091
+ thr = m + 1.0 * s
1092
+ w = roi - thr
1093
+ w[w < 0] = 0.0 # keep only positive contrast (coma/core)
1094
+ if not np.any(w):
1095
+ return px, py
1096
+
1097
+ ys, xs = np.mgrid[y1:y2, x1:x2]
1098
+ Wsum = float(w.sum())
1099
+ cx = float((w * xs).sum() / Wsum)
1100
+ cy = float((w * ys).sum() / Wsum)
1101
+ return cx, cy
1102
+
1103
+
1104
+
1105
+ # ---------------- Qt6-only centroid review dialog ----------------
1106
+ try:
1107
+ # Prefer PyQt6
1108
+ from PyQt6.QtCore import Qt, QPointF, QEvent
1109
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QPen, QCursor
1110
+ from PyQt6.QtWidgets import (
1111
+ QDialog, QListWidget, QListWidgetItem, QLabel, QPushButton, QHBoxLayout,
1112
+ QVBoxLayout, QSlider, QWidget, QSpinBox, QCheckBox, QGraphicsView,
1113
+ QGraphicsScene, QGraphicsPixmapItem, QGraphicsEllipseItem
1114
+ )
1115
+ _QT_BINDING = "PyQt6"
1116
+ except Exception:
1117
+ # Fallback to PySide6 (still Qt6)
1118
+ from PySide6.QtCore import Qt, QPointF, QEvent
1119
+ from PySide6.QtGui import QImage, QPixmap, QPainter, QPen
1120
+ from PySide6.QtWidgets import (
1121
+ QDialog, QListWidget, QListWidgetItem, QLabel, QPushButton, QHBoxLayout,
1122
+ QVBoxLayout, QSlider, QWidget, QSpinBox, QCheckBox, QGraphicsView,
1123
+ QGraphicsScene, QGraphicsPixmapItem, QGraphicsEllipseItem
1124
+ )
1125
+ _QT_BINDING = "PySide6"
1126
+
1127
+ CursorShape = Qt.CursorShape
1128
+
1129
+ class CometCentroidPreview(QDialog):
1130
+ """
1131
+ Qt6 dialog to review/adjust comet centroids for a list of frames.
1132
+ Returns { path: (x, y) } via get_seeds() after accept().
1133
+ """
1134
+ def __init__(self, file_list, initial_xy=None, parent=None):
1135
+ super().__init__(parent)
1136
+ self.setWindowTitle("Comet: Review & Adjust Centroids")
1137
+ self.files = list(file_list)
1138
+ self.xy = dict(initial_xy or {})
1139
+ self.gamma = 0.6
1140
+ self.blur = 3.5
1141
+ self.dot_r = 12
1142
+ self.zoom = 1.0
1143
+
1144
+ # --- left: list ---
1145
+ self.listw = QListWidget()
1146
+ for p in self.files:
1147
+ it = QListWidgetItem(os.path.basename(p))
1148
+ it.setToolTip(p)
1149
+ self.listw.addItem(it)
1150
+ self.listw.currentRowChanged.connect(self._on_select)
1151
+
1152
+ # --- center: graphics view ---
1153
+ self.scene = QGraphicsScene(self)
1154
+ self.view = QGraphicsView(self.scene)
1155
+ self.view.setRenderHints(
1156
+ self.view.renderHints()
1157
+ | QPainter.RenderHint.Antialiasing
1158
+ | QPainter.RenderHint.SmoothPixmapTransform
1159
+ )
1160
+ self.view.setDragMode(QGraphicsView.DragMode.NoDrag)
1161
+ self.view.setCursor(QCursor(CursorShape.ArrowCursor))
1162
+ self.view.viewport().setCursor(QCursor(CursorShape.ArrowCursor))
1163
+ self.pix_item = QGraphicsPixmapItem()
1164
+ self.scene.addItem(self.pix_item)
1165
+ self.cross = QGraphicsEllipseItem(-self.dot_r, -self.dot_r, 2*self.dot_r, 2*self.dot_r)
1166
+ pen = QPen(Qt.GlobalColor.green); pen.setWidthF(1.5)
1167
+ self.cross.setPen(pen)
1168
+ self.scene.addItem(self.cross)
1169
+ self.view.viewport().installEventFilter(self)
1170
+
1171
+ # --- right: controls ---
1172
+ self.s_gamma = QSlider(Qt.Orientation.Horizontal); self._prep_slider(self.s_gamma, 10, 200, int(self.gamma*100))
1173
+ self.s_blur = QSlider(Qt.Orientation.Horizontal); self._prep_slider(self.s_blur, 0, 80, int(self.blur*10))
1174
+ self.s_zoom = QSlider(Qt.Orientation.Horizontal); self._prep_slider(self.s_zoom, 10, 300, int(self.zoom*100))
1175
+ self.s_gamma.valueChanged.connect(self._refresh_current)
1176
+ self.s_blur.valueChanged.connect(self._refresh_current)
1177
+ self.s_zoom.valueChanged.connect(self._apply_zoom)
1178
+
1179
+ self.n_prop = QSpinBox(); self.n_prop.setRange(1, 50); self.n_prop.setValue(3)
1180
+ self.cb_show_gamma = QCheckBox("Show gamma preview"); self.cb_show_gamma.setChecked(True)
1181
+
1182
+ self.btn_auto = QPushButton("Auto")
1183
+ self.btn_prev = QPushButton("⟲ Prev")
1184
+ self.btn_next = QPushButton("Next ⟳")
1185
+ self.btn_copyf = QPushButton("Propagate →")
1186
+ self.btn_ok = QPushButton("OK")
1187
+ self.btn_cancel = QPushButton("Cancel")
1188
+
1189
+ self.btn_auto.clicked.connect(self._auto_pick)
1190
+ self.btn_prev.clicked.connect(lambda: self._change_row(-1))
1191
+ self.btn_next.clicked.connect(lambda: self._change_row(+1))
1192
+ self.btn_copyf.clicked.connect(self._propagate_forward)
1193
+ self.btn_ok.clicked.connect(self.accept)
1194
+ self.btn_cancel.clicked.connect(self.reject)
1195
+
1196
+ ctrls = QVBoxLayout()
1197
+ ctrls.addWidget(QLabel("Gamma")); ctrls.addWidget(self.s_gamma)
1198
+ ctrls.addWidget(QLabel("Blur σ")); ctrls.addWidget(self.s_blur)
1199
+ ctrls.addWidget(QLabel("Zoom")); ctrls.addWidget(self.s_zoom)
1200
+ ctrls.addWidget(self.cb_show_gamma)
1201
+ r1 = QHBoxLayout(); r1.addWidget(self.btn_auto); r1.addWidget(self.btn_copyf); r1.addWidget(self.n_prop); ctrls.addLayout(r1)
1202
+ r2 = QHBoxLayout(); r2.addWidget(self.btn_prev); r2.addWidget(self.btn_next); ctrls.addLayout(r2)
1203
+ ctrls.addStretch(1)
1204
+ r3 = QHBoxLayout(); r3.addWidget(self.btn_ok); r3.addWidget(self.btn_cancel); ctrls.addLayout(r3)
1205
+
1206
+ main = QHBoxLayout(self)
1207
+ main.addWidget(self.listw, 1)
1208
+ main.addWidget(self.view, 4)
1209
+ w = QWidget(); w.setLayout(ctrls)
1210
+ main.addWidget(w, 2)
1211
+
1212
+ self.cb_show_gamma.toggled.connect(self._refresh_current)
1213
+
1214
+ if self.files:
1215
+ self.listw.setCurrentRow(0)
1216
+
1217
+ if self.files and self.files[0] not in self.xy:
1218
+ self._auto_pick(one_file=self.files[0], silent=True)
1219
+ self._place_cross()
1220
+
1221
+ self.view.viewport().installEventFilter(self)
1222
+
1223
+ def eventFilter(self, obj, ev):
1224
+ if obj is self.view.viewport():
1225
+ if ev.type() == QEvent.Type.CursorChange:
1226
+ obj.setCursor(QCursor(CursorShape.ArrowCursor))
1227
+ return True
1228
+ if ev.type() == QEvent.Type.MouseButtonPress:
1229
+ if ev.button() == Qt.MouseButton.LeftButton:
1230
+ pos = self.view.mapToScene(ev.position().toPoint())
1231
+ self._set_xy_current(pos.x(), pos.y())
1232
+ return True
1233
+ return super().eventFilter(obj, ev)
1234
+
1235
+
1236
+ # --- Qt6 helpers ---
1237
+ def _prep_slider(self, s, lo, hi, val):
1238
+ s.setRange(lo, hi); s.setValue(val); s.setSingleStep(1); s.setPageStep(5)
1239
+
1240
+ def eventFilter(self, obj, ev):
1241
+ if obj is self.view.viewport() and ev.type() == QEvent.Type.MouseButtonPress:
1242
+ if ev.button() == Qt.MouseButton.LeftButton:
1243
+ pos = self.view.mapToScene(ev.position().toPoint())
1244
+ self._set_xy_current(pos.x(), pos.y())
1245
+ return True
1246
+ return super().eventFilter(obj, ev)
1247
+
1248
+ def keyPressEvent(self, ev):
1249
+ k = ev.key()
1250
+ if k in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down):
1251
+ dx = -0.5 if k == Qt.Key.Key_Left else (0.5 if k == Qt.Key.Key_Right else 0.0)
1252
+ dy = -0.5 if k == Qt.Key.Key_Up else (0.5 if k == Qt.Key.Key_Down else 0.0)
1253
+ f = self._cur_file()
1254
+ if f in self.xy:
1255
+ x,y = self.xy[f]; self.xy[f] = (x+dx, y+dy); self._place_cross()
1256
+ ev.accept(); return
1257
+ super().keyPressEvent(ev)
1258
+
1259
+ # --- logic ---
1260
+ def _cur_file(self):
1261
+ r = self.listw.currentRow()
1262
+ return self.files[r] if 0 <= r < len(self.files) else None
1263
+
1264
+ def _change_row(self, delta):
1265
+ r = self.listw.currentRow()
1266
+ self.listw.setCurrentRow(max(0, min(len(self.files)-1, r+delta)))
1267
+
1268
+ def _apply_zoom(self):
1269
+ self.zoom = max(0.1, self.s_zoom.value()/100.0)
1270
+ self.view.resetTransform()
1271
+ self.view.scale(self.zoom, self.zoom)
1272
+
1273
+ def _render_preview(self, img):
1274
+ if self.cb_show_gamma.isChecked():
1275
+ sigma = max(0.0, self.s_blur.value()/10.0)
1276
+ g = max(0.1, self.s_gamma.value()/100.0)
1277
+ L = _luma_gauss(img, sigma if sigma>0 else 0.0)
1278
+ G = _gamma_stretch(L, gamma=g)
1279
+ disp = cv2.normalize(G, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
1280
+ else:
1281
+ L = _to_luma(img)
1282
+ disp = cv2.normalize(L, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
1283
+ return disp
1284
+
1285
+ def _on_select(self, row):
1286
+ fp = self._cur_file()
1287
+ if not fp: return
1288
+ img, _, _, _ = load_image(fp)
1289
+ if img is None: return
1290
+ disp = self._render_preview(img)
1291
+ qimg = QImage(disp.data, disp.shape[1], disp.shape[0], disp.strides[0], QImage.Format.Format_Grayscale8)
1292
+ self.pix_item.setPixmap(QPixmap.fromImage(qimg.copy()))
1293
+ self.scene.setSceneRect(0, 0, disp.shape[1], disp.shape[0])
1294
+ if fp not in self.xy:
1295
+ self._auto_pick(one_file=fp, silent=True)
1296
+ self._place_cross()
1297
+ self._apply_zoom()
1298
+
1299
+ def _place_cross(self):
1300
+ fp = self._cur_file()
1301
+ if not fp or fp not in self.xy: return
1302
+ x,y = self.xy[fp]
1303
+ self.cross.setPos(QPointF(x, y))
1304
+
1305
+ def _set_xy_current(self, x, y):
1306
+ fp = self._cur_file()
1307
+ if not fp: return
1308
+ self.xy[fp] = (float(x), float(y))
1309
+ self._place_cross()
1310
+
1311
+ def _auto_pick(self, one_file=None, silent=False):
1312
+ targets = [one_file] if one_file else [self._cur_file()]
1313
+ hint = max(4.0, self.s_blur.value()/10.0)
1314
+ sigmas = [0.6*hint, 0.9*hint, 1.3*hint, 1.8*hint, 2.4*hint]
1315
+
1316
+ for fp in targets:
1317
+ if not fp: continue
1318
+ img, _, _, _ = load_image(fp)
1319
+ if img is None: continue
1320
+ L = _to_luma(img).astype(np.float32)
1321
+
1322
+ # 1) try local search around existing xy (seed or previous)
1323
+ cx0, cy0 = self.xy.get(fp, (None, None))
1324
+ found = False
1325
+ if cx0 is not None:
1326
+ half = max(24, int(3*hint))
1327
+ x1,y1,x2,y2 = _crop_bounds(cx0, cy0, half, L.shape[1], L.shape[0])
1328
+ Ls = _star_suppress(L[y1:y2, x1:x2])
1329
+ cx, cy, used = _log_big_blob(Ls, sigmas)
1330
+ cx += x1; cy += y1
1331
+ g = max(0.1, self.s_gamma.value()/100.0)
1332
+ cx, cy = _refine_centroid(_gamma_stretch(L, g), float(cx), float(cy), r=max(10, int(used)))
1333
+ self.xy[fp] = (float(cx), float(cy))
1334
+ found = True
1335
+
1336
+ # 2) global fallback
1337
+ if not found:
1338
+ Ls = _star_suppress(L)
1339
+ cx, cy, used = _log_big_blob(Ls, sigmas)
1340
+ g = max(0.1, self.s_gamma.value()/100.0)
1341
+ cx, cy = _refine_centroid(_gamma_stretch(L, g), float(cx), float(cy), r=max(10, int(used)))
1342
+ self.xy[fp] = (float(cx), float(cy))
1343
+
1344
+ self._place_cross()
1345
+ if not silent:
1346
+ self._refresh_current()
1347
+
1348
+ def _propagate_forward(self):
1349
+ n = int(self.n_prop.value())
1350
+ r = self.listw.currentRow()
1351
+ if r < 0: return
1352
+ fp = self.files[r]
1353
+ if fp not in self.xy: return
1354
+ for k in range(1, n+1):
1355
+ i = r + k
1356
+ if i >= len(self.files): break
1357
+ self.xy[self.files[i]] = self.xy[fp]
1358
+ self._change_row(+1)
1359
+
1360
+ def get_seeds(self):
1361
+ return dict(self.xy)
1362
+
1363
+ def _refresh_current(self):
1364
+ """Re-render current frame with the latest gamma/blur and keep the cross in place."""
1365
+ r = self.listw.currentRow()
1366
+ if r < 0 or r >= len(self.files):
1367
+ return
1368
+ fp = self.files[r]
1369
+ img, _, _, _ = load_image(fp)
1370
+ if img is None:
1371
+ return
1372
+ disp = self._render_preview(img)
1373
+ qimg = QImage(disp.data, disp.shape[1], disp.shape[0], disp.strides[0],
1374
+ QImage.Format.Format_Grayscale8)
1375
+ self.pix_item.setPixmap(QPixmap.fromImage(qimg.copy()))
1376
+ self.scene.setSceneRect(0, 0, disp.shape[1], disp.shape[0])
1377
+ self._place_cross() # keep marker where it was