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,1476 @@
|
|
|
1
|
+
# ops/scripts.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
import traceback
|
|
8
|
+
import importlib.util
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Optional, Any
|
|
12
|
+
import numpy as np
|
|
13
|
+
from PyQt6.QtCore import QStandardPaths, QObject, QSettings, Qt
|
|
14
|
+
from PyQt6.QtGui import QAction, QDesktopServices, QCursor
|
|
15
|
+
from PyQt6.QtWidgets import QMessageBox
|
|
16
|
+
from PyQt6.QtCore import QUrl
|
|
17
|
+
|
|
18
|
+
from setiastro.saspro.ops.command_runner import run_command as _run_command
|
|
19
|
+
|
|
20
|
+
# -----------------------------------------------------------------------------
|
|
21
|
+
# Scripts folder (FIXED ROOT: SASpro/scripts)
|
|
22
|
+
# -----------------------------------------------------------------------------
|
|
23
|
+
def get_scripts_dir() -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Per-user scripts folder, pinned to a stable 'SASpro/scripts' root.
|
|
26
|
+
|
|
27
|
+
Windows: %LOCALAPPDATA%/SASpro/scripts
|
|
28
|
+
macOS: ~/Library/Application Support/SASpro/scripts
|
|
29
|
+
Linux: ~/.local/share/SASpro/scripts (or $XDG_DATA_HOME)
|
|
30
|
+
|
|
31
|
+
This intentionally does NOT use Qt's AppLocalDataLocation so it won't
|
|
32
|
+
land under SetiAstro/Seti Astro Suite Pro.
|
|
33
|
+
"""
|
|
34
|
+
# Windows
|
|
35
|
+
if sys.platform.startswith("win"):
|
|
36
|
+
base = os.getenv("LOCALAPPDATA")
|
|
37
|
+
if base:
|
|
38
|
+
root = Path(base)
|
|
39
|
+
else:
|
|
40
|
+
root = Path.home() / "AppData" / "Local"
|
|
41
|
+
scripts = root / "SASpro" / "scripts"
|
|
42
|
+
|
|
43
|
+
# macOS
|
|
44
|
+
elif sys.platform == "darwin":
|
|
45
|
+
root = Path.home() / "Library" / "Application Support"
|
|
46
|
+
scripts = root / "SASpro" / "scripts"
|
|
47
|
+
|
|
48
|
+
# Linux / other
|
|
49
|
+
else:
|
|
50
|
+
root = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
51
|
+
scripts = root / "SASpro" / "scripts"
|
|
52
|
+
|
|
53
|
+
scripts.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return scripts
|
|
55
|
+
|
|
56
|
+
def migrate_old_scripts_if_needed():
|
|
57
|
+
"""
|
|
58
|
+
One-time best-effort migration from the old Qt-derived folder into
|
|
59
|
+
the new SASpro/scripts folder.
|
|
60
|
+
|
|
61
|
+
Safe: only copies *.py that don't already exist in new location.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
new_dir = get_scripts_dir()
|
|
65
|
+
|
|
66
|
+
old_dirs: list[Path] = []
|
|
67
|
+
|
|
68
|
+
if sys.platform.startswith("win"):
|
|
69
|
+
old_dirs.append(
|
|
70
|
+
Path.home() / "AppData" / "Local" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
|
|
71
|
+
)
|
|
72
|
+
elif sys.platform == "darwin":
|
|
73
|
+
old_dirs.append(
|
|
74
|
+
Path.home() / "Library" / "Application Support" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
old_dirs.append(
|
|
78
|
+
Path.home() / ".local" / "share" / "SetiAstro" / "Seti Astro Suite Pro" / "scripts"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
for old in old_dirs:
|
|
82
|
+
if not old.exists() or not old.is_dir():
|
|
83
|
+
continue
|
|
84
|
+
for p in old.glob("*.py"):
|
|
85
|
+
dest = new_dir / p.name
|
|
86
|
+
if not dest.exists():
|
|
87
|
+
try:
|
|
88
|
+
dest.write_text(p.read_text(encoding="utf-8"), encoding="utf-8")
|
|
89
|
+
except Exception:
|
|
90
|
+
# fallback binary copy if encoding chokes
|
|
91
|
+
import shutil
|
|
92
|
+
shutil.copy2(p, dest)
|
|
93
|
+
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -----------------------------------------------------------------------------
|
|
99
|
+
# Script context exposed to user scripts
|
|
100
|
+
# -----------------------------------------------------------------------------
|
|
101
|
+
class ScriptContext:
|
|
102
|
+
"""
|
|
103
|
+
Minimal, stable API for user scripts.
|
|
104
|
+
Add helpers over time; try not to break existing ones.
|
|
105
|
+
"""
|
|
106
|
+
def __init__(self, app_window, *, on_base: bool = False):
|
|
107
|
+
self.app = app_window
|
|
108
|
+
self._on_base = bool(on_base)
|
|
109
|
+
|
|
110
|
+
def main_window(self):
|
|
111
|
+
"""Return the main SASpro window (stable helper for scripts)."""
|
|
112
|
+
return self.app
|
|
113
|
+
|
|
114
|
+
# ---- logging ----
|
|
115
|
+
def log(self, msg: str):
|
|
116
|
+
try:
|
|
117
|
+
self.app._log(f"[Script] {msg}")
|
|
118
|
+
except Exception:
|
|
119
|
+
print("[Script]", msg)
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# File-based image I/O (canonical SASpro routes)
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
def load_image(self, filename: str, *, return_metadata: bool = False,
|
|
125
|
+
max_retries: int = 3, wait_seconds: int = 3):
|
|
126
|
+
"""
|
|
127
|
+
Load an image from disk using SASpro's canonical loader.
|
|
128
|
+
|
|
129
|
+
This does NOT open or register a document or subwindow.
|
|
130
|
+
It is purely file I/O.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
If return_metadata=False (default):
|
|
134
|
+
(img, original_header, bit_depth, is_mono)
|
|
135
|
+
If return_metadata=True:
|
|
136
|
+
whatever legacy.image_manager.load_image returns in metadata mode
|
|
137
|
+
(typically includes image_meta/file_meta)
|
|
138
|
+
"""
|
|
139
|
+
from setiastro.saspro.legacy import image_manager # canonical route
|
|
140
|
+
return image_manager.load_image(
|
|
141
|
+
filename,
|
|
142
|
+
max_retries=max_retries,
|
|
143
|
+
wait_seconds=wait_seconds,
|
|
144
|
+
return_metadata=bool(return_metadata),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def save_image(self, img_array, filename: str, *,
|
|
148
|
+
original_format: str | None = None,
|
|
149
|
+
bit_depth=None,
|
|
150
|
+
original_header=None,
|
|
151
|
+
is_mono: bool = False,
|
|
152
|
+
image_meta=None,
|
|
153
|
+
file_meta=None):
|
|
154
|
+
"""
|
|
155
|
+
Save an image to disk using SASpro's canonical saver.
|
|
156
|
+
|
|
157
|
+
This does NOT require an open document.
|
|
158
|
+
It writes exactly through legacy.image_manager.save_image.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
img_array: numpy array (mono or RGB). Any dtype accepted; saver handles it.
|
|
162
|
+
filename: output path
|
|
163
|
+
original_format: e.g. "fits", "tiff", "png". If None, inferred from suffix.
|
|
164
|
+
bit_depth/original_header/is_mono/image_meta/file_meta:
|
|
165
|
+
passed through to legacy saver.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Whatever legacy.image_manager.save_image returns (often None or success flag).
|
|
169
|
+
"""
|
|
170
|
+
from setiastro.saspro.legacy import image_manager # canonical route
|
|
171
|
+
from pathlib import Path
|
|
172
|
+
|
|
173
|
+
p = Path(filename)
|
|
174
|
+
fmt = original_format
|
|
175
|
+
if fmt is None or not str(fmt).strip():
|
|
176
|
+
# infer from extension (".fits", ".fit", ".fz", ".tif", ".tiff", ".png", etc.)
|
|
177
|
+
ext = p.suffix.lower().lstrip(".")
|
|
178
|
+
if ext in ("fit", "fits", "fz", "fits.gz", "fit.gz"):
|
|
179
|
+
fmt = "fits"
|
|
180
|
+
elif ext in ("tif", "tiff"):
|
|
181
|
+
fmt = "tiff"
|
|
182
|
+
else:
|
|
183
|
+
fmt = ext # png/jpg/x
|
|
184
|
+
|
|
185
|
+
return image_manager.save_image(
|
|
186
|
+
img_array,
|
|
187
|
+
str(p),
|
|
188
|
+
fmt,
|
|
189
|
+
bit_depth=bit_depth,
|
|
190
|
+
original_header=original_header,
|
|
191
|
+
is_mono=bool(is_mono),
|
|
192
|
+
image_meta=image_meta,
|
|
193
|
+
file_meta=file_meta,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Friendly aliases (optional, but nice UX)
|
|
197
|
+
open_image = load_image
|
|
198
|
+
write_image = save_image
|
|
199
|
+
|
|
200
|
+
# ---- active view/doc access ----
|
|
201
|
+
def active_subwindow(self):
|
|
202
|
+
try:
|
|
203
|
+
return self.app.mdi.activeSubWindow()
|
|
204
|
+
except Exception:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def active_view(self):
|
|
208
|
+
sw = self.active_subwindow()
|
|
209
|
+
return sw.widget() if sw else None
|
|
210
|
+
|
|
211
|
+
def base_document(self):
|
|
212
|
+
sw = self.active_subwindow()
|
|
213
|
+
if sw and hasattr(self.app, "_target_doc_from_subwindow"):
|
|
214
|
+
try:
|
|
215
|
+
return self.app._target_doc_from_subwindow(sw)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
return self.active_document(fallback_to_base=False)
|
|
219
|
+
|
|
220
|
+
def _docman(self):
|
|
221
|
+
return getattr(self.app, "doc_manager", None)
|
|
222
|
+
|
|
223
|
+
def active_document(self):
|
|
224
|
+
"""
|
|
225
|
+
Normal run:
|
|
226
|
+
- return DocManager.get_active_document() so Preview tabs yield _RoiViewDocument.
|
|
227
|
+
Run-on-base:
|
|
228
|
+
- force base doc even if Preview is active.
|
|
229
|
+
"""
|
|
230
|
+
dm = self._docman()
|
|
231
|
+
|
|
232
|
+
if dm and hasattr(dm, "get_active_document"):
|
|
233
|
+
if self._on_base:
|
|
234
|
+
# focused base is sticky and ignores ROI wrappers
|
|
235
|
+
base = None
|
|
236
|
+
try:
|
|
237
|
+
base = dm.get_focused_base_document()
|
|
238
|
+
except Exception:
|
|
239
|
+
base = None
|
|
240
|
+
return base or self.base_document()
|
|
241
|
+
|
|
242
|
+
# normal run: ROI-aware
|
|
243
|
+
try:
|
|
244
|
+
return dm.get_active_document()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# fallback (should rarely happen)
|
|
249
|
+
view = self.active_view()
|
|
250
|
+
return getattr(view, "document", None) if view else None
|
|
251
|
+
|
|
252
|
+
def get_image(self):
|
|
253
|
+
doc = self.active_document()
|
|
254
|
+
return getattr(doc, "image", None) if doc else None
|
|
255
|
+
|
|
256
|
+
def set_image(self, img, step_name: str = "Script"):
|
|
257
|
+
dm = self._docman()
|
|
258
|
+
if dm is None:
|
|
259
|
+
raise RuntimeError("DocManager not available.")
|
|
260
|
+
|
|
261
|
+
img = np.asarray(img)
|
|
262
|
+
if img.dtype != np.float32:
|
|
263
|
+
img = img.astype(np.float32, copy=False)
|
|
264
|
+
|
|
265
|
+
if self._on_base:
|
|
266
|
+
# ✅ Bypass ROI branch: write to base doc directly
|
|
267
|
+
base_doc = None
|
|
268
|
+
try:
|
|
269
|
+
base_doc = dm.get_focused_base_document()
|
|
270
|
+
except Exception:
|
|
271
|
+
base_doc = None
|
|
272
|
+
base_doc = base_doc or self.base_document()
|
|
273
|
+
|
|
274
|
+
if base_doc is None:
|
|
275
|
+
raise RuntimeError("No base document to update.")
|
|
276
|
+
|
|
277
|
+
base_doc.apply_edit(img, metadata={}, step_name=step_name)
|
|
278
|
+
|
|
279
|
+
# force full repaint, including any preview
|
|
280
|
+
try:
|
|
281
|
+
dm.imageRegionUpdated.emit(base_doc, None)
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
# if a preview is active, ask it to repaint too
|
|
286
|
+
try:
|
|
287
|
+
roi = dm._active_preview_roi() # returns (x,y,w,h) or None
|
|
288
|
+
if roi:
|
|
289
|
+
dm.previewRepaintRequested.emit(base_doc, roi)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
# ✅ Normal run: let DocManager decide (ROI preview vs full)
|
|
295
|
+
dm.update_active_document(img, metadata={}, step_name=step_name)
|
|
296
|
+
|
|
297
|
+
# ---- convenience wrappers into main window ----
|
|
298
|
+
def run_command(self, command_id: str, preset=None, **kwargs):
|
|
299
|
+
return _run_command(self, command_id, preset, **kwargs)
|
|
300
|
+
|
|
301
|
+
def is_frozen(self) -> bool:
|
|
302
|
+
return bool(getattr(sys, "frozen", False))
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
# View / document lookup helpers for scripts
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
def _iter_open_subwindows(self):
|
|
308
|
+
"""Yield (subwindow, widget) for all open MDI subwindows."""
|
|
309
|
+
try:
|
|
310
|
+
mdi = getattr(self.app, "mdi", None)
|
|
311
|
+
if mdi is None:
|
|
312
|
+
return
|
|
313
|
+
for sw in mdi.subWindowList():
|
|
314
|
+
try:
|
|
315
|
+
w = sw.widget()
|
|
316
|
+
except Exception:
|
|
317
|
+
w = None
|
|
318
|
+
if w is not None:
|
|
319
|
+
yield sw, w
|
|
320
|
+
except Exception:
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
def _base_doc_for_widget(self, w):
|
|
324
|
+
"""
|
|
325
|
+
Best-effort unwrap:
|
|
326
|
+
- ImageSubWindow.base_document / _base_document / document
|
|
327
|
+
- LiveViewDocument -> underlying base (_base)
|
|
328
|
+
- ROI wrapper -> parent
|
|
329
|
+
"""
|
|
330
|
+
doc = (
|
|
331
|
+
getattr(w, "base_document", None)
|
|
332
|
+
or getattr(w, "_base_document", None)
|
|
333
|
+
or getattr(w, "document", None)
|
|
334
|
+
)
|
|
335
|
+
if doc is None:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# LiveViewDocument exposes _base
|
|
339
|
+
base = getattr(doc, "_base", None)
|
|
340
|
+
if base is not None:
|
|
341
|
+
doc = base
|
|
342
|
+
|
|
343
|
+
# ROI wrapper -> parent base
|
|
344
|
+
parent = getattr(doc, "_parent_doc", None)
|
|
345
|
+
if parent is not None:
|
|
346
|
+
doc = parent
|
|
347
|
+
|
|
348
|
+
return doc
|
|
349
|
+
|
|
350
|
+
def list_views(self):
|
|
351
|
+
"""
|
|
352
|
+
Return list of open views with stable info.
|
|
353
|
+
Each item:
|
|
354
|
+
{
|
|
355
|
+
"title": <window title>,
|
|
356
|
+
"name": <doc display name>,
|
|
357
|
+
"uid": <doc uid or None>,
|
|
358
|
+
"file_path": <metadata file_path or ''>,
|
|
359
|
+
"is_active": bool
|
|
360
|
+
}
|
|
361
|
+
"""
|
|
362
|
+
out = []
|
|
363
|
+
active_sw = None
|
|
364
|
+
try:
|
|
365
|
+
active_sw = self.active_subwindow()
|
|
366
|
+
except Exception:
|
|
367
|
+
active_sw = None
|
|
368
|
+
|
|
369
|
+
for sw, w in self._iter_open_subwindows():
|
|
370
|
+
base_doc = self._base_doc_for_widget(w)
|
|
371
|
+
if base_doc is None:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# titles / names
|
|
375
|
+
try:
|
|
376
|
+
title = str(sw.windowTitle() or "")
|
|
377
|
+
except Exception:
|
|
378
|
+
title = ""
|
|
379
|
+
try:
|
|
380
|
+
name = str(base_doc.display_name())
|
|
381
|
+
except Exception:
|
|
382
|
+
name = str(getattr(base_doc, "metadata", {}).get("display_name", title) or title)
|
|
383
|
+
|
|
384
|
+
uid = getattr(base_doc, "uid", None)
|
|
385
|
+
file_path = ""
|
|
386
|
+
try:
|
|
387
|
+
file_path = str(getattr(base_doc, "metadata", {}).get("file_path", "") or "")
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
out.append({
|
|
392
|
+
"title": title,
|
|
393
|
+
"name": name,
|
|
394
|
+
"uid": uid,
|
|
395
|
+
"file_path": file_path,
|
|
396
|
+
"is_active": (sw is active_sw),
|
|
397
|
+
})
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
def list_view_names(self):
|
|
401
|
+
"""Convenience: return a list of human-visible names for open views."""
|
|
402
|
+
return [v["name"] or v["title"] for v in self.list_views()]
|
|
403
|
+
|
|
404
|
+
def get_document(self, view_name_or_uid: str, *, prefer_title: bool = False):
|
|
405
|
+
"""
|
|
406
|
+
Look up an open document by:
|
|
407
|
+
- display name (doc.display_name())
|
|
408
|
+
- or window title (subwindow.windowTitle())
|
|
409
|
+
- or uid (exact)
|
|
410
|
+
Matching is case-insensitive for names/titles.
|
|
411
|
+
Returns base ImageDocument (never a ROI wrapper).
|
|
412
|
+
"""
|
|
413
|
+
if not view_name_or_uid:
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
key = str(view_name_or_uid).strip()
|
|
417
|
+
key_low = key.lower()
|
|
418
|
+
|
|
419
|
+
for sw, w in self._iter_open_subwindows():
|
|
420
|
+
base_doc = self._base_doc_for_widget(w)
|
|
421
|
+
if base_doc is None:
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
uid = getattr(base_doc, "uid", None)
|
|
425
|
+
if uid is not None and str(uid) == key:
|
|
426
|
+
return base_doc
|
|
427
|
+
|
|
428
|
+
# compare names/titles
|
|
429
|
+
try:
|
|
430
|
+
doc_name = str(base_doc.display_name() or "").strip()
|
|
431
|
+
except Exception:
|
|
432
|
+
doc_name = str(getattr(base_doc, "metadata", {}).get("display_name", "") or "").strip()
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
title = str(sw.windowTitle() or "").strip()
|
|
436
|
+
except Exception:
|
|
437
|
+
title = ""
|
|
438
|
+
|
|
439
|
+
if prefer_title:
|
|
440
|
+
if title and title.lower() == key_low:
|
|
441
|
+
return base_doc
|
|
442
|
+
if doc_name and doc_name.lower() == key_low:
|
|
443
|
+
return base_doc
|
|
444
|
+
else:
|
|
445
|
+
if doc_name and doc_name.lower() == key_low:
|
|
446
|
+
return base_doc
|
|
447
|
+
if title and title.lower() == key_low:
|
|
448
|
+
return base_doc
|
|
449
|
+
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
def get_image_for(self, view_name_or_uid: str):
|
|
453
|
+
"""Get image ndarray for a named/uid view (base doc)."""
|
|
454
|
+
doc = self.get_document(view_name_or_uid)
|
|
455
|
+
return getattr(doc, "image", None) if doc else None
|
|
456
|
+
|
|
457
|
+
def set_image_for(self, view_name_or_uid: str, img, step_name: str = "Script"):
|
|
458
|
+
"""
|
|
459
|
+
Set image on a named/uid view (base doc), with undo + repaint.
|
|
460
|
+
This updates the full doc, not an ROI preview.
|
|
461
|
+
"""
|
|
462
|
+
dm = self._docman()
|
|
463
|
+
if dm is None:
|
|
464
|
+
raise RuntimeError("DocManager not available.")
|
|
465
|
+
|
|
466
|
+
doc = self.get_document(view_name_or_uid)
|
|
467
|
+
if doc is None:
|
|
468
|
+
raise RuntimeError(f"No open view matches '{view_name_or_uid}'")
|
|
469
|
+
|
|
470
|
+
arr = np.asarray(img)
|
|
471
|
+
if arr.dtype != np.float32:
|
|
472
|
+
arr = arr.astype(np.float32, copy=False)
|
|
473
|
+
|
|
474
|
+
# Apply edit to that doc directly (full-image semantics)
|
|
475
|
+
doc.apply_edit(arr, metadata={}, step_name=step_name)
|
|
476
|
+
|
|
477
|
+
# Clear/invalidate any ROI caches for this base doc so previews don't stale
|
|
478
|
+
try:
|
|
479
|
+
dm._invalidate_roi_cache(doc, None)
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
# Repaint any views showing this doc
|
|
484
|
+
try:
|
|
485
|
+
dm.imageRegionUpdated.emit(doc, None)
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
488
|
+
|
|
489
|
+
def activate_view(self, view_name_or_uid: str) -> bool:
|
|
490
|
+
"""
|
|
491
|
+
Bring a view to front by name/title/uid.
|
|
492
|
+
Returns True if activated.
|
|
493
|
+
"""
|
|
494
|
+
key = str(view_name_or_uid).strip().lower()
|
|
495
|
+
mdi = getattr(self.app, "mdi", None)
|
|
496
|
+
if mdi is None:
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
for sw, w in self._iter_open_subwindows():
|
|
500
|
+
base_doc = self._base_doc_for_widget(w)
|
|
501
|
+
if base_doc is None:
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
uid = getattr(base_doc, "uid", None)
|
|
505
|
+
try:
|
|
506
|
+
doc_name = str(base_doc.display_name() or "").strip().lower()
|
|
507
|
+
except Exception:
|
|
508
|
+
doc_name = ""
|
|
509
|
+
try:
|
|
510
|
+
title = str(sw.windowTitle() or "").strip().lower()
|
|
511
|
+
except Exception:
|
|
512
|
+
title = ""
|
|
513
|
+
|
|
514
|
+
if (uid is not None and str(uid) == view_name_or_uid) or doc_name == key or title == key:
|
|
515
|
+
try:
|
|
516
|
+
mdi.setActiveSubWindow(sw)
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
try:
|
|
520
|
+
sw.show()
|
|
521
|
+
sw.raise_()
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
return True
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
# ---- view enumeration / lookup by user-visible view name ----
|
|
528
|
+
def list_image_views(self):
|
|
529
|
+
"""
|
|
530
|
+
Return a list of (view_title, doc) for all open image subwindows.
|
|
531
|
+
The title is the current MDI window title (what the user renamed it to).
|
|
532
|
+
"""
|
|
533
|
+
out = []
|
|
534
|
+
mdi = getattr(self.app, "mdi", None)
|
|
535
|
+
if mdi is None:
|
|
536
|
+
return out
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
subwins = mdi.subWindowList()
|
|
540
|
+
except Exception:
|
|
541
|
+
subwins = []
|
|
542
|
+
|
|
543
|
+
for sw in subwins:
|
|
544
|
+
try:
|
|
545
|
+
w = sw.widget()
|
|
546
|
+
except Exception:
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
doc = (
|
|
550
|
+
getattr(w, "document", None)
|
|
551
|
+
or getattr(w, "base_document", None)
|
|
552
|
+
or getattr(w, "_base_document", None)
|
|
553
|
+
)
|
|
554
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
title = sw.windowTitle() or ""
|
|
559
|
+
except Exception:
|
|
560
|
+
title = ""
|
|
561
|
+
|
|
562
|
+
if not title:
|
|
563
|
+
# fallback to doc display name if window title missing
|
|
564
|
+
try:
|
|
565
|
+
title = doc.display_name()
|
|
566
|
+
except Exception:
|
|
567
|
+
title = "Untitled"
|
|
568
|
+
|
|
569
|
+
out.append((title, doc))
|
|
570
|
+
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
def get_document_by_view_name(self, name: str):
|
|
574
|
+
"""
|
|
575
|
+
Find the first open image doc whose *view title* matches name.
|
|
576
|
+
Matching is case-insensitive; exact match preferred, else unique prefix.
|
|
577
|
+
"""
|
|
578
|
+
name_l = (name or "").strip().lower()
|
|
579
|
+
if not name_l:
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
views = self.list_image_views()
|
|
583
|
+
|
|
584
|
+
# exact match
|
|
585
|
+
for title, doc in views:
|
|
586
|
+
if title.strip().lower() == name_l:
|
|
587
|
+
return doc
|
|
588
|
+
|
|
589
|
+
# unique prefix match
|
|
590
|
+
pref = [(t, d) for (t, d) in views if t.strip().lower().startswith(name_l)]
|
|
591
|
+
if len(pref) == 1:
|
|
592
|
+
return pref[0][1]
|
|
593
|
+
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
def get_image_by_view_name(self, name: str):
|
|
597
|
+
doc = self.get_document_by_view_name(name)
|
|
598
|
+
return getattr(doc, "image", None) if doc else None
|
|
599
|
+
|
|
600
|
+
def open_new_document(self, img, metadata=None, name: str | None = None):
|
|
601
|
+
"""
|
|
602
|
+
Convenience for scripts: create/register a new ImageDocument from an array.
|
|
603
|
+
"""
|
|
604
|
+
dm = self._docman()
|
|
605
|
+
if dm is None:
|
|
606
|
+
raise RuntimeError("DocManager not available.")
|
|
607
|
+
return dm.open_array(np.asarray(img, dtype=np.float32), metadata=metadata, title=name)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# -----------------------------------------------------------------------------
|
|
611
|
+
# Script registry entries
|
|
612
|
+
# -----------------------------------------------------------------------------
|
|
613
|
+
@dataclass
|
|
614
|
+
class ScriptEntry:
|
|
615
|
+
script_id: str # NEW
|
|
616
|
+
path: Path
|
|
617
|
+
name: str
|
|
618
|
+
group: str = ""
|
|
619
|
+
shortcut: Optional[str] = None # default shortcut from script
|
|
620
|
+
module: Any = None
|
|
621
|
+
run: Optional[Callable[[ScriptContext], None]] = None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# -----------------------------------------------------------------------------
|
|
626
|
+
# Script manager
|
|
627
|
+
# -----------------------------------------------------------------------------
|
|
628
|
+
class ScriptManager(QObject):
|
|
629
|
+
"""
|
|
630
|
+
Owns script discovery/loading and menu binding.
|
|
631
|
+
Main window delegates to this.
|
|
632
|
+
"""
|
|
633
|
+
def __init__(self, app_window):
|
|
634
|
+
super().__init__(app_window)
|
|
635
|
+
self.app = app_window
|
|
636
|
+
self.registry: list[ScriptEntry] = []
|
|
637
|
+
|
|
638
|
+
# ---- internal log ----
|
|
639
|
+
def _log(self, msg: str):
|
|
640
|
+
try:
|
|
641
|
+
self.app._log(msg)
|
|
642
|
+
except Exception:
|
|
643
|
+
print(msg)
|
|
644
|
+
|
|
645
|
+
# ---- loading ----
|
|
646
|
+
def load_registry(self):
|
|
647
|
+
"""
|
|
648
|
+
Discover scripts recursively under SASpro/scripts, load them, and build registry.
|
|
649
|
+
Skips __pycache__, hidden/underscore-prefixed files, and __init__.py.
|
|
650
|
+
"""
|
|
651
|
+
migrate_old_scripts_if_needed()
|
|
652
|
+
scripts_dir = get_scripts_dir()
|
|
653
|
+
self.registry = []
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
candidates = sorted(scripts_dir.rglob("*.py"))
|
|
657
|
+
except Exception:
|
|
658
|
+
candidates = []
|
|
659
|
+
|
|
660
|
+
for path in candidates:
|
|
661
|
+
# Skip pycache anywhere in path
|
|
662
|
+
parts_l = {p.lower() for p in path.parts}
|
|
663
|
+
if "__pycache__" in parts_l:
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
# Skip hidden/private python files and package init
|
|
667
|
+
if path.name == "__init__.py":
|
|
668
|
+
continue
|
|
669
|
+
if path.name.startswith((".", "_")):
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
entry = self._load_one_script(path, scripts_dir)
|
|
674
|
+
if entry:
|
|
675
|
+
self.registry.append(entry)
|
|
676
|
+
except Exception:
|
|
677
|
+
self._log(f"[Scripts] Failed to load {path.name}:\n{traceback.format_exc()}")
|
|
678
|
+
|
|
679
|
+
self._log(f"[Scripts] Loaded {len(self.registry)} script(s) from {scripts_dir}")
|
|
680
|
+
|
|
681
|
+
def load_script_from_path(self, path: Path) -> ScriptEntry | None:
|
|
682
|
+
scripts_root = get_scripts_dir()
|
|
683
|
+
return self._load_one_script(path, scripts_root)
|
|
684
|
+
|
|
685
|
+
def _load_one_script(self, path: Path, scripts_root: Path) -> ScriptEntry | None:
|
|
686
|
+
"""
|
|
687
|
+
Load a single user script from disk.
|
|
688
|
+
|
|
689
|
+
- Creates a unique module name based on mtime so reload picks up changes.
|
|
690
|
+
- Imports module.
|
|
691
|
+
- Determines stable script_id (prefer SCRIPT_ID in module, else persisted id).
|
|
692
|
+
- Pulls metadata: SCRIPT_NAME/GROUP/SHORTCUT.
|
|
693
|
+
Group defaults to relative folder under scripts_root.
|
|
694
|
+
"""
|
|
695
|
+
# Unique module name so reloading actually re-imports
|
|
696
|
+
try:
|
|
697
|
+
mtime_ns = path.stat().st_mtime_ns
|
|
698
|
+
except Exception:
|
|
699
|
+
mtime_ns = 0
|
|
700
|
+
module_name = f"saspro_user_script_{path.stem}_{mtime_ns}"
|
|
701
|
+
|
|
702
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
703
|
+
if not spec or not spec.loader:
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
mod = importlib.util.module_from_spec(spec)
|
|
707
|
+
|
|
708
|
+
# Import module first (so SCRIPT_ID / metadata exists)
|
|
709
|
+
try:
|
|
710
|
+
spec.loader.exec_module(mod) # type: ignore
|
|
711
|
+
except Exception:
|
|
712
|
+
self._log(f"[Scripts] Error importing {path.name}:\n{traceback.format_exc()}")
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
# ---- entrypoint: allow run(ctx) OR main(ctx) ----
|
|
716
|
+
run_func = getattr(mod, "run", None)
|
|
717
|
+
if not callable(run_func):
|
|
718
|
+
run_func = getattr(mod, "main", None)
|
|
719
|
+
|
|
720
|
+
if not callable(run_func):
|
|
721
|
+
self._log(f"[Scripts] {path.name} has no run(ctx) or main(ctx); skipping.")
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
# ---- helper: allow CAPS or lowercase ----
|
|
725
|
+
def _pick(*names, default=None):
|
|
726
|
+
for n in names:
|
|
727
|
+
if hasattr(mod, n):
|
|
728
|
+
return getattr(mod, n)
|
|
729
|
+
return default
|
|
730
|
+
|
|
731
|
+
name = _pick("SCRIPT_NAME", "script_name", default=path.stem)
|
|
732
|
+
|
|
733
|
+
# Prefer explicit group; else derive group from relative folder
|
|
734
|
+
group = _pick("SCRIPT_GROUP", "script_group", default=None)
|
|
735
|
+
if group is None or not str(group).strip():
|
|
736
|
+
try:
|
|
737
|
+
rel_parent = path.parent.relative_to(scripts_root)
|
|
738
|
+
group = "" if str(rel_parent) in ("", ".") else rel_parent.as_posix()
|
|
739
|
+
except Exception:
|
|
740
|
+
group = ""
|
|
741
|
+
|
|
742
|
+
shortcut = _pick("SCRIPT_SHORTCUT", "script_shortcut", default=None)
|
|
743
|
+
|
|
744
|
+
# Stable script id (prefer explicit SCRIPT_ID; else persisted by rel-path)
|
|
745
|
+
script_id = self._script_id_for_path(path, scripts_root, mod)
|
|
746
|
+
|
|
747
|
+
entry = ScriptEntry(
|
|
748
|
+
script_id=str(script_id),
|
|
749
|
+
path=path,
|
|
750
|
+
name=str(name),
|
|
751
|
+
group=str(group or ""),
|
|
752
|
+
shortcut=str(shortcut) if shortcut else None,
|
|
753
|
+
module=mod,
|
|
754
|
+
run=run_func,
|
|
755
|
+
)
|
|
756
|
+
return entry
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ---- menu wiring ----
|
|
761
|
+
def rebuild_menu(self, menu_scripts):
|
|
762
|
+
"""
|
|
763
|
+
Clears and rebuilds the Scripts menu from registry.
|
|
764
|
+
|
|
765
|
+
Expects base actions already created on app window:
|
|
766
|
+
act_script_editor, act_open_scripts_folder, act_reload_scripts, act_create_sample_script
|
|
767
|
+
(optionally) act_open_user_scripts_github, act_open_scripts_discord
|
|
768
|
+
|
|
769
|
+
Integrates scripts into ShortcutManager using command ids:
|
|
770
|
+
"script:<script_id>"
|
|
771
|
+
|
|
772
|
+
Also adds "Pin Script to Canvas" submenu to create desktop shortcuts for scripts.
|
|
773
|
+
"""
|
|
774
|
+
from typing import Any
|
|
775
|
+
from PyQt6.QtCore import Qt, QPoint
|
|
776
|
+
from PyQt6.QtGui import QAction, QCursor
|
|
777
|
+
|
|
778
|
+
menu_scripts.clear()
|
|
779
|
+
|
|
780
|
+
# --- fixed top actions ---
|
|
781
|
+
if getattr(self.app, "act_script_editor", None):
|
|
782
|
+
menu_scripts.addAction(self.app.act_script_editor)
|
|
783
|
+
menu_scripts.addSeparator()
|
|
784
|
+
|
|
785
|
+
if getattr(self.app, "act_open_user_scripts_github", None):
|
|
786
|
+
menu_scripts.addAction(self.app.act_open_user_scripts_github)
|
|
787
|
+
if getattr(self.app, "act_open_scripts_discord", None):
|
|
788
|
+
menu_scripts.addAction(self.app.act_open_scripts_discord)
|
|
789
|
+
|
|
790
|
+
menu_scripts.addSeparator()
|
|
791
|
+
|
|
792
|
+
if getattr(self.app, "act_open_scripts_folder", None):
|
|
793
|
+
menu_scripts.addAction(self.app.act_open_scripts_folder)
|
|
794
|
+
if getattr(self.app, "act_reload_scripts", None):
|
|
795
|
+
menu_scripts.addAction(self.app.act_reload_scripts)
|
|
796
|
+
if getattr(self.app, "act_create_sample_script", None):
|
|
797
|
+
menu_scripts.addAction(self.app.act_create_sample_script)
|
|
798
|
+
|
|
799
|
+
menu_scripts.addSeparator()
|
|
800
|
+
|
|
801
|
+
# ShortcutManager (optional)
|
|
802
|
+
sc = getattr(self.app, "shortcuts", None)
|
|
803
|
+
can_register = callable(getattr(sc, "register_action", None))
|
|
804
|
+
can_add_sc = callable(getattr(sc, "add_shortcut", None))
|
|
805
|
+
|
|
806
|
+
# Helper: pin a command id to canvas at cursor pos
|
|
807
|
+
def _pin_to_canvas(cmdid: str):
|
|
808
|
+
if not (sc and can_add_sc):
|
|
809
|
+
return
|
|
810
|
+
mdi = getattr(self.app, "mdi", None)
|
|
811
|
+
if mdi is None:
|
|
812
|
+
return
|
|
813
|
+
vp = mdi.viewport()
|
|
814
|
+
if vp is None:
|
|
815
|
+
return
|
|
816
|
+
|
|
817
|
+
pos = vp.mapFromGlobal(QCursor.pos())
|
|
818
|
+
if not vp.rect().contains(pos):
|
|
819
|
+
pos = vp.rect().center()
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
sc.add_shortcut(cmdid, QPoint(int(pos.x()), int(pos.y())))
|
|
823
|
+
except Exception:
|
|
824
|
+
pass
|
|
825
|
+
|
|
826
|
+
# "Pin Script to Canvas" submenu (grouped)
|
|
827
|
+
pin_root = menu_scripts.addMenu("Pin Script to Canvas")
|
|
828
|
+
pin_group_menus: dict[str, Any] = {}
|
|
829
|
+
|
|
830
|
+
# group -> submenu for run items
|
|
831
|
+
group_menus: dict[str, Any] = {}
|
|
832
|
+
|
|
833
|
+
for entry in self.registry:
|
|
834
|
+
script_id = getattr(entry, "script_id", None)
|
|
835
|
+
if not script_id:
|
|
836
|
+
# If a script entry has no id, we can still show it in the menu,
|
|
837
|
+
# but it can't be pinned/registered reliably.
|
|
838
|
+
cmdid = None
|
|
839
|
+
else:
|
|
840
|
+
cmdid = f"script:{script_id}"
|
|
841
|
+
|
|
842
|
+
group = (entry.group or "").strip()
|
|
843
|
+
|
|
844
|
+
# ---- RUN menu placement ----
|
|
845
|
+
if group:
|
|
846
|
+
run_sub = group_menus.get(group)
|
|
847
|
+
if run_sub is None:
|
|
848
|
+
run_sub = menu_scripts.addMenu(group)
|
|
849
|
+
group_menus[group] = run_sub
|
|
850
|
+
target_menu = run_sub
|
|
851
|
+
else:
|
|
852
|
+
target_menu = menu_scripts
|
|
853
|
+
|
|
854
|
+
from PyQt6.QtGui import QIcon
|
|
855
|
+
from setiastro.saspro.resources import get_icons
|
|
856
|
+
|
|
857
|
+
icons = get_icons()
|
|
858
|
+
|
|
859
|
+
act = QAction(entry.name, self.app)
|
|
860
|
+
act.setIcon(QIcon(icons.SCRIPT)) # NEW
|
|
861
|
+
|
|
862
|
+
# IMPORTANT: make shortcuts/global binds work regardless of focus
|
|
863
|
+
act.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
|
|
864
|
+
|
|
865
|
+
# store command_id on the action if we have one
|
|
866
|
+
if cmdid:
|
|
867
|
+
act.setProperty("command_id", cmdid)
|
|
868
|
+
|
|
869
|
+
# Default shortcut from script metadata (optional)
|
|
870
|
+
if getattr(entry, "shortcut", None):
|
|
871
|
+
try:
|
|
872
|
+
act.setShortcut(entry.shortcut)
|
|
873
|
+
except Exception:
|
|
874
|
+
pass
|
|
875
|
+
|
|
876
|
+
# Register with ShortcutManager so persisted overrides can apply
|
|
877
|
+
if cmdid and can_register:
|
|
878
|
+
try:
|
|
879
|
+
sc.register_action(cmdid, act)
|
|
880
|
+
except Exception:
|
|
881
|
+
pass
|
|
882
|
+
|
|
883
|
+
act.triggered.connect(lambda _=False, e=entry: self.run_entry(e))
|
|
884
|
+
target_menu.addAction(act)
|
|
885
|
+
|
|
886
|
+
# ---- PIN menu placement ----
|
|
887
|
+
# Only add to pin menu if we have a stable cmdid
|
|
888
|
+
if cmdid:
|
|
889
|
+
if group:
|
|
890
|
+
pin_sub = pin_group_menus.get(group)
|
|
891
|
+
if pin_sub is None:
|
|
892
|
+
pin_sub = pin_root.addMenu(group)
|
|
893
|
+
pin_group_menus[group] = pin_sub
|
|
894
|
+
pin_menu = pin_sub
|
|
895
|
+
else:
|
|
896
|
+
pin_menu = pin_root
|
|
897
|
+
|
|
898
|
+
act_pin = QAction(entry.name, self.app)
|
|
899
|
+
act_pin.setIcon(QIcon(icons.SCRIPT)) # NEW
|
|
900
|
+
act_pin.triggered.connect(lambda _=False, c=cmdid: _pin_to_canvas(c))
|
|
901
|
+
pin_menu.addAction(act_pin)
|
|
902
|
+
|
|
903
|
+
# If there are no pinnable scripts, disable the pin root nicely
|
|
904
|
+
if pin_root.actions() == []:
|
|
905
|
+
a = pin_root.addAction("No scripts to pin")
|
|
906
|
+
a.setEnabled(False)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _script_command_id(self, entry: ScriptEntry, *, on_base: bool = False) -> str:
|
|
910
|
+
# Keep it stable and unique. Path is perfect because scripts are per-user.
|
|
911
|
+
p = entry.path.as_posix()
|
|
912
|
+
return f"script:{'base:' if on_base else ''}{p}"
|
|
913
|
+
|
|
914
|
+
def _pin_command_to_canvas(self, command_id: str):
|
|
915
|
+
mgr = getattr(self.app, "shortcuts", None)
|
|
916
|
+
mdi = getattr(self.app, "mdi", None)
|
|
917
|
+
if mgr is None or mdi is None:
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
vp = mdi.viewport()
|
|
921
|
+
pos = vp.mapFromGlobal(QCursor.pos())
|
|
922
|
+
if not vp.rect().contains(pos):
|
|
923
|
+
pos = vp.rect().center()
|
|
924
|
+
|
|
925
|
+
mgr.add_shortcut(command_id, pos)
|
|
926
|
+
|
|
927
|
+
# ---- running ----
|
|
928
|
+
def run_entry(self, entry: ScriptEntry, *, on_base: bool = False):
|
|
929
|
+
ctx = ScriptContext(self.app, on_base=on_base)
|
|
930
|
+
try:
|
|
931
|
+
self._log(f"[Scripts] Running '{entry.name}' ({entry.path.name}) on_base={on_base}")
|
|
932
|
+
entry.run(ctx) # type: ignore
|
|
933
|
+
self._log(f"[Scripts] Finished '{entry.name}'")
|
|
934
|
+
except Exception as e:
|
|
935
|
+
tb = traceback.format_exc()
|
|
936
|
+
self._log(f"[Scripts] ERROR in '{entry.name}':\n{tb}")
|
|
937
|
+
try:
|
|
938
|
+
QMessageBox.critical(self.app, "Script Error",
|
|
939
|
+
f"{entry.name} failed:\n\n{e}")
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# ---- convenience actions ----
|
|
945
|
+
def open_scripts_folder(self):
|
|
946
|
+
folder = get_scripts_dir()
|
|
947
|
+
try:
|
|
948
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(folder)))
|
|
949
|
+
except Exception:
|
|
950
|
+
# OS fallback
|
|
951
|
+
try:
|
|
952
|
+
if sys.platform.startswith("win"):
|
|
953
|
+
os.startfile(folder) # type: ignore
|
|
954
|
+
elif sys.platform == "darwin":
|
|
955
|
+
os.system(f'open "{folder}"')
|
|
956
|
+
else:
|
|
957
|
+
os.system(f'xdg-open "{folder}"')
|
|
958
|
+
except Exception:
|
|
959
|
+
self._log(f"[Scripts] Couldn't open scripts folder: {folder}")
|
|
960
|
+
|
|
961
|
+
def create_sample_script(self):
|
|
962
|
+
folder = get_scripts_dir()
|
|
963
|
+
|
|
964
|
+
samples: dict[str, str] = {}
|
|
965
|
+
|
|
966
|
+
# ------------------------------------------------------------------
|
|
967
|
+
# 1) sample_invert.py (existing)
|
|
968
|
+
# ------------------------------------------------------------------
|
|
969
|
+
samples["sample_invert.py"] = """\
|
|
970
|
+
# Sample SASpro script
|
|
971
|
+
# Put scripts in this folder; they appear in Scripts menu.
|
|
972
|
+
# Required entrypoint:
|
|
973
|
+
# def run(ctx):
|
|
974
|
+
# ...
|
|
975
|
+
|
|
976
|
+
SCRIPT_NAME = "Invert Image (Sample)"
|
|
977
|
+
SCRIPT_GROUP = "Samples"
|
|
978
|
+
|
|
979
|
+
import numpy as np
|
|
980
|
+
|
|
981
|
+
def run(ctx):
|
|
982
|
+
img = ctx.get_image()
|
|
983
|
+
if img is None:
|
|
984
|
+
ctx.log("No active image.")
|
|
985
|
+
return
|
|
986
|
+
|
|
987
|
+
ctx.log(f"Inverting image... shape={img.shape}, dtype={img.dtype}")
|
|
988
|
+
|
|
989
|
+
f = img.astype(np.float32)
|
|
990
|
+
mx = float(np.nanmax(f)) if f.size else 1.0
|
|
991
|
+
if mx > 1.0:
|
|
992
|
+
f = f / mx
|
|
993
|
+
f = np.clip(f, 0.0, 1.0)
|
|
994
|
+
|
|
995
|
+
out = 1.0 - f
|
|
996
|
+
ctx.set_image(out, step_name="Invert via Script")
|
|
997
|
+
ctx.log("Done.")
|
|
998
|
+
"""
|
|
999
|
+
|
|
1000
|
+
# ------------------------------------------------------------------
|
|
1001
|
+
# 2) sample_star_preview_ui.py (SEP demo)
|
|
1002
|
+
# ------------------------------------------------------------------
|
|
1003
|
+
samples["sample_star_preview_ui.py"] = """\
|
|
1004
|
+
from __future__ import annotations
|
|
1005
|
+
|
|
1006
|
+
# =========================
|
|
1007
|
+
# SASpro Script Metadata
|
|
1008
|
+
# =========================
|
|
1009
|
+
SCRIPT_NAME = "Star Preview UI (SEP Demo)"
|
|
1010
|
+
SCRIPT_GROUP = "Samples"
|
|
1011
|
+
SCRIPT_SHORTCUT = "" # optional
|
|
1012
|
+
|
|
1013
|
+
# -------------------------
|
|
1014
|
+
# Star Preview UI sample
|
|
1015
|
+
# -------------------------
|
|
1016
|
+
|
|
1017
|
+
import numpy as np
|
|
1018
|
+
|
|
1019
|
+
from PyQt6.QtCore import Qt, QTimer
|
|
1020
|
+
from PyQt6.QtGui import QImage, QPixmap
|
|
1021
|
+
from PyQt6.QtWidgets import (
|
|
1022
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
1023
|
+
QSlider, QCheckBox, QMessageBox, QApplication, QWidget
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
# your libs already bundled in SASpro
|
|
1027
|
+
from setiastro.saspro.imageops.stretch import stretch_color_image, stretch_mono_image
|
|
1028
|
+
from setiastro.saspro.imageops.starbasedwhitebalance import apply_star_based_white_balance
|
|
1029
|
+
|
|
1030
|
+
# (optional) for applying result back to active doc
|
|
1031
|
+
from setiastro.saspro.whitebalance import apply_white_balance_to_doc
|
|
1032
|
+
|
|
1033
|
+
# Shared utilities
|
|
1034
|
+
from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class StarPreviewDialog(QDialog):
|
|
1038
|
+
\"""
|
|
1039
|
+
Sample script UI:
|
|
1040
|
+
- Shows active image (auto-updates when subwindow changes)
|
|
1041
|
+
- Runs SEP detection + ellipse overlay
|
|
1042
|
+
- Zoom controls + Fit/1:1
|
|
1043
|
+
- Demo Apply WB to active image
|
|
1044
|
+
\"""
|
|
1045
|
+
def __init__(self, ctx, parent: QWidget | None = None):
|
|
1046
|
+
super().__init__(parent)
|
|
1047
|
+
self.ctx = ctx
|
|
1048
|
+
self.setWindowTitle("Sample Script: Star Preview UI")
|
|
1049
|
+
self.resize(980, 640)
|
|
1050
|
+
|
|
1051
|
+
self._zoom = 1.0
|
|
1052
|
+
self._img01: np.ndarray | None = None
|
|
1053
|
+
self._overlay01: np.ndarray | None = None
|
|
1054
|
+
|
|
1055
|
+
self._build_ui()
|
|
1056
|
+
self._wire()
|
|
1057
|
+
|
|
1058
|
+
# debounce for slider/checkbox
|
|
1059
|
+
self._debounce = QTimer(self)
|
|
1060
|
+
self._debounce.setSingleShot(True)
|
|
1061
|
+
self._debounce.setInterval(500)
|
|
1062
|
+
self._debounce.timeout.connect(self._rebuild_overlay)
|
|
1063
|
+
|
|
1064
|
+
# watch active base doc so preview isn't blank
|
|
1065
|
+
try:
|
|
1066
|
+
dm = getattr(self.ctx.app, "doc_manager", None)
|
|
1067
|
+
if dm is not None and hasattr(dm, "activeBaseChanged"):
|
|
1068
|
+
dm.activeBaseChanged.connect(lambda _=None: self._load_active_image())
|
|
1069
|
+
except Exception:
|
|
1070
|
+
pass
|
|
1071
|
+
|
|
1072
|
+
# initial load
|
|
1073
|
+
QTimer.singleShot(0, self._load_active_image)
|
|
1074
|
+
|
|
1075
|
+
# ---------------- UI ----------------
|
|
1076
|
+
def _build_ui(self):
|
|
1077
|
+
root = QVBoxLayout(self)
|
|
1078
|
+
|
|
1079
|
+
self.preview = QLabel("No active image.")
|
|
1080
|
+
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
1081
|
+
self.preview.setStyleSheet("border: 1px solid #333; background:#1f1f1f;")
|
|
1082
|
+
self.preview.setMinimumSize(720, 420)
|
|
1083
|
+
root.addWidget(self.preview, stretch=1)
|
|
1084
|
+
|
|
1085
|
+
# Zoom bar
|
|
1086
|
+
zrow = QHBoxLayout()
|
|
1087
|
+
self.btn_zoom_in = QPushButton("Zoom +")
|
|
1088
|
+
self.btn_zoom_out = QPushButton("Zoom −")
|
|
1089
|
+
self.btn_fit = QPushButton("Fit")
|
|
1090
|
+
self.btn_1to1 = QPushButton("1:1")
|
|
1091
|
+
zrow.addWidget(self.btn_zoom_in)
|
|
1092
|
+
zrow.addWidget(self.btn_zoom_out)
|
|
1093
|
+
zrow.addWidget(self.btn_fit)
|
|
1094
|
+
zrow.addWidget(self.btn_1to1)
|
|
1095
|
+
zrow.addStretch(1)
|
|
1096
|
+
root.addLayout(zrow)
|
|
1097
|
+
|
|
1098
|
+
# SEP controls
|
|
1099
|
+
ctrl = QHBoxLayout()
|
|
1100
|
+
ctrl.addWidget(QLabel("SEP threshold (σ):"))
|
|
1101
|
+
self.thr_slider = QSlider(Qt.Orientation.Horizontal)
|
|
1102
|
+
self.thr_slider.setRange(1, 100)
|
|
1103
|
+
self.thr_slider.setValue(50)
|
|
1104
|
+
self.thr_slider.setTickInterval(10)
|
|
1105
|
+
self.thr_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
|
|
1106
|
+
ctrl.addWidget(self.thr_slider, stretch=1)
|
|
1107
|
+
|
|
1108
|
+
self.thr_label = QLabel("50")
|
|
1109
|
+
self.thr_label.setFixedWidth(30)
|
|
1110
|
+
ctrl.addWidget(self.thr_label)
|
|
1111
|
+
|
|
1112
|
+
self.chk_autostretch = QCheckBox("Autostretch preview")
|
|
1113
|
+
self.chk_autostretch.setChecked(True)
|
|
1114
|
+
ctrl.addWidget(self.chk_autostretch)
|
|
1115
|
+
|
|
1116
|
+
root.addLayout(ctrl)
|
|
1117
|
+
|
|
1118
|
+
# bottom buttons
|
|
1119
|
+
brow = QHBoxLayout()
|
|
1120
|
+
brow.addStretch(1)
|
|
1121
|
+
self.btn_apply_demo = QPushButton("Apply WB to Active Image (demo)")
|
|
1122
|
+
self.btn_close = QPushButton("Close")
|
|
1123
|
+
brow.addWidget(self.btn_apply_demo)
|
|
1124
|
+
brow.addWidget(self.btn_close)
|
|
1125
|
+
root.addLayout(brow)
|
|
1126
|
+
|
|
1127
|
+
def _wire(self):
|
|
1128
|
+
self.btn_close.clicked.connect(self.reject)
|
|
1129
|
+
|
|
1130
|
+
self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
|
|
1131
|
+
self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
|
|
1132
|
+
self.btn_fit.clicked.connect(self._zoom_fit)
|
|
1133
|
+
self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
|
|
1134
|
+
|
|
1135
|
+
self.thr_slider.valueChanged.connect(self._on_thr_changed)
|
|
1136
|
+
self.chk_autostretch.toggled.connect(lambda _=None: self._debounce.start())
|
|
1137
|
+
|
|
1138
|
+
self.btn_apply_demo.clicked.connect(self._apply_demo_wb)
|
|
1139
|
+
|
|
1140
|
+
# ------------- Active image -------------
|
|
1141
|
+
def _load_active_image(self):
|
|
1142
|
+
try:
|
|
1143
|
+
doc = self.ctx.active_document()
|
|
1144
|
+
except Exception:
|
|
1145
|
+
doc = None
|
|
1146
|
+
|
|
1147
|
+
if doc is None or getattr(doc, "image", None) is None:
|
|
1148
|
+
self._img01 = None
|
|
1149
|
+
self._overlay01 = None
|
|
1150
|
+
self.preview.setText("No active image.")
|
|
1151
|
+
self.preview.setPixmap(QPixmap())
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
img = _to_float01(np.asarray(doc.image))
|
|
1155
|
+
self._img01 = img
|
|
1156
|
+
self._zoom_fit()
|
|
1157
|
+
self._rebuild_overlay()
|
|
1158
|
+
|
|
1159
|
+
# ------------- SEP overlay -------------
|
|
1160
|
+
def _on_thr_changed(self, v: int):
|
|
1161
|
+
self.thr_label.setText(str(v))
|
|
1162
|
+
self._debounce.start()
|
|
1163
|
+
|
|
1164
|
+
def _rebuild_overlay(self):
|
|
1165
|
+
if self._img01 is None:
|
|
1166
|
+
return
|
|
1167
|
+
try:
|
|
1168
|
+
thr = float(self.thr_slider.value())
|
|
1169
|
+
auto = bool(self.chk_autostretch.isChecked())
|
|
1170
|
+
|
|
1171
|
+
img = self._img01
|
|
1172
|
+
# if mono, make a fake RGB for visualization / SEP expects gray anyway
|
|
1173
|
+
if img.ndim == 2:
|
|
1174
|
+
rgb = np.repeat(img[..., None], 3, axis=2)
|
|
1175
|
+
elif img.ndim == 3 and img.shape[2] == 1:
|
|
1176
|
+
rgb = np.repeat(img, 3, axis=2)
|
|
1177
|
+
else:
|
|
1178
|
+
rgb = img
|
|
1179
|
+
|
|
1180
|
+
# Use your WB star detector just for overlay
|
|
1181
|
+
# (balanced output ignored; we only want overlay + count)
|
|
1182
|
+
_balanced, count, overlay = apply_star_based_white_balance(
|
|
1183
|
+
rgb, threshold=thr, autostretch=auto,
|
|
1184
|
+
reuse_cached_sources=False, return_star_colors=False
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
self._overlay01 = overlay
|
|
1188
|
+
self._render_pixmap()
|
|
1189
|
+
self.setWindowTitle(f"Sample Script: Star Preview UI — {count} stars")
|
|
1190
|
+
|
|
1191
|
+
except Exception as e:
|
|
1192
|
+
self._overlay01 = None
|
|
1193
|
+
self.preview.setText(f"Star detection failed:\\n{e}")
|
|
1194
|
+
|
|
1195
|
+
# ------------- Rendering / zoom -------------
|
|
1196
|
+
def _render_pixmap(self):
|
|
1197
|
+
if self._overlay01 is None:
|
|
1198
|
+
return
|
|
1199
|
+
ov = np.clip(self._overlay01, 0, 1)
|
|
1200
|
+
h, w, c = ov.shape
|
|
1201
|
+
qimg = QImage((ov * 255).astype(np.uint8).data, w, h, 3*w, QImage.Format.Format_RGB888)
|
|
1202
|
+
pm = QPixmap.fromImage(qimg)
|
|
1203
|
+
|
|
1204
|
+
# apply zoom
|
|
1205
|
+
zw = int(pm.width() * self._zoom)
|
|
1206
|
+
zh = int(pm.height() * self._zoom)
|
|
1207
|
+
pmz = pm.scaled(zw, zh, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
1208
|
+
self.preview.setPixmap(pmz)
|
|
1209
|
+
|
|
1210
|
+
def _set_zoom(self, z: float):
|
|
1211
|
+
self._zoom = float(np.clip(z, 0.05, 20.0))
|
|
1212
|
+
self._render_pixmap()
|
|
1213
|
+
|
|
1214
|
+
def _zoom_fit(self):
|
|
1215
|
+
if self._overlay01 is None and self._img01 is None:
|
|
1216
|
+
return
|
|
1217
|
+
# fit based on raw image size
|
|
1218
|
+
base = self._overlay01 if self._overlay01 is not None else self._img01
|
|
1219
|
+
h, w = base.shape[:2]
|
|
1220
|
+
vw = max(1, self.preview.width())
|
|
1221
|
+
vh = max(1, self.preview.height())
|
|
1222
|
+
self._zoom = min(vw / w, vh / h)
|
|
1223
|
+
self._render_pixmap()
|
|
1224
|
+
|
|
1225
|
+
# ------------- Demo apply -------------
|
|
1226
|
+
def _apply_demo_wb(self):
|
|
1227
|
+
try:
|
|
1228
|
+
doc = self.ctx.active_document()
|
|
1229
|
+
if doc is None:
|
|
1230
|
+
raise RuntimeError("No active document.")
|
|
1231
|
+
# Reuse your headless preset WB as an example of applying edits
|
|
1232
|
+
preset = {"mode": "star", "threshold": float(self.thr_slider.value())}
|
|
1233
|
+
apply_white_balance_to_doc(doc, preset)
|
|
1234
|
+
QMessageBox.information(self, "Demo", "White Balance applied to active image.")
|
|
1235
|
+
# refresh preview after edit
|
|
1236
|
+
self._load_active_image()
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
QMessageBox.critical(self, "Demo", f"Failed to apply WB:\\n{e}")
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def run(ctx):
|
|
1242
|
+
\"""
|
|
1243
|
+
SASpro entry point.
|
|
1244
|
+
\"""
|
|
1245
|
+
w = StarPreviewDialog(ctx, parent=ctx.app)
|
|
1246
|
+
w.exec()
|
|
1247
|
+
"""
|
|
1248
|
+
|
|
1249
|
+
# ------------------------------------------------------------------
|
|
1250
|
+
# 3) sample_average_two_docs_ui.py (NEW)
|
|
1251
|
+
# ------------------------------------------------------------------
|
|
1252
|
+
samples["sample_average_two_docs_ui.py"] = """\
|
|
1253
|
+
# Sample SASpro script
|
|
1254
|
+
# UI with two dropdowns listing open views by their CURRENT window titles.
|
|
1255
|
+
# Averages the two selected documents and opens a new document.
|
|
1256
|
+
|
|
1257
|
+
from __future__ import annotations
|
|
1258
|
+
|
|
1259
|
+
SCRIPT_NAME = "Average Two Documents (UI Sample)"
|
|
1260
|
+
SCRIPT_GROUP = "Samples"
|
|
1261
|
+
|
|
1262
|
+
import numpy as np
|
|
1263
|
+
|
|
1264
|
+
from PyQt6.QtWidgets import (
|
|
1265
|
+
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
|
|
1266
|
+
QPushButton, QMessageBox
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
class AverageTwoDocsDialog(QDialog):
|
|
1271
|
+
def __init__(self, ctx):
|
|
1272
|
+
super().__init__(parent=ctx.app)
|
|
1273
|
+
self.ctx = ctx
|
|
1274
|
+
self.setWindowTitle("Average Two Documents")
|
|
1275
|
+
self.resize(520, 180)
|
|
1276
|
+
|
|
1277
|
+
self._title_to_doc = {}
|
|
1278
|
+
|
|
1279
|
+
root = QVBoxLayout(self)
|
|
1280
|
+
|
|
1281
|
+
# Row A
|
|
1282
|
+
row_a = QHBoxLayout()
|
|
1283
|
+
row_a.addWidget(QLabel("Document A:"))
|
|
1284
|
+
self.combo_a = QComboBox()
|
|
1285
|
+
row_a.addWidget(self.combo_a, 1)
|
|
1286
|
+
root.addLayout(row_a)
|
|
1287
|
+
|
|
1288
|
+
# Row B
|
|
1289
|
+
row_b = QHBoxLayout()
|
|
1290
|
+
row_b.addWidget(QLabel("Document B:"))
|
|
1291
|
+
self.combo_b = QComboBox()
|
|
1292
|
+
row_b.addWidget(self.combo_b, 1)
|
|
1293
|
+
root.addLayout(row_b)
|
|
1294
|
+
|
|
1295
|
+
# Buttons
|
|
1296
|
+
brow = QHBoxLayout()
|
|
1297
|
+
self.btn_refresh = QPushButton("Refresh List")
|
|
1298
|
+
self.btn_avg = QPushButton("Average → New Doc")
|
|
1299
|
+
self.btn_close = QPushButton("Close")
|
|
1300
|
+
brow.addStretch(1)
|
|
1301
|
+
brow.addWidget(self.btn_refresh)
|
|
1302
|
+
brow.addWidget(self.btn_avg)
|
|
1303
|
+
brow.addWidget(self.btn_close)
|
|
1304
|
+
root.addLayout(brow)
|
|
1305
|
+
|
|
1306
|
+
self.btn_refresh.clicked.connect(self._populate)
|
|
1307
|
+
self.btn_avg.clicked.connect(self._do_average)
|
|
1308
|
+
self.btn_close.clicked.connect(self.reject)
|
|
1309
|
+
|
|
1310
|
+
self._populate()
|
|
1311
|
+
|
|
1312
|
+
def _populate(self):
|
|
1313
|
+
self.combo_a.clear()
|
|
1314
|
+
self.combo_b.clear()
|
|
1315
|
+
self._title_to_doc.clear()
|
|
1316
|
+
|
|
1317
|
+
try:
|
|
1318
|
+
views = self.ctx.list_image_views()
|
|
1319
|
+
except Exception:
|
|
1320
|
+
views = []
|
|
1321
|
+
|
|
1322
|
+
for title, doc in views:
|
|
1323
|
+
# if duplicate names exist, disambiguate slightly
|
|
1324
|
+
key = title
|
|
1325
|
+
if key in self._title_to_doc:
|
|
1326
|
+
# add uid or a counter suffix
|
|
1327
|
+
try:
|
|
1328
|
+
uid = getattr(doc, "uid", "")[:6]
|
|
1329
|
+
key = f"{title} [{uid}]"
|
|
1330
|
+
except Exception:
|
|
1331
|
+
n = 2
|
|
1332
|
+
while f"{title} ({n})" in self._title_to_doc:
|
|
1333
|
+
n += 1
|
|
1334
|
+
key = f"{title} ({n})"
|
|
1335
|
+
|
|
1336
|
+
self._title_to_doc[key] = doc
|
|
1337
|
+
self.combo_a.addItem(key)
|
|
1338
|
+
self.combo_b.addItem(key)
|
|
1339
|
+
|
|
1340
|
+
if self.combo_a.count() == 0:
|
|
1341
|
+
self.combo_a.addItem("<no image views>")
|
|
1342
|
+
self.combo_b.addItem("<no image views>")
|
|
1343
|
+
self.btn_avg.setEnabled(False)
|
|
1344
|
+
else:
|
|
1345
|
+
self.btn_avg.setEnabled(True)
|
|
1346
|
+
|
|
1347
|
+
def _do_average(self):
|
|
1348
|
+
key_a = self.combo_a.currentText()
|
|
1349
|
+
key_b = self.combo_b.currentText()
|
|
1350
|
+
|
|
1351
|
+
doc_a = self._title_to_doc.get(key_a)
|
|
1352
|
+
doc_b = self._title_to_doc.get(key_b)
|
|
1353
|
+
|
|
1354
|
+
if doc_a is None or doc_b is None:
|
|
1355
|
+
QMessageBox.warning(self, "Average", "Please select two valid documents.")
|
|
1356
|
+
return
|
|
1357
|
+
|
|
1358
|
+
img_a = getattr(doc_a, "image", None)
|
|
1359
|
+
img_b = getattr(doc_b, "image", None)
|
|
1360
|
+
|
|
1361
|
+
if img_a is None or img_b is None:
|
|
1362
|
+
QMessageBox.warning(self, "Average", "One of the selected documents has no image.")
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
a = np.asarray(img_a, dtype=np.float32)
|
|
1366
|
+
b = np.asarray(img_b, dtype=np.float32)
|
|
1367
|
+
|
|
1368
|
+
# reconcile mono/color
|
|
1369
|
+
if a.ndim == 2:
|
|
1370
|
+
a = a[..., None]
|
|
1371
|
+
if b.ndim == 2:
|
|
1372
|
+
b = b[..., None]
|
|
1373
|
+
if a.shape[2] == 1 and b.shape[2] == 3:
|
|
1374
|
+
a = np.repeat(a, 3, axis=2)
|
|
1375
|
+
if b.shape[2] == 1 and a.shape[2] == 3:
|
|
1376
|
+
b = np.repeat(b, 3, axis=2)
|
|
1377
|
+
|
|
1378
|
+
if a.shape != b.shape:
|
|
1379
|
+
QMessageBox.warning(
|
|
1380
|
+
self, "Average",
|
|
1381
|
+
f"Shape mismatch:\\nA: {a.shape}\\nB: {b.shape}\\n\\n"
|
|
1382
|
+
"For this sample, images must match exactly."
|
|
1383
|
+
)
|
|
1384
|
+
return
|
|
1385
|
+
|
|
1386
|
+
out = 0.5 * (a + b)
|
|
1387
|
+
|
|
1388
|
+
# name the new doc based on view titles
|
|
1389
|
+
new_name = f"Average({key_a}, {key_b})"
|
|
1390
|
+
|
|
1391
|
+
try:
|
|
1392
|
+
self.ctx.open_new_document(out, metadata={}, name=new_name)
|
|
1393
|
+
QMessageBox.information(self, "Average", f"Created new document:\\n{new_name}")
|
|
1394
|
+
except Exception as e:
|
|
1395
|
+
QMessageBox.critical(self, "Average", f"Failed to create new doc:\\n{e}")
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def run(ctx):
|
|
1399
|
+
dlg = AverageTwoDocsDialog(ctx)
|
|
1400
|
+
dlg.exec()
|
|
1401
|
+
"""
|
|
1402
|
+
|
|
1403
|
+
created = []
|
|
1404
|
+
skipped = []
|
|
1405
|
+
|
|
1406
|
+
for fname, text in samples.items():
|
|
1407
|
+
path = folder / fname
|
|
1408
|
+
if path.exists():
|
|
1409
|
+
skipped.append(fname)
|
|
1410
|
+
continue
|
|
1411
|
+
try:
|
|
1412
|
+
path.write_text(text, encoding="utf-8")
|
|
1413
|
+
created.append(fname)
|
|
1414
|
+
self._log(f"[Scripts] Wrote sample script: {path}")
|
|
1415
|
+
except Exception:
|
|
1416
|
+
self._log(f"[Scripts] Failed to write {fname}:\n{traceback.format_exc()}")
|
|
1417
|
+
|
|
1418
|
+
# user message
|
|
1419
|
+
try:
|
|
1420
|
+
if created and not skipped:
|
|
1421
|
+
QMessageBox.information(
|
|
1422
|
+
self.app, "Sample Scripts Created",
|
|
1423
|
+
"Created sample scripts:\n\n" + "\n".join(created) +
|
|
1424
|
+
"\n\nReload Scripts to see them."
|
|
1425
|
+
)
|
|
1426
|
+
elif created and skipped:
|
|
1427
|
+
QMessageBox.information(
|
|
1428
|
+
self.app, "Sample Scripts Created",
|
|
1429
|
+
"Created:\n" + "\n".join(created) +
|
|
1430
|
+
"\n\nAlready existed:\n" + "\n".join(skipped) +
|
|
1431
|
+
"\n\nReload Scripts to see new ones."
|
|
1432
|
+
)
|
|
1433
|
+
else:
|
|
1434
|
+
QMessageBox.information(
|
|
1435
|
+
self.app, "Sample Scripts",
|
|
1436
|
+
"All sample scripts already exist:\n\n" + "\n".join(skipped)
|
|
1437
|
+
)
|
|
1438
|
+
except Exception:
|
|
1439
|
+
pass
|
|
1440
|
+
|
|
1441
|
+
self._log(f"[Scripts] Failed to write sample script:\n{traceback.format_exc()}")
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _script_id_for_path(self, path: Path, scripts_root: Path, mod=None) -> str:
|
|
1445
|
+
"""
|
|
1446
|
+
Determine a stable script_id.
|
|
1447
|
+
|
|
1448
|
+
Priority:
|
|
1449
|
+
1) SCRIPT_ID / script_id defined in the script (best; survives renames/moves)
|
|
1450
|
+
2) Persisted id in QSettings keyed by *relative path inside scripts_root*
|
|
1451
|
+
(stable across machines if folder structure is same)
|
|
1452
|
+
|
|
1453
|
+
NOTE: We intentionally DO NOT key by absolute path.
|
|
1454
|
+
"""
|
|
1455
|
+
# 1) Prefer explicit SCRIPT_ID in the script file (best)
|
|
1456
|
+
if mod is not None:
|
|
1457
|
+
sid = getattr(mod, "SCRIPT_ID", None) or getattr(mod, "script_id", None)
|
|
1458
|
+
if isinstance(sid, str) and sid.strip():
|
|
1459
|
+
return sid.strip()
|
|
1460
|
+
|
|
1461
|
+
# 2) Persist per-relative-path (not absolute)
|
|
1462
|
+
try:
|
|
1463
|
+
rel = path.relative_to(scripts_root).as_posix()
|
|
1464
|
+
except Exception:
|
|
1465
|
+
rel = path.as_posix()
|
|
1466
|
+
|
|
1467
|
+
s = QSettings()
|
|
1468
|
+
key = f"Scripts/ids_rel/{rel}"
|
|
1469
|
+
sid = s.value(key, "", type=str) or ""
|
|
1470
|
+
if sid:
|
|
1471
|
+
return sid
|
|
1472
|
+
|
|
1473
|
+
sid = uuid.uuid4().hex
|
|
1474
|
+
s.setValue(key, sid)
|
|
1475
|
+
s.sync()
|
|
1476
|
+
return sid
|