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,639 @@
|
|
|
1
|
+
# pro/backgroundneutral.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import Qt, QPointF, QRectF, QEvent, QTimer
|
|
6
|
+
from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon, QPainter
|
|
7
|
+
from PyQt6.QtWidgets import (
|
|
8
|
+
QDialog, QVBoxLayout, QLabel, QGraphicsView, QGraphicsScene,
|
|
9
|
+
QHBoxLayout, QPushButton, QMessageBox, QGraphicsRectItem
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Reuse existing helpers + autostretch
|
|
13
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image
|
|
14
|
+
# Shared utilities
|
|
15
|
+
from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
|
|
16
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ----------------------------
|
|
21
|
+
# Core neutralization function
|
|
22
|
+
# ----------------------------
|
|
23
|
+
def background_neutralize_rgb(img: np.ndarray, rect_xywh: tuple[int, int, int, int]) -> np.ndarray:
|
|
24
|
+
"""
|
|
25
|
+
Apply Background Neutralization to an RGB float32 image in [0,1],
|
|
26
|
+
using an image-space rectangle (x, y, w, h) as the sample region.
|
|
27
|
+
Returns a new float32 array in [0,1].
|
|
28
|
+
"""
|
|
29
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
30
|
+
raise ValueError("Background Neutralization requires a 3-channel RGB image.")
|
|
31
|
+
|
|
32
|
+
h, w, _ = img.shape
|
|
33
|
+
x, y, rw, rh = rect_xywh
|
|
34
|
+
x = max(0, min(int(x), w - 1))
|
|
35
|
+
y = max(0, min(int(y), h - 1))
|
|
36
|
+
rw = max(1, min(int(rw), w - x))
|
|
37
|
+
rh = max(1, min(int(rh), h - y))
|
|
38
|
+
|
|
39
|
+
sample = img[y:y+rh, x:x+rw, :]
|
|
40
|
+
medians = np.median(sample, axis=(0, 1)).astype(np.float32) # (3,)
|
|
41
|
+
avg_med = float(np.mean(medians))
|
|
42
|
+
|
|
43
|
+
out = img.copy()
|
|
44
|
+
eps = 1e-8
|
|
45
|
+
|
|
46
|
+
# Vectorized neutralization
|
|
47
|
+
# diff shape: (3,) -> (1, 1, 3)
|
|
48
|
+
diffs = (medians - avg_med).reshape(1, 1, 3)
|
|
49
|
+
|
|
50
|
+
# denom shape: (1, 1, 3)
|
|
51
|
+
denoms = 1.0 - diffs
|
|
52
|
+
|
|
53
|
+
# Avoid div-by-zero (vectorized)
|
|
54
|
+
# logic: if abs(denom) < eps, set to eps (sign matched)
|
|
55
|
+
# We can do this efficiently:
|
|
56
|
+
small_mask = np.abs(denoms) < eps
|
|
57
|
+
denoms[small_mask] = np.where(denoms[small_mask] >= 0, eps, -eps)
|
|
58
|
+
|
|
59
|
+
# Apply formula: (pixel - diff) / denom
|
|
60
|
+
out = (out - diffs) / denoms
|
|
61
|
+
out = np.clip(out, 0.0, 1.0)
|
|
62
|
+
|
|
63
|
+
return out.astype(np.float32, copy=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ------------------------------------
|
|
67
|
+
# Auto background finder (SASv2 logic)
|
|
68
|
+
# ------------------------------------
|
|
69
|
+
def _find_best_patch_center(lum: np.ndarray) -> tuple[int, int]:
|
|
70
|
+
"""Port of your downhill-walk tile search (works on a luminance plane)."""
|
|
71
|
+
h, w = lum.shape
|
|
72
|
+
th, tw = h // 10, w // 10
|
|
73
|
+
|
|
74
|
+
# Optimized: compute 10x10 tile medians using strided views where possible
|
|
75
|
+
# This avoids repeated slicing and is cache-friendlier
|
|
76
|
+
meds = np.zeros((10, 10), dtype=np.float32)
|
|
77
|
+
|
|
78
|
+
# For tiles that fit evenly, use reshape + median (faster than loop)
|
|
79
|
+
crop_h, crop_w = th * 10, tw * 10
|
|
80
|
+
if crop_h <= h and crop_w <= w:
|
|
81
|
+
lum_crop = lum[:crop_h, :crop_w]
|
|
82
|
+
# Reshape to (10, th, 10, tw) and compute medians
|
|
83
|
+
tiles = lum_crop.reshape(10, th, 10, tw).transpose(0, 2, 1, 3).reshape(10, 10, -1)
|
|
84
|
+
meds = np.median(tiles, axis=2).astype(np.float32)
|
|
85
|
+
|
|
86
|
+
# Handle edge tiles if image doesn't divide evenly
|
|
87
|
+
if h > crop_h or w > crop_w:
|
|
88
|
+
# Bottom row edge
|
|
89
|
+
if h > crop_h:
|
|
90
|
+
for j in range(10):
|
|
91
|
+
x0, x1 = j * tw, (j + 1) * tw if j < 9 else w
|
|
92
|
+
meds[9, j] = np.median(lum[9*th:h, x0:x1])
|
|
93
|
+
# Right column edge
|
|
94
|
+
if w > crop_w:
|
|
95
|
+
for i in range(10):
|
|
96
|
+
y0, y1 = i * th, (i + 1) * th if i < 9 else h
|
|
97
|
+
meds[i, 9] = np.median(lum[y0:y1, 9*tw:w])
|
|
98
|
+
else:
|
|
99
|
+
# Fallback for very small images
|
|
100
|
+
for i in range(10):
|
|
101
|
+
for j in range(10):
|
|
102
|
+
y0, x0 = i * th, j * tw
|
|
103
|
+
y1 = (i + 1) * th if i < 9 else h
|
|
104
|
+
x1 = (j + 1) * tw if j < 9 else w
|
|
105
|
+
meds[i, j] = np.median(lum[y0:y1, x0:x1])
|
|
106
|
+
|
|
107
|
+
idxs = np.argsort(meds.flatten())[:2]
|
|
108
|
+
|
|
109
|
+
finals = []
|
|
110
|
+
for idx in idxs:
|
|
111
|
+
ti, tj = divmod(int(idx), 10)
|
|
112
|
+
y0, x0 = ti * th, tj * tw
|
|
113
|
+
y1 = (ti + 1) * th if ti < 9 else h
|
|
114
|
+
x1 = (tj + 1) * tw if tj < 9 else w
|
|
115
|
+
for _ in range(200):
|
|
116
|
+
y = np.random.randint(y0, y1)
|
|
117
|
+
x = np.random.randint(x0, x1)
|
|
118
|
+
while True:
|
|
119
|
+
mv, mpos = lum[y, x], (y, x)
|
|
120
|
+
for dy in (-1, 0, 1):
|
|
121
|
+
for dx in (-1, 0, 1):
|
|
122
|
+
if dy == 0 and dx == 0:
|
|
123
|
+
continue
|
|
124
|
+
ny, nx = y + dy, x + dx
|
|
125
|
+
if 0 <= ny < h and 0 <= nx < w and lum[ny, nx] < mv:
|
|
126
|
+
mv, mpos = lum[ny, nx], (ny, nx)
|
|
127
|
+
if mpos == (y, x):
|
|
128
|
+
break
|
|
129
|
+
y, x = mpos
|
|
130
|
+
finals.append((y, x))
|
|
131
|
+
|
|
132
|
+
best_val = np.inf
|
|
133
|
+
best_pt = (h // 2, w // 2)
|
|
134
|
+
for (y, x) in finals:
|
|
135
|
+
y0 = max(0, y - 25); y1 = min(h, y + 25)
|
|
136
|
+
x0 = max(0, x - 25); x1 = min(w, x + 25)
|
|
137
|
+
m = np.median(lum[y0:y1, x0:x1])
|
|
138
|
+
if m < best_val:
|
|
139
|
+
best_val, best_pt = m, (y, x)
|
|
140
|
+
return best_pt
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def auto_rect_50x50(img_rgb: np.ndarray) -> tuple[int, int, int, int]:
|
|
144
|
+
"""
|
|
145
|
+
Find a robust 50×50 background rectangle (≥100 px margins) in image space.
|
|
146
|
+
Returns (x, y, w, h).
|
|
147
|
+
"""
|
|
148
|
+
h, w, ch = img_rgb.shape
|
|
149
|
+
if ch != 3:
|
|
150
|
+
raise ValueError("Auto background finder expects a 3-channel RGB image.")
|
|
151
|
+
lum = img_rgb.mean(axis=2).astype(np.float32)
|
|
152
|
+
|
|
153
|
+
cy, cx = _find_best_patch_center(lum)
|
|
154
|
+
|
|
155
|
+
margin = 100
|
|
156
|
+
half = 25
|
|
157
|
+
min_cx, max_cx = margin + half, w - (margin + half)
|
|
158
|
+
min_cy, max_cy = margin + half, h - (margin + half)
|
|
159
|
+
cx = int(np.clip(cx, min_cx, max_cx))
|
|
160
|
+
cy = int(np.clip(cy, min_cy, max_cy))
|
|
161
|
+
|
|
162
|
+
# refine by ±half
|
|
163
|
+
best_val = np.inf
|
|
164
|
+
ty, tx = cy, cx
|
|
165
|
+
for dy in (-half, 0, +half):
|
|
166
|
+
for dx in (-half, 0, +half):
|
|
167
|
+
y = int(np.clip(cy + dy, min_cy, max_cy))
|
|
168
|
+
x = int(np.clip(cx + dx, min_cx, max_cx))
|
|
169
|
+
y0, y1 = y - half, y + half
|
|
170
|
+
x0, x1 = x - half, x + half
|
|
171
|
+
m = np.median(lum[y0:y1, x0:x1])
|
|
172
|
+
if m < best_val:
|
|
173
|
+
best_val, ty, tx = m, y, x
|
|
174
|
+
|
|
175
|
+
return (tx - half, ty - half, 50, 50)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------
|
|
179
|
+
# Headless apply (doc + preset in)
|
|
180
|
+
# --------------------------------
|
|
181
|
+
def apply_background_neutral_to_doc(doc, preset: dict | None = None):
|
|
182
|
+
"""
|
|
183
|
+
Headless entrypoint (used by DnD shortcuts).
|
|
184
|
+
Preset schema:
|
|
185
|
+
{
|
|
186
|
+
"mode": "auto" | "rect",
|
|
187
|
+
# rect in normalized coords if mode == "rect"
|
|
188
|
+
"rect_norm": [x0, y0, w, h] # each in 0..1
|
|
189
|
+
}
|
|
190
|
+
Defaults to {"mode": "auto"}.
|
|
191
|
+
"""
|
|
192
|
+
import numpy as np
|
|
193
|
+
|
|
194
|
+
if preset is None:
|
|
195
|
+
preset = {}
|
|
196
|
+
mode = (preset.get("mode") or "auto").lower()
|
|
197
|
+
|
|
198
|
+
base = np.asarray(doc.image).astype(np.float32, copy=False)
|
|
199
|
+
if base.size == 0:
|
|
200
|
+
raise ValueError("Empty image.")
|
|
201
|
+
|
|
202
|
+
# Defensive normalization (should already be [0,1] in SASpro)
|
|
203
|
+
maxv = float(np.nanmax(base))
|
|
204
|
+
if maxv > 1.0 and np.isfinite(maxv):
|
|
205
|
+
base = base / maxv
|
|
206
|
+
|
|
207
|
+
if base.ndim != 3 or base.shape[2] != 3:
|
|
208
|
+
raise ValueError("Background Neutralization currently supports RGB images.")
|
|
209
|
+
|
|
210
|
+
if mode == "rect":
|
|
211
|
+
rn = preset.get("rect_norm")
|
|
212
|
+
if not rn or len(rn) != 4:
|
|
213
|
+
raise ValueError("rect mode requires rect_norm=[x,y,w,h] in normalized coords.")
|
|
214
|
+
H, W, _ = base.shape
|
|
215
|
+
x = int(np.clip(rn[0], 0, 1) * W)
|
|
216
|
+
y = int(np.clip(rn[1], 0, 1) * H)
|
|
217
|
+
w = int(np.clip(rn[2], 0, 1) * W)
|
|
218
|
+
h = int(np.clip(rn[3], 0, 1) * H)
|
|
219
|
+
rect = (x, y, max(w, 1), max(h, 1))
|
|
220
|
+
else:
|
|
221
|
+
rect = auto_rect_50x50(base)
|
|
222
|
+
|
|
223
|
+
out = background_neutralize_rgb(base, rect)
|
|
224
|
+
|
|
225
|
+
# Destination-mask blend (mask lives on the destination doc)
|
|
226
|
+
m = _active_mask_array_from_doc(doc)
|
|
227
|
+
if m is not None:
|
|
228
|
+
if out.ndim == 3:
|
|
229
|
+
m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32, copy=False)
|
|
230
|
+
else:
|
|
231
|
+
m3 = m.astype(np.float32, copy=False)
|
|
232
|
+
base_for_blend = np.asarray(doc.image).astype(np.float32, copy=False)
|
|
233
|
+
bmax = float(np.nanmax(base_for_blend))
|
|
234
|
+
if bmax > 1.0 and np.isfinite(bmax):
|
|
235
|
+
base_for_blend /= bmax
|
|
236
|
+
out = base_for_blend * (1.0 - m3) + out * m3
|
|
237
|
+
|
|
238
|
+
doc.apply_edit(
|
|
239
|
+
out.astype(np.float32, copy=False),
|
|
240
|
+
metadata={"step_name": "Background Neutralization", "preset": preset},
|
|
241
|
+
step_name="Background Neutralization",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# -------------------------
|
|
246
|
+
# Interactive BN dialog UI
|
|
247
|
+
# -------------------------
|
|
248
|
+
class BackgroundNeutralizationDialog(QDialog):
|
|
249
|
+
def __init__(self, parent, doc, icon: QIcon | None = None):
|
|
250
|
+
super().__init__(parent)
|
|
251
|
+
self._main = parent
|
|
252
|
+
self.doc = doc
|
|
253
|
+
|
|
254
|
+
self._connected_current_doc_changed = False
|
|
255
|
+
if hasattr(self._main, "currentDocumentChanged"):
|
|
256
|
+
try:
|
|
257
|
+
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
258
|
+
self._connected_current_doc_changed = True
|
|
259
|
+
except Exception:
|
|
260
|
+
self._connected_current_doc_changed = False
|
|
261
|
+
|
|
262
|
+
self.finished.connect(self._cleanup_connections)
|
|
263
|
+
try:
|
|
264
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass # older PyQt6 versions
|
|
267
|
+
|
|
268
|
+
if icon:
|
|
269
|
+
self.setWindowIcon(icon)
|
|
270
|
+
self.setWindowTitle(self.tr("Background Neutralization"))
|
|
271
|
+
self.resize(900, 600)
|
|
272
|
+
|
|
273
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
274
|
+
# Non-modal: allow user to switch between images while dialog is open
|
|
275
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
276
|
+
self.setModal(False)
|
|
277
|
+
#self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
278
|
+
|
|
279
|
+
self.auto_stretch = False
|
|
280
|
+
self.zoom_factor = 1.0
|
|
281
|
+
self._user_zoomed = False
|
|
282
|
+
|
|
283
|
+
# --- scene / view ---
|
|
284
|
+
self.scene = QGraphicsScene(self)
|
|
285
|
+
self.graphics_view = QGraphicsView(self)
|
|
286
|
+
self.graphics_view.setScene(self.scene)
|
|
287
|
+
self.graphics_view.setRenderHints(
|
|
288
|
+
QPainter.RenderHint.Antialiasing |
|
|
289
|
+
QPainter.RenderHint.SmoothPixmapTransform
|
|
290
|
+
)
|
|
291
|
+
self.graphics_view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
292
|
+
self.graphics_view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
|
293
|
+
|
|
294
|
+
# --- main layout ---
|
|
295
|
+
layout = QVBoxLayout(self)
|
|
296
|
+
instruction = QLabel("Draw a sample box or click ‘Find Background’ to auto-select.")
|
|
297
|
+
layout.addWidget(instruction)
|
|
298
|
+
layout.addWidget(self.graphics_view, 1)
|
|
299
|
+
|
|
300
|
+
# Buttons row
|
|
301
|
+
btn_row = QHBoxLayout()
|
|
302
|
+
self.btn_apply = QPushButton(self.tr("Apply Neutralization"))
|
|
303
|
+
self.btn_cancel = QPushButton(self.tr("Cancel"))
|
|
304
|
+
self.btn_toggle_stretch = QPushButton(self.tr("Enable Auto-Stretch"))
|
|
305
|
+
self.btn_find_bg = QPushButton(self.tr("Find Background"))
|
|
306
|
+
btn_row.addWidget(self.btn_apply)
|
|
307
|
+
btn_row.addWidget(self.btn_cancel)
|
|
308
|
+
btn_row.addWidget(self.btn_toggle_stretch)
|
|
309
|
+
btn_row.addWidget(self.btn_find_bg)
|
|
310
|
+
layout.addLayout(btn_row)
|
|
311
|
+
|
|
312
|
+
# Zoom row
|
|
313
|
+
# Zoom row (standardized themed toolbuttons)
|
|
314
|
+
zoom_row = QHBoxLayout()
|
|
315
|
+
|
|
316
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
317
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to View")
|
|
318
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
319
|
+
|
|
320
|
+
zoom_row.addWidget(self.btn_zoom_out)
|
|
321
|
+
zoom_row.addWidget(self.btn_fit)
|
|
322
|
+
zoom_row.addWidget(self.btn_zoom_in)
|
|
323
|
+
zoom_row.addStretch(1) # optional: keeps them left-aligned
|
|
324
|
+
|
|
325
|
+
layout.addLayout(zoom_row)
|
|
326
|
+
|
|
327
|
+
# Events
|
|
328
|
+
self.btn_apply.clicked.connect(self._on_apply)
|
|
329
|
+
self.btn_cancel.clicked.connect(self.close)
|
|
330
|
+
self.btn_toggle_stretch.clicked.connect(self._toggle_auto_stretch)
|
|
331
|
+
self.btn_find_bg.clicked.connect(self._on_find_background)
|
|
332
|
+
self.btn_zoom_out.clicked.connect(self.zoom_out)
|
|
333
|
+
self.btn_fit.clicked.connect(self.fit_to_view)
|
|
334
|
+
self.btn_zoom_in.clicked.connect(self.zoom_in)
|
|
335
|
+
|
|
336
|
+
self.graphics_view.viewport().installEventFilter(self)
|
|
337
|
+
self.origin_scene = QPointF()
|
|
338
|
+
self.current_rect_scene = QRectF()
|
|
339
|
+
self.selection_item: QGraphicsRectItem | None = None
|
|
340
|
+
self.drawing = False
|
|
341
|
+
|
|
342
|
+
self._load_image()
|
|
343
|
+
|
|
344
|
+
# ---- active document change ------------------------------------
|
|
345
|
+
def _on_active_doc_changed(self, doc):
|
|
346
|
+
"""Called when user clicks a different image window."""
|
|
347
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
348
|
+
return
|
|
349
|
+
self.doc = doc
|
|
350
|
+
self.selection_item = None
|
|
351
|
+
self._load_image()
|
|
352
|
+
|
|
353
|
+
# ---------- image display ----------
|
|
354
|
+
def _doc_image_normalized(self) -> np.ndarray:
|
|
355
|
+
import numpy as np
|
|
356
|
+
img = np.asarray(self.doc.image).astype(np.float32, copy=False)
|
|
357
|
+
if img.size == 0:
|
|
358
|
+
return img
|
|
359
|
+
m = float(np.nanmax(img))
|
|
360
|
+
if m > 1.0 and np.isfinite(m):
|
|
361
|
+
img = img / m
|
|
362
|
+
return img
|
|
363
|
+
|
|
364
|
+
def _load_image(self):
|
|
365
|
+
self.scene.clear()
|
|
366
|
+
self.selection_item = None
|
|
367
|
+
|
|
368
|
+
img = self._doc_image_normalized()
|
|
369
|
+
if img is None or img.size == 0:
|
|
370
|
+
QMessageBox.warning(self, "No Image", "Open an image first.")
|
|
371
|
+
self.reject()
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
disp = img.copy()
|
|
375
|
+
if self.auto_stretch and disp.ndim == 3 and disp.shape[2] == 3:
|
|
376
|
+
disp = stretch_color_image(disp, 0.25, linked=False, normalize=False)
|
|
377
|
+
|
|
378
|
+
# Build QImage/QPixmap
|
|
379
|
+
if disp.ndim == 2:
|
|
380
|
+
h, w = disp.shape
|
|
381
|
+
qimg = QImage((disp * 255).astype(np.uint8).tobytes(), w, h, w, QImage.Format.Format_Grayscale8)
|
|
382
|
+
else:
|
|
383
|
+
h, w, _ = disp.shape
|
|
384
|
+
qimg = QImage((disp * 255).astype(np.uint8).tobytes(), w, h, 3 * w, QImage.Format.Format_RGB888)
|
|
385
|
+
|
|
386
|
+
pix = QPixmap.fromImage(qimg)
|
|
387
|
+
|
|
388
|
+
# Add to scene; force scene rect to native image pixels and place at (0,0)
|
|
389
|
+
self.scene.clear()
|
|
390
|
+
self.selection_item = None
|
|
391
|
+
self.pixmap_item = self.scene.addPixmap(pix)
|
|
392
|
+
self.pixmap_item.setPos(0, 0)
|
|
393
|
+
self.scene.setSceneRect(0, 0, pix.width(), pix.height())
|
|
394
|
+
|
|
395
|
+
# Reset and fit (this sets initial view, later showEvent/resizeEvent will refit)
|
|
396
|
+
self.graphics_view.resetTransform()
|
|
397
|
+
self.graphics_view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
398
|
+
self.zoom_factor = 1.0
|
|
399
|
+
self._user_zoomed = False
|
|
400
|
+
|
|
401
|
+
def _toggle_auto_stretch(self):
|
|
402
|
+
self.auto_stretch = not self.auto_stretch
|
|
403
|
+
self.btn_toggle_stretch.setText("Disable Auto-Stretch" if self.auto_stretch else "Enable Auto-Stretch")
|
|
404
|
+
self._load_image()
|
|
405
|
+
|
|
406
|
+
# ---------- zoom ----------
|
|
407
|
+
def eventFilter(self, source, event):
|
|
408
|
+
if source is self.graphics_view.viewport():
|
|
409
|
+
et = event.type()
|
|
410
|
+
if et == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
|
|
411
|
+
self.drawing = True
|
|
412
|
+
self.origin_scene = self.graphics_view.mapToScene(event.pos())
|
|
413
|
+
if self.selection_item:
|
|
414
|
+
self.scene.removeItem(self.selection_item)
|
|
415
|
+
self.selection_item = None
|
|
416
|
+
elif et == QEvent.Type.MouseMove and self.drawing:
|
|
417
|
+
cur = self.graphics_view.mapToScene(event.pos())
|
|
418
|
+
self.current_rect_scene = QRectF(self.origin_scene, cur).normalized()
|
|
419
|
+
if self.selection_item:
|
|
420
|
+
self.scene.removeItem(self.selection_item)
|
|
421
|
+
pen = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.DashLine)
|
|
422
|
+
self.selection_item = self.scene.addRect(self.current_rect_scene, pen)
|
|
423
|
+
elif et == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton and self.drawing:
|
|
424
|
+
self.drawing = False
|
|
425
|
+
cur = self.graphics_view.mapToScene(event.pos())
|
|
426
|
+
self.current_rect_scene = QRectF(self.origin_scene, cur).normalized()
|
|
427
|
+
if self.selection_item:
|
|
428
|
+
self.scene.removeItem(self.selection_item)
|
|
429
|
+
if self.current_rect_scene.width() < 10 or self.current_rect_scene.height() < 10:
|
|
430
|
+
QMessageBox.warning(self, "Selection Too Small", "Please draw a larger selection box.")
|
|
431
|
+
self.selection_item = None
|
|
432
|
+
self.current_rect_scene = QRectF()
|
|
433
|
+
else:
|
|
434
|
+
pen = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.SolidLine)
|
|
435
|
+
self.selection_item = self.scene.addRect(self.current_rect_scene, pen)
|
|
436
|
+
return super().eventFilter(source, event)
|
|
437
|
+
|
|
438
|
+
def _on_find_background(self):
|
|
439
|
+
img = self._doc_image_normalized()
|
|
440
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
441
|
+
QMessageBox.warning(self, "Not RGB", "Background Neutralization supports RGB images.")
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
x, y, w, h = auto_rect_50x50(img)
|
|
445
|
+
|
|
446
|
+
if self.selection_item:
|
|
447
|
+
self.scene.removeItem(self.selection_item)
|
|
448
|
+
|
|
449
|
+
pen = QPen(QColor(255, 215, 0), 2) # gold
|
|
450
|
+
rect_scene = QRectF(float(x), float(y), float(w), float(h)) # scene == image pixels now
|
|
451
|
+
self.selection_item = self.scene.addRect(rect_scene, pen)
|
|
452
|
+
self.current_rect_scene = rect_scene
|
|
453
|
+
|
|
454
|
+
def _scene_rect_to_image_rect(self) -> tuple[int, int, int, int]:
|
|
455
|
+
if not self.current_rect_scene or self.current_rect_scene.isNull():
|
|
456
|
+
raise ValueError("No selection rectangle defined.")
|
|
457
|
+
|
|
458
|
+
# Scene == image pixels (because we setSceneRect to pixmap bounds)
|
|
459
|
+
bounds = self.pixmap_item.boundingRect()
|
|
460
|
+
W = int(bounds.width())
|
|
461
|
+
H = int(bounds.height())
|
|
462
|
+
|
|
463
|
+
x = int(max(0.0, min(bounds.width(), self.current_rect_scene.left())))
|
|
464
|
+
y = int(max(0.0, min(bounds.height(), self.current_rect_scene.top())))
|
|
465
|
+
w = int(max(1.0, min(bounds.width() - x, self.current_rect_scene.width())))
|
|
466
|
+
h = int(max(1.0, min(bounds.height() - y, self.current_rect_scene.height())))
|
|
467
|
+
return (x, y, w, h)
|
|
468
|
+
|
|
469
|
+
def _on_apply(self):
|
|
470
|
+
try:
|
|
471
|
+
rect = self._scene_rect_to_image_rect()
|
|
472
|
+
except Exception as e:
|
|
473
|
+
QMessageBox.warning(self, "No Selection", str(e))
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
img = self._doc_image_normalized()
|
|
477
|
+
if img.ndim != 3 or img.shape[2] != 3:
|
|
478
|
+
QMessageBox.warning(self, "Not RGB", "Background Neutralization supports RGB images.")
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
out = background_neutralize_rgb(img, rect)
|
|
482
|
+
|
|
483
|
+
# Destination-mask blend
|
|
484
|
+
m = _active_mask_array_from_doc(self.doc)
|
|
485
|
+
if m is not None:
|
|
486
|
+
if out.ndim == 3:
|
|
487
|
+
m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32, copy=False)
|
|
488
|
+
else:
|
|
489
|
+
m3 = m.astype(np.float32, copy=False)
|
|
490
|
+
base_for_blend = self._doc_image_normalized()
|
|
491
|
+
out = base_for_blend * (1.0 - m3) + out * m3
|
|
492
|
+
|
|
493
|
+
# ---------- Build preset for Replay Last ----------
|
|
494
|
+
preset = None
|
|
495
|
+
try:
|
|
496
|
+
H, W = img.shape[:2]
|
|
497
|
+
x, y, w, h = rect
|
|
498
|
+
if W > 0 and H > 0:
|
|
499
|
+
rect_norm = [
|
|
500
|
+
float(x) / float(W),
|
|
501
|
+
float(y) / float(H),
|
|
502
|
+
float(w) / float(W),
|
|
503
|
+
float(h) / float(H),
|
|
504
|
+
]
|
|
505
|
+
else:
|
|
506
|
+
rect_norm = [0.0, 0.0, 1.0, 1.0]
|
|
507
|
+
|
|
508
|
+
preset = {"mode": "rect", "rect_norm": rect_norm}
|
|
509
|
+
|
|
510
|
+
# Walk up parent chain until we find the main window that carries
|
|
511
|
+
# _last_headless_command
|
|
512
|
+
main = self.parent()
|
|
513
|
+
while main is not None and not hasattr(main, "_last_headless_command"):
|
|
514
|
+
main = main.parent()
|
|
515
|
+
|
|
516
|
+
if main is not None:
|
|
517
|
+
try:
|
|
518
|
+
main._last_headless_command = {
|
|
519
|
+
"command_id": "background_neutral",
|
|
520
|
+
"preset": preset,
|
|
521
|
+
}
|
|
522
|
+
if hasattr(main, "_log"):
|
|
523
|
+
main._log(
|
|
524
|
+
"[Replay] Recorded background_neutral "
|
|
525
|
+
f"(mode=rect, rect_norm={rect_norm})"
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
except Exception:
|
|
530
|
+
# Fallback: at least record mode
|
|
531
|
+
if preset is None:
|
|
532
|
+
preset = {"mode": "rect"}
|
|
533
|
+
|
|
534
|
+
# ---------- Apply edit (include preset in metadata) ----------
|
|
535
|
+
meta = {
|
|
536
|
+
"step_name": "Background Neutralization",
|
|
537
|
+
"rect": rect,
|
|
538
|
+
}
|
|
539
|
+
if preset is not None:
|
|
540
|
+
meta["preset"] = preset
|
|
541
|
+
|
|
542
|
+
self.doc.apply_edit(
|
|
543
|
+
out.astype(np.float32, copy=False),
|
|
544
|
+
metadata=meta,
|
|
545
|
+
step_name="Background Neutralization",
|
|
546
|
+
)
|
|
547
|
+
# Dialog stays open so user can apply to other images
|
|
548
|
+
# Refresh to use the now-active document for next operation
|
|
549
|
+
self.close()
|
|
550
|
+
|
|
551
|
+
def closeEvent(self, ev):
|
|
552
|
+
self._cleanup_connections()
|
|
553
|
+
super().closeEvent(ev)
|
|
554
|
+
|
|
555
|
+
def _cleanup_connections(self):
|
|
556
|
+
# Disconnect active-doc tracking (Fabio hook)
|
|
557
|
+
try:
|
|
558
|
+
if self._connected_current_doc_changed and hasattr(self._main, "currentDocumentChanged"):
|
|
559
|
+
self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
self._connected_current_doc_changed = False
|
|
563
|
+
|
|
564
|
+
# If you ever add threads/workers later, stop them here too (safe no-ops now)
|
|
565
|
+
try:
|
|
566
|
+
if getattr(self, "_worker", None) is not None:
|
|
567
|
+
try:
|
|
568
|
+
self._worker.requestInterruption()
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
if getattr(self, "_thread", None) is not None:
|
|
572
|
+
self._thread.quit()
|
|
573
|
+
self._thread.wait(500)
|
|
574
|
+
except Exception:
|
|
575
|
+
pass
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _refresh_document_from_active(self):
|
|
579
|
+
"""
|
|
580
|
+
Refresh the dialog's document reference to the currently active document.
|
|
581
|
+
This allows reusing the same dialog on different images.
|
|
582
|
+
"""
|
|
583
|
+
try:
|
|
584
|
+
main = self.parent()
|
|
585
|
+
if main and hasattr(main, "_active_doc"):
|
|
586
|
+
new_doc = main._active_doc()
|
|
587
|
+
if new_doc is not None and new_doc is not self.doc:
|
|
588
|
+
self.doc = new_doc
|
|
589
|
+
# Refresh the preview image
|
|
590
|
+
self._load_preview()
|
|
591
|
+
except Exception:
|
|
592
|
+
pass
|
|
593
|
+
|
|
594
|
+
def _zoom(self, factor: float):
|
|
595
|
+
self._user_zoomed = True
|
|
596
|
+
cur = self.graphics_view.transform().m11()
|
|
597
|
+
new_scale = cur * factor
|
|
598
|
+
if new_scale < 0.01 or new_scale > 100.0:
|
|
599
|
+
return
|
|
600
|
+
self.graphics_view.scale(factor, factor)
|
|
601
|
+
|
|
602
|
+
def zoom_in(self):
|
|
603
|
+
self._zoom(1.25)
|
|
604
|
+
|
|
605
|
+
def zoom_out(self):
|
|
606
|
+
self._zoom(0.8)
|
|
607
|
+
|
|
608
|
+
def fit_to_view(self):
|
|
609
|
+
self._user_zoomed = False
|
|
610
|
+
self.graphics_view.resetTransform()
|
|
611
|
+
# Fit the pixmap bounds (not a default huge scene)
|
|
612
|
+
if hasattr(self, "pixmap_item") and self.pixmap_item is not None:
|
|
613
|
+
self.graphics_view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
614
|
+
|
|
615
|
+
def showEvent(self, e):
|
|
616
|
+
super().showEvent(e)
|
|
617
|
+
# fit after the widget is actually visible
|
|
618
|
+
QTimer.singleShot(0, self.fit_to_view)
|
|
619
|
+
|
|
620
|
+
def resizeEvent(self, e):
|
|
621
|
+
super().resizeEvent(e)
|
|
622
|
+
# keep it fitted while the user hasn't manually zoomed
|
|
623
|
+
if not self._user_zoomed:
|
|
624
|
+
self.fit_to_view()
|
|
625
|
+
|
|
626
|
+
from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
|
|
627
|
+
|
|
628
|
+
def run_background_neutral_via_preset(main, preset=None, target_doc=None):
|
|
629
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
630
|
+
from setiastro.saspro.backgroundneutral import apply_background_neutral_to_doc
|
|
631
|
+
|
|
632
|
+
p = dict(preset or {})
|
|
633
|
+
main, doc, _dm = normalize_headless_main(main, target_doc)
|
|
634
|
+
|
|
635
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
636
|
+
QMessageBox.warning(main or None, "Background Neutralization", "Load an image first.")
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
apply_background_neutral_to_doc(doc, p)
|