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,1854 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import glob
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import numpy as np
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from PyQt6.QtCore import Qt, QTimer, QSettings, pyqtSignal
|
|
11
|
+
from PyQt6.QtGui import QIcon, QImage, QPixmap, QAction, QIntValidator, QDoubleValidator
|
|
12
|
+
from PyQt6.QtWidgets import (QDialog, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QLineEdit,
|
|
13
|
+
QFormLayout, QDialogButtonBox, QToolBar, QToolButton, QFileDialog,
|
|
14
|
+
QSizePolicy, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QApplication,
|
|
15
|
+
QMessageBox, QSlider, QCheckBox, QInputDialog, QComboBox)
|
|
16
|
+
|
|
17
|
+
import pyqtgraph as pg
|
|
18
|
+
from astropy.io import fits
|
|
19
|
+
from astropy.stats import sigma_clipped_stats
|
|
20
|
+
|
|
21
|
+
# optional deps used in your code; guard if not installed
|
|
22
|
+
try:
|
|
23
|
+
import rawpy
|
|
24
|
+
except Exception:
|
|
25
|
+
rawpy = None
|
|
26
|
+
try:
|
|
27
|
+
import exifread
|
|
28
|
+
except Exception:
|
|
29
|
+
exifread = None
|
|
30
|
+
|
|
31
|
+
import sep
|
|
32
|
+
import exifread
|
|
33
|
+
|
|
34
|
+
# your helpers/utilities
|
|
35
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
36
|
+
from setiastro.saspro.legacy.numba_utils import apply_flat_division_numba, debayer_fits_fast # adjust names if different
|
|
37
|
+
from setiastro.saspro.legacy.image_manager import load_image
|
|
38
|
+
from setiastro.saspro.star_alignment import StarRegistrationWorker, StarRegistrationThread, IDENTITY_2x3
|
|
39
|
+
from setiastro.saspro.widgets.spinboxes import CustomSpinBox, CustomDoubleSpinBox
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LiveStackSettingsDialog(QDialog):
|
|
43
|
+
"""
|
|
44
|
+
Combined dialog for:
|
|
45
|
+
• Live‐stack parameters (bootstrap frames, σ‐clip threshold)
|
|
46
|
+
• Culling thresholds (max FWHM, max eccentricity, min star count)
|
|
47
|
+
"""
|
|
48
|
+
def __init__(self, parent):
|
|
49
|
+
super().__init__(parent)
|
|
50
|
+
self.setWindowTitle("Live Stack & Culling Settings")
|
|
51
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
52
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
53
|
+
self.setModal(False)
|
|
54
|
+
|
|
55
|
+
# — Live Stack Settings —
|
|
56
|
+
# Bootstrap frames (int)
|
|
57
|
+
self.bs_spin = CustomSpinBox(
|
|
58
|
+
minimum=1,
|
|
59
|
+
maximum=100,
|
|
60
|
+
initial=parent.bootstrap_frames,
|
|
61
|
+
step=1
|
|
62
|
+
)
|
|
63
|
+
self.bs_spin.valueChanged.connect(lambda v: None)
|
|
64
|
+
|
|
65
|
+
# Sigma threshold (float)
|
|
66
|
+
self.sigma_spin = CustomDoubleSpinBox(
|
|
67
|
+
minimum=0.1,
|
|
68
|
+
maximum=10.0,
|
|
69
|
+
initial=parent.clip_threshold,
|
|
70
|
+
step=0.1
|
|
71
|
+
)
|
|
72
|
+
self.sigma_spin.valueChanged.connect(lambda v: None)
|
|
73
|
+
|
|
74
|
+
# — Culling Thresholds —
|
|
75
|
+
# Max FWHM (float)
|
|
76
|
+
self.fwhm_spin = CustomDoubleSpinBox(
|
|
77
|
+
minimum=0.1,
|
|
78
|
+
maximum=50.0,
|
|
79
|
+
initial=parent.max_fwhm,
|
|
80
|
+
step=0.1
|
|
81
|
+
)
|
|
82
|
+
self.fwhm_spin.valueChanged.connect(lambda v: None)
|
|
83
|
+
|
|
84
|
+
# Max eccentricity (float)
|
|
85
|
+
self.ecc_spin = CustomDoubleSpinBox(
|
|
86
|
+
minimum=0.0,
|
|
87
|
+
maximum=1.0,
|
|
88
|
+
initial=parent.max_ecc,
|
|
89
|
+
step=0.01
|
|
90
|
+
)
|
|
91
|
+
self.ecc_spin.valueChanged.connect(lambda v: None)
|
|
92
|
+
|
|
93
|
+
# Min star count (int)
|
|
94
|
+
self.star_spin = CustomSpinBox(
|
|
95
|
+
minimum=0,
|
|
96
|
+
maximum=5000,
|
|
97
|
+
initial=parent.min_star_count,
|
|
98
|
+
step=1
|
|
99
|
+
)
|
|
100
|
+
self.star_spin.valueChanged.connect(lambda v: None)
|
|
101
|
+
|
|
102
|
+
# Acquisition Dely (int)
|
|
103
|
+
self.delay_spin = CustomDoubleSpinBox(
|
|
104
|
+
minimum=0.0,
|
|
105
|
+
maximum=60.0,
|
|
106
|
+
initial=parent.FILE_STABLE_SECS,
|
|
107
|
+
step=0.5
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Build form layout
|
|
113
|
+
form = QFormLayout()
|
|
114
|
+
form.addRow("Switch to μ–σ clipping after:", self.bs_spin)
|
|
115
|
+
form.addRow("Clip threshold:", self.sigma_spin)
|
|
116
|
+
form.addRow(QLabel("")) # blank row for separation
|
|
117
|
+
form.addRow("Max FWHM (px):", self.fwhm_spin)
|
|
118
|
+
form.addRow("Max Eccentricity:", self.ecc_spin)
|
|
119
|
+
form.addRow("Min Star Count:", self.star_spin)
|
|
120
|
+
form.addRow("Acquisition Delay:", self.delay_spin)
|
|
121
|
+
|
|
122
|
+
self.mapping_combo = QComboBox()
|
|
123
|
+
opts = ["Natural", "SHO", "HSO", "OSH", "SOH", "HOS", "OHS"]
|
|
124
|
+
self.mapping_combo.addItems(opts)
|
|
125
|
+
# preselect current
|
|
126
|
+
idx = opts.index(parent.narrowband_mapping) \
|
|
127
|
+
if parent.narrowband_mapping in opts else 0
|
|
128
|
+
self.mapping_combo.setCurrentIndex(idx)
|
|
129
|
+
form.addRow("Narrowband Mapping:", self.mapping_combo)
|
|
130
|
+
|
|
131
|
+
# OK / Cancel buttons
|
|
132
|
+
btns = QDialogButtonBox(
|
|
133
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
134
|
+
)
|
|
135
|
+
btns.accepted.connect(self.accept)
|
|
136
|
+
btns.rejected.connect(self.reject)
|
|
137
|
+
|
|
138
|
+
# Assemble dialog layout
|
|
139
|
+
layout = QVBoxLayout()
|
|
140
|
+
layout.addLayout(form)
|
|
141
|
+
layout.addWidget(btns)
|
|
142
|
+
self.setLayout(layout)
|
|
143
|
+
|
|
144
|
+
def getValues(self):
|
|
145
|
+
"""
|
|
146
|
+
Returns a tuple of five values in order:
|
|
147
|
+
(bootstrap_frames, clip_threshold,
|
|
148
|
+
max_fwhm, max_ecc, min_star_count, delay)
|
|
149
|
+
"""
|
|
150
|
+
bs = int(self.bs_spin.value())
|
|
151
|
+
sigma = self.sigma_spin.value()
|
|
152
|
+
fwhm = self.fwhm_spin.value()
|
|
153
|
+
ecc = self.ecc_spin.value()
|
|
154
|
+
stars = int(self.star_spin.value())
|
|
155
|
+
mapping = self.mapping_combo.currentText()
|
|
156
|
+
delay = self.delay_spin.value()
|
|
157
|
+
return bs, sigma, fwhm, ecc, stars, mapping, delay
|
|
158
|
+
|
|
159
|
+
def _qget(settings: QSettings, key: str, default, typ):
|
|
160
|
+
try:
|
|
161
|
+
return settings.value(key, default, type=typ)
|
|
162
|
+
except TypeError:
|
|
163
|
+
# Key contains junk (likely from earlier method-object save). Reset it.
|
|
164
|
+
try:
|
|
165
|
+
settings.remove(key)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
settings.setValue(key, default)
|
|
169
|
+
return default
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class LiveMetricsPanel(QWidget):
|
|
174
|
+
"""
|
|
175
|
+
A simple 2×2 grid of PyQtGraph plots to show, in real time:
|
|
176
|
+
[0,0] → FWHM (px) vs. frame index
|
|
177
|
+
[0,1] → Eccentricity vs. frame index
|
|
178
|
+
[1,0] → Star Count vs. frame index
|
|
179
|
+
[1,1] → (μ–ν)/σ (∝SNR) vs. frame index
|
|
180
|
+
"""
|
|
181
|
+
def __init__(self, parent=None):
|
|
182
|
+
super().__init__(parent)
|
|
183
|
+
titles = ["FWHM (px)", "Eccentricity", "Star Count", "(μ–ν)/σ (∝SNR)"]
|
|
184
|
+
|
|
185
|
+
layout = QVBoxLayout(self)
|
|
186
|
+
grid = pg.GraphicsLayoutWidget()
|
|
187
|
+
layout.addWidget(grid)
|
|
188
|
+
|
|
189
|
+
self.plots = []
|
|
190
|
+
self.scats = []
|
|
191
|
+
self._data_x = [[], [], [], []]
|
|
192
|
+
self._data_y = [[], [], [], []]
|
|
193
|
+
self._flags = [[], [], [], []] # track if each point was “bad” (True) or “good” (False)
|
|
194
|
+
|
|
195
|
+
for row in range(2):
|
|
196
|
+
for col in range(2):
|
|
197
|
+
pw = grid.addPlot(row=row, col=col)
|
|
198
|
+
idx = row * 2 + col
|
|
199
|
+
pw.setTitle(titles[idx])
|
|
200
|
+
pw.showGrid(x=True, y=True, alpha=0.3)
|
|
201
|
+
pw.setLabel('bottom', "Frame #")
|
|
202
|
+
pw.setLabel('left', titles[idx])
|
|
203
|
+
|
|
204
|
+
scat = pg.ScatterPlotItem(pen=pg.mkPen(None),
|
|
205
|
+
brush=pg.mkBrush(100, 100, 255, 200),
|
|
206
|
+
size=6)
|
|
207
|
+
pw.addItem(scat)
|
|
208
|
+
self.plots.append(pw)
|
|
209
|
+
self.scats.append(scat)
|
|
210
|
+
|
|
211
|
+
def add_point(self, frame_idx: int, fwhm: float, ecc: float, star_cnt: int, snr_val: float, flagged: bool):
|
|
212
|
+
"""
|
|
213
|
+
Append one new data point to each metric.
|
|
214
|
+
If flagged == True, draw that single point in red; else blue.
|
|
215
|
+
But keep all previously-plotted points at their original colors.
|
|
216
|
+
"""
|
|
217
|
+
values = [fwhm, ecc, star_cnt, snr_val]
|
|
218
|
+
for i in range(4):
|
|
219
|
+
self._data_x[i].append(frame_idx)
|
|
220
|
+
self._data_y[i].append(values[i])
|
|
221
|
+
self._flags[i].append(flagged)
|
|
222
|
+
|
|
223
|
+
# Now build a brush list for *all* points up to index i,
|
|
224
|
+
# coloring each point according to its own flag.
|
|
225
|
+
brushes = [
|
|
226
|
+
pg.mkBrush(255, 0, 0, 200) if self._flags[i][j]
|
|
227
|
+
else pg.mkBrush(100, 100, 255, 200)
|
|
228
|
+
for j in range(len(self._data_x[i]))
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
self.scats[i].setData(
|
|
232
|
+
self._data_x[i],
|
|
233
|
+
self._data_y[i],
|
|
234
|
+
brush=brushes,
|
|
235
|
+
pen=pg.mkPen(None),
|
|
236
|
+
size=6
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def clear_all(self):
|
|
240
|
+
"""Clear data from all four plots."""
|
|
241
|
+
for i in range(4):
|
|
242
|
+
self._data_x[i].clear()
|
|
243
|
+
self._data_y[i].clear()
|
|
244
|
+
self._flags[i].clear()
|
|
245
|
+
self.scats[i].clear()
|
|
246
|
+
|
|
247
|
+
class LiveMetricsWindow(QWidget):
|
|
248
|
+
def __init__(self, parent=None):
|
|
249
|
+
super().__init__(parent)
|
|
250
|
+
self.setWindowTitle("Live Stack Metrics")
|
|
251
|
+
self.resize(600, 400)
|
|
252
|
+
|
|
253
|
+
layout = QVBoxLayout(self)
|
|
254
|
+
self.metrics_panel = LiveMetricsPanel(self)
|
|
255
|
+
layout.addWidget(self.metrics_panel)
|
|
256
|
+
|
|
257
|
+
from setiastro.saspro.star_metrics import measure_stars_sep
|
|
258
|
+
|
|
259
|
+
def compute_frame_star_metrics(image_2d):
|
|
260
|
+
"""
|
|
261
|
+
Harmonized with Blink metrics:
|
|
262
|
+
- SEP.Background() for back/noise
|
|
263
|
+
- thresh = 7σ
|
|
264
|
+
- median aggregation for FWHM & Ecc
|
|
265
|
+
"""
|
|
266
|
+
# ensure float32 mono [0..1]
|
|
267
|
+
data = np.asarray(image_2d)
|
|
268
|
+
if data.ndim == 3:
|
|
269
|
+
data = data.mean(axis=2)
|
|
270
|
+
if data.dtype == np.uint8:
|
|
271
|
+
data = data.astype(np.float32) / 255.0
|
|
272
|
+
elif data.dtype == np.uint16:
|
|
273
|
+
data = data.astype(np.float32) / 65535.0
|
|
274
|
+
else:
|
|
275
|
+
data = data.astype(np.float32, copy=False)
|
|
276
|
+
|
|
277
|
+
star_count, fwhm, ecc = measure_stars_sep(
|
|
278
|
+
data,
|
|
279
|
+
thresh_sigma=7.0,
|
|
280
|
+
minarea=16,
|
|
281
|
+
deblend_nthresh=32,
|
|
282
|
+
aggregate="median",
|
|
283
|
+
)
|
|
284
|
+
return star_count, fwhm, ecc
|
|
285
|
+
|
|
286
|
+
def estimate_global_snr(
|
|
287
|
+
stack_image: np.ndarray,
|
|
288
|
+
bkg_box_size: int = 200
|
|
289
|
+
) -> float:
|
|
290
|
+
"""
|
|
291
|
+
“Hybrid” global SNR ≔ (μ_patch − median_patch) / σ_central,
|
|
292
|
+
where:
|
|
293
|
+
• μ_patch and median_patch come from a small bkg_box_size×bkg_box_size patch
|
|
294
|
+
centered inside the middle 50% of the image.
|
|
295
|
+
• σ_central is the standard deviation computed over the entire “middle 50%” region.
|
|
296
|
+
|
|
297
|
+
Steps:
|
|
298
|
+
1) Collapse to grayscale (H×W) if needed.
|
|
299
|
+
2) Identify the middle 50% rectangle of the image.
|
|
300
|
+
3) Within that, center a patch of size up to bkg_box_size×bkg_box_size.
|
|
301
|
+
4) Compute μ_patch = mean(patch), median_patch = median(patch).
|
|
302
|
+
5) Compute σ_central = std(middle50_region).
|
|
303
|
+
6) If σ_central ≤ 0, return 0. Otherwise return (μ_patch − median_patch) / σ_central.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
# 1) Collapse to simple 2D float array (grayscale)
|
|
307
|
+
if stack_image.ndim == 3 and stack_image.shape[2] == 3:
|
|
308
|
+
try:
|
|
309
|
+
import cv2
|
|
310
|
+
# cv2.cvtColor is significantly faster than mean(axis=2)
|
|
311
|
+
# Assuming RGB input, but even if BGR, for SNR estimation luma difference is negligible
|
|
312
|
+
gray = cv2.cvtColor(stack_image, cv2.COLOR_RGB2GRAY)
|
|
313
|
+
if gray.dtype != np.float32:
|
|
314
|
+
gray = gray.astype(np.float32)
|
|
315
|
+
except ImportError:
|
|
316
|
+
# Fallback
|
|
317
|
+
gray = stack_image.mean(axis=2).astype(np.float32)
|
|
318
|
+
else:
|
|
319
|
+
# Already mono: just cast to float32
|
|
320
|
+
gray = stack_image.astype(np.float32)
|
|
321
|
+
|
|
322
|
+
H, W = gray.shape
|
|
323
|
+
|
|
324
|
+
# 2) Compute coordinates of the “middle 50%” rectangle
|
|
325
|
+
y0 = H // 4
|
|
326
|
+
y1 = y0 + (H // 2)
|
|
327
|
+
x0 = W // 4
|
|
328
|
+
x1 = x0 + (W // 2)
|
|
329
|
+
|
|
330
|
+
# Extract that central50 region as a view (no copy)
|
|
331
|
+
central50 = gray[y0:y1, x0:x1]
|
|
332
|
+
|
|
333
|
+
# 3) Within that central50, choose a patch of up to bkg_box_size×bkg_box_size, centered
|
|
334
|
+
center_h = (y1 - y0)
|
|
335
|
+
center_w = (x1 - x0)
|
|
336
|
+
|
|
337
|
+
# Clamp box size so it does not exceed central50 dimensions
|
|
338
|
+
box_h = min(bkg_box_size, center_h)
|
|
339
|
+
box_w = min(bkg_box_size, center_w)
|
|
340
|
+
|
|
341
|
+
# Compute top-left corner of that patch so it’s centered in central50
|
|
342
|
+
cy0 = y0 + (center_h - box_h) // 2
|
|
343
|
+
cx0 = x0 + (center_w - box_w) // 2
|
|
344
|
+
|
|
345
|
+
patch = gray[cy0 : cy0 + box_h, cx0 : cx0 + box_w]
|
|
346
|
+
|
|
347
|
+
# 4) Compute patch statistics
|
|
348
|
+
mu_patch = float(np.mean(patch))
|
|
349
|
+
med_patch = float(np.median(patch))
|
|
350
|
+
min_patch = float(np.min(patch))
|
|
351
|
+
|
|
352
|
+
# 5) Compute σ over the entire central50 region
|
|
353
|
+
sigma_central = float(np.std(central50))
|
|
354
|
+
if sigma_central <= 0.0:
|
|
355
|
+
return 0.0
|
|
356
|
+
|
|
357
|
+
nu = med_patch - 3.0 * sigma_central * med_patch
|
|
358
|
+
|
|
359
|
+
# 6) Return (mean − nu) / σ
|
|
360
|
+
return (mu_patch - nu) / sigma_central
|
|
361
|
+
#return (mu_patch) / sigma_central
|
|
362
|
+
|
|
363
|
+
class LiveStackWindow(QDialog):
|
|
364
|
+
def __init__(self, parent=None, doc_manager=None, wrench_path=None, spinner_path=None):
|
|
365
|
+
super().__init__(parent)
|
|
366
|
+
self.parent = parent
|
|
367
|
+
self._docman = doc_manager
|
|
368
|
+
self._wrench_path = wrench_path
|
|
369
|
+
self._spinner_path = spinner_path
|
|
370
|
+
self.setWindowTitle("Live Stacking")
|
|
371
|
+
self.resize(900, 600)
|
|
372
|
+
|
|
373
|
+
# ─── State Variables ─────────────────────────────────────
|
|
374
|
+
self.watch_folder = None
|
|
375
|
+
self.processed_files = set()
|
|
376
|
+
self.master_dark = None
|
|
377
|
+
self.master_flat = None
|
|
378
|
+
self.master_flats = {}
|
|
379
|
+
|
|
380
|
+
self.filter_stacks = {} # key → np.ndarray (float32)
|
|
381
|
+
self.filter_counts = {} # key → int
|
|
382
|
+
self.filter_buffers = {} # key → list of bootstrap frames [H×W arrays]
|
|
383
|
+
self.filter_mus = {} # key → µ array after bootstrap (H×W)
|
|
384
|
+
self.filter_m2s = {} # key → M2 array after bootstrap (H×W)
|
|
385
|
+
|
|
386
|
+
self.cull_folder = None
|
|
387
|
+
|
|
388
|
+
self.is_running = False
|
|
389
|
+
self.frame_count = 0
|
|
390
|
+
self.current_stack = None
|
|
391
|
+
|
|
392
|
+
self._probe = {} # path -> {"size": int, "mtime": float, "since": float, "penalty_until": float}
|
|
393
|
+
# Tunables:
|
|
394
|
+
self.FILE_STABLE_SECS = 3.0 # how long size+mtime must stay unchanged
|
|
395
|
+
self.OPEN_RETRY_PENALTY_SECS = 10.0 # cool-down after a read/permission failure
|
|
396
|
+
self.MAX_FILE_WAIT_SECS = 600.0 # optional safety cap (unused by default logic)
|
|
397
|
+
|
|
398
|
+
# ── Load persisted settings ───────────────────────────────
|
|
399
|
+
s = QSettings()
|
|
400
|
+
self.bootstrap_frames = _qget(s, "LiveStack/bootstrap_frames", 24, int)
|
|
401
|
+
self.clip_threshold = _qget(s, "LiveStack/clip_threshold", 3.5, float)
|
|
402
|
+
self.max_fwhm = _qget(s, "LiveStack/max_fwhm", 15.0, float)
|
|
403
|
+
self.max_ecc = _qget(s, "LiveStack/max_ecc", 0.9, float)
|
|
404
|
+
self.min_star_count = _qget(s, "LiveStack/min_star_count", 5, int)
|
|
405
|
+
self.narrowband_mapping = _qget(s, "LiveStack/narrowband_mapping", "Natural", str)
|
|
406
|
+
self.star_trail_mode = _qget(s, "LiveStack/star_trail_mode", False, bool)
|
|
407
|
+
self.FILE_STABLE_SECS = _qget(s, "LiveStack/file_stable_secs", 3.0, float)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
self.total_exposure = 0.0 # seconds
|
|
411
|
+
self.exposure_label = QLabel("Total Exp: 00:00:00")
|
|
412
|
+
self.exposure_label.setStyleSheet("color: #cccccc; font-weight: bold;")
|
|
413
|
+
|
|
414
|
+
self.brightness = 0.0 # [-1.0..+1.0]
|
|
415
|
+
self.contrast = 1.0 # [0.1..3.0]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
self._buffer = [] # store up to bootstrap_frames normalized frames
|
|
419
|
+
self._mu = None # per-pixel mean (after bootstrap)
|
|
420
|
+
self._m2 = None # per-pixel sum of squares differences (for Welford)
|
|
421
|
+
|
|
422
|
+
# ─── Create Separate Metrics Window (initially hidden) ─────
|
|
423
|
+
# We do NOT embed this in the stacking dialog’s layout!
|
|
424
|
+
self.metrics_window = LiveMetricsWindow(None)
|
|
425
|
+
self.metrics_window.hide()
|
|
426
|
+
|
|
427
|
+
# ─── UI ELEMENTS FOR STACKING DIALOG ───────────────────────
|
|
428
|
+
# 1) Folder selection
|
|
429
|
+
self.folder_label = QLabel("Folder: (none)")
|
|
430
|
+
self.select_folder_btn = QPushButton("Select Folder…")
|
|
431
|
+
self.select_folder_btn.clicked.connect(self.select_folder)
|
|
432
|
+
|
|
433
|
+
# 2) Load master dark/flat
|
|
434
|
+
self.load_darks_btn = QPushButton("Load Master Dark…")
|
|
435
|
+
self.load_darks_btn.clicked.connect(self.load_masters)
|
|
436
|
+
self.load_flats_btn = QPushButton("Load Master Flat…")
|
|
437
|
+
self.load_flats_btn.clicked.connect(self.load_masters)
|
|
438
|
+
self.load_filter_flats_btn = QPushButton("Load MonoFilter Flats…")
|
|
439
|
+
self.load_filter_flats_btn.clicked.connect(self.load_filter_flats)
|
|
440
|
+
|
|
441
|
+
# 2b) Cull folder selection
|
|
442
|
+
self.cull_folder_label = QLabel("Cull Folder: (none)")
|
|
443
|
+
self.select_cull_btn = QPushButton("Select Cull Folder…")
|
|
444
|
+
self.select_cull_btn.clicked.connect(self.select_cull_folder)
|
|
445
|
+
|
|
446
|
+
self.dark_status_label = QLabel("Dark: ❌")
|
|
447
|
+
self.flat_status_label = QLabel("Flat: ❌")
|
|
448
|
+
for lbl in (self.dark_status_label, self.flat_status_label):
|
|
449
|
+
lbl.setStyleSheet("color: #cccccc; font-weight: bold;")
|
|
450
|
+
# 3) “Process & Monitor” / “Monitor Only” / “Stop” / “Reset”
|
|
451
|
+
self.mono_color_checkbox = QCheckBox("Mono → Color Stacking")
|
|
452
|
+
self.mono_color_checkbox.setToolTip(
|
|
453
|
+
"When checked, bucket mono frames by FILTER and composite R, G, B, Ha, OIII, SII."
|
|
454
|
+
)
|
|
455
|
+
# **Connect the toggled(bool) signal** before we ever call it
|
|
456
|
+
self.mono_color_checkbox.toggled.connect(self._on_mono_color_toggled)
|
|
457
|
+
|
|
458
|
+
# ** new: Star-Trail mode checkbox **
|
|
459
|
+
self.star_trail_checkbox = QCheckBox("★★ Star-Trail Mode ★★")
|
|
460
|
+
self.star_trail_checkbox.setChecked(self.star_trail_mode)
|
|
461
|
+
self.star_trail_checkbox.setToolTip("If checked, build a max-value trail instead of a running stack")
|
|
462
|
+
self.star_trail_checkbox.toggled.connect(self._on_star_trail_toggled)
|
|
463
|
+
|
|
464
|
+
self.process_and_monitor_btn = QPushButton("Process && Monitor")
|
|
465
|
+
self.process_and_monitor_btn.clicked.connect(self.start_and_process)
|
|
466
|
+
self.monitor_only_btn = QPushButton("Monitor Only")
|
|
467
|
+
self.monitor_only_btn.clicked.connect(self.start_monitor_only)
|
|
468
|
+
self.stop_btn = QPushButton("Stop")
|
|
469
|
+
self.stop_btn.clicked.connect(self.stop_live)
|
|
470
|
+
self.reset_btn = QPushButton("Reset")
|
|
471
|
+
self.reset_btn.clicked.connect(self.reset_live)
|
|
472
|
+
|
|
473
|
+
self.frame_count_label = QLabel("Frames: 0")
|
|
474
|
+
|
|
475
|
+
# 4) Live‐stack preview area (QGraphicsView)
|
|
476
|
+
self.scene = QGraphicsScene(self)
|
|
477
|
+
self.view = QGraphicsView(self.scene, self)
|
|
478
|
+
self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
|
479
|
+
self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
480
|
+
self.pixmap_item = QGraphicsPixmapItem()
|
|
481
|
+
self.scene.addItem(self.pixmap_item)
|
|
482
|
+
self._did_initial_fit = False
|
|
483
|
+
|
|
484
|
+
# 5) Zoom toolbar + Settings icon
|
|
485
|
+
tb = QToolBar()
|
|
486
|
+
|
|
487
|
+
zi = QAction(QIcon.fromTheme("zoom-in"), "Zoom In", self)
|
|
488
|
+
zo = QAction(QIcon.fromTheme("zoom-out"), "Zoom Out", self)
|
|
489
|
+
fit = QAction(QIcon.fromTheme("zoom-fit-best"), "Fit to Window", self)
|
|
490
|
+
|
|
491
|
+
tb.addAction(zi)
|
|
492
|
+
tb.addAction(zo)
|
|
493
|
+
tb.addAction(fit)
|
|
494
|
+
|
|
495
|
+
spacer = QWidget()
|
|
496
|
+
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
497
|
+
tb.addWidget(spacer)
|
|
498
|
+
# — Replace the QAction “wrench” with a styled QToolButton —
|
|
499
|
+
self.wrench_button = QToolButton()
|
|
500
|
+
self.wrench_button.setIcon(QIcon(self._wrench_path))
|
|
501
|
+
self.wrench_button.setToolTip("Settings")
|
|
502
|
+
# Apply your stylesheet to the QToolButton
|
|
503
|
+
self.wrench_button.setStyleSheet("""
|
|
504
|
+
QToolButton {
|
|
505
|
+
background-color: #FF4500;
|
|
506
|
+
color: white;
|
|
507
|
+
font-size: 16px;
|
|
508
|
+
padding: 8px;
|
|
509
|
+
border-radius: 5px;
|
|
510
|
+
font-weight: bold;
|
|
511
|
+
}
|
|
512
|
+
QToolButton:hover {
|
|
513
|
+
background-color: #FF6347;
|
|
514
|
+
}
|
|
515
|
+
""")
|
|
516
|
+
# Connect the clicked signal to open_settings()
|
|
517
|
+
self.wrench_button.clicked.connect(self.open_settings)
|
|
518
|
+
|
|
519
|
+
# Add the styled QToolButton into the toolbar
|
|
520
|
+
tb.addWidget(self.wrench_button)
|
|
521
|
+
|
|
522
|
+
zi.triggered.connect(self.zoom_in)
|
|
523
|
+
zo.triggered.connect(self.zoom_out)
|
|
524
|
+
fit.triggered.connect(self.fit_to_window)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# 6) Brightness & Contrast sliders
|
|
528
|
+
bright_slider = QSlider(Qt.Orientation.Horizontal)
|
|
529
|
+
bright_slider.setRange(-100, 100)
|
|
530
|
+
bright_slider.setValue(0)
|
|
531
|
+
bright_slider.setToolTip("Brightness")
|
|
532
|
+
bright_slider.valueChanged.connect(self.on_brightness_changed)
|
|
533
|
+
|
|
534
|
+
contrast_slider = QSlider(Qt.Orientation.Horizontal)
|
|
535
|
+
contrast_slider.setRange(10, 1000)
|
|
536
|
+
contrast_slider.setValue(100)
|
|
537
|
+
contrast_slider.setToolTip("Contrast")
|
|
538
|
+
contrast_slider.valueChanged.connect(self.on_contrast_changed)
|
|
539
|
+
|
|
540
|
+
bc_layout = QHBoxLayout()
|
|
541
|
+
bc_layout.addWidget(QLabel("Brightness"))
|
|
542
|
+
bc_layout.addWidget(bright_slider)
|
|
543
|
+
bc_layout.addWidget(QLabel("Contrast"))
|
|
544
|
+
bc_layout.addWidget(contrast_slider)
|
|
545
|
+
|
|
546
|
+
# 7) “Send to Slot” button
|
|
547
|
+
open_btn = QPushButton("Open in New View ▶")
|
|
548
|
+
open_btn.clicked.connect(self.send_to_new_view)
|
|
549
|
+
|
|
550
|
+
# 8) “Show Metrics” button
|
|
551
|
+
self.show_metrics_btn = QPushButton("Show Metrics")
|
|
552
|
+
self.show_metrics_btn.clicked.connect(self.show_metrics_window)
|
|
553
|
+
|
|
554
|
+
# ─── ASSEMBLE MAIN LAYOUT (exactly one setLayout call!) ─────
|
|
555
|
+
main_layout = QVBoxLayout()
|
|
556
|
+
|
|
557
|
+
# A) Top‐row controls
|
|
558
|
+
controls = QHBoxLayout()
|
|
559
|
+
controls.addWidget(self.select_folder_btn)
|
|
560
|
+
controls.addWidget(self.load_darks_btn)
|
|
561
|
+
controls.addWidget(self.load_flats_btn)
|
|
562
|
+
controls.addWidget(self.load_filter_flats_btn)
|
|
563
|
+
controls.addWidget(self.select_cull_btn)
|
|
564
|
+
controls.addStretch()
|
|
565
|
+
controls.addWidget(self.mono_color_checkbox)
|
|
566
|
+
controls.addWidget(self.star_trail_checkbox)
|
|
567
|
+
controls.addWidget(self.process_and_monitor_btn)
|
|
568
|
+
controls.addWidget(self.monitor_only_btn)
|
|
569
|
+
controls.addWidget(self.stop_btn)
|
|
570
|
+
controls.addWidget(self.reset_btn)
|
|
571
|
+
main_layout.addLayout(controls)
|
|
572
|
+
|
|
573
|
+
# B) Status line: folder label + frame count
|
|
574
|
+
status_line = QHBoxLayout()
|
|
575
|
+
status_line.addWidget(self.folder_label)
|
|
576
|
+
status_line.addWidget(self.dark_status_label)
|
|
577
|
+
status_line.addWidget(self.flat_status_label)
|
|
578
|
+
status_line.addWidget(self.cull_folder_label)
|
|
579
|
+
status_line.addStretch()
|
|
580
|
+
status_line.addWidget(self.frame_count_label)
|
|
581
|
+
status_line.addWidget(self.exposure_label)
|
|
582
|
+
main_layout.addLayout(status_line)
|
|
583
|
+
|
|
584
|
+
# C) Zoom toolbar
|
|
585
|
+
main_layout.addWidget(tb)
|
|
586
|
+
|
|
587
|
+
# D) Show Metrics button (separate window)
|
|
588
|
+
main_layout.addWidget(self.show_metrics_btn)
|
|
589
|
+
|
|
590
|
+
# E) Live‐stack preview area
|
|
591
|
+
main_layout.addWidget(self.view)
|
|
592
|
+
|
|
593
|
+
# F) Brightness/Contrast sliders
|
|
594
|
+
main_layout.addLayout(bc_layout)
|
|
595
|
+
|
|
596
|
+
# G) “Send to Slot” + mode/idle labels
|
|
597
|
+
main_layout.addWidget(open_btn)
|
|
598
|
+
self.mode_label = QLabel("Mode: Linear Average")
|
|
599
|
+
self.mode_label.setStyleSheet("color: #a0a0a0;")
|
|
600
|
+
main_layout.addWidget(self.mode_label)
|
|
601
|
+
self.status_label = QLabel("Idle")
|
|
602
|
+
self.status_label.setStyleSheet("color: #a0a0a0;")
|
|
603
|
+
main_layout.addWidget(self.status_label)
|
|
604
|
+
|
|
605
|
+
# Finalize
|
|
606
|
+
self.setLayout(main_layout)
|
|
607
|
+
|
|
608
|
+
# Timer for polling new files
|
|
609
|
+
self.poll_timer = QTimer(self)
|
|
610
|
+
self.poll_timer.setInterval(1500)
|
|
611
|
+
self.poll_timer.timeout.connect(self.check_for_new_frames)
|
|
612
|
+
self._on_mono_color_toggled(self.mono_color_checkbox.isChecked())
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ─────────────────────────────────────────────────────────────────────────
|
|
616
|
+
def _on_star_trail_toggled(self, checked: bool):
|
|
617
|
+
"""Enable/disable star-trail mode."""
|
|
618
|
+
self.star_trail_mode = checked
|
|
619
|
+
QSettings().setValue("LiveStack/star_trail_mode", checked)
|
|
620
|
+
self.mode_label.setText("Mode: Star-Trail" if checked else "Mode: Linear Average")
|
|
621
|
+
# if you want, disable mono/color checkbox when star-trail is on:
|
|
622
|
+
self.mono_color_checkbox.setEnabled(not checked)
|
|
623
|
+
|
|
624
|
+
def _on_mono_color_toggled(self, checked: bool):
|
|
625
|
+
self.mono_color_mode = checked
|
|
626
|
+
self.filter_stacks.clear()
|
|
627
|
+
self.filter_counts.clear()
|
|
628
|
+
|
|
629
|
+
msg = "Enabled" if checked else "Disabled"
|
|
630
|
+
self.status_label.setText(f"Mono→Color Mode {msg}")
|
|
631
|
+
|
|
632
|
+
def show_metrics_window(self):
|
|
633
|
+
"""Pop up the separate metrics window (never embed it here)."""
|
|
634
|
+
self.metrics_window.show()
|
|
635
|
+
self.metrics_window.raise_()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def select_cull_folder(self):
|
|
639
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Cull Folder")
|
|
640
|
+
if folder:
|
|
641
|
+
self.cull_folder = folder
|
|
642
|
+
self.cull_folder_label.setText(f"Cull: {os.path.basename(folder)}")
|
|
643
|
+
|
|
644
|
+
def _cull_frame(self, path: str):
|
|
645
|
+
"""
|
|
646
|
+
Move a flagged frame into the cull folder (if set),
|
|
647
|
+
or just update the status label if not.
|
|
648
|
+
"""
|
|
649
|
+
name = os.path.basename(path)
|
|
650
|
+
if self.cull_folder:
|
|
651
|
+
try:
|
|
652
|
+
os.makedirs(self.cull_folder, exist_ok=True)
|
|
653
|
+
dst = os.path.join(self.cull_folder, name)
|
|
654
|
+
shutil.move(path, dst)
|
|
655
|
+
self.status_label.setText(f"⚠ Culled {name} → {self.cull_folder}")
|
|
656
|
+
except Exception:
|
|
657
|
+
self.status_label.setText(f"⚠ Failed to cull {name}")
|
|
658
|
+
else:
|
|
659
|
+
self.status_label.setText(f"⚠ Flagged (not stacked): {name}")
|
|
660
|
+
QApplication.processEvents()
|
|
661
|
+
|
|
662
|
+
def open_settings(self):
|
|
663
|
+
dlg = LiveStackSettingsDialog(self)
|
|
664
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
665
|
+
bs, sigma, fwhm, ecc, stars, mapping, delay = dlg.getValues()
|
|
666
|
+
|
|
667
|
+
# 1) Persist into QSettings
|
|
668
|
+
s = QSettings()
|
|
669
|
+
s.setValue("LiveStack/bootstrap_frames", bs)
|
|
670
|
+
s.setValue("LiveStack/clip_threshold", sigma)
|
|
671
|
+
s.setValue("LiveStack/max_fwhm", fwhm)
|
|
672
|
+
s.setValue("LiveStack/max_ecc", ecc)
|
|
673
|
+
s.setValue("LiveStack/min_star_count", stars)
|
|
674
|
+
s.setValue("LiveStack/narrowband_mapping", mapping)
|
|
675
|
+
s.setValue("LiveStack/file_stable_secs", delay)
|
|
676
|
+
|
|
677
|
+
# 2) Apply to this live‐stack session
|
|
678
|
+
self.bootstrap_frames = bs
|
|
679
|
+
self.clip_threshold = sigma
|
|
680
|
+
self.max_fwhm = fwhm
|
|
681
|
+
self.max_ecc = ecc
|
|
682
|
+
self.min_star_count = stars
|
|
683
|
+
self.narrowband_mapping = mapping
|
|
684
|
+
self.FILE_STABLE_SECS = delay
|
|
685
|
+
|
|
686
|
+
self.status_label.setText(
|
|
687
|
+
f"↺ Settings saved: BS={bs}, σ={sigma:.1f}, "
|
|
688
|
+
f"FWHM≤{fwhm:.1f}, ECC≤{ecc:.2f}, Stars≥{stars}, "
|
|
689
|
+
f"Mapping={mapping}"
|
|
690
|
+
)
|
|
691
|
+
QApplication.processEvents()
|
|
692
|
+
|
|
693
|
+
def zoom_in(self):
|
|
694
|
+
self.view.scale(1.2, 1.2)
|
|
695
|
+
|
|
696
|
+
def zoom_out(self):
|
|
697
|
+
self.view.scale(1/1.2, 1/1.2)
|
|
698
|
+
|
|
699
|
+
def fit_to_window(self):
|
|
700
|
+
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
701
|
+
|
|
702
|
+
# — Brightness / Contrast —
|
|
703
|
+
|
|
704
|
+
def _refresh_preview(self):
|
|
705
|
+
"""
|
|
706
|
+
Recompute the current preview array (stack vs. composite)
|
|
707
|
+
and call update_preview on it.
|
|
708
|
+
"""
|
|
709
|
+
if self.mono_color_mode:
|
|
710
|
+
# build the composite from filter_stacks
|
|
711
|
+
preview = self._build_color_composite()
|
|
712
|
+
else:
|
|
713
|
+
# use the normal running stack
|
|
714
|
+
preview = self.current_stack
|
|
715
|
+
|
|
716
|
+
if preview is not None:
|
|
717
|
+
self.update_preview(preview)
|
|
718
|
+
|
|
719
|
+
def on_brightness_changed(self, val: int):
|
|
720
|
+
self.brightness = val / 100.0 # map to [-1,1]
|
|
721
|
+
self._refresh_preview()
|
|
722
|
+
|
|
723
|
+
def on_contrast_changed(self, val: int):
|
|
724
|
+
self.contrast = val / 100.0 # map to [0.1,10.0]
|
|
725
|
+
self._refresh_preview()
|
|
726
|
+
|
|
727
|
+
# — Sending out —
|
|
728
|
+
|
|
729
|
+
def send_to_new_view(self):
|
|
730
|
+
"""
|
|
731
|
+
Create a brand-new document/view from the current live stack or composite.
|
|
732
|
+
Prefers using doc_manager's native numpy-open methods; otherwise falls back
|
|
733
|
+
to writing a temp TIFF and asking the host window to open it.
|
|
734
|
+
"""
|
|
735
|
+
# pick what to export
|
|
736
|
+
if self.mono_color_mode:
|
|
737
|
+
img = self._build_color_composite()
|
|
738
|
+
else:
|
|
739
|
+
img = self.current_stack
|
|
740
|
+
|
|
741
|
+
if img is None:
|
|
742
|
+
self.status_label.setText("⚠ Nothing to open")
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
# ensure float32 in [0,1]
|
|
746
|
+
img = np.clip(img, 0.0, 1.0).astype(np.float32)
|
|
747
|
+
|
|
748
|
+
title = f"LiveStack_{_dt.datetime.now():%Y%m%d_%H%M%S}_{self.frame_count}f"
|
|
749
|
+
metadata = {"source": "LiveStack", "frames_stacked": int(self.frame_count)}
|
|
750
|
+
|
|
751
|
+
# 1) Try doc_manager direct numpy APIs (several common names)
|
|
752
|
+
dm = self._docman
|
|
753
|
+
if dm is not None:
|
|
754
|
+
for name in ("create_numpy_document",
|
|
755
|
+
"new_document_from_numpy",
|
|
756
|
+
"open_numpy",
|
|
757
|
+
"open_array",
|
|
758
|
+
"open_image_array",
|
|
759
|
+
"add_document_from_array"):
|
|
760
|
+
fn = getattr(dm, name, None)
|
|
761
|
+
if callable(fn):
|
|
762
|
+
try:
|
|
763
|
+
fn(img, title=title, metadata=metadata)
|
|
764
|
+
self.status_label.setText(f"Opened new view: {title}")
|
|
765
|
+
return
|
|
766
|
+
except TypeError:
|
|
767
|
+
# some variants might not accept title/metadata
|
|
768
|
+
try:
|
|
769
|
+
fn(img)
|
|
770
|
+
self.status_label.setText(f"Opened new view: {title}")
|
|
771
|
+
return
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
except Exception:
|
|
775
|
+
pass
|
|
776
|
+
|
|
777
|
+
# 2) Fallback: write a temp 16-bit TIFF and ask main window to open it
|
|
778
|
+
try:
|
|
779
|
+
import tifffile as tiff
|
|
780
|
+
tmp = tempfile.NamedTemporaryFile(suffix=".tiff", delete=False)
|
|
781
|
+
tmp_path = tmp.name
|
|
782
|
+
tmp.close()
|
|
783
|
+
# export as 16-bit so it's friendly to the rest of the app
|
|
784
|
+
arr16 = np.clip(img * 65535.0, 0, 65535).astype(np.uint16)
|
|
785
|
+
tiff.imwrite(tmp_path, arr16)
|
|
786
|
+
|
|
787
|
+
host = self.parent
|
|
788
|
+
for name in ("open_files", "open_file", "load_paths", "load_path"):
|
|
789
|
+
fn = getattr(host, name, None)
|
|
790
|
+
if callable(fn):
|
|
791
|
+
try:
|
|
792
|
+
fn([tmp_path]) if fn.__code__.co_argcount != 2 else fn(tmp_path)
|
|
793
|
+
self.status_label.setText(f"Opened new view from temp: {os.path.basename(tmp_path)}")
|
|
794
|
+
return
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
797
|
+
|
|
798
|
+
# ultimate fallback: let the user know where it went
|
|
799
|
+
QMessageBox.information(self, "Saved Temp Image",
|
|
800
|
+
f"Saved temp: {tmp_path}\nOpen it from File → Open.")
|
|
801
|
+
except Exception as e:
|
|
802
|
+
QMessageBox.warning(self, "Open Failed",
|
|
803
|
+
f"Could not open in new view:\n{e}")
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# ── New helper: map header["FILTER"] to a single letter key
|
|
807
|
+
def _get_filter_key(self, header):
|
|
808
|
+
"""
|
|
809
|
+
Map a FITS header FILTER string to one of:
|
|
810
|
+
'L' (luminance),
|
|
811
|
+
'R','G','B',
|
|
812
|
+
'H' (H-alpha),
|
|
813
|
+
'O' (OIII),
|
|
814
|
+
'S' (SII),
|
|
815
|
+
or return None if it doesn’t match.
|
|
816
|
+
"""
|
|
817
|
+
raw = header.get('FILTER', '')
|
|
818
|
+
fn = raw.strip().upper()
|
|
819
|
+
if not fn:
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
# H-alpha
|
|
823
|
+
if fn in ('H', 'HA', 'HALPHA', 'H-ALPHA'):
|
|
824
|
+
return 'H'
|
|
825
|
+
# OIII
|
|
826
|
+
if fn in ('O', 'O3', 'OIII'):
|
|
827
|
+
return 'O'
|
|
828
|
+
# SII
|
|
829
|
+
if fn in ('S', 'S2', 'SII'):
|
|
830
|
+
return 'S'
|
|
831
|
+
# Red
|
|
832
|
+
if fn in ('R', 'RED', 'RD'):
|
|
833
|
+
return 'R'
|
|
834
|
+
# Green
|
|
835
|
+
if fn in ('G', 'GREEN', 'GRN'):
|
|
836
|
+
return 'G'
|
|
837
|
+
# Blue
|
|
838
|
+
if fn in ('B', 'BLUE', 'BL'):
|
|
839
|
+
return 'B'
|
|
840
|
+
# Luminance
|
|
841
|
+
if fn in ('L', 'LUM', 'LUMI', 'LUMINANCE'):
|
|
842
|
+
return 'L'
|
|
843
|
+
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
# ── New helper: stack a single mono frame under filter key
|
|
847
|
+
def _stack_mono_channel(self, key, img, delta=None):
|
|
848
|
+
# img: 2D or 3D array; we convert to 2D mono always
|
|
849
|
+
mono = img if img.ndim==2 else np.mean(img,axis=2)
|
|
850
|
+
# align if you need (use same logic as color branch)
|
|
851
|
+
if hasattr(self, 'reference_image_2d'):
|
|
852
|
+
d = delta or StarRegistrationWorker.compute_affine_transform_astroalign(
|
|
853
|
+
mono, self.reference_image_2d)
|
|
854
|
+
if d is not None:
|
|
855
|
+
mono = StarRegistrationThread.apply_affine_transform_static(mono, d)
|
|
856
|
+
# normalize
|
|
857
|
+
norm = stretch_mono_image(mono, target_median=0.3)
|
|
858
|
+
# first frame?
|
|
859
|
+
if key not in self.filter_stacks:
|
|
860
|
+
self.filter_stacks[key] = norm.copy()
|
|
861
|
+
self.filter_counts[key] = 1
|
|
862
|
+
# set reference on first good channel frame
|
|
863
|
+
if not hasattr(self, 'reference_image_2d'):
|
|
864
|
+
self.reference_image_2d = norm.copy()
|
|
865
|
+
else:
|
|
866
|
+
cnt = self.filter_counts[key]
|
|
867
|
+
self.filter_stacks[key] = (cnt/self.filter_counts[key]+1)*self.filter_stacks[key] \
|
|
868
|
+
+ (1.0/(cnt+1))*norm
|
|
869
|
+
self.filter_counts[key] += 1
|
|
870
|
+
|
|
871
|
+
# ── New helper: build an RGB preview from whatever channels we have
|
|
872
|
+
def _build_color_composite(self):
|
|
873
|
+
"""
|
|
874
|
+
Composite filters into an RGB preview according to self.narrowband_mapping:
|
|
875
|
+
|
|
876
|
+
• "Natural":
|
|
877
|
+
– If SII present:
|
|
878
|
+
R = 0.5*(Ha + SII)
|
|
879
|
+
G = 0.5*(SII + OIII)
|
|
880
|
+
B = OIII
|
|
881
|
+
– Elif any R/G/B loaded:
|
|
882
|
+
R = R_filter
|
|
883
|
+
G = G_filter + OIII
|
|
884
|
+
B = B_filter + OIII
|
|
885
|
+
– Else (no SII, no R/G/B):
|
|
886
|
+
R = Ha
|
|
887
|
+
G = OIII
|
|
888
|
+
B = OIII
|
|
889
|
+
|
|
890
|
+
• Any 3-letter code (e.g. "SHO", "OHS"):
|
|
891
|
+
R = filter_stacks[mapping[0]]
|
|
892
|
+
G = filter_stacks[mapping[1]]
|
|
893
|
+
B = filter_stacks[mapping[2]]
|
|
894
|
+
|
|
895
|
+
Missing channels default to zero.
|
|
896
|
+
"""
|
|
897
|
+
# 1) Determine H, W
|
|
898
|
+
if self.filter_stacks:
|
|
899
|
+
first = next(iter(self.filter_stacks.values()))
|
|
900
|
+
H, W = first.shape
|
|
901
|
+
elif getattr(self, 'current_stack', None) is not None:
|
|
902
|
+
H, W = self.current_stack.shape[:2]
|
|
903
|
+
else:
|
|
904
|
+
return None
|
|
905
|
+
|
|
906
|
+
# helper: get stack or zeros
|
|
907
|
+
def getf(k):
|
|
908
|
+
return self.filter_stacks.get(k, np.zeros((H, W), np.float32))
|
|
909
|
+
|
|
910
|
+
mode = self.narrowband_mapping.upper()
|
|
911
|
+
if mode == "NATURAL":
|
|
912
|
+
Ha = getf('H')
|
|
913
|
+
O3 = getf('O')
|
|
914
|
+
S2 = self.filter_stacks.get('S', None)
|
|
915
|
+
Rf = self.filter_stacks.get('R', None)
|
|
916
|
+
Gf = self.filter_stacks.get('G', None)
|
|
917
|
+
Bf = self.filter_stacks.get('B', None)
|
|
918
|
+
|
|
919
|
+
if S2 is not None:
|
|
920
|
+
# narrowband SII branch
|
|
921
|
+
R = 0.5 * (Ha + S2)
|
|
922
|
+
G = 0.5 * (S2 + O3)
|
|
923
|
+
B = O3.copy()
|
|
924
|
+
|
|
925
|
+
elif any(x is not None for x in (Rf, Gf, Bf)):
|
|
926
|
+
# broadband branch: Rf/Gf/Bf with OIII boost
|
|
927
|
+
R = Rf if Rf is not None else np.zeros((H, W), np.float32)
|
|
928
|
+
G = (Gf if Gf is not None else np.zeros((H, W), np.float32)) + O3
|
|
929
|
+
B = (Bf if Bf is not None else np.zeros((H, W), np.float32)) + O3
|
|
930
|
+
|
|
931
|
+
else:
|
|
932
|
+
# fallback HOO
|
|
933
|
+
R = Ha
|
|
934
|
+
G = O3
|
|
935
|
+
B = O3
|
|
936
|
+
|
|
937
|
+
else:
|
|
938
|
+
# direct mapping: e.g. "SHO" → R=S, G=H, B=O
|
|
939
|
+
letters = list(mode)
|
|
940
|
+
if len(letters) != 3 or any(l not in ("S","H","O") for l in letters):
|
|
941
|
+
# invalid code → fallback to natural
|
|
942
|
+
return self._build_color_composite.__wrapped__(self)
|
|
943
|
+
|
|
944
|
+
R = getf(letters[0])
|
|
945
|
+
G = getf(letters[1])
|
|
946
|
+
B = getf(letters[2])
|
|
947
|
+
|
|
948
|
+
return np.stack([R, G, B], axis=2)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def select_folder(self):
|
|
952
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Folder to Watch")
|
|
953
|
+
if folder:
|
|
954
|
+
self.watch_folder = folder
|
|
955
|
+
self.folder_label.setText(f"Folder: {os.path.basename(folder)}")
|
|
956
|
+
|
|
957
|
+
def load_masters(self):
|
|
958
|
+
"""
|
|
959
|
+
When the user picks “Load Master Dark…” or “Load Master Flat…”, we load exactly one file
|
|
960
|
+
(the first in the dialog). We simply store it in `self.master_dark` or `self.master_flat`,
|
|
961
|
+
but we also check its dimensions against any existing master so that the user can’t load
|
|
962
|
+
a 2D flat while the dark is 3D (for example).
|
|
963
|
+
"""
|
|
964
|
+
sender = self.sender()
|
|
965
|
+
dlg = QFileDialog(self, "Select Master Files",
|
|
966
|
+
filter="FITS TIFF or XISF (*.fit *.fits *.tif *.tiff *.xisf)")
|
|
967
|
+
dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
|
|
968
|
+
if not dlg.exec():
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
chosen = dlg.selectedFiles()[0]
|
|
972
|
+
img, hdr, bit_depth, is_mono = load_image(chosen)
|
|
973
|
+
if img is None:
|
|
974
|
+
QMessageBox.warning(self, "Load Error",
|
|
975
|
+
f"Failed to load master file:\n{chosen}")
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
# Convert everything to float32 for consistency
|
|
979
|
+
img = img.astype(np.float32)
|
|
980
|
+
|
|
981
|
+
if "Dark" in sender.text():
|
|
982
|
+
# If a flat is already loaded, ensure shape‐compatibility
|
|
983
|
+
if self.master_flat is not None:
|
|
984
|
+
if not self._shapes_compatible(master=img, other=self.master_flat):
|
|
985
|
+
QMessageBox.warning(
|
|
986
|
+
self, "Shape Mismatch",
|
|
987
|
+
"Cannot load this master dark: it has incompatible shape "
|
|
988
|
+
"vs. the already‐loaded master flat."
|
|
989
|
+
)
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
self.master_dark = img
|
|
993
|
+
self.dark_status_label.setText("Dark: ✅")
|
|
994
|
+
self.dark_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
|
|
995
|
+
QMessageBox.information(
|
|
996
|
+
self, "Master Dark Loaded",
|
|
997
|
+
f"Loaded master dark:\n{os.path.basename(chosen)}"
|
|
998
|
+
)
|
|
999
|
+
else:
|
|
1000
|
+
# "Flat" was clicked
|
|
1001
|
+
if self.master_dark is not None:
|
|
1002
|
+
if not self._shapes_compatible(master=self.master_dark, other=img):
|
|
1003
|
+
QMessageBox.warning(
|
|
1004
|
+
self, "Shape Mismatch",
|
|
1005
|
+
"Cannot load this master flat: it has incompatible shape "
|
|
1006
|
+
"vs. the already‐loaded master dark."
|
|
1007
|
+
)
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
self.master_flat = img
|
|
1011
|
+
self.flat_status_label.setText("Flat: ✅")
|
|
1012
|
+
self.flat_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
|
|
1013
|
+
QMessageBox.information(
|
|
1014
|
+
self, "Master Flat Loaded",
|
|
1015
|
+
f"Loaded master flat:\n{os.path.basename(chosen)}"
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
def load_filter_flats(self):
|
|
1019
|
+
"""
|
|
1020
|
+
Let the user pick one or more flat files.
|
|
1021
|
+
We try to read the FITS header FILTER key to decide which filter
|
|
1022
|
+
each flat belongs to; otherwise fall back to the filename.
|
|
1023
|
+
"""
|
|
1024
|
+
dlg = QFileDialog(self, "Select Filter Flats",
|
|
1025
|
+
filter="FITS or TIFF (*.fit *.fits *.tif *.tiff)")
|
|
1026
|
+
dlg.setFileMode(QFileDialog.FileMode.ExistingFiles)
|
|
1027
|
+
if not dlg.exec():
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
files = dlg.selectedFiles()
|
|
1031
|
+
loaded = []
|
|
1032
|
+
for path in files:
|
|
1033
|
+
img, hdr, bit_depth, is_mono = load_image(path)
|
|
1034
|
+
if img is None:
|
|
1035
|
+
continue
|
|
1036
|
+
# guess filter key from header, else from filename
|
|
1037
|
+
key = None
|
|
1038
|
+
if hdr and hdr.get("FILTER"):
|
|
1039
|
+
key = self._get_filter_key(hdr)
|
|
1040
|
+
if not key:
|
|
1041
|
+
# fallback: basename before extension
|
|
1042
|
+
key = os.path.splitext(os.path.basename(path))[0]
|
|
1043
|
+
|
|
1044
|
+
# store it
|
|
1045
|
+
self.master_flats[key] = img.astype(np.float32)
|
|
1046
|
+
loaded.append(key)
|
|
1047
|
+
|
|
1048
|
+
# update the flat status label to list loaded filters
|
|
1049
|
+
if loaded:
|
|
1050
|
+
names = ", ".join(loaded)
|
|
1051
|
+
self.flat_status_label.setText(f"Flats: {names}")
|
|
1052
|
+
self.flat_status_label.setStyleSheet("color: #00cc66; font-weight: bold;")
|
|
1053
|
+
QMessageBox.information(
|
|
1054
|
+
self, "Filter Flats Loaded",
|
|
1055
|
+
f"Loaded flats for filters: {names}"
|
|
1056
|
+
)
|
|
1057
|
+
else:
|
|
1058
|
+
QMessageBox.warning(self, "No Flats Loaded",
|
|
1059
|
+
"No flats could be loaded.")
|
|
1060
|
+
|
|
1061
|
+
def _shapes_compatible(self, master: np.ndarray, other: np.ndarray) -> bool:
|
|
1062
|
+
"""
|
|
1063
|
+
Return True if `master` and `other` can be used together in calibration:
|
|
1064
|
+
- Exactly the same shape, OR
|
|
1065
|
+
- master is 2D (H×W) and other is 3D (H×W×3), OR
|
|
1066
|
+
- vice versa.
|
|
1067
|
+
"""
|
|
1068
|
+
if master.shape == other.shape:
|
|
1069
|
+
return True
|
|
1070
|
+
|
|
1071
|
+
# If one is 2D and the other is H×W×3, check the first two dims
|
|
1072
|
+
if master.ndim == 2 and other.ndim == 3 and other.shape[:2] == master.shape:
|
|
1073
|
+
return True
|
|
1074
|
+
if other.ndim == 2 and master.ndim == 3 and master.shape[:2] == other.shape:
|
|
1075
|
+
return True
|
|
1076
|
+
|
|
1077
|
+
return False
|
|
1078
|
+
|
|
1079
|
+
def _average_images(self, paths):
|
|
1080
|
+
# stub: load each via load_image(), convert to float32, accumulate & divide
|
|
1081
|
+
return None
|
|
1082
|
+
|
|
1083
|
+
def _normalized_average(self, paths):
|
|
1084
|
+
# stub: load each, divide by its mean, average them, then renormalize
|
|
1085
|
+
return None
|
|
1086
|
+
|
|
1087
|
+
def start_and_process(self):
|
|
1088
|
+
"""Process everything currently in folder, then begin monitoring."""
|
|
1089
|
+
if not self.watch_folder:
|
|
1090
|
+
self.status_label.setText("❗ No folder selected")
|
|
1091
|
+
return
|
|
1092
|
+
# Clear any old record so existing files are re-processed
|
|
1093
|
+
self.processed_files.clear()
|
|
1094
|
+
# Process all current files once
|
|
1095
|
+
self.check_for_new_frames()
|
|
1096
|
+
# Now start monitoring
|
|
1097
|
+
self.is_running = True
|
|
1098
|
+
self.poll_timer.start()
|
|
1099
|
+
self.status_label.setText(f"▶ Processing & Monitoring: {os.path.basename(self.watch_folder)}")
|
|
1100
|
+
|
|
1101
|
+
def start_monitor_only(self):
|
|
1102
|
+
"""Mark existing files as seen and only process new arrivals."""
|
|
1103
|
+
if not self.watch_folder:
|
|
1104
|
+
self.status_label.setText("❗ No folder selected")
|
|
1105
|
+
return
|
|
1106
|
+
# Populate processed_files with all existing files so they won't be re-processed
|
|
1107
|
+
exts = (
|
|
1108
|
+
"*.fit", "*.fits", "*.tif", "*.tiff",
|
|
1109
|
+
"*.cr2", "*.cr3", "*.nef", "*.arw",
|
|
1110
|
+
"*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf", "*.png", "*.jpg", "*.jpeg"
|
|
1111
|
+
)
|
|
1112
|
+
all_paths = []
|
|
1113
|
+
for ext in exts:
|
|
1114
|
+
all_paths += glob.glob(os.path.join(self.watch_folder, "**", ext), recursive=True)
|
|
1115
|
+
self.processed_files = set(all_paths)
|
|
1116
|
+
|
|
1117
|
+
# Start monitoring
|
|
1118
|
+
self.is_running = True
|
|
1119
|
+
self.poll_timer.start()
|
|
1120
|
+
self.status_label.setText(f"▶ Monitoring Only: {os.path.basename(self.watch_folder)}")
|
|
1121
|
+
|
|
1122
|
+
def start_live(self):
|
|
1123
|
+
if not self.watch_folder:
|
|
1124
|
+
self.status_label.setText("❗ No folder selected")
|
|
1125
|
+
return
|
|
1126
|
+
self.is_running = True
|
|
1127
|
+
self.poll_timer.start()
|
|
1128
|
+
self.status_label.setText(f"▶ Monitoring: {os.path.basename(self.watch_folder)}")
|
|
1129
|
+
self.mode_label.setText("Mode: Linear Average")
|
|
1130
|
+
|
|
1131
|
+
def stop_live(self):
|
|
1132
|
+
if self.is_running:
|
|
1133
|
+
self.is_running = False
|
|
1134
|
+
self.poll_timer.stop()
|
|
1135
|
+
self.status_label.setText("■ Stopped")
|
|
1136
|
+
else:
|
|
1137
|
+
self.status_label.setText("■ Already stopped")
|
|
1138
|
+
|
|
1139
|
+
def reset_live(self):
|
|
1140
|
+
if self.is_running:
|
|
1141
|
+
self.is_running = False
|
|
1142
|
+
self.poll_timer.stop()
|
|
1143
|
+
self.status_label.setText("■ Stopped")
|
|
1144
|
+
else:
|
|
1145
|
+
self.status_label.setText("■ Already stopped")
|
|
1146
|
+
|
|
1147
|
+
# Clear all state
|
|
1148
|
+
self.processed_files.clear()
|
|
1149
|
+
self.frame_count = 0
|
|
1150
|
+
self.current_stack = None
|
|
1151
|
+
|
|
1152
|
+
self.total_exposure = 0.0
|
|
1153
|
+
self.exposure_label.setText("Total Exp: 00:00:00")
|
|
1154
|
+
|
|
1155
|
+
self.filter_stacks.clear()
|
|
1156
|
+
self.filter_counts.clear()
|
|
1157
|
+
self.filter_buffers.clear()
|
|
1158
|
+
self.filter_mus.clear()
|
|
1159
|
+
self.filter_m2s.clear()
|
|
1160
|
+
|
|
1161
|
+
if hasattr(self, 'reference_image_2d'):
|
|
1162
|
+
del self.reference_image_2d
|
|
1163
|
+
|
|
1164
|
+
# Re-initialize bootstrapping stats
|
|
1165
|
+
self._buffer = []
|
|
1166
|
+
self._mu = None
|
|
1167
|
+
self._m2 = None
|
|
1168
|
+
|
|
1169
|
+
# NEW: clear the metrics panel
|
|
1170
|
+
self.metrics_window.metrics_panel.clear_all()
|
|
1171
|
+
|
|
1172
|
+
# Update labels
|
|
1173
|
+
self.frame_count_label.setText("Frames: 0")
|
|
1174
|
+
self.status_label.setText("↺ Reset")
|
|
1175
|
+
self.mode_label.setText("Mode: Linear Average")
|
|
1176
|
+
|
|
1177
|
+
# Clear the displayed image
|
|
1178
|
+
self.pixmap_item.setPixmap(QPixmap())
|
|
1179
|
+
|
|
1180
|
+
# Reset zoom/pan fit flag
|
|
1181
|
+
self._did_initial_fit = False
|
|
1182
|
+
#self.master_dark = None
|
|
1183
|
+
#self.master_flat = None
|
|
1184
|
+
#self.dark_status_label.setText("Dark: ❌")
|
|
1185
|
+
#self.flat_status_label.setText("Flat: ❌")
|
|
1186
|
+
#self.dark_status_label.setStyleSheet("color: #cccccc; font-weight: bold;")
|
|
1187
|
+
#self.flat_status_label.setStyleSheet("color: #cccccc; font-weight: bold;")
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _update_probe(self, path: str) -> dict:
|
|
1191
|
+
"""Update probe info (size, mtime) for path and return the info dict."""
|
|
1192
|
+
try:
|
|
1193
|
+
st = os.stat(path)
|
|
1194
|
+
except FileNotFoundError:
|
|
1195
|
+
# file disappeared; clear any probe info
|
|
1196
|
+
self._probe.pop(path, None)
|
|
1197
|
+
return None
|
|
1198
|
+
now = time.time()
|
|
1199
|
+
size, mtime = st.st_size, st.st_mtime
|
|
1200
|
+
|
|
1201
|
+
info = self._probe.get(path)
|
|
1202
|
+
if info is None:
|
|
1203
|
+
info = {"size": size, "mtime": mtime, "since": now, "penalty_until": 0.0}
|
|
1204
|
+
self._probe[path] = info
|
|
1205
|
+
return info
|
|
1206
|
+
|
|
1207
|
+
# If size or mtime changed, reset stability timer
|
|
1208
|
+
if size != info["size"] or mtime != info["mtime"]:
|
|
1209
|
+
info["size"] = size
|
|
1210
|
+
info["mtime"] = mtime
|
|
1211
|
+
info["since"] = now
|
|
1212
|
+
return info
|
|
1213
|
+
|
|
1214
|
+
def _can_open_for_read(self, path: str) -> bool:
|
|
1215
|
+
"""
|
|
1216
|
+
Try a tiny open+read to ensure the writer has released the handle.
|
|
1217
|
+
If we hit PermissionError / OSError, we mark a penalty and say 'not ready'.
|
|
1218
|
+
"""
|
|
1219
|
+
try:
|
|
1220
|
+
with open(path, "rb") as f:
|
|
1221
|
+
_ = f.read(1)
|
|
1222
|
+
return True
|
|
1223
|
+
except (PermissionError, OSError):
|
|
1224
|
+
# mark a penalty window so we don't hammer the file immediately
|
|
1225
|
+
info = self._probe.get(path) or self._update_probe(path)
|
|
1226
|
+
if info:
|
|
1227
|
+
info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
|
|
1228
|
+
return False
|
|
1229
|
+
|
|
1230
|
+
def _file_ready(self, path: str) -> bool:
|
|
1231
|
+
"""
|
|
1232
|
+
A file is 'ready' when:
|
|
1233
|
+
- we are not inside a penalty window,
|
|
1234
|
+
- size+mtime have been unchanged for FILE_STABLE_SECS,
|
|
1235
|
+
- and we can actually open it for reading.
|
|
1236
|
+
"""
|
|
1237
|
+
info = self._update_probe(path)
|
|
1238
|
+
if info is None:
|
|
1239
|
+
return False # missing
|
|
1240
|
+
|
|
1241
|
+
now = time.time()
|
|
1242
|
+
if now < info.get("penalty_until", 0.0):
|
|
1243
|
+
return False
|
|
1244
|
+
|
|
1245
|
+
# Require size+mtime to be unchanged for FILE_STABLE_SECS
|
|
1246
|
+
if (now - info["since"]) < self.FILE_STABLE_SECS:
|
|
1247
|
+
return False
|
|
1248
|
+
|
|
1249
|
+
# Finally confirm we can open the file (this also sets penalty if it fails)
|
|
1250
|
+
return self._can_open_for_read(path)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def check_for_new_frames(self):
|
|
1254
|
+
if not self.is_running or not self.watch_folder:
|
|
1255
|
+
return
|
|
1256
|
+
|
|
1257
|
+
# Gather candidates
|
|
1258
|
+
exts = (
|
|
1259
|
+
"*.fit", "*.fits", "*.tif", "*.tiff",
|
|
1260
|
+
"*.cr2", "*.cr3", "*.nef", "*.arw",
|
|
1261
|
+
"*.dng", "*.raf", "*.orf", "*.rw2", "*.pef", "*.xisf",
|
|
1262
|
+
"*.png", "*.jpg", "*.jpeg"
|
|
1263
|
+
)
|
|
1264
|
+
all_paths = []
|
|
1265
|
+
for ext in exts:
|
|
1266
|
+
all_paths += glob.glob(os.path.join(self.watch_folder, '**', ext), recursive=True)
|
|
1267
|
+
|
|
1268
|
+
# Only consider paths not yet processed
|
|
1269
|
+
candidates = [p for p in sorted(all_paths) if p not in self.processed_files]
|
|
1270
|
+
if not candidates:
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Show first new file name (status only)
|
|
1274
|
+
self.status_label.setText(f"➜ New/updated files: {len(candidates)}")
|
|
1275
|
+
QApplication.processEvents()
|
|
1276
|
+
|
|
1277
|
+
# Probe each candidate: only process when 'ready'
|
|
1278
|
+
processed_now = 0
|
|
1279
|
+
for path in candidates:
|
|
1280
|
+
# Skip if we recently penalized this path
|
|
1281
|
+
info = self._probe.get(path)
|
|
1282
|
+
if info and time.time() < info.get("penalty_until", 0.0):
|
|
1283
|
+
continue
|
|
1284
|
+
|
|
1285
|
+
# Check readiness: stable size/mtime and can open-for-read
|
|
1286
|
+
if not self._file_ready(path):
|
|
1287
|
+
continue # not yet ready; we'll see it again on the next tick
|
|
1288
|
+
|
|
1289
|
+
# Only *now* do we mark as processed and actually process the frame
|
|
1290
|
+
self.processed_files.add(path)
|
|
1291
|
+
base = os.path.basename(path)
|
|
1292
|
+
self.status_label.setText(f"→ Processing: {base}")
|
|
1293
|
+
QApplication.processEvents()
|
|
1294
|
+
|
|
1295
|
+
try:
|
|
1296
|
+
self.process_frame(path)
|
|
1297
|
+
processed_now += 1
|
|
1298
|
+
except Exception as e:
|
|
1299
|
+
# If anything unexpected happens, clear 'processed' so we can retry later
|
|
1300
|
+
# but add a penalty to avoid tight loops.
|
|
1301
|
+
self.processed_files.discard(path)
|
|
1302
|
+
info = self._probe.get(path) or self._update_probe(path)
|
|
1303
|
+
if info:
|
|
1304
|
+
info["penalty_until"] = time.time() + self.OPEN_RETRY_PENALTY_SECS
|
|
1305
|
+
self.status_label.setText(f"⚠ Error on {base}: {e}")
|
|
1306
|
+
QApplication.processEvents()
|
|
1307
|
+
|
|
1308
|
+
if processed_now > 0:
|
|
1309
|
+
self.status_label.setText(f"✔ Processed {processed_now} file(s)")
|
|
1310
|
+
QApplication.processEvents()
|
|
1311
|
+
|
|
1312
|
+
def process_frame(self, path):
|
|
1313
|
+
if not self._file_ready(path):
|
|
1314
|
+
# do not mark as processed here; monitor will retry after cool-down
|
|
1315
|
+
return
|
|
1316
|
+
|
|
1317
|
+
# if star-trail mode is on, bypass the normal pipeline entirely:
|
|
1318
|
+
if self.star_trail_mode:
|
|
1319
|
+
return self._process_star_trail(path)
|
|
1320
|
+
|
|
1321
|
+
# 1) Load
|
|
1322
|
+
# ─── 1) RAW‐file check ────────────────────────────────────────────
|
|
1323
|
+
lower = path.lower()
|
|
1324
|
+
raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')
|
|
1325
|
+
if lower.endswith(raw_exts):
|
|
1326
|
+
# Attempt to decode using rawpy
|
|
1327
|
+
try:
|
|
1328
|
+
with rawpy.imread(path) as raw:
|
|
1329
|
+
# Postprocess into an 8‐bit RGB array
|
|
1330
|
+
# (you could tweak postprocess params if desired)
|
|
1331
|
+
img_rgb8 = raw.postprocess(
|
|
1332
|
+
use_camera_wb=True,
|
|
1333
|
+
no_auto_bright=True,
|
|
1334
|
+
output_bps=16
|
|
1335
|
+
) # shape (H, W, 3), dtype=uint8
|
|
1336
|
+
|
|
1337
|
+
# Convert to float32 [0..1] so it matches load_image() behavior
|
|
1338
|
+
img = img_rgb8.astype(np.float32) / 65535.0
|
|
1339
|
+
|
|
1340
|
+
# Build a minimal FITS header and attempt to extract EXIF tags
|
|
1341
|
+
header = fits.Header()
|
|
1342
|
+
header["SIMPLE"] = True
|
|
1343
|
+
header["BITPIX"] = 16
|
|
1344
|
+
header["CREATOR"] = "LiveStack(RAW)"
|
|
1345
|
+
header["IMAGETYP"] = "RAW"
|
|
1346
|
+
# Default EXPTIME/ISO/DATE-OBS in case EXIF fails
|
|
1347
|
+
header["EXPTIME"] = "Unknown"
|
|
1348
|
+
header["ISO"] = "Unknown"
|
|
1349
|
+
header["DATE-OBS"] = "Unknown"
|
|
1350
|
+
|
|
1351
|
+
try:
|
|
1352
|
+
with open(path, 'rb') as f:
|
|
1353
|
+
tags = exifread.process_file(f, details=False)
|
|
1354
|
+
# EXIF: ExposureTime
|
|
1355
|
+
exp_tag = tags.get("EXIF ExposureTime") or tags.get("EXIF ShutterSpeedValue")
|
|
1356
|
+
if exp_tag:
|
|
1357
|
+
exp_str = str(exp_tag.values)
|
|
1358
|
+
if '/' in exp_str:
|
|
1359
|
+
top, bot = exp_str.split('/', 1)
|
|
1360
|
+
header["EXPTIME"] = (float(top)/float(bot), "Exposure Time (s)")
|
|
1361
|
+
else:
|
|
1362
|
+
header["EXPTIME"] = (float(exp_str), "Exposure Time (s)")
|
|
1363
|
+
# ISO
|
|
1364
|
+
iso_tag = tags.get("EXIF ISOSpeedRatings")
|
|
1365
|
+
if iso_tag:
|
|
1366
|
+
header["ISO"] = str(iso_tag.values)
|
|
1367
|
+
# Date/time original
|
|
1368
|
+
date_obs = tags.get("EXIF DateTimeOriginal")
|
|
1369
|
+
if date_obs:
|
|
1370
|
+
header["DATE-OBS"] = str(date_obs.values)
|
|
1371
|
+
except Exception:
|
|
1372
|
+
# If EXIF parsing fails, just leave defaults
|
|
1373
|
+
pass
|
|
1374
|
+
|
|
1375
|
+
bit_depth = 16
|
|
1376
|
+
is_mono = False
|
|
1377
|
+
|
|
1378
|
+
except Exception as e:
|
|
1379
|
+
# If rawpy fails, bail out early
|
|
1380
|
+
self.status_label.setText(f"⚠ Failed to decode RAW: {os.path.basename(path)}")
|
|
1381
|
+
QApplication.processEvents()
|
|
1382
|
+
return
|
|
1383
|
+
|
|
1384
|
+
else:
|
|
1385
|
+
# ─── 2) Not RAW → call your existing load_image()
|
|
1386
|
+
img, header, bit_depth, is_mono = load_image(path)
|
|
1387
|
+
if img is None:
|
|
1388
|
+
self.status_label.setText(f"⚠ Failed to load {os.path.basename(path)}")
|
|
1389
|
+
QApplication.processEvents()
|
|
1390
|
+
return
|
|
1391
|
+
|
|
1392
|
+
# ——— 2) CALIBRATION (once) ————————————————————————
|
|
1393
|
+
# ——— 2a) DETECT MONO→COLOR MODE ————————————————————
|
|
1394
|
+
mono_key = None
|
|
1395
|
+
if self.mono_color_mode and is_mono and header.get('FILTER') and 'BAYERPAT' not in header:
|
|
1396
|
+
mono_key = self._get_filter_key(header)
|
|
1397
|
+
|
|
1398
|
+
# ——— 2b) CALIBRATION (once) ————————————————————————
|
|
1399
|
+
if self.master_dark is not None:
|
|
1400
|
+
img = img.astype(np.float32) - self.master_dark
|
|
1401
|
+
# prefer per-filter flat if we’re in mono→color and have one
|
|
1402
|
+
if mono_key and mono_key in self.master_flats:
|
|
1403
|
+
img = apply_flat_division_numba(img, self.master_flats[mono_key])
|
|
1404
|
+
elif self.master_flat is not None:
|
|
1405
|
+
img = apply_flat_division_numba(img, self.master_flat)
|
|
1406
|
+
|
|
1407
|
+
# ——— 3) DEBAYER if BAYERPAT ——————————————————————
|
|
1408
|
+
if is_mono and header.get('BAYERPAT'):
|
|
1409
|
+
pat = header['BAYERPAT'][0] if isinstance(header['BAYERPAT'], tuple) else header['BAYERPAT']
|
|
1410
|
+
img = debayer_fits_fast(img, pat)
|
|
1411
|
+
is_mono = False
|
|
1412
|
+
|
|
1413
|
+
# ——— 5) PROMOTION TO 3-CHANNEL if NOT in mono-mode —————
|
|
1414
|
+
if mono_key is None and img.ndim == 2:
|
|
1415
|
+
img = np.stack([img, img, img], axis=2)
|
|
1416
|
+
|
|
1417
|
+
# ——— 6) BUILD PLANE for alignment & metrics —————————
|
|
1418
|
+
plane = img if (mono_key and img.ndim == 2) else np.mean(img, axis=2)
|
|
1419
|
+
|
|
1420
|
+
# ——— 7) ALIGN to reference_image_2d ——————————————————
|
|
1421
|
+
if hasattr(self, 'reference_image_2d'):
|
|
1422
|
+
delta = StarRegistrationWorker.compute_affine_transform_astroalign(
|
|
1423
|
+
plane, self.reference_image_2d
|
|
1424
|
+
)
|
|
1425
|
+
if delta is None:
|
|
1426
|
+
delta = IDENTITY_2x3
|
|
1427
|
+
# apply to full img (if color) and to plane
|
|
1428
|
+
if mono_key is None:
|
|
1429
|
+
img = StarRegistrationThread.apply_affine_transform_static(img, delta)
|
|
1430
|
+
plane = StarRegistrationThread.apply_affine_transform_static(
|
|
1431
|
+
plane if plane.ndim == 2 else plane[:, :, None], delta
|
|
1432
|
+
).squeeze()
|
|
1433
|
+
|
|
1434
|
+
# ——— 8) NORMALIZE —————————————————————————————
|
|
1435
|
+
if mono_key:
|
|
1436
|
+
norm_plane = stretch_mono_image(plane, target_median=0.3)
|
|
1437
|
+
norm_color = None
|
|
1438
|
+
else:
|
|
1439
|
+
norm_color = stretch_color_image(img, target_median=0.3, linked=False)
|
|
1440
|
+
norm_plane = np.mean(norm_color, axis=2)
|
|
1441
|
+
|
|
1442
|
+
# ——— 9) METRICS & SNR —————————————————————————
|
|
1443
|
+
sc, fwhm, ecc = compute_frame_star_metrics(norm_plane)
|
|
1444
|
+
# instead, use the cumulative stack (or composite) for SNR:
|
|
1445
|
+
if mono_key:
|
|
1446
|
+
# once we have any filter_stacks, build the composite;
|
|
1447
|
+
# fall back to this frame’s plane if it’s the first one
|
|
1448
|
+
if self.filter_stacks:
|
|
1449
|
+
stack_img = self._build_color_composite()
|
|
1450
|
+
else:
|
|
1451
|
+
stack_img = norm_plane
|
|
1452
|
+
else:
|
|
1453
|
+
# for color‐only, use the running‐average stack once it exists,
|
|
1454
|
+
# else fall back to this frame’s normalized color
|
|
1455
|
+
if self.current_stack is not None:
|
|
1456
|
+
stack_img = self.current_stack
|
|
1457
|
+
else:
|
|
1458
|
+
stack_img = norm_color
|
|
1459
|
+
snr_val = estimate_global_snr(stack_img)
|
|
1460
|
+
|
|
1461
|
+
# ——— 10) CULLING? ————————————————————————————
|
|
1462
|
+
flagged = (
|
|
1463
|
+
(fwhm > self.max_fwhm) or
|
|
1464
|
+
(ecc > self.max_ecc) or
|
|
1465
|
+
(sc < self.min_star_count)
|
|
1466
|
+
)
|
|
1467
|
+
if flagged:
|
|
1468
|
+
self._cull_frame(path)
|
|
1469
|
+
self.metrics_window.metrics_panel.add_point(
|
|
1470
|
+
self.frame_count + 1, fwhm, ecc, sc, snr_val, True
|
|
1471
|
+
)
|
|
1472
|
+
return
|
|
1473
|
+
|
|
1474
|
+
# ─── 11) FIRST-FRAME INITIALIZATION ──────────────────────────────
|
|
1475
|
+
if self.frame_count == 0:
|
|
1476
|
+
# set reference on the very first good frame
|
|
1477
|
+
self.reference_image_2d = norm_plane.copy()
|
|
1478
|
+
self.frame_count = 1
|
|
1479
|
+
self.frame_count_label.setText("Frames: 1")
|
|
1480
|
+
# always start in linear‐average mode
|
|
1481
|
+
if mono_key:
|
|
1482
|
+
self.mode_label.setText(f"Mode: Linear Average ({mono_key})")
|
|
1483
|
+
self.status_label.setText(f"Started {mono_key}-filter linear stack")
|
|
1484
|
+
else:
|
|
1485
|
+
self.mode_label.setText("Mode: Linear Average")
|
|
1486
|
+
self.status_label.setText("Started linear stack")
|
|
1487
|
+
QApplication.processEvents()
|
|
1488
|
+
|
|
1489
|
+
if mono_key:
|
|
1490
|
+
# start the filter stack
|
|
1491
|
+
self.filter_stacks[mono_key] = norm_plane.copy()
|
|
1492
|
+
self.filter_counts[mono_key] = 1
|
|
1493
|
+
self.filter_buffers[mono_key] = [norm_plane.copy()]
|
|
1494
|
+
else:
|
|
1495
|
+
# start the normal running stack
|
|
1496
|
+
self.current_stack = norm_color.copy()
|
|
1497
|
+
self._buffer = [norm_color.copy()]
|
|
1498
|
+
# ─── accumulate exposure ─────────────────────
|
|
1499
|
+
exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
|
|
1500
|
+
if exp_val is not None:
|
|
1501
|
+
try:
|
|
1502
|
+
secs = float(exp_val)
|
|
1503
|
+
self.total_exposure += secs
|
|
1504
|
+
hrs = int(self.total_exposure // 3600)
|
|
1505
|
+
mins = int((self.total_exposure % 3600) // 60)
|
|
1506
|
+
secs_rem = int(self.total_exposure % 60)
|
|
1507
|
+
self.exposure_label.setText(
|
|
1508
|
+
f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
|
|
1509
|
+
)
|
|
1510
|
+
except Exception:
|
|
1511
|
+
pass # Ignore exposure parsing errors
|
|
1512
|
+
QApplication.processEvents()
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
else:
|
|
1516
|
+
# ─── 12) RUNNING–AVERAGE or CLIP-σ UPDATE ────────────────────
|
|
1517
|
+
if mono_key is None:
|
|
1518
|
+
# — Color-only stacking —
|
|
1519
|
+
if self.frame_count < self.bootstrap_frames:
|
|
1520
|
+
# 12a) Linear bootstrap
|
|
1521
|
+
n = self.frame_count + 1
|
|
1522
|
+
self.current_stack = (
|
|
1523
|
+
(self.frame_count / n) * self.current_stack
|
|
1524
|
+
+ (1.0 / n) * norm_color
|
|
1525
|
+
)
|
|
1526
|
+
self._buffer.append(norm_color.copy())
|
|
1527
|
+
|
|
1528
|
+
# hit the bootstrap threshold?
|
|
1529
|
+
if n == self.bootstrap_frames:
|
|
1530
|
+
# init Welford stats
|
|
1531
|
+
buf = np.stack(self._buffer, axis=0)
|
|
1532
|
+
self._mu = np.mean(buf, axis=0)
|
|
1533
|
+
diffs = buf - self._mu[np.newaxis, ...]
|
|
1534
|
+
self._m2 = np.sum(diffs * diffs, axis=0)
|
|
1535
|
+
self._buffer = None
|
|
1536
|
+
|
|
1537
|
+
# switch to clipping mode
|
|
1538
|
+
self.mode_label.setText("Mode: μ-σ Clipping Average")
|
|
1539
|
+
self.status_label.setText("Switched to μ–σ clipping (color)")
|
|
1540
|
+
QApplication.processEvents()
|
|
1541
|
+
else:
|
|
1542
|
+
# still linear
|
|
1543
|
+
self.mode_label.setText("Mode: Linear Average")
|
|
1544
|
+
self.status_label.setText(f"Processed color frame #{n} (linear)")
|
|
1545
|
+
QApplication.processEvents()
|
|
1546
|
+
else:
|
|
1547
|
+
# 12b) μ–σ clipping
|
|
1548
|
+
sigma = np.sqrt(self._m2 / (self.frame_count - 1))
|
|
1549
|
+
mask = np.abs(norm_color - self._mu) <= (self.clip_threshold * sigma)
|
|
1550
|
+
clipped = np.where(mask, norm_color, self._mu)
|
|
1551
|
+
|
|
1552
|
+
n = self.frame_count + 1
|
|
1553
|
+
self.current_stack = (
|
|
1554
|
+
(self.frame_count / n) * self.current_stack
|
|
1555
|
+
+ (1.0 / n) * clipped
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
# Welford update
|
|
1559
|
+
delta_mu = clipped - self._mu
|
|
1560
|
+
self._mu += delta_mu / n
|
|
1561
|
+
delta2 = clipped - self._mu
|
|
1562
|
+
self._m2 += delta_mu * delta2
|
|
1563
|
+
|
|
1564
|
+
# stay in clipping mode
|
|
1565
|
+
self.mode_label.setText("Mode: μ-σ Clipping Average")
|
|
1566
|
+
self.status_label.setText(f"Processed color frame #{n} (clipped)")
|
|
1567
|
+
QApplication.processEvents()
|
|
1568
|
+
|
|
1569
|
+
# bump global frame count
|
|
1570
|
+
self.frame_count = n
|
|
1571
|
+
# ─── accumulate exposure ─────────────────────
|
|
1572
|
+
exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
|
|
1573
|
+
if exp_val is not None:
|
|
1574
|
+
try:
|
|
1575
|
+
secs = float(exp_val)
|
|
1576
|
+
self.total_exposure += secs
|
|
1577
|
+
hrs = int(self.total_exposure // 3600)
|
|
1578
|
+
mins = int((self.total_exposure % 3600) // 60)
|
|
1579
|
+
secs_rem = int(self.total_exposure % 60)
|
|
1580
|
+
self.exposure_label.setText(
|
|
1581
|
+
f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
|
|
1582
|
+
)
|
|
1583
|
+
except Exception:
|
|
1584
|
+
pass # Ignore exposure parsing errors
|
|
1585
|
+
QApplication.processEvents()
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
else:
|
|
1589
|
+
# — Mono→color (per-filter) stacking —
|
|
1590
|
+
count = self.filter_counts.get(mono_key, 0)
|
|
1591
|
+
buf = self.filter_buffers.setdefault(mono_key, [])
|
|
1592
|
+
|
|
1593
|
+
if count < self.bootstrap_frames:
|
|
1594
|
+
# 12c) Linear bootstrap per-filter
|
|
1595
|
+
new_count = count + 1
|
|
1596
|
+
if count == 0:
|
|
1597
|
+
self.filter_stacks[mono_key] = norm_plane.copy()
|
|
1598
|
+
else:
|
|
1599
|
+
self.filter_stacks[mono_key] = (
|
|
1600
|
+
(count / new_count) * self.filter_stacks[mono_key]
|
|
1601
|
+
+ (1.0 / new_count) * norm_plane
|
|
1602
|
+
)
|
|
1603
|
+
buf.append(norm_plane.copy())
|
|
1604
|
+
self.filter_counts[mono_key] = new_count
|
|
1605
|
+
|
|
1606
|
+
if new_count == self.bootstrap_frames:
|
|
1607
|
+
# init Welford
|
|
1608
|
+
stacked = np.stack(buf, axis=0)
|
|
1609
|
+
mu = np.mean(stacked, axis=0)
|
|
1610
|
+
diffs = stacked - mu[np.newaxis, ...]
|
|
1611
|
+
m2 = np.sum(diffs * diffs, axis=0)
|
|
1612
|
+
self.filter_mus[mono_key] = mu
|
|
1613
|
+
self.filter_m2s[mono_key] = m2
|
|
1614
|
+
|
|
1615
|
+
self.mode_label.setText(f"Mode: μ-σ Clipping Average ({mono_key})")
|
|
1616
|
+
self.status_label.setText(f"Switched to μ–σ clipping ({mono_key})")
|
|
1617
|
+
QApplication.processEvents()
|
|
1618
|
+
else:
|
|
1619
|
+
# still linear
|
|
1620
|
+
self.mode_label.setText(f"Mode: Linear Average ({mono_key})")
|
|
1621
|
+
self.status_label.setText(
|
|
1622
|
+
f"Processed {mono_key}-filter frame #{new_count} (linear)"
|
|
1623
|
+
)
|
|
1624
|
+
QApplication.processEvents()
|
|
1625
|
+
|
|
1626
|
+
else:
|
|
1627
|
+
# 12d) μ–σ clipping per-filter
|
|
1628
|
+
mu = self.filter_mus[mono_key]
|
|
1629
|
+
m2 = self.filter_m2s[mono_key]
|
|
1630
|
+
sigma = np.sqrt(m2 / (count - 1))
|
|
1631
|
+
mask = np.abs(norm_plane - mu) <= (self.clip_threshold * sigma)
|
|
1632
|
+
clipped = np.where(mask, norm_plane, mu)
|
|
1633
|
+
|
|
1634
|
+
new_count = count + 1
|
|
1635
|
+
self.filter_stacks[mono_key] = (
|
|
1636
|
+
(count / new_count) * self.filter_stacks[mono_key]
|
|
1637
|
+
+ (1.0 / new_count) * clipped
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
# Welford update on µ and m2
|
|
1641
|
+
delta = clipped - mu
|
|
1642
|
+
new_mu = mu + delta / new_count
|
|
1643
|
+
delta2 = clipped - new_mu
|
|
1644
|
+
new_m2 = m2 + delta * delta2
|
|
1645
|
+
self.filter_mus[mono_key] = new_mu
|
|
1646
|
+
self.filter_m2s[mono_key] = new_m2
|
|
1647
|
+
self.filter_counts[mono_key] = new_count
|
|
1648
|
+
|
|
1649
|
+
self.mode_label.setText(f"Mode: μ-σ Clipping Average ({mono_key})")
|
|
1650
|
+
self.status_label.setText(
|
|
1651
|
+
f"Processed {mono_key}-filter frame #{new_count} (clipped)"
|
|
1652
|
+
)
|
|
1653
|
+
QApplication.processEvents()
|
|
1654
|
+
|
|
1655
|
+
# bump global frame count
|
|
1656
|
+
self.frame_count += 1
|
|
1657
|
+
self.frame_count_label.setText(f"Frames: {self.frame_count}")
|
|
1658
|
+
# ─── accumulate exposure ─────────────────────
|
|
1659
|
+
exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
|
|
1660
|
+
if exp_val is not None:
|
|
1661
|
+
try:
|
|
1662
|
+
secs = float(exp_val)
|
|
1663
|
+
self.total_exposure += secs
|
|
1664
|
+
hrs = int(self.total_exposure // 3600)
|
|
1665
|
+
mins = int((self.total_exposure % 3600) // 60)
|
|
1666
|
+
secs_rem = int(self.total_exposure % 60)
|
|
1667
|
+
self.exposure_label.setText(
|
|
1668
|
+
f"Total Exp: {hrs:02d}:{mins:02d}:{secs_rem:02d}"
|
|
1669
|
+
)
|
|
1670
|
+
except Exception:
|
|
1671
|
+
pass # Ignore exposure parsing errors
|
|
1672
|
+
QApplication.processEvents()
|
|
1673
|
+
|
|
1674
|
+
# ─── 13) Update UI ─────────────────────────────────────────
|
|
1675
|
+
self.frame_count_label.setText(f"Frames: {self.frame_count}")
|
|
1676
|
+
QApplication.processEvents()
|
|
1677
|
+
|
|
1678
|
+
# ——— 13) METRICS PANEL for good frame —————————————
|
|
1679
|
+
self.metrics_window.metrics_panel.add_point(
|
|
1680
|
+
self.frame_count, fwhm, ecc, sc, snr_val, False
|
|
1681
|
+
)
|
|
1682
|
+
|
|
1683
|
+
# ——— 14) PREVIEW & STATUS LABEL —————————————————————
|
|
1684
|
+
if mono_key:
|
|
1685
|
+
preview = self._build_color_composite()
|
|
1686
|
+
self.status_label.setText(f"Stacked {mono_key}-filter frame {os.path.basename(path)}")
|
|
1687
|
+
QApplication.processEvents()
|
|
1688
|
+
else:
|
|
1689
|
+
preview = self.current_stack
|
|
1690
|
+
self.status_label.setText(f"✔ processed {os.path.basename(path)}")
|
|
1691
|
+
QApplication.processEvents()
|
|
1692
|
+
|
|
1693
|
+
self.update_preview(preview)
|
|
1694
|
+
QApplication.processEvents()
|
|
1695
|
+
|
|
1696
|
+
def _process_star_trail(self, path: str):
|
|
1697
|
+
"""
|
|
1698
|
+
Load/calibrate a single frame (RAW or FITS/TIFF), debayer if needed,
|
|
1699
|
+
normalize, then build a max‐value “star trail” in self.current_stack.
|
|
1700
|
+
"""
|
|
1701
|
+
# ─── 1) Load (RAW vs FITS) ─────────────────────────────
|
|
1702
|
+
lower = path.lower()
|
|
1703
|
+
raw_exts = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf',
|
|
1704
|
+
'.orf', '.rw2', '.pef')
|
|
1705
|
+
if lower.endswith(raw_exts):
|
|
1706
|
+
try:
|
|
1707
|
+
with rawpy.imread(path) as raw:
|
|
1708
|
+
img_rgb8 = raw.postprocess(use_camera_wb=True,
|
|
1709
|
+
no_auto_bright=True,
|
|
1710
|
+
output_bps=16)
|
|
1711
|
+
img = img_rgb8.astype(np.float32) / 65535.0
|
|
1712
|
+
header = fits.Header()
|
|
1713
|
+
header["SIMPLE"] = True
|
|
1714
|
+
header["BITPIX"] = 16
|
|
1715
|
+
header["CREATOR"] = "LiveStack(RAW)"
|
|
1716
|
+
header["IMAGETYP"] = "RAW"
|
|
1717
|
+
header["EXPTIME"] = "Unknown"
|
|
1718
|
+
# attempt EXIF, same as process_frame…
|
|
1719
|
+
try:
|
|
1720
|
+
with open(path,'rb') as f:
|
|
1721
|
+
tags = exifread.process_file(f, details=False)
|
|
1722
|
+
exp_tag = tags.get("EXIF ExposureTime") \
|
|
1723
|
+
or tags.get("EXIF ShutterSpeedValue")
|
|
1724
|
+
if exp_tag:
|
|
1725
|
+
ev = str(exp_tag.values)
|
|
1726
|
+
if '/' in ev:
|
|
1727
|
+
n,d = ev.split('/',1)
|
|
1728
|
+
header["EXPTIME"] = (float(n)/float(d),
|
|
1729
|
+
"Exposure Time (s)")
|
|
1730
|
+
else:
|
|
1731
|
+
header["EXPTIME"] = (float(ev),
|
|
1732
|
+
"Exposure Time (s)")
|
|
1733
|
+
except Exception:
|
|
1734
|
+
pass # Ignore EXIF parsing errors
|
|
1735
|
+
bit_depth = 16
|
|
1736
|
+
is_mono = False
|
|
1737
|
+
except Exception:
|
|
1738
|
+
self.status_label.setText(
|
|
1739
|
+
f"⚠ Failed to decode RAW: {os.path.basename(path)}"
|
|
1740
|
+
)
|
|
1741
|
+
QApplication.processEvents()
|
|
1742
|
+
return
|
|
1743
|
+
else:
|
|
1744
|
+
# FITS / TIFF / XISF
|
|
1745
|
+
img, header, bit_depth, is_mono = load_image(path)
|
|
1746
|
+
if img is None:
|
|
1747
|
+
self.status_label.setText(
|
|
1748
|
+
f"⚠ Failed to load {os.path.basename(path)}"
|
|
1749
|
+
)
|
|
1750
|
+
QApplication.processEvents()
|
|
1751
|
+
return
|
|
1752
|
+
|
|
1753
|
+
# ─── 2) Calibration ─────────────────────────────────────
|
|
1754
|
+
mono_key = None
|
|
1755
|
+
if (self.mono_color_mode
|
|
1756
|
+
and is_mono
|
|
1757
|
+
and header.get('FILTER')
|
|
1758
|
+
and 'BAYERPAT' not in header):
|
|
1759
|
+
mono_key = self._get_filter_key(header)
|
|
1760
|
+
|
|
1761
|
+
if self.master_dark is not None:
|
|
1762
|
+
img = img.astype(np.float32) - self.master_dark
|
|
1763
|
+
|
|
1764
|
+
if mono_key and mono_key in self.master_flats:
|
|
1765
|
+
img = apply_flat_division_numba(img,
|
|
1766
|
+
self.master_flats[mono_key])
|
|
1767
|
+
elif self.master_flat is not None:
|
|
1768
|
+
img = apply_flat_division_numba(img,
|
|
1769
|
+
self.master_flat)
|
|
1770
|
+
|
|
1771
|
+
# ─── 3) Debayer ─────────────────────────────────────────
|
|
1772
|
+
if is_mono and header.get('BAYERPAT'):
|
|
1773
|
+
pat = (header['BAYERPAT'][0]
|
|
1774
|
+
if isinstance(header['BAYERPAT'], tuple)
|
|
1775
|
+
else header['BAYERPAT'])
|
|
1776
|
+
img = debayer_fits_fast(img, pat)
|
|
1777
|
+
is_mono = False
|
|
1778
|
+
|
|
1779
|
+
# ─── 4) Force 3-channel if still mono ───────────────────
|
|
1780
|
+
if not mono_key and img.ndim == 2:
|
|
1781
|
+
img = np.stack([img, img, img], axis=2)
|
|
1782
|
+
|
|
1783
|
+
# ─── 5) Normalize ───────────────────────────────────────
|
|
1784
|
+
# for star-trail we want a visible, stretched version:
|
|
1785
|
+
if img.ndim == 2:
|
|
1786
|
+
plane = stretch_mono_image(img, target_median=0.3)
|
|
1787
|
+
norm_color = np.stack([plane]*3, axis=2)
|
|
1788
|
+
else:
|
|
1789
|
+
norm_color = stretch_color_image(img,
|
|
1790
|
+
target_median=0.3,
|
|
1791
|
+
linked=False)
|
|
1792
|
+
|
|
1793
|
+
# ─── 6) Build max-value stack ───────────────────────────
|
|
1794
|
+
if self.frame_count == 0:
|
|
1795
|
+
self.current_stack = norm_color.copy()
|
|
1796
|
+
else:
|
|
1797
|
+
# elementwise max over all frames so far
|
|
1798
|
+
self.current_stack = np.maximum(self.current_stack,
|
|
1799
|
+
norm_color)
|
|
1800
|
+
|
|
1801
|
+
# ─── 7) Update counters and labels ──────────────────────
|
|
1802
|
+
self.frame_count += 1
|
|
1803
|
+
self.frame_count_label.setText(f"Frames: {self.frame_count}")
|
|
1804
|
+
|
|
1805
|
+
exp_val = header.get("EXPOSURE", header.get("EXPTIME", None))
|
|
1806
|
+
if exp_val is not None:
|
|
1807
|
+
try:
|
|
1808
|
+
secs = float(exp_val)
|
|
1809
|
+
self.total_exposure += secs
|
|
1810
|
+
h = int(self.total_exposure // 3600)
|
|
1811
|
+
m = int((self.total_exposure % 3600)//60)
|
|
1812
|
+
s = int(self.total_exposure % 60)
|
|
1813
|
+
self.exposure_label.setText(
|
|
1814
|
+
f"Total Exp: {h:02d}:{m:02d}:{s:02d}")
|
|
1815
|
+
except Exception:
|
|
1816
|
+
pass # Ignore exposure parsing errors
|
|
1817
|
+
|
|
1818
|
+
self.status_label.setText(
|
|
1819
|
+
f"★ Star-Trail frame {self.frame_count}: "
|
|
1820
|
+
f"{os.path.basename(path)}"
|
|
1821
|
+
)
|
|
1822
|
+
self.update_preview(self.current_stack)
|
|
1823
|
+
QApplication.processEvents()
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def update_preview(self, array: np.ndarray):
|
|
1828
|
+
# 1) normalize, apply contrast/brightness
|
|
1829
|
+
arr = np.clip(array, 0.0, 1.0).astype(np.float32)
|
|
1830
|
+
pivot = 0.3
|
|
1831
|
+
arr = ((arr - pivot) * self.contrast + pivot) + self.brightness
|
|
1832
|
+
arr = np.clip(arr, 0.0, 1.0)
|
|
1833
|
+
|
|
1834
|
+
# 2) convert to uint8 and KEEP a reference on self
|
|
1835
|
+
self._last_frame_bytes = (arr * 255).astype(np.uint8)
|
|
1836
|
+
h, w = self._last_frame_bytes.shape[:2]
|
|
1837
|
+
|
|
1838
|
+
# 3) build QImage from the kept buffer
|
|
1839
|
+
if self._last_frame_bytes.ndim == 2:
|
|
1840
|
+
fmt = QImage.Format.Format_Grayscale8
|
|
1841
|
+
bytespp = w
|
|
1842
|
+
else:
|
|
1843
|
+
fmt = QImage.Format.Format_RGB888
|
|
1844
|
+
bytespp = 3 * w
|
|
1845
|
+
qimg = QImage(self._last_frame_bytes.data, w, h, bytespp, fmt)
|
|
1846
|
+
|
|
1847
|
+
# 4) update scene
|
|
1848
|
+
self.pixmap_item.setPixmap(QPixmap.fromImage(qimg))
|
|
1849
|
+
self.scene.setSceneRect(0, 0, w, h)
|
|
1850
|
+
|
|
1851
|
+
if not self._did_initial_fit:
|
|
1852
|
+
self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
|
|
1853
|
+
self._did_initial_fit = True
|
|
1854
|
+
|