setiastrosuitepro 1.6.7__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.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotatearbitrary.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +128 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +964 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1379 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +910 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +627 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +639 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +494 -0
- setiastro/saspro/blink_comparator_pro.py +3149 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +371 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1620 -0
- setiastro/saspro/convo.py +1403 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1593 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +1005 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2608 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2727 -0
- setiastro/saspro/exoplanet_detector.py +2258 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1352 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +728 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +638 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8928 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
- setiastro/saspro/gui/mixins/update_mixin.py +323 -0
- setiastro/saspro/gui/mixins/view_mixin.py +477 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +492 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +760 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +421 -0
- setiastro/saspro/image_peeker_pro.py +1608 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1186 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2360 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1213 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1854 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +510 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +1090 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3909 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3312 -0
- setiastro/saspro/mfdeconvsport.py +2459 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +411 -0
- setiastro/saspro/multiscale_decomp.py +1751 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1105 -0
- setiastro/saspro/ops/scripts.py +1476 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1105 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2480 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +631 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +446 -0
- setiastro/saspro/resources.py +570 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +727 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1614 -0
- setiastro/saspro/sfcc.py +1530 -0
- setiastro/saspro/shortcuts.py +3125 -0
- setiastro/saspro/signature_insert.py +1106 -0
- setiastro/saspro/stacking_suite.py +19069 -0
- setiastro/saspro/star_alignment.py +7383 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +769 -0
- setiastro/saspro/star_stretch.py +542 -0
- setiastro/saspro/stat_stretch.py +554 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3523 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +134 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +4726 -0
- setiastro/saspro/translations/ar_translations.py +4096 -0
- setiastro/saspro/translations/de_translations.py +3728 -0
- setiastro/saspro/translations/es_translations.py +4169 -0
- setiastro/saspro/translations/fr_translations.py +4090 -0
- setiastro/saspro/translations/hi_translations.py +3803 -0
- setiastro/saspro/translations/integrate_translations.py +271 -0
- setiastro/saspro/translations/it_translations.py +4728 -0
- setiastro/saspro/translations/ja_translations.py +3834 -0
- setiastro/saspro/translations/pt_translations.py +3847 -0
- setiastro/saspro/translations/ru_translations.py +3082 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +16019 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14548 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +16202 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +15870 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14855 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +19046 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14980 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +15024 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11835 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15237 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +15248 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +15289 -0
- setiastro/saspro/translations/sw_translations.py +3897 -0
- setiastro/saspro/translations/uk_translations.py +3929 -0
- setiastro/saspro/translations/zh_translations.py +3910 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +648 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +683 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +540 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +991 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +313 -0
- setiastro/saspro/widgets/spinboxes.py +290 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7367 -0
- setiastro/saspro/wims.py +588 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1213 -0
- setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
- setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
- setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.7.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
|