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,1352 @@
|
|
|
1
|
+
# pro/frequency_separation.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
# Optional deps used by the processing threads
|
|
7
|
+
try:
|
|
8
|
+
import cv2
|
|
9
|
+
except Exception:
|
|
10
|
+
cv2 = None
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import pywt
|
|
14
|
+
except Exception:
|
|
15
|
+
pywt = None
|
|
16
|
+
|
|
17
|
+
from PyQt6.QtCore import (
|
|
18
|
+
Qt, QSize, QPoint, QEvent, QThread, pyqtSignal, QTimer
|
|
19
|
+
)
|
|
20
|
+
from PyQt6.QtWidgets import (
|
|
21
|
+
QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QComboBox, QSlider,
|
|
22
|
+
QCheckBox, QScrollArea, QToolButton, QStyle, QFileDialog, QMessageBox
|
|
23
|
+
)
|
|
24
|
+
from PyQt6.QtGui import (
|
|
25
|
+
QPixmap, QImage, QMovie, QCursor, QWheelEvent
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .doc_manager import ImageDocument # add this import
|
|
29
|
+
from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
|
|
30
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
31
|
+
|
|
32
|
+
# ---------------------------- Threads ----------------------------
|
|
33
|
+
|
|
34
|
+
class FrequencySeperationThread(QThread):
|
|
35
|
+
separation_done = pyqtSignal(np.ndarray, np.ndarray)
|
|
36
|
+
error_signal = pyqtSignal(str)
|
|
37
|
+
|
|
38
|
+
def __init__(self, image: np.ndarray, method='Gaussian', radius=10.0, tolerance=50, parent=None):
|
|
39
|
+
super().__init__(parent)
|
|
40
|
+
self.image = image.astype(np.float32, copy=False)
|
|
41
|
+
self.method = method
|
|
42
|
+
self.radius = float(radius)
|
|
43
|
+
self.tolerance = int(tolerance)
|
|
44
|
+
try:
|
|
45
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass # older PyQt6 versions
|
|
48
|
+
def run(self):
|
|
49
|
+
try:
|
|
50
|
+
if self.image.ndim == 3 and self.image.shape[2] == 3:
|
|
51
|
+
if cv2 is None:
|
|
52
|
+
raise RuntimeError("OpenCV (cv2) is required for color frequency separation.")
|
|
53
|
+
bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
|
|
54
|
+
else:
|
|
55
|
+
bgr = self.image.copy()
|
|
56
|
+
|
|
57
|
+
if self.method == 'Gaussian':
|
|
58
|
+
if cv2 is None:
|
|
59
|
+
raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
|
|
60
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
61
|
+
elif self.method == 'Median':
|
|
62
|
+
if cv2 is None:
|
|
63
|
+
raise RuntimeError("OpenCV (cv2) is required for median blur.")
|
|
64
|
+
ksize = max(1, int(self.radius) // 2 * 2 + 1)
|
|
65
|
+
low_bgr = cv2.medianBlur(bgr, ksize)
|
|
66
|
+
elif self.method == 'Bilateral':
|
|
67
|
+
if cv2 is None:
|
|
68
|
+
raise RuntimeError("OpenCV (cv2) is required for bilateral filter.")
|
|
69
|
+
sigma = 50.0 * (self.tolerance / 100.0)
|
|
70
|
+
d = max(1, int(self.radius))
|
|
71
|
+
low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
|
|
72
|
+
else:
|
|
73
|
+
# fallback
|
|
74
|
+
if cv2 is None:
|
|
75
|
+
raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
|
|
76
|
+
low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
|
|
77
|
+
|
|
78
|
+
if low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
|
|
79
|
+
low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
|
|
80
|
+
else:
|
|
81
|
+
low_rgb = low_bgr
|
|
82
|
+
|
|
83
|
+
high_rgb = self.image - low_rgb # keep signed HF
|
|
84
|
+
self.separation_done.emit(low_rgb.astype(np.float32), high_rgb.astype(np.float32))
|
|
85
|
+
except Exception as e:
|
|
86
|
+
self.error_signal.emit(str(e))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class HFEnhancementThread(QThread):
|
|
90
|
+
enhancement_done = pyqtSignal(np.ndarray)
|
|
91
|
+
error_signal = pyqtSignal(str)
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
hf_image: np.ndarray,
|
|
96
|
+
enable_scale=True,
|
|
97
|
+
sharpen_scale=1.0,
|
|
98
|
+
enable_wavelet=True,
|
|
99
|
+
wavelet_level=2,
|
|
100
|
+
wavelet_boost=1.2,
|
|
101
|
+
wavelet_name='db2',
|
|
102
|
+
enable_denoise=False,
|
|
103
|
+
denoise_strength=3.0,
|
|
104
|
+
parent=None
|
|
105
|
+
):
|
|
106
|
+
super().__init__(parent)
|
|
107
|
+
self.hf_image = hf_image.astype(np.float32, copy=False)
|
|
108
|
+
self.enable_scale = bool(enable_scale)
|
|
109
|
+
self.sharpen_scale = float(sharpen_scale)
|
|
110
|
+
self.enable_wavelet = bool(enable_wavelet)
|
|
111
|
+
self.wavelet_level = int(wavelet_level)
|
|
112
|
+
self.wavelet_boost = float(wavelet_boost)
|
|
113
|
+
self.wavelet_name = str(wavelet_name)
|
|
114
|
+
self.enable_denoise = bool(enable_denoise)
|
|
115
|
+
self.denoise_strength = float(denoise_strength)
|
|
116
|
+
|
|
117
|
+
def run(self):
|
|
118
|
+
try:
|
|
119
|
+
out = self.hf_image.copy()
|
|
120
|
+
|
|
121
|
+
if self.enable_scale:
|
|
122
|
+
out *= self.sharpen_scale
|
|
123
|
+
|
|
124
|
+
if self.enable_wavelet:
|
|
125
|
+
if pywt is None:
|
|
126
|
+
raise RuntimeError("PyWavelets (pywt) is required for wavelet sharpening.")
|
|
127
|
+
out = self._wavelet_sharpen(out, self.wavelet_name, self.wavelet_level, self.wavelet_boost)
|
|
128
|
+
|
|
129
|
+
if self.enable_denoise:
|
|
130
|
+
if cv2 is None:
|
|
131
|
+
raise RuntimeError("OpenCV (cv2) is required for HF denoise.")
|
|
132
|
+
out = self._denoise_hf(out, self.denoise_strength)
|
|
133
|
+
|
|
134
|
+
self.enhancement_done.emit(out.astype(np.float32))
|
|
135
|
+
except Exception as e:
|
|
136
|
+
self.error_signal.emit(str(e))
|
|
137
|
+
|
|
138
|
+
def _wavelet_sharpen(self, img, wavelet='db2', level=2, boost=1.2):
|
|
139
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
140
|
+
chs = []
|
|
141
|
+
for c in range(3):
|
|
142
|
+
chs.append(self._wavelet_sharpen_mono(img[..., c], wavelet, level, boost))
|
|
143
|
+
return np.stack(chs, axis=-1)
|
|
144
|
+
else:
|
|
145
|
+
return self._wavelet_sharpen_mono(img, wavelet, level, boost)
|
|
146
|
+
|
|
147
|
+
def _wavelet_sharpen_mono(self, mono, wavelet, level, boost):
|
|
148
|
+
coeffs = pywt.wavedec2(mono, wavelet=wavelet, level=level, mode='periodization')
|
|
149
|
+
new_coeffs = [coeffs[0]]
|
|
150
|
+
for (cH, cV, cD) in coeffs[1:]:
|
|
151
|
+
new_coeffs.append((cH * boost, cV * boost, cD * boost))
|
|
152
|
+
rec = pywt.waverec2(new_coeffs, wavelet=wavelet, mode='periodization')
|
|
153
|
+
|
|
154
|
+
# shape guard
|
|
155
|
+
if rec.shape != mono.shape:
|
|
156
|
+
h, w = mono.shape[:2]
|
|
157
|
+
rec = rec[:h, :w]
|
|
158
|
+
return rec
|
|
159
|
+
|
|
160
|
+
def _denoise_hf(self, hf, strength=3.0):
|
|
161
|
+
# Shift to [0..1], denoise, shift back.
|
|
162
|
+
if hf.ndim == 3 and hf.shape[2] == 3:
|
|
163
|
+
bgr = hf[..., ::-1] # RGB->BGR
|
|
164
|
+
tmp = np.clip(bgr + 0.5, 0, 1)
|
|
165
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
166
|
+
den = cv2.fastNlMeansDenoisingColored(u8, None, strength, strength, 7, 21)
|
|
167
|
+
f32 = den.astype(np.float32) / 255.0 - 0.5
|
|
168
|
+
return f32[..., ::-1] # back to RGB
|
|
169
|
+
else:
|
|
170
|
+
tmp = np.clip(hf + 0.5, 0, 1)
|
|
171
|
+
u8 = (tmp * 255).astype(np.uint8)
|
|
172
|
+
den = cv2.fastNlMeansDenoising(u8, None, strength, 7, 21)
|
|
173
|
+
return den.astype(np.float32) / 255.0 - 0.5
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------- Widget ----------------------------
|
|
177
|
+
|
|
178
|
+
class FrequencySeperationTab(QWidget):
|
|
179
|
+
"""
|
|
180
|
+
SASpro version:
|
|
181
|
+
- Side-by-side LF/HF previews with synced panning
|
|
182
|
+
- Ctrl+wheel zoom-at-mouse (no wheel-scroll)
|
|
183
|
+
- Gaussian / Median / Bilateral
|
|
184
|
+
- Optional HF scale, wavelet sharpen, denoise
|
|
185
|
+
- Push LF/HF/Combined to new views via DocManager
|
|
186
|
+
"""
|
|
187
|
+
def __init__(self, image_manager=None, doc_manager=None, parent=None, document: ImageDocument | None = None):
|
|
188
|
+
super().__init__(parent)
|
|
189
|
+
self.doc_manager = doc_manager or image_manager
|
|
190
|
+
self.doc: ImageDocument | None = document
|
|
191
|
+
|
|
192
|
+
# state
|
|
193
|
+
self.image: np.ndarray | None = None
|
|
194
|
+
self.low_freq_image: np.ndarray | None = None
|
|
195
|
+
self.high_freq_image: np.ndarray | None = None
|
|
196
|
+
self.original_header = None
|
|
197
|
+
self.is_mono = False
|
|
198
|
+
self.filename = None
|
|
199
|
+
|
|
200
|
+
self.zoom_factor = 1.0
|
|
201
|
+
self._dragging = False
|
|
202
|
+
self._last_pos: QPoint | None = None
|
|
203
|
+
self._sync_guard = False
|
|
204
|
+
self._hf_history: list[np.ndarray] = []
|
|
205
|
+
|
|
206
|
+
# parameters
|
|
207
|
+
self.method = 'Gaussian'
|
|
208
|
+
self.radius = 10.0
|
|
209
|
+
self.tolerance = 50
|
|
210
|
+
self.enable_scale = True
|
|
211
|
+
self.sharpen_scale = 1.0
|
|
212
|
+
self.enable_wavelet = True
|
|
213
|
+
self.wavelet_level = 2
|
|
214
|
+
self.wavelet_boost = 1.2
|
|
215
|
+
self.enable_denoise = False
|
|
216
|
+
self.denoise_strength = 3.0
|
|
217
|
+
|
|
218
|
+
self.proc_thread: FrequencySeperationThread | None = None
|
|
219
|
+
self.hf_thread: HFEnhancementThread | None = None
|
|
220
|
+
self._auto_loaded = False
|
|
221
|
+
self._build_ui()
|
|
222
|
+
|
|
223
|
+
if self.doc is not None and getattr(self.doc, "image", None) is not None:
|
|
224
|
+
# Preload immediately; avoids any focus/MDI ambiguity
|
|
225
|
+
self.set_image_from_doc(np.asarray(self.doc.image),
|
|
226
|
+
getattr(self.doc, "metadata", {}))
|
|
227
|
+
self._auto_loaded = True
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------- UI ----------------
|
|
231
|
+
def _build_ui(self):
|
|
232
|
+
main = QHBoxLayout(self)
|
|
233
|
+
self.setLayout(main)
|
|
234
|
+
|
|
235
|
+
# left controls
|
|
236
|
+
left = QVBoxLayout()
|
|
237
|
+
left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(280)
|
|
238
|
+
|
|
239
|
+
self.fileLabel = QLabel("", self)
|
|
240
|
+
left.addWidget(self.fileLabel)
|
|
241
|
+
|
|
242
|
+
# Method
|
|
243
|
+
left.addWidget(QLabel(self.tr("Method:"), self))
|
|
244
|
+
self.method_combo = QComboBox(self)
|
|
245
|
+
self.method_combo.addItems(['Gaussian', 'Median', 'Bilateral'])
|
|
246
|
+
self.method_combo.currentTextChanged.connect(self._on_method_changed)
|
|
247
|
+
left.addWidget(self.method_combo)
|
|
248
|
+
|
|
249
|
+
# Radius (0..100 mapped to 0.1..100)
|
|
250
|
+
self.radius_label = QLabel("Radius: 10.00", self); left.addWidget(self.radius_label)
|
|
251
|
+
self.radius_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
252
|
+
self.radius_slider.setRange(1, 100); self.radius_slider.setValue(50)
|
|
253
|
+
self.radius_slider.valueChanged.connect(self._on_radius_changed)
|
|
254
|
+
left.addWidget(self.radius_slider)
|
|
255
|
+
|
|
256
|
+
# Tolerance (for Bilateral only)
|
|
257
|
+
self.tol_label = QLabel("Tolerance: 50%", self); left.addWidget(self.tol_label)
|
|
258
|
+
self.tol_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
259
|
+
self.tol_slider.setRange(0, 100); self.tol_slider.setValue(50)
|
|
260
|
+
self.tol_slider.valueChanged.connect(self._on_tol_changed)
|
|
261
|
+
left.addWidget(self.tol_slider)
|
|
262
|
+
self._toggle_tol_enabled(False)
|
|
263
|
+
|
|
264
|
+
# Apply separation
|
|
265
|
+
btn_apply = QPushButton(self.tr("Apply - Split HF & LF"), self)
|
|
266
|
+
btn_apply.clicked.connect(self._apply_separation)
|
|
267
|
+
left.addWidget(btn_apply)
|
|
268
|
+
|
|
269
|
+
left.addWidget(QLabel(self.tr("<b>HF Enhancements</b>"), self))
|
|
270
|
+
|
|
271
|
+
# Sharpen scale
|
|
272
|
+
self.cb_scale = QCheckBox(self.tr("Enable Sharpen Scale"), self)
|
|
273
|
+
self.cb_scale.setChecked(True); left.addWidget(self.cb_scale)
|
|
274
|
+
self.scale_label = QLabel("Sharpen Scale: 1.00", self); left.addWidget(self.scale_label)
|
|
275
|
+
self.scale_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
276
|
+
self.scale_slider.setRange(10, 300); self.scale_slider.setValue(100)
|
|
277
|
+
self.scale_slider.valueChanged.connect(lambda v: self._update_scale(v))
|
|
278
|
+
left.addWidget(self.scale_slider)
|
|
279
|
+
|
|
280
|
+
# Wavelet
|
|
281
|
+
self.cb_wavelet = QCheckBox(self.tr("Enable Wavelet Sharpening"), self)
|
|
282
|
+
self.cb_wavelet.setChecked(True); left.addWidget(self.cb_wavelet)
|
|
283
|
+
self.wavelet_level_label = QLabel("Wavelet Level: 2", self); left.addWidget(self.wavelet_level_label)
|
|
284
|
+
self.wavelet_level_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
285
|
+
self.wavelet_level_slider.setRange(1, 5); self.wavelet_level_slider.setValue(2)
|
|
286
|
+
self.wavelet_level_slider.valueChanged.connect(lambda v: self._update_wavelet_level(v))
|
|
287
|
+
left.addWidget(self.wavelet_level_slider)
|
|
288
|
+
|
|
289
|
+
self.wavelet_boost_label = QLabel("Wavelet Boost: 1.20", self); left.addWidget(self.wavelet_boost_label)
|
|
290
|
+
self.wavelet_boost_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
291
|
+
self.wavelet_boost_slider.setRange(50, 300); self.wavelet_boost_slider.setValue(120)
|
|
292
|
+
self.wavelet_boost_slider.valueChanged.connect(lambda v: self._update_wavelet_boost(v))
|
|
293
|
+
left.addWidget(self.wavelet_boost_slider)
|
|
294
|
+
|
|
295
|
+
# Denoise
|
|
296
|
+
self.cb_denoise = QCheckBox(self.tr("Enable HF Denoise"), self)
|
|
297
|
+
self.cb_denoise.setChecked(False); left.addWidget(self.cb_denoise)
|
|
298
|
+
self.denoise_label = QLabel("Denoise Strength: 3.00", self); left.addWidget(self.denoise_label)
|
|
299
|
+
self.denoise_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
300
|
+
self.denoise_slider.setRange(0, 50); self.denoise_slider.setValue(30) # 0..5.0 (we'll /10)
|
|
301
|
+
self.denoise_slider.valueChanged.connect(lambda v: self._update_denoise(v))
|
|
302
|
+
left.addWidget(self.denoise_slider)
|
|
303
|
+
|
|
304
|
+
# HF actions row
|
|
305
|
+
row = QHBoxLayout()
|
|
306
|
+
self.btn_apply_hf = QPushButton(self.tr("Apply HF Enhancements"), self)
|
|
307
|
+
self.btn_apply_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton))
|
|
308
|
+
self.btn_apply_hf.clicked.connect(self._apply_hf_enhancements)
|
|
309
|
+
row.addWidget(self.btn_apply_hf)
|
|
310
|
+
|
|
311
|
+
self.btn_undo_hf = QToolButton(self)
|
|
312
|
+
self.btn_undo_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
|
|
313
|
+
self.btn_undo_hf.setToolTip("Undo last HF enhancement")
|
|
314
|
+
self.btn_undo_hf.setEnabled(False)
|
|
315
|
+
self.btn_undo_hf.clicked.connect(self._undo_hf)
|
|
316
|
+
row.addWidget(self.btn_undo_hf)
|
|
317
|
+
left.addLayout(row)
|
|
318
|
+
|
|
319
|
+
# Push buttons
|
|
320
|
+
push_row = QHBoxLayout()
|
|
321
|
+
self.btn_push_lf = QPushButton(self.tr("Push LF"), self); self.btn_push_lf.clicked.connect(lambda: self._push_array(self.low_freq_image, "LF"))
|
|
322
|
+
self.btn_push_hf = QPushButton(self.tr("Push HF"), self); self.btn_push_hf.clicked.connect(lambda: self._push_array(self._hf_display_for_push(), "HF"))
|
|
323
|
+
push_row.addWidget(self.btn_push_lf); push_row.addWidget(self.btn_push_hf)
|
|
324
|
+
left.addLayout(push_row)
|
|
325
|
+
|
|
326
|
+
#load_row = QHBoxLayout()
|
|
327
|
+
#self.btn_load_hf = QPushButton("Load HF…", self)
|
|
328
|
+
#self.btn_load_hf.clicked.connect(self._load_hf_from_file)
|
|
329
|
+
#load_row.addWidget(self.btn_load_hf)
|
|
330
|
+
|
|
331
|
+
#self.btn_load_lf = QPushButton("Load LF…", self)
|
|
332
|
+
#self.btn_load_lf.clicked.connect(self._load_lf_from_file)
|
|
333
|
+
#load_row.addWidget(self.btn_load_lf)
|
|
334
|
+
|
|
335
|
+
#left.addLayout(load_row)
|
|
336
|
+
|
|
337
|
+
# --- Load from VIEW (active subwindow) ---
|
|
338
|
+
load_row = QHBoxLayout()
|
|
339
|
+
self.btn_load_hf_view = QPushButton("Load HF (View)", self)
|
|
340
|
+
self.btn_load_lf_view = QPushButton("Load LF (View)", self)
|
|
341
|
+
self.btn_load_hf_view.clicked.connect(lambda: self._load_component_from_view("HF"))
|
|
342
|
+
self.btn_load_lf_view.clicked.connect(lambda: self._load_component_from_view("LF"))
|
|
343
|
+
load_row.addWidget(self.btn_load_lf_view)
|
|
344
|
+
load_row.addWidget(self.btn_load_hf_view)
|
|
345
|
+
|
|
346
|
+
left.addLayout(load_row)
|
|
347
|
+
|
|
348
|
+
btn_combine_push = QPushButton(self.tr("Combine HF+LF -> Push"), self)
|
|
349
|
+
btn_combine_push.clicked.connect(self._combine_and_push)
|
|
350
|
+
left.addWidget(btn_combine_push)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# Spinner
|
|
355
|
+
self.spinnerLabel = QLabel(self); self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
356
|
+
try:
|
|
357
|
+
# if you have a resource_path util in your project, use it; otherwise show text
|
|
358
|
+
from setiastro.saspro.resources import resource_path # adjust if your helper lives elsewhere
|
|
359
|
+
mov = QMovie(resource_path("spinner.gif"))
|
|
360
|
+
self.spinnerLabel.setMovie(mov)
|
|
361
|
+
self._spinner = mov
|
|
362
|
+
except Exception:
|
|
363
|
+
self.spinnerLabel.setText("Processing…")
|
|
364
|
+
self._spinner = None
|
|
365
|
+
self.spinnerLabel.hide()
|
|
366
|
+
left.addWidget(self.spinnerLabel)
|
|
367
|
+
|
|
368
|
+
main.addWidget(left_host, 0)
|
|
369
|
+
|
|
370
|
+
# right previews
|
|
371
|
+
right = QVBoxLayout()
|
|
372
|
+
top_row = QHBoxLayout()
|
|
373
|
+
top_row.addStretch(1)
|
|
374
|
+
|
|
375
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
376
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
377
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
|
|
378
|
+
|
|
379
|
+
self.btn_zoom_in.clicked.connect(lambda: self._zoom_at_pair(1.25))
|
|
380
|
+
self.btn_zoom_out.clicked.connect(lambda: self._zoom_at_pair(0.8))
|
|
381
|
+
self.btn_fit.clicked.connect(self._fit_to_preview)
|
|
382
|
+
|
|
383
|
+
top_row.addWidget(self.btn_zoom_in)
|
|
384
|
+
top_row.addWidget(self.btn_zoom_out)
|
|
385
|
+
top_row.addWidget(self.btn_fit)
|
|
386
|
+
|
|
387
|
+
right.addLayout(top_row)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# two scroll areas
|
|
391
|
+
self.scrollHF = QScrollArea(self); self.scrollHF.setWidgetResizable(False); self.scrollHF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
392
|
+
self.scrollLF = QScrollArea(self); self.scrollLF.setWidgetResizable(False); self.scrollLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
393
|
+
|
|
394
|
+
self.labelHF = QLabel("High Frequency", self); self.labelHF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
395
|
+
self.labelLF = QLabel("Low Frequency", self); self.labelLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
396
|
+
|
|
397
|
+
self.scrollHF.setWidget(self.labelHF)
|
|
398
|
+
self.scrollLF.setWidget(self.labelLF)
|
|
399
|
+
|
|
400
|
+
# install filters to support ctrl+wheel & drag pan, and to suppress wheel scrolling
|
|
401
|
+
for w in (self.labelHF, self.scrollHF, self.scrollHF.viewport(),
|
|
402
|
+
|
|
403
|
+
self.labelLF, self.scrollLF, self.scrollLF.viewport(),
|
|
404
|
+
):
|
|
405
|
+
w.installEventFilter(self)
|
|
406
|
+
|
|
407
|
+
row_previews = QHBoxLayout()
|
|
408
|
+
row_previews.addWidget(self.scrollHF, 1)
|
|
409
|
+
row_previews.addWidget(self.scrollLF, 1)
|
|
410
|
+
right.addLayout(row_previews, 1)
|
|
411
|
+
|
|
412
|
+
right_host = QWidget(self); right_host.setLayout(right)
|
|
413
|
+
main.addWidget(right_host, 1)
|
|
414
|
+
|
|
415
|
+
def _try_autoload_active(self) -> bool:
|
|
416
|
+
# 1) DocManager paths
|
|
417
|
+
dm = self.doc_manager
|
|
418
|
+
doc = None
|
|
419
|
+
if dm is not None:
|
|
420
|
+
# common names first
|
|
421
|
+
for name in ("active_document", "current_document", "document"):
|
|
422
|
+
doc = getattr(dm, name, None)
|
|
423
|
+
if callable(doc):
|
|
424
|
+
doc = doc()
|
|
425
|
+
if doc is not None:
|
|
426
|
+
break
|
|
427
|
+
# sometimes the active subwindow is exposed
|
|
428
|
+
if doc is None:
|
|
429
|
+
sw = getattr(dm, "active_subwindow", None)
|
|
430
|
+
if sw is not None:
|
|
431
|
+
doc = getattr(sw, "document", None)
|
|
432
|
+
|
|
433
|
+
# 2) Fallback: sniff the main window’s active ImageSubWindow
|
|
434
|
+
if doc is None:
|
|
435
|
+
mw = self._find_main_window()
|
|
436
|
+
if mw is not None:
|
|
437
|
+
try:
|
|
438
|
+
from setiastro.saspro.subwindow import ImageSubWindow
|
|
439
|
+
subs = mw.findChildren(ImageSubWindow)
|
|
440
|
+
pick = None
|
|
441
|
+
for s in subs:
|
|
442
|
+
if s.isActiveWindow() or s.hasFocus():
|
|
443
|
+
pick = s; break
|
|
444
|
+
if pick is None and subs:
|
|
445
|
+
pick = subs[0]
|
|
446
|
+
if pick is not None:
|
|
447
|
+
doc = getattr(pick, "document", None)
|
|
448
|
+
except Exception:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
img = getattr(doc, "image", None) if doc is not None else None
|
|
452
|
+
md = getattr(doc, "metadata", {}) if doc is not None else {}
|
|
453
|
+
if img is not None:
|
|
454
|
+
self.set_image_from_doc(img, md)
|
|
455
|
+
return True
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
def _get_active_document(self, strict: bool = False) -> ImageDocument | None:
|
|
459
|
+
"""
|
|
460
|
+
Try to get the currently active ImageDocument from the MDI.
|
|
461
|
+
If strict=True: do NOT fall back to the most-recent DocManager doc.
|
|
462
|
+
"""
|
|
463
|
+
# 1) MDI active subwindow
|
|
464
|
+
mw = self._find_main_window()
|
|
465
|
+
try:
|
|
466
|
+
if mw and hasattr(mw, "mdi"):
|
|
467
|
+
sub = mw.mdi.activeSubWindow()
|
|
468
|
+
if sub:
|
|
469
|
+
w = sub.widget()
|
|
470
|
+
doc = getattr(w, "document", None)
|
|
471
|
+
if isinstance(doc, ImageDocument):
|
|
472
|
+
return doc
|
|
473
|
+
# a softer fallback inside MDI only (still 'strict' to MDI)
|
|
474
|
+
subs = getattr(mw.mdi, "subWindowList", lambda: [])()
|
|
475
|
+
if subs:
|
|
476
|
+
# top of stacking order is usually most recent active
|
|
477
|
+
w = subs[0].widget()
|
|
478
|
+
doc = getattr(w, "document", None)
|
|
479
|
+
if isinstance(doc, ImageDocument):
|
|
480
|
+
return doc
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
if strict:
|
|
485
|
+
return None # ⬅️ don’t wander to “last-created” when strict
|
|
486
|
+
|
|
487
|
+
# 2) Non-strict fallback: last opened doc in DocManager (as before)
|
|
488
|
+
dm = self.doc_manager
|
|
489
|
+
try:
|
|
490
|
+
docs = getattr(dm, "_docs", None)
|
|
491
|
+
if docs and len(docs) > 0 and isinstance(docs[-1], ImageDocument):
|
|
492
|
+
return docs[-1]
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _use_doc_image_as(self, target: str):
|
|
499
|
+
"""
|
|
500
|
+
Load image from *another* open view and assign to HF/LF.
|
|
501
|
+
target: 'HF' or 'LF'
|
|
502
|
+
"""
|
|
503
|
+
doc = self._get_active_document()
|
|
504
|
+
if doc is None or doc.image is None:
|
|
505
|
+
QMessageBox.warning(self, "From View", "No active view found with an image.")
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
# If this dialog was opened for an active document, it might be the same doc.
|
|
509
|
+
# That's ok—user can still use its image as HF/LF if they want.
|
|
510
|
+
|
|
511
|
+
ref = self._ref_shape() # shape we want to match (base image or available HF/LF)
|
|
512
|
+
try:
|
|
513
|
+
imgc = self._coerce_to_ref(np.asarray(doc.image), ref)
|
|
514
|
+
except Exception as e:
|
|
515
|
+
QMessageBox.critical(self, "From View", f"Shape/channel mismatch:\n{e}")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
if self.image is None and self.low_freq_image is None and self.high_freq_image is None:
|
|
519
|
+
# adopt this as the reference image (so future loads coerce to this)
|
|
520
|
+
self.set_image_from_doc(imgc, getattr(doc, "metadata", {}))
|
|
521
|
+
|
|
522
|
+
if target == "HF":
|
|
523
|
+
self.high_freq_image = imgc.astype(np.float32, copy=False)
|
|
524
|
+
self.labelHF.setText(f"HF ← {doc.display_name()}")
|
|
525
|
+
else:
|
|
526
|
+
self.low_freq_image = imgc.astype(np.float32, copy=False)
|
|
527
|
+
self.labelLF.setText(f"LF ← {doc.display_name()}")
|
|
528
|
+
|
|
529
|
+
self._update_previews()
|
|
530
|
+
|
|
531
|
+
def _load_hf_from_view(self):
|
|
532
|
+
self._use_doc_image_as("HF")
|
|
533
|
+
|
|
534
|
+
def _load_lf_from_view(self):
|
|
535
|
+
self._use_doc_image_as("LF")
|
|
536
|
+
|
|
537
|
+
def _collect_open_documents(self) -> list[tuple[str, object]]:
|
|
538
|
+
"""
|
|
539
|
+
Returns [(display_name, ImageDocument), ...] for all known open docs.
|
|
540
|
+
Tries DocManager first; falls back to scanning MDI subwindows.
|
|
541
|
+
Active view (if found) is placed first.
|
|
542
|
+
"""
|
|
543
|
+
items: list[tuple[str, object]] = []
|
|
544
|
+
active_doc = None
|
|
545
|
+
|
|
546
|
+
# Try to get active from main window
|
|
547
|
+
mw = self._find_main_window()
|
|
548
|
+
if mw and hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
|
|
549
|
+
try:
|
|
550
|
+
active_widget = mw.mdi.activeSubWindow().widget()
|
|
551
|
+
active_doc = getattr(active_widget, "document", None)
|
|
552
|
+
except Exception:
|
|
553
|
+
active_doc = None
|
|
554
|
+
|
|
555
|
+
# Prefer DocManager list
|
|
556
|
+
dm = self.doc_manager
|
|
557
|
+
docs = []
|
|
558
|
+
if dm is not None:
|
|
559
|
+
for attr in ("documents", "all_documents", "_docs"):
|
|
560
|
+
d = getattr(dm, attr, None)
|
|
561
|
+
if d:
|
|
562
|
+
docs = list(d)
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
# If no doc list, scan subwindows
|
|
566
|
+
if not docs and mw is not None:
|
|
567
|
+
try:
|
|
568
|
+
from setiastro.saspro.subwindow import ImageSubWindow
|
|
569
|
+
subs = mw.findChildren(ImageSubWindow)
|
|
570
|
+
for s in subs:
|
|
571
|
+
doc = getattr(s, "document", None)
|
|
572
|
+
if doc:
|
|
573
|
+
docs.append(doc)
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
# Build names
|
|
578
|
+
def _name_for(doc):
|
|
579
|
+
name = None
|
|
580
|
+
# ImageDocument has display_name(); metadata may have display_name/file_path
|
|
581
|
+
for cand in ("display_name",):
|
|
582
|
+
if hasattr(doc, cand) and callable(getattr(doc, cand)):
|
|
583
|
+
try:
|
|
584
|
+
name = getattr(doc, cand)()
|
|
585
|
+
except Exception:
|
|
586
|
+
name = None
|
|
587
|
+
if not name:
|
|
588
|
+
md = getattr(doc, "metadata", {}) or {}
|
|
589
|
+
name = md.get("display_name") or md.get("file_path") or "Untitled"
|
|
590
|
+
import os
|
|
591
|
+
if isinstance(name, str):
|
|
592
|
+
name = os.path.basename(name)
|
|
593
|
+
return name
|
|
594
|
+
|
|
595
|
+
# Put active first
|
|
596
|
+
if active_doc and active_doc in docs:
|
|
597
|
+
items.append((f"★ { _name_for(active_doc) } (active)", active_doc))
|
|
598
|
+
docs = [d for d in docs if d is not active_doc]
|
|
599
|
+
|
|
600
|
+
for d in docs:
|
|
601
|
+
items.append((_name_for(d), d))
|
|
602
|
+
|
|
603
|
+
return items
|
|
604
|
+
|
|
605
|
+
def _select_document_via_dropdown(self, which: str | None = None) -> object | None:
|
|
606
|
+
items = self._collect_open_documents()
|
|
607
|
+
if not items:
|
|
608
|
+
QMessageBox.information(self, f"Select View for {which or ''}".strip(),
|
|
609
|
+
"No open views/documents found.")
|
|
610
|
+
return None
|
|
611
|
+
dlg = SelectViewDialog(self, items, which=which)
|
|
612
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
613
|
+
return dlg.selected_doc()
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _image_from_doc(self, doc) -> np.ndarray | None:
|
|
618
|
+
"""
|
|
619
|
+
Extract float32 image from an ImageDocument-like object.
|
|
620
|
+
For integer sources, scale into [0..1] using width-based heuristic.
|
|
621
|
+
"""
|
|
622
|
+
try:
|
|
623
|
+
img = getattr(doc, "image", None)
|
|
624
|
+
if img is None:
|
|
625
|
+
return None
|
|
626
|
+
arr = np.asarray(img)
|
|
627
|
+
if arr.dtype == np.float32:
|
|
628
|
+
return arr
|
|
629
|
+
if np.issubdtype(arr.dtype, np.floating):
|
|
630
|
+
return arr.astype(np.float32, copy=False)
|
|
631
|
+
# Integer → normalize to [0..1]
|
|
632
|
+
scale = 65535.0 if (arr.dtype.itemsize >= 2) else 255.0
|
|
633
|
+
return (arr.astype(np.float32) / scale)
|
|
634
|
+
except Exception as e:
|
|
635
|
+
QMessageBox.warning(self, "Load from View", f"Could not read image from view:\n{e}")
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
def _load_component_from_view(self, which: str):
|
|
639
|
+
"""
|
|
640
|
+
which ∈ {"HF", "LF"}
|
|
641
|
+
"""
|
|
642
|
+
doc = self._select_document_via_dropdown(which)
|
|
643
|
+
if not doc:
|
|
644
|
+
return
|
|
645
|
+
arr = self._image_from_doc(doc)
|
|
646
|
+
if arr is None:
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
# Assign and update preview
|
|
650
|
+
if which.upper() == "HF":
|
|
651
|
+
self.high_freq_image = arr.astype(np.float32, copy=False)
|
|
652
|
+
else:
|
|
653
|
+
self.low_freq_image = arr.astype(np.float32, copy=False)
|
|
654
|
+
|
|
655
|
+
# Warn on dimensional mismatch (combine needs same shape)
|
|
656
|
+
if (self.low_freq_image is not None and self.high_freq_image is not None and
|
|
657
|
+
self.low_freq_image.shape != self.high_freq_image.shape):
|
|
658
|
+
QMessageBox.warning(
|
|
659
|
+
self, "Dimension Mismatch",
|
|
660
|
+
"Loaded image dimensions do not match the other component.\n"
|
|
661
|
+
"You can still view/edit, but Combine requires matching sizes."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
self._update_previews()
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _ref_shape(self):
|
|
668
|
+
"""
|
|
669
|
+
Returns a reference shape to coerce incoming HF/LF to:
|
|
670
|
+
- Prefer the main image's shape
|
|
671
|
+
- Else prefer whichever of LF/HF exists
|
|
672
|
+
- Else None (no constraint yet)
|
|
673
|
+
"""
|
|
674
|
+
if isinstance(self.image, np.ndarray):
|
|
675
|
+
return self.image.shape
|
|
676
|
+
if isinstance(self.low_freq_image, np.ndarray):
|
|
677
|
+
return self.low_freq_image.shape
|
|
678
|
+
if isinstance(self.high_freq_image, np.ndarray):
|
|
679
|
+
return self.high_freq_image.shape
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
def _coerce_to_ref(self, arr: np.ndarray, ref_shape: tuple[int, ...] | None) -> np.ndarray:
|
|
683
|
+
"""
|
|
684
|
+
Try to coerce 'arr' to match ref_shape where possible:
|
|
685
|
+
- If ref is HxW and arr is HxW x3 → convert to mono (mean)
|
|
686
|
+
- If ref is HxW x3 and arr is HxW → tile to 3 channels
|
|
687
|
+
- H/W must match; no resize is performed (we error if they differ)
|
|
688
|
+
"""
|
|
689
|
+
a = np.asarray(arr, dtype=np.float32)
|
|
690
|
+
|
|
691
|
+
if ref_shape is None:
|
|
692
|
+
return a # nothing to coerce against yet
|
|
693
|
+
|
|
694
|
+
# spatial guard
|
|
695
|
+
if a.ndim == 2:
|
|
696
|
+
ah, aw = a.shape
|
|
697
|
+
ch = 1
|
|
698
|
+
elif a.ndim == 3 and a.shape[2] in (1, 3):
|
|
699
|
+
ah, aw, ch = a.shape[0], a.shape[1], a.shape[2]
|
|
700
|
+
else:
|
|
701
|
+
raise ValueError("Unsupported array shape for HF/LF (expect HxW or HxWx{1,3}).")
|
|
702
|
+
|
|
703
|
+
if len(ref_shape) == 2:
|
|
704
|
+
rh, rw = ref_shape
|
|
705
|
+
rch = 1
|
|
706
|
+
else:
|
|
707
|
+
rh, rw, rch = ref_shape
|
|
708
|
+
|
|
709
|
+
if (ah != rh) or (aw != rw):
|
|
710
|
+
raise ValueError(f"Image dimensions {ah}x{aw} do not match reference {rh}x{rw}.")
|
|
711
|
+
|
|
712
|
+
# channel reconcile
|
|
713
|
+
if rch == 1 and ch == 3:
|
|
714
|
+
# convert RGB→mono (use weighted luma for consistency, or mean if desired. Original was mean)
|
|
715
|
+
a = a.mean(axis=2).astype(np.float32)
|
|
716
|
+
elif rch == 3 and ch == 1:
|
|
717
|
+
# Broadcast mono to 3 channels without copying
|
|
718
|
+
# (H,W,1) -> (H,W,3) via broadcasted view if consumer allows,
|
|
719
|
+
# but usually downstream (like subtraction) handles broadcasting fine.
|
|
720
|
+
# If explicit physical layout is needed, we must check usage.
|
|
721
|
+
# Here: used for subtraction (OK) and preview (OK).
|
|
722
|
+
# We return a view using broadcast_to or striding tricks.
|
|
723
|
+
a = np.broadcast_to(a, (ah, aw, 3))
|
|
724
|
+
|
|
725
|
+
return a
|
|
726
|
+
|
|
727
|
+
def _load_hf_from_file(self):
|
|
728
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
729
|
+
self, "Load High-Frequency Image", "",
|
|
730
|
+
"Images (*.tif *.tiff *.fits *.fit *.png *.xisf);;All Files (*)"
|
|
731
|
+
)
|
|
732
|
+
if not path:
|
|
733
|
+
return
|
|
734
|
+
try:
|
|
735
|
+
img, _, _, _ = legacy_load_image(path)
|
|
736
|
+
if img is None:
|
|
737
|
+
raise RuntimeError("Could not load image.")
|
|
738
|
+
ref = self._ref_shape()
|
|
739
|
+
imgc = self._coerce_to_ref(img, ref)
|
|
740
|
+
self.high_freq_image = imgc.astype(np.float32, copy=False)
|
|
741
|
+
self._update_previews()
|
|
742
|
+
QMessageBox.information(self, "HF Loaded", os.path.basename(path))
|
|
743
|
+
except Exception as e:
|
|
744
|
+
QMessageBox.critical(self, "Load HF", f"Failed to load HF:\n{e}")
|
|
745
|
+
|
|
746
|
+
def _load_lf_from_file(self):
|
|
747
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
748
|
+
self, "Load Low-Frequency Image", "",
|
|
749
|
+
"Images (*.tif *.tiff *.fits *.fit *.png *.xisf);;All Files (*)"
|
|
750
|
+
)
|
|
751
|
+
if not path:
|
|
752
|
+
return
|
|
753
|
+
try:
|
|
754
|
+
img, _, _, _ = legacy_load_image(path)
|
|
755
|
+
if img is None:
|
|
756
|
+
raise RuntimeError("Could not load image.")
|
|
757
|
+
ref = self._ref_shape()
|
|
758
|
+
imgc = self._coerce_to_ref(img, ref)
|
|
759
|
+
self.low_freq_image = imgc.astype(np.float32, copy=False)
|
|
760
|
+
self._update_previews()
|
|
761
|
+
QMessageBox.information(self, "LF Loaded", os.path.basename(path))
|
|
762
|
+
except Exception as e:
|
|
763
|
+
QMessageBox.critical(self, "Load LF", f"Failed to load LF:\n{e}")
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# --- NEW: autoload exactly once when the dialog shows ---
|
|
767
|
+
def showEvent(self, e):
|
|
768
|
+
super().showEvent(e)
|
|
769
|
+
if not self._auto_loaded:
|
|
770
|
+
self._auto_loaded = True
|
|
771
|
+
# Strong preference order:
|
|
772
|
+
# (1) self.doc (injected at construction time)
|
|
773
|
+
# (2) active MDI doc (strict — no "last-created" fallback)
|
|
774
|
+
src_doc = self.doc or self._get_active_document(strict=True)
|
|
775
|
+
if src_doc is not None and getattr(src_doc, "image", None) is not None:
|
|
776
|
+
try:
|
|
777
|
+
self.set_image_from_doc(np.asarray(src_doc.image),
|
|
778
|
+
getattr(src_doc, "metadata", {}))
|
|
779
|
+
return
|
|
780
|
+
except Exception:
|
|
781
|
+
pass
|
|
782
|
+
|
|
783
|
+
# --------------- helpers ---------------
|
|
784
|
+
def _toggle_tol_enabled(self, on: bool):
|
|
785
|
+
self.tol_slider.setEnabled(on)
|
|
786
|
+
self.tol_label.setEnabled(on)
|
|
787
|
+
|
|
788
|
+
def _map_slider_to_radius(self, pos: int) -> float:
|
|
789
|
+
if pos <= 10:
|
|
790
|
+
t = pos / 10.0
|
|
791
|
+
return 0.1 + t * (1.0 - 0.1)
|
|
792
|
+
elif pos <= 50:
|
|
793
|
+
t = (pos - 10) / 40.0
|
|
794
|
+
return 1.0 + t * (10.0 - 1.0)
|
|
795
|
+
else:
|
|
796
|
+
t = (pos - 50) / 50.0
|
|
797
|
+
return 10.0 + t * (100.0 - 10.0)
|
|
798
|
+
|
|
799
|
+
def _update_scale(self, v: int):
|
|
800
|
+
self.sharpen_scale = v / 100.0
|
|
801
|
+
self.scale_label.setText(f"Sharpen Scale: {self.sharpen_scale:.2f}")
|
|
802
|
+
|
|
803
|
+
def _update_wavelet_level(self, v: int):
|
|
804
|
+
self.wavelet_level = int(v)
|
|
805
|
+
self.wavelet_level_label.setText(f"Wavelet Level: {self.wavelet_level}")
|
|
806
|
+
|
|
807
|
+
def _update_wavelet_boost(self, v: int):
|
|
808
|
+
self.wavelet_boost = v / 100.0
|
|
809
|
+
self.wavelet_boost_label.setText(f"Wavelet Boost: {self.wavelet_boost:.2f}")
|
|
810
|
+
|
|
811
|
+
def _update_denoise(self, v: int):
|
|
812
|
+
self.denoise_strength = v / 10.0
|
|
813
|
+
self.denoise_label.setText(f"Denoise Strength: {self.denoise_strength:.2f}")
|
|
814
|
+
|
|
815
|
+
# --------------- image I/O hooks ---------------
|
|
816
|
+
def set_image_from_doc(self, image: np.ndarray, metadata: dict | None):
|
|
817
|
+
"""Call this from the main app when there’s an active image; or adapt to your ImageManager signal."""
|
|
818
|
+
if image is None:
|
|
819
|
+
return
|
|
820
|
+
self.image = image.astype(np.float32, copy=False)
|
|
821
|
+
md = metadata or {}
|
|
822
|
+
self.filename = md.get("file_path", None)
|
|
823
|
+
self.original_header = md.get("original_header", None)
|
|
824
|
+
self.is_mono = bool(md.get("is_mono", False))
|
|
825
|
+
self.fileLabel.setText(os.path.basename(self.filename) if self.filename else "(from view)")
|
|
826
|
+
# clear outputs
|
|
827
|
+
self.low_freq_image = None
|
|
828
|
+
self.high_freq_image = None
|
|
829
|
+
self._apply_separation()
|
|
830
|
+
|
|
831
|
+
# --------------- controls handlers ---------------
|
|
832
|
+
def _on_method_changed(self, text: str):
|
|
833
|
+
self.method = text
|
|
834
|
+
self._toggle_tol_enabled(self.method == 'Bilateral')
|
|
835
|
+
|
|
836
|
+
def _on_radius_changed(self, v: int):
|
|
837
|
+
self.radius = self._map_slider_to_radius(v)
|
|
838
|
+
self.radius_label.setText(f"Radius: {self.radius:.2f}")
|
|
839
|
+
|
|
840
|
+
def _on_tol_changed(self, v: int):
|
|
841
|
+
self.tolerance = int(v)
|
|
842
|
+
self.tol_label.setText(f"Tolerance: {self.tolerance}%")
|
|
843
|
+
|
|
844
|
+
# --------------- processing ---------------
|
|
845
|
+
def _apply_separation(self):
|
|
846
|
+
if self.image is None:
|
|
847
|
+
QMessageBox.warning(self, "No Image", "Load or select an image first.")
|
|
848
|
+
return
|
|
849
|
+
self._show_spinner(True)
|
|
850
|
+
|
|
851
|
+
if self.proc_thread and self.proc_thread.isRunning():
|
|
852
|
+
self.proc_thread.quit(); self.proc_thread.wait()
|
|
853
|
+
|
|
854
|
+
self.proc_thread = FrequencySeperationThread(
|
|
855
|
+
image=self.image, method=self.method, radius=self.radius, tolerance=self.tolerance
|
|
856
|
+
)
|
|
857
|
+
self.proc_thread.separation_done.connect(self._on_sep_done)
|
|
858
|
+
self.proc_thread.error_signal.connect(self._on_sep_error)
|
|
859
|
+
self.proc_thread.start()
|
|
860
|
+
|
|
861
|
+
def _on_sep_done(self, lf: np.ndarray, hf: np.ndarray):
|
|
862
|
+
self._show_spinner(False)
|
|
863
|
+
self.low_freq_image = lf.astype(np.float32)
|
|
864
|
+
self.high_freq_image = hf.astype(np.float32)
|
|
865
|
+
self._update_previews()
|
|
866
|
+
|
|
867
|
+
def _on_sep_error(self, msg: str):
|
|
868
|
+
self._show_spinner(False)
|
|
869
|
+
QMessageBox.critical(self, "Frequency Separation", msg)
|
|
870
|
+
|
|
871
|
+
def _apply_hf_enhancements(self):
|
|
872
|
+
if self.high_freq_image is None:
|
|
873
|
+
QMessageBox.information(self, "HF", "No HF image to enhance.")
|
|
874
|
+
return
|
|
875
|
+
# history for undo
|
|
876
|
+
self._hf_history.append(self.high_freq_image.copy())
|
|
877
|
+
self.btn_undo_hf.setEnabled(True)
|
|
878
|
+
|
|
879
|
+
self._show_spinner(True)
|
|
880
|
+
if self.hf_thread and self.hf_thread.isRunning():
|
|
881
|
+
self.hf_thread.quit(); self.hf_thread.wait()
|
|
882
|
+
|
|
883
|
+
self.hf_thread = HFEnhancementThread(
|
|
884
|
+
hf_image=self.high_freq_image,
|
|
885
|
+
enable_scale=self.cb_scale.isChecked(),
|
|
886
|
+
sharpen_scale=self.sharpen_scale,
|
|
887
|
+
enable_wavelet=self.cb_wavelet.isChecked(),
|
|
888
|
+
wavelet_level=self.wavelet_level,
|
|
889
|
+
wavelet_boost=self.wavelet_boost,
|
|
890
|
+
enable_denoise=self.cb_denoise.isChecked(),
|
|
891
|
+
denoise_strength=self.denoise_strength
|
|
892
|
+
)
|
|
893
|
+
self.hf_thread.enhancement_done.connect(self._on_hf_done)
|
|
894
|
+
self.hf_thread.error_signal.connect(self._on_hf_error)
|
|
895
|
+
self.hf_thread.start()
|
|
896
|
+
|
|
897
|
+
def _on_hf_done(self, new_hf: np.ndarray):
|
|
898
|
+
self._show_spinner(False)
|
|
899
|
+
self.high_freq_image = new_hf.astype(np.float32)
|
|
900
|
+
self._update_previews()
|
|
901
|
+
|
|
902
|
+
def _on_hf_error(self, msg: str):
|
|
903
|
+
self._show_spinner(False)
|
|
904
|
+
QMessageBox.critical(self, "HF Enhancements", msg)
|
|
905
|
+
|
|
906
|
+
def _undo_hf(self):
|
|
907
|
+
if not self._hf_history:
|
|
908
|
+
return
|
|
909
|
+
self.high_freq_image = self._hf_history.pop()
|
|
910
|
+
self.btn_undo_hf.setEnabled(bool(self._hf_history))
|
|
911
|
+
self._update_previews()
|
|
912
|
+
|
|
913
|
+
# --------------- spinner ---------------
|
|
914
|
+
def _show_spinner(self, on: bool):
|
|
915
|
+
if on:
|
|
916
|
+
self.spinnerLabel.show()
|
|
917
|
+
if self._spinner: self._spinner.start()
|
|
918
|
+
else:
|
|
919
|
+
self.spinnerLabel.hide()
|
|
920
|
+
if self._spinner: self._spinner.stop()
|
|
921
|
+
|
|
922
|
+
# --------------- preview rendering ---------------
|
|
923
|
+
def _numpy_to_qpix(self, arr: np.ndarray) -> QPixmap:
|
|
924
|
+
a = np.clip(arr, 0, 1)
|
|
925
|
+
if a.ndim == 2:
|
|
926
|
+
a = np.stack([a]*3, axis=-1)
|
|
927
|
+
u8 = (a * 255).astype(np.uint8)
|
|
928
|
+
h, w, ch = u8.shape
|
|
929
|
+
qimg = QImage(u8.data, w, h, w*ch, QImage.Format.Format_RGB888)
|
|
930
|
+
return QPixmap.fromImage(qimg.copy())
|
|
931
|
+
|
|
932
|
+
def _update_previews(self):
|
|
933
|
+
# LF
|
|
934
|
+
if self.low_freq_image is not None:
|
|
935
|
+
pm = self._numpy_to_qpix(self.low_freq_image)
|
|
936
|
+
scaled = pm.scaled(pm.size() * self.zoom_factor,
|
|
937
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
938
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
939
|
+
self.labelLF.setPixmap(scaled)
|
|
940
|
+
self.labelLF.resize(scaled.size())
|
|
941
|
+
else:
|
|
942
|
+
self.labelLF.setText("Low Frequency"); self.labelLF.resize(self.labelLF.sizeHint())
|
|
943
|
+
|
|
944
|
+
# HF (offset +0.5 for view)
|
|
945
|
+
if self.high_freq_image is not None:
|
|
946
|
+
disp = np.clip(self.high_freq_image + 0.5, 0, 1)
|
|
947
|
+
pm = self._numpy_to_qpix(disp)
|
|
948
|
+
scaled = pm.scaled(pm.size() * self.zoom_factor,
|
|
949
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
950
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
951
|
+
self.labelHF.setPixmap(scaled)
|
|
952
|
+
self.labelHF.resize(scaled.size())
|
|
953
|
+
else:
|
|
954
|
+
self.labelHF.setText("High Frequency"); self.labelHF.resize(self.labelHF.sizeHint())
|
|
955
|
+
|
|
956
|
+
# center if smaller than viewport
|
|
957
|
+
QTimer.singleShot(0, self._center_if_fit)
|
|
958
|
+
|
|
959
|
+
def _center_if_fit(self):
|
|
960
|
+
for sc, lbl in ((self.scrollHF, self.labelHF), (self.scrollLF, self.labelLF)):
|
|
961
|
+
if lbl.width() <= sc.viewport().width():
|
|
962
|
+
sc.horizontalScrollBar().setValue((sc.horizontalScrollBar().maximum() + sc.horizontalScrollBar().minimum()) // 2)
|
|
963
|
+
if lbl.height() <= sc.viewport().height():
|
|
964
|
+
sc.verticalScrollBar().setValue((sc.verticalScrollBar().maximum() + sc.verticalScrollBar().minimum()) // 2)
|
|
965
|
+
|
|
966
|
+
# --------------- zoom/pan (dual scrollareas) ---------------
|
|
967
|
+
def _zoom_at_pair(self, factor: float, anchor_hf_vp: QPoint | None = None, anchor_lf_vp: QPoint | None = None):
|
|
968
|
+
if self.low_freq_image is None and self.high_freq_image is None:
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
old = self.zoom_factor
|
|
972
|
+
new = max(0.05, min(8.0, old * factor))
|
|
973
|
+
ratio = new / max(1e-6, old)
|
|
974
|
+
|
|
975
|
+
def _center(sc):
|
|
976
|
+
vp = sc.viewport()
|
|
977
|
+
return QPoint(vp.width() // 2, vp.height() // 2)
|
|
978
|
+
|
|
979
|
+
if anchor_hf_vp is None: anchor_hf_vp = _center(self.scrollHF)
|
|
980
|
+
if anchor_lf_vp is None: anchor_lf_vp = _center(self.scrollLF)
|
|
981
|
+
|
|
982
|
+
HFh, HFv = self.scrollHF.horizontalScrollBar(), self.scrollHF.verticalScrollBar()
|
|
983
|
+
LFh, LFv = self.scrollLF.horizontalScrollBar(), self.scrollLF.verticalScrollBar()
|
|
984
|
+
hf_cx = HFh.value() + anchor_hf_vp.x()
|
|
985
|
+
hf_cy = HFv.value() + anchor_hf_vp.y()
|
|
986
|
+
lf_cx = LFh.value() + anchor_lf_vp.x()
|
|
987
|
+
lf_cy = LFv.value() + anchor_lf_vp.y()
|
|
988
|
+
|
|
989
|
+
self.zoom_factor = new
|
|
990
|
+
self._update_previews() # updates label sizes & scrollbar ranges
|
|
991
|
+
|
|
992
|
+
def _restore(sc_area, anchor, cx, cy, lbl):
|
|
993
|
+
hbar, vbar = sc_area.horizontalScrollBar(), sc_area.verticalScrollBar()
|
|
994
|
+
vp = sc_area.viewport()
|
|
995
|
+
if lbl.width() <= vp.width():
|
|
996
|
+
hbar.setValue((hbar.maximum() + hbar.minimum()) // 2)
|
|
997
|
+
else:
|
|
998
|
+
hbar.setValue(int(cx * ratio - anchor.x()))
|
|
999
|
+
if lbl.height() <= vp.height():
|
|
1000
|
+
vbar.setValue((vbar.maximum() + vbar.minimum()) // 2)
|
|
1001
|
+
else:
|
|
1002
|
+
vbar.setValue(int(cy * ratio - anchor.y()))
|
|
1003
|
+
|
|
1004
|
+
_restore(self.scrollHF, anchor_hf_vp, hf_cx, hf_cy, self.labelHF)
|
|
1005
|
+
_restore(self.scrollLF, anchor_lf_vp, lf_cx, lf_cy, self.labelLF)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _fit_to_preview(self):
|
|
1009
|
+
# Fit width to the *smaller* of the two viewports; use LF size if available, else HF
|
|
1010
|
+
if self.image is None:
|
|
1011
|
+
return
|
|
1012
|
+
base_h, base_w = (self.low_freq_image.shape[:2]
|
|
1013
|
+
if self.low_freq_image is not None else
|
|
1014
|
+
(self.high_freq_image.shape[:2] if self.high_freq_image is not None else (None, None)))
|
|
1015
|
+
if base_w is None:
|
|
1016
|
+
return
|
|
1017
|
+
vpw = min(self.scrollHF.viewport().width(), self.scrollLF.viewport().width())
|
|
1018
|
+
self.zoom_factor = max(0.05, min(8.0, vpw / float(base_w)))
|
|
1019
|
+
self._update_previews()
|
|
1020
|
+
|
|
1021
|
+
# --------------- pushing to new views ---------------
|
|
1022
|
+
def _push_array(self, arr: np.ndarray | None, title: str):
|
|
1023
|
+
if arr is None:
|
|
1024
|
+
QMessageBox.information(self, "Push", f"No {title} image to push.")
|
|
1025
|
+
return
|
|
1026
|
+
mw = self._find_main_window()
|
|
1027
|
+
dm = getattr(mw, "docman", None) or self.doc_manager
|
|
1028
|
+
if not dm:
|
|
1029
|
+
QMessageBox.critical(self, "UI", "DocManager not available.")
|
|
1030
|
+
return
|
|
1031
|
+
try:
|
|
1032
|
+
if hasattr(dm, "open_array"):
|
|
1033
|
+
doc = dm.open_array(arr, metadata={"is_mono": (arr.ndim == 2)}, title=title)
|
|
1034
|
+
elif hasattr(dm, "create_document"):
|
|
1035
|
+
doc = dm.create_document(image=arr, metadata={"is_mono": (arr.ndim == 2)}, name=title)
|
|
1036
|
+
else:
|
|
1037
|
+
raise RuntimeError("DocManager lacks open_array/create_document")
|
|
1038
|
+
if hasattr(mw, "_spawn_subwindow_for"):
|
|
1039
|
+
mw._spawn_subwindow_for(doc)
|
|
1040
|
+
else:
|
|
1041
|
+
from setiastro.saspro.subwindow import ImageSubWindow
|
|
1042
|
+
sw = ImageSubWindow(doc, parent=mw); sw.setWindowTitle(title); sw.show()
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
QMessageBox.critical(self, "Push", f"Failed to open new view:\n{e}")
|
|
1045
|
+
|
|
1046
|
+
def _push_to_active(self, img: np.ndarray, step_name: str, extra_md: dict | None = None):
|
|
1047
|
+
dm = self.doc_manager
|
|
1048
|
+
if dm is None:
|
|
1049
|
+
# try to discover from main window just in case
|
|
1050
|
+
mw = self.parent() or self.window()
|
|
1051
|
+
dm = getattr(mw, "docman", None)
|
|
1052
|
+
if dm is None:
|
|
1053
|
+
QMessageBox.critical(self, "Error", "DocManager not available; cannot apply to active view.")
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
# build metadata (keep what we know so history/exports are consistent)
|
|
1057
|
+
md = dict(extra_md or {})
|
|
1058
|
+
md.setdefault("original_header", getattr(self, "original_header", None))
|
|
1059
|
+
md.setdefault("is_mono", (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1))
|
|
1060
|
+
md.setdefault("bit_depth", "32-bit floating point") # HF/LF math is float32 in this tool
|
|
1061
|
+
|
|
1062
|
+
# try a few common method names so this works with your DocManager
|
|
1063
|
+
try:
|
|
1064
|
+
if hasattr(dm, "update_active_document"):
|
|
1065
|
+
dm.update_active_document(updated_image=img, metadata=md, step_name=step_name)
|
|
1066
|
+
elif hasattr(dm, "update_image"):
|
|
1067
|
+
dm.update_image(updated_image=img, metadata=md, step_name=step_name)
|
|
1068
|
+
elif hasattr(dm, "set_image"):
|
|
1069
|
+
# older API; many builds accept step_name here too
|
|
1070
|
+
dm.set_image(img, md, step_name=step_name)
|
|
1071
|
+
elif hasattr(dm, "apply_edit_to_active"):
|
|
1072
|
+
dm.apply_edit_to_active(img, step_name=step_name, metadata=md)
|
|
1073
|
+
else:
|
|
1074
|
+
raise RuntimeError("DocManager has no known update method")
|
|
1075
|
+
except Exception as e:
|
|
1076
|
+
QMessageBox.critical(self, "Apply Failed", f"Could not apply result to the active view:\n{e}")
|
|
1077
|
+
return
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _hf_display_for_push(self) -> np.ndarray | None:
|
|
1081
|
+
# push the true HF (signed), but clamp for safety into viewable range around 0
|
|
1082
|
+
if self.high_freq_image is None:
|
|
1083
|
+
return None
|
|
1084
|
+
# keep signed HF; app stack supports float32 arrays
|
|
1085
|
+
return self.high_freq_image.astype(np.float32, copy=False)
|
|
1086
|
+
|
|
1087
|
+
def _combine_and_push(self):
|
|
1088
|
+
if self.low_freq_image is None or self.high_freq_image is None:
|
|
1089
|
+
QMessageBox.information(self, "Combine", "LF or HF missing.")
|
|
1090
|
+
return
|
|
1091
|
+
|
|
1092
|
+
combined = np.clip(self.low_freq_image + self.high_freq_image, 0.0, 1.0).astype(np.float32)
|
|
1093
|
+
step_name = "Frequency Separation (Combine HF+LF)"
|
|
1094
|
+
|
|
1095
|
+
# ✅ Blend with active mask (if any)
|
|
1096
|
+
blended, mid, mname, masked = self._blend_with_active_mask(combined)
|
|
1097
|
+
|
|
1098
|
+
# Build metadata
|
|
1099
|
+
md = {
|
|
1100
|
+
"bit_depth": "32-bit floating point",
|
|
1101
|
+
"is_mono": (blended.ndim == 2) or (blended.ndim == 3 and blended.shape[2] == 1),
|
|
1102
|
+
"original_header": getattr(self, "original_header", None),
|
|
1103
|
+
}
|
|
1104
|
+
if masked:
|
|
1105
|
+
md.update({
|
|
1106
|
+
"masked": True,
|
|
1107
|
+
"mask_id": mid,
|
|
1108
|
+
"mask_name": mname,
|
|
1109
|
+
"mask_blend": "m*out + (1-m)*src",
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
# Prefer applying to the injected ImageDocument
|
|
1113
|
+
if isinstance(self.doc, ImageDocument):
|
|
1114
|
+
try:
|
|
1115
|
+
self.doc.apply_edit(blended, metadata=md, step_name=step_name)
|
|
1116
|
+
except Exception as e:
|
|
1117
|
+
QMessageBox.critical(self, "Apply Failed", f"Could not apply to active document:\n{e}")
|
|
1118
|
+
return
|
|
1119
|
+
|
|
1120
|
+
# Fallback: push to active via DocManager (still pre-blended)
|
|
1121
|
+
self._push_to_active(blended, step_name, extra_md=md)
|
|
1122
|
+
|
|
1123
|
+
# --------------- event filter (wheel + drag pan + sync) ---------------
|
|
1124
|
+
def eventFilter(self, obj, ev):
|
|
1125
|
+
# -------- Ctrl+Wheel Zoom (safe) --------
|
|
1126
|
+
if ev.type() == QEvent.Type.Wheel:
|
|
1127
|
+
targets = {self.scrollHF.viewport(), self.labelHF,
|
|
1128
|
+
self.scrollLF.viewport(), self.labelLF}
|
|
1129
|
+
if obj in targets:
|
|
1130
|
+
# Only zoom when Ctrl is held; otherwise let normal scrolling work
|
|
1131
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
1132
|
+
try:
|
|
1133
|
+
dy = ev.pixelDelta().y()
|
|
1134
|
+
if dy == 0:
|
|
1135
|
+
dy = ev.angleDelta().y()
|
|
1136
|
+
factor = 1.25 if dy > 0 else 0.8
|
|
1137
|
+
|
|
1138
|
+
# Anchor positions (robust mapping child→viewport)
|
|
1139
|
+
if obj is self.labelHF:
|
|
1140
|
+
anchor_hf = self.labelHF.mapTo(
|
|
1141
|
+
self.scrollHF.viewport(), ev.position().toPoint()
|
|
1142
|
+
)
|
|
1143
|
+
anchor_lf = QPoint(
|
|
1144
|
+
self.scrollLF.viewport().width() // 2,
|
|
1145
|
+
self.scrollLF.viewport().height() // 2
|
|
1146
|
+
)
|
|
1147
|
+
elif obj is self.scrollHF.viewport():
|
|
1148
|
+
anchor_hf = ev.position().toPoint()
|
|
1149
|
+
anchor_lf = QPoint(
|
|
1150
|
+
self.scrollLF.viewport().width() // 2,
|
|
1151
|
+
self.scrollLF.viewport().height() // 2
|
|
1152
|
+
)
|
|
1153
|
+
elif obj is self.labelLF:
|
|
1154
|
+
anchor_lf = self.labelLF.mapTo(
|
|
1155
|
+
self.scrollLF.viewport(), ev.position().toPoint()
|
|
1156
|
+
)
|
|
1157
|
+
anchor_hf = QPoint(
|
|
1158
|
+
self.scrollHF.viewport().width() // 2,
|
|
1159
|
+
self.scrollHF.viewport().height() // 2
|
|
1160
|
+
)
|
|
1161
|
+
else: # obj is self.scrollLF.viewport()
|
|
1162
|
+
anchor_lf = ev.position().toPoint()
|
|
1163
|
+
anchor_hf = QPoint(
|
|
1164
|
+
self.scrollHF.viewport().width() // 2,
|
|
1165
|
+
self.scrollHF.viewport().height() // 2
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
self._zoom_at_pair(factor, anchor_hf, anchor_lf)
|
|
1169
|
+
except Exception:
|
|
1170
|
+
# If anything goes weird (trackpad/gesture edge cases), center-zoom safely
|
|
1171
|
+
self._zoom_at_pair(1.25 if (ev.angleDelta().y() if hasattr(ev, "angleDelta") else 1) > 0 else 0.8)
|
|
1172
|
+
ev.accept()
|
|
1173
|
+
return True
|
|
1174
|
+
# Not Ctrl: let the scroll area do normal scrolling
|
|
1175
|
+
return False
|
|
1176
|
+
|
|
1177
|
+
# -------- Drag-pan inside each viewport (sync the other) --------
|
|
1178
|
+
if obj in (self.scrollHF.viewport(), self.scrollLF.viewport()):
|
|
1179
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
1180
|
+
self._dragging = True
|
|
1181
|
+
self._last_pos = ev.position().toPoint()
|
|
1182
|
+
obj.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
|
|
1183
|
+
return True
|
|
1184
|
+
if ev.type() == QEvent.Type.MouseMove and self._dragging:
|
|
1185
|
+
cur = ev.position().toPoint()
|
|
1186
|
+
delta = cur - (self._last_pos or cur)
|
|
1187
|
+
self._last_pos = cur
|
|
1188
|
+
if obj is self.scrollHF.viewport():
|
|
1189
|
+
self._move_scrolls(self.scrollHF, self.scrollLF, delta)
|
|
1190
|
+
else:
|
|
1191
|
+
self._move_scrolls(self.scrollLF, self.scrollHF, delta)
|
|
1192
|
+
return True
|
|
1193
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
1194
|
+
self._dragging = False
|
|
1195
|
+
self._last_pos = None
|
|
1196
|
+
obj.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
|
|
1197
|
+
return True
|
|
1198
|
+
|
|
1199
|
+
return super().eventFilter(obj, ev)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _move_scrolls(self, src_sc: QScrollArea, dst_sc: QScrollArea, delta):
|
|
1203
|
+
self._sync_guard = True
|
|
1204
|
+
try:
|
|
1205
|
+
sh, sv = src_sc.horizontalScrollBar(), src_sc.verticalScrollBar()
|
|
1206
|
+
dh, dv = dst_sc.horizontalScrollBar(), dst_sc.verticalScrollBar()
|
|
1207
|
+
sh.setValue(sh.value() - delta.x()); sv.setValue(sv.value() - delta.y())
|
|
1208
|
+
dh.setValue(sh.value()); dv.setValue(sv.value())
|
|
1209
|
+
finally:
|
|
1210
|
+
self._sync_guard = False
|
|
1211
|
+
|
|
1212
|
+
# --------------- utilities ---------------
|
|
1213
|
+
def _find_main_window(self):
|
|
1214
|
+
w = self
|
|
1215
|
+
from PyQt6.QtWidgets import QMainWindow, QApplication
|
|
1216
|
+
while w is not None and not isinstance(w, QMainWindow):
|
|
1217
|
+
w = w.parentWidget()
|
|
1218
|
+
if w: return w
|
|
1219
|
+
for tlw in QApplication.topLevelWidgets():
|
|
1220
|
+
if isinstance(tlw, QMainWindow):
|
|
1221
|
+
return tlw
|
|
1222
|
+
return None
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
# ---- MASK HELPERS -------------------------------------------------
|
|
1226
|
+
def _doc_for_mask(self):
|
|
1227
|
+
"""Prefer the dialog-injected doc; else the active MDI doc."""
|
|
1228
|
+
return self.doc or self._get_active_document()
|
|
1229
|
+
|
|
1230
|
+
def _active_mask_array(self):
|
|
1231
|
+
"""
|
|
1232
|
+
Return (mask_float01, mask_id, mask_name) or (None, None, None).
|
|
1233
|
+
"""
|
|
1234
|
+
doc = self._doc_for_mask()
|
|
1235
|
+
if not doc:
|
|
1236
|
+
return None, None, None
|
|
1237
|
+
mid = getattr(doc, "active_mask_id", None)
|
|
1238
|
+
if not mid:
|
|
1239
|
+
return None, None, None
|
|
1240
|
+
layer = (getattr(doc, "masks", {}) or {}).get(mid)
|
|
1241
|
+
if layer is None:
|
|
1242
|
+
return None, None, None
|
|
1243
|
+
|
|
1244
|
+
import numpy as np
|
|
1245
|
+
m = np.asarray(getattr(layer, "data", None))
|
|
1246
|
+
if m is None or m.size == 0:
|
|
1247
|
+
return None, None, None
|
|
1248
|
+
|
|
1249
|
+
m = m.astype(np.float32, copy=False)
|
|
1250
|
+
if m.dtype.kind in "ui":
|
|
1251
|
+
m /= float(np.iinfo(m.dtype).max)
|
|
1252
|
+
else:
|
|
1253
|
+
mx = float(m.max()) if m.size else 1.0
|
|
1254
|
+
if mx > 1.0:
|
|
1255
|
+
m /= mx
|
|
1256
|
+
return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
|
|
1257
|
+
|
|
1258
|
+
def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
|
|
1259
|
+
"""Nearest-neighbor resize via integer indexing."""
|
|
1260
|
+
import numpy as np
|
|
1261
|
+
mh, mw = mask.shape[:2]
|
|
1262
|
+
th, tw = out_hw
|
|
1263
|
+
if (mh, mw) == (th, tw):
|
|
1264
|
+
return mask
|
|
1265
|
+
yi = np.linspace(0, mh - 1, th).astype(np.int32)
|
|
1266
|
+
xi = np.linspace(0, mw - 1, tw).astype(np.int32)
|
|
1267
|
+
return mask[yi][:, xi]
|
|
1268
|
+
|
|
1269
|
+
def _prepare_src_like(self, src, ref):
|
|
1270
|
+
"""
|
|
1271
|
+
Convert document source image to float32 [0..1] and reconcile channels to match ref.
|
|
1272
|
+
"""
|
|
1273
|
+
import numpy as np
|
|
1274
|
+
s = np.asarray(src)
|
|
1275
|
+
if s.dtype.kind in "ui":
|
|
1276
|
+
# assume 16-bit if >=2 bytes, else 8-bit
|
|
1277
|
+
scale = float(65535.0 if s.dtype.itemsize >= 2 else 255.0)
|
|
1278
|
+
s = s.astype(np.float32) / scale
|
|
1279
|
+
elif np.issubdtype(s.dtype, np.floating):
|
|
1280
|
+
s = s.astype(np.float32, copy=False)
|
|
1281
|
+
mx = float(s.max()) if s.size else 1.0
|
|
1282
|
+
if mx > 5.0:
|
|
1283
|
+
s = s / mx
|
|
1284
|
+
|
|
1285
|
+
# channel reconcile
|
|
1286
|
+
if s.ndim == 2 and ref.ndim == 3:
|
|
1287
|
+
s = np.stack([s]*3, axis=-1)
|
|
1288
|
+
elif s.ndim == 3 and s.shape[2] == 1 and ref.ndim == 3 and ref.shape[2] == 3:
|
|
1289
|
+
s = np.repeat(s, 3, axis=2)
|
|
1290
|
+
elif s.ndim == 3 and ref.ndim == 2:
|
|
1291
|
+
s = s[..., 0]
|
|
1292
|
+
|
|
1293
|
+
return s.astype(np.float32, copy=False)
|
|
1294
|
+
|
|
1295
|
+
def _blend_with_active_mask(self, processed: np.ndarray):
|
|
1296
|
+
"""
|
|
1297
|
+
Blend processed result with the *current* document image using active mask.
|
|
1298
|
+
Returns (blended, mask_id, mask_name, masked_bool).
|
|
1299
|
+
If no mask, returns (processed, None, None, False).
|
|
1300
|
+
"""
|
|
1301
|
+
mask, mid, mname = self._active_mask_array()
|
|
1302
|
+
if mask is None:
|
|
1303
|
+
return processed, None, None, False
|
|
1304
|
+
|
|
1305
|
+
import numpy as np
|
|
1306
|
+
out = np.asarray(processed, dtype=np.float32, copy=False)
|
|
1307
|
+
|
|
1308
|
+
doc = self._doc_for_mask()
|
|
1309
|
+
src = getattr(doc, "image", None)
|
|
1310
|
+
if src is None:
|
|
1311
|
+
return processed, mid, mname, True
|
|
1312
|
+
|
|
1313
|
+
srcf = self._prepare_src_like(src, out)
|
|
1314
|
+
m = self._resample_mask_if_needed(mask, out.shape[:2])
|
|
1315
|
+
if out.ndim == 3:
|
|
1316
|
+
m = m[..., None]
|
|
1317
|
+
|
|
1318
|
+
blended = (m * out + (1.0 - m) * srcf).astype(np.float32, copy=False)
|
|
1319
|
+
return blended, mid, mname, True
|
|
1320
|
+
# ---- /MASK HELPERS ------------------------------------------------
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QComboBox
|
|
1324
|
+
|
|
1325
|
+
class SelectViewDialog(QDialog):
|
|
1326
|
+
def __init__(self, parent, items: list[tuple[str, object]],
|
|
1327
|
+
which: str | None = None, title: str | None = None):
|
|
1328
|
+
super().__init__(parent)
|
|
1329
|
+
# Use a nice default title if none provided
|
|
1330
|
+
if title is None:
|
|
1331
|
+
title = f"Select View for {which.upper()}" if which else "Select View"
|
|
1332
|
+
self.setWindowTitle(title)
|
|
1333
|
+
|
|
1334
|
+
self._items = items
|
|
1335
|
+
self.combo = QComboBox(self)
|
|
1336
|
+
for name, _doc in items:
|
|
1337
|
+
self.combo.addItem(name)
|
|
1338
|
+
|
|
1339
|
+
form = QFormLayout(self)
|
|
1340
|
+
if which:
|
|
1341
|
+
form.addRow(QLabel(f"Load into: {which.upper()}"))
|
|
1342
|
+
form.addRow("View:", self.combo)
|
|
1343
|
+
|
|
1344
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
|
|
1345
|
+
QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1346
|
+
btns.accepted.connect(self.accept)
|
|
1347
|
+
btns.rejected.connect(self.reject)
|
|
1348
|
+
form.addRow(btns)
|
|
1349
|
+
|
|
1350
|
+
def selected_doc(self):
|
|
1351
|
+
idx = self.combo.currentIndex()
|
|
1352
|
+
return self._items[idx][1] if 0 <= idx < len(self._items) else None
|