setiastrosuitepro 1.6.2.post1__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/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/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 +945 -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 +694 -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 +602 -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 +2926 -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 +973 -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 +8810 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -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 +389 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -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 +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1841 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +931 -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 +3831 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1293 -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 +1102 -0
- setiastro/saspro/ops/scripts.py +1473 -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 +1071 -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 +404 -0
- setiastro/saspro/resources.py +501 -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 +1552 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +18470 -0
- setiastro/saspro/star_alignment.py +7435 -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 +3328 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +3749 -0
- setiastro/saspro/translations/es_translations.py +3939 -0
- setiastro/saspro/translations/fr_translations.py +3858 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +270 -0
- setiastro/saspro/translations/it_translations.py +3678 -0
- setiastro/saspro/translations/ja_translations.py +3601 -0
- setiastro/saspro/translations/pt_translations.py +3869 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +3675 -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 +492 -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 +986 -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 +237 -0
- setiastro/saspro/widgets/spinboxes.py +275 -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 +1123 -0
- setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
- setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
import cv2
|
|
6
|
+
except Exception:
|
|
7
|
+
cv2 = None
|
|
8
|
+
|
|
9
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
10
|
+
from PyQt6.QtGui import QImage, QPixmap, QIcon, QGuiApplication
|
|
11
|
+
from PyQt6.QtWidgets import (
|
|
12
|
+
QDialog, QWidget, QLabel, QPushButton, QComboBox, QCheckBox, QSlider, QGroupBox,
|
|
13
|
+
QVBoxLayout, QHBoxLayout, QGridLayout, QMessageBox, QSpinBox, QDoubleSpinBox,
|
|
14
|
+
QFileDialog, QScrollArea, QFrame, QTabWidget, QSplitter
|
|
15
|
+
)
|
|
16
|
+
from PyQt6.QtWidgets import QSizePolicy
|
|
17
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------
|
|
20
|
+
# Small helpers
|
|
21
|
+
# ---------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _to_uint8_rgb(img01: np.ndarray) -> np.ndarray:
|
|
24
|
+
a = np.clip(img01, 0.0, 1.0)
|
|
25
|
+
if a.ndim == 2:
|
|
26
|
+
a = np.repeat(a[..., None], 3, axis=2)
|
|
27
|
+
return (a * 255.0 + 0.5).astype(np.uint8)
|
|
28
|
+
|
|
29
|
+
def _to_pixmap(img01: np.ndarray) -> QPixmap:
|
|
30
|
+
a = _to_uint8_rgb(img01)
|
|
31
|
+
h, w, _ = a.shape
|
|
32
|
+
qimg = QImage(a.data, w, h, a.strides[0], QImage.Format.Format_RGB888)
|
|
33
|
+
return QPixmap.fromImage(qimg)
|
|
34
|
+
|
|
35
|
+
def _rgb_to_hsv01(img01: np.ndarray) -> np.ndarray:
|
|
36
|
+
if cv2 is None:
|
|
37
|
+
raise RuntimeError("OpenCV (cv2) is required for Selective Color.")
|
|
38
|
+
# expects 8-bit for best speed
|
|
39
|
+
u8 = _to_uint8_rgb(img01)
|
|
40
|
+
hsv = cv2.cvtColor(u8, cv2.COLOR_RGB2HSV) # H in [0,180], S,V in [0,255]
|
|
41
|
+
out = np.empty_like(hsv, dtype=np.float32)
|
|
42
|
+
out[...,0] = hsv[...,0].astype(np.float32) / 180.0 # 0..1
|
|
43
|
+
out[...,1] = hsv[...,1].astype(np.float32) / 255.0
|
|
44
|
+
out[...,2] = hsv[...,2].astype(np.float32) / 255.0
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
def _luminance01(img01: np.ndarray) -> np.ndarray:
|
|
48
|
+
if img01.ndim == 2:
|
|
49
|
+
return np.clip(img01, 0.0, 1.0).astype(np.float32)
|
|
50
|
+
r, g, b = img01[...,0], img01[...,1], img01[...,2]
|
|
51
|
+
return (0.2989*r + 0.5870*g + 0.1140*b).astype(np.float32)
|
|
52
|
+
|
|
53
|
+
def _softstep(x, edge0, edge1):
|
|
54
|
+
# smoothstep
|
|
55
|
+
t = np.clip((x - edge0) / max(edge1 - edge0, 1e-6), 0.0, 1.0)
|
|
56
|
+
return t * t * (3 - 2*t)
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------
|
|
59
|
+
# Mask generation
|
|
60
|
+
# ---------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_PRESETS = {
|
|
63
|
+
"Red": [(340, 360), (0, 15)],
|
|
64
|
+
"Orange": [(15, 40)],
|
|
65
|
+
"Yellow": [(40, 70)],
|
|
66
|
+
"Green": [(70, 170)],
|
|
67
|
+
"Cyan": [(170, 200)],
|
|
68
|
+
"Blue": [(200, 270)],
|
|
69
|
+
"Magenta": [(270, 340)],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def _hue_band(Hdeg: np.ndarray, lo: float, hi: float, smooth_deg: float) -> np.ndarray:
|
|
73
|
+
"""
|
|
74
|
+
Soft band on the hue circle (degrees 0..360), but with *local* feathering:
|
|
75
|
+
- core band is the forward arc lo → hi
|
|
76
|
+
- smooth_deg only adds a ramp *right after hi* and *right before lo*
|
|
77
|
+
- never balloons into the whole hue wheel
|
|
78
|
+
"""
|
|
79
|
+
H = Hdeg.astype(np.float32)
|
|
80
|
+
|
|
81
|
+
lo = float(lo) % 360.0
|
|
82
|
+
hi = float(hi) % 360.0
|
|
83
|
+
|
|
84
|
+
# length of the forward arc
|
|
85
|
+
L = (hi - lo) % 360.0
|
|
86
|
+
if L <= 1e-6:
|
|
87
|
+
return np.zeros_like(H, dtype=np.float32)
|
|
88
|
+
|
|
89
|
+
s = float(max(smooth_deg, 0.0))
|
|
90
|
+
|
|
91
|
+
# forward distance from lo → hue (always 0..360)
|
|
92
|
+
fwd = (H - lo) % 360.0
|
|
93
|
+
# backward distance from hue → lo (always 0..360)
|
|
94
|
+
bwd = (lo - H) % 360.0
|
|
95
|
+
|
|
96
|
+
# start with zeros
|
|
97
|
+
band = np.zeros_like(H, dtype=np.float32)
|
|
98
|
+
|
|
99
|
+
# 1) core: strictly inside the band
|
|
100
|
+
inside = (fwd <= L)
|
|
101
|
+
band[inside] = 1.0
|
|
102
|
+
|
|
103
|
+
if s > 1e-6:
|
|
104
|
+
# 2) upper feather: just after hi
|
|
105
|
+
upper = (fwd > L) & (fwd < L + s)
|
|
106
|
+
band[upper] = np.maximum(
|
|
107
|
+
band[upper],
|
|
108
|
+
1.0 - (fwd[upper] - L) / s
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# 3) lower feather: just before lo (going backwards)
|
|
112
|
+
lower = (bwd > 0) & (bwd < s)
|
|
113
|
+
band[lower] = np.maximum(
|
|
114
|
+
band[lower],
|
|
115
|
+
1.0 - bwd[lower] / s
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return np.clip(band, 0.0, 1.0).astype(np.float32)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _hue_mask(img01: np.ndarray,
|
|
123
|
+
ranges_deg: list[tuple[float,float]],
|
|
124
|
+
min_chroma: float,
|
|
125
|
+
min_light: float,
|
|
126
|
+
max_light: float,
|
|
127
|
+
smooth_deg: float,
|
|
128
|
+
invert_range: bool = False) -> np.ndarray:
|
|
129
|
+
"""
|
|
130
|
+
Return mask in 0..1 for the UNION of hue bands in ranges_deg (degrees).
|
|
131
|
+
Handles wrap-around without recursion. If invert_range=True, selects the
|
|
132
|
+
COMPLEMENT of the union on the hue circle (before chroma/light gating).
|
|
133
|
+
"""
|
|
134
|
+
hsv = _rgb_to_hsv01(img01) # H in [0..1)
|
|
135
|
+
Hdeg = (np.mod(hsv[..., 0] * 360.0, 360.0)).astype(np.float32)
|
|
136
|
+
S = hsv[..., 1].astype(np.float32)
|
|
137
|
+
V = hsv[..., 2].astype(np.float32)
|
|
138
|
+
|
|
139
|
+
m = np.zeros_like(Hdeg, dtype=np.float32)
|
|
140
|
+
for lo, hi in ranges_deg:
|
|
141
|
+
m = np.maximum(m, _hue_band(Hdeg, lo, hi, smooth_deg))
|
|
142
|
+
|
|
143
|
+
# Invert selection on the hue circle if requested
|
|
144
|
+
if invert_range:
|
|
145
|
+
m = 1.0 - m
|
|
146
|
+
|
|
147
|
+
# chroma/light gating
|
|
148
|
+
if min_chroma > 0:
|
|
149
|
+
chroma = (S * V).astype(np.float32)
|
|
150
|
+
m *= _softstep(chroma, float(min_chroma)*0.7, float(min_chroma))
|
|
151
|
+
if min_light > 0:
|
|
152
|
+
m *= (V >= float(min_light)).astype(np.float32)
|
|
153
|
+
if max_light < 1:
|
|
154
|
+
m *= (V <= float(max_light)).astype(np.float32)
|
|
155
|
+
|
|
156
|
+
return np.clip(m, 0.0, 1.0)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _weight_shadows_highlights(mask: np.ndarray,
|
|
162
|
+
img01: np.ndarray,
|
|
163
|
+
shadows: float,
|
|
164
|
+
highlights: float,
|
|
165
|
+
balance: float) -> np.ndarray:
|
|
166
|
+
"""
|
|
167
|
+
New behavior:
|
|
168
|
+
- `shadows` in [0..1]: pixels BELOW this luminance get faded OUT.
|
|
169
|
+
- `highlights` in [0..1]: pixels ABOVE this luminance get faded OUT.
|
|
170
|
+
- `balance` just tweaks feather width (optional).
|
|
171
|
+
"""
|
|
172
|
+
L = _luminance01(img01).astype(np.float32)
|
|
173
|
+
w = np.ones_like(L, dtype=np.float32)
|
|
174
|
+
|
|
175
|
+
# feather size ~ 8% of range, you can tune this
|
|
176
|
+
feather = 0.08 + 0.12 * balance # 0.08..0.2
|
|
177
|
+
|
|
178
|
+
# 1) shadow gate: fade OUT below `shadows`
|
|
179
|
+
if shadows > 1e-3:
|
|
180
|
+
s0 = max(0.0, shadows - feather)
|
|
181
|
+
s1 = min(1.0, shadows + 1e-6)
|
|
182
|
+
# below s0 → 0, above s1 → 1
|
|
183
|
+
w *= _softstep(L, s0, s1)
|
|
184
|
+
|
|
185
|
+
# 2) highlight gate: fade OUT above `highlights`
|
|
186
|
+
if highlights < 0.999:
|
|
187
|
+
h0 = max(0.0, highlights - 1e-6)
|
|
188
|
+
h1 = min(1.0, highlights + feather)
|
|
189
|
+
# below h0 → 1, above h1 → 0
|
|
190
|
+
w *= (1.0 - _softstep(L, h0, h1))
|
|
191
|
+
|
|
192
|
+
# apply to mask
|
|
193
|
+
return np.clip(mask * w, 0.0, 1.0)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------
|
|
197
|
+
# Color adjustments
|
|
198
|
+
# ---------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _apply_selective_adjustments(img01: np.ndarray,
|
|
201
|
+
mask01: np.ndarray,
|
|
202
|
+
cyan: float, magenta: float, yellow: float,
|
|
203
|
+
r: float, g: float, b: float,
|
|
204
|
+
lum: float, chroma: float, sat: float, con: float,
|
|
205
|
+
intensity: float,
|
|
206
|
+
use_chroma_mode: bool) -> np.ndarray:
|
|
207
|
+
|
|
208
|
+
"""
|
|
209
|
+
CMY/RGB sliders in [-1..+1] range (we’ll clamp).
|
|
210
|
+
L/S/C also in [-1..+1].
|
|
211
|
+
"""
|
|
212
|
+
a = img01.astype(np.float32, copy=True)
|
|
213
|
+
m = np.clip(mask01.astype(np.float32) * float(intensity), 0.0, 1.0)
|
|
214
|
+
|
|
215
|
+
# RGB base
|
|
216
|
+
if a.ndim == 2:
|
|
217
|
+
a = np.repeat(a[..., None], 3, axis=2)
|
|
218
|
+
|
|
219
|
+
R = a[...,0]; G = a[...,1]; B = a[...,2]
|
|
220
|
+
|
|
221
|
+
# CMY = reduce the complementary primary
|
|
222
|
+
# Positive Cyan -> reduce Red; negative Cyan -> increase Red.
|
|
223
|
+
R = np.clip(R + (-cyan) * m, 0.0, 1.0)
|
|
224
|
+
G = np.clip(G + (-magenta) * m, 0.0, 1.0)
|
|
225
|
+
B = np.clip(B + (-yellow) * m, 0.0, 1.0)
|
|
226
|
+
|
|
227
|
+
# Primary boosts
|
|
228
|
+
R = np.clip(R + r * m, 0.0, 1.0)
|
|
229
|
+
G = np.clip(G + g * m, 0.0, 1.0)
|
|
230
|
+
B = np.clip(B + b * m, 0.0, 1.0)
|
|
231
|
+
|
|
232
|
+
out = np.stack([R,G,B], axis=-1)
|
|
233
|
+
|
|
234
|
+
# L / Chroma-or-Sat / Contrast
|
|
235
|
+
if any(abs(x) > 1e-6 for x in (lum, chroma, sat, con)):
|
|
236
|
+
if abs(lum) > 0:
|
|
237
|
+
out = np.clip(out + lum * m[..., None], 0.0, 1.0)
|
|
238
|
+
|
|
239
|
+
if abs(con) > 0:
|
|
240
|
+
out = np.clip((out - 0.5) * (1.0 + con * m[..., None]) + 0.5, 0.0, 1.0)
|
|
241
|
+
|
|
242
|
+
if use_chroma_mode:
|
|
243
|
+
if abs(chroma) > 0:
|
|
244
|
+
out = _apply_chroma_boost(out, m, chroma)
|
|
245
|
+
else:
|
|
246
|
+
if abs(sat) > 0:
|
|
247
|
+
hsv = _rgb_to_hsv01(out)
|
|
248
|
+
hsv[..., 1] = np.clip(hsv[..., 1] * (1.0 + sat * m), 0.0, 1.0)
|
|
249
|
+
# HSV->RGB using cv2 (expects 8-bit)
|
|
250
|
+
hv = (hsv[..., 0] * 180.0).astype(np.uint8)
|
|
251
|
+
sv = (hsv[..., 1] * 255.0).astype(np.uint8)
|
|
252
|
+
vv = (hsv[..., 2] * 255.0).astype(np.uint8)
|
|
253
|
+
hsv8 = np.stack([hv, sv, vv], axis=-1)
|
|
254
|
+
rgb8 = cv2.cvtColor(hsv8, cv2.COLOR_HSV2RGB)
|
|
255
|
+
out = rgb8.astype(np.float32) / 255.0
|
|
256
|
+
|
|
257
|
+
return np.clip(out, 0.0, 1.0)
|
|
258
|
+
|
|
259
|
+
def _apply_chroma_boost(rgb01: np.ndarray, m01: np.ndarray, chroma: float) -> np.ndarray:
|
|
260
|
+
"""
|
|
261
|
+
L-preserving chroma change:
|
|
262
|
+
rgb' = Y + (rgb - Y) * (1 + chroma * m)
|
|
263
|
+
where Y is luminance and m is the 0..1 mask (with intensity applied upstream).
|
|
264
|
+
Positive chroma -> more colorfulness; negative -> less.
|
|
265
|
+
"""
|
|
266
|
+
rgb = _ensure_rgb01(rgb01).astype(np.float32)
|
|
267
|
+
m = np.clip(m01.astype(np.float32), 0.0, 1.0)[..., None]
|
|
268
|
+
Y = _luminance01(rgb)[..., None] # HxWx1
|
|
269
|
+
d = rgb - Y # chroma direction
|
|
270
|
+
k = (1.0 + float(chroma) * m) # scale per-pixel with mask
|
|
271
|
+
out = Y + d * k
|
|
272
|
+
return np.clip(out, 0.0, 1.0)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _ensure_rgb01(img: np.ndarray) -> np.ndarray:
|
|
276
|
+
"""Return an RGB float image in [0,1]."""
|
|
277
|
+
a = np.clip(img.astype(np.float32), 0.0, 1.0)
|
|
278
|
+
if a.ndim == 2:
|
|
279
|
+
a = np.repeat(a[..., None], 3, axis=2)
|
|
280
|
+
return a
|
|
281
|
+
|
|
282
|
+
class HueWheel(QWidget):
|
|
283
|
+
"""
|
|
284
|
+
A compact HSV hue wheel with two draggable handles for start/end (degrees 0..360).
|
|
285
|
+
Emits rangeChanged(start_deg, end_deg) when either handle moves.
|
|
286
|
+
"""
|
|
287
|
+
from PyQt6.QtCore import pyqtSignal
|
|
288
|
+
rangeChanged = pyqtSignal(int, int)
|
|
289
|
+
|
|
290
|
+
def __init__(self, start_deg=65, end_deg=158, parent=None):
|
|
291
|
+
super().__init__(parent)
|
|
292
|
+
self.setMinimumSize(160, 160)
|
|
293
|
+
self._start = int(start_deg) % 360
|
|
294
|
+
self._end = int(end_deg) % 360
|
|
295
|
+
self._dragging = None # "start" | "end" | None
|
|
296
|
+
self._ring_img = None
|
|
297
|
+
self._picked = None # degrees or None
|
|
298
|
+
|
|
299
|
+
# --- public API
|
|
300
|
+
def setRange(self, start_deg: int, end_deg: int, notify=True):
|
|
301
|
+
s = int(start_deg) % 360
|
|
302
|
+
e = int(end_deg) % 360
|
|
303
|
+
if s == self._start and e == self._end:
|
|
304
|
+
return
|
|
305
|
+
self._start, self._end = s, e
|
|
306
|
+
self.update()
|
|
307
|
+
if notify:
|
|
308
|
+
self.rangeChanged.emit(self._start, self._end)
|
|
309
|
+
|
|
310
|
+
def range(self):
|
|
311
|
+
return self._start, self._end
|
|
312
|
+
|
|
313
|
+
def setPickedHue(self, deg: float | int | None):
|
|
314
|
+
"""Show a small marker on the wheel at the sampled hue (degrees)."""
|
|
315
|
+
if deg is None:
|
|
316
|
+
self._picked = None
|
|
317
|
+
else:
|
|
318
|
+
self._picked = int(deg) % 360
|
|
319
|
+
self.update()
|
|
320
|
+
|
|
321
|
+
# --- util
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _ang_from_pos(cx, cy, x, y):
|
|
324
|
+
import math
|
|
325
|
+
a = math.degrees(math.atan2(y - cy, x - cx))
|
|
326
|
+
a = (a + 360.0) % 360.0
|
|
327
|
+
return a
|
|
328
|
+
|
|
329
|
+
def _ensure_ring(self, side):
|
|
330
|
+
# cache a color wheel image to paint fast
|
|
331
|
+
if self._ring_img is not None and self._ring_img.width() == side and self._ring_img.height() == side:
|
|
332
|
+
return
|
|
333
|
+
import math
|
|
334
|
+
side = int(side)
|
|
335
|
+
img = np.zeros((side, side, 3), np.uint8)
|
|
336
|
+
cx = cy = side // 2
|
|
337
|
+
r = int(side*0.48)
|
|
338
|
+
rr2 = r*r
|
|
339
|
+
for y in range(side):
|
|
340
|
+
dy = y - cy
|
|
341
|
+
for x in range(side):
|
|
342
|
+
dx = x - cx
|
|
343
|
+
d2 = dx*dx + dy*dy
|
|
344
|
+
if rr2 - r*12 <= d2 <= rr2: # thin ring
|
|
345
|
+
ang = self._ang_from_pos(cx, cy, x, y)
|
|
346
|
+
hsv = np.array([ang/2, 255, 255], np.uint8) # H 0..180 in OpenCV
|
|
347
|
+
rgb = cv2.cvtColor(hsv[None,None,:], cv2.COLOR_HSV2RGB)[0,0]
|
|
348
|
+
img[y, x] = rgb
|
|
349
|
+
h, w, _ = img.shape
|
|
350
|
+
self._ring_img = QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888).copy()
|
|
351
|
+
|
|
352
|
+
# --- events
|
|
353
|
+
def paintEvent(self, ev):
|
|
354
|
+
from PyQt6.QtGui import QPainter, QPen, QBrush, QColor
|
|
355
|
+
p = QPainter(self)
|
|
356
|
+
side = min(self.width(), self.height())
|
|
357
|
+
self._ensure_ring(side)
|
|
358
|
+
|
|
359
|
+
# center ring
|
|
360
|
+
x0 = (self.width() - side)//2
|
|
361
|
+
y0 = (self.height() - side)//2
|
|
362
|
+
p.drawImage(x0, y0, self._ring_img)
|
|
363
|
+
|
|
364
|
+
# draw handles & arc
|
|
365
|
+
cx = x0 + side//2
|
|
366
|
+
cy = y0 + side//2
|
|
367
|
+
r = int(side*0.48)
|
|
368
|
+
|
|
369
|
+
def pt(ang_deg):
|
|
370
|
+
import math
|
|
371
|
+
th = math.radians(ang_deg)
|
|
372
|
+
return int(cx + r*math.cos(th)), int(cy + r*math.sin(th))
|
|
373
|
+
|
|
374
|
+
# --- RANGE ARC (match mask logic) ---
|
|
375
|
+
# Mask defines band as positive arc from start -> end with L = (end - start) % 360.
|
|
376
|
+
s, e = int(self._start) % 360, int(self._end) % 360
|
|
377
|
+
L = (e - s) % 360 # arc length in degrees (0..359)
|
|
378
|
+
steps = 60
|
|
379
|
+
if L > 0:
|
|
380
|
+
p.setPen(QPen(QColor(255, 255, 255, 140), 4))
|
|
381
|
+
px, py = pt(s)
|
|
382
|
+
for k in range(1, steps + 1):
|
|
383
|
+
a = (s + (L * k) / steps) % 360 # move forward along the positive arc
|
|
384
|
+
qx, qy = pt(a)
|
|
385
|
+
p.drawLine(px, py, qx, qy)
|
|
386
|
+
px, py = qx, qy
|
|
387
|
+
|
|
388
|
+
# handles
|
|
389
|
+
p.setBrush(QBrush(QColor(255,255,255)))
|
|
390
|
+
p.setPen(QPen(QColor(0,0,0), 1))
|
|
391
|
+
for ang in (self._start, self._end):
|
|
392
|
+
xh, yh = pt(ang)
|
|
393
|
+
p.drawEllipse(xh-5, yh-5, 10, 10)
|
|
394
|
+
|
|
395
|
+
# sampled hue marker
|
|
396
|
+
if self._picked is not None:
|
|
397
|
+
import math
|
|
398
|
+
th = math.radians(self._picked)
|
|
399
|
+
px = int(cx + r*math.cos(th)); py = int(cy + r*math.sin(th))
|
|
400
|
+
p.setBrush(QBrush(QColor(0, 0, 0)))
|
|
401
|
+
p.setPen(QPen(QColor(255, 255, 255), 2))
|
|
402
|
+
p.drawEllipse(px-6, py-6, 12, 12)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def mousePressEvent(self, ev):
|
|
406
|
+
x, y = ev.position().x(), ev.position().y()
|
|
407
|
+
side = min(self.width(), self.height())
|
|
408
|
+
x0 = (self.width() - side)//2
|
|
409
|
+
y0 = (self.height() - side)//2
|
|
410
|
+
cx = x0 + side//2
|
|
411
|
+
cy = y0 + side//2
|
|
412
|
+
a = self._ang_from_pos(cx, cy, x, y)
|
|
413
|
+
# pick the nearest handle
|
|
414
|
+
def d(a0, a1):
|
|
415
|
+
dd = abs((a0 - a1 + 180) % 360 - 180)
|
|
416
|
+
return dd
|
|
417
|
+
if d(a, self._start) <= d(a, self._end):
|
|
418
|
+
self._dragging = "start"
|
|
419
|
+
self._start = int(a)
|
|
420
|
+
else:
|
|
421
|
+
self._dragging = "end"
|
|
422
|
+
self._end = int(a)
|
|
423
|
+
self.update()
|
|
424
|
+
self.rangeChanged.emit(self._start, self._end)
|
|
425
|
+
|
|
426
|
+
def mouseMoveEvent(self, ev):
|
|
427
|
+
if not self._dragging:
|
|
428
|
+
return
|
|
429
|
+
x, y = ev.position().x(), ev.position().y()
|
|
430
|
+
side = min(self.width(), self.height())
|
|
431
|
+
x0 = (self.width() - side)//2
|
|
432
|
+
y0 = (self.height() - side)//2
|
|
433
|
+
cx = x0 + side//2
|
|
434
|
+
cy = y0 + side//2
|
|
435
|
+
a = int(self._ang_from_pos(cx, cy, x, y)) % 360
|
|
436
|
+
if self._dragging == "start":
|
|
437
|
+
self._start = a
|
|
438
|
+
else:
|
|
439
|
+
self._end = a
|
|
440
|
+
self.update()
|
|
441
|
+
self.rangeChanged.emit(self._start, self._end)
|
|
442
|
+
|
|
443
|
+
def mouseReleaseEvent(self, ev):
|
|
444
|
+
self._dragging = None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------
|
|
448
|
+
# UI
|
|
449
|
+
# ---------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
class SelectiveColorCorrection(QDialog):
|
|
452
|
+
"""
|
|
453
|
+
v1.0 — live preview, mask overlay, presets + custom hue range,
|
|
454
|
+
CMY/RGB + L/S/C sliders. Loads active document's image.
|
|
455
|
+
"""
|
|
456
|
+
def __init__(self, doc_manager=None, document=None, parent=None, window_icon: QIcon | None = None):
|
|
457
|
+
super().__init__(parent)
|
|
458
|
+
self.setWindowTitle(self.tr("Selective Color Correction"))
|
|
459
|
+
if window_icon:
|
|
460
|
+
self.setWindowIcon(window_icon)
|
|
461
|
+
|
|
462
|
+
self.docman = doc_manager
|
|
463
|
+
self.document = document
|
|
464
|
+
if self.document is None or getattr(self.document, "image", None) is None:
|
|
465
|
+
QMessageBox.information(self, "No image", "Open an image first.")
|
|
466
|
+
self.close(); return
|
|
467
|
+
|
|
468
|
+
self.img = np.clip(self.document.image.astype(np.float32), 0.0, 1.0)
|
|
469
|
+
self.preview_img = self.img.copy()
|
|
470
|
+
|
|
471
|
+
self._imported_mask_full = None # full-res mask (H x W) float32 0..1
|
|
472
|
+
self._imported_mask_name = None # nice label to show in UI
|
|
473
|
+
self._use_imported_mask = False # checkbox state mirror
|
|
474
|
+
self._mask_delay_ms = 200
|
|
475
|
+
self._adj_delay_ms = 200
|
|
476
|
+
self._build_ui()
|
|
477
|
+
self._mask_delay_ms = 200 # 0.2s idle before recomputing mask
|
|
478
|
+
self._mask_timer = QTimer(self)
|
|
479
|
+
self._mask_timer.setSingleShot(True)
|
|
480
|
+
self._mask_timer.timeout.connect(self._recompute_mask_and_preview)
|
|
481
|
+
self._adj_delay_ms = 200
|
|
482
|
+
self._adj_timer = QTimer(self)
|
|
483
|
+
self._adj_timer.setSingleShot(True)
|
|
484
|
+
self._adj_timer.timeout.connect(self._update_preview_pixmap)
|
|
485
|
+
self.dd_preset.setCurrentText("Red")
|
|
486
|
+
self._setting_preset = False
|
|
487
|
+
self._recompute_mask_and_preview()
|
|
488
|
+
self._panning = False
|
|
489
|
+
self._pan_start_pos = None # QPointF in label coords
|
|
490
|
+
self._pan_start_scroll = (0, 0) # (hval, vval)
|
|
491
|
+
self._pan_deadzone = 1
|
|
492
|
+
self._pan_start_pos_vp = None
|
|
493
|
+
|
|
494
|
+
# ------------- UI -------------
|
|
495
|
+
def _build_ui(self):
|
|
496
|
+
# --- Root layout -------------------------------------------------------
|
|
497
|
+
root = QHBoxLayout(self)
|
|
498
|
+
root.setContentsMargins(8, 8, 8, 8)
|
|
499
|
+
root.setSpacing(10)
|
|
500
|
+
|
|
501
|
+
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
502
|
+
splitter.setChildrenCollapsible(False)
|
|
503
|
+
splitter.setHandleWidth(6)
|
|
504
|
+
root.addWidget(splitter)
|
|
505
|
+
|
|
506
|
+
# ======================================================================
|
|
507
|
+
# LEFT PANE (header → "small preview" toggle → scroller → live toggle → buttons)
|
|
508
|
+
# ======================================================================
|
|
509
|
+
left_widget = QWidget()
|
|
510
|
+
left_widget.setMinimumWidth(360)
|
|
511
|
+
left_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
|
512
|
+
splitter.addWidget(left_widget)
|
|
513
|
+
|
|
514
|
+
left_outer = QVBoxLayout(left_widget)
|
|
515
|
+
left_outer.setContentsMargins(0, 0, 0, 0)
|
|
516
|
+
left_outer.setSpacing(8)
|
|
517
|
+
|
|
518
|
+
# Header (target view label)
|
|
519
|
+
try:
|
|
520
|
+
disp = getattr(self.document, "display_name", lambda: "Image")()
|
|
521
|
+
except Exception:
|
|
522
|
+
disp = "Image"
|
|
523
|
+
self.lbl_target = QLabel(f"Target View: <b>{disp}</b>")
|
|
524
|
+
left_outer.addWidget(self.lbl_target)
|
|
525
|
+
|
|
526
|
+
# Small/fast preview toggle
|
|
527
|
+
self.cb_small_preview = QCheckBox("Small-sized Preview (fast)")
|
|
528
|
+
self.cb_small_preview.setChecked(True)
|
|
529
|
+
self.cb_small_preview.toggled.connect(self._recompute_mask_and_preview)
|
|
530
|
+
left_outer.addWidget(self.cb_small_preview)
|
|
531
|
+
|
|
532
|
+
# ---------- SCROLLABLE CONTROLS (placed inside a QScrollArea) ----------
|
|
533
|
+
controls_container = QWidget()
|
|
534
|
+
left = QVBoxLayout(controls_container)
|
|
535
|
+
left.setContentsMargins(0, 0, 0, 0)
|
|
536
|
+
left.setSpacing(8)
|
|
537
|
+
|
|
538
|
+
# ===== Mask group
|
|
539
|
+
gb_mask = QGroupBox(self.tr("Mask"))
|
|
540
|
+
gl = QGridLayout(gb_mask)
|
|
541
|
+
gl.setContentsMargins(8, 8, 8, 8)
|
|
542
|
+
gl.setHorizontalSpacing(10)
|
|
543
|
+
gl.setVerticalSpacing(8)
|
|
544
|
+
|
|
545
|
+
# Row 0: Preset
|
|
546
|
+
gl.addWidget(QLabel("Preset:"), 0, 0)
|
|
547
|
+
self.dd_preset = QComboBox()
|
|
548
|
+
self.dd_preset.addItems(["Custom"] + list(_PRESETS.keys()))
|
|
549
|
+
self.dd_preset.currentTextChanged.connect(self._on_preset_change)
|
|
550
|
+
gl.addWidget(self.dd_preset, 0, 1, 1, 4)
|
|
551
|
+
|
|
552
|
+
# Hue wheel
|
|
553
|
+
self.hue_wheel = HueWheel(start_deg=65, end_deg=158)
|
|
554
|
+
self.hue_wheel.setMinimumSize(130, 130)
|
|
555
|
+
self.hue_wheel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
|
556
|
+
gl.addWidget(self.hue_wheel, 1, 0, 7, 2)
|
|
557
|
+
|
|
558
|
+
# Helper: integer slider + spin (0..360)
|
|
559
|
+
def _deg_pair(grid: QGridLayout, label: str, row: int):
|
|
560
|
+
grid.addWidget(QLabel(label), row, 2)
|
|
561
|
+
sld = QSlider(Qt.Orientation.Horizontal)
|
|
562
|
+
sld.setRange(0, 360); sld.setSingleStep(1); sld.setPageStep(10)
|
|
563
|
+
spn = QSpinBox(); spn.setRange(0, 360)
|
|
564
|
+
sld.valueChanged.connect(spn.setValue)
|
|
565
|
+
spn.valueChanged.connect(sld.setValue)
|
|
566
|
+
grid.addWidget(sld, row, 3, 1, 3)
|
|
567
|
+
grid.addWidget(spn, row, 6, 1, 1)
|
|
568
|
+
return sld, spn
|
|
569
|
+
|
|
570
|
+
# Rows 1–2: Hue Start/End
|
|
571
|
+
self.sl_h1, self.sp_h1 = _deg_pair(gl, "Hue start (°):", 1)
|
|
572
|
+
self.sl_h2, self.sp_h2 = _deg_pair(gl, "Hue end (°):", 2)
|
|
573
|
+
self.sp_h1.setValue(65); self.sp_h2.setValue(158)
|
|
574
|
+
|
|
575
|
+
# Row 3: chroma + lightness
|
|
576
|
+
gl.addWidget(QLabel("Min chroma:"), 3, 2)
|
|
577
|
+
self.ds_minC = QDoubleSpinBox(); self.ds_minC.setRange(0,1); self.ds_minC.setSingleStep(0.05); self.ds_minC.setValue(0.0)
|
|
578
|
+
self.ds_minC.valueChanged.connect(self._recompute_mask_and_preview)
|
|
579
|
+
gl.addWidget(self.ds_minC, 3, 3)
|
|
580
|
+
|
|
581
|
+
gl.addWidget(QLabel("Lightness min/max:"), 3, 4)
|
|
582
|
+
self.ds_minL = QDoubleSpinBox(); self.ds_minL.setRange(0,1); self.ds_minL.setSingleStep(0.05); self.ds_minL.setValue(0.0)
|
|
583
|
+
self.ds_maxL = QDoubleSpinBox(); self.ds_maxL.setRange(0,1); self.ds_maxL.setSingleStep(0.05); self.ds_maxL.setValue(1.0)
|
|
584
|
+
self.ds_minL.valueChanged.connect(self._recompute_mask_and_preview)
|
|
585
|
+
self.ds_maxL.valueChanged.connect(self._recompute_mask_and_preview)
|
|
586
|
+
gl.addWidget(self.ds_minL, 3, 5)
|
|
587
|
+
gl.addWidget(QLabel("to"), 3, 6)
|
|
588
|
+
gl.addWidget(self.ds_maxL, 3, 7)
|
|
589
|
+
|
|
590
|
+
# Row 4: smoothness + invert
|
|
591
|
+
gl.addWidget(QLabel("Smoothness (deg):"), 4, 2)
|
|
592
|
+
self.ds_smooth = QDoubleSpinBox(); self.ds_smooth.setRange(0,60); self.ds_smooth.setSingleStep(1.0); self.ds_smooth.setValue(10.0)
|
|
593
|
+
self.ds_smooth.valueChanged.connect(self._recompute_mask_and_preview)
|
|
594
|
+
gl.addWidget(self.ds_smooth, 4, 3)
|
|
595
|
+
|
|
596
|
+
self.cb_invert = QCheckBox("Invert hue range")
|
|
597
|
+
self.cb_invert.setChecked(False)
|
|
598
|
+
self.cb_invert.toggled.connect(self._recompute_mask_and_preview)
|
|
599
|
+
gl.addWidget(self.cb_invert, 4, 4, 1, 3)
|
|
600
|
+
|
|
601
|
+
# Row 5: shadows/highlights + intensity
|
|
602
|
+
gl.addWidget(QLabel("Shadows:"), 5, 2)
|
|
603
|
+
self.ds_sh = QDoubleSpinBox(); self.ds_sh.setRange(0,1); self.ds_sh.setSingleStep(0.05); self.ds_sh.setValue(0.0)
|
|
604
|
+
self.ds_sh.valueChanged.connect(self._recompute_mask_and_preview)
|
|
605
|
+
gl.addWidget(self.ds_sh, 5, 3)
|
|
606
|
+
|
|
607
|
+
gl.addWidget(QLabel("Highlights:"), 5, 4)
|
|
608
|
+
self.ds_hi = QDoubleSpinBox(); self.ds_hi.setRange(0,1); self.ds_hi.setSingleStep(0.05); self.ds_hi.setValue(1.0)
|
|
609
|
+
self.ds_hi.valueChanged.connect(self._recompute_mask_and_preview)
|
|
610
|
+
gl.addWidget(self.ds_hi, 5, 5)
|
|
611
|
+
|
|
612
|
+
self.ds_bal = QDoubleSpinBox(); self.ds_bal.setRange(0,1); self.ds_bal.setSingleStep(0.05); self.ds_bal.setValue(0.5)
|
|
613
|
+
self.ds_bal.valueChanged.connect(self._recompute_mask_and_preview)
|
|
614
|
+
self.ds_bal.setVisible(False) # used in math, hidden in UI
|
|
615
|
+
|
|
616
|
+
gl.addWidget(QLabel("Intensity:"), 5, 6)
|
|
617
|
+
self.ds_int = QDoubleSpinBox(); self.ds_int.setRange(0, 2.0); self.ds_int.setSingleStep(0.05); self.ds_int.setValue(1.0)
|
|
618
|
+
self.ds_int.valueChanged.connect(self._recompute_mask_and_preview)
|
|
619
|
+
gl.addWidget(self.ds_int, 5, 7)
|
|
620
|
+
|
|
621
|
+
# Row 6: blur + overlay
|
|
622
|
+
gl.addWidget(QLabel("Edge blur (px):"), 6, 2)
|
|
623
|
+
self.sb_blur = QSpinBox(); self.sb_blur.setRange(0, 150); self.sb_blur.setValue(0)
|
|
624
|
+
self.sb_blur.valueChanged.connect(self._recompute_mask_and_preview)
|
|
625
|
+
gl.addWidget(self.sb_blur, 6, 3)
|
|
626
|
+
|
|
627
|
+
self.cb_show_mask = QCheckBox("Show mask overlay")
|
|
628
|
+
self.cb_show_mask.setChecked(False)
|
|
629
|
+
self.cb_show_mask.toggled.connect(self._update_preview_pixmap)
|
|
630
|
+
gl.addWidget(self.cb_show_mask, 6, 4, 1, 2)
|
|
631
|
+
|
|
632
|
+
# Row 7: imported mask
|
|
633
|
+
self.cb_use_imported = QCheckBox("Use imported mask")
|
|
634
|
+
self.cb_use_imported.setChecked(False)
|
|
635
|
+
self.cb_use_imported.toggled.connect(self._on_use_imported_mask_toggled)
|
|
636
|
+
gl.addWidget(self.cb_use_imported, 7, 2, 1, 2)
|
|
637
|
+
|
|
638
|
+
self.btn_import_mask = QPushButton("Pick mask from view…")
|
|
639
|
+
self.btn_import_mask.clicked.connect(self._import_mask_from_view)
|
|
640
|
+
gl.addWidget(self.btn_import_mask, 7, 4, 1, 2)
|
|
641
|
+
|
|
642
|
+
self.lbl_imported_mask = QLabel("No imported mask")
|
|
643
|
+
gl.addWidget(self.lbl_imported_mask, 7, 6, 1, 2)
|
|
644
|
+
|
|
645
|
+
# Column sizing
|
|
646
|
+
gl.setColumnStretch(0, 0)
|
|
647
|
+
gl.setColumnStretch(1, 0)
|
|
648
|
+
for c in (2,3,4,5,6,7):
|
|
649
|
+
gl.setColumnStretch(c, 1)
|
|
650
|
+
|
|
651
|
+
left.addWidget(gb_mask)
|
|
652
|
+
|
|
653
|
+
# ===== Adjustments
|
|
654
|
+
# CMY
|
|
655
|
+
gb_cmy = QGroupBox(self.tr("Complementary colors (CMY)"))
|
|
656
|
+
glc = QGridLayout(gb_cmy)
|
|
657
|
+
self.sl_c, self.ds_c = self._slider_pair(glc, "Cyan:", 0)
|
|
658
|
+
self.sl_m, self.ds_m = self._slider_pair(glc, "Magenta:", 1)
|
|
659
|
+
self.sl_y, self.ds_y = self._slider_pair(glc, "Yellow:", 2)
|
|
660
|
+
left.addWidget(gb_cmy)
|
|
661
|
+
|
|
662
|
+
# RGB
|
|
663
|
+
gb_rgb = QGroupBox(self.tr("RGB Colors"))
|
|
664
|
+
glr = QGridLayout(gb_rgb)
|
|
665
|
+
self.sl_r, self.ds_r = self._slider_pair(glr, "Red:", 0)
|
|
666
|
+
self.sl_g, self.ds_g = self._slider_pair(glr, "Green:", 1)
|
|
667
|
+
self.sl_b, self.ds_b = self._slider_pair(glr, "Blue:", 2)
|
|
668
|
+
left.addWidget(gb_rgb)
|
|
669
|
+
|
|
670
|
+
# LSC
|
|
671
|
+
gb_lsc = QGroupBox(self.tr("Luminance, Chroma/Saturation, Contrast"))
|
|
672
|
+
gll = QGridLayout(gb_lsc)
|
|
673
|
+
self.sl_l, self.ds_l = self._slider_pair(gll, "Luminance:", 0)
|
|
674
|
+
self.sl_chroma, self.ds_chroma = self._slider_pair(gll, "Chroma (L-preserving):", 1)
|
|
675
|
+
self.sl_s, self.ds_s = self._slider_pair(gll, "Saturation (HSV S):", 2)
|
|
676
|
+
self.sl_c2, self.ds_c2 = self._slider_pair(gll, "Contrast:", 3)
|
|
677
|
+
gll.addWidget(QLabel("Color boost mode:"), 4, 0)
|
|
678
|
+
self.dd_color_mode = QComboBox()
|
|
679
|
+
self.dd_color_mode.addItems(["Chroma (L-preserving)", "Saturation (HSV S)"])
|
|
680
|
+
self.dd_color_mode.setCurrentIndex(0)
|
|
681
|
+
self.dd_color_mode.currentIndexChanged.connect(self._update_color_mode_enabled)
|
|
682
|
+
gll.addWidget(self.dd_color_mode, 4, 1, 1, 2)
|
|
683
|
+
left.addWidget(gb_lsc)
|
|
684
|
+
|
|
685
|
+
# Wrap controls in a scroller (horizontal scroll allowed if needed)
|
|
686
|
+
left_scroll = QScrollArea()
|
|
687
|
+
left_scroll.setWidget(controls_container)
|
|
688
|
+
left_scroll.setWidgetResizable(False)
|
|
689
|
+
left_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
690
|
+
left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
|
691
|
+
left_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
|
692
|
+
left_outer.addWidget(left_scroll, 1)
|
|
693
|
+
|
|
694
|
+
# Live toggle (non-scroll)
|
|
695
|
+
self.cb_live = QCheckBox("Preview changed image")
|
|
696
|
+
self.cb_live.setChecked(True)
|
|
697
|
+
self.cb_live.toggled.connect(self._update_preview_pixmap)
|
|
698
|
+
left_outer.addWidget(self.cb_live)
|
|
699
|
+
|
|
700
|
+
# Buttons row (non-scroll)
|
|
701
|
+
row = QHBoxLayout()
|
|
702
|
+
self.btn_apply = QPushButton("Apply")
|
|
703
|
+
self.btn_apply.clicked.connect(self._apply_to_document)
|
|
704
|
+
self.btn_push = QPushButton("Apply as New Document")
|
|
705
|
+
self.btn_push.clicked.connect(self._apply_as_new_doc)
|
|
706
|
+
self.btn_export_mask = QPushButton("Export Mask")
|
|
707
|
+
self.btn_export_mask.clicked.connect(self._export_mask_doc)
|
|
708
|
+
self.btn_reset = QPushButton("↺ Reset")
|
|
709
|
+
self.btn_reset.clicked.connect(self._reset_controls)
|
|
710
|
+
row.addWidget(self.btn_apply)
|
|
711
|
+
row.addWidget(self.btn_push)
|
|
712
|
+
row.addWidget(self.btn_export_mask)
|
|
713
|
+
row.addWidget(self.btn_reset)
|
|
714
|
+
left_outer.addLayout(row)
|
|
715
|
+
|
|
716
|
+
# ======================================================================
|
|
717
|
+
# RIGHT PANE (zoom toolbar + preview scroller + picked hue readout)
|
|
718
|
+
# ======================================================================
|
|
719
|
+
right_widget = QWidget()
|
|
720
|
+
right_widget.setMinimumWidth(420)
|
|
721
|
+
right_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
722
|
+
splitter.addWidget(right_widget)
|
|
723
|
+
|
|
724
|
+
right = QVBoxLayout(right_widget)
|
|
725
|
+
right.setContentsMargins(0, 0, 0, 0)
|
|
726
|
+
right.setSpacing(8)
|
|
727
|
+
|
|
728
|
+
# Zoom toolbar (themed)
|
|
729
|
+
zoom_row = QHBoxLayout()
|
|
730
|
+
|
|
731
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
|
|
732
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
733
|
+
self.btn_zoom_1 = themed_toolbtn("zoom-original", "1:1")
|
|
734
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit")
|
|
735
|
+
|
|
736
|
+
zoom_row.addWidget(self.btn_zoom_out)
|
|
737
|
+
zoom_row.addWidget(self.btn_zoom_in)
|
|
738
|
+
zoom_row.addWidget(self.btn_zoom_1)
|
|
739
|
+
zoom_row.addWidget(self.btn_fit)
|
|
740
|
+
zoom_row.addStretch(1)
|
|
741
|
+
right.addLayout(zoom_row)
|
|
742
|
+
|
|
743
|
+
self.lbl_help = QLabel(
|
|
744
|
+
"🖱️ <b>Click</b>: pick hue • "
|
|
745
|
+
"<b>Ctrl + Click & Drag</b>: pan • "
|
|
746
|
+
"<b>Ctrl + Wheel</b>: zoom"
|
|
747
|
+
)
|
|
748
|
+
self.lbl_help.setWordWrap(True)
|
|
749
|
+
self.lbl_help.setTextFormat(Qt.TextFormat.RichText)
|
|
750
|
+
self.lbl_help.setStyleSheet("color: #888; font-size: 11px;")
|
|
751
|
+
right.addWidget(self.lbl_help)
|
|
752
|
+
|
|
753
|
+
# Preview scroller
|
|
754
|
+
self.scroll = QScrollArea()
|
|
755
|
+
self.scroll.setWidgetResizable(False)
|
|
756
|
+
self.lbl_preview = QLabel()
|
|
757
|
+
self.lbl_preview.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|
758
|
+
self.lbl_preview.setMinimumSize(10, 10)
|
|
759
|
+
self.scroll.setWidget(self.lbl_preview)
|
|
760
|
+
right.addWidget(self.scroll, 1)
|
|
761
|
+
|
|
762
|
+
vp = self.scroll.viewport()
|
|
763
|
+
vp.setMouseTracking(True)
|
|
764
|
+
vp.installEventFilter(self)
|
|
765
|
+
|
|
766
|
+
self.lbl_preview.setToolTip(
|
|
767
|
+
"Click to sample hue.\n"
|
|
768
|
+
"Ctrl + Click & Drag to pan.\n"
|
|
769
|
+
"Ctrl + Mouse Wheel to zoom."
|
|
770
|
+
)
|
|
771
|
+
self.btn_zoom_in.setToolTip("Zoom in (centers view)")
|
|
772
|
+
self.btn_zoom_out.setToolTip("Zoom out (centers view)")
|
|
773
|
+
self.btn_zoom_1.setToolTip("Reset zoom to 1:1")
|
|
774
|
+
|
|
775
|
+
# Hue readout
|
|
776
|
+
self.lbl_hue_readout = QLabel("Picked hue: —")
|
|
777
|
+
right.addWidget(self.lbl_hue_readout)
|
|
778
|
+
|
|
779
|
+
# Splitter stretch: make preview greedy
|
|
780
|
+
splitter.setStretchFactor(0, 0) # left
|
|
781
|
+
splitter.setStretchFactor(1, 1) # right
|
|
782
|
+
splitter.setSizes([420, 900])
|
|
783
|
+
|
|
784
|
+
# Clamp dialog height and add size grip
|
|
785
|
+
self.setSizeGripEnabled(True)
|
|
786
|
+
try:
|
|
787
|
+
g = QGuiApplication.primaryScreen().availableGeometry()
|
|
788
|
+
max_h = int(g.height() * 0.9)
|
|
789
|
+
self.resize(1080, min(680, max_h))
|
|
790
|
+
self.setMaximumHeight(max_h)
|
|
791
|
+
except Exception:
|
|
792
|
+
self.resize(1080, 680)
|
|
793
|
+
|
|
794
|
+
# ---- Wiring that depends on built widgets ----------------------------
|
|
795
|
+
self._update_color_mode_enabled()
|
|
796
|
+
for w in (self.ds_c, self.ds_m, self.ds_y, self.ds_r, self.ds_g, self.ds_b, self.ds_l, self.ds_s, self.ds_c2, self.ds_int):
|
|
797
|
+
w.valueChanged.connect(self._schedule_adjustments)
|
|
798
|
+
|
|
799
|
+
def _sliders_to_wheel(_=None):
|
|
800
|
+
if not self._setting_preset and self.dd_preset.currentText() != "Custom":
|
|
801
|
+
self.dd_preset.setCurrentText("Custom")
|
|
802
|
+
s = int(self.sp_h1.value()); e = int(self.sp_h2.value())
|
|
803
|
+
self.hue_wheel.setRange(s, e, notify=False)
|
|
804
|
+
self._schedule_mask()
|
|
805
|
+
|
|
806
|
+
self.sp_h1.valueChanged.connect(_sliders_to_wheel)
|
|
807
|
+
self.sp_h2.valueChanged.connect(_sliders_to_wheel)
|
|
808
|
+
self.sl_h1.valueChanged.connect(_sliders_to_wheel)
|
|
809
|
+
self.sl_h2.valueChanged.connect(_sliders_to_wheel)
|
|
810
|
+
|
|
811
|
+
# Zoom behavior
|
|
812
|
+
self._zoom = 1.0
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
self.btn_zoom_in.clicked.connect(lambda: self._apply_zoom(self._zoom * 1.25, None))
|
|
816
|
+
self.btn_zoom_out.clicked.connect(lambda: self._apply_zoom(self._zoom / 1.25, None))
|
|
817
|
+
self.btn_zoom_1.clicked.connect(lambda: self._apply_zoom(1.0, None))
|
|
818
|
+
|
|
819
|
+
# Ctrl+wheel: zoom around mouse position (label coords)
|
|
820
|
+
def _wheel_event(ev):
|
|
821
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
822
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
|
|
823
|
+
self._apply_zoom(self._zoom * factor, anchor_label_pos=ev.position())
|
|
824
|
+
ev.accept()
|
|
825
|
+
return
|
|
826
|
+
QLabel.wheelEvent(self.lbl_preview, ev)
|
|
827
|
+
|
|
828
|
+
self.lbl_preview.wheelEvent = _wheel_event
|
|
829
|
+
|
|
830
|
+
# Preview interactions
|
|
831
|
+
self.lbl_preview.setMouseTracking(True)
|
|
832
|
+
self.lbl_preview.installEventFilter(self)
|
|
833
|
+
|
|
834
|
+
# First paint
|
|
835
|
+
self._update_preview_pixmap()
|
|
836
|
+
|
|
837
|
+
# --- Zoom helpers ----------------------------------------------------
|
|
838
|
+
def _current_scroll(self):
|
|
839
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
840
|
+
vbar = self.scroll.verticalScrollBar()
|
|
841
|
+
return hbar.value(), vbar.value(), hbar.maximum(), vbar.maximum()
|
|
842
|
+
|
|
843
|
+
def _set_scroll(self, x, y):
|
|
844
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
845
|
+
vbar = self.scroll.verticalScrollBar()
|
|
846
|
+
hbar.setValue(int(max(0, min(x, hbar.maximum()))))
|
|
847
|
+
vbar.setValue(int(max(0, min(y, vbar.maximum()))))
|
|
848
|
+
|
|
849
|
+
def _apply_zoom(self, new_zoom: float, anchor_label_pos=None):
|
|
850
|
+
"""
|
|
851
|
+
new_zoom: float
|
|
852
|
+
anchor_label_pos: QPointF in *label (content)* coords to keep fixed on screen.
|
|
853
|
+
If None, use viewport center.
|
|
854
|
+
"""
|
|
855
|
+
old_zoom = getattr(self, "_zoom", 1.0)
|
|
856
|
+
new_zoom = max(0.05, min(16.0, float(new_zoom)))
|
|
857
|
+
if abs(new_zoom - old_zoom) < 1e-6:
|
|
858
|
+
return
|
|
859
|
+
|
|
860
|
+
# Figure out the anchor (content coords)
|
|
861
|
+
if anchor_label_pos is None:
|
|
862
|
+
# viewport center → content coords
|
|
863
|
+
sx, sy, _, _ = self._current_scroll()
|
|
864
|
+
vp = self.scroll.viewport().rect()
|
|
865
|
+
cx = (sx + vp.width() / 2.0) / max(old_zoom, 1e-9)
|
|
866
|
+
cy = (sy + vp.height() / 2.0) / max(old_zoom, 1e-9)
|
|
867
|
+
else:
|
|
868
|
+
cx = float(anchor_label_pos.x()) / max(1.0, 1.0) # label coords already in content space
|
|
869
|
+
cy = float(anchor_label_pos.y()) / max(1.0, 1.0)
|
|
870
|
+
|
|
871
|
+
# Where is that content point on the viewport *before* zoom?
|
|
872
|
+
sx, sy, _, _ = self._current_scroll()
|
|
873
|
+
vp = self.scroll.viewport().rect()
|
|
874
|
+
pvx = cx * old_zoom - sx # pixel pos in viewport
|
|
875
|
+
pvy = cy * old_zoom - sy
|
|
876
|
+
|
|
877
|
+
# Apply zoom and repaint
|
|
878
|
+
self._zoom = new_zoom
|
|
879
|
+
self._update_preview_pixmap()
|
|
880
|
+
|
|
881
|
+
# Set scroll so that the same content point stays at the same viewport pixel
|
|
882
|
+
nx = cx * new_zoom - pvx
|
|
883
|
+
ny = cy * new_zoom - pvy
|
|
884
|
+
self._set_scroll(nx, ny)
|
|
885
|
+
|
|
886
|
+
def _fit_to_preview(self):
|
|
887
|
+
if not hasattr(self, "_base_pm") or self._base_pm is None:
|
|
888
|
+
return
|
|
889
|
+
vp = self.scroll.viewport().size()
|
|
890
|
+
pm = self._base_pm.size()
|
|
891
|
+
if pm.width() <= 0 or pm.height() <= 0:
|
|
892
|
+
return
|
|
893
|
+
k = min(vp.width() / pm.width(), vp.height() / pm.height())
|
|
894
|
+
self._apply_zoom(k, anchor_label_pos=None)
|
|
895
|
+
|
|
896
|
+
# --- Pan helpers -----------------------------------------------------
|
|
897
|
+
def _begin_pan(self, pos_label):
|
|
898
|
+
self._panning = True
|
|
899
|
+
self._pan_start_pos = pos_label
|
|
900
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
901
|
+
vbar = self.scroll.verticalScrollBar()
|
|
902
|
+
self._pan_start_scroll = (hbar.value(), vbar.value())
|
|
903
|
+
try:
|
|
904
|
+
self.lbl_preview.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
def _update_pan(self, pos_label):
|
|
909
|
+
if not self._panning or self._pan_start_pos is None:
|
|
910
|
+
return
|
|
911
|
+
dx = pos_label.x() - self._pan_start_pos.x() # label pixels
|
|
912
|
+
dy = pos_label.y() - self._pan_start_pos.y()
|
|
913
|
+
sx0, sy0 = self._pan_start_scroll
|
|
914
|
+
# invert to move content with the mouse
|
|
915
|
+
self._set_scroll(sx0 - dx, sy0 - dy)
|
|
916
|
+
|
|
917
|
+
def _end_pan(self):
|
|
918
|
+
self._panning = False
|
|
919
|
+
self._pan_start_pos = None
|
|
920
|
+
try:
|
|
921
|
+
self.lbl_preview.setCursor(Qt.CursorShape.ArrowCursor)
|
|
922
|
+
except Exception:
|
|
923
|
+
pass
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _update_color_mode_enabled(self):
|
|
927
|
+
use_chroma = (self.dd_color_mode.currentIndex() == 0)
|
|
928
|
+
# enable Chroma controls when chroma mode; disable Sat controls, and vice versa
|
|
929
|
+
self.ds_chroma.setEnabled(use_chroma); self.sl_chroma.setEnabled(use_chroma)
|
|
930
|
+
self.ds_s.setEnabled(not use_chroma); self.sl_s.setEnabled(not use_chroma)
|
|
931
|
+
# refresh preview
|
|
932
|
+
self._schedule_adjustments()
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _set_pair(self, sld: QSlider, box: QDoubleSpinBox, value: float):
|
|
936
|
+
# block both sides to avoid ping-pong and callbacks
|
|
937
|
+
sld.blockSignals(True); box.blockSignals(True)
|
|
938
|
+
sld.setValue(int(round(value * 100))) # because slider units are *100
|
|
939
|
+
box.setValue(float(value))
|
|
940
|
+
sld.blockSignals(False); box.blockSignals(False)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _reset_controls(self):
|
|
944
|
+
"""Reset all UI controls to defaults and rebuild mask/preview on current self.img."""
|
|
945
|
+
# pause timers while resetting
|
|
946
|
+
self._mask_timer.stop()
|
|
947
|
+
self._adj_timer.stop()
|
|
948
|
+
|
|
949
|
+
# --- Preset: make 'Red' the default and let _on_preset_change drive the wheel/sliders ---
|
|
950
|
+
# IMPORTANT: do NOT overwrite with 'Custom' afterwards.
|
|
951
|
+
self._setting_preset = True
|
|
952
|
+
try:
|
|
953
|
+
# This emits currentTextChanged -> _on_preset_change(), which:
|
|
954
|
+
# - sets the hue_wheel to the preset range (notify=False)
|
|
955
|
+
# - sets sp_h1/sp_h2 to the preset lo/hi
|
|
956
|
+
# - calls _recompute_mask_and_preview()
|
|
957
|
+
self.dd_preset.setCurrentText("Red")
|
|
958
|
+
finally:
|
|
959
|
+
self._setting_preset = False
|
|
960
|
+
|
|
961
|
+
# --- Mask gating defaults (won't change the preset/wheel) ---
|
|
962
|
+
def setv(w, val):
|
|
963
|
+
w.blockSignals(True)
|
|
964
|
+
if isinstance(w, (QDoubleSpinBox, QSpinBox)):
|
|
965
|
+
w.setValue(val)
|
|
966
|
+
elif isinstance(w, QCheckBox):
|
|
967
|
+
w.setChecked(bool(val))
|
|
968
|
+
elif isinstance(w, QComboBox):
|
|
969
|
+
idx = w.findText(val)
|
|
970
|
+
if idx >= 0:
|
|
971
|
+
w.setCurrentIndex(idx)
|
|
972
|
+
elif isinstance(w, QSlider):
|
|
973
|
+
w.setValue(int(val))
|
|
974
|
+
w.blockSignals(False)
|
|
975
|
+
|
|
976
|
+
setv(self.ds_minC, 0.0)
|
|
977
|
+
setv(self.ds_minL, 0.0)
|
|
978
|
+
setv(self.ds_maxL, 1.0)
|
|
979
|
+
setv(self.ds_smooth, 10.0)
|
|
980
|
+
setv(self.cb_invert, False)
|
|
981
|
+
|
|
982
|
+
# Shadows/Highlights/Balance
|
|
983
|
+
setv(self.ds_sh, 0.0)
|
|
984
|
+
setv(self.ds_hi, 1.0)
|
|
985
|
+
setv(self.ds_bal, 0.5)
|
|
986
|
+
|
|
987
|
+
# Blur / overlays / preview
|
|
988
|
+
setv(self.sb_blur, 0)
|
|
989
|
+
setv(self.cb_show_mask, False)
|
|
990
|
+
# keep user’s small/large preview choice & zoom as-is
|
|
991
|
+
|
|
992
|
+
# CMY/RGB/LSC back to 0, intensity to 1.0
|
|
993
|
+
self._set_pair(self.sl_c, self.ds_c, 0.0)
|
|
994
|
+
self._set_pair(self.sl_m, self.ds_m, 0.0)
|
|
995
|
+
self._set_pair(self.sl_y, self.ds_y, 0.0)
|
|
996
|
+
self._set_pair(self.sl_r, self.ds_r, 0.0)
|
|
997
|
+
self._set_pair(self.sl_g, self.ds_g, 0.0)
|
|
998
|
+
self._set_pair(self.sl_b, self.ds_b, 0.0)
|
|
999
|
+
self._set_pair(self.sl_l, self.ds_l, 0.0)
|
|
1000
|
+
self._set_pair(self.sl_s, self.ds_s, 0.0)
|
|
1001
|
+
self._set_pair(self.sl_c2, self.ds_c2, 0.0)
|
|
1002
|
+
|
|
1003
|
+
self._set_pair(self.sl_chroma, self.ds_chroma, 0.0)
|
|
1004
|
+
# default to Chroma mode
|
|
1005
|
+
self.dd_color_mode.blockSignals(True)
|
|
1006
|
+
self.dd_color_mode.setCurrentIndex(0)
|
|
1007
|
+
self.dd_color_mode.blockSignals(False)
|
|
1008
|
+
self._update_color_mode_enabled()
|
|
1009
|
+
|
|
1010
|
+
self.ds_int.blockSignals(True)
|
|
1011
|
+
self.ds_int.setValue(1.0)
|
|
1012
|
+
self.ds_int.blockSignals(False)
|
|
1013
|
+
|
|
1014
|
+
# Clear any sampled hue marker on the wheel
|
|
1015
|
+
self.hue_wheel.setPickedHue(None)
|
|
1016
|
+
|
|
1017
|
+
# Rebuild preview (preset handler already recomputed the mask, but this is safe)
|
|
1018
|
+
self._recompute_mask_and_preview()
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _schedule_adjustments(self, delay_ms: int | None = None):
|
|
1022
|
+
if delay_ms is None:
|
|
1023
|
+
delay_ms = getattr(self, "_adj_delay_ms", 200)
|
|
1024
|
+
# if called very early, just no-op safely
|
|
1025
|
+
if not hasattr(self, "_adj_timer"):
|
|
1026
|
+
return
|
|
1027
|
+
self._adj_timer.stop()
|
|
1028
|
+
self._adj_timer.start(int(delay_ms))
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _schedule_mask(self, delay_ms: int | None = None):
|
|
1032
|
+
"""Debounce mask recomputation for hue changes."""
|
|
1033
|
+
if delay_ms is None:
|
|
1034
|
+
delay_ms = self._mask_delay_ms
|
|
1035
|
+
# restart the timer on every change
|
|
1036
|
+
self._mask_timer.stop()
|
|
1037
|
+
self._mask_timer.start(int(delay_ms))
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _sample_hue_deg_from_base(self, x: int, y: int) -> float | None:
|
|
1041
|
+
"""Return hue in degrees at (x,y) in _last_base (float RGB in [0,1])."""
|
|
1042
|
+
base = getattr(self, "_last_base", None)
|
|
1043
|
+
if base is None:
|
|
1044
|
+
return None
|
|
1045
|
+
h, w = base.shape[:2]
|
|
1046
|
+
if not (0 <= x < w and 0 <= y < h):
|
|
1047
|
+
return None
|
|
1048
|
+
pix = base[y:y+1, x:x+1, :] if base.ndim == 3 else np.repeat(base[y:y+1, x:x+1][...,None], 3, axis=2)
|
|
1049
|
+
hsv = _rgb_to_hsv01(pix) # 1x1x3, H in [0,1]
|
|
1050
|
+
return float(hsv[0,0,0] * 360.0)
|
|
1051
|
+
|
|
1052
|
+
def _map_label_point_to_image_xy(self, ev_pos):
|
|
1053
|
+
"""Map a click on the *label* to base image (x,y), accounting for zoom."""
|
|
1054
|
+
base = getattr(self, "_last_base", None)
|
|
1055
|
+
if base is None:
|
|
1056
|
+
return None
|
|
1057
|
+
bh, bw = base.shape[:2]
|
|
1058
|
+
# ev_pos is in the label's local coordinates
|
|
1059
|
+
x = int(round(ev_pos.x() / max(self._zoom, 1e-6)))
|
|
1060
|
+
y = int(round(ev_pos.y() / max(self._zoom, 1e-6)))
|
|
1061
|
+
if x < 0 or y < 0 or x >= bw or y >= bh:
|
|
1062
|
+
return None
|
|
1063
|
+
return (x, y)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def eventFilter(self, obj, ev):
|
|
1067
|
+
from PyQt6.QtCore import QEvent, Qt
|
|
1068
|
+
|
|
1069
|
+
# Helper: get event position in *viewport* coords regardless of target
|
|
1070
|
+
def _pos_in_viewport(o, e):
|
|
1071
|
+
if o is self.scroll.viewport():
|
|
1072
|
+
return e.position() # already viewport coords (QPointF)
|
|
1073
|
+
# map label-local → viewport
|
|
1074
|
+
return self.lbl_preview.mapTo(self.scroll.viewport(), e.position().toPoint())
|
|
1075
|
+
|
|
1076
|
+
# --- PANNING (Ctrl + LMB) on viewport *or* label ---
|
|
1077
|
+
if obj in (self.scroll.viewport(), self.lbl_preview):
|
|
1078
|
+
if ev.type() == QEvent.Type.MouseButtonPress:
|
|
1079
|
+
if (ev.button() == Qt.MouseButton.LeftButton and
|
|
1080
|
+
ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
|
1081
|
+
self._panning = True
|
|
1082
|
+
self._pan_start_pos_vp = _pos_in_viewport(obj, ev)
|
|
1083
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1084
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1085
|
+
self._pan_start_scroll = (hbar.value(), vbar.value())
|
|
1086
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
1087
|
+
return True
|
|
1088
|
+
|
|
1089
|
+
elif ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
1090
|
+
cur = _pos_in_viewport(obj, ev)
|
|
1091
|
+
dx = cur.x() - self._pan_start_pos_vp.x()
|
|
1092
|
+
dy = cur.y() - self._pan_start_pos_vp.y()
|
|
1093
|
+
if abs(dx) > self._pan_deadzone or abs(dy) > self._pan_deadzone:
|
|
1094
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1095
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1096
|
+
hbar.setValue(int(self._pan_start_scroll[0] - dx))
|
|
1097
|
+
vbar.setValue(int(self._pan_start_scroll[1] - dy))
|
|
1098
|
+
return True
|
|
1099
|
+
|
|
1100
|
+
elif ev.type() in (QEvent.Type.MouseButtonRelease, QEvent.Type.Leave):
|
|
1101
|
+
if self._panning:
|
|
1102
|
+
self._panning = False
|
|
1103
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
|
1104
|
+
return True
|
|
1105
|
+
|
|
1106
|
+
# --- Hue pick (plain click) on the label only ---
|
|
1107
|
+
if obj is self.lbl_preview and ev.type() == QEvent.Type.MouseButtonPress:
|
|
1108
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
1109
|
+
return True # Ctrl is for panning; let pan branch handle it
|
|
1110
|
+
pt = self._map_label_point_to_image_xy(ev.position())
|
|
1111
|
+
if pt is not None:
|
|
1112
|
+
x, y = pt
|
|
1113
|
+
hue = self._sample_hue_deg_from_base(x, y)
|
|
1114
|
+
if hue is not None:
|
|
1115
|
+
self.hue_wheel.setPickedHue(hue)
|
|
1116
|
+
self.lbl_hue_readout.setText(f"Picked hue: {hue:.1f}°")
|
|
1117
|
+
if ev.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
|
1118
|
+
half = 15
|
|
1119
|
+
self.hue_wheel.setRange(int((hue-half) % 360), int((hue+half) % 360))
|
|
1120
|
+
return True
|
|
1121
|
+
|
|
1122
|
+
return super().eventFilter(obj, ev)
|
|
1123
|
+
|
|
1124
|
+
def _slider_row(self, grid: QGridLayout, name: str, row: int) -> QDoubleSpinBox:
|
|
1125
|
+
grid.addWidget(QLabel(name), row, 0)
|
|
1126
|
+
s = QDoubleSpinBox()
|
|
1127
|
+
s.setRange(-1.0, 1.0); s.setSingleStep(0.05); s.setDecimals(2); s.setValue(0.0)
|
|
1128
|
+
s.valueChanged.connect(self._recompute_mask_and_preview)
|
|
1129
|
+
grid.addWidget(s, row, 1)
|
|
1130
|
+
return s
|
|
1131
|
+
|
|
1132
|
+
def _slider_pair(self, grid: QGridLayout, name: str, row: int, minv=-1.0, maxv=1.0, step=0.05):
|
|
1133
|
+
import math
|
|
1134
|
+
|
|
1135
|
+
def _to_slider(v: float) -> int:
|
|
1136
|
+
# Symmetric rounding away from zero at half-steps; no banker’s rounding.
|
|
1137
|
+
s = abs(v) * 100.0
|
|
1138
|
+
s = math.floor(s + 0.5)
|
|
1139
|
+
return int(-s if v < 0 else s)
|
|
1140
|
+
|
|
1141
|
+
def _to_spin(v_int: int) -> float:
|
|
1142
|
+
return float(v_int) / 100.0
|
|
1143
|
+
|
|
1144
|
+
grid.addWidget(QLabel(name), row, 0)
|
|
1145
|
+
|
|
1146
|
+
sld = QSlider(Qt.Orientation.Horizontal)
|
|
1147
|
+
sld.setRange(int(minv*100), int(maxv*100)) # e.g., -100..100
|
|
1148
|
+
sld.setSingleStep(int(step*100)) # e.g., 5
|
|
1149
|
+
sld.setPageStep(int(5*step*100)) # e.g., 25
|
|
1150
|
+
sld.setValue(0)
|
|
1151
|
+
|
|
1152
|
+
box = QDoubleSpinBox()
|
|
1153
|
+
box.setRange(minv, maxv)
|
|
1154
|
+
box.setSingleStep(step)
|
|
1155
|
+
box.setDecimals(2)
|
|
1156
|
+
box.setValue(0.0)
|
|
1157
|
+
box.setKeyboardTracking(False) # only fire on committed changes
|
|
1158
|
+
|
|
1159
|
+
# Two-way binding without ping-pong
|
|
1160
|
+
def _sld_to_box(v_int: int):
|
|
1161
|
+
box.blockSignals(True)
|
|
1162
|
+
box.setValue(_to_spin(v_int))
|
|
1163
|
+
box.blockSignals(False)
|
|
1164
|
+
|
|
1165
|
+
def _box_to_sld(v_float: float):
|
|
1166
|
+
sld.blockSignals(True)
|
|
1167
|
+
sld.setValue(_to_slider(v_float))
|
|
1168
|
+
sld.blockSignals(False)
|
|
1169
|
+
|
|
1170
|
+
sld.valueChanged.connect(_sld_to_box)
|
|
1171
|
+
box.valueChanged.connect(_box_to_sld)
|
|
1172
|
+
|
|
1173
|
+
# Debounced preview updates (adjustments don’t rebuild mask)
|
|
1174
|
+
sld.valueChanged.connect(self._schedule_adjustments)
|
|
1175
|
+
box.valueChanged.connect(self._schedule_adjustments)
|
|
1176
|
+
# Nice UX: force one final refresh on release
|
|
1177
|
+
sld.sliderReleased.connect(self._update_preview_pixmap)
|
|
1178
|
+
box.editingFinished.connect(self._update_preview_pixmap)
|
|
1179
|
+
|
|
1180
|
+
grid.addWidget(sld, row, 1)
|
|
1181
|
+
grid.addWidget(box, row, 2)
|
|
1182
|
+
return sld, box
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
# ------------- Logic -------------
|
|
1186
|
+
def _on_preset_change(self, txt: str):
|
|
1187
|
+
self._setting_preset = True
|
|
1188
|
+
try:
|
|
1189
|
+
if txt != "Custom":
|
|
1190
|
+
intervals = _PRESETS.get(txt, [])
|
|
1191
|
+
if intervals:
|
|
1192
|
+
lo, hi = (intervals[0][0], intervals[-1][1]) if len(intervals) > 1 else intervals[0]
|
|
1193
|
+
self.hue_wheel.setRange(int(lo), int(hi), notify=False) # update wheel silently
|
|
1194
|
+
self.hue_wheel.update() # ensure repaint
|
|
1195
|
+
self.sp_h1.blockSignals(True); self.sp_h2.blockSignals(True)
|
|
1196
|
+
self.sp_h1.setValue(int(lo)); self.sp_h2.setValue(int(hi))
|
|
1197
|
+
self.sp_h1.blockSignals(False); self.sp_h2.blockSignals(False)
|
|
1198
|
+
self._recompute_mask_and_preview()
|
|
1199
|
+
finally:
|
|
1200
|
+
self._setting_preset = False
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _downsample(self, img, max_dim=1024):
|
|
1204
|
+
h, w = img.shape[:2]
|
|
1205
|
+
s = max(h, w)
|
|
1206
|
+
if s <= max_dim: return img
|
|
1207
|
+
k = max_dim / float(s)
|
|
1208
|
+
if cv2 is None:
|
|
1209
|
+
return cv2.resize(img, (int(w*k), int(h*k))) if False else img[::int(1/k), ::int(1/k)]
|
|
1210
|
+
return cv2.resize(img, (int(w*k), int(h*k)), interpolation=cv2.INTER_AREA)
|
|
1211
|
+
|
|
1212
|
+
def _recompute_mask_and_preview(self):
|
|
1213
|
+
if self.img is None:
|
|
1214
|
+
return
|
|
1215
|
+
|
|
1216
|
+
base = self._downsample(self.img, 1200) if self.cb_small_preview.isChecked() else self.img
|
|
1217
|
+
self._last_base = base
|
|
1218
|
+
|
|
1219
|
+
# if user wants imported mask and we have one → use it
|
|
1220
|
+
if self._use_imported_mask and self._imported_mask_full is not None:
|
|
1221
|
+
imp = self._imported_mask_full
|
|
1222
|
+
bh, bw = base.shape[:2]
|
|
1223
|
+
mh, mw = imp.shape[:2]
|
|
1224
|
+
if (mh, mw) != (bh, bw):
|
|
1225
|
+
if cv2 is not None:
|
|
1226
|
+
mask = cv2.resize(imp, (bw, bh), interpolation=cv2.INTER_LINEAR)
|
|
1227
|
+
else:
|
|
1228
|
+
yy = (np.linspace(0, mh - 1, bh)).astype(int)
|
|
1229
|
+
xx = (np.linspace(0, mw - 1, bw)).astype(int)
|
|
1230
|
+
mask = imp[yy[:, None], xx[None, :]]
|
|
1231
|
+
else:
|
|
1232
|
+
mask = imp
|
|
1233
|
+
mask = np.clip(mask.astype(np.float32), 0.0, 1.0)
|
|
1234
|
+
else:
|
|
1235
|
+
# your original hue-based build
|
|
1236
|
+
preset = self.dd_preset.currentText()
|
|
1237
|
+
if preset == "Custom":
|
|
1238
|
+
ranges = [(float(self.sp_h1.value()), float(self.sp_h2.value()))]
|
|
1239
|
+
else:
|
|
1240
|
+
ranges = _PRESETS[preset]
|
|
1241
|
+
|
|
1242
|
+
mask = _hue_mask(
|
|
1243
|
+
base,
|
|
1244
|
+
ranges_deg=ranges,
|
|
1245
|
+
min_chroma=float(self.ds_minC.value()),
|
|
1246
|
+
min_light=float(self.ds_minL.value()),
|
|
1247
|
+
max_light=float(self.ds_maxL.value()),
|
|
1248
|
+
smooth_deg=float(self.ds_smooth.value()),
|
|
1249
|
+
invert_range=self.cb_invert.isChecked(),
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
mask = _weight_shadows_highlights(
|
|
1253
|
+
mask, base,
|
|
1254
|
+
shadows=float(self.ds_sh.value()),
|
|
1255
|
+
highlights=float(self.ds_hi.value()),
|
|
1256
|
+
balance=float(self.ds_bal.value()),
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
k = int(self.sb_blur.value())
|
|
1260
|
+
if k > 0 and cv2 is not None:
|
|
1261
|
+
mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
|
|
1262
|
+
|
|
1263
|
+
self._mask = np.clip(mask, 0.0, 1.0)
|
|
1264
|
+
self._update_preview_pixmap()
|
|
1265
|
+
|
|
1266
|
+
def _on_use_imported_mask_toggled(self, on: bool):
|
|
1267
|
+
self._use_imported_mask = bool(on)
|
|
1268
|
+
# if we don't have an imported mask yet, turn it off again
|
|
1269
|
+
if self._use_imported_mask and self._imported_mask_full is None:
|
|
1270
|
+
self._use_imported_mask = False
|
|
1271
|
+
self.cb_use_imported.setChecked(False)
|
|
1272
|
+
QMessageBox.information(self, "No imported mask", "Pick a mask view first.")
|
|
1273
|
+
return
|
|
1274
|
+
|
|
1275
|
+
# just rebuild preview with the external mask
|
|
1276
|
+
self._recompute_mask_and_preview()
|
|
1277
|
+
|
|
1278
|
+
def _import_mask_from_view(self):
|
|
1279
|
+
if self.docman is None:
|
|
1280
|
+
QMessageBox.information(self, "No document manager", "Cannot import without a document manager.")
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
# get ALL docs user currently has open (renamed, FITS layers, XISF layers, duplicates, etc.)
|
|
1284
|
+
docs = self.docman.all_documents() or []
|
|
1285
|
+
# only image docs
|
|
1286
|
+
img_docs = [d for d in docs if hasattr(d, "image") and d.image is not None]
|
|
1287
|
+
|
|
1288
|
+
if not img_docs:
|
|
1289
|
+
QMessageBox.information(self, "No views", "There are no image views to import a mask from.")
|
|
1290
|
+
return
|
|
1291
|
+
|
|
1292
|
+
# build names as the user sees them
|
|
1293
|
+
items = []
|
|
1294
|
+
for d in img_docs:
|
|
1295
|
+
try:
|
|
1296
|
+
nm = d.display_name()
|
|
1297
|
+
except Exception:
|
|
1298
|
+
nm = "Untitled"
|
|
1299
|
+
items.append(nm)
|
|
1300
|
+
|
|
1301
|
+
from PyQt6.QtWidgets import QInputDialog
|
|
1302
|
+
choice, ok = QInputDialog.getItem(
|
|
1303
|
+
self,
|
|
1304
|
+
"Pick mask view",
|
|
1305
|
+
"Open image views:",
|
|
1306
|
+
items,
|
|
1307
|
+
0,
|
|
1308
|
+
False
|
|
1309
|
+
)
|
|
1310
|
+
if not ok:
|
|
1311
|
+
return
|
|
1312
|
+
|
|
1313
|
+
# find selected document
|
|
1314
|
+
sel_doc = None
|
|
1315
|
+
for d, nm in zip(img_docs, items):
|
|
1316
|
+
if nm == choice:
|
|
1317
|
+
sel_doc = d
|
|
1318
|
+
break
|
|
1319
|
+
|
|
1320
|
+
if sel_doc is None or getattr(sel_doc, "image", None) is None:
|
|
1321
|
+
QMessageBox.warning(self, "Import failed", "Selected view has no image.")
|
|
1322
|
+
return
|
|
1323
|
+
|
|
1324
|
+
mask_img = np.clip(sel_doc.image.astype(np.float32), 0.0, 1.0)
|
|
1325
|
+
|
|
1326
|
+
# if it's RGB, take channel 0 — that’s how your exported mask would look (3 equal channels)
|
|
1327
|
+
if mask_img.ndim == 3:
|
|
1328
|
+
mask_img = mask_img[..., 0]
|
|
1329
|
+
|
|
1330
|
+
# resize to current image size if needed
|
|
1331
|
+
dst_h, dst_w = self.img.shape[:2]
|
|
1332
|
+
src_h, src_w = mask_img.shape[:2]
|
|
1333
|
+
if (src_h, src_w) != (dst_h, dst_w):
|
|
1334
|
+
if cv2 is not None:
|
|
1335
|
+
mask_full = cv2.resize(mask_img, (dst_w, dst_h), interpolation=cv2.INTER_LINEAR)
|
|
1336
|
+
else:
|
|
1337
|
+
yy = (np.linspace(0, src_h - 1, dst_h)).astype(int)
|
|
1338
|
+
xx = (np.linspace(0, src_w - 1, dst_w)).astype(int)
|
|
1339
|
+
mask_full = mask_img[yy[:, None], xx[None, :]]
|
|
1340
|
+
else:
|
|
1341
|
+
mask_full = mask_img
|
|
1342
|
+
|
|
1343
|
+
mask_full = np.clip(mask_full.astype(np.float32), 0.0, 1.0)
|
|
1344
|
+
|
|
1345
|
+
# store
|
|
1346
|
+
self._imported_mask_full = mask_full
|
|
1347
|
+
self._imported_mask_name = choice
|
|
1348
|
+
self.lbl_imported_mask.setText(f"Imported: {choice}")
|
|
1349
|
+
|
|
1350
|
+
# auto-enable
|
|
1351
|
+
self.cb_use_imported.setChecked(True)
|
|
1352
|
+
self._use_imported_mask = True
|
|
1353
|
+
|
|
1354
|
+
# refresh preview
|
|
1355
|
+
self._recompute_mask_and_preview()
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _overlay_mask(self, base: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
|
1359
|
+
base = _ensure_rgb01(base)
|
|
1360
|
+
# mask is HxW -> expand to HxWx1 for broadcasting
|
|
1361
|
+
alpha = np.clip(mask.astype(np.float32), 0.0, 1.0)[..., None] * 0.6
|
|
1362
|
+
# red overlay
|
|
1363
|
+
overlay = base.copy()
|
|
1364
|
+
overlay[..., 0] = np.clip(base[..., 0]*(1 - alpha[..., 0]) + 1.0*alpha[..., 0], 0.0, 1.0)
|
|
1365
|
+
overlay[..., 1] = np.clip(base[..., 1]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
|
|
1366
|
+
overlay[..., 2] = np.clip(base[..., 2]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
|
|
1367
|
+
return overlay
|
|
1368
|
+
|
|
1369
|
+
def _update_preview_pixmap(self):
|
|
1370
|
+
if not hasattr(self, "_last_base"):
|
|
1371
|
+
self._recompute_mask_and_preview(); return
|
|
1372
|
+
|
|
1373
|
+
base = self._last_base
|
|
1374
|
+
mask = getattr(self, "_mask", np.zeros(base.shape[:2], np.float32))
|
|
1375
|
+
|
|
1376
|
+
if self.cb_live.isChecked():
|
|
1377
|
+
out = _apply_selective_adjustments(
|
|
1378
|
+
base, mask,
|
|
1379
|
+
cyan=float(self.ds_c.value()),
|
|
1380
|
+
magenta=float(self.ds_m.value()),
|
|
1381
|
+
yellow=float(self.ds_y.value()),
|
|
1382
|
+
r=float(self.ds_r.value()),
|
|
1383
|
+
g=float(self.ds_g.value()),
|
|
1384
|
+
b=float(self.ds_b.value()),
|
|
1385
|
+
lum=float(self.ds_l.value()),
|
|
1386
|
+
chroma=float(self.ds_chroma.value()),
|
|
1387
|
+
sat=float(self.ds_s.value()),
|
|
1388
|
+
con=float(self.ds_c2.value()),
|
|
1389
|
+
intensity=float(self.ds_int.value()),
|
|
1390
|
+
use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
out = _ensure_rgb01(out)
|
|
1394
|
+
else:
|
|
1395
|
+
out = _ensure_rgb01(base)
|
|
1396
|
+
|
|
1397
|
+
if self.cb_show_mask.isChecked():
|
|
1398
|
+
# fade overlay by intensity too
|
|
1399
|
+
mask_vis = mask * float(self.ds_int.value())
|
|
1400
|
+
show = self._overlay_mask(out, mask_vis)
|
|
1401
|
+
else:
|
|
1402
|
+
show = out
|
|
1403
|
+
|
|
1404
|
+
pm = _to_pixmap(show)
|
|
1405
|
+
h, w = show.shape[:2]
|
|
1406
|
+
zw, zh = max(1, int(round(w * self._zoom))), max(1, int(round(h * self._zoom)))
|
|
1407
|
+
pm_scaled = pm.scaled(zw, zh, Qt.AspectRatioMode.IgnoreAspectRatio,
|
|
1408
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
1409
|
+
self.lbl_preview.setPixmap(pm_scaled)
|
|
1410
|
+
self.lbl_preview.resize(zw, zh)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def resizeEvent(self, ev):
|
|
1414
|
+
super().resizeEvent(ev)
|
|
1415
|
+
QTimer.singleShot(0, self._update_preview_pixmap)
|
|
1416
|
+
|
|
1417
|
+
# ------------- Apply -------------
|
|
1418
|
+
def _apply_fullres(self) -> np.ndarray:
|
|
1419
|
+
base = self.img
|
|
1420
|
+
|
|
1421
|
+
if self._use_imported_mask and self._imported_mask_full is not None:
|
|
1422
|
+
mask = np.clip(self._imported_mask_full.astype(np.float32), 0.0, 1.0)
|
|
1423
|
+
else:
|
|
1424
|
+
mask = self._build_mask(base)
|
|
1425
|
+
|
|
1426
|
+
out = _apply_selective_adjustments(
|
|
1427
|
+
base, mask,
|
|
1428
|
+
cyan=float(self.ds_c.value()),
|
|
1429
|
+
magenta=float(self.ds_m.value()),
|
|
1430
|
+
yellow=float(self.ds_y.value()),
|
|
1431
|
+
r=float(self.ds_r.value()),
|
|
1432
|
+
g=float(self.ds_g.value()),
|
|
1433
|
+
b=float(self.ds_b.value()),
|
|
1434
|
+
lum=float(self.ds_l.value()),
|
|
1435
|
+
chroma=float(self.ds_chroma.value()),
|
|
1436
|
+
sat=float(self.ds_s.value()),
|
|
1437
|
+
con=float(self.ds_c2.value()),
|
|
1438
|
+
intensity=float(self.ds_int.value()),
|
|
1439
|
+
use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
return out
|
|
1443
|
+
|
|
1444
|
+
def _export_mask_doc(self):
|
|
1445
|
+
if self.docman is None:
|
|
1446
|
+
QMessageBox.information(self, "No document manager", "Cannot export mask without a document manager.")
|
|
1447
|
+
return
|
|
1448
|
+
|
|
1449
|
+
base = self.img
|
|
1450
|
+
if base is None:
|
|
1451
|
+
QMessageBox.information(self, "No image", "Open an image first.")
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
mask = self._build_mask(base) # H x W, float32, 0..1
|
|
1455
|
+
mask_rgb = np.repeat(mask[..., None], 3, axis=2).astype(np.float32)
|
|
1456
|
+
|
|
1457
|
+
name = getattr(self.document, "display_name", lambda: "Image")()
|
|
1458
|
+
title = f"{name} [SelectiveColor MASK]"
|
|
1459
|
+
try:
|
|
1460
|
+
self.docman.open_array(mask_rgb, title=title)
|
|
1461
|
+
except Exception as e:
|
|
1462
|
+
QMessageBox.warning(self, "Export failed", str(e))
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _build_mask(self, base: np.ndarray) -> np.ndarray:
|
|
1466
|
+
"""
|
|
1467
|
+
Build the full-res mask using the *current UI settings*.
|
|
1468
|
+
This is exactly what your old _apply_fullres did, just pulled out.
|
|
1469
|
+
"""
|
|
1470
|
+
preset = self.dd_preset.currentText()
|
|
1471
|
+
ranges = (
|
|
1472
|
+
[(float(self.sp_h1.value()), float(self.sp_h2.value()))]
|
|
1473
|
+
if preset == "Custom"
|
|
1474
|
+
else _PRESETS[preset]
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
# 1) hue / chroma / light / smooth / invert
|
|
1478
|
+
mask = _hue_mask(
|
|
1479
|
+
base,
|
|
1480
|
+
ranges_deg=ranges,
|
|
1481
|
+
min_chroma=float(self.ds_minC.value()),
|
|
1482
|
+
min_light=float(self.ds_minL.value()),
|
|
1483
|
+
max_light=float(self.ds_maxL.value()),
|
|
1484
|
+
smooth_deg=float(self.ds_smooth.value()),
|
|
1485
|
+
invert_range=self.cb_invert.isChecked(),
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
# 2) shadows / highlights weighting
|
|
1489
|
+
mask = _weight_shadows_highlights(
|
|
1490
|
+
mask, base,
|
|
1491
|
+
shadows=float(self.ds_sh.value()),
|
|
1492
|
+
highlights=float(self.ds_hi.value()),
|
|
1493
|
+
balance=float(self.ds_bal.value()),
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
# 3) optional blur
|
|
1497
|
+
k = int(self.sb_blur.value())
|
|
1498
|
+
if k > 0 and cv2 is not None:
|
|
1499
|
+
mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
|
|
1500
|
+
|
|
1501
|
+
return np.clip(mask, 0.0, 1.0).astype(np.float32)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _apply_to_document(self):
|
|
1506
|
+
try:
|
|
1507
|
+
result = self._apply_fullres()
|
|
1508
|
+
except Exception as e:
|
|
1509
|
+
QMessageBox.warning(self, "Error", str(e)); return
|
|
1510
|
+
|
|
1511
|
+
# write back to the same document (preferred)
|
|
1512
|
+
try:
|
|
1513
|
+
if hasattr(self.document, "set_image"):
|
|
1514
|
+
self.document.set_image(result)
|
|
1515
|
+
except Exception:
|
|
1516
|
+
# fallback: if set_image fails, at least open it as a new view (but keep dialog open)
|
|
1517
|
+
name = getattr(self.document, "display_name", lambda: "Image")()
|
|
1518
|
+
if hasattr(self.docman, "open_array"):
|
|
1519
|
+
self.docman.open_array(result, title=f"{name} [SelectiveColor]")
|
|
1520
|
+
|
|
1521
|
+
# make the processed image the new working base for further tweaks
|
|
1522
|
+
self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
|
|
1523
|
+
self._last_base = None # force rebuild from current self.img
|
|
1524
|
+
self._reset_controls() # reset knobs; dialog remains open
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _apply_as_new_doc(self):
|
|
1528
|
+
try:
|
|
1529
|
+
result = self._apply_fullres()
|
|
1530
|
+
except Exception as e:
|
|
1531
|
+
QMessageBox.warning(self, "Error", str(e)); return
|
|
1532
|
+
|
|
1533
|
+
name = getattr(self.document, "display_name", lambda: "Image")()
|
|
1534
|
+
new_doc = None
|
|
1535
|
+
if hasattr(self.docman, "open_array"):
|
|
1536
|
+
new_doc = self.docman.open_array(result, title=f"{name} [SelectiveColor]")
|
|
1537
|
+
|
|
1538
|
+
# continue editing the new doc if we got a handle; otherwise just keep editing current
|
|
1539
|
+
if new_doc is not None:
|
|
1540
|
+
self.document = new_doc
|
|
1541
|
+
# refresh label
|
|
1542
|
+
try:
|
|
1543
|
+
disp = getattr(self.document, "display_name", lambda: "Image")()
|
|
1544
|
+
except Exception:
|
|
1545
|
+
disp = "Image"
|
|
1546
|
+
self.lbl_target.setText(f"Target View: <b>{disp}</b>")
|
|
1547
|
+
|
|
1548
|
+
# new working base is the processed pixels either way
|
|
1549
|
+
self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
|
|
1550
|
+
self._last_base = None
|
|
1551
|
+
self._reset_controls()
|
|
1552
|
+
|