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,1604 @@
|
|
|
1
|
+
# pro/pixelmath.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtCore import Qt, QTimer, QPointF
|
|
9
|
+
from PyQt6.QtGui import QIcon, QCursor, QImage, QPixmap, QTransform, QActionGroup
|
|
10
|
+
from PyQt6.QtWidgets import (
|
|
11
|
+
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel,
|
|
12
|
+
QPushButton, QPlainTextEdit, QComboBox, QDialogButtonBox, QRadioButton, QApplication, QSplitter,
|
|
13
|
+
QTabWidget, QWidget, QMessageBox, QMenu, QScrollArea, QButtonGroup, QListWidget, QListWidgetItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QToolButton
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from setiastro.saspro.autostretch import autostretch
|
|
17
|
+
|
|
18
|
+
# Import shared utilities
|
|
19
|
+
from setiastro.saspro.widgets.image_utils import nearest_resize_2d as _nearest_resize_2d
|
|
20
|
+
from setiastro.saspro.widgets.image_utils import float_to_qimage_rgb8 as _float_to_qimage_rgb8
|
|
21
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
22
|
+
|
|
23
|
+
# ---- Optional accelerators from setiastro.saspro.legacy.numba_utils -------------------------
|
|
24
|
+
try:
|
|
25
|
+
from setiastro.saspro.legacy.numba_utils import fast_mad as _fast_mad
|
|
26
|
+
except Exception:
|
|
27
|
+
_fast_mad = None
|
|
28
|
+
|
|
29
|
+
# _float_to_qimage_rgb8 imported from setiastro.saspro.widgets.image_utils
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# =============================================================================
|
|
33
|
+
# PixelImage wrapper (vector ops, indexing, ^ as exponent, ~ as invert)
|
|
34
|
+
# =============================================================================
|
|
35
|
+
class PixelImage:
|
|
36
|
+
"""
|
|
37
|
+
Lightweight wrapper to enable intuitive pixel math:
|
|
38
|
+
• Supports per-channel indexing: img[0], img[1], img[2] → (H,W) planes
|
|
39
|
+
• Broadcasts (H,W) ⇄ (H,W,3) for +,-,*,/, power, and comparisons
|
|
40
|
+
• ~img means (1 - img)
|
|
41
|
+
"""
|
|
42
|
+
__array_priority__ = 10_000 # ensure numpy uses our dunder ops
|
|
43
|
+
|
|
44
|
+
def __init__(self, array: np.ndarray):
|
|
45
|
+
self.array = np.asarray(array, dtype=np.float32)
|
|
46
|
+
|
|
47
|
+
# ---- channel indexing ----
|
|
48
|
+
def __getitem__(self, ch):
|
|
49
|
+
a = self.array
|
|
50
|
+
if a.ndim < 3:
|
|
51
|
+
raise ValueError("This image has no channel dimension to index.")
|
|
52
|
+
if not (0 <= ch < a.shape[2]):
|
|
53
|
+
raise IndexError(f"Channel index {ch} out of range for shape {a.shape}")
|
|
54
|
+
return PixelImage(a[..., ch])
|
|
55
|
+
|
|
56
|
+
# ---- shape coercion (H,W) ⇄ (H,W,3) ----
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _coerce(a, b):
|
|
59
|
+
a = np.asarray(a, dtype=np.float32)
|
|
60
|
+
b = np.asarray(b, dtype=np.float32)
|
|
61
|
+
if a.ndim == 3 and b.ndim == 2:
|
|
62
|
+
# Broadcast b to (H,W,1) virtual view; numpy ufuncs handle (H,W,3) vs (H,W,1) automatically
|
|
63
|
+
b = b[..., None]
|
|
64
|
+
elif a.ndim == 2 and b.ndim == 3:
|
|
65
|
+
a = a[..., None]
|
|
66
|
+
return a, b
|
|
67
|
+
|
|
68
|
+
# ---- binary arithmetic helpers ----
|
|
69
|
+
def _bin(self, other, op):
|
|
70
|
+
a = self.array
|
|
71
|
+
b = other.array if isinstance(other, PixelImage) else other
|
|
72
|
+
a, b = self._coerce(a, b)
|
|
73
|
+
return PixelImage(op(a, b))
|
|
74
|
+
|
|
75
|
+
# ---- comparisons with coercion (return ndarray masks) ----
|
|
76
|
+
def _cmp(self, other, op):
|
|
77
|
+
a = self.array
|
|
78
|
+
b = other.array if isinstance(other, PixelImage) else other
|
|
79
|
+
a, b = self._coerce(a, b)
|
|
80
|
+
return op(a, b)
|
|
81
|
+
|
|
82
|
+
# ---- arithmetic ----
|
|
83
|
+
__add__ = lambda self, o: self._bin(o, np.add)
|
|
84
|
+
__radd__ = __add__
|
|
85
|
+
__sub__ = lambda self, o: self._bin(o, np.subtract)
|
|
86
|
+
__mul__ = lambda self, o: self._bin(o, np.multiply)
|
|
87
|
+
__rmul__ = __mul__
|
|
88
|
+
__truediv__ = lambda self, o: self._bin(o, np.divide)
|
|
89
|
+
|
|
90
|
+
def __rsub__(self, o):
|
|
91
|
+
a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
|
|
92
|
+
return PixelImage(np.subtract(a, b))
|
|
93
|
+
|
|
94
|
+
def __rtruediv__(self, o):
|
|
95
|
+
a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
|
|
96
|
+
return PixelImage(np.divide(a, b))
|
|
97
|
+
|
|
98
|
+
# power ** and ^
|
|
99
|
+
def __pow__(self, o):
|
|
100
|
+
a = self.array; b = o.array if isinstance(o, PixelImage) else o
|
|
101
|
+
a, b = self._coerce(a, b)
|
|
102
|
+
return PixelImage(np.power(a, b))
|
|
103
|
+
|
|
104
|
+
def __rpow__(self, o):
|
|
105
|
+
a = o.array if isinstance(o, PixelImage) else o; b = self.array
|
|
106
|
+
a, b = self._coerce(a, b)
|
|
107
|
+
return PixelImage(np.power(a, b))
|
|
108
|
+
|
|
109
|
+
# keep ^ as alias for power for convenience
|
|
110
|
+
def __xor__(self, o):
|
|
111
|
+
return self.__pow__(o)
|
|
112
|
+
|
|
113
|
+
def __rxor__(self, o):
|
|
114
|
+
return self.__rpow__(o)
|
|
115
|
+
|
|
116
|
+
# invert (~img) → 1 - img
|
|
117
|
+
def __invert__(self):
|
|
118
|
+
return PixelImage(1.0 - self.array)
|
|
119
|
+
|
|
120
|
+
# ---- comparisons (return boolean ndarray) ----
|
|
121
|
+
__lt__ = lambda self, o: self._cmp(o, np.less)
|
|
122
|
+
__le__ = lambda self, o: self._cmp(o, np.less_equal)
|
|
123
|
+
__eq__ = lambda self, o: self._cmp(o, np.equal)
|
|
124
|
+
__ne__ = lambda self, o: self._cmp(o, np.not_equal)
|
|
125
|
+
__gt__ = lambda self, o: self._cmp(o, np.greater)
|
|
126
|
+
__ge__ = lambda self, o: self._cmp(o, np.greater_equal)
|
|
127
|
+
|
|
128
|
+
def __repr__(self):
|
|
129
|
+
return f"PixelImage(shape={self.array.shape}, dtype={self.array.dtype})"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# =============================================================================
|
|
134
|
+
# Helpers
|
|
135
|
+
# =============================================================================
|
|
136
|
+
_ID_RX = re.compile(r'[^0-9a-zA-Z_]+')
|
|
137
|
+
def _sanitize_ident(name: str) -> str:
|
|
138
|
+
s = _ID_RX.sub('_', str(name)).strip('_')
|
|
139
|
+
if not s: s = "view"
|
|
140
|
+
if s[0].isdigit(): s = "_" + s
|
|
141
|
+
return s
|
|
142
|
+
|
|
143
|
+
def _as_rgb(arr: np.ndarray) -> np.ndarray:
|
|
144
|
+
a = np.asarray(arr, dtype=np.float32)
|
|
145
|
+
a = np.clip(a, 0.0, 1.0)
|
|
146
|
+
if a.ndim == 2:
|
|
147
|
+
a = np.repeat(a[..., None], 3, axis=2)
|
|
148
|
+
elif a.ndim == 3 and a.shape[2] == 1:
|
|
149
|
+
a = np.repeat(a, 3, axis=2)
|
|
150
|
+
return a
|
|
151
|
+
|
|
152
|
+
# _nearest_resize_2d imported from setiastro.saspro.widgets.image_utils
|
|
153
|
+
|
|
154
|
+
def _get_doc_active_mask_2d(doc, H: int, W: int) -> np.ndarray | None:
|
|
155
|
+
"""
|
|
156
|
+
Returns the active mask as a 2-D float32 array in [0..1], resized to (H,W).
|
|
157
|
+
"""
|
|
158
|
+
if doc is None:
|
|
159
|
+
return None
|
|
160
|
+
mid = getattr(doc, "active_mask_id", None)
|
|
161
|
+
if not mid:
|
|
162
|
+
return None
|
|
163
|
+
masks = getattr(doc, "masks", {}) or {}
|
|
164
|
+
layer = masks.get(mid)
|
|
165
|
+
if layer is None:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# Extract data robustly without using `or` on arrays
|
|
169
|
+
data = None
|
|
170
|
+
# object-style
|
|
171
|
+
for attr in ("data", "mask", "image", "array"):
|
|
172
|
+
if hasattr(layer, attr):
|
|
173
|
+
val = getattr(layer, attr)
|
|
174
|
+
if val is not None:
|
|
175
|
+
data = val
|
|
176
|
+
break
|
|
177
|
+
# dict-style
|
|
178
|
+
if data is None and isinstance(layer, dict):
|
|
179
|
+
for key in ("data", "mask", "image", "array"):
|
|
180
|
+
if key in layer and layer[key] is not None:
|
|
181
|
+
data = layer[key]
|
|
182
|
+
break
|
|
183
|
+
# ndarray
|
|
184
|
+
if data is None and isinstance(layer, np.ndarray):
|
|
185
|
+
data = layer
|
|
186
|
+
if data is None:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
m = np.asarray(data)
|
|
190
|
+
if m.ndim == 3: # collapse RGB(A) → gray
|
|
191
|
+
m = m.mean(axis=2)
|
|
192
|
+
m = m.astype(np.float32, copy=False)
|
|
193
|
+
|
|
194
|
+
# normalize to [0..1]
|
|
195
|
+
if m.max(initial=0.0) > 1.0:
|
|
196
|
+
m /= float(m.max())
|
|
197
|
+
|
|
198
|
+
m = np.clip(m, 0.0, 1.0)
|
|
199
|
+
return _nearest_resize_2d(m, H, W)
|
|
200
|
+
|
|
201
|
+
def _mask_for_ref(doc, ref_like: np.ndarray) -> np.ndarray | None:
|
|
202
|
+
"""
|
|
203
|
+
Returns a mask shaped for `ref_like`:
|
|
204
|
+
- 2-D for mono ref
|
|
205
|
+
- H×W×C (broadcast) for color ref
|
|
206
|
+
"""
|
|
207
|
+
ref = np.asarray(ref_like)
|
|
208
|
+
H, W = ref.shape[:2]
|
|
209
|
+
m2d = _get_doc_active_mask_2d(doc, H, W)
|
|
210
|
+
if m2d is None:
|
|
211
|
+
return None
|
|
212
|
+
if ref.ndim == 3:
|
|
213
|
+
return np.repeat(m2d[:, :, None], ref.shape[2], axis=2)
|
|
214
|
+
return m2d
|
|
215
|
+
|
|
216
|
+
def _blend_masked(base: np.ndarray, out: np.ndarray, m: np.ndarray) -> np.ndarray:
|
|
217
|
+
base = _as_rgb(base) # (H,W,3)
|
|
218
|
+
out = _as_rgb(out) # (H,W,3)
|
|
219
|
+
m = np.asarray(m, dtype=np.float32)
|
|
220
|
+
m = np.clip(m, 0.0, 1.0)
|
|
221
|
+
|
|
222
|
+
# Allow 2-D or 3-D masks
|
|
223
|
+
if m.ndim == 2:
|
|
224
|
+
m = m[..., None] # (H,W,1)
|
|
225
|
+
elif m.ndim == 3 and m.shape[2] not in (1, 3):
|
|
226
|
+
raise ValueError("Mask must be 2-D or have 1 or 3 channels.")
|
|
227
|
+
|
|
228
|
+
return np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# =============================================================================
|
|
232
|
+
# Headless apply
|
|
233
|
+
# =============================================================================
|
|
234
|
+
def apply_pixel_math_to_doc(parent, doc, preset: dict | None):
|
|
235
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
236
|
+
raise RuntimeError("Document has no image.")
|
|
237
|
+
expr = (preset or {}).get("expr", "").strip()
|
|
238
|
+
ev = _Evaluator(parent, doc)
|
|
239
|
+
if expr:
|
|
240
|
+
out = ev.eval_single(expr)
|
|
241
|
+
else:
|
|
242
|
+
r = (preset or {}).get("expr_r", "").strip()
|
|
243
|
+
g = (preset or {}).get("expr_g", "").strip()
|
|
244
|
+
b = (preset or {}).get("expr_b", "").strip()
|
|
245
|
+
if not (r or g or b):
|
|
246
|
+
raise RuntimeError("Pixel Math preset empty.")
|
|
247
|
+
out = ev.eval_rgb(r, g, b, default_channels=(0, 1, 2))
|
|
248
|
+
|
|
249
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
250
|
+
if hasattr(doc, "set_image"):
|
|
251
|
+
doc.set_image(out, step_name="Pixel Math")
|
|
252
|
+
elif hasattr(doc, "apply_numpy"):
|
|
253
|
+
doc.apply_numpy(out, step_name="Pixel Math")
|
|
254
|
+
else:
|
|
255
|
+
doc.image = out
|
|
256
|
+
|
|
257
|
+
# =============================================================================
|
|
258
|
+
# Evaluator
|
|
259
|
+
# =============================================================================
|
|
260
|
+
class _Evaluator:
|
|
261
|
+
def __init__(self, parent, doc):
|
|
262
|
+
self.parent = parent
|
|
263
|
+
self.doc = doc
|
|
264
|
+
self._build_namespace()
|
|
265
|
+
|
|
266
|
+
def _build_namespace(self):
|
|
267
|
+
self.ns = {
|
|
268
|
+
"np": np,
|
|
269
|
+
# existing:
|
|
270
|
+
"med": self._med, "mean": self._mean, "min": self._min, "max": self._max,
|
|
271
|
+
"std": self._std, "mad": self._mad, "log": self._log, "iff": self._iff, "mtf": self._mtf,
|
|
272
|
+
# new math helpers:
|
|
273
|
+
"clamp": self._clamp,
|
|
274
|
+
"rescale": self._rescale,
|
|
275
|
+
"gamma": self._gamma,
|
|
276
|
+
"pow_safe": self._pow_safe,
|
|
277
|
+
"absf": self._absf,
|
|
278
|
+
"expf": self._expf,
|
|
279
|
+
"sqrtf": self._sqrtf,
|
|
280
|
+
"arcsin": self._arcsin,
|
|
281
|
+
"sigmoid": self._sigmoid,
|
|
282
|
+
"smoothstep": self._smoothstep,
|
|
283
|
+
"lerp": self._lerp, "mix": self._lerp,
|
|
284
|
+
# stats / normalization:
|
|
285
|
+
"percentile": self._percentile,
|
|
286
|
+
"normalize01": self._normalize01,
|
|
287
|
+
"zscore": self._zscore,
|
|
288
|
+
# channels & color:
|
|
289
|
+
"ch": self._ch,
|
|
290
|
+
"luma": self._luma,
|
|
291
|
+
"compose": self._compose,
|
|
292
|
+
# mask helpers:
|
|
293
|
+
"mask": self._mask_fn,
|
|
294
|
+
"apply_mask": self._apply_mask_fn,
|
|
295
|
+
# optional filters (cv2-backed):
|
|
296
|
+
"boxblur": self._boxblur,
|
|
297
|
+
"gauss": self._gauss,
|
|
298
|
+
"median": self._median,
|
|
299
|
+
"unsharp": self._unsharp,
|
|
300
|
+
# constants:
|
|
301
|
+
"pi": float(np.pi), "e": float(np.e), "EPS": 1e-8,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
cur = np.asarray(self.doc.image, dtype=np.float32)
|
|
305
|
+
self._img_shape = cur.shape
|
|
306
|
+
self.ns["img"] = PixelImage(_as_rgb(cur))
|
|
307
|
+
|
|
308
|
+
H, W = cur.shape[:2]
|
|
309
|
+
C = 1 if cur.ndim == 2 else cur.shape[2]
|
|
310
|
+
self.ns["H"], self.ns["W"], self.ns["C"] = int(H), int(W), int(C)
|
|
311
|
+
self.ns["shape"] = (int(H), int(W), int(C))
|
|
312
|
+
|
|
313
|
+
# Normalized coordinate grids (2-D, float32)
|
|
314
|
+
xx = np.linspace(0.0, 1.0, W, dtype=np.float32)
|
|
315
|
+
yy = np.linspace(0.0, 1.0, H, dtype=np.float32)
|
|
316
|
+
X, Y = np.meshgrid(xx, yy)
|
|
317
|
+
self.ns["X"] = X
|
|
318
|
+
self.ns["Y"] = Y
|
|
319
|
+
|
|
320
|
+
# map: raw title → ident (existing)
|
|
321
|
+
self.title_map = []
|
|
322
|
+
open_docs = []
|
|
323
|
+
if hasattr(self.parent, "_subwindow_docs"):
|
|
324
|
+
open_docs = list(self.parent._subwindow_docs())
|
|
325
|
+
else:
|
|
326
|
+
open_docs = [(getattr(self.doc, "display_name", lambda: "view")(), self.doc)]
|
|
327
|
+
|
|
328
|
+
used = set(self.ns.keys())
|
|
329
|
+
for raw_title, d in open_docs:
|
|
330
|
+
ident = _sanitize_ident(raw_title or "view")
|
|
331
|
+
base, i = ident, 2
|
|
332
|
+
while ident in used:
|
|
333
|
+
ident = f"{base}_{i}"; i += 1
|
|
334
|
+
used.add(ident)
|
|
335
|
+
arr = getattr(d, "image", None)
|
|
336
|
+
if arr is None:
|
|
337
|
+
continue
|
|
338
|
+
self.ns[ident] = PixelImage(np.asarray(arr, dtype=np.float32)) # keep native 2D/3D
|
|
339
|
+
self.title_map.append((str(raw_title), ident))
|
|
340
|
+
|
|
341
|
+
# -------- expression rewriting: allow raw window titles in user code
|
|
342
|
+
def _rewrite_names(self, expr: str) -> str:
|
|
343
|
+
if not expr: return expr
|
|
344
|
+
out = expr
|
|
345
|
+
for raw, ident in self.title_map:
|
|
346
|
+
# raw title
|
|
347
|
+
pat = re.compile(rf'(?<![\w]){re.escape(raw)}(?![\w])')
|
|
348
|
+
out = pat.sub(ident, out)
|
|
349
|
+
# basename without extension
|
|
350
|
+
base = os.path.splitext(raw)[0]
|
|
351
|
+
if base and base != raw:
|
|
352
|
+
pat2 = re.compile(rf'(?<![\w]){re.escape(base)}(?![\w])')
|
|
353
|
+
out = pat2.sub(ident, out)
|
|
354
|
+
return out
|
|
355
|
+
|
|
356
|
+
# -------- functions
|
|
357
|
+
def _med(self, x):
|
|
358
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
359
|
+
if a.ndim == 2:
|
|
360
|
+
v = np.median(a); out = np.full_like(a, v)
|
|
361
|
+
else:
|
|
362
|
+
v = np.median(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
|
|
363
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
364
|
+
|
|
365
|
+
def _mean(self, x):
|
|
366
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
367
|
+
if a.ndim == 2:
|
|
368
|
+
v = np.mean(a); out = np.full_like(a, v)
|
|
369
|
+
else:
|
|
370
|
+
v = np.mean(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
|
|
371
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
372
|
+
|
|
373
|
+
def _min(self, x):
|
|
374
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
375
|
+
if a.ndim == 2:
|
|
376
|
+
v = np.min(a); out = np.full_like(a, v)
|
|
377
|
+
else:
|
|
378
|
+
v = np.min(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
|
|
379
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
380
|
+
|
|
381
|
+
def _max(self, x):
|
|
382
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
383
|
+
if a.ndim == 2:
|
|
384
|
+
v = np.max(a); out = np.full_like(a, v)
|
|
385
|
+
else:
|
|
386
|
+
v = np.max(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
|
|
387
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
388
|
+
|
|
389
|
+
def _std(self, x):
|
|
390
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
391
|
+
if a.ndim == 2:
|
|
392
|
+
v = np.std(a); out = np.full_like(a, v)
|
|
393
|
+
else:
|
|
394
|
+
v = np.std(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
|
|
395
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
396
|
+
|
|
397
|
+
def _mad(self, x):
|
|
398
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
399
|
+
if a.ndim == 2:
|
|
400
|
+
if _fast_mad is not None:
|
|
401
|
+
v = float(_fast_mad(a))
|
|
402
|
+
else:
|
|
403
|
+
m = np.median(a); v = np.median(np.abs(a - m))
|
|
404
|
+
out = np.full_like(a, v)
|
|
405
|
+
else:
|
|
406
|
+
out = np.empty_like(a)
|
|
407
|
+
for c in range(a.shape[2]):
|
|
408
|
+
ch = a[..., c]
|
|
409
|
+
if _fast_mad is not None:
|
|
410
|
+
v = float(_fast_mad(ch))
|
|
411
|
+
else:
|
|
412
|
+
m = np.median(ch); v = np.median(np.abs(ch - m))
|
|
413
|
+
out[..., c] = v
|
|
414
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
415
|
+
|
|
416
|
+
def _log(self, x):
|
|
417
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
418
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
419
|
+
y = np.log(np.clip(a, 1e-12, None))
|
|
420
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
421
|
+
|
|
422
|
+
def _iff(self, cond, a, b):
|
|
423
|
+
c = cond.array if isinstance(cond, PixelImage) else cond
|
|
424
|
+
av = a.array if isinstance(a, PixelImage) else a
|
|
425
|
+
bv = b.array if isinstance(b, PixelImage) else b
|
|
426
|
+
r = np.where(c, av, bv)
|
|
427
|
+
return PixelImage(r) if any(isinstance(z, PixelImage) for z in (cond, a, b)) else r
|
|
428
|
+
|
|
429
|
+
def _mtf(self, x, m):
|
|
430
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x)
|
|
431
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
432
|
+
y = ((m - 1.0) * a) / (((2.0 * m - 1.0) * a) - m)
|
|
433
|
+
y = np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0)
|
|
434
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
435
|
+
|
|
436
|
+
# ---- math helpers ----
|
|
437
|
+
def _clamp(self, x, lo=0.0, hi=1.0):
|
|
438
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
439
|
+
y = np.clip(a, float(lo), float(hi))
|
|
440
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
441
|
+
|
|
442
|
+
def _rescale(self, x, a, b, lo=0.0, hi=1.0):
|
|
443
|
+
a = np.asarray(x.array if isinstance(x, PixelImage) else x, dtype=np.float32)
|
|
444
|
+
src_lo, src_hi = float(a.min()), float(a.max())
|
|
445
|
+
if np.isfinite(a).any():
|
|
446
|
+
src_lo, src_hi = float(a), float(b)
|
|
447
|
+
# avoid div-by-zero
|
|
448
|
+
denom = max(src_hi - src_lo, 1e-12)
|
|
449
|
+
y = (a - src_lo) / denom
|
|
450
|
+
y = y * (hi - lo) + lo
|
|
451
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
452
|
+
|
|
453
|
+
def _gamma(self, x, g):
|
|
454
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
455
|
+
y = np.power(np.clip(a, 0.0, 1.0), float(g))
|
|
456
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
457
|
+
|
|
458
|
+
def _pow_safe(self, x, p):
|
|
459
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
460
|
+
y = np.power(np.clip(a, 1e-8, None), float(p))
|
|
461
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
462
|
+
|
|
463
|
+
def _absf(self, x):
|
|
464
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
465
|
+
y = np.abs(a)
|
|
466
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
467
|
+
|
|
468
|
+
def _expf(self, x):
|
|
469
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
470
|
+
y = np.exp(a)
|
|
471
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
472
|
+
|
|
473
|
+
def _sqrtf(self, x):
|
|
474
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
475
|
+
y = np.sqrt(np.clip(a, 0.0, None))
|
|
476
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
477
|
+
|
|
478
|
+
def _arcsin(self, x):
|
|
479
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
480
|
+
y = np.arcsin(np.clip(a, -1.0, 1.0))
|
|
481
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _sigmoid(self, x, k=10.0, mid=0.5):
|
|
485
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
486
|
+
y = 1.0 / (1.0 + np.exp(-float(k) * (a - float(mid))))
|
|
487
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
488
|
+
|
|
489
|
+
def _smoothstep(self, e0, e1, x):
|
|
490
|
+
e0, e1 = float(e0), float(e1)
|
|
491
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
492
|
+
t = np.clip((a - e0) / max(e1 - e0, 1e-12), 0.0, 1.0)
|
|
493
|
+
y = t * t * (3 - 2 * t)
|
|
494
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
495
|
+
|
|
496
|
+
def _lerp(self, a, b, t):
|
|
497
|
+
av = a.array if isinstance(a, PixelImage) else np.asarray(a, dtype=np.float32)
|
|
498
|
+
bv = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
|
|
499
|
+
tv = t.array if isinstance(t, PixelImage) else np.asarray(t, dtype=np.float32)
|
|
500
|
+
y = av * (1.0 - tv) + bv * tv
|
|
501
|
+
return PixelImage(y) if any(isinstance(z, PixelImage) for z in (a, b, t)) else y
|
|
502
|
+
|
|
503
|
+
# ---- stats/normalization ----
|
|
504
|
+
def _percentile(self, x, p):
|
|
505
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
506
|
+
if a.ndim == 2:
|
|
507
|
+
v = np.percentile(a, float(p))
|
|
508
|
+
out = np.full_like(a, v)
|
|
509
|
+
else:
|
|
510
|
+
out = np.empty_like(a)
|
|
511
|
+
for c in range(a.shape[2]):
|
|
512
|
+
v = np.percentile(a[..., c], float(p))
|
|
513
|
+
out[..., c] = v
|
|
514
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
515
|
+
|
|
516
|
+
def _normalize01(self, x):
|
|
517
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
518
|
+
if a.ndim == 2:
|
|
519
|
+
lo, hi = float(a.min()), float(a.max())
|
|
520
|
+
out = (a - lo) / max(hi - lo, 1e-12)
|
|
521
|
+
else:
|
|
522
|
+
out = np.empty_like(a)
|
|
523
|
+
for c in range(a.shape[2]):
|
|
524
|
+
ch = a[..., c]
|
|
525
|
+
lo, hi = float(ch.min()), float(ch.max())
|
|
526
|
+
out[..., c] = (ch - lo) / max(hi - lo, 1e-12)
|
|
527
|
+
return PixelImage(np.clip(out, 0.0, 1.0)) if isinstance(x, PixelImage) else np.clip(out, 0.0, 1.0)
|
|
528
|
+
|
|
529
|
+
def _zscore(self, x):
|
|
530
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
531
|
+
if a.ndim == 2:
|
|
532
|
+
m, s = float(a.mean()), float(a.std())
|
|
533
|
+
out = (a - m) / max(s, 1e-12)
|
|
534
|
+
else:
|
|
535
|
+
out = np.empty_like(a)
|
|
536
|
+
for c in range(a.shape[2]):
|
|
537
|
+
ch = a[..., c]
|
|
538
|
+
m, s = float(ch.mean()), float(ch.std())
|
|
539
|
+
out[..., c] = (ch - m) / max(s, 1e-12)
|
|
540
|
+
return PixelImage(out) if isinstance(x, PixelImage) else out
|
|
541
|
+
|
|
542
|
+
# ---- channels & color ----
|
|
543
|
+
def _ch(self, x, i):
|
|
544
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
545
|
+
if a.ndim != 3: raise ValueError("ch(x,i) expects RGB image")
|
|
546
|
+
return a[..., int(i)]
|
|
547
|
+
|
|
548
|
+
def _luma(self, x):
|
|
549
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
550
|
+
if a.ndim == 2:
|
|
551
|
+
return a
|
|
552
|
+
y = 0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]
|
|
553
|
+
return y
|
|
554
|
+
|
|
555
|
+
def _compose(self, r, g, b):
|
|
556
|
+
R = r.array if isinstance(r, PixelImage) else np.asarray(r, dtype=np.float32)
|
|
557
|
+
G = g.array if isinstance(g, PixelImage) else np.asarray(g, dtype=np.float32)
|
|
558
|
+
B = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
|
|
559
|
+
if R.ndim != 2 or G.ndim != 2 or B.ndim != 2:
|
|
560
|
+
raise ValueError("compose(r,g,b) expects three 2-D planes")
|
|
561
|
+
return np.stack([R, G, B], axis=2)
|
|
562
|
+
|
|
563
|
+
# ---- mask helpers exposed to the user ----
|
|
564
|
+
def _mask_fn(self):
|
|
565
|
+
ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
|
|
566
|
+
m = _mask_for_ref(self.doc, ref)
|
|
567
|
+
if m is None:
|
|
568
|
+
m = np.zeros(ref.shape[:2], dtype=np.float32)
|
|
569
|
+
if m.ndim == 3:
|
|
570
|
+
m = m[...,0]
|
|
571
|
+
return m
|
|
572
|
+
|
|
573
|
+
def _apply_mask_fn(self, base, out, m):
|
|
574
|
+
basev = base.array if isinstance(base, PixelImage) else np.asarray(base, dtype=np.float32)
|
|
575
|
+
outv = out.array if isinstance(out, PixelImage) else np.asarray(out, dtype=np.float32)
|
|
576
|
+
mv = m.array if isinstance(m, PixelImage) else np.asarray(m, dtype=np.float32)
|
|
577
|
+
return _blend_masked(basev, outv, mv) # _blend_masked now handles 2-D or 3-D
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---- tiny filters (cv2 optional) ----
|
|
581
|
+
def _apply_per_channel(self, a, fn):
|
|
582
|
+
if a.ndim == 2:
|
|
583
|
+
return fn(a)
|
|
584
|
+
out = np.empty_like(a)
|
|
585
|
+
for c in range(a.shape[2]):
|
|
586
|
+
out[..., c] = fn(a[..., c])
|
|
587
|
+
return out
|
|
588
|
+
|
|
589
|
+
def _boxblur(self, x, k=3):
|
|
590
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
591
|
+
try:
|
|
592
|
+
import cv2
|
|
593
|
+
k = int(max(1, k))
|
|
594
|
+
y = self._apply_per_channel(a, lambda ch: cv2.blur(ch, (k, k)))
|
|
595
|
+
except Exception:
|
|
596
|
+
# naive fallback
|
|
597
|
+
from math import floor
|
|
598
|
+
k = int(max(1, k))
|
|
599
|
+
r = k//2
|
|
600
|
+
y = a.copy()
|
|
601
|
+
# very simple and slow fallback; okay as last resort
|
|
602
|
+
for i in range(a.shape[0]):
|
|
603
|
+
i0, i1 = max(0, i-r), min(a.shape[0], i+r+1)
|
|
604
|
+
for j in range(a.shape[1]):
|
|
605
|
+
j0, j1 = max(0, j-r), min(a.shape[1], j+r+1)
|
|
606
|
+
y[i, j] = a[i0:i1, j0:j1].mean(axis=(0,1))
|
|
607
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
608
|
+
|
|
609
|
+
def _gauss(self, x, sigma=1.0):
|
|
610
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
611
|
+
try:
|
|
612
|
+
import cv2
|
|
613
|
+
s = float(sigma)
|
|
614
|
+
k = int(max(1, 2*int(3*s)+1))
|
|
615
|
+
y = self._apply_per_channel(a, lambda ch: cv2.GaussianBlur(ch, (k, k), s))
|
|
616
|
+
except Exception:
|
|
617
|
+
# approximate with box blur passes
|
|
618
|
+
y = self._boxblur(a, k=max(1, int(2*sigma)+1))
|
|
619
|
+
y = y.array if isinstance(y, PixelImage) else y
|
|
620
|
+
y = self._boxblur(PixelImage(y), k=max(1, int(2*sigma)+1))
|
|
621
|
+
y = y.array if isinstance(y, PixelImage) else y
|
|
622
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
623
|
+
|
|
624
|
+
def _median(self, x, k=3):
|
|
625
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
626
|
+
try:
|
|
627
|
+
import cv2
|
|
628
|
+
k = int(max(1, k)) | 1 # must be odd
|
|
629
|
+
y = self._apply_per_channel(a, lambda ch: cv2.medianBlur(ch, k))
|
|
630
|
+
except Exception:
|
|
631
|
+
# crude fallback: percentile in local box
|
|
632
|
+
y = self._boxblur(a, k=k) # not truly median, but better than nothing
|
|
633
|
+
y = y.array if isinstance(y, PixelImage) else y
|
|
634
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
635
|
+
|
|
636
|
+
def _unsharp(self, x, sigma=1.5, amount=1.0):
|
|
637
|
+
a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
|
|
638
|
+
blur = self._gauss(PixelImage(a), sigma)
|
|
639
|
+
blur = blur.array if isinstance(blur, PixelImage) else blur
|
|
640
|
+
y = np.clip(a + float(amount) * (a - blur), 0.0, 1.0)
|
|
641
|
+
return PixelImage(y) if isinstance(x, PixelImage) else y
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# -------- core eval
|
|
645
|
+
def _eval_multiline(self, expr: str):
|
|
646
|
+
lines = [ln for ln in (expr or "").splitlines() if ln.strip()]
|
|
647
|
+
if not lines:
|
|
648
|
+
return 0
|
|
649
|
+
scope = dict(self.ns)
|
|
650
|
+
for ln in lines[:-1]:
|
|
651
|
+
exec(ln, {"__builtins__": None}, scope)
|
|
652
|
+
return eval(lines[-1], {"__builtins__": None}, scope)
|
|
653
|
+
|
|
654
|
+
def eval_single(self, expr: str) -> np.ndarray:
|
|
655
|
+
expr = self._rewrite_names(expr)
|
|
656
|
+
r = self._eval_multiline(expr)
|
|
657
|
+
if isinstance(r, PixelImage):
|
|
658
|
+
r = r.array
|
|
659
|
+
|
|
660
|
+
ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
|
|
661
|
+
if np.isscalar(r):
|
|
662
|
+
r = np.full(ref.shape, float(r), dtype=np.float32)
|
|
663
|
+
r = _as_rgb(r.astype(np.float32, copy=False))
|
|
664
|
+
|
|
665
|
+
m = _mask_for_ref(self.doc, ref)
|
|
666
|
+
if m is not None:
|
|
667
|
+
r = _blend_masked(ref, r, m)
|
|
668
|
+
return r
|
|
669
|
+
|
|
670
|
+
def eval_rgb(self, er: str, eg: str, eb: str, default_channels=(0, 1, 2)) -> np.ndarray:
|
|
671
|
+
er, eg, eb = self._rewrite_names(er), self._rewrite_names(eg), self._rewrite_names(eb)
|
|
672
|
+
ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
|
|
673
|
+
H, W, _ = ref.shape
|
|
674
|
+
|
|
675
|
+
def one(e, ci: int):
|
|
676
|
+
if not e:
|
|
677
|
+
return 0
|
|
678
|
+
v = self._eval_multiline(e)
|
|
679
|
+
if isinstance(v, PixelImage):
|
|
680
|
+
v = v.array
|
|
681
|
+
|
|
682
|
+
# Scalars become (H,W) plane later, so handle that below
|
|
683
|
+
if np.isscalar(v):
|
|
684
|
+
return np.full((H, W), float(v), dtype=np.float32)
|
|
685
|
+
|
|
686
|
+
v = np.asarray(v, dtype=np.float32)
|
|
687
|
+
|
|
688
|
+
# NEW: if user returned a color (HxWx3) in per-channel slot, assume the tab's channel
|
|
689
|
+
if v.ndim == 3:
|
|
690
|
+
if v.shape[2] == 1:
|
|
691
|
+
v = v[..., 0]
|
|
692
|
+
else:
|
|
693
|
+
# auto-pick requested channel
|
|
694
|
+
v = v[..., int(ci)]
|
|
695
|
+
|
|
696
|
+
# At this point expect 2-D plane
|
|
697
|
+
if v.ndim != 2:
|
|
698
|
+
raise ValueError("Per-channel mode expects a 2-D result (or an RGB where the tab's channel can be taken).")
|
|
699
|
+
return v
|
|
700
|
+
|
|
701
|
+
R = one(er, default_channels[0])
|
|
702
|
+
G = one(eg, default_channels[1])
|
|
703
|
+
B = one(eb, default_channels[2])
|
|
704
|
+
out = np.stack([R, G, B], axis=2)
|
|
705
|
+
|
|
706
|
+
m = _mask_for_ref(self.doc, ref)
|
|
707
|
+
if m is not None:
|
|
708
|
+
out = _blend_masked(ref, out, m)
|
|
709
|
+
return out
|
|
710
|
+
|
|
711
|
+
class _PreviewView(QGraphicsView):
|
|
712
|
+
"""QGraphicsView with left-drag panning and Ctrl+wheel zoom."""
|
|
713
|
+
def __init__(self, parent=None):
|
|
714
|
+
super().__init__(parent)
|
|
715
|
+
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # click & drag to pan
|
|
716
|
+
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
717
|
+
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
|
718
|
+
self._zoom = 1.0
|
|
719
|
+
self._min_zoom = 0.05
|
|
720
|
+
self._max_zoom = 20.0
|
|
721
|
+
|
|
722
|
+
def wheelEvent(self, ev):
|
|
723
|
+
# Ctrl + wheel → zoom; otherwise, default scroll behavior
|
|
724
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
725
|
+
angle = ev.angleDelta().y()
|
|
726
|
+
step = 1.25 if angle > 0 else 1/1.25
|
|
727
|
+
new_zoom = max(self._min_zoom, min(self._zoom * step, self._max_zoom))
|
|
728
|
+
step = new_zoom / self._zoom # clamp-aware step
|
|
729
|
+
self._zoom = new_zoom
|
|
730
|
+
self.scale(step, step)
|
|
731
|
+
ev.accept()
|
|
732
|
+
else:
|
|
733
|
+
super().wheelEvent(ev)
|
|
734
|
+
|
|
735
|
+
# helpers to keep external controls in sync
|
|
736
|
+
def zoom_reset(self):
|
|
737
|
+
self.resetTransform()
|
|
738
|
+
self._zoom = 1.0
|
|
739
|
+
|
|
740
|
+
def zoom_by(self, factor: float):
|
|
741
|
+
new_zoom = max(self._min_zoom, min(self._zoom * float(factor), self._max_zoom))
|
|
742
|
+
factor = new_zoom / self._zoom
|
|
743
|
+
self._zoom = new_zoom
|
|
744
|
+
self.scale(factor, factor)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
# =============================================================================
|
|
748
|
+
# Dialog
|
|
749
|
+
# =============================================================================
|
|
750
|
+
class PixelMathDialogPro(QDialog):
|
|
751
|
+
"""
|
|
752
|
+
Pixel Math with view-name variables.
|
|
753
|
+
• img → active view
|
|
754
|
+
• one variable per OPEN VIEW using the window title (sanitized).
|
|
755
|
+
• Output: Overwrite active OR Create new view
|
|
756
|
+
"""
|
|
757
|
+
def __init__(self, parent, doc, icon: QIcon | None = None):
|
|
758
|
+
super().__init__(parent)
|
|
759
|
+
self.setWindowTitle(self.tr("Pixel Math"))
|
|
760
|
+
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
761
|
+
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
762
|
+
self.setModal(False)
|
|
763
|
+
if icon:
|
|
764
|
+
try:
|
|
765
|
+
self.setWindowIcon(icon)
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
self.doc = doc
|
|
770
|
+
self.ev = _Evaluator(parent, doc)
|
|
771
|
+
|
|
772
|
+
self._load_autostretch_prefs()
|
|
773
|
+
|
|
774
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
775
|
+
# Root split layout: controls (left, scrollable) | preview (right, flexible)
|
|
776
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
777
|
+
root = QHBoxLayout(self)
|
|
778
|
+
|
|
779
|
+
# Left column (controls)
|
|
780
|
+
left_scroll = QScrollArea()
|
|
781
|
+
left_scroll.setWidgetResizable(True)
|
|
782
|
+
left_panel = QWidget()
|
|
783
|
+
left_col = QVBoxLayout(left_panel)
|
|
784
|
+
left_col.setContentsMargins(0, 0, 0, 0)
|
|
785
|
+
left_col.setSpacing(8)
|
|
786
|
+
left_scroll.setWidget(left_panel)
|
|
787
|
+
|
|
788
|
+
# Right column (preview)
|
|
789
|
+
right_panel = QWidget()
|
|
790
|
+
right_col = QVBoxLayout(right_panel)
|
|
791
|
+
right_col.setContentsMargins(0, 0, 0, 0)
|
|
792
|
+
right_col.setSpacing(8)
|
|
793
|
+
|
|
794
|
+
# Put them into a splitter so user can drag the boundary
|
|
795
|
+
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
796
|
+
splitter.setChildrenCollapsible(False)
|
|
797
|
+
splitter.addWidget(left_scroll)
|
|
798
|
+
splitter.addWidget(right_panel)
|
|
799
|
+
splitter.setStretchFactor(0, 0) # left: fixed-ish
|
|
800
|
+
splitter.setStretchFactor(1, 1) # right: grows
|
|
801
|
+
|
|
802
|
+
# Give the left side a reasonable minimum so it doesn't disappear
|
|
803
|
+
left_scroll.setMinimumWidth(260)
|
|
804
|
+
|
|
805
|
+
root.addWidget(splitter)
|
|
806
|
+
self._splitter = splitter # optional, if you ever want to tweak later
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
810
|
+
# Variables mapping (raw title → identifier)
|
|
811
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
812
|
+
vars_grp = QGroupBox(self.tr("Variables"))
|
|
813
|
+
vars_layout = QVBoxLayout(vars_grp)
|
|
814
|
+
|
|
815
|
+
self.vars_list = QListWidget()
|
|
816
|
+
self.vars_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
|
|
817
|
+
self.vars_list.setAlternatingRowColors(True)
|
|
818
|
+
self.vars_list.setTextElideMode(Qt.TextElideMode.ElideRight)
|
|
819
|
+
|
|
820
|
+
def _shorten_title(raw_title: str, ident: str, max_chars: int = 48) -> tuple[str, str]:
|
|
821
|
+
"""
|
|
822
|
+
Return (display_text, full_text).
|
|
823
|
+
display_text is shortened so the left panel doesn't explode.
|
|
824
|
+
full_text is put into the tooltip.
|
|
825
|
+
"""
|
|
826
|
+
base = str(raw_title)
|
|
827
|
+
if len(base) > max_chars:
|
|
828
|
+
head = max_chars // 2 - 1
|
|
829
|
+
tail = max_chars - head - 1
|
|
830
|
+
base = base[:head] + "…" + base[-tail:]
|
|
831
|
+
disp = f"{base} → {ident}"
|
|
832
|
+
full = f"{raw_title} → {ident}"
|
|
833
|
+
return disp, full
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
# First item = active view
|
|
837
|
+
active_item = QListWidgetItem("img (active)")
|
|
838
|
+
active_item.setData(Qt.ItemDataRole.UserRole, "img") # ← stash the real name
|
|
839
|
+
active_item.setToolTip(self.tr("img (active)"))
|
|
840
|
+
self.vars_list.addItem(active_item)
|
|
841
|
+
|
|
842
|
+
# Other open views
|
|
843
|
+
for raw, ident in self.ev.title_map:
|
|
844
|
+
disp, full = _shorten_title(raw, ident)
|
|
845
|
+
it = QListWidgetItem(disp)
|
|
846
|
+
it.setData(Qt.ItemDataRole.UserRole, ident) # ← stash the ident
|
|
847
|
+
it.setToolTip(full)
|
|
848
|
+
self.vars_list.addItem(it)
|
|
849
|
+
|
|
850
|
+
# Comfortable height; scroll appears as needed
|
|
851
|
+
self.vars_list.setMinimumHeight(120)
|
|
852
|
+
self.vars_list.setMaximumHeight(180)
|
|
853
|
+
|
|
854
|
+
hint = QLabel(self.tr("Tip: double-click to insert the identifier at the cursor"))
|
|
855
|
+
hint.setStyleSheet("color: gray; font-size: 11px;")
|
|
856
|
+
|
|
857
|
+
vars_layout.addWidget(self.vars_list)
|
|
858
|
+
vars_layout.addWidget(hint)
|
|
859
|
+
|
|
860
|
+
def _insert_ident_into_current_editor(item: QListWidgetItem):
|
|
861
|
+
ident = item.data(Qt.ItemDataRole.UserRole) or item.text().split("→", 1)[-1].strip()
|
|
862
|
+
ed = self.ed_single if self.rb_single.isChecked() else (
|
|
863
|
+
self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b
|
|
864
|
+
)
|
|
865
|
+
ed.setFocus()
|
|
866
|
+
ed.insertPlainText(str(ident))
|
|
867
|
+
|
|
868
|
+
self._on_var_dblclick = _insert_ident_into_current_editor
|
|
869
|
+
try:
|
|
870
|
+
self.vars_list.itemDoubleClicked.connect(self._on_var_dblclick, Qt.ConnectionType.UniqueConnection)
|
|
871
|
+
except TypeError:
|
|
872
|
+
# Already connected; ignore
|
|
873
|
+
pass
|
|
874
|
+
|
|
875
|
+
left_col.addWidget(vars_grp)
|
|
876
|
+
|
|
877
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
878
|
+
# Output group
|
|
879
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
880
|
+
out_grp = QGroupBox(self.tr("Output"))
|
|
881
|
+
out_row = QHBoxLayout(out_grp)
|
|
882
|
+
self.rb_out_overwrite = QRadioButton(self.tr("Overwrite active")); self.rb_out_overwrite.setChecked(True)
|
|
883
|
+
self.rb_out_new = QRadioButton(self.tr("Create new view"))
|
|
884
|
+
out_row.addWidget(self.rb_out_overwrite)
|
|
885
|
+
out_row.addWidget(self.rb_out_new)
|
|
886
|
+
out_row.addStretch(1)
|
|
887
|
+
left_col.addWidget(out_grp)
|
|
888
|
+
|
|
889
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
890
|
+
# Mode (single expression vs per-channel)
|
|
891
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
892
|
+
mode_row = QHBoxLayout()
|
|
893
|
+
self.rb_single = QRadioButton(self.tr("Single Expression")); self.rb_single.setChecked(True)
|
|
894
|
+
self.rb_sep = QRadioButton(self.tr("Separate (R / G / B)"))
|
|
895
|
+
mode_row.addWidget(self.rb_single)
|
|
896
|
+
mode_row.addWidget(self.rb_sep)
|
|
897
|
+
mode_row.addStretch(1)
|
|
898
|
+
left_col.addLayout(mode_row)
|
|
899
|
+
|
|
900
|
+
self.mode_group = QButtonGroup(self)
|
|
901
|
+
self.mode_group.setExclusive(True)
|
|
902
|
+
self.mode_group.addButton(self.rb_single)
|
|
903
|
+
self.mode_group.addButton(self.rb_sep)
|
|
904
|
+
|
|
905
|
+
# Editors
|
|
906
|
+
self.ed_single = QPlainTextEdit()
|
|
907
|
+
self.ed_single.setPlaceholderText(self.tr("e.g. (img + otherView) / 2"))
|
|
908
|
+
left_col.addWidget(self.ed_single)
|
|
909
|
+
|
|
910
|
+
self.tabs = QTabWidget(); self.tabs.setVisible(False)
|
|
911
|
+
self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
|
|
912
|
+
for ed, name in ((self.ed_r, self.tr("Red")), (self.ed_g, self.tr("Green")), (self.ed_b, self.tr("Blue"))):
|
|
913
|
+
w = QWidget(); lay = QVBoxLayout(w); lay.addWidget(ed); self.tabs.addTab(w, name)
|
|
914
|
+
left_col.addWidget(self.tabs)
|
|
915
|
+
|
|
916
|
+
self.rb_single.toggled.connect(lambda on: self._mode(on))
|
|
917
|
+
|
|
918
|
+
glossary_btn = QPushButton(self.tr("Glossary…"))
|
|
919
|
+
glossary_btn.clicked.connect(self._open_glossary)
|
|
920
|
+
left_col.addWidget(glossary_btn)
|
|
921
|
+
|
|
922
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
923
|
+
# Preview (right side)
|
|
924
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
925
|
+
preview_grp = QGroupBox(self.tr("Preview"))
|
|
926
|
+
pv_lay = QVBoxLayout(preview_grp)
|
|
927
|
+
|
|
928
|
+
# Toolbar
|
|
929
|
+
tb = QHBoxLayout()
|
|
930
|
+
self.btn_preview = QPushButton(self.tr("Preview"))
|
|
931
|
+
self.btn_preview.setToolTip(self.tr("Compute Pixel Math and show the result here without committing."))
|
|
932
|
+
|
|
933
|
+
# NEW: Auto-stretch toggle with a dropdown menu
|
|
934
|
+
self.btn_autostretch = QToolButton()
|
|
935
|
+
self.btn_autostretch.setText(self.tr("Auto-stretch"))
|
|
936
|
+
self.btn_autostretch.setCheckable(True)
|
|
937
|
+
self.btn_autostretch.setChecked(self._as_enabled)
|
|
938
|
+
self.btn_autostretch.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
|
|
939
|
+
|
|
940
|
+
as_menu = QMenu(self)
|
|
941
|
+
act_toggle = as_menu.addAction(self.tr("Enable Auto-stretch"))
|
|
942
|
+
act_toggle.setCheckable(True); act_toggle.setChecked(self._as_enabled)
|
|
943
|
+
as_menu.addSeparator()
|
|
944
|
+
|
|
945
|
+
# Target presets
|
|
946
|
+
tgt_menu = as_menu.addMenu(self.tr("Target median"))
|
|
947
|
+
self._tgt_group = QActionGroup(self); self._tgt_group.setExclusive(True)
|
|
948
|
+
for label, val in ((self.tr("0.18 (soft)"), 0.18), (self.tr("0.25 (default)"), 0.25), (self.tr("0.35 (brighter)"), 0.35)):
|
|
949
|
+
a = tgt_menu.addAction(label)
|
|
950
|
+
a.setCheckable(True)
|
|
951
|
+
a.setChecked(abs(self._as_target - val) < 1e-6)
|
|
952
|
+
self._tgt_group.addAction(a)
|
|
953
|
+
a.triggered.connect(lambda _=False, v=val: self._set_as_target(v))
|
|
954
|
+
|
|
955
|
+
# Sigma presets
|
|
956
|
+
sig_menu = as_menu.addMenu(self.tr("Black-point sigma"))
|
|
957
|
+
self._sig_group = QActionGroup(self); self._sig_group.setExclusive(True)
|
|
958
|
+
for label, val in ((self.tr("σ=2.5"), 2.5), (self.tr("σ=3 (default)"), 3.0), (self.tr("σ=4 (deeper black)"), 4.0)):
|
|
959
|
+
a = sig_menu.addAction(label)
|
|
960
|
+
a.setCheckable(True)
|
|
961
|
+
a.setChecked(abs(self._as_sigma - val) < 1e-6)
|
|
962
|
+
self._sig_group.addAction(a)
|
|
963
|
+
a.triggered.connect(lambda _=False, v=val: self._set_as_sigma(v))
|
|
964
|
+
|
|
965
|
+
# Linked channels
|
|
966
|
+
act_linked = as_menu.addAction(self.tr("Linked channels (use luminance)"))
|
|
967
|
+
act_linked.setCheckable(True); act_linked.setChecked(self._as_linked)
|
|
968
|
+
as_menu.addSeparator()
|
|
969
|
+
|
|
970
|
+
# Output precision
|
|
971
|
+
act_16 = as_menu.addAction(self.tr("Use 16-bit stats"))
|
|
972
|
+
act_16.setCheckable(True); act_16.setChecked(self._as_16bit)
|
|
973
|
+
|
|
974
|
+
self.btn_autostretch.setMenu(as_menu)
|
|
975
|
+
|
|
976
|
+
# Keep toggle and menu in sync
|
|
977
|
+
def _apply_menu_state():
|
|
978
|
+
self._as_enabled = self.btn_autostretch.isChecked()
|
|
979
|
+
act_toggle.setChecked(self._as_enabled)
|
|
980
|
+
self._save_autostretch_prefs()
|
|
981
|
+
self._rerun_preview_if_any()
|
|
982
|
+
|
|
983
|
+
self.btn_autostretch.toggled.connect(_apply_menu_state)
|
|
984
|
+
|
|
985
|
+
act_toggle.toggled.connect(lambda on: (self.btn_autostretch.setChecked(on),
|
|
986
|
+
self._save_autostretch_prefs(),
|
|
987
|
+
self._rerun_preview_if_any()))
|
|
988
|
+
|
|
989
|
+
act_linked.toggled.connect(lambda on: (setattr(self, "_as_linked", on),
|
|
990
|
+
self._save_autostretch_prefs(),
|
|
991
|
+
self._rerun_preview_if_any()))
|
|
992
|
+
|
|
993
|
+
act_16.toggled.connect( lambda on: (setattr(self, "_as_16bit", on),
|
|
994
|
+
self._save_autostretch_prefs(),
|
|
995
|
+
self._rerun_preview_if_any()))
|
|
996
|
+
|
|
997
|
+
# tiny helpers for radio menus
|
|
998
|
+
def _refresh_target_checks():
|
|
999
|
+
for a in self._tgt_group.actions():
|
|
1000
|
+
txt = a.text() # "0.18 (soft)" / "0.25 (default)" / "0.35 (brighter)"
|
|
1001
|
+
v = 0.18 if txt.startswith("0.18") else 0.25 if txt.startswith("0.25") else 0.35
|
|
1002
|
+
a.setChecked(abs(self._as_target - v) < 1e-6)
|
|
1003
|
+
|
|
1004
|
+
def _refresh_sigma_checks():
|
|
1005
|
+
for a in self._sig_group.actions():
|
|
1006
|
+
# texts are "σ=2.5", "σ=3 (default)", "σ=4 (deeper black)"
|
|
1007
|
+
tail = a.text().split("=", 1)[-1].strip().split()[0] # "2.5" / "3" / "4"
|
|
1008
|
+
v = float(tail)
|
|
1009
|
+
a.setChecked(abs(self._as_sigma - v) < 1e-6)
|
|
1010
|
+
|
|
1011
|
+
# API used by the actions above
|
|
1012
|
+
def _set_target(v: float):
|
|
1013
|
+
self._as_target = float(v)
|
|
1014
|
+
self._save_autostretch_prefs()
|
|
1015
|
+
_refresh_target_checks()
|
|
1016
|
+
self._rerun_preview_if_any()
|
|
1017
|
+
|
|
1018
|
+
def _set_sigma(v: float):
|
|
1019
|
+
self._as_sigma = float(v)
|
|
1020
|
+
self._save_autostretch_prefs()
|
|
1021
|
+
_refresh_sigma_checks()
|
|
1022
|
+
self._rerun_preview_if_any()
|
|
1023
|
+
|
|
1024
|
+
self._set_as_target = _set_target
|
|
1025
|
+
self._set_as_sigma = _set_sigma
|
|
1026
|
+
|
|
1027
|
+
# existing zoom buttons
|
|
1028
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
|
|
1029
|
+
self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
|
|
1030
|
+
self.btn_zoom_1_1 = themed_toolbtn("zoom-original", self.tr("1:1"))
|
|
1031
|
+
self.btn_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
|
|
1032
|
+
|
|
1033
|
+
tb.addWidget(self.btn_preview)
|
|
1034
|
+
tb.addWidget(self.btn_autostretch) # ← NEW
|
|
1035
|
+
tb.addSpacing(12)
|
|
1036
|
+
tb.addWidget(self.btn_zoom_in); tb.addWidget(self.btn_zoom_out)
|
|
1037
|
+
tb.addWidget(self.btn_zoom_1_1); tb.addWidget(self.btn_fit)
|
|
1038
|
+
tb.addStretch(1)
|
|
1039
|
+
pv_lay.addLayout(tb)
|
|
1040
|
+
|
|
1041
|
+
# Graphics view
|
|
1042
|
+
self.preview_view = _PreviewView() # <-- was QGraphicsView()
|
|
1043
|
+
self.preview_view.setRenderHints(self.preview_view.renderHints())
|
|
1044
|
+
self.preview_scene = QGraphicsScene(self.preview_view)
|
|
1045
|
+
self.preview_view.setScene(self.preview_scene)
|
|
1046
|
+
self._preview_item: QGraphicsPixmapItem | None = None
|
|
1047
|
+
self._preview_zoom = 1.0 # keep if you like; the view tracks its own zoom too
|
|
1048
|
+
pv_lay.addWidget(self.preview_view, 1)
|
|
1049
|
+
|
|
1050
|
+
right_col.addWidget(preview_grp, 1)
|
|
1051
|
+
|
|
1052
|
+
# Wire up preview actions
|
|
1053
|
+
self.btn_preview.clicked.connect(self._do_preview)
|
|
1054
|
+
self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
|
|
1055
|
+
self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
|
|
1056
|
+
self.btn_zoom_1_1.clicked.connect(self._zoom_reset_1_1)
|
|
1057
|
+
self.btn_fit.clicked.connect(self._fit_to_view)
|
|
1058
|
+
|
|
1059
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1060
|
+
# Examples (insertable templates)
|
|
1061
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1062
|
+
ex_row = QHBoxLayout()
|
|
1063
|
+
ex_row.addWidget(QLabel(self.tr("Examples:")))
|
|
1064
|
+
self.cb_examples = QComboBox()
|
|
1065
|
+
self.cb_examples.addItem(self.tr("Insert example…"))
|
|
1066
|
+
for title, kind, payload in self._examples_list():
|
|
1067
|
+
self.cb_examples.addItem(title, (kind, payload))
|
|
1068
|
+
self.cb_examples.currentIndexChanged.connect(self._apply_example_from_combo)
|
|
1069
|
+
ex_row.addWidget(self.cb_examples, 1)
|
|
1070
|
+
left_col.addLayout(ex_row)
|
|
1071
|
+
|
|
1072
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1073
|
+
# Favorites
|
|
1074
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1075
|
+
fav_row = QHBoxLayout()
|
|
1076
|
+
self.cb_fav = QComboBox(); self.cb_fav.addItem(self.tr("Select a favorite expression"))
|
|
1077
|
+
self._load_favorites()
|
|
1078
|
+
self.cb_fav.currentTextChanged.connect(self._pick_favorite)
|
|
1079
|
+
|
|
1080
|
+
b_save = QPushButton(self.tr("Save as Favorite"))
|
|
1081
|
+
b_del = QPushButton(self.tr("Delete Favorite"))
|
|
1082
|
+
|
|
1083
|
+
b_save.clicked.connect(self._save_favorite)
|
|
1084
|
+
b_del.clicked.connect(self._delete_favorite)
|
|
1085
|
+
|
|
1086
|
+
fav_row.addWidget(self.cb_fav, 1)
|
|
1087
|
+
fav_row.addWidget(b_save)
|
|
1088
|
+
fav_row.addWidget(b_del)
|
|
1089
|
+
left_col.addLayout(fav_row)
|
|
1090
|
+
|
|
1091
|
+
def _fav_context_menu(point):
|
|
1092
|
+
if self.cb_fav.currentIndex() <= 0:
|
|
1093
|
+
return
|
|
1094
|
+
menu = QMenu(self)
|
|
1095
|
+
act_del = menu.addAction(self.tr("Delete this favorite"))
|
|
1096
|
+
act = menu.exec(self.cb_fav.mapToGlobal(point))
|
|
1097
|
+
if act == act_del:
|
|
1098
|
+
self._delete_favorite()
|
|
1099
|
+
|
|
1100
|
+
self.cb_fav.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
1101
|
+
self.cb_fav.customContextMenuRequested.connect(_fav_context_menu)
|
|
1102
|
+
|
|
1103
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1104
|
+
# Buttons + Help (left)
|
|
1105
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1106
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
|
|
1107
|
+
btns.accepted.connect(self._apply)
|
|
1108
|
+
btns.rejected.connect(self.reject)
|
|
1109
|
+
b_help = btns.addButton(self.tr("Help"), QDialogButtonBox.ButtonRole.HelpRole)
|
|
1110
|
+
b_help.clicked.connect(self._help)
|
|
1111
|
+
left_col.addWidget(btns)
|
|
1112
|
+
|
|
1113
|
+
# Output group selection model
|
|
1114
|
+
self.out_group = QButtonGroup(self)
|
|
1115
|
+
self.out_group.setExclusive(True)
|
|
1116
|
+
self.out_group.addButton(self.rb_out_overwrite)
|
|
1117
|
+
self.out_group.addButton(self.rb_out_new)
|
|
1118
|
+
|
|
1119
|
+
# Initialize editor visibility
|
|
1120
|
+
QTimer.singleShot(0, lambda: self._mode(self.rb_single.isChecked()))
|
|
1121
|
+
|
|
1122
|
+
# A little wider to favor the preview
|
|
1123
|
+
self.resize(940, 700)
|
|
1124
|
+
QTimer.singleShot(0, lambda: self._splitter.setSizes([320, max(620, self.width() - 320)]))
|
|
1125
|
+
|
|
1126
|
+
# ─────────────── Auto-stretch prefs ───────────────
|
|
1127
|
+
def _as_settings(self):
|
|
1128
|
+
p = self.parent()
|
|
1129
|
+
return getattr(p, "settings", None)
|
|
1130
|
+
|
|
1131
|
+
def _load_autostretch_prefs(self):
|
|
1132
|
+
s = self._as_settings()
|
|
1133
|
+
self._as_enabled = False
|
|
1134
|
+
self._as_linked = True
|
|
1135
|
+
self._as_target = 0.25
|
|
1136
|
+
self._as_sigma = 3.0
|
|
1137
|
+
self._as_16bit = True
|
|
1138
|
+
if s:
|
|
1139
|
+
self._as_enabled = s.value("pixelmath/preview_autostretch", False, type=bool)
|
|
1140
|
+
self._as_linked = s.value("pixelmath/preview_as_linked", True, type=bool)
|
|
1141
|
+
self._as_target = s.value("pixelmath/preview_as_target", 0.25, type=float)
|
|
1142
|
+
self._as_sigma = s.value("pixelmath/preview_as_sigma", 3.0, type=float)
|
|
1143
|
+
self._as_16bit = s.value("pixelmath/preview_as_16bit", True, type=bool)
|
|
1144
|
+
|
|
1145
|
+
def _save_autostretch_prefs(self):
|
|
1146
|
+
s = self._as_settings()
|
|
1147
|
+
if not s: return
|
|
1148
|
+
s.setValue("pixelmath/preview_autostretch", self._as_enabled)
|
|
1149
|
+
s.setValue("pixelmath/preview_as_linked", self._as_linked)
|
|
1150
|
+
s.setValue("pixelmath/preview_as_target", float(self._as_target))
|
|
1151
|
+
s.setValue("pixelmath/preview_as_sigma", float(self._as_sigma))
|
|
1152
|
+
s.setValue("pixelmath/preview_as_16bit", self._as_16bit)
|
|
1153
|
+
|
|
1154
|
+
def _rerun_preview_if_any(self):
|
|
1155
|
+
# If we already showed something, recompute so user sees the change immediately
|
|
1156
|
+
if self._preview_item is not None:
|
|
1157
|
+
QTimer.singleShot(0, self._do_preview)
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
# ---------- Preview helpers ------------------------------------------------
|
|
1161
|
+
def _do_preview(self):
|
|
1162
|
+
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
|
|
1163
|
+
try:
|
|
1164
|
+
if self.rb_single.isChecked():
|
|
1165
|
+
out = self.ev.eval_single(self.ed_single.toPlainText().strip())
|
|
1166
|
+
else:
|
|
1167
|
+
out = self.ev.eval_rgb(
|
|
1168
|
+
self.ed_r.toPlainText().strip(),
|
|
1169
|
+
self.ed_g.toPlainText().strip(),
|
|
1170
|
+
self.ed_b.toPlainText().strip(),
|
|
1171
|
+
default_channels=(0, 1, 2)
|
|
1172
|
+
)
|
|
1173
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1174
|
+
|
|
1175
|
+
# NEW: optional auto-stretch (preview only)
|
|
1176
|
+
if getattr(self, "_as_enabled", False):
|
|
1177
|
+
out = autostretch(
|
|
1178
|
+
out,
|
|
1179
|
+
target_median=float(getattr(self, "_as_target", 0.25)),
|
|
1180
|
+
linked=bool(getattr(self, "_as_linked", True)),
|
|
1181
|
+
sigma=float(getattr(self, "_as_sigma", 3.0)),
|
|
1182
|
+
use_16bit=bool(getattr(self, "_as_16bit", True)),
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
self._set_preview_image(out)
|
|
1186
|
+
if self._preview_item is not None and abs(self._preview_zoom - 1.0) < 1e-6:
|
|
1187
|
+
self._fit_to_view()
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
msg = str(e)
|
|
1190
|
+
if "name '" in msg and "' is not defined" in msg:
|
|
1191
|
+
msg += self.tr("\n\nTip: use the identifier listed in Variables (or the raw title; it’s auto-mapped).")
|
|
1192
|
+
QMessageBox.critical(self, self.tr("Pixel Math Preview"), self.tr("Failed:\n{0}").format(msg))
|
|
1193
|
+
finally:
|
|
1194
|
+
QApplication.restoreOverrideCursor()
|
|
1195
|
+
|
|
1196
|
+
def _set_preview_image(self, img: np.ndarray):
|
|
1197
|
+
"""Render numpy float image into the preview scene, preserving zoom/pan."""
|
|
1198
|
+
qim = _float_to_qimage_rgb8(img)
|
|
1199
|
+
pm = QPixmap.fromImage(qim)
|
|
1200
|
+
|
|
1201
|
+
# --- capture current view state (before we clear/replace) ---
|
|
1202
|
+
view = self.preview_view
|
|
1203
|
+
old_transform = QTransform(view.transform())
|
|
1204
|
+
old_zoom = getattr(view, "_zoom", 1.0)
|
|
1205
|
+
|
|
1206
|
+
old_center_norm = None
|
|
1207
|
+
if self._preview_item is not None:
|
|
1208
|
+
# current center in scene coords → normalize to old image size
|
|
1209
|
+
center_scene = view.mapToScene(view.viewport().rect().center())
|
|
1210
|
+
old_pix = self._preview_item.pixmap()
|
|
1211
|
+
ow, oh = float(old_pix.width()), float(old_pix.height())
|
|
1212
|
+
if ow > 0 and oh > 0:
|
|
1213
|
+
old_center_norm = QPointF(center_scene.x() / ow, center_scene.y() / oh)
|
|
1214
|
+
|
|
1215
|
+
# --- replace scene content ---
|
|
1216
|
+
self.preview_scene.clear()
|
|
1217
|
+
self._preview_item = self.preview_scene.addPixmap(pm)
|
|
1218
|
+
self.preview_scene.setSceneRect(self._preview_item.boundingRect())
|
|
1219
|
+
|
|
1220
|
+
# --- restore transform and center ---
|
|
1221
|
+
# (don’t call zoom_reset / fit here—respect user’s current view)
|
|
1222
|
+
view.setTransform(old_transform)
|
|
1223
|
+
view._zoom = float(old_zoom)
|
|
1224
|
+
self._preview_zoom = float(old_zoom)
|
|
1225
|
+
|
|
1226
|
+
if old_center_norm is not None:
|
|
1227
|
+
nw, nh = float(pm.width()), float(pm.height())
|
|
1228
|
+
new_center = QPointF(old_center_norm.x() * nw, old_center_norm.y() * nh)
|
|
1229
|
+
view.centerOn(new_center)
|
|
1230
|
+
|
|
1231
|
+
self.preview_view.viewport().update()
|
|
1232
|
+
|
|
1233
|
+
def _zoom_by(self, factor: float):
|
|
1234
|
+
if self._preview_item is None:
|
|
1235
|
+
return
|
|
1236
|
+
self.preview_view.zoom_by(float(factor))
|
|
1237
|
+
# mirror into our logical tracker (optional)
|
|
1238
|
+
self._preview_zoom = self.preview_view._zoom
|
|
1239
|
+
|
|
1240
|
+
def _zoom_reset_1_1(self):
|
|
1241
|
+
if self._preview_item is None:
|
|
1242
|
+
return
|
|
1243
|
+
self.preview_view.zoom_reset()
|
|
1244
|
+
self._preview_zoom = 1.0
|
|
1245
|
+
|
|
1246
|
+
def _fit_to_view(self):
|
|
1247
|
+
if self._preview_item is None:
|
|
1248
|
+
return
|
|
1249
|
+
# Fit the item, then record logical zoom as 1.0 (we treat "fit" as baseline)
|
|
1250
|
+
self.preview_view.fitInView(self._preview_item, Qt.AspectRatioMode.KeepAspectRatio)
|
|
1251
|
+
self.preview_view._zoom = 1.0
|
|
1252
|
+
self._preview_zoom = 1.0
|
|
1253
|
+
|
|
1254
|
+
# ---------- examples -------------------------------------------------------
|
|
1255
|
+
def _examples_list(self):
|
|
1256
|
+
a = "img"
|
|
1257
|
+
others = [ident for (_, ident) in self.ev.title_map if ident != a]
|
|
1258
|
+
b = others[0] if others else a
|
|
1259
|
+
c = others[1] if len(others) > 1 else a
|
|
1260
|
+
|
|
1261
|
+
return [
|
|
1262
|
+
# --- existing basics ---
|
|
1263
|
+
(self.tr("Average two views"), "single", f"({a} + {b}) / 2"),
|
|
1264
|
+
(self.tr("Difference (A - B)"), "single", f"{a} - {b}"),
|
|
1265
|
+
(self.tr("Invert active"), "single", f"~{a}"),
|
|
1266
|
+
(self.tr("Subtract median (bias remove)"), "single", f"{a} - med({a})"),
|
|
1267
|
+
(self.tr("Zero-center by mean"), "single", f"{a} - mean({a})"),
|
|
1268
|
+
(self.tr("Min + Max combine"), "single", f"min({a}) + max({a})"),
|
|
1269
|
+
(self.tr("Log transform"), "single", f"log({a} + 1e-6)"),
|
|
1270
|
+
(self.tr("Midtones transform m=0.25"), "single", f"mtf({a}, 0.25)"),
|
|
1271
|
+
(self.tr("If darker than median → 0 else 1"), "single", f"iff({a} < med({a}), 0, 1)"),
|
|
1272
|
+
|
|
1273
|
+
(self.tr("Per-channel: swap R↔B"), "rgb", (f"{a}[2]", f"{a}[1]", f"{a}[0]")),
|
|
1274
|
+
(self.tr("Per-channel: avg A & B"), "rgb", (f"({a}[0]+{b}[0])/2", f"({a}[1]+{b}[1])/2", f"({a}[2]+{b}[2])/2")),
|
|
1275
|
+
(self.tr("Per-channel: build RGB from A,B,C"), "rgb", (f"{a}[0]", f"{b}[1]", f"{c}[2]")),
|
|
1276
|
+
|
|
1277
|
+
# --- new, single-expression tone/normalization ---
|
|
1278
|
+
(self.tr("Normalize to 0–1 (per-channel)"), "single", f"normalize01({a})"),
|
|
1279
|
+
(self.tr("Sigmoid contrast (k=12, mid=0.4)"), "single", f"sigmoid({a}, k=12, mid=0.4)"),
|
|
1280
|
+
(self.tr("Gamma 0.6 (brighten midtones)"), "single", f"gamma({a}, 0.6)"),
|
|
1281
|
+
(self.tr("Percentile stretch 0.5–99.5%"), "single",
|
|
1282
|
+
f"lo = percentile({a}, 0.5)\nhi = percentile({a}, 99.5)\nclamp(({a} - lo) / (hi - lo), 0, 1)"),
|
|
1283
|
+
|
|
1284
|
+
# --- blending & masking ---
|
|
1285
|
+
(self.tr("Blend A→B by horizontal gradient X"), "single", f"t = X\nlerp({a}, {b}, t)"),
|
|
1286
|
+
(self.tr("Apply active mask to blend A→B"), "single", f"m = mask()\napply_mask({a}, {b}, m)"),
|
|
1287
|
+
|
|
1288
|
+
# --- sharpening with mask (multiline) ---
|
|
1289
|
+
(self.tr("Masked unsharp (luma-based)"), "single",
|
|
1290
|
+
f"base = {a}\nsh = unsharp({a}, sigma=1.2, amount=0.8)\n"
|
|
1291
|
+
f"m = smoothstep(0.10, 0.60, luma({a}))\napply_mask(base, sh, m)"),
|
|
1292
|
+
|
|
1293
|
+
# --- view matching / calibration ---
|
|
1294
|
+
(self.tr("Match medians of A to B"), "single", f"{a} * (med({b}) / med({a}))"),
|
|
1295
|
+
|
|
1296
|
+
# --- small filters ---
|
|
1297
|
+
(self.tr("Gaussian blur σ=2"), "single", f"gauss({a}, sigma=2.0)"),
|
|
1298
|
+
(self.tr("Median filter k=3"), "single", f"median({a}, k=3)"),
|
|
1299
|
+
|
|
1300
|
+
# --- per-channel examples using new helpers ---
|
|
1301
|
+
(self.tr("Per-channel: luma to all channels"), "rgb", (f"luma({a})", f"luma({a})", f"luma({a})")),
|
|
1302
|
+
(self.tr("Per-channel: A’s R, B’s G, C’s B (normed)"), "rgb",
|
|
1303
|
+
(f"normalize01({a}[0])", f"normalize01({b}[1])", f"normalize01({c}[2])")),
|
|
1304
|
+
]
|
|
1305
|
+
|
|
1306
|
+
def _function_glossary(self):
|
|
1307
|
+
# name -> (signature / template, short description)
|
|
1308
|
+
return {
|
|
1309
|
+
"clamp": ("clamp(x, lo=0, hi=1)", self.tr("Limit values to [lo..hi].")),
|
|
1310
|
+
"rescale": ("rescale(x, a, b, lo=0, hi=1)", self.tr("Map range [a..b] to [lo..hi].")),
|
|
1311
|
+
"gamma": ("gamma(x, g)", self.tr("Apply gamma curve.")),
|
|
1312
|
+
"pow_safe": ("pow_safe(x, p)", self.tr("Power with EPS floor.")),
|
|
1313
|
+
"absf": ("absf(x)", self.tr("Absolute value.")),
|
|
1314
|
+
"expf": ("expf(x)", self.tr("Exponential.")),
|
|
1315
|
+
"sqrtf": ("sqrtf(x)", self.tr("Square root (clamped to ≥0).")),
|
|
1316
|
+
"arcsin": ("arcsin(x)", self.tr("Inverse sine (radians), input clipped to [-1,1].")),
|
|
1317
|
+
"sigmoid": ("sigmoid(x, k=10, mid=0.5)", self.tr("S-shaped tone curve.")),
|
|
1318
|
+
"smoothstep": ("smoothstep(e0, e1, x)", self.tr("Cubic smooth ramp.")),
|
|
1319
|
+
"lerp/mix": ("lerp(a, b, t)", self.tr("Linear blend.")),
|
|
1320
|
+
"percentile": ("percentile(x, p)", self.tr("Per-channel percentile image.")),
|
|
1321
|
+
"normalize01": ("normalize01(x)", self.tr("Per-channel [0..1] normalization.")),
|
|
1322
|
+
"zscore": ("zscore(x)", self.tr("Per-channel (x-mean)/std.")),
|
|
1323
|
+
"ch": ("ch(x, i)", self.tr("Extract channel i (0/1/2) as 2-D.")),
|
|
1324
|
+
"luma": ("luma(x)", self.tr("Rec.709 luminance as 2-D.")),
|
|
1325
|
+
"compose": ("compose(R, G, B)", self.tr("Stack three planes to RGB.")),
|
|
1326
|
+
"mask": ("m = mask()", self.tr("Active mask (2-D, [0..1]).")),
|
|
1327
|
+
"apply_mask": ("apply_mask(base, out, m)", self.tr("Blend by mask.")),
|
|
1328
|
+
"boxblur": ("boxblur(x, k=3)", self.tr("Box blur (cv2 if available).")),
|
|
1329
|
+
"gauss": ("gauss(x, sigma=1.0)", self.tr("Gaussian blur.")),
|
|
1330
|
+
"median": ("median(x, k=3)", self.tr("Median filter (cv2 if avail).")),
|
|
1331
|
+
"unsharp": ("unsharp(x, sigma=1.5, amount=1.0)", self.tr("Unsharp mask.")),
|
|
1332
|
+
"mtf": ("mtf(x, m)", self.tr("Midtones transfer (existing).")),
|
|
1333
|
+
"iff": ("iff(cond, a, b)", self.tr("Conditional (existing).")),
|
|
1334
|
+
"X / Y": ("X, Y", self.tr("Normalized coordinates in [0..1].")),
|
|
1335
|
+
"H/W/C": ("H, W, C, shape", self.tr("Image dimensions.")),
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
def _open_glossary(self):
|
|
1339
|
+
dlg = QDialog(self)
|
|
1340
|
+
dlg.setWindowTitle(self.tr("Pixel Math Glossary"))
|
|
1341
|
+
lay = QVBoxLayout(dlg)
|
|
1342
|
+
|
|
1343
|
+
info = QLabel(self.tr("Double-click to insert a template at the cursor."))
|
|
1344
|
+
info.setStyleSheet("color: gray;")
|
|
1345
|
+
lay.addWidget(info)
|
|
1346
|
+
|
|
1347
|
+
from PyQt6.QtWidgets import QLineEdit, QListWidget, QListWidgetItem, QHBoxLayout, QPushButton
|
|
1348
|
+
search = QLineEdit()
|
|
1349
|
+
search.setPlaceholderText(self.tr("Search…"))
|
|
1350
|
+
lay.addWidget(search)
|
|
1351
|
+
|
|
1352
|
+
lst = QListWidget()
|
|
1353
|
+
lst.setMinimumHeight(220)
|
|
1354
|
+
lay.addWidget(lst, 1)
|
|
1355
|
+
|
|
1356
|
+
# fill
|
|
1357
|
+
gl = self._function_glossary()
|
|
1358
|
+
def _refill():
|
|
1359
|
+
q = search.text().strip().lower()
|
|
1360
|
+
lst.clear()
|
|
1361
|
+
for name, (sig, desc) in gl.items():
|
|
1362
|
+
if not q or q in name.lower() or q in sig.lower() or q in desc.lower():
|
|
1363
|
+
item = QListWidgetItem(f"{sig} — {desc}")
|
|
1364
|
+
item.setData(Qt.ItemDataRole.UserRole, sig)
|
|
1365
|
+
lst.addItem(item)
|
|
1366
|
+
_refill()
|
|
1367
|
+
|
|
1368
|
+
def _insert_current():
|
|
1369
|
+
item = lst.currentItem()
|
|
1370
|
+
if not item: return
|
|
1371
|
+
sig = item.data(Qt.ItemDataRole.UserRole) or ""
|
|
1372
|
+
ed = self.ed_single if self.rb_single.isChecked() else (self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b)
|
|
1373
|
+
ed.insertPlainText(sig)
|
|
1374
|
+
|
|
1375
|
+
lst.itemDoubleClicked.connect(lambda *_: (_insert_current(), None))
|
|
1376
|
+
search.textChanged.connect(lambda *_: _refill())
|
|
1377
|
+
|
|
1378
|
+
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
|
1379
|
+
insert_btn = QPushButton(self.tr("Insert"))
|
|
1380
|
+
btns.addButton(insert_btn, QDialogButtonBox.ButtonRole.ApplyRole)
|
|
1381
|
+
insert_btn.clicked.connect(_insert_current)
|
|
1382
|
+
btns.rejected.connect(dlg.reject)
|
|
1383
|
+
lay.addWidget(btns)
|
|
1384
|
+
|
|
1385
|
+
dlg.resize(620, 400)
|
|
1386
|
+
dlg.exec()
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _delete_favorite(self):
|
|
1390
|
+
text = self.cb_fav.currentText()
|
|
1391
|
+
if text == self.tr("Select a favorite expression"):
|
|
1392
|
+
return
|
|
1393
|
+
# Remove from in-memory list
|
|
1394
|
+
try:
|
|
1395
|
+
idx_in_list = self._favs.index(text)
|
|
1396
|
+
except ValueError:
|
|
1397
|
+
return
|
|
1398
|
+
|
|
1399
|
+
self._favs.pop(idx_in_list)
|
|
1400
|
+
|
|
1401
|
+
# Rebuild combo to keep indices clean
|
|
1402
|
+
self.cb_fav.blockSignals(True)
|
|
1403
|
+
self.cb_fav.clear()
|
|
1404
|
+
self.cb_fav.addItem(self.tr("Select a favorite expression"))
|
|
1405
|
+
for f in self._favs:
|
|
1406
|
+
self.cb_fav.addItem(f)
|
|
1407
|
+
self.cb_fav.setCurrentIndex(0)
|
|
1408
|
+
self.cb_fav.blockSignals(False)
|
|
1409
|
+
|
|
1410
|
+
# Persist
|
|
1411
|
+
s = self._settings()
|
|
1412
|
+
if s:
|
|
1413
|
+
s.setValue("pixelmath_favorites", json.dumps(self._favs))
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _apply_example_from_combo(self, idx: int):
|
|
1417
|
+
if idx <= 0: # "Insert example…"
|
|
1418
|
+
return
|
|
1419
|
+
kind, payload = self.cb_examples.currentData()
|
|
1420
|
+
# Switch mode first, then inject text on the next event loop tick to avoid any race with toggled()
|
|
1421
|
+
if kind == "single":
|
|
1422
|
+
self.rb_single.setChecked(True)
|
|
1423
|
+
def set_text():
|
|
1424
|
+
self._mode(True)
|
|
1425
|
+
self.ed_single.setPlainText(str(payload))
|
|
1426
|
+
QTimer.singleShot(0, set_text)
|
|
1427
|
+
else:
|
|
1428
|
+
self.rb_sep.setChecked(True)
|
|
1429
|
+
def set_text_rgb():
|
|
1430
|
+
self._mode(False)
|
|
1431
|
+
r, g, b = payload
|
|
1432
|
+
self.ed_r.setPlainText(r)
|
|
1433
|
+
self.ed_g.setPlainText(g)
|
|
1434
|
+
self.ed_b.setPlainText(b)
|
|
1435
|
+
QTimer.singleShot(0, set_text_rgb)
|
|
1436
|
+
# reset the combo back to the prompt so it can be used repeatedly
|
|
1437
|
+
QTimer.singleShot(0, lambda: self.cb_examples.setCurrentIndex(0))
|
|
1438
|
+
|
|
1439
|
+
# ---------- favorites ------------------------------------------------------
|
|
1440
|
+
def _settings(self):
|
|
1441
|
+
p = self.parent(); return getattr(p, "settings", None)
|
|
1442
|
+
|
|
1443
|
+
def _load_favorites(self):
|
|
1444
|
+
self._favs = []
|
|
1445
|
+
s = self._settings()
|
|
1446
|
+
if s:
|
|
1447
|
+
raw = s.value("pixelmath_favorites", "", type=str) or ""
|
|
1448
|
+
try: self._favs = json.loads(raw) if raw else []
|
|
1449
|
+
except Exception: self._favs = []
|
|
1450
|
+
for f in self._favs: self.cb_fav.addItem(f)
|
|
1451
|
+
|
|
1452
|
+
def _save_favorite(self):
|
|
1453
|
+
if self.rb_single.isChecked():
|
|
1454
|
+
expr = self.ed_single.toPlainText().strip()
|
|
1455
|
+
else:
|
|
1456
|
+
expr = f"[R]{self.ed_r.toPlainText().strip()} | [G]{self.ed_g.toPlainText().strip()} | [B]{self.ed_b.toPlainText().strip()}"
|
|
1457
|
+
if not expr or expr in self._favs: return
|
|
1458
|
+
self._favs.append(expr); self.cb_fav.addItem(expr)
|
|
1459
|
+
s = self._settings()
|
|
1460
|
+
if s: s.setValue("pixelmath_favorites", json.dumps(self._favs))
|
|
1461
|
+
|
|
1462
|
+
def _pick_favorite(self, text):
|
|
1463
|
+
if text == self.tr("Select a favorite expression"): return
|
|
1464
|
+
if "[R]" in text or "[G]" in text or "[B]" in text:
|
|
1465
|
+
self.rb_sep.setChecked(True); self._mode(False)
|
|
1466
|
+
parts = {}
|
|
1467
|
+
for p in [t.strip() for t in text.split("|") if t.strip()]:
|
|
1468
|
+
parts[p[:3]] = p[3:].strip()
|
|
1469
|
+
self.ed_r.setPlainText(parts.get("[R]", "")); self.ed_g.setPlainText(parts.get("[G]", "")); self.ed_b.setPlainText(parts.get("[B]", ""))
|
|
1470
|
+
else:
|
|
1471
|
+
self.rb_single.setChecked(True); self._mode(True)
|
|
1472
|
+
self.ed_single.setPlainText(text)
|
|
1473
|
+
|
|
1474
|
+
# =============================================================================
|
|
1475
|
+
# New-view delivery helper (used by PixelMathDialogPro)
|
|
1476
|
+
# =============================================================================
|
|
1477
|
+
|
|
1478
|
+
@staticmethod
|
|
1479
|
+
def _deliver_new_view(parent, src_doc, img: np.ndarray, step_name: str = "Pixel Math"):
|
|
1480
|
+
dm = getattr(parent, "doc_manager", None)
|
|
1481
|
+
if dm is None:
|
|
1482
|
+
if hasattr(src_doc, "set_image"):
|
|
1483
|
+
src_doc.set_image(img, step_name=step_name)
|
|
1484
|
+
else:
|
|
1485
|
+
src_doc.image = img
|
|
1486
|
+
return src_doc
|
|
1487
|
+
|
|
1488
|
+
base = src_doc.display_name() if callable(getattr(src_doc, "display_name", None)) else getattr(src_doc, "display_name", "Untitled")
|
|
1489
|
+
base = base if isinstance(base, str) and base else "Untitled"
|
|
1490
|
+
new_title = f"{base} — {step_name}"
|
|
1491
|
+
|
|
1492
|
+
meta = dict(getattr(src_doc, "metadata", {}) or {})
|
|
1493
|
+
meta["step_name"] = step_name
|
|
1494
|
+
|
|
1495
|
+
new_doc = dm.open_array(np.asarray(img, dtype=np.float32), metadata=meta, title=new_title)
|
|
1496
|
+
if hasattr(parent, "_spawn_subwindow_for"):
|
|
1497
|
+
parent._spawn_subwindow_for(new_doc)
|
|
1498
|
+
return new_doc
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
# ---------- UI helpers -----------------------------------------------------
|
|
1502
|
+
def _mode(self, single_on: bool):
|
|
1503
|
+
self.ed_single.setVisible(single_on)
|
|
1504
|
+
self.tabs.setVisible(not single_on)
|
|
1505
|
+
|
|
1506
|
+
def _help(self):
|
|
1507
|
+
gl = self._function_glossary()
|
|
1508
|
+
lines = [
|
|
1509
|
+
self.tr("Operators: + - * / ^(power) ~(invert)"),
|
|
1510
|
+
self.tr("Comparisons: <, == (use inside iff)"),
|
|
1511
|
+
"",
|
|
1512
|
+
self.tr("Variables:"),
|
|
1513
|
+
self.tr(" • img (active) and one per open view (by window title, auto-mapped)."),
|
|
1514
|
+
self.tr(" • Coordinates: X, Y in [0..1]."),
|
|
1515
|
+
self.tr(" • Sizes: H, W, C, shape."),
|
|
1516
|
+
"",
|
|
1517
|
+
self.tr("Per-channel indexing: view[0], view[1], view[2]."),
|
|
1518
|
+
self.tr("Multiline: last line is the result."),
|
|
1519
|
+
self.tr("Output: Overwrite active or Create new view."),
|
|
1520
|
+
"",
|
|
1521
|
+
self.tr("Functions:")
|
|
1522
|
+
]
|
|
1523
|
+
# Pretty column-ish dump
|
|
1524
|
+
for name, (sig, desc) in gl.items():
|
|
1525
|
+
lines.append(f" {sig}\n {desc}")
|
|
1526
|
+
QMessageBox.information(self, self.tr("Pixel Math Help"), "\n".join(lines))
|
|
1527
|
+
|
|
1528
|
+
# ---------- Apply ----------------------------------------------------------
|
|
1529
|
+
# ---------- Apply ----------------------------------------------------------
|
|
1530
|
+
def _apply(self):
|
|
1531
|
+
try:
|
|
1532
|
+
# Capture expressions first so we can store them for replay
|
|
1533
|
+
if self.rb_single.isChecked():
|
|
1534
|
+
mode = "single"
|
|
1535
|
+
expr = self.ed_single.toPlainText().strip()
|
|
1536
|
+
expr_r = ""
|
|
1537
|
+
expr_g = ""
|
|
1538
|
+
expr_b = ""
|
|
1539
|
+
out = self.ev.eval_single(expr)
|
|
1540
|
+
else:
|
|
1541
|
+
mode = "rgb"
|
|
1542
|
+
expr = ""
|
|
1543
|
+
expr_r = self.ed_r.toPlainText().strip()
|
|
1544
|
+
expr_g = self.ed_g.toPlainText().strip()
|
|
1545
|
+
expr_b = self.ed_b.toPlainText().strip()
|
|
1546
|
+
out = self.ev.eval_rgb(
|
|
1547
|
+
expr_r,
|
|
1548
|
+
expr_g,
|
|
1549
|
+
expr_b,
|
|
1550
|
+
default_channels=(0, 1, 2)
|
|
1551
|
+
)
|
|
1552
|
+
|
|
1553
|
+
out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
1554
|
+
|
|
1555
|
+
# Output route
|
|
1556
|
+
if self.rb_out_new.isChecked():
|
|
1557
|
+
self._deliver_new_view(self.parent(), self.doc, out, "Pixel Math")
|
|
1558
|
+
else:
|
|
1559
|
+
if hasattr(self.doc, "set_image"):
|
|
1560
|
+
self.doc.set_image(out, step_name="Pixel Math")
|
|
1561
|
+
elif hasattr(self.doc, "apply_numpy"):
|
|
1562
|
+
self.doc.apply_numpy(out, step_name="Pixel Math")
|
|
1563
|
+
else:
|
|
1564
|
+
self.doc.image = out
|
|
1565
|
+
|
|
1566
|
+
# ── Register as last_headless_command for replay ──────────
|
|
1567
|
+
try:
|
|
1568
|
+
main = self.parent()
|
|
1569
|
+
if main is not None:
|
|
1570
|
+
preset = {
|
|
1571
|
+
"mode": mode,
|
|
1572
|
+
"expr": expr,
|
|
1573
|
+
"expr_r": expr_r,
|
|
1574
|
+
"expr_g": expr_g,
|
|
1575
|
+
"expr_b": expr_b,
|
|
1576
|
+
}
|
|
1577
|
+
payload = {
|
|
1578
|
+
"command_id": "pixel_math",
|
|
1579
|
+
"preset": dict(preset),
|
|
1580
|
+
}
|
|
1581
|
+
setattr(main, "_last_headless_command", payload)
|
|
1582
|
+
|
|
1583
|
+
# optional log
|
|
1584
|
+
try:
|
|
1585
|
+
if hasattr(main, "_log"):
|
|
1586
|
+
if mode == "single" and expr:
|
|
1587
|
+
desc = expr
|
|
1588
|
+
else:
|
|
1589
|
+
desc = f"R:{expr_r} G:{expr_g} B:{expr_b}"
|
|
1590
|
+
main._log(f"[Replay] Registered Pixel Math as last action → {desc}")
|
|
1591
|
+
except Exception:
|
|
1592
|
+
pass
|
|
1593
|
+
except Exception:
|
|
1594
|
+
# don't break apply if replay wiring fails
|
|
1595
|
+
pass
|
|
1596
|
+
# ───────────────────────────────────────────────────────────
|
|
1597
|
+
|
|
1598
|
+
self.accept()
|
|
1599
|
+
except Exception as e:
|
|
1600
|
+
msg = str(e)
|
|
1601
|
+
if "name '" in msg and "' is not defined" in msg:
|
|
1602
|
+
msg += self.tr("\n\nTip: use the identifier shown beside Variables (e.g. 'andromeda_png'), ")
|
|
1603
|
+
msg += self.tr("or just type the raw title; it will be auto-mapped.")
|
|
1604
|
+
QMessageBox.critical(self, "Pixel Math", f"Failed:\n{msg}")
|