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,1593 @@
|
|
|
1
|
+
# pro/cosmicclarity.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import glob
|
|
6
|
+
import time
|
|
7
|
+
import tempfile
|
|
8
|
+
import uuid
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from PyQt6.QtCore import Qt, QTimer, QSettings, QThread, pyqtSignal, QFileSystemWatcher, QEvent
|
|
12
|
+
from PyQt6.QtGui import QIcon, QAction, QImage, QPixmap
|
|
13
|
+
from PyQt6.QtWidgets import (
|
|
14
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel, QPushButton,
|
|
15
|
+
QSlider, QCheckBox, QComboBox, QMessageBox, QWidget, QRadioButton, QProgressBar,
|
|
16
|
+
QTextEdit, QFileDialog, QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog
|
|
17
|
+
)
|
|
18
|
+
from PyQt6.QtCore import QProcess
|
|
19
|
+
|
|
20
|
+
# ---- bring in your image IO helpers ----
|
|
21
|
+
# Adjust these imports to your project structure if needed.
|
|
22
|
+
from setiastro.saspro.legacy.image_manager import load_image, save_image
|
|
23
|
+
|
|
24
|
+
from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
|
|
25
|
+
|
|
26
|
+
# Import centralized preview dialog
|
|
27
|
+
from setiastro.saspro.widgets.preview_dialogs import ImagePreviewDialog
|
|
28
|
+
|
|
29
|
+
import shutil
|
|
30
|
+
import subprocess
|
|
31
|
+
|
|
32
|
+
# --- replace your _atomic_fsync_replace with this ---
|
|
33
|
+
def _atomic_fsync_replace(src_bytes_writer, final_path: str):
|
|
34
|
+
"""
|
|
35
|
+
Write to a unique temp file next to final_path, fsync it, then atomically
|
|
36
|
+
replace final_path. src_bytes_writer(tmp_path) must CREATE tmp_path.
|
|
37
|
+
"""
|
|
38
|
+
d = os.path.dirname(final_path) or "."
|
|
39
|
+
os.makedirs(d, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# Use same extension so writers (like your save_image) don't append a new one.
|
|
42
|
+
ext = os.path.splitext(final_path)[1] or ".tmp"
|
|
43
|
+
tmp_path = os.path.join(d, f".stage_{uuid.uuid4().hex}{ext}")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
# Let caller create/write the file at tmp_path
|
|
47
|
+
src_bytes_writer(tmp_path)
|
|
48
|
+
|
|
49
|
+
# Ensure written bytes are on disk
|
|
50
|
+
try:
|
|
51
|
+
with open(tmp_path, "rb", buffering=0) as f:
|
|
52
|
+
os.fsync(f.fileno())
|
|
53
|
+
except Exception:
|
|
54
|
+
# If a backend keeps the file open exclusively or doesn't support fsync,
|
|
55
|
+
# we still continue; replace() below is atomic on the same filesystem.
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
# Promote atomically
|
|
59
|
+
os.replace(tmp_path, final_path)
|
|
60
|
+
|
|
61
|
+
# POSIX-only: best-effort directory entry fsync (Windows doesn't support this)
|
|
62
|
+
if os.name != "nt":
|
|
63
|
+
try:
|
|
64
|
+
dirfd = os.open(d, os.O_DIRECTORY)
|
|
65
|
+
try: os.fsync(dirfd)
|
|
66
|
+
finally: os.close(dirfd)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
finally:
|
|
71
|
+
# Cleanup if anything left behind
|
|
72
|
+
try:
|
|
73
|
+
if os.path.exists(tmp_path):
|
|
74
|
+
os.remove(tmp_path)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def resolve_cosmic_root(parent=None) -> str:
|
|
79
|
+
s = QSettings()
|
|
80
|
+
root = s.value("paths/cosmic_clarity", "", type=str) or ""
|
|
81
|
+
if root and os.path.isdir(root):
|
|
82
|
+
return root
|
|
83
|
+
|
|
84
|
+
# Try common relatives to the app executable
|
|
85
|
+
appdir = os.path.dirname(os.path.abspath(sys.argv[0]))
|
|
86
|
+
candidates = [
|
|
87
|
+
appdir,
|
|
88
|
+
os.path.join(appdir, "cosmic_clarity"),
|
|
89
|
+
os.path.join(appdir, "CosmicClarity"),
|
|
90
|
+
os.path.dirname(appdir), # one up
|
|
91
|
+
]
|
|
92
|
+
exe_names = {
|
|
93
|
+
"win": ["SetiAstroCosmicClarity.exe", "SetiAstroCosmicClarity_denoise.exe"],
|
|
94
|
+
"mac": ["SetiAstroCosmicClaritymac", "SetiAstroCosmicClarity_denoisemac"],
|
|
95
|
+
"nix": ["SetiAstroCosmicClarity", "SetiAstroCosmicClarity_denoise"],
|
|
96
|
+
}
|
|
97
|
+
key = "win" if os.name == "nt" else ("mac" if sys.platform=="darwin" else "nix")
|
|
98
|
+
|
|
99
|
+
for c in candidates:
|
|
100
|
+
if all(os.path.exists(os.path.join(c, name)) for name in exe_names[key]):
|
|
101
|
+
# ensure in/out exist
|
|
102
|
+
os.makedirs(os.path.join(c, "input"), exist_ok=True)
|
|
103
|
+
os.makedirs(os.path.join(c, "output"), exist_ok=True)
|
|
104
|
+
s.setValue("paths/cosmic_clarity", c); s.sync()
|
|
105
|
+
return c
|
|
106
|
+
|
|
107
|
+
# Prompt user once
|
|
108
|
+
QMessageBox.information(parent, "Cosmic Clarity",
|
|
109
|
+
"Please select your Cosmic Clarity folder (the one that contains the CC executables and input/output).")
|
|
110
|
+
folder = QFileDialog.getExistingDirectory(parent, "Select Cosmic Clarity Folder", "")
|
|
111
|
+
if folder:
|
|
112
|
+
s.setValue("paths/cosmic_clarity", folder); s.sync()
|
|
113
|
+
os.makedirs(os.path.join(folder, "input"), exist_ok=True)
|
|
114
|
+
os.makedirs(os.path.join(folder, "output"), exist_ok=True)
|
|
115
|
+
return folder
|
|
116
|
+
return "" # caller should handle "not set"
|
|
117
|
+
|
|
118
|
+
def _wait_stable_file(path: str, timeout_ms: int = 4000, poll_ms: int = 50) -> bool:
|
|
119
|
+
"""Return True when path exists and its size doesn't change for 2 polls in a row."""
|
|
120
|
+
t0 = time.monotonic()
|
|
121
|
+
last = (-1, -1.0) # (size, mtime)
|
|
122
|
+
stable_count = 0
|
|
123
|
+
while (time.monotonic() - t0) * 1000 < timeout_ms:
|
|
124
|
+
try:
|
|
125
|
+
st = os.stat(path)
|
|
126
|
+
cur = (st.st_size, st.st_mtime)
|
|
127
|
+
if cur == last and st.st_size > 0:
|
|
128
|
+
stable_count += 1
|
|
129
|
+
if stable_count >= 2:
|
|
130
|
+
return True
|
|
131
|
+
else:
|
|
132
|
+
stable_count = 0
|
|
133
|
+
last = cur
|
|
134
|
+
except FileNotFoundError:
|
|
135
|
+
stable_count = 0
|
|
136
|
+
time.sleep(poll_ms / 1000.0)
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# =============================================================================
|
|
141
|
+
# Small helpers
|
|
142
|
+
# =============================================================================
|
|
143
|
+
def _satellite_exe_name() -> str:
|
|
144
|
+
base = "setiastrocosmicclarity_satellite"
|
|
145
|
+
return f"{base}.exe" if os.name == "nt" else base
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _get_cosmic_root_from_settings() -> str:
|
|
149
|
+
return resolve_cosmic_root(parent=None) # or pass self as parent
|
|
150
|
+
|
|
151
|
+
def _ensure_dirs(root: str):
|
|
152
|
+
os.makedirs(os.path.join(root, "input"), exist_ok=True)
|
|
153
|
+
os.makedirs(os.path.join(root, "output"), exist_ok=True)
|
|
154
|
+
|
|
155
|
+
_IMG_EXTS = ('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
|
|
156
|
+
'.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef',
|
|
157
|
+
'.jpg', '.jpeg')
|
|
158
|
+
|
|
159
|
+
def _purge_dir(path: str, *, prefix: str | None = None):
|
|
160
|
+
"""Delete lingering image-like files in a folder. Safe: files only."""
|
|
161
|
+
try:
|
|
162
|
+
if not os.path.isdir(path):
|
|
163
|
+
return
|
|
164
|
+
for fn in os.listdir(path):
|
|
165
|
+
fp = os.path.join(path, fn)
|
|
166
|
+
if not os.path.isfile(fp):
|
|
167
|
+
continue
|
|
168
|
+
if prefix and not fn.startswith(prefix):
|
|
169
|
+
continue
|
|
170
|
+
if os.path.splitext(fn)[1].lower() in _IMG_EXTS:
|
|
171
|
+
try: os.remove(fp)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
import logging
|
|
174
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def _purge_cc_io(root: str, *, clear_input: bool, clear_output: bool, prefix: str | None = None):
|
|
179
|
+
"""Convenience to purge CC input/output dirs."""
|
|
180
|
+
try:
|
|
181
|
+
if clear_input:
|
|
182
|
+
_purge_dir(os.path.join(root, "input"), prefix=prefix)
|
|
183
|
+
if clear_output:
|
|
184
|
+
_purge_dir(os.path.join(root, "output"), prefix=prefix)
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def _platform_exe_names(mode: str) -> str:
|
|
189
|
+
"""
|
|
190
|
+
Return executable filename for sharpen/denoise based on OS.
|
|
191
|
+
Matches SASv2 you pasted:
|
|
192
|
+
- Windows: SetiAstroCosmicClarity.exe / SetiAstroCosmicClarity_denoise.exe
|
|
193
|
+
- macOS : SetiAstroCosmicClaritymac / SetiAstroCosmicClarity_denoisemac
|
|
194
|
+
- Linux : SetiAstroCosmicClarity / SetiAstroCosmicClarity_denoise
|
|
195
|
+
"""
|
|
196
|
+
is_win = os.name == "nt"
|
|
197
|
+
is_mac = sys.platform == "darwin"
|
|
198
|
+
if mode == "sharpen":
|
|
199
|
+
return "SetiAstroCosmicClarity.exe" if is_win else ("SetiAstroCosmicClaritymac" if is_mac else "SetiAstroCosmicClarity")
|
|
200
|
+
elif mode == "denoise":
|
|
201
|
+
return "SetiAstroCosmicClarity_denoise.exe" if is_win else ("SetiAstroCosmicClarity_denoisemac" if is_mac else "SetiAstroCosmicClarity_denoise")
|
|
202
|
+
elif mode == "superres":
|
|
203
|
+
# SASv2 used lowercase for superres on Windows
|
|
204
|
+
return "setiastrocosmicclarity_superres.exe" if is_win else "setiastrocosmicclarity_superres"
|
|
205
|
+
else:
|
|
206
|
+
return ""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# =============================================================================
|
|
210
|
+
# Wait UI
|
|
211
|
+
# =============================================================================
|
|
212
|
+
class WaitDialog(QDialog):
|
|
213
|
+
cancelled = pyqtSignal()
|
|
214
|
+
def __init__(self, title="Processing…", parent=None):
|
|
215
|
+
super().__init__(parent)
|
|
216
|
+
self.setWindowTitle(title)
|
|
217
|
+
v = QVBoxLayout(self)
|
|
218
|
+
self.lbl = QLabel("Processing, please wait…")
|
|
219
|
+
self.txt = QTextEdit(); self.txt.setReadOnly(True)
|
|
220
|
+
self.pb = QProgressBar(); self.pb.setRange(0, 100)
|
|
221
|
+
btn = QPushButton("Cancel"); btn.clicked.connect(self.cancelled.emit)
|
|
222
|
+
v.addWidget(self.lbl); v.addWidget(self.txt); v.addWidget(self.pb); v.addWidget(btn)
|
|
223
|
+
def append_output(self, line: str): self.txt.append(line)
|
|
224
|
+
def set_progress(self, p: int): self.pb.setValue(int(max(0, min(100, p))))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class WaitForFileWorker(QThread):
|
|
228
|
+
fileFound = pyqtSignal(str)
|
|
229
|
+
cancelled = pyqtSignal()
|
|
230
|
+
error = pyqtSignal(str)
|
|
231
|
+
def __init__(self, glob_pat: str, timeout_sec=1800, parent=None):
|
|
232
|
+
super().__init__(parent)
|
|
233
|
+
self._glob = glob_pat
|
|
234
|
+
self._timeout = timeout_sec
|
|
235
|
+
self._running = True
|
|
236
|
+
def run(self):
|
|
237
|
+
start = time.time()
|
|
238
|
+
while self._running and (time.time() - start < self._timeout):
|
|
239
|
+
m = glob.glob(self._glob)
|
|
240
|
+
if m:
|
|
241
|
+
self.fileFound.emit(m[0]); return
|
|
242
|
+
time.sleep(1)
|
|
243
|
+
if self._running: self.error.emit("Output file not found within timeout.")
|
|
244
|
+
else: self.cancelled.emit()
|
|
245
|
+
def stop(self): self._running = False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# =============================================================================
|
|
249
|
+
# Dialog
|
|
250
|
+
# =============================================================================
|
|
251
|
+
class CosmicClarityDialogPro(QDialog):
|
|
252
|
+
"""
|
|
253
|
+
Pro port of SASv2 Cosmic Clarity panel:
|
|
254
|
+
• Modes: Sharpen, Denoise, Both, Super Resolution
|
|
255
|
+
• GPU toggle
|
|
256
|
+
• PSF, stellar/nonstellar amounts
|
|
257
|
+
• Denoise strengths/mode
|
|
258
|
+
• Super-res scale
|
|
259
|
+
• Apply target: overwrite / new view
|
|
260
|
+
Uses QSettings key: paths/cosmic_clarity
|
|
261
|
+
"""
|
|
262
|
+
def __init__(self, parent, doc, icon: QIcon | None = None, *, headless: bool=False, bypass_guard: bool=False):
|
|
263
|
+
super().__init__(parent)
|
|
264
|
+
# Hard guard unless explicitly bypassed (used by preset runner)
|
|
265
|
+
if not bypass_guard and self._headless_guard_active():
|
|
266
|
+
# avoid any flash; never show
|
|
267
|
+
try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
import logging
|
|
270
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
271
|
+
QTimer.singleShot(0, self.reject)
|
|
272
|
+
return
|
|
273
|
+
self.setWindowTitle(self.tr("Cosmic Clarity"))
|
|
274
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
275
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
276
|
+
self.setModal(False)
|
|
277
|
+
try:
|
|
278
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass # older PyQt6 versions
|
|
281
|
+
if icon:
|
|
282
|
+
try: self.setWindowIcon(icon)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
import logging
|
|
285
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
286
|
+
|
|
287
|
+
self.parent_ref = parent
|
|
288
|
+
self.doc = doc
|
|
289
|
+
self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
|
|
290
|
+
self.cosmic_root = _get_cosmic_root_from_settings()
|
|
291
|
+
|
|
292
|
+
v = QVBoxLayout(self)
|
|
293
|
+
|
|
294
|
+
# ---------------- Controls ----------------
|
|
295
|
+
grp = QGroupBox(self.tr("Parameters"))
|
|
296
|
+
grid = QGridLayout(grp)
|
|
297
|
+
|
|
298
|
+
# Mode
|
|
299
|
+
grid.addWidget(QLabel(self.tr("Mode:")), 0, 0)
|
|
300
|
+
self.cmb_mode = QComboBox()
|
|
301
|
+
self.cmb_mode.addItems(["Sharpen", "Denoise", "Both", "Super Resolution"])
|
|
302
|
+
self.cmb_mode.currentIndexChanged.connect(self._mode_changed)
|
|
303
|
+
grid.addWidget(self.cmb_mode, 0, 1, 1, 2)
|
|
304
|
+
|
|
305
|
+
# GPU
|
|
306
|
+
grid.addWidget(QLabel(self.tr("Use GPU:")), 1, 0)
|
|
307
|
+
self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems([self.tr("Yes"), self.tr("No")])
|
|
308
|
+
grid.addWidget(self.cmb_gpu, 1, 1)
|
|
309
|
+
|
|
310
|
+
# Sharpen block
|
|
311
|
+
self.lbl_sh_mode = QLabel("Sharpening Mode:")
|
|
312
|
+
self.cmb_sh_mode = QComboBox(); self.cmb_sh_mode.addItems(["Both", "Stellar Only", "Non-Stellar Only"])
|
|
313
|
+
grid.addWidget(self.lbl_sh_mode, 2, 0); grid.addWidget(self.cmb_sh_mode, 2, 1)
|
|
314
|
+
|
|
315
|
+
self.chk_sh_sep = QCheckBox("Sharpen RGB channels separately")
|
|
316
|
+
self.chk_sh_sep.setToolTip(
|
|
317
|
+
"Run the mono sharpening model independently on R, G, and B instead of a shared color model.\n"
|
|
318
|
+
"Use for difficult color data where channels need slightly different sharpening."
|
|
319
|
+
)
|
|
320
|
+
grid.addWidget(self.chk_sh_sep, 3, 0)
|
|
321
|
+
|
|
322
|
+
self.chk_auto_psf = QCheckBox("Auto Detect PSF"); self.chk_auto_psf.setChecked(True)
|
|
323
|
+
grid.addWidget(self.chk_auto_psf, 3, 1)
|
|
324
|
+
|
|
325
|
+
self.lbl_psf = QLabel("Non-Stellar PSF (1.0–8.0): 3.0")
|
|
326
|
+
self.sld_psf = QSlider(Qt.Orientation.Horizontal); self.sld_psf.setRange(10, 80); self.sld_psf.setValue(30)
|
|
327
|
+
self.sld_psf.valueChanged.connect(self._psf_label)
|
|
328
|
+
grid.addWidget(self.lbl_psf, 4, 0, 1, 2); grid.addWidget(self.sld_psf, 5, 0, 1, 3)
|
|
329
|
+
|
|
330
|
+
self.lbl_st_amt = QLabel("Stellar Amount (0–1): 0.50")
|
|
331
|
+
self.sld_st_amt = QSlider(Qt.Orientation.Horizontal); self.sld_st_amt.setRange(0, 100); self.sld_st_amt.setValue(50)
|
|
332
|
+
|
|
333
|
+
self.sld_st_amt.valueChanged.connect(self._on_st_amt)
|
|
334
|
+
grid.addWidget(self.lbl_st_amt, 6, 0, 1, 2); grid.addWidget(self.sld_st_amt, 7, 0, 1, 3)
|
|
335
|
+
|
|
336
|
+
self.lbl_nst_amt = QLabel("Non-Stellar Amount (0–1): 0.50")
|
|
337
|
+
self.sld_nst_amt = QSlider(Qt.Orientation.Horizontal); self.sld_nst_amt.setRange(0, 100); self.sld_nst_amt.setValue(50)
|
|
338
|
+
|
|
339
|
+
self.sld_nst_amt.valueChanged.connect(self._on_nst_amt)
|
|
340
|
+
grid.addWidget(self.lbl_nst_amt, 8, 0, 1, 2); grid.addWidget(self.sld_nst_amt, 9, 0, 1, 3)
|
|
341
|
+
|
|
342
|
+
# Denoise block
|
|
343
|
+
self.lbl_dn_lum = QLabel("Luminance Denoise (0–1): 0.50")
|
|
344
|
+
self.sld_dn_lum = QSlider(Qt.Orientation.Horizontal); self.sld_dn_lum.setRange(0, 100); self.sld_dn_lum.setValue(50)
|
|
345
|
+
self.sld_dn_lum.valueChanged.connect(lambda v: self.lbl_dn_lum.setText(f"Luminance Denoise (0–1): {v/100:.2f}"))
|
|
346
|
+
grid.addWidget(self.lbl_dn_lum, 10, 0, 1, 2); grid.addWidget(self.sld_dn_lum, 11, 0, 1, 3)
|
|
347
|
+
|
|
348
|
+
self.lbl_dn_col = QLabel("Color Denoise (0–1): 0.50")
|
|
349
|
+
self.sld_dn_col = QSlider(Qt.Orientation.Horizontal); self.sld_dn_col.setRange(0, 100); self.sld_dn_col.setValue(50)
|
|
350
|
+
self.sld_dn_col.valueChanged.connect(lambda v: self.lbl_dn_col.setText(f"Color Denoise (0–1): {v/100:.2f}"))
|
|
351
|
+
grid.addWidget(self.lbl_dn_col, 12, 0, 1, 2); grid.addWidget(self.sld_dn_col, 13, 0, 1, 3)
|
|
352
|
+
|
|
353
|
+
self.lbl_dn_mode = QLabel("Denoise Mode:")
|
|
354
|
+
self.cmb_dn_mode = QComboBox(); self.cmb_dn_mode.addItems(["full", "luminance"])
|
|
355
|
+
grid.addWidget(self.lbl_dn_mode, 14, 0); grid.addWidget(self.cmb_dn_mode, 14, 1)
|
|
356
|
+
|
|
357
|
+
self.chk_dn_sep = QCheckBox("Process RGB channels separately")
|
|
358
|
+
grid.addWidget(self.chk_dn_sep, 15, 1)
|
|
359
|
+
|
|
360
|
+
# Super-res
|
|
361
|
+
self.lbl_scale = QLabel("Scale Factor:")
|
|
362
|
+
self.cmb_scale = QComboBox(); self.cmb_scale.addItems(["2x", "3x", "4x"])
|
|
363
|
+
grid.addWidget(self.lbl_scale, 16, 0); grid.addWidget(self.cmb_scale, 16, 1)
|
|
364
|
+
|
|
365
|
+
# Apply target
|
|
366
|
+
grid.addWidget(QLabel("Apply to:"), 17, 0)
|
|
367
|
+
self.cmb_target = QComboBox(); self.cmb_target.addItems(["Overwrite active view", "Create new view"])
|
|
368
|
+
grid.addWidget(self.cmb_target, 17, 1, 1, 2)
|
|
369
|
+
|
|
370
|
+
v.addWidget(grp)
|
|
371
|
+
|
|
372
|
+
# Buttons
|
|
373
|
+
row = QHBoxLayout()
|
|
374
|
+
b_run = QPushButton(self.tr("Execute")); b_run.clicked.connect(self._run_main)
|
|
375
|
+
b_close = QPushButton(self.tr("Close")); b_close.clicked.connect(self.reject)
|
|
376
|
+
row.addStretch(1); row.addWidget(b_run); row.addWidget(b_close)
|
|
377
|
+
v.addLayout(row)
|
|
378
|
+
|
|
379
|
+
self._mode_changed() # set initial visibility
|
|
380
|
+
|
|
381
|
+
self._wait = None
|
|
382
|
+
self._wait_thread = None
|
|
383
|
+
self._proc = None
|
|
384
|
+
|
|
385
|
+
self._headless = bool(headless)
|
|
386
|
+
if self._headless:
|
|
387
|
+
# Don’t show the control panel; we’ll still exec() to run the event loop.
|
|
388
|
+
try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
389
|
+
except Exception as e:
|
|
390
|
+
import logging
|
|
391
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
392
|
+
self.resize(560, 540)
|
|
393
|
+
|
|
394
|
+
# ----- UI helpers -----
|
|
395
|
+
def _headless_guard_active(self) -> bool:
|
|
396
|
+
# 1) fast path: flags on the main window
|
|
397
|
+
try:
|
|
398
|
+
p = self.parent()
|
|
399
|
+
if p and (getattr(p, "_cosmicclarity_guard", False) or getattr(p, "_cosmicclarity_headless_running", False)):
|
|
400
|
+
return True
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
# 2) cross-module path: QSettings flag set by the preset runner
|
|
404
|
+
try:
|
|
405
|
+
s = QSettings()
|
|
406
|
+
v = s.value("cc/headless_in_progress", False, type=bool)
|
|
407
|
+
return bool(v)
|
|
408
|
+
except Exception:
|
|
409
|
+
# fallback if type kwarg unsupported in some Qt builds
|
|
410
|
+
try:
|
|
411
|
+
return bool(QSettings().value("cc/headless_in_progress", False))
|
|
412
|
+
except Exception:
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
# Never show if guard is active
|
|
416
|
+
def showEvent(self, e):
|
|
417
|
+
if self._headless_guard_active():
|
|
418
|
+
e.ignore()
|
|
419
|
+
QTimer.singleShot(0, self.reject)
|
|
420
|
+
return
|
|
421
|
+
return super().showEvent(e)
|
|
422
|
+
|
|
423
|
+
# Never exec if guard is active
|
|
424
|
+
def exec(self) -> int:
|
|
425
|
+
if self._headless_guard_active():
|
|
426
|
+
return 0
|
|
427
|
+
return super().exec()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _on_st_amt(self, v: int): self.lbl_st_amt.setText(f"Stellar Amount (0–1): {v/100:.2f}")
|
|
431
|
+
def _on_nst_amt(self, v: int): self.lbl_nst_amt.setText(f"Non-Stellar Amount (0–1): {v/100:.2f}")
|
|
432
|
+
|
|
433
|
+
def _psf_label(self):
|
|
434
|
+
self.lbl_psf.setText(f"Non-Stellar PSF (1.0–8.0): {self.sld_psf.value()/10:.1f}")
|
|
435
|
+
|
|
436
|
+
def _mode_changed(self):
|
|
437
|
+
idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
|
|
438
|
+
# Sharpen controls visible if Sharpen or Both
|
|
439
|
+
show_sh = idx in (0, 2)
|
|
440
|
+
for w in (self.lbl_sh_mode, self.cmb_sh_mode, self.chk_sh_sep, self.chk_auto_psf, self.lbl_psf, self.sld_psf, self.lbl_st_amt, self.sld_st_amt, self.lbl_nst_amt, self.sld_nst_amt):
|
|
441
|
+
w.setVisible(show_sh)
|
|
442
|
+
|
|
443
|
+
# Denoise controls visible if Denoise or Both
|
|
444
|
+
show_dn = idx in (1, 2)
|
|
445
|
+
for w in (self.lbl_dn_lum, self.sld_dn_lum, self.lbl_dn_col, self.sld_dn_col, self.lbl_dn_mode, self.cmb_dn_mode, self.chk_dn_sep):
|
|
446
|
+
w.setVisible(show_dn)
|
|
447
|
+
|
|
448
|
+
# Super-res controls visible if Super-Res
|
|
449
|
+
show_sr = idx == 3
|
|
450
|
+
for w in (self.lbl_scale, self.cmb_scale):
|
|
451
|
+
w.setVisible(show_sr)
|
|
452
|
+
|
|
453
|
+
# GPU hidden for superres (matches your SASv2)
|
|
454
|
+
self.cmb_gpu.setVisible(not show_sr)
|
|
455
|
+
self.parentWidget()
|
|
456
|
+
|
|
457
|
+
# ----- Validation -----
|
|
458
|
+
def _validate_root(self) -> bool:
|
|
459
|
+
if not self.cosmic_root:
|
|
460
|
+
QMessageBox.warning(self, "Cosmic Clarity", "No Cosmic Clarity folder is set. Set it in Preferences (Settings).")
|
|
461
|
+
return False
|
|
462
|
+
# basic presence check (don’t force a specific exe here, we do that later)
|
|
463
|
+
if not os.path.isdir(self.cosmic_root):
|
|
464
|
+
QMessageBox.warning(self, "Cosmic Clarity", "The Cosmic Clarity folder in Settings doesn’t exist anymore.")
|
|
465
|
+
return False
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
# ----- Execution -----
|
|
469
|
+
def _run_main(self):
|
|
470
|
+
if not self._validate_root():
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
# --- Register this run as "last action" for replay ---
|
|
474
|
+
try:
|
|
475
|
+
main = self.parent_ref or self.parent()
|
|
476
|
+
if main is not None:
|
|
477
|
+
preset = self.build_preset_from_ui()
|
|
478
|
+
payload = {
|
|
479
|
+
"cid": "cosmic_clarity",
|
|
480
|
+
"preset": preset,
|
|
481
|
+
# optional label for your UI if you use it
|
|
482
|
+
"label": f"Cosmic Clarity ({preset.get('mode', 'sharpen')})",
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
# Preferred: use the same helper you used for CLAHE / Morphology / PixelMath
|
|
486
|
+
if hasattr(main, "_set_last_headless_command"):
|
|
487
|
+
main._set_last_headless_command(payload)
|
|
488
|
+
else:
|
|
489
|
+
# Fallback: write directly if you're using a bare _last_headless_command dict
|
|
490
|
+
setattr(main, "_last_headless_command", payload)
|
|
491
|
+
if hasattr(main, "_update_replay_button"):
|
|
492
|
+
main._update_replay_button()
|
|
493
|
+
except Exception:
|
|
494
|
+
# Never let replay bookkeeping kill the effect itself
|
|
495
|
+
pass
|
|
496
|
+
|
|
497
|
+
_ensure_dirs(self.cosmic_root)
|
|
498
|
+
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=False)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# Determine queue of operations
|
|
502
|
+
mode_idx = self.cmb_mode.currentIndex()
|
|
503
|
+
if mode_idx == 3:
|
|
504
|
+
# Super-res path
|
|
505
|
+
self._run_superres(); return
|
|
506
|
+
elif mode_idx == 0:
|
|
507
|
+
ops = [("sharpen", "_sharpened")]
|
|
508
|
+
elif mode_idx == 1:
|
|
509
|
+
ops = [("denoise", "_denoised")]
|
|
510
|
+
else:
|
|
511
|
+
ops = [("sharpen", "_sharpened"), ("denoise", "_denoised")]
|
|
512
|
+
|
|
513
|
+
# Save current doc image to input
|
|
514
|
+
base = self._base_name()
|
|
515
|
+
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
516
|
+
try:
|
|
517
|
+
# Use atomic fsync
|
|
518
|
+
base = self._base_name()
|
|
519
|
+
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
520
|
+
arr = self.orig # already float32 [0..1]
|
|
521
|
+
|
|
522
|
+
def _writer(tmp_path):
|
|
523
|
+
# reuse your save_image impl to tmp
|
|
524
|
+
save_image(arr, tmp_path, "tiff", "32-bit floating point",
|
|
525
|
+
getattr(self.doc, "original_header", None),
|
|
526
|
+
getattr(self.doc, "is_mono", False))
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
_atomic_fsync_replace(_writer, in_path)
|
|
530
|
+
except Exception as e:
|
|
531
|
+
print("Atomic save failed:", repr(e))
|
|
532
|
+
raise
|
|
533
|
+
|
|
534
|
+
# ensure stable on disk before launching
|
|
535
|
+
if not _wait_stable_file(in_path):
|
|
536
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Failed to stage input TIFF (not stable on disk).")
|
|
537
|
+
return
|
|
538
|
+
except Exception as e:
|
|
539
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
# Run queue
|
|
543
|
+
self._op_queue = ops
|
|
544
|
+
self._current_input = in_path
|
|
545
|
+
self._run_next()
|
|
546
|
+
|
|
547
|
+
def _run_next(self):
|
|
548
|
+
if not self._op_queue:
|
|
549
|
+
# If we ever get here without more steps, we’re done.
|
|
550
|
+
self.accept()
|
|
551
|
+
return
|
|
552
|
+
mode, suffix = self._op_queue.pop(0)
|
|
553
|
+
exe_name = _platform_exe_names(mode)
|
|
554
|
+
exe_path = os.path.join(self.cosmic_root, exe_name)
|
|
555
|
+
if not os.path.exists(exe_path):
|
|
556
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
# Build args (SASv2 flags mirrored)
|
|
560
|
+
args = []
|
|
561
|
+
if mode == "sharpen":
|
|
562
|
+
psf = self.sld_psf.value()/10.0
|
|
563
|
+
args += [
|
|
564
|
+
"--sharpening_mode", self.cmb_sh_mode.currentText(),
|
|
565
|
+
"--stellar_amount", f"{self.sld_st_amt.value()/100:.2f}",
|
|
566
|
+
"--nonstellar_strength", f"{psf:.1f}",
|
|
567
|
+
"--nonstellar_amount", f"{self.sld_nst_amt.value()/100:.2f}"
|
|
568
|
+
]
|
|
569
|
+
# NEW: per-channel sharpen toggle
|
|
570
|
+
if self.chk_sh_sep.isChecked():
|
|
571
|
+
args.append("--sharpen_channels_separately")
|
|
572
|
+
|
|
573
|
+
if self.chk_auto_psf.isChecked():
|
|
574
|
+
args.append("--auto_detect_psf")
|
|
575
|
+
elif mode == "denoise":
|
|
576
|
+
args += ["--denoise_strength", f"{self.sld_dn_lum.value()/100:.2f}",
|
|
577
|
+
"--color_denoise_strength", f"{self.sld_dn_col.value()/100:.2f}",
|
|
578
|
+
"--denoise_mode", self.cmb_dn_mode.currentText()]
|
|
579
|
+
if self.chk_dn_sep.isChecked():
|
|
580
|
+
args.append("--separate_channels")
|
|
581
|
+
|
|
582
|
+
if self.cmb_gpu.currentText() == "No" and mode in ("sharpen","denoise"):
|
|
583
|
+
args.append("--disable_gpu")
|
|
584
|
+
|
|
585
|
+
# Run process
|
|
586
|
+
self._proc = QProcess(self)
|
|
587
|
+
self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
|
588
|
+
self._proc.setWorkingDirectory(self.cosmic_root) # <-- add this line
|
|
589
|
+
|
|
590
|
+
self._proc.readyReadStandardOutput.connect(self._read_proc_output_main)
|
|
591
|
+
from functools import partial
|
|
592
|
+
self._proc.finished.connect(partial(self._on_proc_finished, mode, suffix))
|
|
593
|
+
self._proc.setProgram(exe_path)
|
|
594
|
+
self._proc.setArguments(args)
|
|
595
|
+
self._proc.start()
|
|
596
|
+
if not self._proc.waitForStarted(3000):
|
|
597
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Failed to start process.")
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
# Wait for output file
|
|
601
|
+
base = self._base_name()
|
|
602
|
+
out_glob = os.path.join(self.cosmic_root, "output", f"{base}{suffix}.*")
|
|
603
|
+
self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
|
|
604
|
+
self._wait.cancelled.connect(self._cancel_all)
|
|
605
|
+
self._wait.show()
|
|
606
|
+
|
|
607
|
+
self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
|
|
608
|
+
self._wait_thread.fileFound.connect(lambda path, mode=mode: self._on_output_file(path, mode))
|
|
609
|
+
self._wait_thread.error.connect(self._on_wait_error)
|
|
610
|
+
self._wait_thread.cancelled.connect(self._on_wait_cancel)
|
|
611
|
+
self._wait_thread.start()
|
|
612
|
+
|
|
613
|
+
def _read_proc_output_main(self):
|
|
614
|
+
self._read_proc_output(self._proc, which="main")
|
|
615
|
+
|
|
616
|
+
def _read_proc_output(self, proc: QProcess, which="main"):
|
|
617
|
+
out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
|
618
|
+
if not self._wait: return
|
|
619
|
+
for line in out.splitlines():
|
|
620
|
+
line = line.strip()
|
|
621
|
+
if not line: continue
|
|
622
|
+
if line.startswith("Progress:"):
|
|
623
|
+
try:
|
|
624
|
+
pct = float(line.split()[1].replace("%",""))
|
|
625
|
+
self._wait.set_progress(int(pct))
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
else:
|
|
629
|
+
self._wait.append_output(line)
|
|
630
|
+
print(f"[CC] {line}")
|
|
631
|
+
|
|
632
|
+
def _on_proc_finished(self, mode, suffix, code, status):
|
|
633
|
+
if code != 0:
|
|
634
|
+
if self._wait: self._wait.append_output(f"Process exited with code {code}.")
|
|
635
|
+
# still let the file-watcher decide success/failure (some exes write before exit)
|
|
636
|
+
|
|
637
|
+
def _on_output_file(self, out_path: str, mode: str):
|
|
638
|
+
# stop waiting UI
|
|
639
|
+
if self._wait: self._wait.close(); self._wait = None
|
|
640
|
+
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
641
|
+
|
|
642
|
+
has_more = bool(self._op_queue)
|
|
643
|
+
|
|
644
|
+
# --- Optimization: Chained Execution Fast Path ---
|
|
645
|
+
# If we have more steps, skip the expensive load/display/save cycle.
|
|
646
|
+
# Just move the output file to be the input for the next step.
|
|
647
|
+
if has_more:
|
|
648
|
+
if not out_path or not os.path.exists(out_path):
|
|
649
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Output file missing during chain execution.")
|
|
650
|
+
self._op_queue.clear()
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
base = self._base_name()
|
|
654
|
+
next_in = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
655
|
+
prev_in = getattr(self, "_current_input", None)
|
|
656
|
+
|
|
657
|
+
try:
|
|
658
|
+
# Direct move/copy instead of decode+encode
|
|
659
|
+
if os.path.abspath(out_path) != os.path.abspath(next_in):
|
|
660
|
+
# Windows cannot atomic replace if target exists via os.rename usually,
|
|
661
|
+
# but shutil.move is generally robust.
|
|
662
|
+
# We remove target first to be sure.
|
|
663
|
+
if os.path.exists(next_in):
|
|
664
|
+
os.remove(next_in)
|
|
665
|
+
shutil.move(out_path, next_in)
|
|
666
|
+
|
|
667
|
+
# Ensure stability of the *new* input
|
|
668
|
+
if not _wait_stable_file(next_in):
|
|
669
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Staged input for next step is unstable.")
|
|
670
|
+
self._op_queue.clear()
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
self._current_input = next_in
|
|
674
|
+
|
|
675
|
+
# Cleanup previous input if distinct
|
|
676
|
+
if prev_in and prev_in != next_in and os.path.exists(prev_in):
|
|
677
|
+
os.remove(prev_in)
|
|
678
|
+
|
|
679
|
+
except Exception as e:
|
|
680
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to stage next step:\n{e}")
|
|
681
|
+
self._op_queue.clear()
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
# Trigger next step immediately
|
|
685
|
+
QTimer.singleShot(50, self._run_next)
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
# --- Final Step (or Single Step): Load and Display ---
|
|
689
|
+
try:
|
|
690
|
+
img, hdr, bd, mono = load_image(out_path)
|
|
691
|
+
if img is None:
|
|
692
|
+
raise RuntimeError("Unable to load output image.")
|
|
693
|
+
except Exception as e:
|
|
694
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load output:\n{e}")
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
dest = img.astype(np.float32, copy=False)
|
|
698
|
+
|
|
699
|
+
# Apply to document
|
|
700
|
+
step_title = f"Cosmic Clarity – {mode.title()}"
|
|
701
|
+
create_new = (self.cmb_target.currentIndex() == 1)
|
|
702
|
+
|
|
703
|
+
if create_new:
|
|
704
|
+
ok = self._spawn_new_doc_from_numpy(dest, step_title)
|
|
705
|
+
if not ok:
|
|
706
|
+
self._apply_to_active(dest, step_title)
|
|
707
|
+
else:
|
|
708
|
+
self._apply_to_active(dest, step_title)
|
|
709
|
+
|
|
710
|
+
# Cleanup final output
|
|
711
|
+
if out_path and os.path.exists(out_path):
|
|
712
|
+
try: os.remove(out_path)
|
|
713
|
+
except OSError: pass
|
|
714
|
+
|
|
715
|
+
# Cleanup final input
|
|
716
|
+
prev_in = getattr(self, "_current_input", None)
|
|
717
|
+
if prev_in and os.path.exists(prev_in):
|
|
718
|
+
try: os.remove(prev_in)
|
|
719
|
+
except OSError: pass
|
|
720
|
+
|
|
721
|
+
# Final purge
|
|
722
|
+
try:
|
|
723
|
+
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
724
|
+
except Exception:
|
|
725
|
+
pass
|
|
726
|
+
self.accept()
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _on_wait_error(self, msg: str):
|
|
730
|
+
if self._wait: self._wait.close(); self._wait = None
|
|
731
|
+
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
732
|
+
QMessageBox.critical(self, "Cosmic Clarity", msg)
|
|
733
|
+
|
|
734
|
+
def _on_wait_cancel(self):
|
|
735
|
+
if self._wait: self._wait.close(); self._wait = None
|
|
736
|
+
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
737
|
+
|
|
738
|
+
def _cancel_all(self):
|
|
739
|
+
try:
|
|
740
|
+
if self._proc: self._proc.kill()
|
|
741
|
+
except Exception as e:
|
|
742
|
+
import logging
|
|
743
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
744
|
+
self._on_wait_cancel()
|
|
745
|
+
|
|
746
|
+
def _base_name(self) -> str:
|
|
747
|
+
fp = getattr(self.doc, "file_path", None)
|
|
748
|
+
if isinstance(fp, str) and fp:
|
|
749
|
+
return os.path.splitext(os.path.basename(fp))[0]
|
|
750
|
+
name = getattr(self.doc, "display_name", None)
|
|
751
|
+
if callable(name):
|
|
752
|
+
try:
|
|
753
|
+
n = name() or ""
|
|
754
|
+
if n:
|
|
755
|
+
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
return "image"
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _apply_to_active(self, arr: np.ndarray, step_title: str):
|
|
762
|
+
"""Overwrite the active document image."""
|
|
763
|
+
if hasattr(self.doc, "set_image"):
|
|
764
|
+
self.doc.set_image(arr, step_name=step_title)
|
|
765
|
+
elif hasattr(self.doc, "apply_numpy"):
|
|
766
|
+
self.doc.apply_numpy(arr, step_name=step_title)
|
|
767
|
+
else:
|
|
768
|
+
self.doc.image = arr
|
|
769
|
+
|
|
770
|
+
def _spawn_new_doc_from_numpy(self, arr: np.ndarray, step_title: str) -> bool:
|
|
771
|
+
"""Create a brand-new document + view from a numpy array. Returns True on success."""
|
|
772
|
+
mw = self.parent()
|
|
773
|
+
dm = getattr(mw, "docman", None)
|
|
774
|
+
if dm is None:
|
|
775
|
+
return False
|
|
776
|
+
|
|
777
|
+
# build a reasonable title and metadata
|
|
778
|
+
base_name = getattr(self.doc, "display_name", None)
|
|
779
|
+
base = base_name() if callable(base_name) else (base_name or "Image")
|
|
780
|
+
title = f"{base} [{step_title}]"
|
|
781
|
+
|
|
782
|
+
meta = {
|
|
783
|
+
"bit_depth": "32-bit floating point",
|
|
784
|
+
"is_mono": (arr.ndim == 2) or (arr.ndim == 3 and arr.shape[2] == 1),
|
|
785
|
+
"source": "Cosmic Clarity",
|
|
786
|
+
"original_header": getattr(self.doc, "original_header", None),
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
try:
|
|
790
|
+
new_doc = dm.open_array(arr.astype(np.float32, copy=False), metadata=meta, title=title)
|
|
791
|
+
if hasattr(mw, "_spawn_subwindow_for"): # same hook used in ABE
|
|
792
|
+
mw._spawn_subwindow_for(new_doc)
|
|
793
|
+
return True
|
|
794
|
+
except Exception as e:
|
|
795
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to create new view:\n{e}")
|
|
796
|
+
return False
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# ----- Super-resolution -----
|
|
800
|
+
def _run_superres(self):
|
|
801
|
+
exe_name = _platform_exe_names("superres")
|
|
802
|
+
exe_path = os.path.join(self.cosmic_root, exe_name)
|
|
803
|
+
if not os.path.exists(exe_path):
|
|
804
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Super Resolution executable not found:\n{exe_path}")
|
|
805
|
+
return
|
|
806
|
+
|
|
807
|
+
_ensure_dirs(self.cosmic_root)
|
|
808
|
+
# 🔸 purge output too so any file that appears is from THIS run
|
|
809
|
+
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
810
|
+
|
|
811
|
+
base = self._base_name()
|
|
812
|
+
in_path = os.path.join(self.cosmic_root, "input", f"{base}.tif")
|
|
813
|
+
try:
|
|
814
|
+
save_image(self.orig, in_path, "tiff", "32-bit floating point",
|
|
815
|
+
getattr(self.doc, "original_header", None),
|
|
816
|
+
getattr(self.doc, "is_mono", False))
|
|
817
|
+
except Exception as e:
|
|
818
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to save input TIFF:\n{e}")
|
|
819
|
+
return
|
|
820
|
+
self._current_input = in_path
|
|
821
|
+
|
|
822
|
+
scale = int(self.cmb_scale.currentText().replace("x", ""))
|
|
823
|
+
# keep args as-is if your superres build expects explicit paths
|
|
824
|
+
args = [
|
|
825
|
+
"--input", in_path,
|
|
826
|
+
"--output_dir", os.path.join(self.cosmic_root, "output"),
|
|
827
|
+
"--scale", str(scale),
|
|
828
|
+
"--model_dir", self.cosmic_root
|
|
829
|
+
]
|
|
830
|
+
|
|
831
|
+
self._proc = QProcess(self)
|
|
832
|
+
self._proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
|
833
|
+
self._proc.readyReadStandardOutput.connect(self._read_superres_output_main)
|
|
834
|
+
# finished handler not required; the file watcher drives success
|
|
835
|
+
self._proc.setProgram(exe_path)
|
|
836
|
+
self._proc.setArguments(args)
|
|
837
|
+
self._proc.start()
|
|
838
|
+
if not self._proc.waitForStarted(3000):
|
|
839
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Failed to start Super Resolution process.")
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
self._wait = WaitDialog("Cosmic Clarity – Super Resolution", self)
|
|
843
|
+
self._wait.cancelled.connect(self._cancel_all)
|
|
844
|
+
self._wait.show()
|
|
845
|
+
|
|
846
|
+
# 🔸 Watch broadly; we purged output so the first file is from this run.
|
|
847
|
+
# We'll still re-pick the exact file in the slot for safety.
|
|
848
|
+
self._sr_base = base
|
|
849
|
+
self._sr_scale = scale
|
|
850
|
+
out_glob = os.path.join(self.cosmic_root, "output", "*.*")
|
|
851
|
+
|
|
852
|
+
self._wait_thread = WaitForFileWorker(out_glob, timeout_sec=1800, parent=self)
|
|
853
|
+
self._wait_thread.fileFound.connect(self._on_superres_file) # path arg is ignored; we reselect
|
|
854
|
+
self._wait_thread.error.connect(self._on_wait_error)
|
|
855
|
+
self._wait_thread.cancelled.connect(self._on_wait_cancel)
|
|
856
|
+
self._wait_thread.start()
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def apply_preset(self, p: dict):
|
|
860
|
+
# Mode
|
|
861
|
+
mode = str(p.get("mode","sharpen")).lower()
|
|
862
|
+
self.cmb_mode.setCurrentIndex({"sharpen":0,"denoise":1,"both":2,"superres":3}.get(mode,0))
|
|
863
|
+
# GPU
|
|
864
|
+
self.cmb_gpu.setCurrentIndex(0 if p.get("gpu", True) else 1)
|
|
865
|
+
# Target
|
|
866
|
+
self.cmb_target.setCurrentIndex(1 if p.get("create_new_view", False) else 0)
|
|
867
|
+
# Sharpen
|
|
868
|
+
self.cmb_sh_mode.setCurrentText(p.get("sharpening_mode","Both"))
|
|
869
|
+
self.chk_auto_psf.setChecked(bool(p.get("auto_psf", True)))
|
|
870
|
+
self.sld_psf.setValue(int(max(10, min(80, round(float(p.get("nonstellar_psf",3.0))*10)))))
|
|
871
|
+
self.sld_st_amt.setValue(int(max(0, min(100, round(float(p.get("stellar_amount",0.5))*100)))))
|
|
872
|
+
self.sld_nst_amt.setValue(int(max(0, min(100, round(float(p.get("nonstellar_amount",0.5))*100)))))
|
|
873
|
+
# NEW: allow presets to opt into per-channel sharpen (still defaults off without a preset)
|
|
874
|
+
self.chk_sh_sep.setChecked(bool(p.get("sharpen_channels_separately", False)))
|
|
875
|
+
|
|
876
|
+
# Denoise
|
|
877
|
+
self.sld_dn_lum.setValue(int(max(0, min(100, round(float(p.get("denoise_luma",0.5))*100)))))
|
|
878
|
+
self.sld_dn_col.setValue(int(max(0, min(100, round(float(p.get("denoise_color",0.5))*100)))))
|
|
879
|
+
self.cmb_dn_mode.setCurrentText(str(p.get("denoise_mode","full")))
|
|
880
|
+
self.chk_dn_sep.setChecked(bool(p.get("separate_channels", False)))
|
|
881
|
+
# Super-Res
|
|
882
|
+
self.cmb_scale.setCurrentText(str(int(p.get("scale",2))))
|
|
883
|
+
|
|
884
|
+
def build_preset_from_ui(self) -> dict:
|
|
885
|
+
"""Snapshot current UI state into a preset dict usable by headless runner / replay."""
|
|
886
|
+
idx = self.cmb_mode.currentIndex() # 0 Sharpen, 1 Denoise, 2 Both, 3 Super-Res
|
|
887
|
+
mode = {0: "sharpen", 1: "denoise", 2: "both", 3: "superres"}.get(idx, "sharpen")
|
|
888
|
+
|
|
889
|
+
preset: dict = {
|
|
890
|
+
"mode": mode,
|
|
891
|
+
"gpu": (self.cmb_gpu.currentIndex() == 0),
|
|
892
|
+
"create_new_view": (self.cmb_target.currentIndex() == 1),
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
# Sharpen / Both block
|
|
896
|
+
if mode in ("sharpen", "both"):
|
|
897
|
+
preset.update({
|
|
898
|
+
"sharpening_mode": self.cmb_sh_mode.currentText(),
|
|
899
|
+
"auto_psf": self.chk_auto_psf.isChecked(),
|
|
900
|
+
"nonstellar_psf": self.sld_psf.value() / 10.0, # slider 10–80 → 1.0–8.0
|
|
901
|
+
"stellar_amount": self.sld_st_amt.value() / 100.0, # 0–100 → 0–1
|
|
902
|
+
"nonstellar_amount": self.sld_nst_amt.value() / 100.0, # 0–100 → 0–1
|
|
903
|
+
"sharpen_channels_separately": self.chk_sh_sep.isChecked(),
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
# Denoise / Both block
|
|
907
|
+
if mode in ("denoise", "both"):
|
|
908
|
+
preset.update({
|
|
909
|
+
"denoise_luma": self.sld_dn_lum.value() / 100.0,
|
|
910
|
+
"denoise_color": self.sld_dn_col.value() / 100.0,
|
|
911
|
+
"denoise_mode": self.cmb_dn_mode.currentText(),
|
|
912
|
+
"separate_channels": self.chk_dn_sep.isChecked(),
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
# Super-res
|
|
916
|
+
if mode == "superres":
|
|
917
|
+
try:
|
|
918
|
+
scale_txt = self.cmb_scale.currentText()
|
|
919
|
+
# can be "2x" in the main dialog or just "2" in the preset dialog
|
|
920
|
+
scale_txt = scale_txt.replace("x", "")
|
|
921
|
+
preset["scale"] = int(scale_txt)
|
|
922
|
+
except Exception:
|
|
923
|
+
preset["scale"] = 2
|
|
924
|
+
|
|
925
|
+
return preset
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _read_superres_output_main(self):
|
|
930
|
+
self._read_superres_output(self._proc)
|
|
931
|
+
|
|
932
|
+
def _read_superres_output(self, proc: QProcess):
|
|
933
|
+
out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
|
934
|
+
if not self._wait: return
|
|
935
|
+
for line in out.splitlines():
|
|
936
|
+
if line.startswith("PROGRESS:") or line.startswith("Progress:"):
|
|
937
|
+
try:
|
|
938
|
+
tail = line.split(":",1)[1] if ":" in line else line.split()[1]
|
|
939
|
+
pct = int(float(tail.strip().replace("%","")))
|
|
940
|
+
self._wait.set_progress(pct)
|
|
941
|
+
except Exception:
|
|
942
|
+
pass
|
|
943
|
+
else:
|
|
944
|
+
self._wait.append_output(line)
|
|
945
|
+
|
|
946
|
+
def _pick_superres_output(self, base: str, scale: int) -> str | None:
|
|
947
|
+
"""
|
|
948
|
+
Find the most plausible super-res output file. We try several common
|
|
949
|
+
name patterns, then fall back to the newest/largest file in the output dir.
|
|
950
|
+
"""
|
|
951
|
+
out_dir = os.path.join(self.cosmic_root, "output")
|
|
952
|
+
|
|
953
|
+
def _best(paths: list[str]) -> str | None:
|
|
954
|
+
if not paths:
|
|
955
|
+
return None
|
|
956
|
+
# prefer bigger file; tie-break by newest mtime
|
|
957
|
+
paths.sort(key=lambda p: (os.path.getsize(p), os.path.getmtime(p)), reverse=True)
|
|
958
|
+
return paths[0]
|
|
959
|
+
|
|
960
|
+
# common patterns used by different builds
|
|
961
|
+
patterns = [
|
|
962
|
+
f"{base}_upscaled{scale}.*",
|
|
963
|
+
f"{base}_upscaled*.*",
|
|
964
|
+
f"{base}*upscal*.*",
|
|
965
|
+
f"{base}*superres*.*",
|
|
966
|
+
]
|
|
967
|
+
for pat in patterns:
|
|
968
|
+
hit = _best(glob.glob(os.path.join(out_dir, pat)))
|
|
969
|
+
if hit:
|
|
970
|
+
return hit
|
|
971
|
+
|
|
972
|
+
# fallback: anything in output (we purge it first, so whatever appears is ours)
|
|
973
|
+
return _best(glob.glob(os.path.join(out_dir, "*.*")))
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _on_superres_file(self, _first_path_from_watcher: str):
|
|
977
|
+
# stop waiting UI
|
|
978
|
+
if self._wait: self._wait.close(); self._wait = None
|
|
979
|
+
if self._wait_thread: self._wait_thread.stop(); self._wait_thread = None
|
|
980
|
+
|
|
981
|
+
# pick the actual output (robust to naming)
|
|
982
|
+
base = getattr(self, "_sr_base", self._base_name())
|
|
983
|
+
scale = int(getattr(self, "_sr_scale", int(self.cmb_scale.currentText().replace("x",""))))
|
|
984
|
+
out_path = self._pick_superres_output(base, scale)
|
|
985
|
+
if not out_path or not os.path.exists(out_path):
|
|
986
|
+
QMessageBox.critical(self, "Cosmic Clarity", "Super Resolution output file not found.")
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
img, hdr, bd, mono = load_image(out_path)
|
|
991
|
+
if img is None:
|
|
992
|
+
raise RuntimeError("Unable to load output image.")
|
|
993
|
+
except Exception as e:
|
|
994
|
+
QMessageBox.critical(self, "Cosmic Clarity", f"Failed to load Super Resolution output:\n{e}")
|
|
995
|
+
return
|
|
996
|
+
|
|
997
|
+
dest = img.astype(np.float32, copy=False)
|
|
998
|
+
step_title = "Cosmic Clarity – Super Resolution"
|
|
999
|
+
create_new = (self.cmb_target.currentIndex() == 1)
|
|
1000
|
+
|
|
1001
|
+
if create_new:
|
|
1002
|
+
ok = self._spawn_new_doc_from_numpy(dest, step_title)
|
|
1003
|
+
if not ok:
|
|
1004
|
+
self._apply_to_active(dest, step_title)
|
|
1005
|
+
else:
|
|
1006
|
+
self._apply_to_active(dest, step_title)
|
|
1007
|
+
|
|
1008
|
+
# cleanup mirrors sharpen/denoise
|
|
1009
|
+
try:
|
|
1010
|
+
if getattr(self, "_current_input", None) and os.path.exists(self._current_input):
|
|
1011
|
+
os.remove(self._current_input)
|
|
1012
|
+
if os.path.exists(out_path):
|
|
1013
|
+
os.remove(out_path)
|
|
1014
|
+
_purge_cc_io(self.cosmic_root, clear_input=True, clear_output=True)
|
|
1015
|
+
except Exception:
|
|
1016
|
+
pass
|
|
1017
|
+
|
|
1018
|
+
self.accept()
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# =============================================================================
|
|
1023
|
+
# Satellite removal
|
|
1024
|
+
# =============================================================================
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
class CosmicClaritySatelliteDialogPro(QDialog):
|
|
1028
|
+
"""
|
|
1029
|
+
Pro dialog that mirrors SASv2 Cosmic Clarity Satellite tab:
|
|
1030
|
+
• Select input/output folders, live monitor, or batch process
|
|
1031
|
+
• GPU toggle, mode (full/luminance), clip trail, sensitivity, skip-save
|
|
1032
|
+
• Tree views for input/output with preview (autostretch + zoom)
|
|
1033
|
+
Uses QSettings key: paths/cosmic_clarity
|
|
1034
|
+
"""
|
|
1035
|
+
def __init__(self, parent, doc=None, icon: QIcon | None = None):
|
|
1036
|
+
super().__init__(parent)
|
|
1037
|
+
self.setWindowTitle("Cosmic Clarity – Satellite Removal")
|
|
1038
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
1039
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
1040
|
+
self.setModal(False)
|
|
1041
|
+
if icon:
|
|
1042
|
+
try: self.setWindowIcon(icon)
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
import logging
|
|
1045
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1046
|
+
|
|
1047
|
+
self.settings = QSettings()
|
|
1048
|
+
self.cosmic_clarity_folder = self.settings.value("paths/cosmic_clarity", "", type=str) or ""
|
|
1049
|
+
self.input_folder = ""
|
|
1050
|
+
self.output_folder = ""
|
|
1051
|
+
self.sensitivity = 0.10 # 0.01–0.50
|
|
1052
|
+
self.doc = doc
|
|
1053
|
+
|
|
1054
|
+
self.file_watcher = QFileSystemWatcher()
|
|
1055
|
+
self.file_watcher.directoryChanged.connect(self._on_folder_changed)
|
|
1056
|
+
|
|
1057
|
+
self._sat_thread = None
|
|
1058
|
+
self._wait = None
|
|
1059
|
+
|
|
1060
|
+
self._build_ui()
|
|
1061
|
+
|
|
1062
|
+
# ---------- UI ----------
|
|
1063
|
+
def _build_ui(self):
|
|
1064
|
+
main = QHBoxLayout(self)
|
|
1065
|
+
|
|
1066
|
+
# Left controls
|
|
1067
|
+
left = QVBoxLayout()
|
|
1068
|
+
|
|
1069
|
+
# Input/Output folder chooser row
|
|
1070
|
+
row_io = QHBoxLayout()
|
|
1071
|
+
self.btn_in = QPushButton("Select Input Folder"); self.btn_in.clicked.connect(self._choose_input)
|
|
1072
|
+
self.btn_out = QPushButton("Select Output Folder"); self.btn_out.clicked.connect(self._choose_output)
|
|
1073
|
+
row_io.addWidget(self.btn_in); row_io.addWidget(self.btn_out)
|
|
1074
|
+
left.addLayout(row_io)
|
|
1075
|
+
|
|
1076
|
+
# GPU
|
|
1077
|
+
left.addWidget(QLabel("Use GPU Acceleration:"))
|
|
1078
|
+
self.cmb_gpu = QComboBox(); self.cmb_gpu.addItems(["Yes", "No"])
|
|
1079
|
+
left.addWidget(self.cmb_gpu)
|
|
1080
|
+
|
|
1081
|
+
# Mode
|
|
1082
|
+
left.addWidget(QLabel("Satellite Removal Mode:"))
|
|
1083
|
+
self.cmb_mode = QComboBox(); self.cmb_mode.addItems(["Full", "Luminance"])
|
|
1084
|
+
left.addWidget(self.cmb_mode)
|
|
1085
|
+
|
|
1086
|
+
# Clip trail
|
|
1087
|
+
self.chk_clip = QCheckBox("Clip Satellite Trail to 0.000"); self.chk_clip.setChecked(True)
|
|
1088
|
+
left.addWidget(self.chk_clip)
|
|
1089
|
+
|
|
1090
|
+
# Sensitivity slider
|
|
1091
|
+
row_sens = QHBoxLayout()
|
|
1092
|
+
row_sens.addWidget(QLabel("Clipping Sensitivity (Lower = more aggressive):"))
|
|
1093
|
+
self.sld_sens = QSlider(Qt.Orientation.Horizontal)
|
|
1094
|
+
self.sld_sens.setRange(1, 50) # 0.01–0.50
|
|
1095
|
+
self.sld_sens.setValue(int(self.sensitivity * 100))
|
|
1096
|
+
self.sld_sens.setTickInterval(1)
|
|
1097
|
+
self.sld_sens.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
1098
|
+
self.sld_sens.valueChanged.connect(self._on_sens_change)
|
|
1099
|
+
row_sens.addWidget(self.sld_sens)
|
|
1100
|
+
self.lbl_sens_val = QLabel(f"{self.sensitivity:.2f}")
|
|
1101
|
+
row_sens.addWidget(self.lbl_sens_val)
|
|
1102
|
+
left.addLayout(row_sens)
|
|
1103
|
+
|
|
1104
|
+
# Skip save if no trail
|
|
1105
|
+
self.chk_skip = QCheckBox("Skip Save if No Satellite Trail Detected")
|
|
1106
|
+
self.chk_skip.setChecked(False)
|
|
1107
|
+
left.addWidget(self.chk_skip)
|
|
1108
|
+
|
|
1109
|
+
# Process row: single image / batch
|
|
1110
|
+
row_proc = QHBoxLayout()
|
|
1111
|
+
self.btn_single = QPushButton("Process Single Image"); self.btn_single.clicked.connect(self._process_single_image)
|
|
1112
|
+
self.btn_batch = QPushButton("Batch Process Input Folder"); self.btn_batch.clicked.connect(self._batch_process)
|
|
1113
|
+
row_proc.addWidget(self.btn_single); row_proc.addWidget(self.btn_batch)
|
|
1114
|
+
left.addLayout(row_proc)
|
|
1115
|
+
|
|
1116
|
+
# Live monitor
|
|
1117
|
+
self.btn_monitor = QPushButton("Live Monitor Input Folder"); self.btn_monitor.clicked.connect(self._live_monitor)
|
|
1118
|
+
left.addWidget(self.btn_monitor)
|
|
1119
|
+
|
|
1120
|
+
# Folder display + chooser for Cosmic Clarity root
|
|
1121
|
+
self.lbl_root = QLabel(f"Folder: {self.cosmic_clarity_folder or 'Not set'}")
|
|
1122
|
+
left.addWidget(self.lbl_root)
|
|
1123
|
+
self.btn_pick_root = QPushButton("Choose Cosmic Clarity Folder…"); self.btn_pick_root.clicked.connect(self._choose_root)
|
|
1124
|
+
left.addWidget(self.btn_pick_root)
|
|
1125
|
+
|
|
1126
|
+
left.addStretch(1)
|
|
1127
|
+
|
|
1128
|
+
# Right: trees
|
|
1129
|
+
right = QVBoxLayout()
|
|
1130
|
+
right.addWidget(QLabel("Input Folder Files:"))
|
|
1131
|
+
self.tree_in = QTreeWidget(); self.tree_in.setHeaderLabels(["Filename"])
|
|
1132
|
+
self.tree_in.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_in, is_input=True))
|
|
1133
|
+
self.tree_in.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
1134
|
+
self.tree_in.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_in, pos, is_input=True))
|
|
1135
|
+
right.addWidget(self.tree_in)
|
|
1136
|
+
|
|
1137
|
+
right.addWidget(QLabel("Output Folder Files:"))
|
|
1138
|
+
self.tree_out = QTreeWidget(); self.tree_out.setHeaderLabels(["Filename"])
|
|
1139
|
+
self.tree_out.itemDoubleClicked.connect(lambda *_: self._preview_from_tree(self.tree_out, is_input=False))
|
|
1140
|
+
self.tree_out.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
1141
|
+
self.tree_out.customContextMenuRequested.connect(lambda pos: self._context_menu(self.tree_out, pos, is_input=False))
|
|
1142
|
+
right.addWidget(self.tree_out)
|
|
1143
|
+
|
|
1144
|
+
main.addLayout(left, 2)
|
|
1145
|
+
main.addLayout(right, 1)
|
|
1146
|
+
|
|
1147
|
+
self.resize(900, 600)
|
|
1148
|
+
|
|
1149
|
+
# ---------- Settings / root ----------
|
|
1150
|
+
def _choose_root(self):
|
|
1151
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Cosmic Clarity Folder", self.cosmic_clarity_folder or "")
|
|
1152
|
+
if not folder: return
|
|
1153
|
+
self.cosmic_clarity_folder = folder
|
|
1154
|
+
self.settings.setValue("paths/cosmic_clarity", folder)
|
|
1155
|
+
self.lbl_root.setText(f"Folder: {folder}")
|
|
1156
|
+
|
|
1157
|
+
# ---------- IO folders ----------
|
|
1158
|
+
def _choose_input(self):
|
|
1159
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Input Folder", self.input_folder or "")
|
|
1160
|
+
if not folder: return
|
|
1161
|
+
self.input_folder = folder
|
|
1162
|
+
self.btn_in.setText(f"Input: {os.path.basename(folder)}")
|
|
1163
|
+
self._watch(folder)
|
|
1164
|
+
self._refresh_tree(self.tree_in, folder)
|
|
1165
|
+
|
|
1166
|
+
def _choose_output(self):
|
|
1167
|
+
folder = QFileDialog.getExistingDirectory(self, "Select Output Folder", self.output_folder or "")
|
|
1168
|
+
if not folder: return
|
|
1169
|
+
self.output_folder = folder
|
|
1170
|
+
self.btn_out.setText(f"Output: {os.path.basename(folder)}")
|
|
1171
|
+
self._watch(folder)
|
|
1172
|
+
self._refresh_tree(self.tree_out, folder)
|
|
1173
|
+
|
|
1174
|
+
def _watch(self, folder):
|
|
1175
|
+
try:
|
|
1176
|
+
if folder and folder not in self.file_watcher.directories():
|
|
1177
|
+
self.file_watcher.addPath(folder)
|
|
1178
|
+
except Exception:
|
|
1179
|
+
pass
|
|
1180
|
+
|
|
1181
|
+
def _on_folder_changed(self, path):
|
|
1182
|
+
if path == self.input_folder:
|
|
1183
|
+
self._refresh_tree(self.tree_in, self.input_folder)
|
|
1184
|
+
elif path == self.output_folder:
|
|
1185
|
+
self._refresh_tree(self.tree_out, self.output_folder)
|
|
1186
|
+
|
|
1187
|
+
def _refresh_tree(self, tree: QTreeWidget, folder: str):
|
|
1188
|
+
tree.clear()
|
|
1189
|
+
if not folder or not os.path.isdir(folder): return
|
|
1190
|
+
for fn in sorted(os.listdir(folder)):
|
|
1191
|
+
if fn.lower().endswith(('.png', '.tif', '.tiff', '.fit', '.fits', '.xisf',
|
|
1192
|
+
'.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef', '.jpg', '.jpeg')):
|
|
1193
|
+
QTreeWidgetItem(tree, [fn])
|
|
1194
|
+
|
|
1195
|
+
# ---------- Sensitivity ----------
|
|
1196
|
+
def _on_sens_change(self, v: int):
|
|
1197
|
+
self.sensitivity = v / 100.0
|
|
1198
|
+
self.lbl_sens_val.setText(f"{self.sensitivity:.2f}")
|
|
1199
|
+
|
|
1200
|
+
# ---------- Context menu ----------
|
|
1201
|
+
def _context_menu(self, tree: QTreeWidget, pos, is_input: bool):
|
|
1202
|
+
item = tree.itemAt(pos)
|
|
1203
|
+
if not item: return
|
|
1204
|
+
menu = QMenu(self)
|
|
1205
|
+
act_del = QAction("Delete File", self)
|
|
1206
|
+
act_ren = QAction("Rename File", self)
|
|
1207
|
+
act_del.triggered.connect(lambda: self._delete_file(tree, is_input))
|
|
1208
|
+
act_ren.triggered.connect(lambda: self._rename_file(tree, is_input))
|
|
1209
|
+
menu.addAction(act_del); menu.addAction(act_ren)
|
|
1210
|
+
menu.exec(tree.viewport().mapToGlobal(pos))
|
|
1211
|
+
|
|
1212
|
+
def _folder_of(self, is_input: bool) -> str:
|
|
1213
|
+
return self.input_folder if is_input else self.output_folder
|
|
1214
|
+
|
|
1215
|
+
def _delete_file(self, tree: QTreeWidget, is_input: bool):
|
|
1216
|
+
item = tree.currentItem()
|
|
1217
|
+
if not item: return
|
|
1218
|
+
folder = self._folder_of(is_input)
|
|
1219
|
+
fp = os.path.join(folder, item.text(0))
|
|
1220
|
+
if not os.path.exists(fp): return
|
|
1221
|
+
if QMessageBox.question(self, "Confirm Delete", f"Delete {item.text(0)}?",
|
|
1222
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1223
|
+
QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
|
|
1224
|
+
os.remove(fp)
|
|
1225
|
+
self._refresh_tree(tree, folder)
|
|
1226
|
+
|
|
1227
|
+
def _rename_file(self, tree: QTreeWidget, is_input: bool):
|
|
1228
|
+
item = tree.currentItem()
|
|
1229
|
+
if not item: return
|
|
1230
|
+
folder = self._folder_of(is_input)
|
|
1231
|
+
fp = os.path.join(folder, item.text(0))
|
|
1232
|
+
new, ok = QInputDialog.getText(self, "Rename File", "Enter new name:", text=item.text(0))
|
|
1233
|
+
if ok and new:
|
|
1234
|
+
np = os.path.join(folder, new)
|
|
1235
|
+
os.rename(fp, np)
|
|
1236
|
+
self._refresh_tree(tree, folder)
|
|
1237
|
+
|
|
1238
|
+
# ---------- Preview ----------
|
|
1239
|
+
def _preview_from_tree(self, tree: QTreeWidget, is_input: bool):
|
|
1240
|
+
item = tree.currentItem()
|
|
1241
|
+
if not item: return
|
|
1242
|
+
folder = self._folder_of(is_input)
|
|
1243
|
+
fp = os.path.join(folder, item.text(0))
|
|
1244
|
+
if not os.path.isfile(fp): return
|
|
1245
|
+
try:
|
|
1246
|
+
img, _, _, is_mono = load_image(fp)
|
|
1247
|
+
if img is None:
|
|
1248
|
+
QMessageBox.critical(self, "Error", "Failed to load image for preview.")
|
|
1249
|
+
return
|
|
1250
|
+
dlg = ImagePreviewDialog(img, is_mono=is_mono, parent=self)
|
|
1251
|
+
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
|
1252
|
+
dlg.show()
|
|
1253
|
+
except Exception as e:
|
|
1254
|
+
QMessageBox.critical(self, "Error", f"Failed to preview image:\n{e}")
|
|
1255
|
+
|
|
1256
|
+
# ---------- Single image processing ----------
|
|
1257
|
+
def _process_single_image(self):
|
|
1258
|
+
# Gather possible open views
|
|
1259
|
+
views = self._collect_open_views()
|
|
1260
|
+
|
|
1261
|
+
# Decide source: view or file
|
|
1262
|
+
use_view = False
|
|
1263
|
+
if views:
|
|
1264
|
+
mb = QMessageBox(self)
|
|
1265
|
+
mb.setWindowTitle("Process Single Image")
|
|
1266
|
+
mb.setText("Choose the source to process:")
|
|
1267
|
+
btn_view = mb.addButton("Open View", QMessageBox.ButtonRole.AcceptRole)
|
|
1268
|
+
btn_file = mb.addButton("File on Disk", QMessageBox.ButtonRole.AcceptRole)
|
|
1269
|
+
mb.addButton(QMessageBox.StandardButton.Cancel)
|
|
1270
|
+
mb.exec()
|
|
1271
|
+
if mb.clickedButton() is btn_view:
|
|
1272
|
+
use_view = True
|
|
1273
|
+
elif mb.clickedButton() is None or mb.clickedButton() == mb.buttons()[-1]: # Cancel
|
|
1274
|
+
return
|
|
1275
|
+
|
|
1276
|
+
# --- Branch 1: Process an OPEN VIEW ---
|
|
1277
|
+
if use_view:
|
|
1278
|
+
# If multiple views, ask which one
|
|
1279
|
+
chosen_doc = None
|
|
1280
|
+
if len(views) == 1:
|
|
1281
|
+
chosen_doc = views[0][1]
|
|
1282
|
+
base_name = self._base_name_for_doc(chosen_doc)
|
|
1283
|
+
else:
|
|
1284
|
+
titles = [t for (t, _) in views]
|
|
1285
|
+
sel, ok = QInputDialog.getItem(self, "Select View", "Choose an open view:", titles, 0, False)
|
|
1286
|
+
if not ok:
|
|
1287
|
+
return
|
|
1288
|
+
idx = titles.index(sel)
|
|
1289
|
+
chosen_doc = views[idx][1]
|
|
1290
|
+
base_name = self._base_name_for_doc(chosen_doc)
|
|
1291
|
+
|
|
1292
|
+
# Stage image from the chosen view
|
|
1293
|
+
temp_in = self._create_temp_folder()
|
|
1294
|
+
temp_out = self._create_temp_folder()
|
|
1295
|
+
staged_in = os.path.join(temp_in, f"{base_name}.tif")
|
|
1296
|
+
|
|
1297
|
+
try:
|
|
1298
|
+
# 32-bit float TIFF like SASv2
|
|
1299
|
+
img = np.clip(np.asarray(chosen_doc.image, dtype=np.float32), 0.0, 1.0)
|
|
1300
|
+
save_image(
|
|
1301
|
+
img, staged_in,
|
|
1302
|
+
"tiff", "32-bit floating point",
|
|
1303
|
+
getattr(chosen_doc, "original_header", None),
|
|
1304
|
+
getattr(chosen_doc, "is_mono", False)
|
|
1305
|
+
)
|
|
1306
|
+
except Exception as e:
|
|
1307
|
+
QMessageBox.critical(self, "Error", f"Failed to stage view for processing:\n{e}")
|
|
1308
|
+
return
|
|
1309
|
+
|
|
1310
|
+
# Run satellite
|
|
1311
|
+
try:
|
|
1312
|
+
self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
|
|
1313
|
+
except Exception as e:
|
|
1314
|
+
QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
|
|
1315
|
+
return
|
|
1316
|
+
|
|
1317
|
+
# Pick up result and apply back to the view
|
|
1318
|
+
out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
|
|
1319
|
+
if not out:
|
|
1320
|
+
# Likely --skip-save and no trail, or failure
|
|
1321
|
+
QMessageBox.information(self, "Satellite Removal", "No output produced (possibly no satellite trail detected).")
|
|
1322
|
+
else:
|
|
1323
|
+
out_path = out[0]
|
|
1324
|
+
try:
|
|
1325
|
+
result, hdr, bd, mono = load_image(out_path)
|
|
1326
|
+
if result is None:
|
|
1327
|
+
raise RuntimeError("Unable to load output image.")
|
|
1328
|
+
result = result.astype(np.float32, copy=False)
|
|
1329
|
+
|
|
1330
|
+
# Apply back to the chosen doc
|
|
1331
|
+
if hasattr(chosen_doc, "set_image"):
|
|
1332
|
+
chosen_doc.set_image(result, step_name="Cosmic Clarity – Satellite Removal")
|
|
1333
|
+
elif hasattr(chosen_doc, "apply_numpy"):
|
|
1334
|
+
chosen_doc.apply_numpy(result, step_name="Cosmic Clarity – Satellite Removal")
|
|
1335
|
+
else:
|
|
1336
|
+
chosen_doc.image = result
|
|
1337
|
+
except Exception as e:
|
|
1338
|
+
QMessageBox.critical(self, "Error", f"Failed to apply result to view:\n{e}")
|
|
1339
|
+
# fall through to cleanup
|
|
1340
|
+
finally:
|
|
1341
|
+
# Clean up temp files
|
|
1342
|
+
try:
|
|
1343
|
+
if os.path.exists(out_path): os.remove(out_path)
|
|
1344
|
+
except Exception:
|
|
1345
|
+
pass
|
|
1346
|
+
|
|
1347
|
+
# Clean up temp dirs
|
|
1348
|
+
try:
|
|
1349
|
+
shutil.rmtree(temp_in, ignore_errors=True)
|
|
1350
|
+
shutil.rmtree(temp_out, ignore_errors=True)
|
|
1351
|
+
except Exception:
|
|
1352
|
+
pass
|
|
1353
|
+
|
|
1354
|
+
return # done
|
|
1355
|
+
|
|
1356
|
+
# --- Branch 2: Process a FILE on disk ---
|
|
1357
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1358
|
+
self, "Select Image", "",
|
|
1359
|
+
"Image Files (*.png *.tif *.tiff *.fit *.fits *.xisf *.cr2 *.nef *.arw *.dng *.raf *.orf *.rw2 *.pef *.jpg *.jpeg)"
|
|
1360
|
+
)
|
|
1361
|
+
if not file_path:
|
|
1362
|
+
QMessageBox.warning(self, "Warning", "No file selected.")
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
temp_in = self._create_temp_folder()
|
|
1366
|
+
temp_out = self._create_temp_folder()
|
|
1367
|
+
try:
|
|
1368
|
+
shutil.copy(file_path, temp_in)
|
|
1369
|
+
except Exception as e:
|
|
1370
|
+
QMessageBox.critical(self, "Error", f"Failed to stage input:\n{e}")
|
|
1371
|
+
return
|
|
1372
|
+
|
|
1373
|
+
try:
|
|
1374
|
+
self._run_satellite(input_dir=temp_in, output_dir=temp_out, live=False)
|
|
1375
|
+
except Exception as e:
|
|
1376
|
+
QMessageBox.critical(self, "Error", f"Error processing image:\n{e}")
|
|
1377
|
+
return
|
|
1378
|
+
|
|
1379
|
+
# Move output back next to original
|
|
1380
|
+
out = glob.glob(os.path.join(temp_out, "*_satellited.*"))
|
|
1381
|
+
if out:
|
|
1382
|
+
dst = os.path.join(os.path.dirname(file_path), os.path.basename(out[0]))
|
|
1383
|
+
try:
|
|
1384
|
+
shutil.move(out[0], dst)
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
QMessageBox.critical(self, "Error", f"Failed to save result:\n{e}")
|
|
1387
|
+
return
|
|
1388
|
+
QMessageBox.information(self, "Success", f"Processed image saved to:\n{dst}")
|
|
1389
|
+
else:
|
|
1390
|
+
QMessageBox.warning(self, "Warning", "No output file found.")
|
|
1391
|
+
|
|
1392
|
+
# Cleanup
|
|
1393
|
+
try:
|
|
1394
|
+
shutil.rmtree(temp_in, ignore_errors=True)
|
|
1395
|
+
shutil.rmtree(temp_out, ignore_errors=True)
|
|
1396
|
+
except Exception:
|
|
1397
|
+
pass
|
|
1398
|
+
|
|
1399
|
+
def _collect_open_views(self):
|
|
1400
|
+
"""
|
|
1401
|
+
Return a list of (title, doc) for all open MDI views with an image.
|
|
1402
|
+
Includes self.doc if supplied and valid.
|
|
1403
|
+
"""
|
|
1404
|
+
views = []
|
|
1405
|
+
# include self.doc first if valid
|
|
1406
|
+
if getattr(self, "doc", None) is not None and getattr(self.doc, "image", None) is not None:
|
|
1407
|
+
title = getattr(self.doc, "display_name", lambda: "Active View")()
|
|
1408
|
+
views.append((title, self.doc))
|
|
1409
|
+
|
|
1410
|
+
# try to enumerate MDI subwindows on the parent main window
|
|
1411
|
+
try:
|
|
1412
|
+
main = self.parent()
|
|
1413
|
+
mdi = getattr(main, "mdi", None)
|
|
1414
|
+
if mdi is not None:
|
|
1415
|
+
for sw in mdi.subWindowList():
|
|
1416
|
+
w = sw.widget()
|
|
1417
|
+
d = getattr(w, "document", None)
|
|
1418
|
+
if d is not None and getattr(d, "image", None) is not None:
|
|
1419
|
+
t = w.windowTitle() if hasattr(w, "windowTitle") else getattr(d, "display_name", lambda:"View")()
|
|
1420
|
+
# don’t duplicate self.doc if it’s the same object
|
|
1421
|
+
if not any(d is existing for _, existing in views):
|
|
1422
|
+
views.append((t, d))
|
|
1423
|
+
except Exception:
|
|
1424
|
+
pass
|
|
1425
|
+
|
|
1426
|
+
return views
|
|
1427
|
+
|
|
1428
|
+
def _base_name_for_doc(self, d):
|
|
1429
|
+
"""Derive a simple basename for staging temp files from a document."""
|
|
1430
|
+
fp = getattr(d, "file_path", None)
|
|
1431
|
+
if isinstance(fp, str) and fp:
|
|
1432
|
+
return os.path.splitext(os.path.basename(fp))[0]
|
|
1433
|
+
name = getattr(d, "display_name", None)
|
|
1434
|
+
if callable(name):
|
|
1435
|
+
try:
|
|
1436
|
+
n = name() or ""
|
|
1437
|
+
if n:
|
|
1438
|
+
return "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in n).strip("_") or "image"
|
|
1439
|
+
except Exception:
|
|
1440
|
+
pass
|
|
1441
|
+
return "image"
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
# ---------- Batch ----------
|
|
1445
|
+
def _batch_process(self):
|
|
1446
|
+
if not self.input_folder or not self.output_folder:
|
|
1447
|
+
QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
|
|
1448
|
+
return
|
|
1449
|
+
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1450
|
+
if not os.path.exists(exe):
|
|
1451
|
+
QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=True, monitor=False)
|
|
1455
|
+
self._run_threaded(cmd, title="Satellite – Batch processing")
|
|
1456
|
+
|
|
1457
|
+
# ---------- Live monitor ----------
|
|
1458
|
+
def _live_monitor(self):
|
|
1459
|
+
if not self.input_folder or not self.output_folder:
|
|
1460
|
+
QMessageBox.warning(self, "Warning", "Please select both input and output folders.")
|
|
1461
|
+
return
|
|
1462
|
+
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1463
|
+
if not os.path.exists(exe):
|
|
1464
|
+
QMessageBox.critical(self, "Error", f"Executable not found:\n{exe}")
|
|
1465
|
+
return
|
|
1466
|
+
|
|
1467
|
+
cmd = self._build_cmd(exe, self.input_folder, self.output_folder, batch=False, monitor=True)
|
|
1468
|
+
self.sld_sens.setEnabled(False)
|
|
1469
|
+
self._run_threaded(cmd, title="Satellite – Live monitoring", on_finish=lambda: self.sld_sens.setEnabled(True))
|
|
1470
|
+
|
|
1471
|
+
# ---------- Command / run ----------
|
|
1472
|
+
def _build_cmd(self, exe_path: str, in_dir: str, out_dir: str, *, batch: bool, monitor: bool):
|
|
1473
|
+
cmd = [
|
|
1474
|
+
exe_path,
|
|
1475
|
+
"--input", in_dir,
|
|
1476
|
+
"--output", out_dir,
|
|
1477
|
+
"--mode", self.cmb_mode.currentText().lower(),
|
|
1478
|
+
]
|
|
1479
|
+
if self.cmb_gpu.currentText() == "Yes":
|
|
1480
|
+
cmd.append("--use-gpu")
|
|
1481
|
+
if self.chk_clip.isChecked():
|
|
1482
|
+
cmd.append("--clip-trail")
|
|
1483
|
+
else:
|
|
1484
|
+
cmd.append("--no-clip-trail")
|
|
1485
|
+
if self.chk_skip.isChecked():
|
|
1486
|
+
cmd.append("--skip-save")
|
|
1487
|
+
if batch:
|
|
1488
|
+
cmd.append("--batch")
|
|
1489
|
+
if monitor:
|
|
1490
|
+
cmd.append("--monitor")
|
|
1491
|
+
cmd += ["--sensitivity", f"{self.sensitivity}"]
|
|
1492
|
+
return cmd
|
|
1493
|
+
|
|
1494
|
+
def _run_threaded(self, cmd, title="Processing…", on_finish=None):
|
|
1495
|
+
# Wait dialog + threaded subprocess (mirrors SASv2 SatelliteProcessingThread)
|
|
1496
|
+
self._wait = WaitDialog(title, self)
|
|
1497
|
+
self._wait.show()
|
|
1498
|
+
|
|
1499
|
+
self._sat_thread = SatelliteProcessingThread(cmd)
|
|
1500
|
+
self._sat_thread.log_signal.connect(self._wait.append_output)
|
|
1501
|
+
self._sat_thread.finished_signal.connect(lambda: self._on_thread_finished(on_finish))
|
|
1502
|
+
self._wait.cancelled.connect(self._cancel_sat_thread)
|
|
1503
|
+
self._sat_thread.start()
|
|
1504
|
+
|
|
1505
|
+
def _cancel_sat_thread(self):
|
|
1506
|
+
if self._sat_thread:
|
|
1507
|
+
self._sat_thread.cancel()
|
|
1508
|
+
if self._wait:
|
|
1509
|
+
self._wait.close()
|
|
1510
|
+
self._wait = None
|
|
1511
|
+
|
|
1512
|
+
def _on_thread_finished(self, on_finish):
|
|
1513
|
+
if self._wait: self._wait.close(); self._wait = None
|
|
1514
|
+
if callable(on_finish):
|
|
1515
|
+
try: on_finish()
|
|
1516
|
+
except Exception as e:
|
|
1517
|
+
import logging
|
|
1518
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1519
|
+
QMessageBox.information(self, "Done", "Processing finished.")
|
|
1520
|
+
|
|
1521
|
+
def _run_satellite(self, *, input_dir: str, output_dir: str, live: bool):
|
|
1522
|
+
if not self.cosmic_clarity_folder:
|
|
1523
|
+
raise RuntimeError("Cosmic Clarity folder not set. Choose it in Preferences or with the button below.")
|
|
1524
|
+
exe = os.path.join(self.cosmic_clarity_folder, _satellite_exe_name())
|
|
1525
|
+
if not os.path.exists(exe):
|
|
1526
|
+
raise FileNotFoundError(f"Executable not found: {exe}")
|
|
1527
|
+
|
|
1528
|
+
cmd = self._build_cmd(exe, input_dir, output_dir, batch=not live, monitor=live)
|
|
1529
|
+
print("Running command:", " ".join(cmd))
|
|
1530
|
+
subprocess.run(cmd, check=True)
|
|
1531
|
+
|
|
1532
|
+
# ---------- Utils ----------
|
|
1533
|
+
@staticmethod
|
|
1534
|
+
def _create_temp_folder(base="~"):
|
|
1535
|
+
user_dir = os.path.expanduser(base)
|
|
1536
|
+
temp_folder = os.path.join(user_dir, "CosmicClarityTemp")
|
|
1537
|
+
os.makedirs(temp_folder, exist_ok=True)
|
|
1538
|
+
return temp_folder
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
class SatelliteProcessingThread(QThread):
|
|
1542
|
+
log_signal = pyqtSignal(str)
|
|
1543
|
+
finished_signal = pyqtSignal()
|
|
1544
|
+
def __init__(self, command):
|
|
1545
|
+
super().__init__()
|
|
1546
|
+
self.command = command
|
|
1547
|
+
self.process = None
|
|
1548
|
+
|
|
1549
|
+
def cancel(self):
|
|
1550
|
+
if self.process:
|
|
1551
|
+
try:
|
|
1552
|
+
self.process.kill()
|
|
1553
|
+
except Exception:
|
|
1554
|
+
pass
|
|
1555
|
+
|
|
1556
|
+
def run(self):
|
|
1557
|
+
try:
|
|
1558
|
+
self.log_signal.emit("Running command: " + " ".join(self.command))
|
|
1559
|
+
self.process = subprocess.Popen(
|
|
1560
|
+
self.command,
|
|
1561
|
+
stdout=subprocess.PIPE,
|
|
1562
|
+
stderr=subprocess.STDOUT,
|
|
1563
|
+
universal_newlines=True,
|
|
1564
|
+
text=True
|
|
1565
|
+
)
|
|
1566
|
+
# Read output to prevent deadlock
|
|
1567
|
+
for line in iter(self.process.stdout.readline, ""):
|
|
1568
|
+
if not line: break
|
|
1569
|
+
# Optional: emit log signal for verbose output?
|
|
1570
|
+
# The original code didn't log stdout, but blocked.
|
|
1571
|
+
# Let's just log it if we want, or consume it.
|
|
1572
|
+
# The prompt says "I think starnet stops but the window doesnt close"
|
|
1573
|
+
# so maybe verbose logging isn't the priority, but consuming stdout is mandatory.
|
|
1574
|
+
# However, the original code used subprocess.run which captures output if specified,
|
|
1575
|
+
# but it didn't specify capture_output=True or stdout/stderr args in the snippet I saw?
|
|
1576
|
+
# Wait, let's check the snippet I saw earlier for SatelliteProcessingThread.
|
|
1577
|
+
pass
|
|
1578
|
+
|
|
1579
|
+
# Close stdout to ensure cleanup
|
|
1580
|
+
if self.process.stdout:
|
|
1581
|
+
self.process.stdout.close()
|
|
1582
|
+
|
|
1583
|
+
rc = self.process.wait()
|
|
1584
|
+
if rc == 0:
|
|
1585
|
+
self.log_signal.emit("Processing complete.")
|
|
1586
|
+
else:
|
|
1587
|
+
self.log_signal.emit(f"Processing failed with code {rc}")
|
|
1588
|
+
|
|
1589
|
+
except Exception as e:
|
|
1590
|
+
self.log_signal.emit(f"Unexpected error: {e}")
|
|
1591
|
+
finally:
|
|
1592
|
+
self.process = None
|
|
1593
|
+
self.finished_signal.emit()
|