setiastrosuitepro 1.6.1__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/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/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +809 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -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 +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -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 +178 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -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 +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +956 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2641 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +745 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/generate_translations.py +2378 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8567 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +443 -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 +361 -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/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/i18n.py +156 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1601 -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 +244 -0
- setiastro/saspro/isophote.py +1179 -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 +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -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 +928 -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 +3826 -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 +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -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 +1413 -0
- setiastro/saspro/ops/settings.py +679 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1070 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2444 -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 +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +477 -0
- setiastro/saspro/rgb_combination.py +207 -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 +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1430 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +18181 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +506 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1716 -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/de_translations.py +3733 -0
- setiastro/saspro/translations/es_translations.py +3923 -0
- setiastro/saspro/translations/fr_translations.py +3842 -0
- setiastro/saspro/translations/integrate_translations.py +234 -0
- setiastro/saspro/translations/it_translations.py +3662 -0
- setiastro/saspro/translations/ja_translations.py +3585 -0
- setiastro/saspro/translations/pt_translations.py +3853 -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_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_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/zh_translations.py +3659 -0
- setiastro/saspro/versioning.py +71 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +658 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -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/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
- setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
- setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from PyQt6.QtCore import Qt, QSize, QPointF, QEvent, QMimeData
|
|
3
|
+
from PyQt6.QtWidgets import (
|
|
4
|
+
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QPushButton, QLabel,
|
|
5
|
+
QScrollArea, QWidget, QMessageBox, QSlider, QListWidgetItem, QApplication
|
|
6
|
+
)
|
|
7
|
+
from PyQt6.QtGui import QImage, QPixmap, QPainter, QMouseEvent, QDrag
|
|
8
|
+
from PyQt6 import sip
|
|
9
|
+
import numpy as np
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from .autostretch import autostretch
|
|
13
|
+
from .dnd_mime import MIME_CMD
|
|
14
|
+
from setiastro.saspro.swap_manager import get_swap_manager
|
|
15
|
+
|
|
16
|
+
from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------- helpers ----------
|
|
20
|
+
def _pack_cmd_payload(command_id: str, preset: dict | None = None) -> bytes:
|
|
21
|
+
return json.dumps({"command_id": command_id, "preset": preset or {}}).encode("utf-8")
|
|
22
|
+
|
|
23
|
+
# Map human step names → command_id used by replay_last_action_on_base
|
|
24
|
+
_NAME_TO_COMMAND_ID = {
|
|
25
|
+
# Background neutralization / WB
|
|
26
|
+
"background neutralization": "background_neutral",
|
|
27
|
+
"background neutralisation": "background_neutral",
|
|
28
|
+
"background neutral": "background_neutral",
|
|
29
|
+
"white balance": "white_balance",
|
|
30
|
+
|
|
31
|
+
# Simple tools with no real preset
|
|
32
|
+
"pedestal removal": "pedestal",
|
|
33
|
+
"pedestal": "pedestal",
|
|
34
|
+
"linear fit": "linear_fit",
|
|
35
|
+
|
|
36
|
+
# Stretching / tone tools
|
|
37
|
+
"statistical stretch": "stat_stretch",
|
|
38
|
+
"stat stretch": "stat_stretch",
|
|
39
|
+
"star stretch": "star_stretch",
|
|
40
|
+
"curves": "curves",
|
|
41
|
+
"ghs": "ghs",
|
|
42
|
+
"generalized hyperbolic stretch": "ghs",
|
|
43
|
+
|
|
44
|
+
# Background/gradient
|
|
45
|
+
"abe": "abe",
|
|
46
|
+
"automatic background extraction": "abe",
|
|
47
|
+
"graxpert": "graxpert",
|
|
48
|
+
|
|
49
|
+
# Star / color tools
|
|
50
|
+
"remove stars": "remove_stars",
|
|
51
|
+
"remove green": "remove_green",
|
|
52
|
+
|
|
53
|
+
# Convolution / deconvolution
|
|
54
|
+
"convo / deconvo": "convo",
|
|
55
|
+
"convolution / deconvolution": "convo",
|
|
56
|
+
"convolution": "convo",
|
|
57
|
+
"deconvolution": "convo",
|
|
58
|
+
|
|
59
|
+
# Wavescale tools
|
|
60
|
+
"wavescale hdr": "wavescale_hdr",
|
|
61
|
+
"wave scale hdr": "wavescale_hdr",
|
|
62
|
+
"wavescale dark enhancer": "wavescale_dark_enhance",
|
|
63
|
+
"wave scale dark enhancer": "wavescale_dark_enhance",
|
|
64
|
+
"dark structure enhance": "wavescale_dark_enhance",
|
|
65
|
+
|
|
66
|
+
# Other image-processing tools
|
|
67
|
+
"clahe": "clahe",
|
|
68
|
+
"morphology": "morphology",
|
|
69
|
+
"pixel math": "pixel_math",
|
|
70
|
+
"pixelmath": "pixel_math",
|
|
71
|
+
"halo-b-gon": "halo_b_gon",
|
|
72
|
+
"halo b gon": "halo_b_gon",
|
|
73
|
+
"aberration ai": "aberrationai",
|
|
74
|
+
"cosmic clarity": "cosmic_clarity",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _norm_preset(p) -> dict:
|
|
79
|
+
"""Best effort: turn whatever we get into a dict."""
|
|
80
|
+
if not p:
|
|
81
|
+
return {}
|
|
82
|
+
if isinstance(p, dict):
|
|
83
|
+
return dict(p)
|
|
84
|
+
try:
|
|
85
|
+
return dict(p)
|
|
86
|
+
except Exception:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract_cmd_payload_from_meta(meta: dict | None) -> dict | None:
|
|
91
|
+
"""
|
|
92
|
+
Best-effort: pull a (command_id, preset) payload out of a history meta dict.
|
|
93
|
+
|
|
94
|
+
We look in three places, in order:
|
|
95
|
+
|
|
96
|
+
1) Embedded payloads (headless_payload / replay_payload / cmd_payload)
|
|
97
|
+
2) Explicit command_id / cid + preset directly on metadata
|
|
98
|
+
3) Inference from step_name + preset or special tool keys
|
|
99
|
+
"""
|
|
100
|
+
if not isinstance(meta, dict):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# --- 1) Embedded payload dicts ----------------------------
|
|
104
|
+
for key in ("headless_payload", "replay_payload", "cmd_payload"):
|
|
105
|
+
p = meta.get(key)
|
|
106
|
+
if isinstance(p, dict):
|
|
107
|
+
cid = p.get("command_id") or p.get("cid")
|
|
108
|
+
if cid:
|
|
109
|
+
return {
|
|
110
|
+
"command_id": str(cid),
|
|
111
|
+
"preset": _norm_preset(p.get("preset")),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# --- 2) Direct command_id + preset on metadata ------------
|
|
115
|
+
cid = meta.get("command_id") or meta.get("cid")
|
|
116
|
+
if cid:
|
|
117
|
+
preset = meta.get("preset") or meta.get("preset_dict") or {}
|
|
118
|
+
return {
|
|
119
|
+
"command_id": str(cid),
|
|
120
|
+
"preset": _norm_preset(preset),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# --- 3) Heuristics for tools that only store preset + step_name ----
|
|
124
|
+
preset = _norm_preset(meta.get("preset"))
|
|
125
|
+
step_name_raw = meta.get("step_name") or meta.get("name")
|
|
126
|
+
step_name = str(step_name_raw or "").strip().lower()
|
|
127
|
+
|
|
128
|
+
inferred_cid = None
|
|
129
|
+
|
|
130
|
+
if step_name:
|
|
131
|
+
# Normalize a bit: underscores / hyphens → spaces
|
|
132
|
+
base = step_name.replace("_", " ").replace("-", " ")
|
|
133
|
+
|
|
134
|
+
# Exact match first
|
|
135
|
+
inferred_cid = _NAME_TO_COMMAND_ID.get(base)
|
|
136
|
+
|
|
137
|
+
# Fuzzy: allow things like "Remove Green (mask=ON)"
|
|
138
|
+
if inferred_cid is None:
|
|
139
|
+
for key, val in _NAME_TO_COMMAND_ID.items():
|
|
140
|
+
if key in base:
|
|
141
|
+
inferred_cid = val
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
# Fallback: look for tool-specific keys in metadata
|
|
145
|
+
if inferred_cid is None:
|
|
146
|
+
for k in meta.keys():
|
|
147
|
+
k_norm = str(k).lower()
|
|
148
|
+
|
|
149
|
+
if k_norm in ("remove_green", "remove green"):
|
|
150
|
+
inferred_cid = "remove_green"
|
|
151
|
+
break
|
|
152
|
+
if k_norm in ("stat_stretch", "statistical_stretch"):
|
|
153
|
+
inferred_cid = "stat_stretch"
|
|
154
|
+
break
|
|
155
|
+
if k_norm in ("ghs", "generalized hyperbolic stretch"):
|
|
156
|
+
inferred_cid = "ghs"
|
|
157
|
+
break
|
|
158
|
+
if k_norm in ("abe", "automatic background extraction"):
|
|
159
|
+
inferred_cid = "abe"
|
|
160
|
+
break
|
|
161
|
+
if k_norm in ("wavescale_hdr", "wave_scale_hdr"):
|
|
162
|
+
inferred_cid = "wavescale_hdr"
|
|
163
|
+
break
|
|
164
|
+
if k_norm in ("wavescale_dark_enhance", "dark_structure_enhance"):
|
|
165
|
+
inferred_cid = "wavescale_dark_enhance"
|
|
166
|
+
break
|
|
167
|
+
if k_norm in ("pixel_math", "pixelmath"):
|
|
168
|
+
inferred_cid = "pixel_math"
|
|
169
|
+
break
|
|
170
|
+
if k_norm in ("halo_b_gon", "halo b gon"):
|
|
171
|
+
inferred_cid = "halo_b_gon"
|
|
172
|
+
break
|
|
173
|
+
if k_norm in ("aberrationai", "aberration_ai"):
|
|
174
|
+
inferred_cid = "aberrationai"
|
|
175
|
+
break
|
|
176
|
+
if k_norm in ("cosmic_clarity",):
|
|
177
|
+
inferred_cid = "cosmic_clarity"
|
|
178
|
+
break
|
|
179
|
+
if k_norm in ("convo", "convolution", "deconvolution"):
|
|
180
|
+
inferred_cid = "convo"
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
if inferred_cid is None:
|
|
184
|
+
# Nothing we know how to replay
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"command_id": str(inferred_cid),
|
|
189
|
+
"preset": preset,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Map human step names → command_id used by replay_last_action_on_base
|
|
193
|
+
_NAME_TO_COMMAND_ID = {
|
|
194
|
+
"pedestal removal": "pedestal",
|
|
195
|
+
"pedestal": "pedestal",
|
|
196
|
+
|
|
197
|
+
"statistical stretch": "stat_stretch",
|
|
198
|
+
"stat stretch": "stat_stretch",
|
|
199
|
+
|
|
200
|
+
"curves": "curves",
|
|
201
|
+
|
|
202
|
+
"remove green": "remove_green",
|
|
203
|
+
|
|
204
|
+
"background neutralization": "background_neutral",
|
|
205
|
+
"background neutralisation": "background_neutral",
|
|
206
|
+
"background neutral": "background_neutral",
|
|
207
|
+
"bn": "background_neutral",
|
|
208
|
+
|
|
209
|
+
"white balance": "white_balance",
|
|
210
|
+
|
|
211
|
+
"convo/deconvo": "convo",
|
|
212
|
+
"convolution / deconvolution": "convo",
|
|
213
|
+
"convolution": "convo",
|
|
214
|
+
"deconvolution": "convo",
|
|
215
|
+
|
|
216
|
+
"ghs": "ghs",
|
|
217
|
+
"generalized hyperbolic stretch": "ghs",
|
|
218
|
+
|
|
219
|
+
"automatic background extraction": "abe",
|
|
220
|
+
"abe": "abe",
|
|
221
|
+
|
|
222
|
+
"graxpert": "graxpert",
|
|
223
|
+
|
|
224
|
+
"remove stars": "remove_stars",
|
|
225
|
+
|
|
226
|
+
"star stretch": "star_stretch",
|
|
227
|
+
|
|
228
|
+
"wavescale hdr": "wavescale_hdr",
|
|
229
|
+
"wave scale hdr": "wavescale_hdr",
|
|
230
|
+
|
|
231
|
+
"wavescale dark enhancer": "wavescale_dark_enhance",
|
|
232
|
+
"dark structure enhance": "wavescale_dark_enhance",
|
|
233
|
+
|
|
234
|
+
"clahe": "clahe",
|
|
235
|
+
|
|
236
|
+
"morphology": "morphology",
|
|
237
|
+
|
|
238
|
+
"pixel math": "pixel_math",
|
|
239
|
+
"pixelmath": "pixel_math",
|
|
240
|
+
|
|
241
|
+
"halo-b-gon": "halo_b_gon",
|
|
242
|
+
"halo b gon": "halo_b_gon",
|
|
243
|
+
|
|
244
|
+
"aberration ai": "aberrationai",
|
|
245
|
+
|
|
246
|
+
"cosmic clarity": "cosmic_clarity",
|
|
247
|
+
|
|
248
|
+
"linear fit": "linear_fit",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _norm_step_label(label: str) -> str:
|
|
253
|
+
"""Normalize a human label like 'Statistical Stretch (target=0.25, unlinked)'."""
|
|
254
|
+
s = str(label or "").strip().lower()
|
|
255
|
+
if not s:
|
|
256
|
+
return ""
|
|
257
|
+
# Drop decorations like '(...)' or ' - extra'
|
|
258
|
+
for sep in ("(", "[", " - "):
|
|
259
|
+
idx = s.find(sep)
|
|
260
|
+
if idx > 0:
|
|
261
|
+
s = s[:idx]
|
|
262
|
+
return " ".join(s.split())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _command_id_for_step_label(label: str) -> str | None:
|
|
266
|
+
"""Map a history step label to a canonical command_id."""
|
|
267
|
+
base = _norm_step_label(label)
|
|
268
|
+
if not base:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
# Exact match
|
|
272
|
+
cid = _NAME_TO_COMMAND_ID.get(base)
|
|
273
|
+
if cid:
|
|
274
|
+
return cid
|
|
275
|
+
|
|
276
|
+
# Fuzzy: allow 'statistical stretch (target=...)'
|
|
277
|
+
for key, val in _NAME_TO_COMMAND_ID.items():
|
|
278
|
+
if key in base:
|
|
279
|
+
return val
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _payloads_from_headless_history(main_window, undo_entries):
|
|
284
|
+
"""
|
|
285
|
+
Use the main window's headless history to get presets for each undo entry.
|
|
286
|
+
|
|
287
|
+
We walk FORWARD through _headless_history so that:
|
|
288
|
+
- repeated operations get the right preset in order
|
|
289
|
+
- commands on other documents are skipped automatically.
|
|
290
|
+
Returns a list[len(undo_entries)] of payload dicts or None.
|
|
291
|
+
"""
|
|
292
|
+
n = len(undo_entries)
|
|
293
|
+
payloads = [None] * n
|
|
294
|
+
|
|
295
|
+
if main_window is None or not hasattr(main_window, "get_headless_history"):
|
|
296
|
+
return payloads
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
hist = list(main_window.get_headless_history()) or []
|
|
300
|
+
except Exception:
|
|
301
|
+
return payloads
|
|
302
|
+
|
|
303
|
+
if not hist:
|
|
304
|
+
return payloads
|
|
305
|
+
|
|
306
|
+
H = len(hist)
|
|
307
|
+
h_idx = 0
|
|
308
|
+
|
|
309
|
+
for i, (_img, meta, name) in enumerate(undo_entries):
|
|
310
|
+
label = name or (meta or {}).get("step_name") or ""
|
|
311
|
+
cid = _command_id_for_step_label(label)
|
|
312
|
+
if not cid:
|
|
313
|
+
continue
|
|
314
|
+
cid = cid.strip().lower()
|
|
315
|
+
|
|
316
|
+
# Scan forward in global history until we find the next entry with this cid.
|
|
317
|
+
while h_idx < H:
|
|
318
|
+
entry = hist[h_idx]
|
|
319
|
+
h_idx += 1
|
|
320
|
+
entry_cid = str(entry.get("command_id", "")).strip().lower()
|
|
321
|
+
if entry_cid != cid:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
preset = entry.get("preset") or {}
|
|
325
|
+
if not isinstance(preset, dict):
|
|
326
|
+
try:
|
|
327
|
+
preset = dict(preset)
|
|
328
|
+
except Exception:
|
|
329
|
+
preset = {}
|
|
330
|
+
payloads[i] = {"command_id": cid, "preset": preset}
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
return payloads
|
|
334
|
+
|
|
335
|
+
# Shared utilities
|
|
336
|
+
from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _mk_qimage_rgb8(float01: np.ndarray) -> tuple[QImage, np.ndarray]:
|
|
340
|
+
"""Make a QImage (RGB888) and return it along with the backing uint8 buffer to keep alive."""
|
|
341
|
+
f = float01
|
|
342
|
+
if f.ndim == 2:
|
|
343
|
+
f = np.stack([f] * 3, axis=-1)
|
|
344
|
+
elif f.ndim == 3 and f.shape[2] == 1:
|
|
345
|
+
f = np.repeat(f, 3, axis=2)
|
|
346
|
+
buf8 = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8, copy=False)
|
|
347
|
+
buf8 = np.ascontiguousarray(buf8)
|
|
348
|
+
h, w, _ = buf8.shape
|
|
349
|
+
bpl = buf8.strides[0]
|
|
350
|
+
ptr = sip.voidptr(buf8.ctypes.data)
|
|
351
|
+
qimg = QImage(ptr, w, h, bpl, QImage.Format.Format_RGB888)
|
|
352
|
+
return qimg, buf8
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _extract_undo_entries(doc):
|
|
356
|
+
# Prefer the public getter we just added
|
|
357
|
+
if hasattr(doc, "get_undo_stack"):
|
|
358
|
+
return list(doc.get_undo_stack())
|
|
359
|
+
|
|
360
|
+
# Fallbacks if needed
|
|
361
|
+
for attr in ("_undo_stack", "undo_stack"):
|
|
362
|
+
stack = getattr(doc, attr, None)
|
|
363
|
+
if stack is None:
|
|
364
|
+
continue
|
|
365
|
+
out = []
|
|
366
|
+
for item in stack:
|
|
367
|
+
if isinstance(item, (list, tuple)):
|
|
368
|
+
if len(item) >= 3:
|
|
369
|
+
# item[0] is now swap_id (str) or image (ndarray)
|
|
370
|
+
sid_or_img, meta, name = item[0], item[1] or {}, item[2] or "Unnamed"
|
|
371
|
+
elif len(item) == 2:
|
|
372
|
+
sid_or_img, meta = item
|
|
373
|
+
meta = meta or {}
|
|
374
|
+
name = meta.get("step_name", "Unnamed")
|
|
375
|
+
else:
|
|
376
|
+
continue
|
|
377
|
+
out.append((sid_or_img, meta, str(name)))
|
|
378
|
+
|
|
379
|
+
if out:
|
|
380
|
+
return out
|
|
381
|
+
return []
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class HistoryListWidget(QListWidget):
|
|
385
|
+
"""
|
|
386
|
+
QListWidget that supports Alt+drag of replayable steps.
|
|
387
|
+
Alt+drag starts a MIME_CMD drag with (command_id, preset).
|
|
388
|
+
"""
|
|
389
|
+
def __init__(self, parent=None):
|
|
390
|
+
super().__init__(parent)
|
|
391
|
+
self._press_pos = None
|
|
392
|
+
|
|
393
|
+
def mousePressEvent(self, e: QMouseEvent):
|
|
394
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
395
|
+
self._press_pos = e.position().toPoint()
|
|
396
|
+
super().mousePressEvent(e)
|
|
397
|
+
|
|
398
|
+
def mouseMoveEvent(self, e: QMouseEvent):
|
|
399
|
+
if self._press_pos is not None and (e.buttons() & Qt.MouseButton.LeftButton):
|
|
400
|
+
delta = e.position().toPoint() - self._press_pos
|
|
401
|
+
if delta.manhattanLength() >= QApplication.startDragDistance():
|
|
402
|
+
mods = QApplication.keyboardModifiers()
|
|
403
|
+
if mods & Qt.KeyboardModifier.AltModifier:
|
|
404
|
+
item = self.itemAt(self._press_pos)
|
|
405
|
+
if item is not None:
|
|
406
|
+
payload = item.data(Qt.ItemDataRole.UserRole)
|
|
407
|
+
if isinstance(payload, dict) and payload.get("command_id"):
|
|
408
|
+
self._start_drag(payload)
|
|
409
|
+
self._press_pos = None
|
|
410
|
+
return
|
|
411
|
+
super().mouseMoveEvent(e)
|
|
412
|
+
|
|
413
|
+
def mouseReleaseEvent(self, e: QMouseEvent):
|
|
414
|
+
self._press_pos = None
|
|
415
|
+
super().mouseReleaseEvent(e)
|
|
416
|
+
|
|
417
|
+
def _start_drag(self, payload: dict):
|
|
418
|
+
cid = payload.get("command_id")
|
|
419
|
+
preset = payload.get("preset") or {}
|
|
420
|
+
if not cid:
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
md = QMimeData()
|
|
424
|
+
md.setData(MIME_CMD, _pack_cmd_payload(cid, preset))
|
|
425
|
+
|
|
426
|
+
drag = QDrag(self)
|
|
427
|
+
drag.setMimeData(md)
|
|
428
|
+
pm = QPixmap(32, 32)
|
|
429
|
+
pm.fill(Qt.GlobalColor.darkGray)
|
|
430
|
+
drag.setPixmap(pm)
|
|
431
|
+
drag.setHotSpot(pm.rect().center())
|
|
432
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class HistoryExplorerDialog(QDialog):
|
|
436
|
+
def __init__(self, document, parent=None):
|
|
437
|
+
super().__init__(parent)
|
|
438
|
+
self.setWindowTitle("History Explorer")
|
|
439
|
+
self.setModal(False)
|
|
440
|
+
self.doc = document
|
|
441
|
+
|
|
442
|
+
self.setMinimumSize(700, 500)
|
|
443
|
+
layout = QVBoxLayout(self)
|
|
444
|
+
|
|
445
|
+
self.history_list = HistoryListWidget(self)
|
|
446
|
+
layout.addWidget(self.history_list)
|
|
447
|
+
|
|
448
|
+
# ---- Fetch undo stack ----
|
|
449
|
+
self.undo_entries = _extract_undo_entries(self.doc) # list[(sid_or_img, meta, name)]
|
|
450
|
+
self.items: list[tuple[object, dict, str]] = []
|
|
451
|
+
# Headless command presets aligned to each undo entry
|
|
452
|
+
mw = self._find_main_window()
|
|
453
|
+
self._history_payloads = _payloads_from_headless_history(mw, self.undo_entries)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# DEBUG: log what's in the undo stack
|
|
457
|
+
mw = self._find_main_window()
|
|
458
|
+
log = getattr(mw, "_log", None)
|
|
459
|
+
if log:
|
|
460
|
+
try:
|
|
461
|
+
log(
|
|
462
|
+
f"[HistoryExplorer] doc id={id(self.doc)} has "
|
|
463
|
+
f"{len(self.undo_entries)} undo entries"
|
|
464
|
+
)
|
|
465
|
+
for idx, (img, meta, name) in enumerate(self.undo_entries):
|
|
466
|
+
mk = list((meta or {}).keys())
|
|
467
|
+
payload_meta = _extract_cmd_payload_from_meta(meta or {})
|
|
468
|
+
payload_hist = (
|
|
469
|
+
self._history_payloads[idx]
|
|
470
|
+
if 0 <= idx < len(self._history_payloads)
|
|
471
|
+
else None
|
|
472
|
+
)
|
|
473
|
+
payload = payload_hist or payload_meta
|
|
474
|
+
cid_dbg = None
|
|
475
|
+
if payload:
|
|
476
|
+
cid_dbg = payload.get("command_id") or payload.get("cid")
|
|
477
|
+
src = "hist" if payload_hist else ("meta" if payload_meta else "-")
|
|
478
|
+
log(
|
|
479
|
+
f"[HistoryExplorer] undo[{idx}] name='{name}', "
|
|
480
|
+
f"step_name='{(meta or {}).get('step_name')}', "
|
|
481
|
+
f"meta_keys={mk}, replayable={bool(payload)}, "
|
|
482
|
+
f"cid={cid_dbg}, src={src}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
cm = getattr(self.doc, "metadata", {}) or {}
|
|
486
|
+
mk = list(cm.keys())
|
|
487
|
+
payload_meta = _extract_cmd_payload_from_meta(cm)
|
|
488
|
+
payload_hist_last = None
|
|
489
|
+
for p in reversed(self._history_payloads):
|
|
490
|
+
if p:
|
|
491
|
+
payload_hist_last = p
|
|
492
|
+
break
|
|
493
|
+
payload = payload_hist_last or payload_meta
|
|
494
|
+
cid_dbg = None
|
|
495
|
+
if payload:
|
|
496
|
+
cid_dbg = payload.get("command_id") or payload.get("cid")
|
|
497
|
+
src = "hist" if payload_hist_last else ("meta" if payload_meta else "-")
|
|
498
|
+
log(
|
|
499
|
+
f"[HistoryExplorer] current image: "
|
|
500
|
+
f"meta_keys={mk}, replayable={bool(payload)}, "
|
|
501
|
+
f"cid={cid_dbg}, src={src}"
|
|
502
|
+
)
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ---- Build rows ----
|
|
508
|
+
# We want:
|
|
509
|
+
# 1. Original Image (oldest snapshot)
|
|
510
|
+
# 2. State after 1st op → label = undo[0].name
|
|
511
|
+
# 3. State after 2nd op → label = undo[1].name
|
|
512
|
+
# ...
|
|
513
|
+
# N+1. State after Nth op (current image) → label = undo[N-1].name
|
|
514
|
+
# N+2. Current Image
|
|
515
|
+
|
|
516
|
+
# 1) Original Image (if any undo entries exist)
|
|
517
|
+
row_index = 0
|
|
518
|
+
if self.undo_entries:
|
|
519
|
+
orig_src, orig_meta, _ = self.undo_entries[0]
|
|
520
|
+
item = QListWidgetItem("1. Original Image")
|
|
521
|
+
self.history_list.addItem(item)
|
|
522
|
+
self.items.append((orig_src, orig_meta, "Original Image"))
|
|
523
|
+
row_index += 1
|
|
524
|
+
|
|
525
|
+
# 2) Per-operation states
|
|
526
|
+
n = len(self.undo_entries)
|
|
527
|
+
for op_idx in range(n):
|
|
528
|
+
op_name = self.undo_entries[op_idx][2] or f"Step {op_idx + 1}"
|
|
529
|
+
|
|
530
|
+
if op_idx + 1 < n:
|
|
531
|
+
src, meta, _ = self.undo_entries[op_idx + 1]
|
|
532
|
+
else:
|
|
533
|
+
# Last operation → use current image + metadata
|
|
534
|
+
src = getattr(self.doc, "image", None)
|
|
535
|
+
meta = getattr(self.doc, "metadata", {}) or {}
|
|
536
|
+
|
|
537
|
+
# 1) Prefer preset from headless history
|
|
538
|
+
payload = None
|
|
539
|
+
if 0 <= op_idx < len(self._history_payloads):
|
|
540
|
+
payload = self._history_payloads[op_idx]
|
|
541
|
+
|
|
542
|
+
# 2) Fallback: infer from metadata for tools that don't yet
|
|
543
|
+
# record into headless history (BN/WB, etc.)
|
|
544
|
+
if payload is None:
|
|
545
|
+
payload = _extract_cmd_payload_from_meta(meta)
|
|
546
|
+
|
|
547
|
+
is_replayable = payload is not None
|
|
548
|
+
|
|
549
|
+
label = f"{row_index + 1}. {op_name}"
|
|
550
|
+
if is_replayable:
|
|
551
|
+
label += " ⟲"
|
|
552
|
+
|
|
553
|
+
item = QListWidgetItem(label)
|
|
554
|
+
if is_replayable:
|
|
555
|
+
item.setData(Qt.ItemDataRole.UserRole, payload)
|
|
556
|
+
item.setToolTip("Replayable step. Alt+Drag to drop onto a view or desktop.")
|
|
557
|
+
self.history_list.addItem(item)
|
|
558
|
+
|
|
559
|
+
self.items.append((src, meta, op_name))
|
|
560
|
+
row_index += 1
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
# 3) Final "Current Image" row
|
|
564
|
+
cur_img = getattr(self.doc, "image", None)
|
|
565
|
+
cur_meta = getattr(self.doc, "metadata", {}) or {}
|
|
566
|
+
|
|
567
|
+
# Prefer the most recent headless history payload, if any
|
|
568
|
+
cur_payload = None
|
|
569
|
+
for p in reversed(self._history_payloads):
|
|
570
|
+
if p:
|
|
571
|
+
cur_payload = p
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
if cur_payload is None:
|
|
575
|
+
cur_payload = _extract_cmd_payload_from_meta(cur_meta)
|
|
576
|
+
|
|
577
|
+
cur_replay = cur_payload is not None
|
|
578
|
+
|
|
579
|
+
label = f"{row_index + 1}. Current Image"
|
|
580
|
+
if cur_replay:
|
|
581
|
+
label += " ⟲"
|
|
582
|
+
cur_item = QListWidgetItem(label)
|
|
583
|
+
if cur_replay:
|
|
584
|
+
cur_item.setData(Qt.ItemDataRole.UserRole, cur_payload)
|
|
585
|
+
cur_item.setToolTip("Replayable step. Alt+Drag to drop onto a view or desktop.")
|
|
586
|
+
self.history_list.addItem(cur_item)
|
|
587
|
+
self.items.append((cur_img, cur_meta, "Current Image"))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
self.history_list.itemDoubleClicked.connect(self._open_preview)
|
|
591
|
+
|
|
592
|
+
row = QHBoxLayout()
|
|
593
|
+
btn_close = QPushButton("Close")
|
|
594
|
+
btn_close.clicked.connect(self.close)
|
|
595
|
+
row.addStretch(1)
|
|
596
|
+
row.addWidget(btn_close)
|
|
597
|
+
layout.addLayout(row)
|
|
598
|
+
|
|
599
|
+
def _open_preview(self, item):
|
|
600
|
+
row = self.history_list.row(item)
|
|
601
|
+
if 0 <= row < len(self.items):
|
|
602
|
+
src, meta, name = self.items[row]
|
|
603
|
+
if src is None:
|
|
604
|
+
QMessageBox.warning(self, "Preview", "No image stored for this step.")
|
|
605
|
+
return
|
|
606
|
+
pv = HistoryImagePreview(src, meta, self.doc, parent=self)
|
|
607
|
+
pv.setWindowTitle(item.text())
|
|
608
|
+
pv.show()
|
|
609
|
+
mw = self._find_main_window()
|
|
610
|
+
if mw and hasattr(mw, "_log"):
|
|
611
|
+
mw._log(f"History: preview opened → {item.text()}")
|
|
612
|
+
else:
|
|
613
|
+
QMessageBox.warning(self, "Preview", "Invalid selection.")
|
|
614
|
+
|
|
615
|
+
def _find_main_window(self):
|
|
616
|
+
p = self.parent()
|
|
617
|
+
while p is not None and not hasattr(p, "docman"):
|
|
618
|
+
p = p.parent()
|
|
619
|
+
return p
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class HistoryImagePreview(QWidget):
|
|
623
|
+
"""
|
|
624
|
+
Preview a single history entry with zoom/pan, optional display autostretch,
|
|
625
|
+
compare vs current, and restore.
|
|
626
|
+
"""
|
|
627
|
+
def __init__(self, image_source: object, metadata: dict, document, parent=None):
|
|
628
|
+
super().__init__(parent, Qt.WindowType.Window)
|
|
629
|
+
self.doc = document
|
|
630
|
+
self.metadata = metadata or {}
|
|
631
|
+
|
|
632
|
+
# Resolve image source (ndarray or swap_id)
|
|
633
|
+
self.image_data = None
|
|
634
|
+
if isinstance(image_source, str):
|
|
635
|
+
# It's a swap ID
|
|
636
|
+
sm = get_swap_manager()
|
|
637
|
+
loaded = sm.load_state(image_source)
|
|
638
|
+
if loaded is not None:
|
|
639
|
+
self.image_data = loaded
|
|
640
|
+
else:
|
|
641
|
+
# Failed to load
|
|
642
|
+
self.image_data = None
|
|
643
|
+
else:
|
|
644
|
+
# Assume it's an ndarray
|
|
645
|
+
self.image_data = image_source
|
|
646
|
+
|
|
647
|
+
if self.image_data is None:
|
|
648
|
+
# Fallback placeholder?
|
|
649
|
+
self.image_data = np.zeros((100, 100, 3), dtype=np.float32)
|
|
650
|
+
|
|
651
|
+
self.zoom = 1.0
|
|
652
|
+
self._panning = False
|
|
653
|
+
self._pan_start = QPointF()
|
|
654
|
+
self._autostretch_on = False
|
|
655
|
+
|
|
656
|
+
self._qimg_src = None
|
|
657
|
+
self._buf8 = None
|
|
658
|
+
|
|
659
|
+
# UI
|
|
660
|
+
self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
661
|
+
self.scroll = QScrollArea(widgetResizable=False)
|
|
662
|
+
self.scroll.setWidget(self.label)
|
|
663
|
+
self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
664
|
+
self.scroll.viewport().installEventFilter(self)
|
|
665
|
+
self.label.installEventFilter(self)
|
|
666
|
+
|
|
667
|
+
# controls
|
|
668
|
+
self.btn_stretch = QPushButton("Toggle AutoStretch")
|
|
669
|
+
self.btn_stretch.clicked.connect(self._toggle_autostretch)
|
|
670
|
+
|
|
671
|
+
self.btn_fit = QPushButton("Fit")
|
|
672
|
+
self.btn_fit.clicked.connect(self._fit_to_view)
|
|
673
|
+
|
|
674
|
+
self.btn_1to1 = QPushButton("1:1")
|
|
675
|
+
self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
|
|
676
|
+
|
|
677
|
+
self.btn_compare = QPushButton("Compare to Current…")
|
|
678
|
+
self.btn_compare.clicked.connect(self._open_compare)
|
|
679
|
+
|
|
680
|
+
self.btn_restore = QPushButton("Restore This Version")
|
|
681
|
+
self.btn_restore.clicked.connect(self._restore)
|
|
682
|
+
|
|
683
|
+
self.slider = QSlider(Qt.Orientation.Horizontal)
|
|
684
|
+
self.slider.setRange(10, 800)
|
|
685
|
+
self.slider.setValue(100)
|
|
686
|
+
self.slider.valueChanged.connect(lambda v: self._set_zoom(v/100.0))
|
|
687
|
+
|
|
688
|
+
ctrl = QHBoxLayout()
|
|
689
|
+
ctrl.addWidget(self.btn_stretch)
|
|
690
|
+
ctrl.addStretch(1)
|
|
691
|
+
ctrl.addWidget(self.btn_fit)
|
|
692
|
+
ctrl.addWidget(self.btn_1to1)
|
|
693
|
+
ctrl.addWidget(self.slider)
|
|
694
|
+
ctrl.addWidget(self.btn_compare)
|
|
695
|
+
ctrl.addWidget(self.btn_restore)
|
|
696
|
+
|
|
697
|
+
lay = QVBoxLayout(self)
|
|
698
|
+
lay.addWidget(self.scroll, 1)
|
|
699
|
+
lay.addLayout(ctrl)
|
|
700
|
+
|
|
701
|
+
self._rebuild_source()
|
|
702
|
+
self._fit_to_view()
|
|
703
|
+
|
|
704
|
+
# data → qimage
|
|
705
|
+
def _make_vis(self) -> np.ndarray:
|
|
706
|
+
f = _to_float01(self.image_data)
|
|
707
|
+
if f is None:
|
|
708
|
+
return None
|
|
709
|
+
if self._autostretch_on:
|
|
710
|
+
try:
|
|
711
|
+
return np.clip(autostretch(f, target_median=0.25, linked=False), 0, 1)
|
|
712
|
+
except Exception:
|
|
713
|
+
pass
|
|
714
|
+
return np.clip(f, 0, 1)
|
|
715
|
+
|
|
716
|
+
def _rebuild_source(self):
|
|
717
|
+
vis = self._make_vis()
|
|
718
|
+
if vis is None:
|
|
719
|
+
self.label.clear(); self._qimg_src = None; self._buf8 = None
|
|
720
|
+
return
|
|
721
|
+
self._qimg_src, self._buf8 = _mk_qimage_rgb8(vis)
|
|
722
|
+
self._update_scaled()
|
|
723
|
+
|
|
724
|
+
# zoom/pan
|
|
725
|
+
def _set_zoom(self, z: float):
|
|
726
|
+
self.zoom = float(max(0.05, min(z, 8.0)))
|
|
727
|
+
self.slider.blockSignals(True)
|
|
728
|
+
self.slider.setValue(int(self.zoom * 100))
|
|
729
|
+
self.slider.blockSignals(False)
|
|
730
|
+
self._update_scaled()
|
|
731
|
+
|
|
732
|
+
def _fit_to_view(self):
|
|
733
|
+
if self._qimg_src is None:
|
|
734
|
+
return
|
|
735
|
+
vp = self.scroll.viewport().size()
|
|
736
|
+
if self._qimg_src.width() == 0 or self._qimg_src.height() == 0:
|
|
737
|
+
return
|
|
738
|
+
s = min(vp.width() / self._qimg_src.width(), vp.height() / self._qimg_src.height())
|
|
739
|
+
self._set_zoom(max(0.05, s))
|
|
740
|
+
|
|
741
|
+
def _update_scaled(self):
|
|
742
|
+
if self._qimg_src is None:
|
|
743
|
+
return
|
|
744
|
+
sw = max(1, int(self._qimg_src.width() * self.zoom))
|
|
745
|
+
sh = max(1, int(self._qimg_src.height() * self.zoom))
|
|
746
|
+
scaled = self._qimg_src.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio,
|
|
747
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
748
|
+
self.label.setPixmap(QPixmap.fromImage(scaled))
|
|
749
|
+
self.label.resize(scaled.size())
|
|
750
|
+
|
|
751
|
+
# actions
|
|
752
|
+
def _toggle_autostretch(self):
|
|
753
|
+
self._autostretch_on = not self._autostretch_on
|
|
754
|
+
self._rebuild_source()
|
|
755
|
+
|
|
756
|
+
def _open_compare(self):
|
|
757
|
+
cur = getattr(self.doc, "image", None)
|
|
758
|
+
if cur is None:
|
|
759
|
+
QMessageBox.warning(self, "Compare", "No current image to compare.")
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
win = QWidget(self, Qt.WindowType.Window)
|
|
763
|
+
win.setWindowTitle("Compare with Current")
|
|
764
|
+
win.resize(900, 700)
|
|
765
|
+
|
|
766
|
+
v = QVBoxLayout(win)
|
|
767
|
+
self.slider_widget = ComparisonSlider(self.image_data, cur, parent=win)
|
|
768
|
+
v.addWidget(self.slider_widget, 1)
|
|
769
|
+
|
|
770
|
+
bar = QHBoxLayout()
|
|
771
|
+
b_out = QPushButton("Zoom Out"); b_in = QPushButton("Zoom In")
|
|
772
|
+
b_fit = QPushButton("Fit"); b_1 = QPushButton("1:1")
|
|
773
|
+
b_st = QPushButton("Toggle AutoStretch")
|
|
774
|
+
b_out.clicked.connect(self.slider_widget.zoom_out)
|
|
775
|
+
b_in.clicked.connect(self.slider_widget.zoom_in)
|
|
776
|
+
b_fit.clicked.connect(self.slider_widget.fit_to_view)
|
|
777
|
+
b_1.clicked.connect(lambda: self.slider_widget.set_zoom(1.0))
|
|
778
|
+
b_st.clicked.connect(self.slider_widget.toggle_autostretch)
|
|
779
|
+
|
|
780
|
+
bar.addWidget(b_out); bar.addWidget(b_in); bar.addWidget(b_fit); bar.addWidget(b_1)
|
|
781
|
+
bar.addStretch(1); bar.addWidget(b_st)
|
|
782
|
+
v.addLayout(bar)
|
|
783
|
+
|
|
784
|
+
win.show()
|
|
785
|
+
mw = self._find_main_window()
|
|
786
|
+
if mw and hasattr(mw, "_log"):
|
|
787
|
+
mw._log("History: opened Compare with Current.")
|
|
788
|
+
|
|
789
|
+
def _restore(self):
|
|
790
|
+
try:
|
|
791
|
+
# Prefer a method that records step name if available
|
|
792
|
+
if hasattr(self.doc, "set_image"):
|
|
793
|
+
self.doc.set_image(self.image_data.copy(), {"step_name": "Restored from History"})
|
|
794
|
+
elif hasattr(self.doc, "update_image"):
|
|
795
|
+
self.doc.update_image(self.image_data.copy(), {"step_name": "Restored from History"})
|
|
796
|
+
else:
|
|
797
|
+
QMessageBox.critical(self, "Restore", "Document does not support setting image.")
|
|
798
|
+
return
|
|
799
|
+
mw = self._find_main_window()
|
|
800
|
+
if mw and hasattr(mw, "_log"):
|
|
801
|
+
mw._log("History: restored image from history.")
|
|
802
|
+
self.close()
|
|
803
|
+
except Exception as e:
|
|
804
|
+
QMessageBox.critical(self, "Restore failed", str(e))
|
|
805
|
+
|
|
806
|
+
def _find_main_window(self):
|
|
807
|
+
p = self.parent()
|
|
808
|
+
while p is not None and not hasattr(p, "docman"):
|
|
809
|
+
p = p.parent()
|
|
810
|
+
return p
|
|
811
|
+
|
|
812
|
+
# input
|
|
813
|
+
def eventFilter(self, obj, ev):
|
|
814
|
+
if obj is self.scroll.viewport():
|
|
815
|
+
if ev.type() == QEvent.Type.Wheel:
|
|
816
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
817
|
+
if self._qimg_src is None or self.label.pixmap() is None:
|
|
818
|
+
return True
|
|
819
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
|
|
820
|
+
pos_vp = ev.position()
|
|
821
|
+
pos_lb = self.label.mapFrom(self.scroll.viewport(), pos_vp.toPoint())
|
|
822
|
+
old = self.label.pixmap().size()
|
|
823
|
+
rel_x = pos_lb.x() / max(1, old.width())
|
|
824
|
+
rel_y = pos_lb.y() / max(1, old.height())
|
|
825
|
+
self._set_zoom(self.zoom * factor)
|
|
826
|
+
new = self.label.pixmap().size()
|
|
827
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
828
|
+
vbar = self.scroll.verticalScrollBar()
|
|
829
|
+
hbar.setValue(int(rel_x * new.width() - self.scroll.viewport().width()/2))
|
|
830
|
+
vbar.setValue(int(rel_y * new.height() - self.scroll.viewport().height()/2))
|
|
831
|
+
return True
|
|
832
|
+
return False
|
|
833
|
+
|
|
834
|
+
if obj is self.scroll.viewport() or obj is self.label:
|
|
835
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
836
|
+
self._panning = True
|
|
837
|
+
self._pan_start = ev.position()
|
|
838
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
839
|
+
return True
|
|
840
|
+
if ev.type() == QEvent.Type.MouseMove and self._panning:
|
|
841
|
+
d = ev.position() - self._pan_start
|
|
842
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
843
|
+
vbar = self.scroll.verticalScrollBar()
|
|
844
|
+
hbar.setValue(hbar.value() - int(d.x()))
|
|
845
|
+
vbar.setValue(vbar.value() - int(d.y()))
|
|
846
|
+
self._pan_start = ev.position()
|
|
847
|
+
return True
|
|
848
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
|
|
849
|
+
self._panning = False
|
|
850
|
+
self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
|
851
|
+
return True
|
|
852
|
+
|
|
853
|
+
return super().eventFilter(obj, ev)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
class ComparisonSlider(QWidget):
|
|
857
|
+
"""Before/after slider with Ctrl+wheel zoom, Fit, 1:1, optional display autostretch."""
|
|
858
|
+
def __init__(self, before_image: np.ndarray, after_image: np.ndarray, parent=None):
|
|
859
|
+
super().__init__(parent)
|
|
860
|
+
self.before = np.asarray(before_image)
|
|
861
|
+
self.after = np.asarray(after_image)
|
|
862
|
+
self.zoom = 1.0
|
|
863
|
+
self.autostretch_on = False
|
|
864
|
+
self.slider_pos = 0.5
|
|
865
|
+
|
|
866
|
+
self._q_before = None; self._buf_before = None
|
|
867
|
+
self._q_after = None; self._buf_after = None
|
|
868
|
+
self.setMouseTracking(True)
|
|
869
|
+
self.setMinimumSize(400, 300)
|
|
870
|
+
self._rebuild()
|
|
871
|
+
|
|
872
|
+
def _mk_vis(self, a: np.ndarray) -> np.ndarray:
|
|
873
|
+
f = _to_float01(a)
|
|
874
|
+
if self.autostretch_on:
|
|
875
|
+
try:
|
|
876
|
+
return np.clip(autostretch(f, target_median=0.25, linked=False), 0, 1)
|
|
877
|
+
except Exception:
|
|
878
|
+
pass
|
|
879
|
+
return np.clip(f, 0, 1)
|
|
880
|
+
|
|
881
|
+
def _rebuild(self):
|
|
882
|
+
qb, bb = _mk_qimage_rgb8(self._mk_vis(self.before))
|
|
883
|
+
qa, ba = _mk_qimage_rgb8(self._mk_vis(self.after))
|
|
884
|
+
self._q_before, self._buf_before = qb, bb
|
|
885
|
+
self._q_after, self._buf_after = qa, ba
|
|
886
|
+
|
|
887
|
+
# public controls
|
|
888
|
+
def set_zoom(self, z: float):
|
|
889
|
+
self.zoom = float(max(0.05, min(z, 8.0))); self.update()
|
|
890
|
+
def zoom_in(self): self.set_zoom(self.zoom * 1.25)
|
|
891
|
+
def zoom_out(self): self.set_zoom(self.zoom / 1.25)
|
|
892
|
+
def fit_to_view(self):
|
|
893
|
+
if not self._q_before: return
|
|
894
|
+
W,H = self.width(), self.height()
|
|
895
|
+
iw,ih = self._q_before.width(), self._q_before.height()
|
|
896
|
+
if iw==0 or ih==0: return
|
|
897
|
+
self.set_zoom(min(W/iw, H/ih))
|
|
898
|
+
|
|
899
|
+
def toggle_autostretch(self):
|
|
900
|
+
self.autostretch_on = not self.autostretch_on
|
|
901
|
+
self._rebuild(); self.update()
|
|
902
|
+
|
|
903
|
+
# painting & input
|
|
904
|
+
def paintEvent(self, _ev):
|
|
905
|
+
if not self._q_before or not self._q_after:
|
|
906
|
+
return
|
|
907
|
+
p = QPainter(self)
|
|
908
|
+
W,H = self.width(), self.height()
|
|
909
|
+
iw, ih = self._q_before.width(), self._q_before.height()
|
|
910
|
+
if iw==0 or ih==0: return
|
|
911
|
+
s = min(W/iw, H/ih) * self.zoom
|
|
912
|
+
tw, th = int(iw*s), int(ih*s)
|
|
913
|
+
b = self._q_before.scaled(tw, th, Qt.AspectRatioMode.KeepAspectRatio,
|
|
914
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
915
|
+
a = self._q_after.scaled(tw, th, Qt.AspectRatioMode.KeepAspectRatio,
|
|
916
|
+
Qt.TransformationMode.SmoothTransformation)
|
|
917
|
+
ox = (W - b.width()) // 2
|
|
918
|
+
oy = (H - b.height()) // 2
|
|
919
|
+
cut = int(W * self.slider_pos)
|
|
920
|
+
|
|
921
|
+
p.save(); p.setClipRect(0, 0, cut, H); p.drawImage(ox, oy, b); p.restore()
|
|
922
|
+
p.save(); p.setClipRect(cut, 0, W-cut, H); p.drawImage(ox, oy, a); p.restore()
|
|
923
|
+
|
|
924
|
+
p.setPen(Qt.GlobalColor.red); p.drawLine(cut, 0, cut, H)
|
|
925
|
+
|
|
926
|
+
def mousePressEvent(self, ev):
|
|
927
|
+
if ev.button() == Qt.MouseButton.LeftButton:
|
|
928
|
+
self._set_div(ev.position().x())
|
|
929
|
+
def mouseMoveEvent(self, ev):
|
|
930
|
+
if ev.buttons() & Qt.MouseButton.LeftButton:
|
|
931
|
+
self._set_div(ev.position().x())
|
|
932
|
+
def _set_div(self, x):
|
|
933
|
+
self.slider_pos = min(max(x / max(1, self.width()), 0.0), 1.0); self.update()
|
|
934
|
+
def wheelEvent(self, ev):
|
|
935
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
936
|
+
self.set_zoom(self.zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
|
|
937
|
+
ev.accept()
|
|
938
|
+
else:
|
|
939
|
+
ev.ignore()
|