setiastrosuitepro 1.6.5.post3__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.
- 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/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/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.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/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/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/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 +126 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +958 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1346 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +698 -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/add_stars.py +624 -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 +611 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +491 -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 +368 -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 +1617 -0
- setiastro/saspro/convo.py +1400 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1589 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +983 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2562 -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 +2664 -0
- setiastro/saspro/exoplanet_detector.py +2166 -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 +1349 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +663 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +637 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8792 -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 +390 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
- setiastro/saspro/gui/mixins/update_mixin.py +323 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +488 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +756 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +417 -0
- setiastro/saspro/image_peeker_pro.py +1604 -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 +1182 -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 +1086 -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 +407 -0
- setiastro/saspro/multiscale_decomp.py +1747 -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 +2445 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -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 +503 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -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 +1611 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3116 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +19066 -0
- setiastro/saspro/star_alignment.py +7380 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +765 -0
- setiastro/saspro/star_stretch.py +507 -0
- setiastro/saspro/stat_stretch.py +538 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3407 -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 +645 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +680 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +513 -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 +263 -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 +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1213 -0
- setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
- setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
# pro/multiscale_decomp.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
import os
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from PyQt6.QtCore import Qt, QTimer, QRect, QRectF
|
|
9
|
+
from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon, QMovie
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout,
|
|
12
|
+
QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox,
|
|
13
|
+
QTableWidget, QTableWidgetItem, QWidget, QLabel, QGraphicsView,
|
|
14
|
+
QGraphicsScene, QGraphicsPixmapItem, QMessageBox, QToolButton, QSlider, QSplitter,
|
|
15
|
+
QProgressDialog, QApplication
|
|
16
|
+
)
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from setiastro.saspro.resources import get_resources
|
|
19
|
+
try:
|
|
20
|
+
cv2.setUseOptimized(True)
|
|
21
|
+
cv2.setNumThreads(0) # 0 = let OpenCV decide
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
class _ZoomPanView(QGraphicsView):
|
|
26
|
+
"""
|
|
27
|
+
QGraphicsView that supports wheel-zoom and click-drag panning.
|
|
28
|
+
Calls on_view_changed() whenever viewport position/scale changes.
|
|
29
|
+
"""
|
|
30
|
+
def __init__(self, *args, on_view_changed=None, **kwargs):
|
|
31
|
+
super().__init__(*args, **kwargs)
|
|
32
|
+
self.setDragMode(QGraphicsView.DragMode.NoDrag)
|
|
33
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
34
|
+
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
35
|
+
|
|
36
|
+
self._panning = False
|
|
37
|
+
self._pan_start = None
|
|
38
|
+
self._on_view_changed = on_view_changed # callable or None
|
|
39
|
+
|
|
40
|
+
def _notify(self):
|
|
41
|
+
cb = self._on_view_changed
|
|
42
|
+
if callable(cb):
|
|
43
|
+
cb()
|
|
44
|
+
|
|
45
|
+
def wheelEvent(self, ev):
|
|
46
|
+
delta = ev.angleDelta().y()
|
|
47
|
+
if delta == 0:
|
|
48
|
+
return
|
|
49
|
+
factor = 1.25 if delta > 0 else 0.8
|
|
50
|
+
self.scale(factor, factor)
|
|
51
|
+
ev.accept()
|
|
52
|
+
self._notify()
|
|
53
|
+
|
|
54
|
+
def mousePressEvent(self, ev):
|
|
55
|
+
if ev.button() == Qt.MouseButton.LeftButton:
|
|
56
|
+
self._panning = True
|
|
57
|
+
self._pan_start = ev.pos()
|
|
58
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
59
|
+
ev.accept()
|
|
60
|
+
return
|
|
61
|
+
super().mousePressEvent(ev)
|
|
62
|
+
|
|
63
|
+
def mouseMoveEvent(self, ev):
|
|
64
|
+
if self._panning and self._pan_start is not None:
|
|
65
|
+
delta = ev.pos() - self._pan_start
|
|
66
|
+
self._pan_start = ev.pos()
|
|
67
|
+
|
|
68
|
+
h = self.horizontalScrollBar()
|
|
69
|
+
v = self.verticalScrollBar()
|
|
70
|
+
h.setValue(h.value() - delta.x())
|
|
71
|
+
v.setValue(v.value() - delta.y())
|
|
72
|
+
ev.accept()
|
|
73
|
+
# scrollbars will trigger _notify via their signals too, but harmless:
|
|
74
|
+
self._notify()
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
super().mouseMoveEvent(ev)
|
|
78
|
+
|
|
79
|
+
def mouseReleaseEvent(self, ev):
|
|
80
|
+
if ev.button() == Qt.MouseButton.LeftButton and self._panning:
|
|
81
|
+
self._panning = False
|
|
82
|
+
self._pan_start = None
|
|
83
|
+
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
84
|
+
ev.accept()
|
|
85
|
+
return
|
|
86
|
+
super().mouseReleaseEvent(ev)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─────────────────────────────────────────────
|
|
91
|
+
# Core math (your backbone)
|
|
92
|
+
# ─────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def _blur_gaussian(img01: np.ndarray, sigma: float) -> np.ndarray:
|
|
95
|
+
k = int(max(3, 2 * round(3 * sigma) + 1)) # odd
|
|
96
|
+
return cv2.GaussianBlur(img01, (k, k), sigmaX=sigma, sigmaY=sigma, borderType=cv2.BORDER_REFLECT)
|
|
97
|
+
|
|
98
|
+
def multiscale_decompose(img01: np.ndarray, layers: int, base_sigma: float = 1.0):
|
|
99
|
+
c = img01.astype(np.float32, copy=False)
|
|
100
|
+
details = []
|
|
101
|
+
for k in range(layers):
|
|
102
|
+
sigma = base_sigma * (2 ** k)
|
|
103
|
+
c_next = _blur_gaussian(c, sigma)
|
|
104
|
+
w = c - c_next
|
|
105
|
+
details.append(w)
|
|
106
|
+
c = c_next
|
|
107
|
+
residual = c
|
|
108
|
+
return details, residual
|
|
109
|
+
|
|
110
|
+
def multiscale_reconstruct(details, residual):
|
|
111
|
+
out = residual.astype(np.float32, copy=True)
|
|
112
|
+
for w in details:
|
|
113
|
+
out += w
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
def soft_threshold(x: np.ndarray, t: float):
|
|
117
|
+
a = np.abs(x)
|
|
118
|
+
return np.sign(x) * np.maximum(0.0, a - t)
|
|
119
|
+
|
|
120
|
+
def apply_layer_ops(
|
|
121
|
+
w: np.ndarray,
|
|
122
|
+
bias_gain: float,
|
|
123
|
+
thr_sigma: float, # threshold in units of σ
|
|
124
|
+
amount: float,
|
|
125
|
+
denoise_strength: float = 0.0,
|
|
126
|
+
sigma: float | np.ndarray | None = None,
|
|
127
|
+
*,
|
|
128
|
+
mode: str = "μ–σ Thresholding",
|
|
129
|
+
):
|
|
130
|
+
w2 = w
|
|
131
|
+
|
|
132
|
+
# Normalize mode to something robust to label wording
|
|
133
|
+
m = (mode or "").strip().lower()
|
|
134
|
+
is_linear = m.startswith("linear")
|
|
135
|
+
|
|
136
|
+
# --- Linear mode: strictly linear multiscale transform ---
|
|
137
|
+
if is_linear:
|
|
138
|
+
# Ignore thresholding and denoise; just apply gain
|
|
139
|
+
if abs(bias_gain - 1.0) > 1e-6:
|
|
140
|
+
return w * bias_gain
|
|
141
|
+
return w
|
|
142
|
+
|
|
143
|
+
# --- μ–σ Thresholding mode (robust nonlinear) ---
|
|
144
|
+
# 1) Noise reduction step (MMT-style NR)
|
|
145
|
+
if denoise_strength > 0.0:
|
|
146
|
+
if sigma is None:
|
|
147
|
+
sigma = _robust_sigma(w2)
|
|
148
|
+
sigma_f = float(sigma)
|
|
149
|
+
# 3σ at denoise=1, scaled linearly
|
|
150
|
+
t_dn = max(0.0, denoise_strength * 3.0 * sigma_f)
|
|
151
|
+
if t_dn > 0.0:
|
|
152
|
+
w_dn = soft_threshold(w2, t_dn)
|
|
153
|
+
# Blend original vs denoised based on denoise_strength
|
|
154
|
+
w2 = (1.0 - denoise_strength) * w2 + denoise_strength * w_dn
|
|
155
|
+
|
|
156
|
+
# 2) Threshold in σ units + bias shaping
|
|
157
|
+
if thr_sigma > 0.0:
|
|
158
|
+
if sigma is None:
|
|
159
|
+
sigma = _robust_sigma(w2)
|
|
160
|
+
sigma_f = float(sigma)
|
|
161
|
+
t = thr_sigma * sigma_f # convert N·σ → absolute threshold
|
|
162
|
+
if t > 0.0:
|
|
163
|
+
wt = soft_threshold(w2, t)
|
|
164
|
+
w2 = (1.0 - amount) * w2 + amount * wt
|
|
165
|
+
|
|
166
|
+
if abs(bias_gain - 1.0) > 1e-6:
|
|
167
|
+
w2 = w2 * bias_gain
|
|
168
|
+
return w2
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _robust_sigma(arr: np.ndarray) -> float:
|
|
172
|
+
"""
|
|
173
|
+
Robust per-layer sigma estimate using MAD, fallback to std if needed.
|
|
174
|
+
Ignores NaN/Inf and uses a subset if very large.
|
|
175
|
+
"""
|
|
176
|
+
a = np.asarray(arr, dtype=np.float32)
|
|
177
|
+
a = a[np.isfinite(a)]
|
|
178
|
+
if a.size == 0:
|
|
179
|
+
return 1e-6
|
|
180
|
+
|
|
181
|
+
# Optional: subsample for speed on huge arrays
|
|
182
|
+
if a.size > 500_000:
|
|
183
|
+
idx = np.random.choice(a.size, 500_000, replace=False)
|
|
184
|
+
a = a[idx]
|
|
185
|
+
|
|
186
|
+
med = np.median(a)
|
|
187
|
+
mad = np.median(np.abs(a - med))
|
|
188
|
+
if mad <= 0:
|
|
189
|
+
# fallback to plain std if MAD degenerates
|
|
190
|
+
s = float(np.std(a))
|
|
191
|
+
return s if s > 0 else 1e-6
|
|
192
|
+
|
|
193
|
+
sigma = 1.4826 * mad # MAD → σ for Gaussian
|
|
194
|
+
return sigma if sigma > 0 else 1e-6
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ─────────────────────────────────────────────
|
|
198
|
+
# Layer config
|
|
199
|
+
# ─────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class LayerCfg:
|
|
204
|
+
enabled: bool = True
|
|
205
|
+
bias_gain: float = 1.0 # 1.0 = unchanged
|
|
206
|
+
thr: float = 0.0 # soft threshold in detail domain
|
|
207
|
+
amount: float = 0.0 # 0..1 blend toward thresholded
|
|
208
|
+
denoise: float = 0.0 # 0..1 additional noise reduction
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ─────────────────────────────────────────────
|
|
212
|
+
# Dialog
|
|
213
|
+
# ─────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
class MultiscaleDecompDialog(QDialog):
|
|
216
|
+
def __init__(self, parent, doc):
|
|
217
|
+
super().__init__(parent)
|
|
218
|
+
self.setWindowTitle("Multiscale Decomposition")
|
|
219
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
220
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
221
|
+
self.setModal(False)
|
|
222
|
+
self.setMinimumSize(1050, 700)
|
|
223
|
+
self.residual_enabled = True
|
|
224
|
+
self._layer_noise = None # list[float] per detail layer
|
|
225
|
+
self._cached_coarse = None
|
|
226
|
+
self._cached_img_id = None
|
|
227
|
+
self._doc = doc
|
|
228
|
+
base = getattr(doc, "image", None)
|
|
229
|
+
if base is None:
|
|
230
|
+
raise RuntimeError("Document has no image.")
|
|
231
|
+
|
|
232
|
+
# normalize to float32 [0..1] ...
|
|
233
|
+
img0 = np.asarray(base)
|
|
234
|
+
is_int = (img0.dtype.kind in "ui")
|
|
235
|
+
|
|
236
|
+
img = img0.astype(np.float32, copy=False)
|
|
237
|
+
if is_int:
|
|
238
|
+
maxv = float(np.nanmax(img0)) or 1.0
|
|
239
|
+
img = img / max(1.0, maxv)
|
|
240
|
+
img = np.clip(img, 0.0, 1.0).astype(np.float32, copy=False)
|
|
241
|
+
|
|
242
|
+
self._orig_shape = img.shape
|
|
243
|
+
self._orig_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
|
|
244
|
+
|
|
245
|
+
# force display buffer to 3ch ...
|
|
246
|
+
if img.ndim == 2:
|
|
247
|
+
img3 = np.repeat(img[:, :, None], 3, axis=2)
|
|
248
|
+
elif img.ndim == 3 and img.shape[2] == 1:
|
|
249
|
+
img3 = np.repeat(img, 3, axis=2)
|
|
250
|
+
else:
|
|
251
|
+
img3 = img[:, :, :3]
|
|
252
|
+
|
|
253
|
+
self._image = img3.copy() # working linear image (edited on Apply only)
|
|
254
|
+
self._preview_img = img3.copy()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# decomposition cache
|
|
258
|
+
self._cached_layers = None
|
|
259
|
+
self._cached_residual = None
|
|
260
|
+
self._cached_key = None
|
|
261
|
+
|
|
262
|
+
# per-layer configs
|
|
263
|
+
self.layers = 4
|
|
264
|
+
self.base_sigma = 1.0
|
|
265
|
+
self.cfgs: list[LayerCfg] = [LayerCfg() for _ in range(self.layers)]
|
|
266
|
+
|
|
267
|
+
# debounce preview updates
|
|
268
|
+
self._preview_timer = QTimer(self)
|
|
269
|
+
self._preview_timer.setSingleShot(True)
|
|
270
|
+
self._preview_timer.timeout.connect(self._rebuild_preview)
|
|
271
|
+
|
|
272
|
+
self._build_ui()
|
|
273
|
+
H, W = self._image.shape[:2]
|
|
274
|
+
self.scene.setSceneRect(QRectF(0, 0, W, H))
|
|
275
|
+
# ───── NEW: initialization busy dialog ─────
|
|
276
|
+
prog = QProgressDialog("Initializing multiscale decomposition…", "", 0, 0, self)
|
|
277
|
+
prog.setWindowTitle("Multiscale Decomposition")
|
|
278
|
+
prog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
|
279
|
+
prog.setCancelButton(None) # no cancel button, just a busy indicator
|
|
280
|
+
prog.setMinimumDuration(0) # show immediately
|
|
281
|
+
prog.show()
|
|
282
|
+
QApplication.processEvents()
|
|
283
|
+
|
|
284
|
+
# heavy work (MADs, blurs, etc.)
|
|
285
|
+
self._recompute_decomp(force=True)
|
|
286
|
+
self._rebuild_preview()
|
|
287
|
+
|
|
288
|
+
prog.close()
|
|
289
|
+
# ─────────────── END NEW ───────────────
|
|
290
|
+
|
|
291
|
+
QTimer.singleShot(0, self._fit_view)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------- UI ----------
|
|
295
|
+
def _build_ui(self):
|
|
296
|
+
root = QHBoxLayout(self)
|
|
297
|
+
|
|
298
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
299
|
+
root.addWidget(splitter)
|
|
300
|
+
|
|
301
|
+
# ----- LEFT: preview -----
|
|
302
|
+
left_widget = QWidget(self)
|
|
303
|
+
left = QVBoxLayout(left_widget)
|
|
304
|
+
|
|
305
|
+
self.scene = QGraphicsScene(self)
|
|
306
|
+
|
|
307
|
+
self.view = _ZoomPanView(self.scene, on_view_changed=self._schedule_roi_preview)
|
|
308
|
+
self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
309
|
+
|
|
310
|
+
# Base full-image item (keeps zoom/pan working)
|
|
311
|
+
self.pix_base = QGraphicsPixmapItem()
|
|
312
|
+
self.pix_base.setOffset(0, 0)
|
|
313
|
+
self.scene.addItem(self.pix_base)
|
|
314
|
+
|
|
315
|
+
# ROI overlay item (updates fast)
|
|
316
|
+
self.pix_roi = QGraphicsPixmapItem()
|
|
317
|
+
self.pix_roi.setZValue(10) # draw above base
|
|
318
|
+
self.scene.addItem(self.pix_roi)
|
|
319
|
+
|
|
320
|
+
left.addWidget(self.view)
|
|
321
|
+
# Busy overlay (shown during recompute)
|
|
322
|
+
self.busy_label = QLabel("Computing…", self.view.viewport())
|
|
323
|
+
self.busy_label.setStyleSheet("""
|
|
324
|
+
QLabel {
|
|
325
|
+
background: rgba(0,0,0,140);
|
|
326
|
+
color: white;
|
|
327
|
+
padding: 6px 10px;
|
|
328
|
+
border-radius: 8px;
|
|
329
|
+
font-weight: 600;
|
|
330
|
+
}
|
|
331
|
+
""")
|
|
332
|
+
self.busy_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
|
333
|
+
self.busy_label.hide()
|
|
334
|
+
# --- Spinner (animated) ---
|
|
335
|
+
self.busy_spinner = QLabel()
|
|
336
|
+
self.busy_spinner.setFixedSize(20, 20)
|
|
337
|
+
self.busy_spinner.setToolTip("Computing…")
|
|
338
|
+
self.busy_spinner.setVisible(False)
|
|
339
|
+
|
|
340
|
+
gif_path = get_resources().SPINNER_GIF # <- canonical, works frozen/dev
|
|
341
|
+
gif_path = os.path.normpath(gif_path)
|
|
342
|
+
|
|
343
|
+
self._busy_movie = QMovie(gif_path)
|
|
344
|
+
self._busy_movie.setScaledSize(self.busy_spinner.size())
|
|
345
|
+
self.busy_spinner.setMovie(self._busy_movie)
|
|
346
|
+
|
|
347
|
+
self._busy_show_timer = QTimer(self)
|
|
348
|
+
self._busy_show_timer.setSingleShot(True)
|
|
349
|
+
self._busy_show_timer.timeout.connect(self._show_busy_overlay)
|
|
350
|
+
self._busy_depth = 0
|
|
351
|
+
zoom_row = QHBoxLayout()
|
|
352
|
+
|
|
353
|
+
self.zoom_out_btn = QToolButton()
|
|
354
|
+
self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out"))
|
|
355
|
+
self.zoom_out_btn.setToolTip("Zoom Out")
|
|
356
|
+
|
|
357
|
+
self.zoom_in_btn = QToolButton()
|
|
358
|
+
self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in"))
|
|
359
|
+
self.zoom_in_btn.setToolTip("Zoom In")
|
|
360
|
+
|
|
361
|
+
self.fit_btn = QToolButton()
|
|
362
|
+
self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best"))
|
|
363
|
+
self.fit_btn.setToolTip("Fit to Preview")
|
|
364
|
+
|
|
365
|
+
self.one_to_one_btn = QToolButton()
|
|
366
|
+
self.one_to_one_btn.setIcon(QIcon.fromTheme("zoom-original"))
|
|
367
|
+
self.one_to_one_btn.setToolTip("1:1")
|
|
368
|
+
|
|
369
|
+
self.zoom_out_btn.clicked.connect(lambda: (self.view.scale(0.8, 0.8), self._schedule_roi_preview()))
|
|
370
|
+
self.zoom_in_btn.clicked.connect(lambda: (self.view.scale(1.25, 1.25), self._schedule_roi_preview()))
|
|
371
|
+
self.fit_btn.clicked.connect(self._fit_view)
|
|
372
|
+
self.one_to_one_btn.clicked.connect(self._one_to_one)
|
|
373
|
+
|
|
374
|
+
zoom_row.addStretch(1)
|
|
375
|
+
zoom_row.addWidget(self.zoom_out_btn)
|
|
376
|
+
zoom_row.addWidget(self.zoom_in_btn)
|
|
377
|
+
zoom_row.addSpacing(10)
|
|
378
|
+
zoom_row.addWidget(self.fit_btn)
|
|
379
|
+
zoom_row.addWidget(self.one_to_one_btn)
|
|
380
|
+
zoom_row.addSpacing(10)
|
|
381
|
+
zoom_row.addWidget(self.busy_spinner) # <-- add here
|
|
382
|
+
zoom_row.addStretch(1)
|
|
383
|
+
|
|
384
|
+
left.addLayout(zoom_row)
|
|
385
|
+
|
|
386
|
+
# ----- RIGHT: controls -----
|
|
387
|
+
right_widget = QWidget(self)
|
|
388
|
+
right = QVBoxLayout(right_widget)
|
|
389
|
+
|
|
390
|
+
gb_global = QGroupBox("Global")
|
|
391
|
+
form = QFormLayout(gb_global)
|
|
392
|
+
|
|
393
|
+
self.spin_layers = QSpinBox()
|
|
394
|
+
self.spin_layers.setRange(1, 10)
|
|
395
|
+
self.spin_layers.setValue(self.layers)
|
|
396
|
+
|
|
397
|
+
self.spin_sigma = QDoubleSpinBox()
|
|
398
|
+
self.spin_sigma.setRange(0.3, 5.0)
|
|
399
|
+
self.spin_sigma.setSingleStep(0.1)
|
|
400
|
+
self.spin_sigma.setValue(self.base_sigma)
|
|
401
|
+
|
|
402
|
+
self.cb_linked_rgb = QCheckBox("Linked RGB (apply same params to all channels)")
|
|
403
|
+
self.cb_linked_rgb.setChecked(True)
|
|
404
|
+
|
|
405
|
+
# NEW: Fast ROI preview
|
|
406
|
+
self.cb_fast_roi_preview = QCheckBox("Fast ROI preview (compute visible area only)")
|
|
407
|
+
self.cb_fast_roi_preview.setChecked(True)
|
|
408
|
+
self.cb_fast_roi_preview.setToolTip(
|
|
409
|
+
"When enabled, preview only computes the currently visible region (with padding for blur).\n"
|
|
410
|
+
"Apply/Send-to-Doc always computes the full image."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
self.combo_mode = QComboBox()
|
|
414
|
+
self.combo_mode.addItems(["μ–σ Thresholding", "Linear"])
|
|
415
|
+
self.combo_mode.setCurrentText("μ–σ Thresholding")
|
|
416
|
+
self.combo_mode.setToolTip(
|
|
417
|
+
"Multiscale mode:\n"
|
|
418
|
+
"• μ–σ Thresholding: σ-based thresholding + denoise and gain (nonlinear).\n"
|
|
419
|
+
"• Linear: strictly linear multiscale transform; only Gain is applied."
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
self.combo_preview = QComboBox()
|
|
423
|
+
self._refresh_preview_combo()
|
|
424
|
+
|
|
425
|
+
form.addRow("Layers:", self.spin_layers)
|
|
426
|
+
form.addRow("Base sigma:", self.spin_sigma)
|
|
427
|
+
form.addRow(self.cb_linked_rgb)
|
|
428
|
+
form.addRow(self.cb_fast_roi_preview)
|
|
429
|
+
form.addRow("Mode:", self.combo_mode)
|
|
430
|
+
form.addRow("Layer preview:", self.combo_preview)
|
|
431
|
+
|
|
432
|
+
right.addWidget(gb_global)
|
|
433
|
+
|
|
434
|
+
# Layers table
|
|
435
|
+
gb_layers = QGroupBox("Layers")
|
|
436
|
+
v = QVBoxLayout(gb_layers)
|
|
437
|
+
self.table = QTableWidget(0, 8)
|
|
438
|
+
self.table.setHorizontalHeaderLabels(
|
|
439
|
+
["On", "Layer", "Scale", "Gain", "Thr (σ)", "Amt", "NR", "Type"]
|
|
440
|
+
)
|
|
441
|
+
self.table.verticalHeader().setVisible(False)
|
|
442
|
+
self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
|
|
443
|
+
self.table.setSelectionMode(self.table.SelectionMode.SingleSelection)
|
|
444
|
+
v.addWidget(self.table)
|
|
445
|
+
right.addWidget(gb_layers, stretch=1)
|
|
446
|
+
|
|
447
|
+
# Per-layer editor...
|
|
448
|
+
gb_edit = QGroupBox("Selected Layer")
|
|
449
|
+
ef = QFormLayout(gb_edit)
|
|
450
|
+
self.lbl_sel = QLabel("Layer: —")
|
|
451
|
+
|
|
452
|
+
# --- Spin boxes ---
|
|
453
|
+
self.spin_gain = QDoubleSpinBox()
|
|
454
|
+
self.spin_gain.setRange(0.0, 3.0)
|
|
455
|
+
self.spin_gain.setSingleStep(0.05)
|
|
456
|
+
self.spin_gain.setValue(1.0)
|
|
457
|
+
self.spin_gain.setToolTip(
|
|
458
|
+
"Gain: multiplies the detail coefficients on this layer.\n"
|
|
459
|
+
"1.0 = unchanged, >1.0 boosts detail, <1.0 reduces it."
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
self.spin_thr = QDoubleSpinBox()
|
|
463
|
+
self.spin_thr.setRange(0.0, 10.0) # N·σ
|
|
464
|
+
self.spin_thr.setSingleStep(0.1)
|
|
465
|
+
self.spin_thr.setDecimals(2)
|
|
466
|
+
self.spin_thr.setToolTip(
|
|
467
|
+
"Threshold (σ): soft threshold level in units of this layer's noise σ.\n"
|
|
468
|
+
"0 = no thresholding; 1–3 ≈ mild to strong suppression of small coefficients."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
self.spin_amt = QDoubleSpinBox()
|
|
472
|
+
self.spin_amt.setRange(0.0, 1.0)
|
|
473
|
+
self.spin_amt.setSingleStep(0.05)
|
|
474
|
+
self.spin_amt.setToolTip(
|
|
475
|
+
"Amount: blend factor toward the thresholded version of the layer.\n"
|
|
476
|
+
"0 = ignore thresholding, 1 = fully use the thresholded layer."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
self.spin_denoise = QDoubleSpinBox()
|
|
480
|
+
self.spin_denoise.setRange(0.0, 1.0)
|
|
481
|
+
self.spin_denoise.setSingleStep(0.05)
|
|
482
|
+
self.spin_denoise.setValue(0.0)
|
|
483
|
+
self.spin_denoise.setToolTip(
|
|
484
|
+
"Denoise: extra multiscale noise reduction on this layer.\n"
|
|
485
|
+
"0 = off, 1 = strong NR (≈3σ soft threshold blended in)."
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# --- Sliders (int ranges, mapped to spins) ---
|
|
489
|
+
self.slider_gain = QSlider(Qt.Orientation.Horizontal)
|
|
490
|
+
self.slider_gain.setRange(0, 300) # 0..3.00
|
|
491
|
+
self.slider_gain.setToolTip(self.spin_gain.toolTip())
|
|
492
|
+
|
|
493
|
+
self.slider_thr = QSlider(Qt.Orientation.Horizontal)
|
|
494
|
+
self.slider_thr.setRange(0, 1000) # 0..10.00 σ (×0.01)
|
|
495
|
+
self.slider_thr.setToolTip(self.spin_thr.toolTip())
|
|
496
|
+
|
|
497
|
+
self.slider_amt = QSlider(Qt.Orientation.Horizontal)
|
|
498
|
+
self.slider_amt.setRange(0, 100) # 0..1.00
|
|
499
|
+
self.slider_amt.setToolTip(self.spin_amt.toolTip())
|
|
500
|
+
|
|
501
|
+
self.slider_denoise = QSlider(Qt.Orientation.Horizontal)
|
|
502
|
+
self.slider_denoise.setRange(0, 100) # 0..1.00
|
|
503
|
+
self.slider_denoise.setToolTip(self.spin_denoise.toolTip())
|
|
504
|
+
|
|
505
|
+
# Layout rows: label -> [slider | spinbox]
|
|
506
|
+
ef.addRow(self.lbl_sel)
|
|
507
|
+
|
|
508
|
+
gain_row = QHBoxLayout()
|
|
509
|
+
gain_row.addWidget(self.slider_gain)
|
|
510
|
+
gain_row.addWidget(self.spin_gain)
|
|
511
|
+
ef.addRow("Gain:", gain_row)
|
|
512
|
+
|
|
513
|
+
thr_row = QHBoxLayout()
|
|
514
|
+
thr_row.addWidget(self.slider_thr)
|
|
515
|
+
thr_row.addWidget(self.spin_thr)
|
|
516
|
+
ef.addRow("Threshold (σ):", thr_row)
|
|
517
|
+
|
|
518
|
+
amt_row = QHBoxLayout()
|
|
519
|
+
amt_row.addWidget(self.slider_amt)
|
|
520
|
+
amt_row.addWidget(self.spin_amt)
|
|
521
|
+
ef.addRow("Amount:", amt_row)
|
|
522
|
+
|
|
523
|
+
dn_row = QHBoxLayout()
|
|
524
|
+
dn_row.addWidget(self.slider_denoise)
|
|
525
|
+
dn_row.addWidget(self.spin_denoise)
|
|
526
|
+
ef.addRow("Denoise:", dn_row)
|
|
527
|
+
|
|
528
|
+
right.addWidget(gb_edit)
|
|
529
|
+
|
|
530
|
+
# Buttons...
|
|
531
|
+
btn_row = QHBoxLayout()
|
|
532
|
+
self.btn_apply = QPushButton("Apply to Document")
|
|
533
|
+
self.btn_detail_new = QPushButton("Send to New Document")
|
|
534
|
+
self.btn_split_layers = QPushButton("Split Layers to Documents")
|
|
535
|
+
self.btn_close = QPushButton("Close")
|
|
536
|
+
|
|
537
|
+
btn_row.addStretch(1)
|
|
538
|
+
btn_row.addWidget(self.btn_apply)
|
|
539
|
+
btn_row.addWidget(self.btn_detail_new)
|
|
540
|
+
btn_row.addWidget(self.btn_split_layers)
|
|
541
|
+
btn_row.addWidget(self.btn_close)
|
|
542
|
+
right.addLayout(btn_row)
|
|
543
|
+
|
|
544
|
+
splitter.addWidget(left_widget)
|
|
545
|
+
splitter.addWidget(right_widget)
|
|
546
|
+
splitter.setStretchFactor(0, 2)
|
|
547
|
+
splitter.setStretchFactor(1, 1)
|
|
548
|
+
|
|
549
|
+
# ----- Signals -----
|
|
550
|
+
self.spin_layers.valueChanged.connect(self._on_layers_changed)
|
|
551
|
+
self.spin_sigma.valueChanged.connect(self._on_global_changed)
|
|
552
|
+
self.combo_mode.currentIndexChanged.connect(self._on_mode_changed)
|
|
553
|
+
self.combo_preview.currentIndexChanged.connect(self._schedule_preview)
|
|
554
|
+
self.cb_fast_roi_preview.toggled.connect(self._schedule_roi_preview)
|
|
555
|
+
|
|
556
|
+
self.table.itemSelectionChanged.connect(self._on_table_select)
|
|
557
|
+
|
|
558
|
+
self.spin_gain.valueChanged.connect(self._on_layer_editor_changed)
|
|
559
|
+
self.spin_thr.valueChanged.connect(self._on_layer_editor_changed)
|
|
560
|
+
self.spin_amt.valueChanged.connect(self._on_layer_editor_changed)
|
|
561
|
+
self.spin_denoise.valueChanged.connect(self._on_layer_editor_changed)
|
|
562
|
+
|
|
563
|
+
self.slider_gain.valueChanged.connect(self._on_gain_slider_changed)
|
|
564
|
+
self.slider_thr.valueChanged.connect(self._on_thr_slider_changed)
|
|
565
|
+
self.slider_amt.valueChanged.connect(self._on_amt_slider_changed)
|
|
566
|
+
self.slider_denoise.valueChanged.connect(self._on_dn_slider_changed)
|
|
567
|
+
|
|
568
|
+
self.btn_apply.clicked.connect(self._commit_to_doc)
|
|
569
|
+
self.btn_detail_new.clicked.connect(self._send_detail_to_new_doc)
|
|
570
|
+
self.btn_split_layers.clicked.connect(self._split_layers_to_docs)
|
|
571
|
+
self.btn_close.clicked.connect(self.reject)
|
|
572
|
+
|
|
573
|
+
# Connect viewport scroll changes
|
|
574
|
+
self._connect_viewport_signals()
|
|
575
|
+
|
|
576
|
+
# ---------- Preview plumbing ----------
|
|
577
|
+
def _spinner_on(self):
|
|
578
|
+
if getattr(self, "busy_spinner", None) is None:
|
|
579
|
+
return
|
|
580
|
+
self.busy_spinner.setVisible(True)
|
|
581
|
+
if getattr(self, "_busy_movie", None) is not None:
|
|
582
|
+
if self._busy_movie.state() != QMovie.MovieState.Running:
|
|
583
|
+
self._busy_movie.start()
|
|
584
|
+
|
|
585
|
+
def _spinner_off(self):
|
|
586
|
+
if getattr(self, "busy_spinner", None) is None:
|
|
587
|
+
return
|
|
588
|
+
if getattr(self, "_busy_movie", None) is not None:
|
|
589
|
+
self._busy_movie.stop()
|
|
590
|
+
self.busy_spinner.setVisible(False)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _show_busy_overlay(self):
|
|
594
|
+
try:
|
|
595
|
+
self.busy_label.adjustSize()
|
|
596
|
+
self.busy_label.move(12, 12)
|
|
597
|
+
self.busy_label.show()
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
def _begin_busy(self):
|
|
602
|
+
self._busy_depth += 1
|
|
603
|
+
if self._busy_depth == 1:
|
|
604
|
+
# show only if compute isn't instant
|
|
605
|
+
self._busy_show_timer.start(120)
|
|
606
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
607
|
+
|
|
608
|
+
def _end_busy(self):
|
|
609
|
+
self._busy_depth = max(0, self._busy_depth - 1)
|
|
610
|
+
if self._busy_depth == 0:
|
|
611
|
+
self._busy_show_timer.stop()
|
|
612
|
+
self.busy_label.hide()
|
|
613
|
+
QApplication.restoreOverrideCursor()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _on_mode_changed(self, idx: int):
|
|
617
|
+
# Re-enable/disable controls as needed
|
|
618
|
+
self._update_param_widgets_for_mode()
|
|
619
|
+
self._schedule_preview()
|
|
620
|
+
|
|
621
|
+
def _schedule_preview(self):
|
|
622
|
+
# generic “something changed” entry point
|
|
623
|
+
self._preview_timer.start(60)
|
|
624
|
+
|
|
625
|
+
def _schedule_roi_preview(self):
|
|
626
|
+
# view changed (scroll/zoom/pan) — still debounced
|
|
627
|
+
self._preview_timer.start(60)
|
|
628
|
+
|
|
629
|
+
def _connect_viewport_signals(self):
|
|
630
|
+
"""
|
|
631
|
+
Any pan/scroll should schedule ROI preview recompute.
|
|
632
|
+
"""
|
|
633
|
+
try:
|
|
634
|
+
self.view.horizontalScrollBar().valueChanged.connect(self._schedule_roi_preview)
|
|
635
|
+
self.view.verticalScrollBar().valueChanged.connect(self._schedule_roi_preview)
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
def _recompute_decomp(self, force: bool = False):
|
|
640
|
+
layers = int(self.spin_layers.value())
|
|
641
|
+
base_sigma = float(self.spin_sigma.value())
|
|
642
|
+
|
|
643
|
+
# cache identity: sigma + the actual ndarray buffer identity
|
|
644
|
+
img_id = id(self._image)
|
|
645
|
+
key = (base_sigma, img_id)
|
|
646
|
+
|
|
647
|
+
if force or self._cached_key != key or self._cached_layers is None or self._cached_coarse is None:
|
|
648
|
+
self.layers = layers
|
|
649
|
+
self.base_sigma = base_sigma
|
|
650
|
+
|
|
651
|
+
c = self._image.astype(np.float32, copy=False)
|
|
652
|
+
details = []
|
|
653
|
+
coarse = []
|
|
654
|
+
|
|
655
|
+
for k in range(layers):
|
|
656
|
+
sigma = base_sigma * (2 ** k)
|
|
657
|
+
c_next = _blur_gaussian(c, sigma)
|
|
658
|
+
details.append(c - c_next)
|
|
659
|
+
c = c_next
|
|
660
|
+
coarse.append(c)
|
|
661
|
+
|
|
662
|
+
self._cached_layers = details
|
|
663
|
+
self._cached_coarse = coarse
|
|
664
|
+
self._cached_residual = c
|
|
665
|
+
self._cached_key = key
|
|
666
|
+
|
|
667
|
+
self._layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in self._cached_layers]
|
|
668
|
+
self._sync_cfgs_and_ui()
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# reuse existing pyramid, adjust layer count
|
|
672
|
+
old_layers = len(self._cached_layers)
|
|
673
|
+
self.layers = layers
|
|
674
|
+
self.base_sigma = base_sigma
|
|
675
|
+
|
|
676
|
+
if layers == old_layers:
|
|
677
|
+
self._sync_cfgs_and_ui()
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
if layers < old_layers:
|
|
681
|
+
self._cached_layers = self._cached_layers[:layers]
|
|
682
|
+
self._cached_coarse = self._cached_coarse[:layers]
|
|
683
|
+
self._layer_noise = self._layer_noise[:layers]
|
|
684
|
+
|
|
685
|
+
if layers > 0:
|
|
686
|
+
self._cached_residual = self._cached_coarse[layers - 1]
|
|
687
|
+
else:
|
|
688
|
+
self._cached_residual = self._image.astype(np.float32, copy=False)
|
|
689
|
+
|
|
690
|
+
self._sync_cfgs_and_ui()
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
# Grow: compute only missing layers from current residual
|
|
694
|
+
c = self._cached_residual
|
|
695
|
+
for k in range(old_layers, layers):
|
|
696
|
+
sigma = base_sigma * (2 ** k)
|
|
697
|
+
c_next = _blur_gaussian(c, sigma)
|
|
698
|
+
w = c - c_next
|
|
699
|
+
|
|
700
|
+
self._cached_layers.append(w)
|
|
701
|
+
self._cached_coarse.append(c_next)
|
|
702
|
+
self._layer_noise.append(_robust_sigma(w) if w.size else 1e-6)
|
|
703
|
+
|
|
704
|
+
c = c_next
|
|
705
|
+
|
|
706
|
+
self._cached_residual = c
|
|
707
|
+
self._sync_cfgs_and_ui()
|
|
708
|
+
|
|
709
|
+
def _sync_cfgs_and_ui(self):
|
|
710
|
+
# ensure cfg list matches layer count (your existing logic, just moved)
|
|
711
|
+
if len(self.cfgs) != self.layers:
|
|
712
|
+
old = self.cfgs[:]
|
|
713
|
+
self.cfgs = [LayerCfg() for _ in range(self.layers)]
|
|
714
|
+
for i in range(min(len(old), self.layers)):
|
|
715
|
+
self.cfgs[i] = old[i]
|
|
716
|
+
|
|
717
|
+
self._rebuild_table()
|
|
718
|
+
self._refresh_preview_combo()
|
|
719
|
+
|
|
720
|
+
def _build_tuned_layers(self):
|
|
721
|
+
self._recompute_decomp(force=False)
|
|
722
|
+
|
|
723
|
+
details = self._cached_layers
|
|
724
|
+
residual = self._cached_residual
|
|
725
|
+
if details is None or residual is None:
|
|
726
|
+
return None, None
|
|
727
|
+
|
|
728
|
+
mode = self.combo_mode.currentText()
|
|
729
|
+
|
|
730
|
+
def do_one(i_w):
|
|
731
|
+
i, w = i_w
|
|
732
|
+
cfg = self.cfgs[i]
|
|
733
|
+
if not cfg.enabled:
|
|
734
|
+
return i, np.zeros_like(w)
|
|
735
|
+
sigma = self._layer_noise[i] if self._layer_noise and i < len(self._layer_noise) else None
|
|
736
|
+
out = apply_layer_ops(
|
|
737
|
+
w,
|
|
738
|
+
cfg.bias_gain,
|
|
739
|
+
cfg.thr,
|
|
740
|
+
cfg.amount,
|
|
741
|
+
cfg.denoise,
|
|
742
|
+
sigma,
|
|
743
|
+
mode=mode,
|
|
744
|
+
)
|
|
745
|
+
return i, out
|
|
746
|
+
|
|
747
|
+
n = len(details)
|
|
748
|
+
if n == 0:
|
|
749
|
+
return [], residual
|
|
750
|
+
|
|
751
|
+
max_workers = min(os.cpu_count() or 4, n)
|
|
752
|
+
|
|
753
|
+
tuned = [None] * n
|
|
754
|
+
# ThreadPoolExecutor is fine here because apply_layer_ops is numpy-heavy
|
|
755
|
+
# (but real speed-up depends on GIL/OpenCV/BLAS behavior).
|
|
756
|
+
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
|
757
|
+
for i, out in ex.map(do_one, enumerate(details)):
|
|
758
|
+
tuned[i] = out
|
|
759
|
+
|
|
760
|
+
return tuned, residual
|
|
761
|
+
|
|
762
|
+
def _rebuild_preview(self):
|
|
763
|
+
self._spinner_on()
|
|
764
|
+
QApplication.processEvents()
|
|
765
|
+
#self._begin_busy()
|
|
766
|
+
try:
|
|
767
|
+
# ROI preview can't work until we have *some* pixmap in the scene to derive visible rects from.
|
|
768
|
+
roi_ok = (
|
|
769
|
+
getattr(self, "cb_fast_roi_preview", None) is not None
|
|
770
|
+
and self.cb_fast_roi_preview.isChecked()
|
|
771
|
+
and not self.pix_base.pixmap().isNull()
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
if roi_ok:
|
|
775
|
+
roi_img, roi_rect = self._compute_preview_roi()
|
|
776
|
+
if roi_img is None:
|
|
777
|
+
return
|
|
778
|
+
self._refresh_pix_roi(roi_img, roi_rect)
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
# ---- Full-frame preview (bootstrap path, and when ROI disabled) ----
|
|
782
|
+
tuned, residual = self._build_tuned_layers()
|
|
783
|
+
if tuned is None or residual is None:
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
res = residual if self.residual_enabled else np.zeros_like(residual)
|
|
787
|
+
out_raw = multiscale_reconstruct(tuned, res)
|
|
788
|
+
out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
|
|
789
|
+
|
|
790
|
+
sel = self.combo_preview.currentData()
|
|
791
|
+
if sel is None or sel == "final":
|
|
792
|
+
if not self.residual_enabled:
|
|
793
|
+
d = out_raw.astype(np.float32, copy=False)
|
|
794
|
+
vis = 0.5 + d * 4.0
|
|
795
|
+
self._preview_img = np.clip(vis, 0.0, 1.0).astype(np.float32, copy=False)
|
|
796
|
+
else:
|
|
797
|
+
self._preview_img = out
|
|
798
|
+
elif sel == "residual":
|
|
799
|
+
self._preview_img = np.clip(residual, 0, 1)
|
|
800
|
+
else:
|
|
801
|
+
w = tuned[int(sel)]
|
|
802
|
+
vis = np.clip(0.5 + (w * 4.0), 0.0, 1.0)
|
|
803
|
+
self._preview_img = vis.astype(np.float32, copy=False)
|
|
804
|
+
|
|
805
|
+
self._refresh_pix()
|
|
806
|
+
|
|
807
|
+
finally:
|
|
808
|
+
#self._end_busy()
|
|
809
|
+
self._spinner_off()
|
|
810
|
+
|
|
811
|
+
def _update_param_widgets_for_mode(self):
|
|
812
|
+
linear = (self.combo_mode.currentText() == "Linear")
|
|
813
|
+
|
|
814
|
+
# Always allow Gain in both modes
|
|
815
|
+
gain_widgets = (self.spin_gain, self.slider_gain)
|
|
816
|
+
|
|
817
|
+
# These are only meaningful in Mean mode
|
|
818
|
+
nonlin_widgets = (
|
|
819
|
+
self.spin_thr, self.slider_thr,
|
|
820
|
+
self.spin_amt, self.slider_amt,
|
|
821
|
+
self.spin_denoise, self.slider_denoise,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# For residual row we already disable everything in _load_layer_into_editor,
|
|
825
|
+
# so here we just respect the current selection.
|
|
826
|
+
idx = getattr(self, "_selected_layer", None)
|
|
827
|
+
if idx is None or idx == self.layers:
|
|
828
|
+
# Residual – handled in _load_layer_into_editor
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
for w in gain_widgets:
|
|
832
|
+
w.setEnabled(True)
|
|
833
|
+
|
|
834
|
+
for w in nonlin_widgets:
|
|
835
|
+
w.setEnabled(not linear)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _np_to_qpix(self, img: np.ndarray) -> QPixmap:
|
|
839
|
+
arr = np.ascontiguousarray(np.clip(img * 255.0, 0, 255).astype(np.uint8))
|
|
840
|
+
h, w = arr.shape[:2]
|
|
841
|
+
if arr.ndim == 2:
|
|
842
|
+
arr = np.repeat(arr[:, :, None], 3, axis=2)
|
|
843
|
+
qimg = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
844
|
+
return QPixmap.fromImage(qimg)
|
|
845
|
+
|
|
846
|
+
def _refresh_pix(self):
|
|
847
|
+
pm = self._np_to_qpix(self._preview_img)
|
|
848
|
+
self.pix_base.setPixmap(pm)
|
|
849
|
+
self.pix_base.setOffset(0, 0)
|
|
850
|
+
|
|
851
|
+
# Optional: clear ROI overlay on full refresh
|
|
852
|
+
self.pix_roi.setPixmap(QPixmap())
|
|
853
|
+
self.pix_roi.setOffset(0, 0)
|
|
854
|
+
|
|
855
|
+
H, W = self._image.shape[:2]
|
|
856
|
+
self.scene.setSceneRect(QRectF(0, 0, W, H))
|
|
857
|
+
|
|
858
|
+
def _fast_preview_enabled(self) -> bool:
|
|
859
|
+
return bool(getattr(self, "cb_fast_roi_preview", None)) and self.cb_fast_roi_preview.isChecked()
|
|
860
|
+
|
|
861
|
+
def _invalidate_full_decomp_cache(self):
|
|
862
|
+
self._cached_layers = None
|
|
863
|
+
self._cached_coarse = None
|
|
864
|
+
self._cached_residual = None
|
|
865
|
+
self._cached_key = None
|
|
866
|
+
self._layer_noise = None
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _fit_view(self):
|
|
870
|
+
if self.pix_base.pixmap().isNull():
|
|
871
|
+
return
|
|
872
|
+
self.view.resetTransform()
|
|
873
|
+
self.view.fitInView(self.pix_base, Qt.AspectRatioMode.KeepAspectRatio)
|
|
874
|
+
self._schedule_roi_preview()
|
|
875
|
+
|
|
876
|
+
def _one_to_one(self):
|
|
877
|
+
self.view.resetTransform()
|
|
878
|
+
self._schedule_roi_preview()
|
|
879
|
+
|
|
880
|
+
# ---------- Table / layer editing ----------
|
|
881
|
+
def _on_gain_slider_changed(self, v: int):
|
|
882
|
+
# 0..300 -> 0.00..3.00
|
|
883
|
+
val = v / 100.0
|
|
884
|
+
self.spin_gain.blockSignals(True)
|
|
885
|
+
self.spin_gain.setValue(val)
|
|
886
|
+
self.spin_gain.blockSignals(False)
|
|
887
|
+
self._on_layer_editor_changed()
|
|
888
|
+
|
|
889
|
+
def _on_thr_slider_changed(self, v: int):
|
|
890
|
+
# 0..1000 -> 0.00..10.00 σ
|
|
891
|
+
val = v / 100.0
|
|
892
|
+
self.spin_thr.blockSignals(True)
|
|
893
|
+
self.spin_thr.setValue(val)
|
|
894
|
+
self.spin_thr.blockSignals(False)
|
|
895
|
+
self._on_layer_editor_changed()
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _on_amt_slider_changed(self, v: int):
|
|
899
|
+
# 0..100 -> 0.00..1.00
|
|
900
|
+
val = v / 100.0
|
|
901
|
+
self.spin_amt.blockSignals(True)
|
|
902
|
+
self.spin_amt.setValue(val)
|
|
903
|
+
self.spin_amt.blockSignals(False)
|
|
904
|
+
self._on_layer_editor_changed()
|
|
905
|
+
|
|
906
|
+
def _on_dn_slider_changed(self, v: int):
|
|
907
|
+
# 0..100 -> 0.00..1.00
|
|
908
|
+
val = v / 100.0
|
|
909
|
+
self.spin_denoise.blockSignals(True)
|
|
910
|
+
self.spin_denoise.setValue(val)
|
|
911
|
+
self.spin_denoise.blockSignals(False)
|
|
912
|
+
self._on_layer_editor_changed()
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _rebuild_table(self):
|
|
916
|
+
self.table.blockSignals(True)
|
|
917
|
+
try:
|
|
918
|
+
# +1 row for residual ("R")
|
|
919
|
+
self.table.setRowCount(self.layers + 1)
|
|
920
|
+
|
|
921
|
+
# detail rows
|
|
922
|
+
for i in range(self.layers):
|
|
923
|
+
cfg = self.cfgs[i]
|
|
924
|
+
|
|
925
|
+
item_on = QTableWidgetItem("")
|
|
926
|
+
item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
927
|
+
item_on.setCheckState(Qt.CheckState.Checked if cfg.enabled else Qt.CheckState.Unchecked)
|
|
928
|
+
self.table.setItem(i, 0, item_on)
|
|
929
|
+
|
|
930
|
+
self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
|
|
931
|
+
self.table.setItem(i, 2, QTableWidgetItem(f"{self.base_sigma * (2**i):.2f}"))
|
|
932
|
+
self.table.setItem(i, 3, QTableWidgetItem(f"{cfg.bias_gain:.2f}"))
|
|
933
|
+
self.table.setItem(i, 4, QTableWidgetItem(f"{cfg.thr:.2f}")) # N·σ
|
|
934
|
+
self.table.setItem(i, 5, QTableWidgetItem(f"{cfg.amount:.2f}"))
|
|
935
|
+
self.table.setItem(i, 6, QTableWidgetItem(f"{cfg.denoise:.2f}"))
|
|
936
|
+
|
|
937
|
+
self.table.setItem(i, 7, QTableWidgetItem("D"))
|
|
938
|
+
|
|
939
|
+
# residual row
|
|
940
|
+
r = self.layers
|
|
941
|
+
item_on = QTableWidgetItem("")
|
|
942
|
+
item_on.setFlags(item_on.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
943
|
+
item_on.setCheckState(
|
|
944
|
+
Qt.CheckState.Checked if self.residual_enabled else Qt.CheckState.Unchecked
|
|
945
|
+
)
|
|
946
|
+
self.table.setItem(r, 0, item_on)
|
|
947
|
+
|
|
948
|
+
self.table.setItem(r, 1, QTableWidgetItem("R"))
|
|
949
|
+
self.table.setItem(r, 2, QTableWidgetItem("—"))
|
|
950
|
+
self.table.setItem(r, 3, QTableWidgetItem("1.00"))
|
|
951
|
+
self.table.setItem(r, 4, QTableWidgetItem("0.0000"))
|
|
952
|
+
self.table.setItem(r, 5, QTableWidgetItem("0.00"))
|
|
953
|
+
self.table.setItem(r, 6, QTableWidgetItem("0.00"))
|
|
954
|
+
self.table.setItem(r, 7, QTableWidgetItem("R"))
|
|
955
|
+
|
|
956
|
+
finally:
|
|
957
|
+
self.table.blockSignals(False)
|
|
958
|
+
|
|
959
|
+
# connect once (avoid stacking connects)
|
|
960
|
+
try:
|
|
961
|
+
self.table.itemChanged.disconnect(self._on_table_item_changed)
|
|
962
|
+
except Exception:
|
|
963
|
+
pass
|
|
964
|
+
self.table.itemChanged.connect(self._on_table_item_changed)
|
|
965
|
+
|
|
966
|
+
if self.layers > 0 and not self.table.selectedItems():
|
|
967
|
+
self.table.selectRow(0)
|
|
968
|
+
self._load_layer_into_editor(0)
|
|
969
|
+
|
|
970
|
+
def _on_table_item_changed(self, item: QTableWidgetItem):
|
|
971
|
+
r, c = item.row(), item.column()
|
|
972
|
+
|
|
973
|
+
# Residual row
|
|
974
|
+
if r == self.layers:
|
|
975
|
+
if c == 0:
|
|
976
|
+
self.residual_enabled = (item.checkState() == Qt.CheckState.Checked)
|
|
977
|
+
self._schedule_preview()
|
|
978
|
+
# ignore other edits for residual
|
|
979
|
+
return
|
|
980
|
+
|
|
981
|
+
if not (0 <= r < len(self.cfgs)):
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
cfg = self.cfgs[r]
|
|
985
|
+
|
|
986
|
+
if c == 0:
|
|
987
|
+
# On/off
|
|
988
|
+
cfg.enabled = (item.checkState() == Qt.CheckState.Checked)
|
|
989
|
+
self._schedule_preview()
|
|
990
|
+
return
|
|
991
|
+
|
|
992
|
+
# numeric columns: Gain(3), Thr(4), Amt(5), NR(6)
|
|
993
|
+
try:
|
|
994
|
+
text = item.text().strip()
|
|
995
|
+
val = float(text) if text else 0.0
|
|
996
|
+
except Exception:
|
|
997
|
+
return
|
|
998
|
+
|
|
999
|
+
if c == 3:
|
|
1000
|
+
cfg.bias_gain = val
|
|
1001
|
+
elif c == 4:
|
|
1002
|
+
cfg.thr = val
|
|
1003
|
+
elif c == 5:
|
|
1004
|
+
cfg.amount = val
|
|
1005
|
+
elif c == 6:
|
|
1006
|
+
cfg.denoise = val
|
|
1007
|
+
else:
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
# If this row is currently selected, update editor widgets too
|
|
1011
|
+
if getattr(self, "_selected_layer", None) == r:
|
|
1012
|
+
self._load_layer_into_editor(r)
|
|
1013
|
+
|
|
1014
|
+
self._schedule_preview()
|
|
1015
|
+
|
|
1016
|
+
@contextmanager
|
|
1017
|
+
def _busy_popup(self, text: str):
|
|
1018
|
+
dlg = QProgressDialog(text, "", 0, 0, self)
|
|
1019
|
+
dlg.setWindowTitle("Multiscale Decomposition")
|
|
1020
|
+
dlg.setWindowModality(Qt.WindowModality.ApplicationModal)
|
|
1021
|
+
dlg.setCancelButton(None)
|
|
1022
|
+
dlg.setMinimumDuration(0)
|
|
1023
|
+
dlg.show()
|
|
1024
|
+
|
|
1025
|
+
self._spinner_on()
|
|
1026
|
+
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
1027
|
+
QApplication.processEvents()
|
|
1028
|
+
|
|
1029
|
+
try:
|
|
1030
|
+
yield dlg
|
|
1031
|
+
finally:
|
|
1032
|
+
try:
|
|
1033
|
+
dlg.close()
|
|
1034
|
+
except Exception:
|
|
1035
|
+
pass
|
|
1036
|
+
QApplication.restoreOverrideCursor()
|
|
1037
|
+
self._spinner_off()
|
|
1038
|
+
QApplication.processEvents()
|
|
1039
|
+
|
|
1040
|
+
def _on_table_select(self):
|
|
1041
|
+
rows = {it.row() for it in self.table.selectedItems()}
|
|
1042
|
+
if not rows:
|
|
1043
|
+
return
|
|
1044
|
+
r = min(rows)
|
|
1045
|
+
self._load_layer_into_editor(r)
|
|
1046
|
+
|
|
1047
|
+
def _load_layer_into_editor(self, idx: int):
|
|
1048
|
+
self._selected_layer = idx
|
|
1049
|
+
|
|
1050
|
+
if idx == self.layers:
|
|
1051
|
+
self.lbl_sel.setText("Layer: R (Residual)")
|
|
1052
|
+
for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
|
|
1053
|
+
self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
|
|
1054
|
+
w.setEnabled(False)
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
for w in (self.spin_gain, self.spin_thr, self.spin_amt, self.spin_denoise,
|
|
1058
|
+
self.slider_gain, self.slider_thr, self.slider_amt, self.slider_denoise):
|
|
1059
|
+
w.setEnabled(True)
|
|
1060
|
+
|
|
1061
|
+
cfg = self.cfgs[idx]
|
|
1062
|
+
self.lbl_sel.setText(f"Layer: {idx+1} / {self.layers}")
|
|
1063
|
+
|
|
1064
|
+
# spins + sliders in sync
|
|
1065
|
+
self.spin_gain.blockSignals(True)
|
|
1066
|
+
self.spin_thr.blockSignals(True)
|
|
1067
|
+
self.spin_amt.blockSignals(True)
|
|
1068
|
+
self.spin_denoise.blockSignals(True)
|
|
1069
|
+
|
|
1070
|
+
self.slider_gain.blockSignals(True)
|
|
1071
|
+
self.slider_thr.blockSignals(True)
|
|
1072
|
+
self.slider_amt.blockSignals(True)
|
|
1073
|
+
self.slider_denoise.blockSignals(True)
|
|
1074
|
+
try:
|
|
1075
|
+
self.spin_gain.setValue(cfg.bias_gain)
|
|
1076
|
+
self.spin_thr.setValue(cfg.thr) # thr is N·σ now
|
|
1077
|
+
self.spin_amt.setValue(cfg.amount)
|
|
1078
|
+
self.spin_denoise.setValue(cfg.denoise)
|
|
1079
|
+
|
|
1080
|
+
self.slider_gain.setValue(int(round(cfg.bias_gain * 100.0)))
|
|
1081
|
+
self.slider_thr.setValue(int(round(cfg.thr * 100.0))) # N·σ → 0..1000
|
|
1082
|
+
self.slider_amt.setValue(int(round(cfg.amount * 100.0)))
|
|
1083
|
+
self.slider_denoise.setValue(int(round(cfg.denoise * 100.0)))
|
|
1084
|
+
finally:
|
|
1085
|
+
self.spin_gain.blockSignals(False)
|
|
1086
|
+
self.spin_thr.blockSignals(False)
|
|
1087
|
+
self.spin_amt.blockSignals(False)
|
|
1088
|
+
self.spin_denoise.blockSignals(False)
|
|
1089
|
+
self.slider_gain.blockSignals(False)
|
|
1090
|
+
self.slider_thr.blockSignals(False)
|
|
1091
|
+
self.slider_amt.blockSignals(False)
|
|
1092
|
+
self.slider_denoise.blockSignals(False)
|
|
1093
|
+
self._update_param_widgets_for_mode()
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _on_layer_editor_changed(self):
|
|
1098
|
+
idx = getattr(self, "_selected_layer", None)
|
|
1099
|
+
if idx is None or not (0 <= idx < len(self.cfgs)):
|
|
1100
|
+
return
|
|
1101
|
+
cfg = self.cfgs[idx]
|
|
1102
|
+
cfg.bias_gain = float(self.spin_gain.value())
|
|
1103
|
+
cfg.thr = float(self.spin_thr.value())
|
|
1104
|
+
cfg.amount = float(self.spin_amt.value())
|
|
1105
|
+
cfg.denoise = float(self.spin_denoise.value())
|
|
1106
|
+
|
|
1107
|
+
# keep table in sync
|
|
1108
|
+
self.table.blockSignals(True)
|
|
1109
|
+
try:
|
|
1110
|
+
self.table.item(idx, 3).setText(f"{cfg.bias_gain:.2f}")
|
|
1111
|
+
self.table.item(idx, 4).setText(f"{cfg.thr:.2f}") # N·σ
|
|
1112
|
+
self.table.item(idx, 5).setText(f"{cfg.amount:.2f}")
|
|
1113
|
+
self.table.item(idx, 6).setText(f"{cfg.denoise:.2f}")
|
|
1114
|
+
|
|
1115
|
+
finally:
|
|
1116
|
+
self.table.blockSignals(False)
|
|
1117
|
+
|
|
1118
|
+
self._schedule_preview()
|
|
1119
|
+
|
|
1120
|
+
def _on_layers_changed(self):
|
|
1121
|
+
# Always update counts/UI
|
|
1122
|
+
self.layers = int(self.spin_layers.value())
|
|
1123
|
+
|
|
1124
|
+
# Ensure cfgs length matches new layer count and table/combos update
|
|
1125
|
+
self._sync_cfgs_and_ui()
|
|
1126
|
+
|
|
1127
|
+
if self._fast_preview_enabled():
|
|
1128
|
+
# Do NOT recompute full pyramid here; ROI preview will compute on-demand
|
|
1129
|
+
self._invalidate_full_decomp_cache()
|
|
1130
|
+
self._schedule_roi_preview()
|
|
1131
|
+
return
|
|
1132
|
+
|
|
1133
|
+
# Old behavior for non-ROI mode
|
|
1134
|
+
self._recompute_decomp(force=True)
|
|
1135
|
+
self._schedule_preview()
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _on_global_changed(self):
|
|
1139
|
+
self.base_sigma = float(self.spin_sigma.value())
|
|
1140
|
+
|
|
1141
|
+
# Update table scale column text (it uses self.base_sigma)
|
|
1142
|
+
self._sync_cfgs_and_ui()
|
|
1143
|
+
|
|
1144
|
+
if self._fast_preview_enabled():
|
|
1145
|
+
self._invalidate_full_decomp_cache()
|
|
1146
|
+
self._schedule_roi_preview()
|
|
1147
|
+
return
|
|
1148
|
+
|
|
1149
|
+
self._recompute_decomp(force=True)
|
|
1150
|
+
self._schedule_preview()
|
|
1151
|
+
|
|
1152
|
+
def _refresh_preview_combo(self):
|
|
1153
|
+
self.combo_preview.blockSignals(True)
|
|
1154
|
+
try:
|
|
1155
|
+
self.combo_preview.clear()
|
|
1156
|
+
self.combo_preview.addItem("Final", userData="final")
|
|
1157
|
+
self.combo_preview.addItem("R (Residual)", userData="residual")
|
|
1158
|
+
for i in range(self.layers):
|
|
1159
|
+
self.combo_preview.addItem(f"Detail Layer {i+1}", userData=i)
|
|
1160
|
+
finally:
|
|
1161
|
+
self.combo_preview.blockSignals(False)
|
|
1162
|
+
|
|
1163
|
+
def _visible_image_rect(self) -> tuple[int, int, int, int] | None:
|
|
1164
|
+
# Use full image rect, NOT the pixmap bounds
|
|
1165
|
+
H, W = self._image.shape[:2]
|
|
1166
|
+
full_item_rect_scene = QRectF(0, 0, W, H)
|
|
1167
|
+
|
|
1168
|
+
vr = self.view.viewport().rect()
|
|
1169
|
+
tl = self.view.mapToScene(vr.topLeft())
|
|
1170
|
+
br = self.view.mapToScene(vr.bottomRight())
|
|
1171
|
+
scene_rect = QRectF(tl, br).normalized()
|
|
1172
|
+
|
|
1173
|
+
inter = scene_rect.intersected(full_item_rect_scene)
|
|
1174
|
+
if inter.isEmpty():
|
|
1175
|
+
return None
|
|
1176
|
+
|
|
1177
|
+
x0 = int(np.floor(inter.left()))
|
|
1178
|
+
y0 = int(np.floor(inter.top()))
|
|
1179
|
+
x1 = int(np.ceil(inter.right()))
|
|
1180
|
+
y1 = int(np.ceil(inter.bottom()))
|
|
1181
|
+
|
|
1182
|
+
x0 = max(0, min(W, x0))
|
|
1183
|
+
x1 = max(0, min(W, x1))
|
|
1184
|
+
y0 = max(0, min(H, y0))
|
|
1185
|
+
y1 = max(0, min(H, y1))
|
|
1186
|
+
|
|
1187
|
+
if x1 <= x0 or y1 <= y0:
|
|
1188
|
+
return None
|
|
1189
|
+
return (x0, y0, x1, y1)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _compute_preview_roi(self):
|
|
1193
|
+
"""
|
|
1194
|
+
Computes preview only for visible ROI (plus padding), then returns:
|
|
1195
|
+
(roi_img_float01, (x0,y0,x1,y1)) or (None, None)
|
|
1196
|
+
roi_img is float32 RGB [0..1] and corresponds exactly to visible roi box.
|
|
1197
|
+
"""
|
|
1198
|
+
vis = self._visible_image_rect()
|
|
1199
|
+
if vis is None:
|
|
1200
|
+
return None, None
|
|
1201
|
+
|
|
1202
|
+
x0, y0, x1, y1 = vis
|
|
1203
|
+
|
|
1204
|
+
# ROI cap to prevent enormous compute in fit-to-preview scenarios
|
|
1205
|
+
MAX = 1400
|
|
1206
|
+
w = x1 - x0
|
|
1207
|
+
h = y1 - y0
|
|
1208
|
+
if w > MAX:
|
|
1209
|
+
cx = (x0 + x1) // 2
|
|
1210
|
+
x0 = max(0, cx - MAX // 2)
|
|
1211
|
+
x1 = min(self._image.shape[1], x0 + MAX)
|
|
1212
|
+
if h > MAX:
|
|
1213
|
+
cy = (y0 + y1) // 2
|
|
1214
|
+
y0 = max(0, cy - MAX // 2)
|
|
1215
|
+
y1 = min(self._image.shape[0], y0 + MAX)
|
|
1216
|
+
|
|
1217
|
+
layers = int(self.spin_layers.value())
|
|
1218
|
+
base_sigma = float(self.spin_sigma.value())
|
|
1219
|
+
if layers <= 0:
|
|
1220
|
+
return None, None
|
|
1221
|
+
|
|
1222
|
+
sigma_max = base_sigma * (2 ** (layers - 1))
|
|
1223
|
+
pad = int(np.ceil(3.0 * sigma_max)) + 2
|
|
1224
|
+
|
|
1225
|
+
H, W = self._image.shape[:2]
|
|
1226
|
+
px0 = max(0, x0 - pad)
|
|
1227
|
+
py0 = max(0, y0 - pad)
|
|
1228
|
+
px1 = min(W, x1 + pad)
|
|
1229
|
+
py1 = min(H, y1 + pad)
|
|
1230
|
+
|
|
1231
|
+
crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
|
|
1232
|
+
|
|
1233
|
+
details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
|
|
1234
|
+
layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
|
|
1235
|
+
|
|
1236
|
+
mode = self.combo_mode.currentText()
|
|
1237
|
+
|
|
1238
|
+
# Apply per-layer ops (threaded)
|
|
1239
|
+
def do_one(i_w):
|
|
1240
|
+
i, w = i_w
|
|
1241
|
+
cfg = self.cfgs[i]
|
|
1242
|
+
if not cfg.enabled:
|
|
1243
|
+
return i, np.zeros_like(w)
|
|
1244
|
+
return i, apply_layer_ops(
|
|
1245
|
+
w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
|
|
1246
|
+
layer_noise[i], mode=mode
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
tuned = [None] * len(details)
|
|
1250
|
+
max_workers = min(os.cpu_count() or 4, len(details) or 1)
|
|
1251
|
+
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
|
1252
|
+
for i, out in ex.map(do_one, enumerate(details)):
|
|
1253
|
+
tuned[i] = out
|
|
1254
|
+
|
|
1255
|
+
res = residual if self.residual_enabled else np.zeros_like(residual)
|
|
1256
|
+
out_raw = multiscale_reconstruct(tuned, res)
|
|
1257
|
+
|
|
1258
|
+
# Match preview rules
|
|
1259
|
+
if not self.residual_enabled:
|
|
1260
|
+
out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1261
|
+
else:
|
|
1262
|
+
out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1263
|
+
|
|
1264
|
+
# Crop back to visible ROI coordinates
|
|
1265
|
+
cx0 = x0 - px0
|
|
1266
|
+
cy0 = y0 - py0
|
|
1267
|
+
cx1 = cx0 + (x1 - x0)
|
|
1268
|
+
cy1 = cy0 + (y1 - y0)
|
|
1269
|
+
|
|
1270
|
+
roi = out[cy0:cy1, cx0:cx1]
|
|
1271
|
+
return roi, (x0, y0, x1, y1)
|
|
1272
|
+
|
|
1273
|
+
def _np_to_qpix_roi_comp(self, img_rgb01: np.ndarray) -> QPixmap:
|
|
1274
|
+
"""
|
|
1275
|
+
img_rgb01 is float32 RGB [0..1]
|
|
1276
|
+
"""
|
|
1277
|
+
arr = np.ascontiguousarray(np.clip(img_rgb01 * 255.0, 0, 255).astype(np.uint8))
|
|
1278
|
+
h, w = arr.shape[:2]
|
|
1279
|
+
if arr.ndim == 2:
|
|
1280
|
+
arr = np.repeat(arr[:, :, None], 3, axis=2)
|
|
1281
|
+
|
|
1282
|
+
bytes_per_line = arr.strides[0]
|
|
1283
|
+
qimg = QImage(arr.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
|
|
1284
|
+
return QPixmap.fromImage(qimg.copy()) # copy to detach from numpy buffer
|
|
1285
|
+
|
|
1286
|
+
def _refresh_pix_roi(self, roi_img01: np.ndarray, roi_rect: tuple[int,int,int,int]):
|
|
1287
|
+
x0, y0, x1, y1 = roi_rect
|
|
1288
|
+
pm = self._np_to_qpix_roi_comp(roi_img01)
|
|
1289
|
+
|
|
1290
|
+
self.pix_roi.setPixmap(pm)
|
|
1291
|
+
self.pix_roi.setOffset(x0, y0)
|
|
1292
|
+
|
|
1293
|
+
# Keep scene bounds as full image, not ROI
|
|
1294
|
+
H, W = self._image.shape[:2]
|
|
1295
|
+
self.scene.setSceneRect(QRectF(0, 0, W, H))
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _build_preview_roi(self):
|
|
1299
|
+
vis = self._visible_image_rect()
|
|
1300
|
+
if vis is None:
|
|
1301
|
+
return None
|
|
1302
|
+
|
|
1303
|
+
x0,y0,x1,y1 = vis
|
|
1304
|
+
layers = int(self.spin_layers.value())
|
|
1305
|
+
base_sigma = float(self.spin_sigma.value())
|
|
1306
|
+
|
|
1307
|
+
if layers <= 0:
|
|
1308
|
+
return None
|
|
1309
|
+
|
|
1310
|
+
sigma_max = base_sigma * (2 ** (layers - 1))
|
|
1311
|
+
pad = int(np.ceil(3.0 * sigma_max)) + 2
|
|
1312
|
+
|
|
1313
|
+
H, W = self._image.shape[:2]
|
|
1314
|
+
px0 = max(0, x0 - pad); py0 = max(0, y0 - pad)
|
|
1315
|
+
px1 = min(W, x1 + pad); py1 = min(H, y1 + pad)
|
|
1316
|
+
|
|
1317
|
+
crop = self._image[py0:py1, px0:px1].astype(np.float32, copy=False)
|
|
1318
|
+
|
|
1319
|
+
# Decompose crop
|
|
1320
|
+
details, residual = multiscale_decompose(crop, layers=layers, base_sigma=base_sigma)
|
|
1321
|
+
|
|
1322
|
+
# noise per layer (crop-based) — good enough for preview
|
|
1323
|
+
layer_noise = [_robust_sigma(w) if w.size else 1e-6 for w in details]
|
|
1324
|
+
|
|
1325
|
+
# Apply tuning per layer (can thread this like we discussed)
|
|
1326
|
+
mode = self.combo_mode.currentText()
|
|
1327
|
+
tuned = []
|
|
1328
|
+
for i,w in enumerate(details):
|
|
1329
|
+
cfg = self.cfgs[i]
|
|
1330
|
+
if not cfg.enabled:
|
|
1331
|
+
tuned.append(np.zeros_like(w))
|
|
1332
|
+
else:
|
|
1333
|
+
tuned.append(apply_layer_ops(w, cfg.bias_gain, cfg.thr, cfg.amount, cfg.denoise,
|
|
1334
|
+
layer_noise[i], mode=mode))
|
|
1335
|
+
|
|
1336
|
+
res = residual if self.residual_enabled else np.zeros_like(residual)
|
|
1337
|
+
out_raw = multiscale_reconstruct(tuned, res)
|
|
1338
|
+
|
|
1339
|
+
# Match your preview rules
|
|
1340
|
+
if not self.residual_enabled:
|
|
1341
|
+
out = np.clip(0.5 + out_raw * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1342
|
+
else:
|
|
1343
|
+
out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1344
|
+
|
|
1345
|
+
# Crop back from padded-crop coords to visible ROI coords
|
|
1346
|
+
cx0 = x0 - px0; cy0 = y0 - py0
|
|
1347
|
+
cx1 = cx0 + (x1 - x0); cy1 = cy0 + (y1 - y0)
|
|
1348
|
+
return out[cy0:cy1, cx0:cx1], (x0,y0,x1,y1)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
# ---------- Apply to doc ----------
|
|
1352
|
+
def _commit_to_doc(self):
|
|
1353
|
+
with self._busy_popup("Applying multiscale result to document…"):
|
|
1354
|
+
tuned, residual = self._build_tuned_layers()
|
|
1355
|
+
if tuned is None or residual is None:
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
# --- Reconstruction (match preview behavior) ---
|
|
1359
|
+
res = residual if self.residual_enabled else np.zeros_like(residual)
|
|
1360
|
+
out_raw = multiscale_reconstruct(tuned, res)
|
|
1361
|
+
|
|
1362
|
+
if not self.residual_enabled:
|
|
1363
|
+
# Detail-only result: same “mid-gray + gain” hack as preview
|
|
1364
|
+
d = out_raw.astype(np.float32, copy=False)
|
|
1365
|
+
out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1366
|
+
else:
|
|
1367
|
+
out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1368
|
+
|
|
1369
|
+
# convert back to mono if original was mono
|
|
1370
|
+
if self._orig_mono:
|
|
1371
|
+
mono = out[..., 0]
|
|
1372
|
+
if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
|
|
1373
|
+
mono = mono[:, :, None]
|
|
1374
|
+
out_final = mono.astype(np.float32, copy=False)
|
|
1375
|
+
else:
|
|
1376
|
+
out_final = out
|
|
1377
|
+
|
|
1378
|
+
try:
|
|
1379
|
+
if hasattr(self._doc, "set_image"):
|
|
1380
|
+
self._doc.set_image(out_final, step_name="Multiscale Decomposition")
|
|
1381
|
+
elif hasattr(self._doc, "apply_numpy"):
|
|
1382
|
+
self._doc.apply_numpy(out_final, step_name="Multiscale Decomposition")
|
|
1383
|
+
else:
|
|
1384
|
+
self._doc.image = out_final
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
QMessageBox.critical(self, "Multiscale Decomposition", f"Failed to write to document:\n{e}")
|
|
1387
|
+
return
|
|
1388
|
+
|
|
1389
|
+
if hasattr(self.parent(), "_refresh_active_view"):
|
|
1390
|
+
try:
|
|
1391
|
+
self.parent()._refresh_active_view()
|
|
1392
|
+
except Exception:
|
|
1393
|
+
pass
|
|
1394
|
+
|
|
1395
|
+
self.accept()
|
|
1396
|
+
|
|
1397
|
+
def _send_detail_to_new_doc(self):
|
|
1398
|
+
"""
|
|
1399
|
+
Send the *final* multiscale result (same as Apply to Document)
|
|
1400
|
+
to a brand-new document via DocManager.
|
|
1401
|
+
|
|
1402
|
+
- If residual is enabled: standard 0..1 clipped composite.
|
|
1403
|
+
- If residual is disabled: uses the mid-gray detail-only hack
|
|
1404
|
+
(0.5 + d*4.0), just like the preview/commit path.
|
|
1405
|
+
"""
|
|
1406
|
+
with self._busy_popup("Creating new document from multiscale result…"):
|
|
1407
|
+
self._recompute_decomp(force=False)
|
|
1408
|
+
|
|
1409
|
+
details = self._cached_layers
|
|
1410
|
+
residual = self._cached_residual
|
|
1411
|
+
if details is None or residual is None:
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
dm = self._get_doc_manager()
|
|
1415
|
+
if dm is None:
|
|
1416
|
+
QMessageBox.warning(
|
|
1417
|
+
self,
|
|
1418
|
+
"Multiscale Decomposition",
|
|
1419
|
+
"No DocManager available to create a new document."
|
|
1420
|
+
)
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
# --- Same tuned-layer logic as _commit_to_doc -------------------
|
|
1424
|
+
mode = self.combo_mode.currentText() # "μ–σ Thresholding" or "Linear"
|
|
1425
|
+
|
|
1426
|
+
tuned = []
|
|
1427
|
+
for i, w in enumerate(details):
|
|
1428
|
+
cfg = self.cfgs[i]
|
|
1429
|
+
if not cfg.enabled:
|
|
1430
|
+
tuned.append(np.zeros_like(w))
|
|
1431
|
+
else:
|
|
1432
|
+
sigma = None
|
|
1433
|
+
if self._layer_noise is not None and i < len(self._layer_noise):
|
|
1434
|
+
sigma = self._layer_noise[i]
|
|
1435
|
+
tuned.append(
|
|
1436
|
+
apply_layer_ops(
|
|
1437
|
+
w,
|
|
1438
|
+
cfg.bias_gain,
|
|
1439
|
+
cfg.thr,
|
|
1440
|
+
cfg.amount,
|
|
1441
|
+
cfg.denoise,
|
|
1442
|
+
sigma,
|
|
1443
|
+
mode=mode,
|
|
1444
|
+
)
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
# --- Reconstruction (match Apply-to-Document behavior) ----------
|
|
1448
|
+
res = residual if self.residual_enabled else np.zeros_like(residual)
|
|
1449
|
+
out_raw = multiscale_reconstruct(tuned, res)
|
|
1450
|
+
|
|
1451
|
+
if not self.residual_enabled:
|
|
1452
|
+
# Detail-only flavor: mid-gray + gain hack
|
|
1453
|
+
d = out_raw.astype(np.float32, copy=False)
|
|
1454
|
+
out = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1455
|
+
else:
|
|
1456
|
+
out = np.clip(out_raw, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1457
|
+
|
|
1458
|
+
# --- Back to original mono/color layout -------------------------
|
|
1459
|
+
if self._orig_mono:
|
|
1460
|
+
mono = out[..., 0]
|
|
1461
|
+
if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
|
|
1462
|
+
mono = mono[:, :, None]
|
|
1463
|
+
out_final = mono.astype(np.float32, copy=False)
|
|
1464
|
+
else:
|
|
1465
|
+
out_final = out
|
|
1466
|
+
|
|
1467
|
+
title = "Multiscale Result"
|
|
1468
|
+
meta = self._build_new_doc_metadata(title, out_final)
|
|
1469
|
+
|
|
1470
|
+
try:
|
|
1471
|
+
dm.create_document(out_final, metadata=meta, name=title)
|
|
1472
|
+
except Exception as e:
|
|
1473
|
+
QMessageBox.critical(
|
|
1474
|
+
self,
|
|
1475
|
+
"Multiscale Decomposition",
|
|
1476
|
+
f"Failed to create new document:\n{e}"
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
def _split_layers_to_docs(self):
|
|
1480
|
+
"""
|
|
1481
|
+
Create a new document for each tuned detail layer *and* the residual.
|
|
1482
|
+
|
|
1483
|
+
- Detail layers use the same mid-gray visualization as the per-layer preview:
|
|
1484
|
+
vis = 0.5 + layer*4.0
|
|
1485
|
+
- Residual layer is just the residual itself (0..1 clipped).
|
|
1486
|
+
"""
|
|
1487
|
+
with self._busy_popup("Splitting layers into documents…") as prog:
|
|
1488
|
+
self._recompute_decomp(force=False)
|
|
1489
|
+
|
|
1490
|
+
details = self._cached_layers
|
|
1491
|
+
residual = self._cached_residual
|
|
1492
|
+
if details is None or residual is None:
|
|
1493
|
+
return
|
|
1494
|
+
|
|
1495
|
+
dm = self._get_doc_manager()
|
|
1496
|
+
if dm is None:
|
|
1497
|
+
QMessageBox.warning(
|
|
1498
|
+
self,
|
|
1499
|
+
"Multiscale Decomposition",
|
|
1500
|
+
"No DocManager available to create new documents."
|
|
1501
|
+
)
|
|
1502
|
+
return
|
|
1503
|
+
|
|
1504
|
+
mode = self.combo_mode.currentText()
|
|
1505
|
+
# Build tuned layers just like everywhere else
|
|
1506
|
+
tuned = []
|
|
1507
|
+
for i, w in enumerate(details):
|
|
1508
|
+
cfg = self.cfgs[i]
|
|
1509
|
+
if not cfg.enabled:
|
|
1510
|
+
tuned.append(np.zeros_like(w))
|
|
1511
|
+
else:
|
|
1512
|
+
sigma = None
|
|
1513
|
+
if self._layer_noise is not None and i < len(self._layer_noise):
|
|
1514
|
+
sigma = self._layer_noise[i]
|
|
1515
|
+
tuned.append(
|
|
1516
|
+
apply_layer_ops(
|
|
1517
|
+
w,
|
|
1518
|
+
cfg.bias_gain,
|
|
1519
|
+
cfg.thr,
|
|
1520
|
+
cfg.amount,
|
|
1521
|
+
cfg.denoise,
|
|
1522
|
+
sigma,
|
|
1523
|
+
mode=mode,
|
|
1524
|
+
)
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
# ---- 1) Detail layers ------------------------------------------
|
|
1528
|
+
for i, layer in enumerate(tuned):
|
|
1529
|
+
d = layer.astype(np.float32, copy=False)
|
|
1530
|
+
vis = np.clip(0.5 + d * 4.0, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1531
|
+
|
|
1532
|
+
if self._orig_mono:
|
|
1533
|
+
mono = vis[..., 0]
|
|
1534
|
+
if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
|
|
1535
|
+
mono = mono[:, :, None]
|
|
1536
|
+
out_final = mono.astype(np.float32, copy=False)
|
|
1537
|
+
else:
|
|
1538
|
+
out_final = vis
|
|
1539
|
+
|
|
1540
|
+
title = f"Multiscale Detail Layer {i+1}"
|
|
1541
|
+
meta = self._build_new_doc_metadata(title, out_final)
|
|
1542
|
+
|
|
1543
|
+
try:
|
|
1544
|
+
dm.create_document(out_final, metadata=meta, name=title)
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
QMessageBox.critical(
|
|
1547
|
+
self,
|
|
1548
|
+
"Multiscale Decomposition",
|
|
1549
|
+
f"Failed to create document for layer {i+1}:\n{e}"
|
|
1550
|
+
)
|
|
1551
|
+
# Don’t bail entirely on first error if you’d rather continue;
|
|
1552
|
+
# right now we stop on first hard failure.
|
|
1553
|
+
return
|
|
1554
|
+
|
|
1555
|
+
# ---- 2) Residual layer -----------------------------------------
|
|
1556
|
+
try:
|
|
1557
|
+
res = residual.astype(np.float32, copy=False)
|
|
1558
|
+
res_img = np.clip(res, 0.0, 1.0)
|
|
1559
|
+
|
|
1560
|
+
if self._orig_mono:
|
|
1561
|
+
mono = res_img[..., 0]
|
|
1562
|
+
if len(self._orig_shape) == 3 and self._orig_shape[2] == 1:
|
|
1563
|
+
mono = mono[:, :, None]
|
|
1564
|
+
res_final = mono.astype(np.float32, copy=False)
|
|
1565
|
+
else:
|
|
1566
|
+
res_final = res_img
|
|
1567
|
+
|
|
1568
|
+
r_title = "Multiscale Residual Layer"
|
|
1569
|
+
r_meta = self._build_new_doc_metadata(r_title, res_final)
|
|
1570
|
+
|
|
1571
|
+
dm.create_document(res_final, metadata=r_meta, name=r_title)
|
|
1572
|
+
except Exception as e:
|
|
1573
|
+
QMessageBox.critical(
|
|
1574
|
+
self,
|
|
1575
|
+
"Multiscale Decomposition",
|
|
1576
|
+
f"Failed to create residual-layer document:\n{e}"
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _get_doc_manager(self):
|
|
1582
|
+
"""
|
|
1583
|
+
Best-effort: find the DocManager that owns the source document.
|
|
1584
|
+
Prefer the doc's own _doc_manager; fall back to parent.doc_manager.
|
|
1585
|
+
"""
|
|
1586
|
+
doc = getattr(self, "_doc", None)
|
|
1587
|
+
dm = getattr(doc, "_doc_manager", None) if doc is not None else None
|
|
1588
|
+
|
|
1589
|
+
if dm is None:
|
|
1590
|
+
parent = self.parent()
|
|
1591
|
+
dm = getattr(parent, "doc_manager", None) if parent is not None else None
|
|
1592
|
+
|
|
1593
|
+
return dm
|
|
1594
|
+
|
|
1595
|
+
def _build_new_doc_metadata(self, title: str, img: np.ndarray) -> dict:
|
|
1596
|
+
"""
|
|
1597
|
+
Clone the source document's metadata and sanitize it for a brand-new doc.
|
|
1598
|
+
"""
|
|
1599
|
+
base_doc = getattr(self, "_doc", None)
|
|
1600
|
+
base_meta = getattr(base_doc, "metadata", {}) or {}
|
|
1601
|
+
meta = dict(base_meta)
|
|
1602
|
+
|
|
1603
|
+
# New display name
|
|
1604
|
+
if title:
|
|
1605
|
+
meta["display_name"] = title
|
|
1606
|
+
|
|
1607
|
+
# Drop things that make it look linked/preview/ROI
|
|
1608
|
+
imi = dict(meta.get("image_meta") or {})
|
|
1609
|
+
for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
|
|
1610
|
+
imi.pop(k, None)
|
|
1611
|
+
meta["image_meta"] = imi
|
|
1612
|
+
|
|
1613
|
+
# Remove any ROI-ish keys
|
|
1614
|
+
for k in list(meta.keys()):
|
|
1615
|
+
if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
|
|
1616
|
+
meta.pop(k, None)
|
|
1617
|
+
|
|
1618
|
+
# For a brand-new doc, don't keep the original file_path
|
|
1619
|
+
meta.pop("file_path", None)
|
|
1620
|
+
|
|
1621
|
+
# Normalize mono flag
|
|
1622
|
+
if isinstance(img, np.ndarray):
|
|
1623
|
+
meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
|
|
1624
|
+
|
|
1625
|
+
# Keep bit depth / headers / WCS as-is; DocManager.open_array() will
|
|
1626
|
+
# ensure bit_depth etc. are sane.
|
|
1627
|
+
return meta
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
class _MultiScaleDecompPresetDialog(QDialog):
|
|
1632
|
+
"""
|
|
1633
|
+
Preset editor for Multiscale Decomposition (headless + shortcuts).
|
|
1634
|
+
"""
|
|
1635
|
+
def __init__(self, parent=None, initial: dict | None = None):
|
|
1636
|
+
super().__init__(parent)
|
|
1637
|
+
self.setWindowTitle("Multiscale Decomposition — Preset")
|
|
1638
|
+
init = dict(initial or {})
|
|
1639
|
+
|
|
1640
|
+
v = QVBoxLayout(self)
|
|
1641
|
+
|
|
1642
|
+
# ---- Global ----
|
|
1643
|
+
gb = QGroupBox("Global")
|
|
1644
|
+
form = QFormLayout(gb)
|
|
1645
|
+
|
|
1646
|
+
self.sp_layers = QSpinBox()
|
|
1647
|
+
self.sp_layers.setRange(1, 10)
|
|
1648
|
+
self.sp_layers.setValue(int(init.get("layers", 4)))
|
|
1649
|
+
|
|
1650
|
+
self.sp_sigma = QDoubleSpinBox()
|
|
1651
|
+
self.sp_sigma.setRange(0.3, 5.0)
|
|
1652
|
+
self.sp_sigma.setDecimals(2)
|
|
1653
|
+
self.sp_sigma.setSingleStep(0.1)
|
|
1654
|
+
self.sp_sigma.setValue(float(init.get("base_sigma", 1.0)))
|
|
1655
|
+
|
|
1656
|
+
self.cb_linked = QCheckBox("Linked RGB channels")
|
|
1657
|
+
self.cb_linked.setChecked(bool(init.get("linked_rgb", True)))
|
|
1658
|
+
|
|
1659
|
+
form.addRow("Layers:", self.sp_layers)
|
|
1660
|
+
form.addRow("Base sigma:", self.sp_sigma)
|
|
1661
|
+
form.addRow("", self.cb_linked)
|
|
1662
|
+
|
|
1663
|
+
v.addWidget(gb)
|
|
1664
|
+
|
|
1665
|
+
# ---- Layers ----
|
|
1666
|
+
gb_layers = QGroupBox("Per-Layer Settings")
|
|
1667
|
+
lv = QVBoxLayout(gb_layers)
|
|
1668
|
+
|
|
1669
|
+
self.table = QTableWidget(0, 6)
|
|
1670
|
+
self.table.setHorizontalHeaderLabels(
|
|
1671
|
+
["On", "Layer", "Gain", "Thr (σ)", "Amount", "Denoise"]
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
self.table.verticalHeader().setVisible(False)
|
|
1675
|
+
lv.addWidget(self.table)
|
|
1676
|
+
|
|
1677
|
+
v.addWidget(gb_layers)
|
|
1678
|
+
|
|
1679
|
+
# ---- Buttons ----
|
|
1680
|
+
btns = QHBoxLayout()
|
|
1681
|
+
ok = QPushButton("OK")
|
|
1682
|
+
cancel = QPushButton("Cancel")
|
|
1683
|
+
btns.addStretch(1)
|
|
1684
|
+
btns.addWidget(ok)
|
|
1685
|
+
btns.addWidget(cancel)
|
|
1686
|
+
v.addLayout(btns)
|
|
1687
|
+
|
|
1688
|
+
ok.clicked.connect(self.accept)
|
|
1689
|
+
cancel.clicked.connect(self.reject)
|
|
1690
|
+
|
|
1691
|
+
self._populate_table(init)
|
|
1692
|
+
|
|
1693
|
+
def _populate_table(self, init: dict):
|
|
1694
|
+
layers = int(self.sp_layers.value())
|
|
1695
|
+
cfgs = init.get("layers_cfg", [])
|
|
1696
|
+
|
|
1697
|
+
self.table.setRowCount(layers)
|
|
1698
|
+
|
|
1699
|
+
for i in range(layers):
|
|
1700
|
+
cfg = cfgs[i] if i < len(cfgs) else {}
|
|
1701
|
+
|
|
1702
|
+
# Enabled
|
|
1703
|
+
chk = QTableWidgetItem("")
|
|
1704
|
+
chk.setFlags(chk.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
|
1705
|
+
chk.setCheckState(
|
|
1706
|
+
Qt.CheckState.Checked if cfg.get("enabled", True)
|
|
1707
|
+
else Qt.CheckState.Unchecked
|
|
1708
|
+
)
|
|
1709
|
+
self.table.setItem(i, 0, chk)
|
|
1710
|
+
|
|
1711
|
+
self.table.setItem(i, 1, QTableWidgetItem(str(i + 1)))
|
|
1712
|
+
self.table.setItem(i, 2, QTableWidgetItem(f"{float(cfg.get('gain', 1.0)):.2f}"))
|
|
1713
|
+
self.table.setItem(i, 3, QTableWidgetItem(f"{float(cfg.get('thr', 0.0)):.2f}")) # N·σ
|
|
1714
|
+
self.table.setItem(i, 4, QTableWidgetItem(f"{float(cfg.get('amount', 0.0)):.2f}"))
|
|
1715
|
+
self.table.setItem(i, 5, QTableWidgetItem(f"{float(cfg.get('denoise',0.0)):.2f}"))
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def result_dict(self) -> dict:
|
|
1720
|
+
layers = int(self.sp_layers.value())
|
|
1721
|
+
out_layers = []
|
|
1722
|
+
|
|
1723
|
+
for r in range(layers):
|
|
1724
|
+
enabled = self.table.item(r, 0).checkState() == Qt.CheckState.Checked
|
|
1725
|
+
gain = float(self.table.item(r, 2).text())
|
|
1726
|
+
thr = float(self.table.item(r, 3).text())
|
|
1727
|
+
amt = float(self.table.item(r, 4).text())
|
|
1728
|
+
try:
|
|
1729
|
+
dn = float(self.table.item(r, 5).text())
|
|
1730
|
+
except Exception:
|
|
1731
|
+
dn = 0.0
|
|
1732
|
+
|
|
1733
|
+
out_layers.append({
|
|
1734
|
+
"enabled": enabled,
|
|
1735
|
+
"gain": gain,
|
|
1736
|
+
"thr": thr,
|
|
1737
|
+
"amount": amt,
|
|
1738
|
+
"denoise": dn,
|
|
1739
|
+
})
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
return {
|
|
1743
|
+
"layers": layers,
|
|
1744
|
+
"base_sigma": float(self.sp_sigma.value()),
|
|
1745
|
+
"linked_rgb": bool(self.cb_linked.isChecked()),
|
|
1746
|
+
"layers_cfg": out_layers,
|
|
1747
|
+
}
|