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