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,760 @@
|
|
|
1
|
+
# pro/histogram.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtCore import Qt, QSettings, QTimer, QEvent, pyqtSignal
|
|
6
|
+
from PyQt6.QtWidgets import (
|
|
7
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QScrollArea,
|
|
8
|
+
QTableWidget, QTableWidgetItem, QMessageBox, QToolButton, QInputDialog, QSplitter, QSizePolicy, QHeaderView
|
|
9
|
+
)
|
|
10
|
+
from PyQt6.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QPalette
|
|
11
|
+
|
|
12
|
+
# Shared utilities
|
|
13
|
+
from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
|
|
14
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
15
|
+
|
|
16
|
+
def _to_float_preserve(img):
|
|
17
|
+
if img is None: return None
|
|
18
|
+
a = np.asarray(img)
|
|
19
|
+
return a.astype(np.float32, copy=False) if a.dtype != np.float32 else a
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HistogramDialog(QDialog):
|
|
24
|
+
"""
|
|
25
|
+
Per-document histogram (non-modal).
|
|
26
|
+
- Connects to ImageDocument.changed and repaints automatically.
|
|
27
|
+
- Multiple dialogs can be open at once (each bound to one doc).
|
|
28
|
+
"""
|
|
29
|
+
pivotPicked = pyqtSignal(float) # normalized [0..1] x position for GHS pivot
|
|
30
|
+
def __init__(self, parent, document):
|
|
31
|
+
super().__init__(parent)
|
|
32
|
+
self.setWindowTitle(self.tr("Histogram"))
|
|
33
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
34
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
35
|
+
self.setModal(False)
|
|
36
|
+
try:
|
|
37
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass # older PyQt6 versions
|
|
40
|
+
self.doc = document
|
|
41
|
+
self.image = _to_float_preserve(document.image)
|
|
42
|
+
|
|
43
|
+
self.zoom_factor = 1.0 # 1.0 = 100%
|
|
44
|
+
self.log_scale = False # log X
|
|
45
|
+
self.log_y = False # log Y
|
|
46
|
+
#self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
47
|
+
self._eps_log = 1e-6 # first log bin edge (for labels)
|
|
48
|
+
|
|
49
|
+
# for mapping clicks → normalized x
|
|
50
|
+
self._click_mapping = None # dict or None
|
|
51
|
+
self.settings = QSettings()
|
|
52
|
+
self.sensor_max01 = 1.0
|
|
53
|
+
self.sensor_native_max = None # user ADU max (e.g., 65532)
|
|
54
|
+
self.native_theoretical_max = None
|
|
55
|
+
|
|
56
|
+
# histogram cache
|
|
57
|
+
self._bin_count = 512
|
|
58
|
+
self._bin_edges_lin = None # np.ndarray | None
|
|
59
|
+
self._bin_edges_log = None # np.ndarray | None
|
|
60
|
+
self._counts_lin = None # list[np.ndarray] | None
|
|
61
|
+
self._counts_log = None # list[np.ndarray] | None
|
|
62
|
+
self._is_color = False
|
|
63
|
+
self._eps_log = 1e-6 # first log bin edge (for labels)
|
|
64
|
+
|
|
65
|
+
self._load_sensor_max_setting()
|
|
66
|
+
self._build_ui()
|
|
67
|
+
|
|
68
|
+
# debounce timer for resize / splitter moves
|
|
69
|
+
self._resize_timer = QTimer(self)
|
|
70
|
+
self._resize_timer.setSingleShot(True)
|
|
71
|
+
self._resize_timer.setInterval(80) # ms; tweak if you want snappier/slower
|
|
72
|
+
self._resize_timer.timeout.connect(self._draw_histogram)
|
|
73
|
+
|
|
74
|
+
# prime histogram & stats from initial image
|
|
75
|
+
self._recompute_hist_cache()
|
|
76
|
+
self._update_stats()
|
|
77
|
+
|
|
78
|
+
# wire up to this specific document
|
|
79
|
+
self.doc.changed.connect(self._on_doc_changed)
|
|
80
|
+
# If the doc object goes away, close this dialog
|
|
81
|
+
self.doc.destroyed.connect(self.deleteLater)
|
|
82
|
+
|
|
83
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
84
|
+
self._doc_conn = False
|
|
85
|
+
if getattr(self, "doc", None) is not None:
|
|
86
|
+
try:
|
|
87
|
+
self.doc.destroyed.connect(self._on_doc_destroyed)
|
|
88
|
+
self._doc_conn = True
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Do the first draw once the widget has a real size
|
|
93
|
+
QTimer.singleShot(0, self._draw_histogram)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------- UI ----------
|
|
98
|
+
def _build_ui(self):
|
|
99
|
+
# Make it start at a sensible size
|
|
100
|
+
self.setMinimumSize(800, 400)
|
|
101
|
+
self.resize(900, 500)
|
|
102
|
+
|
|
103
|
+
main_layout = QVBoxLayout(self)
|
|
104
|
+
|
|
105
|
+
# --- top area: splitter with histogram + stats ---
|
|
106
|
+
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
107
|
+
|
|
108
|
+
# left: scroll area + label for the pixmap
|
|
109
|
+
self.scroll_area = QScrollArea(self)
|
|
110
|
+
self.scroll_area.setWidgetResizable(True)
|
|
111
|
+
self.scroll_area.setSizePolicy(
|
|
112
|
+
QSizePolicy.Policy.Expanding,
|
|
113
|
+
QSizePolicy.Policy.Expanding,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self.hist_label = QLabel(self)
|
|
117
|
+
self.hist_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
118
|
+
self.scroll_area.setWidget(self.hist_label)
|
|
119
|
+
self.hist_label.installEventFilter(self)
|
|
120
|
+
self.hist_label.setToolTip(self.tr(
|
|
121
|
+
"Ctrl+Click on the histogram to send that intensity as the "
|
|
122
|
+
"pivot to Hyperbolic Stretch (if open)."
|
|
123
|
+
))
|
|
124
|
+
self.scroll_area.viewport().installEventFilter(self)
|
|
125
|
+
|
|
126
|
+
splitter.addWidget(self.scroll_area)
|
|
127
|
+
|
|
128
|
+
# right: stats table
|
|
129
|
+
self.stats_table = QTableWidget(self)
|
|
130
|
+
self.stats_table.setRowCount(7)
|
|
131
|
+
self.stats_table.setColumnCount(1)
|
|
132
|
+
self.stats_table.setVerticalHeaderLabels([
|
|
133
|
+
self.tr("Min"), self.tr("Max"), self.tr("Median"), self.tr("StdDev"),
|
|
134
|
+
self.tr("MAD"), self.tr("Low Clipped"), self.tr("High Clipped")
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
# Let it grow/shrink with the splitter
|
|
138
|
+
self.stats_table.setMinimumWidth(320)
|
|
139
|
+
self.stats_table.setSizePolicy(
|
|
140
|
+
QSizePolicy.Policy.Preferred, # <- was Fixed
|
|
141
|
+
QSizePolicy.Policy.Expanding,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Make the columns use available width nicely
|
|
145
|
+
hdr = self.stats_table.horizontalHeader()
|
|
146
|
+
hdr.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
147
|
+
# hdr.setStretchLastSection(True)
|
|
148
|
+
splitter.addWidget(self.stats_table)
|
|
149
|
+
|
|
150
|
+
# Give more space to histogram side by default
|
|
151
|
+
splitter.setStretchFactor(0, 3)
|
|
152
|
+
splitter.setStretchFactor(1, 1)
|
|
153
|
+
# Explicit initial sizes so it doesn't start with a tiny histogram
|
|
154
|
+
splitter.setSizes([650, 250])
|
|
155
|
+
|
|
156
|
+
QTimer.singleShot(0, self._adjust_stats_width)
|
|
157
|
+
|
|
158
|
+
main_layout.addWidget(splitter)
|
|
159
|
+
|
|
160
|
+
# --- controls row (unchanged except for being below splitter) ---
|
|
161
|
+
ctl = QHBoxLayout()
|
|
162
|
+
self.zoom_slider = QSlider(Qt.Orientation.Horizontal, self)
|
|
163
|
+
self.zoom_slider.setRange(50, 1000)
|
|
164
|
+
self.zoom_slider.setValue(100)
|
|
165
|
+
self.zoom_slider.setTickInterval(10)
|
|
166
|
+
self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
167
|
+
self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
|
168
|
+
|
|
169
|
+
ctl.addWidget(QLabel(self.tr("Zoom:")))
|
|
170
|
+
ctl.addWidget(self.zoom_slider)
|
|
171
|
+
|
|
172
|
+
self.btn_logx = QPushButton(self.tr("Toggle Log X-Axis"), self)
|
|
173
|
+
self.btn_logx.setCheckable(True)
|
|
174
|
+
self.btn_logx.toggled.connect(self._toggle_log_x)
|
|
175
|
+
ctl.addWidget(self.btn_logx)
|
|
176
|
+
|
|
177
|
+
self.btn_logy = QPushButton(self.tr("Toggle Log Y-Axis"), self)
|
|
178
|
+
self.btn_logy.setCheckable(True)
|
|
179
|
+
self.btn_logy.toggled.connect(self._toggle_log_y)
|
|
180
|
+
ctl.addWidget(self.btn_logy)
|
|
181
|
+
|
|
182
|
+
self.btn_sensor_max = QToolButton(self)
|
|
183
|
+
self.btn_sensor_max.setText("?")
|
|
184
|
+
self.btn_sensor_max.setToolTip(self.tr(
|
|
185
|
+
"Set your camera's true saturation level for clipping warnings.\n"
|
|
186
|
+
"Tip: take an overexposed frame and see its max ADU."
|
|
187
|
+
))
|
|
188
|
+
self.btn_sensor_max.clicked.connect(self._prompt_sensor_max)
|
|
189
|
+
ctl.addWidget(self.btn_sensor_max)
|
|
190
|
+
|
|
191
|
+
main_layout.addLayout(ctl)
|
|
192
|
+
|
|
193
|
+
btn_close = QPushButton(self.tr("Close"), self)
|
|
194
|
+
btn_close.clicked.connect(self.accept)
|
|
195
|
+
main_layout.addWidget(btn_close)
|
|
196
|
+
|
|
197
|
+
self.setLayout(main_layout)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------- slots ----------
|
|
202
|
+
def _on_doc_changed(self):
|
|
203
|
+
self.image = _to_float_preserve(self.doc.image)
|
|
204
|
+
self._recompute_hist_cache()
|
|
205
|
+
self._update_stats()
|
|
206
|
+
self._draw_histogram()
|
|
207
|
+
|
|
208
|
+
def _on_zoom_changed(self, v: int):
|
|
209
|
+
self.zoom_factor = v / 100.0
|
|
210
|
+
self._draw_histogram()
|
|
211
|
+
|
|
212
|
+
def _toggle_log_x(self, on: bool):
|
|
213
|
+
self.log_scale = bool(on)
|
|
214
|
+
self._draw_histogram()
|
|
215
|
+
|
|
216
|
+
def _toggle_log_y(self, on: bool):
|
|
217
|
+
self.log_y = bool(on)
|
|
218
|
+
self._draw_histogram()
|
|
219
|
+
|
|
220
|
+
# ---------- drawing ----------
|
|
221
|
+
# ---------- drawing ----------
|
|
222
|
+
def _draw_histogram(self):
|
|
223
|
+
# nothing to draw yet
|
|
224
|
+
if self.image is None or self._bin_edges_lin is None:
|
|
225
|
+
self.hist_label.clear()
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# use available size in the scroll area's viewport
|
|
229
|
+
if self.scroll_area is not None:
|
|
230
|
+
vp = self.scroll_area.viewport()
|
|
231
|
+
avail_w = max(200, vp.width())
|
|
232
|
+
avail_h = max(200, vp.height())
|
|
233
|
+
else:
|
|
234
|
+
avail_w = 512
|
|
235
|
+
avail_h = 300
|
|
236
|
+
|
|
237
|
+
base_width = avail_w
|
|
238
|
+
height = avail_h
|
|
239
|
+
width = int(base_width * self.zoom_factor)
|
|
240
|
+
|
|
241
|
+
# layout margins
|
|
242
|
+
left_margin = 32 # room for Y labels
|
|
243
|
+
top_margin = 12 # room so top ticks/text aren't clipped
|
|
244
|
+
bottom_margin = 24 # room for X labels
|
|
245
|
+
axis_y = height - bottom_margin
|
|
246
|
+
usable_h = max(1, axis_y - top_margin)
|
|
247
|
+
plot_width = max(1, width - left_margin)
|
|
248
|
+
|
|
249
|
+
# choose edges + raw counts from cache
|
|
250
|
+
if self.log_scale:
|
|
251
|
+
bin_edges = self._bin_edges_log
|
|
252
|
+
counts_list = self._counts_log
|
|
253
|
+
else:
|
|
254
|
+
bin_edges = self._bin_edges_lin
|
|
255
|
+
counts_list = self._counts_lin
|
|
256
|
+
|
|
257
|
+
if bin_edges is None or counts_list is None:
|
|
258
|
+
self.hist_label.clear()
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
bin_count = len(bin_edges) - 1
|
|
262
|
+
|
|
263
|
+
# precompute log range if needed
|
|
264
|
+
if self.log_scale:
|
|
265
|
+
# guard: avoid log10(<=0)
|
|
266
|
+
be0 = float(bin_edges[0])
|
|
267
|
+
if be0 <= 0:
|
|
268
|
+
be0 = self._eps_log
|
|
269
|
+
log_min = np.log10(be0)
|
|
270
|
+
log_max = 0.0
|
|
271
|
+
else:
|
|
272
|
+
log_min = None
|
|
273
|
+
log_max = None
|
|
274
|
+
|
|
275
|
+
# map X-domain edge → pixel X
|
|
276
|
+
def x_pos(edge: float) -> int:
|
|
277
|
+
if self.log_scale:
|
|
278
|
+
if edge <= 0:
|
|
279
|
+
edge = self._eps_log
|
|
280
|
+
if abs(log_max - log_min) < 1e-12:
|
|
281
|
+
return left_margin
|
|
282
|
+
return left_margin + int(
|
|
283
|
+
(np.log10(edge) - log_min) / (log_max - log_min) * plot_width
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
return left_margin + int(edge * plot_width)
|
|
287
|
+
|
|
288
|
+
# --- convert counts → display values (linear or log Y) ---
|
|
289
|
+
vals_list: list[np.ndarray] = []
|
|
290
|
+
max_val = 0.0
|
|
291
|
+
for counts in counts_list:
|
|
292
|
+
if self.log_y:
|
|
293
|
+
vals = np.log10(counts + 1.0)
|
|
294
|
+
else:
|
|
295
|
+
vals = counts.astype(np.float32)
|
|
296
|
+
if vals.size:
|
|
297
|
+
max_val = max(max_val, float(vals.max()))
|
|
298
|
+
vals_list.append(vals)
|
|
299
|
+
|
|
300
|
+
if max_val <= 0:
|
|
301
|
+
max_val = 1.0
|
|
302
|
+
|
|
303
|
+
# theme colors
|
|
304
|
+
pal = self.window().palette() if self.window() else self.palette()
|
|
305
|
+
bg_color = pal.color(QPalette.ColorRole.Window)
|
|
306
|
+
text_color = pal.color(QPalette.ColorRole.Text)
|
|
307
|
+
|
|
308
|
+
if bg_color.lightness() < 128:
|
|
309
|
+
axis_color = QColor(210, 210, 210)
|
|
310
|
+
label_color = QColor(245, 245, 245)
|
|
311
|
+
else:
|
|
312
|
+
axis_color = QColor(40, 40, 40)
|
|
313
|
+
label_color = text_color
|
|
314
|
+
|
|
315
|
+
grid_color = QColor(axis_color)
|
|
316
|
+
grid_color.setAlpha(60)
|
|
317
|
+
grid_pen = QPen(grid_color)
|
|
318
|
+
grid_pen.setWidth(1)
|
|
319
|
+
|
|
320
|
+
pm = QPixmap(width, height)
|
|
321
|
+
pm.fill(bg_color)
|
|
322
|
+
p = QPainter(pm)
|
|
323
|
+
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
324
|
+
|
|
325
|
+
# helper: map normalized [0,1] → Y pixel (0 at bottom, 1 at top)
|
|
326
|
+
def y_pos(norm: float) -> int:
|
|
327
|
+
# norm in [0,1], map 0→axis_y, 1→top_margin
|
|
328
|
+
return int(top_margin + (1.0 - norm) * usable_h)
|
|
329
|
+
|
|
330
|
+
# ----- draw bars -----
|
|
331
|
+
if self._is_color:
|
|
332
|
+
colors = [
|
|
333
|
+
QColor(255, 0, 0, 140),
|
|
334
|
+
QColor(0, 180, 0, 140),
|
|
335
|
+
QColor(0, 0, 255, 140),
|
|
336
|
+
]
|
|
337
|
+
for ch_idx, vals in enumerate(vals_list):
|
|
338
|
+
hn = vals / max_val
|
|
339
|
+
p.setPen(QPen(colors[ch_idx]))
|
|
340
|
+
for i in range(bin_count):
|
|
341
|
+
x0 = x_pos(float(bin_edges[i]))
|
|
342
|
+
x1 = x_pos(float(bin_edges[i + 1]))
|
|
343
|
+
w = max(1, x1 - x0)
|
|
344
|
+
h = int(hn[i] * usable_h)
|
|
345
|
+
y0 = axis_y - h
|
|
346
|
+
p.drawRect(x0, y0, w, h)
|
|
347
|
+
else:
|
|
348
|
+
vals = vals_list[0]
|
|
349
|
+
hn = vals / max_val
|
|
350
|
+
p.setPen(QPen(axis_color))
|
|
351
|
+
for i in range(bin_count):
|
|
352
|
+
x0 = x_pos(float(bin_edges[i]))
|
|
353
|
+
x1 = x_pos(float(bin_edges[i + 1]))
|
|
354
|
+
w = max(1, x1 - x0)
|
|
355
|
+
h = int(hn[i] * usable_h)
|
|
356
|
+
y0 = axis_y - h
|
|
357
|
+
p.drawRect(x0, y0, w, h)
|
|
358
|
+
|
|
359
|
+
# ----- axes -----
|
|
360
|
+
p.setPen(QPen(axis_color, 2))
|
|
361
|
+
# X axis at axis_y, Y axis from top_margin down to axis_y
|
|
362
|
+
p.drawLine(left_margin, axis_y, width - 1, axis_y)
|
|
363
|
+
p.drawLine(left_margin, top_margin, left_margin, axis_y)
|
|
364
|
+
|
|
365
|
+
p.setFont(QFont("Arial", 10))
|
|
366
|
+
|
|
367
|
+
# ----- X ticks + grid -----
|
|
368
|
+
if self.log_scale:
|
|
369
|
+
ticks = np.logspace(np.log10(bin_edges[0]), 0.0, 11)
|
|
370
|
+
for t in ticks:
|
|
371
|
+
x = x_pos(float(t))
|
|
372
|
+
if left_margin < x < width - 1:
|
|
373
|
+
p.setPen(grid_pen)
|
|
374
|
+
p.drawLine(x, top_margin, x, axis_y)
|
|
375
|
+
p.setPen(axis_color)
|
|
376
|
+
p.drawLine(x, axis_y, x, axis_y - 5)
|
|
377
|
+
p.setPen(label_color)
|
|
378
|
+
p.drawText(x - 18, axis_y + bottom_margin - 8, f"{t:.3f}")
|
|
379
|
+
else:
|
|
380
|
+
ticks = np.linspace(0.0, 1.0, 11)
|
|
381
|
+
for t in ticks:
|
|
382
|
+
x = x_pos(float(t))
|
|
383
|
+
if left_margin < x < width - 1:
|
|
384
|
+
p.setPen(grid_pen)
|
|
385
|
+
p.drawLine(x, top_margin, x, axis_y)
|
|
386
|
+
p.setPen(axis_color)
|
|
387
|
+
p.drawLine(x, axis_y, x, axis_y - 5)
|
|
388
|
+
p.setPen(label_color)
|
|
389
|
+
p.drawText(x - 10, axis_y + bottom_margin - 8, f"{t:.1f}")
|
|
390
|
+
|
|
391
|
+
# ----- Y ticks + grid -----
|
|
392
|
+
n_yticks = 6
|
|
393
|
+
if self.log_y:
|
|
394
|
+
exps = np.linspace(0.0, max_val, n_yticks)
|
|
395
|
+
norms = exps / max_val
|
|
396
|
+
labels = [f"{10**e:.0f}" for e in exps]
|
|
397
|
+
else:
|
|
398
|
+
vals_for_ticks = np.linspace(0.0, max_val, n_yticks)
|
|
399
|
+
norms = vals_for_ticks / max_val
|
|
400
|
+
labels = [f"{v:.0f}" for v in vals_for_ticks]
|
|
401
|
+
|
|
402
|
+
for i, (yn, lab) in enumerate(zip(norms, labels)):
|
|
403
|
+
y = y_pos(float(yn))
|
|
404
|
+
if 0 < i < n_yticks - 1:
|
|
405
|
+
p.setPen(grid_pen)
|
|
406
|
+
p.drawLine(left_margin, y, width - 1, y)
|
|
407
|
+
p.setPen(axis_color)
|
|
408
|
+
p.drawLine(left_margin - 5, y, left_margin, y)
|
|
409
|
+
p.setPen(label_color)
|
|
410
|
+
p.drawText(2, y + 4, lab)
|
|
411
|
+
|
|
412
|
+
# --- draw effective-max marker if user set one ---
|
|
413
|
+
if self.sensor_max01 < 0.9999:
|
|
414
|
+
x = x_pos(self.sensor_max01)
|
|
415
|
+
p.setPen(QPen(QColor(220, 0, 0), 2, Qt.PenStyle.DashLine))
|
|
416
|
+
p.drawLine(x, top_margin, x, axis_y)
|
|
417
|
+
p.drawText(min(x + 4, width - 80), top_margin + 12,
|
|
418
|
+
self.tr("True Max {0:.4f}").format(self.sensor_max01))
|
|
419
|
+
# store mapping info for Ctrl+click → normalized x
|
|
420
|
+
try:
|
|
421
|
+
self._click_mapping = {
|
|
422
|
+
"left_margin": left_margin,
|
|
423
|
+
"plot_width": plot_width,
|
|
424
|
+
"axis_y": axis_y,
|
|
425
|
+
"top_margin": top_margin,
|
|
426
|
+
"height": height,
|
|
427
|
+
"log_scale": bool(self.log_scale),
|
|
428
|
+
"log_min": log_min,
|
|
429
|
+
"log_max": log_max,
|
|
430
|
+
}
|
|
431
|
+
except Exception:
|
|
432
|
+
self._click_mapping = None
|
|
433
|
+
p.end()
|
|
434
|
+
self.hist_label.setPixmap(pm)
|
|
435
|
+
self.hist_label.resize(pm.size())
|
|
436
|
+
|
|
437
|
+
def _x_pix_to_u(self, x_pix: int) -> float | None:
|
|
438
|
+
"""
|
|
439
|
+
Map a horizontal pixel coordinate (in the label) to a normalized
|
|
440
|
+
intensity in [0..1], respecting linear / log X modes.
|
|
441
|
+
"""
|
|
442
|
+
m = self._click_mapping
|
|
443
|
+
if not m:
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
left = m["left_margin"]
|
|
447
|
+
width = max(1, m["plot_width"])
|
|
448
|
+
if x_pix < left or x_pix > left + width:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
t = (x_pix - left) / float(width)
|
|
452
|
+
t = max(0.0, min(1.0, t))
|
|
453
|
+
|
|
454
|
+
if not m["log_scale"]:
|
|
455
|
+
# linear: domain is already [0..1]
|
|
456
|
+
return float(t)
|
|
457
|
+
|
|
458
|
+
# log X: t in [0..1] corresponds to [10^log_min .. 10^log_max] (log_max ~ 0)
|
|
459
|
+
log_min = m.get("log_min", None)
|
|
460
|
+
log_max = m.get("log_max", None)
|
|
461
|
+
if log_min is None or log_max is None or abs(log_max - log_min) < 1e-12:
|
|
462
|
+
return float(t)
|
|
463
|
+
|
|
464
|
+
log_v = log_min + t * (log_max - log_min)
|
|
465
|
+
v = 10.0 ** log_v
|
|
466
|
+
# v is in (eps .. 1]; clamp to [0..1]
|
|
467
|
+
return float(max(0.0, min(1.0, v)))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _recompute_hist_cache(self):
|
|
471
|
+
"""Compute histograms once for the current image.
|
|
472
|
+
|
|
473
|
+
This is called when the document image changes. Resizing / zooming
|
|
474
|
+
will only redraw using this cached data.
|
|
475
|
+
"""
|
|
476
|
+
img = self.image
|
|
477
|
+
self._bin_edges_lin = None
|
|
478
|
+
self._bin_edges_log = None
|
|
479
|
+
self._counts_lin = None
|
|
480
|
+
self._counts_log = None
|
|
481
|
+
self._is_color = False
|
|
482
|
+
self._eps_log = 1e-6
|
|
483
|
+
|
|
484
|
+
if img is None:
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
a = img
|
|
488
|
+
if a.ndim == 3 and a.shape[2] == 1:
|
|
489
|
+
a = a[..., 0]
|
|
490
|
+
|
|
491
|
+
if a.ndim == 3 and a.shape[2] == 3:
|
|
492
|
+
chans = [a[..., i] for i in range(3)]
|
|
493
|
+
self._is_color = True
|
|
494
|
+
else:
|
|
495
|
+
chan = a if a.ndim == 2 else a[..., 0]
|
|
496
|
+
chans = [chan]
|
|
497
|
+
self._is_color = False
|
|
498
|
+
|
|
499
|
+
bin_count = self._bin_count
|
|
500
|
+
|
|
501
|
+
# --- linear X bins ---
|
|
502
|
+
bin_edges_lin = np.linspace(0.0, 1.0, bin_count + 1).astype(np.float32)
|
|
503
|
+
counts_lin: list[np.ndarray] = []
|
|
504
|
+
for c in chans:
|
|
505
|
+
counts, _ = np.histogram(c.ravel(), bins=bin_edges_lin)
|
|
506
|
+
counts_lin.append(counts.astype(np.float32))
|
|
507
|
+
|
|
508
|
+
# --- log X bins ---
|
|
509
|
+
pos = a[a > 0]
|
|
510
|
+
eps = max(1e-6, float(pos.min())) if pos.size else 1e-6
|
|
511
|
+
log_min, log_max = np.log10(eps), 0.0
|
|
512
|
+
if abs(log_max - log_min) < 1e-12:
|
|
513
|
+
bin_edges_log = np.linspace(eps, 1.0, bin_count + 1).astype(np.float32)
|
|
514
|
+
else:
|
|
515
|
+
bin_edges_log = np.logspace(log_min, log_max, bin_count + 1).astype(np.float32)
|
|
516
|
+
|
|
517
|
+
counts_log: list[np.ndarray] = []
|
|
518
|
+
for c in chans:
|
|
519
|
+
counts, _ = np.histogram(c.ravel(), bins=bin_edges_log)
|
|
520
|
+
counts_log.append(counts.astype(np.float32))
|
|
521
|
+
|
|
522
|
+
self._bin_edges_lin = bin_edges_lin
|
|
523
|
+
self._bin_edges_log = bin_edges_log
|
|
524
|
+
self._counts_lin = counts_lin
|
|
525
|
+
self._counts_log = counts_log
|
|
526
|
+
self._eps_log = float(eps)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _schedule_redraw(self):
|
|
530
|
+
# Only bother if visible; restart timer each time
|
|
531
|
+
if self.isVisible():
|
|
532
|
+
self._resize_timer.start()
|
|
533
|
+
|
|
534
|
+
def resizeEvent(self, event):
|
|
535
|
+
super().resizeEvent(event)
|
|
536
|
+
self._schedule_redraw()
|
|
537
|
+
|
|
538
|
+
def eventFilter(self, obj, event):
|
|
539
|
+
# Ctrl+click on the histogram pixmap → emit pivotPicked(u)
|
|
540
|
+
if obj is self.hist_label and event.type() == QEvent.Type.MouseButtonPress:
|
|
541
|
+
if (event.button() == Qt.MouseButton.LeftButton and
|
|
542
|
+
(event.modifiers() & Qt.KeyboardModifier.ControlModifier)):
|
|
543
|
+
pos = event.position().toPoint()
|
|
544
|
+
u = self._x_pix_to_u(pos.x())
|
|
545
|
+
if u is not None:
|
|
546
|
+
# emit normalized pivot in [0..1]
|
|
547
|
+
self.pivotPicked.emit(u)
|
|
548
|
+
event.accept()
|
|
549
|
+
return True
|
|
550
|
+
|
|
551
|
+
# When the splitter moves, the scroll_area viewport gets a Resize event
|
|
552
|
+
if self.scroll_area is not None and obj is self.scroll_area.viewport():
|
|
553
|
+
if event.type() == QEvent.Type.Resize:
|
|
554
|
+
self._schedule_redraw()
|
|
555
|
+
|
|
556
|
+
return super().eventFilter(obj, event)
|
|
557
|
+
|
|
558
|
+
def _update_stats(self):
|
|
559
|
+
if self.image is None:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
img = self.image
|
|
563
|
+
# determine channels
|
|
564
|
+
if img.ndim == 3 and img.shape[2] == 3:
|
|
565
|
+
chans = [img[..., i] for i in range(3)]
|
|
566
|
+
self.stats_table.setColumnCount(3)
|
|
567
|
+
self.stats_table.setHorizontalHeaderLabels(["R", "G", "B"])
|
|
568
|
+
else:
|
|
569
|
+
chan = img if img.ndim == 2 else img[..., 0]
|
|
570
|
+
chans = [chan]
|
|
571
|
+
self.stats_table.setColumnCount(1)
|
|
572
|
+
self.stats_table.setHorizontalHeaderLabels(["Gray"])
|
|
573
|
+
|
|
574
|
+
eps = 1e-6 # tolerance for "exactly 0/1" after float ops
|
|
575
|
+
|
|
576
|
+
row_defs = [
|
|
577
|
+
(self.tr("Min"), lambda c: float(np.min(c)), "{:.4f}"),
|
|
578
|
+
(self.tr("Max"), lambda c: float(np.max(c)), "{:.4f}"),
|
|
579
|
+
(self.tr("Median"), lambda c: float(np.median(c)), "{:.4f}"),
|
|
580
|
+
(self.tr("StdDev"), lambda c: float(np.std(c)), "{:.4f}"),
|
|
581
|
+
(self.tr("MAD"), lambda c: float(np.median(np.abs(c - np.median(c)))), "{:.4f}"),
|
|
582
|
+
(self.tr("Low Clipped"), lambda c: _clip_fmt(c, low=True, eps=eps), "{}"),
|
|
583
|
+
(self.tr("High Clipped"), lambda c: _clip_fmt(c, low=False, eps=eps), "{}"),
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
def _clip_fmt(c, low: bool, eps: float):
|
|
587
|
+
flat = np.ravel(c)
|
|
588
|
+
n = flat.size if flat.size else 1
|
|
589
|
+
if low:
|
|
590
|
+
k = int(np.count_nonzero(flat <= eps))
|
|
591
|
+
else:
|
|
592
|
+
hi_thr = max(eps, self.sensor_max01 - eps)
|
|
593
|
+
k = int(np.count_nonzero(flat >= hi_thr))
|
|
594
|
+
pct = 100.0 * k / n
|
|
595
|
+
return f"{k} ({pct:.3f}%)"
|
|
596
|
+
|
|
597
|
+
# apply labels + sizes
|
|
598
|
+
self.stats_table.setRowCount(len(row_defs))
|
|
599
|
+
self.stats_table.setVerticalHeaderLabels([lab for lab, _, _ in row_defs])
|
|
600
|
+
|
|
601
|
+
# fill cells
|
|
602
|
+
for r, (lab, fn, fmt) in enumerate(row_defs):
|
|
603
|
+
for c_idx, c_arr in enumerate(chans):
|
|
604
|
+
val = fn(c_arr)
|
|
605
|
+
text = fmt.format(val)
|
|
606
|
+
it = QTableWidgetItem(text)
|
|
607
|
+
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
608
|
+
|
|
609
|
+
# --- visual pop for non-trivial clipping ---
|
|
610
|
+
if lab in (self.tr("Low Clipped"), self.tr("High Clipped")):
|
|
611
|
+
# text looks like: "123 (0.456%)"
|
|
612
|
+
try:
|
|
613
|
+
pct_str = text.split("(")[1].split("%")[0]
|
|
614
|
+
pct = float(pct_str)
|
|
615
|
+
except Exception:
|
|
616
|
+
pct = 0.0
|
|
617
|
+
|
|
618
|
+
# thresholds you can tweak
|
|
619
|
+
# <0.01%: ignore
|
|
620
|
+
# 0.01–0.1%: mild warning
|
|
621
|
+
# 0.1–1%: clear warning
|
|
622
|
+
# >1%: strong warning
|
|
623
|
+
if pct >= 1.0:
|
|
624
|
+
it.setBackground(QColor(100, 30, 30)) # strong red tint
|
|
625
|
+
elif pct >= 0.1:
|
|
626
|
+
it.setBackground(QColor(70, 30, 30)) # medium red tint
|
|
627
|
+
elif pct >= 0.01:
|
|
628
|
+
it.setBackground(QColor(40, 30, 30)) # mild red tint
|
|
629
|
+
|
|
630
|
+
self.stats_table.setItem(r, c_idx, it)
|
|
631
|
+
|
|
632
|
+
self._adjust_stats_width()
|
|
633
|
+
|
|
634
|
+
def _theoretical_native_max_from_meta(self):
|
|
635
|
+
meta = getattr(self.doc, "metadata", None) or {}
|
|
636
|
+
bd = str(meta.get("bit_depth", "")).lower()
|
|
637
|
+
|
|
638
|
+
if "16-bit" in bd:
|
|
639
|
+
return 65535
|
|
640
|
+
if "8-bit" in bd:
|
|
641
|
+
return 255
|
|
642
|
+
if "32-bit unsigned" in bd:
|
|
643
|
+
return 4294967295
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
def _settings_key_for_native_max(self, native_theoretical_max):
|
|
647
|
+
if native_theoretical_max == 65535:
|
|
648
|
+
return "histogram/sensor_max_native_16"
|
|
649
|
+
if native_theoretical_max == 255:
|
|
650
|
+
return "histogram/sensor_max_native_8"
|
|
651
|
+
if native_theoretical_max == 4294967295:
|
|
652
|
+
return "histogram/sensor_max_native_32u"
|
|
653
|
+
return "histogram/sensor_max_native_generic"
|
|
654
|
+
|
|
655
|
+
def _load_sensor_max_setting(self):
|
|
656
|
+
self.native_theoretical_max = self._theoretical_native_max_from_meta()
|
|
657
|
+
if self.native_theoretical_max:
|
|
658
|
+
key = self._settings_key_for_native_max(self.native_theoretical_max)
|
|
659
|
+
val = self.settings.value(key, None)
|
|
660
|
+
if val is not None:
|
|
661
|
+
try:
|
|
662
|
+
self.sensor_native_max = float(val)
|
|
663
|
+
except Exception:
|
|
664
|
+
self.sensor_native_max = None
|
|
665
|
+
|
|
666
|
+
self._recompute_effective_max01()
|
|
667
|
+
|
|
668
|
+
def _recompute_effective_max01(self):
|
|
669
|
+
if self.native_theoretical_max and self.sensor_native_max:
|
|
670
|
+
self.sensor_max01 = float(self.sensor_native_max) / float(self.native_theoretical_max)
|
|
671
|
+
self.sensor_max01 = float(np.clip(self.sensor_max01, 1e-6, 1.0))
|
|
672
|
+
else:
|
|
673
|
+
self.sensor_max01 = 1.0
|
|
674
|
+
|
|
675
|
+
def _prompt_sensor_max(self):
|
|
676
|
+
self.native_theoretical_max = self._theoretical_native_max_from_meta()
|
|
677
|
+
|
|
678
|
+
if self.native_theoretical_max:
|
|
679
|
+
key = self._settings_key_for_native_max(self.native_theoretical_max)
|
|
680
|
+
current = self.sensor_native_max or self.native_theoretical_max
|
|
681
|
+
|
|
682
|
+
val, ok = QInputDialog.getInt(
|
|
683
|
+
self,
|
|
684
|
+
self.tr("Sensor True Max (ADU)"),
|
|
685
|
+
self.tr("Enter your sensor's true saturation value in native ADU.\n"
|
|
686
|
+
"(Typical max for this file type is {0})\n\n"
|
|
687
|
+
"You can measure this by taking a deliberately overexposed frame\n"
|
|
688
|
+
"and reading its maximum pixel value.").format(self.native_theoretical_max),
|
|
689
|
+
int(current),
|
|
690
|
+
1,
|
|
691
|
+
int(self.native_theoretical_max)
|
|
692
|
+
)
|
|
693
|
+
if ok:
|
|
694
|
+
self.sensor_native_max = float(val)
|
|
695
|
+
self.settings.setValue(key, float(val))
|
|
696
|
+
else:
|
|
697
|
+
# float images / unknown depth: allow normalized max
|
|
698
|
+
val, ok = QInputDialog.getDouble(
|
|
699
|
+
self,
|
|
700
|
+
self.tr("Histogram Effective Max"),
|
|
701
|
+
self.tr("Enter effective maximum for clipping (normalized units)."),
|
|
702
|
+
float(self.sensor_max01),
|
|
703
|
+
1e-6,
|
|
704
|
+
1.0,
|
|
705
|
+
6
|
|
706
|
+
)
|
|
707
|
+
if ok:
|
|
708
|
+
self.sensor_max01 = float(val)
|
|
709
|
+
self.settings.setValue("histogram/sensor_max01_generic", float(val))
|
|
710
|
+
|
|
711
|
+
self._recompute_effective_max01()
|
|
712
|
+
self._update_stats() # High Clipped row depends on sensor_max01
|
|
713
|
+
self._draw_histogram()
|
|
714
|
+
|
|
715
|
+
def _adjust_stats_width(self):
|
|
716
|
+
"""Resize stats table so all columns are visible without a scrollbar."""
|
|
717
|
+
if not self.stats_table:
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
# Let Qt compute natural column widths
|
|
721
|
+
self.stats_table.resizeColumnsToContents()
|
|
722
|
+
self.stats_table.resizeRowsToContents()
|
|
723
|
+
|
|
724
|
+
vh = self.stats_table.verticalHeader()
|
|
725
|
+
frame = self.stats_table.frameWidth()
|
|
726
|
+
|
|
727
|
+
total_w = vh.width() + 2 * frame
|
|
728
|
+
|
|
729
|
+
for col in range(self.stats_table.columnCount()):
|
|
730
|
+
total_w += self.stats_table.columnWidth(col)
|
|
731
|
+
|
|
732
|
+
# Room for a possible vertical scrollbar
|
|
733
|
+
vbar = self.stats_table.verticalScrollBar()
|
|
734
|
+
if vbar is not None:
|
|
735
|
+
total_w += vbar.sizeHint().width()
|
|
736
|
+
|
|
737
|
+
# A tiny padding so text isn't tight
|
|
738
|
+
total_w += 6
|
|
739
|
+
|
|
740
|
+
self.stats_table.setMinimumWidth(total_w)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _on_doc_destroyed(self, *args):
|
|
744
|
+
# Called when the owner/document goes away.
|
|
745
|
+
try:
|
|
746
|
+
# Avoid re-entrancy; schedule deletion safely.
|
|
747
|
+
self.deleteLater()
|
|
748
|
+
except RuntimeError:
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
def closeEvent(self, event):
|
|
752
|
+
# Cleanly disconnect to avoid stray callbacks.
|
|
753
|
+
if getattr(self, "_doc_conn", False) and getattr(self, "doc", None) is not None:
|
|
754
|
+
try:
|
|
755
|
+
self.doc.destroyed.disconnect(self._on_doc_destroyed)
|
|
756
|
+
except (TypeError, RuntimeError):
|
|
757
|
+
pass
|
|
758
|
+
self._doc_conn = False
|
|
759
|
+
|
|
760
|
+
super().closeEvent(event)
|