setiastrosuitepro 1.6.2.post1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/Background_startup.jpg +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/qml/ResourceMonitor.qml +126 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +945 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +3 -0
- setiastro/saspro/abe.py +1346 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +624 -0
- setiastro/saspro/astrobin_exporter.py +1010 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1841 -0
- setiastro/saspro/autostretch.py +198 -0
- setiastro/saspro/backgroundneutral.py +602 -0
- setiastro/saspro/batch_convert.py +328 -0
- setiastro/saspro/batch_renamer.py +522 -0
- setiastro/saspro/blemish_blaster.py +491 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +213 -0
- setiastro/saspro/clahe.py +368 -0
- setiastro/saspro/comet_stacking.py +1442 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1400 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +190 -0
- setiastro/saspro/cosmicclarity.py +1589 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +973 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2562 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +673 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2664 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +748 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1349 -0
- setiastro/saspro/function_bundle.py +1596 -0
- setiastro/saspro/generate_translations.py +3092 -0
- setiastro/saspro/ghs_dialog_pro.py +663 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +637 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8810 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
- setiastro/saspro/gui/mixins/file_mixin.py +450 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/gui/statistics_dialog.py +47 -0
- setiastro/saspro/halobgon.py +488 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +756 -0
- setiastro/saspro/history_explorer.py +941 -0
- setiastro/saspro/i18n.py +168 -0
- setiastro/saspro/image_combine.py +417 -0
- setiastro/saspro/image_peeker_pro.py +1604 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +236 -0
- setiastro/saspro/isophote.py +1182 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3676 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +537 -0
- setiastro/saspro/live_stacking.py +1841 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +931 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3831 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +407 -0
- setiastro/saspro/multiscale_decomp.py +1293 -0
- setiastro/saspro/nbtorgb_stars.py +541 -0
- setiastro/saspro/numba_utils.py +3145 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1473 -0
- setiastro/saspro/ops/settings.py +637 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1071 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1604 -0
- setiastro/saspro/plate_solver.py +2445 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +331 -0
- setiastro/saspro/remove_stars.py +1599 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +501 -0
- setiastro/saspro/rgb_combination.py +208 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +73 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1472 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1102 -0
- setiastro/saspro/stacking_suite.py +18470 -0
- setiastro/saspro/star_alignment.py +7435 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +765 -0
- setiastro/saspro/star_stretch.py +507 -0
- setiastro/saspro/stat_stretch.py +538 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3328 -0
- setiastro/saspro/supernovaasteroidhunter.py +1719 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/all_source_strings.json +3654 -0
- setiastro/saspro/translations/ar_translations.py +3865 -0
- setiastro/saspro/translations/de_translations.py +3749 -0
- setiastro/saspro/translations/es_translations.py +3939 -0
- setiastro/saspro/translations/fr_translations.py +3858 -0
- setiastro/saspro/translations/hi_translations.py +3571 -0
- setiastro/saspro/translations/integrate_translations.py +270 -0
- setiastro/saspro/translations/it_translations.py +3678 -0
- setiastro/saspro/translations/ja_translations.py +3601 -0
- setiastro/saspro/translations/pt_translations.py +3869 -0
- setiastro/saspro/translations/ru_translations.py +2848 -0
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +255 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +257 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +237 -0
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +257 -0
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +10771 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/sw_translations.py +3671 -0
- setiastro/saspro/translations/uk_translations.py +3700 -0
- setiastro/saspro/translations/zh_translations.py +3675 -0
- setiastro/saspro/versioning.py +77 -0
- setiastro/saspro/view_bundle.py +1558 -0
- setiastro/saspro/wavescale_hdr.py +645 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +680 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +492 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/minigame/game.js +986 -0
- setiastro/saspro/widgets/minigame/index.html +53 -0
- setiastro/saspro/widgets/minigame/style.css +241 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/resource_monitor.py +237 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +331 -0
- setiastro/saspro/wimi.py +7996 -0
- setiastro/saspro/wims.py +578 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
- setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
- setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
# pro/project_io.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import zipfile
|
|
8
|
+
import uuid
|
|
9
|
+
import pickle
|
|
10
|
+
from typing import Any, Dict, List, Tuple
|
|
11
|
+
import numpy as np
|
|
12
|
+
from PyQt6.QtWidgets import QMdiSubWindow
|
|
13
|
+
from PyQt6.QtCore import QPoint, QRect, QTimer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from PyQt6 import sip
|
|
18
|
+
except Exception:
|
|
19
|
+
sip = None
|
|
20
|
+
# ---------- helpers ----------
|
|
21
|
+
def _np_save_to_bytes(arr, *, compress: bool = True) -> bytes:
|
|
22
|
+
"""
|
|
23
|
+
Safely serialize an image-like payload to bytes.
|
|
24
|
+
|
|
25
|
+
- Accepts numpy arrays, things convertible via np.asarray, and (optionally)
|
|
26
|
+
torch tensors if torch is installed.
|
|
27
|
+
- Ensures the final payload is a numeric float32 ndarray before writing.
|
|
28
|
+
- Raises a clear TypeError for non-numeric / unexpected payloads.
|
|
29
|
+
"""
|
|
30
|
+
import numpy as _np
|
|
31
|
+
bio = io.BytesIO()
|
|
32
|
+
|
|
33
|
+
# Unwrap various possible payload types into a numpy array
|
|
34
|
+
a = arr
|
|
35
|
+
|
|
36
|
+
# Torch tensor support (if present)
|
|
37
|
+
try:
|
|
38
|
+
import torch
|
|
39
|
+
except Exception:
|
|
40
|
+
torch = None
|
|
41
|
+
|
|
42
|
+
if torch is not None and isinstance(a, torch.Tensor): # type: ignore[name-defined]
|
|
43
|
+
a = a.detach().cpu().numpy()
|
|
44
|
+
|
|
45
|
+
# If it's not already an ndarray, try to coerce
|
|
46
|
+
if not isinstance(a, _np.ndarray):
|
|
47
|
+
try:
|
|
48
|
+
a = _np.asarray(a)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
raise TypeError(
|
|
51
|
+
f"Unsupported image payload type {type(arr).__name__} (cannot convert to ndarray)"
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
# At this point we MUST have an ndarray
|
|
55
|
+
if not isinstance(a, _np.ndarray):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"Unsupported image payload type after coercion: {type(a).__name__}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Only allow numeric arrays (int/float); bail out on strings/objects
|
|
61
|
+
if not _np.issubdtype(a.dtype, _np.number):
|
|
62
|
+
raise TypeError(
|
|
63
|
+
f"Non-numeric image payload dtype {a.dtype!r} (expected numeric image data)"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
a = a.astype(_np.float32, copy=False)
|
|
67
|
+
|
|
68
|
+
if compress:
|
|
69
|
+
_np.savez_compressed(bio, img=a)
|
|
70
|
+
else:
|
|
71
|
+
_np.save(bio, a)
|
|
72
|
+
|
|
73
|
+
return bio.getvalue()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_dead(obj) -> bool:
|
|
78
|
+
"""True if a PyQt object has been deleted or is None."""
|
|
79
|
+
try:
|
|
80
|
+
if obj is None:
|
|
81
|
+
return True
|
|
82
|
+
if sip is not None:
|
|
83
|
+
return sip.isdeleted(obj)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- NEW: header + file helpers ---------------------------------------------
|
|
90
|
+
def _serialize_header_any(hdr) -> dict:
|
|
91
|
+
"""
|
|
92
|
+
Try to serialize a FITS/ASTAP/whatever header into JSON-safe form.
|
|
93
|
+
Prefers .cards (astropy) -> list of [key, value, comment].
|
|
94
|
+
Falls back to plain dict or repr-string.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
# astropy.io.fits.Header style
|
|
98
|
+
cards = getattr(hdr, "cards", None)
|
|
99
|
+
if cards is not None:
|
|
100
|
+
out = []
|
|
101
|
+
for c in cards:
|
|
102
|
+
# c may be a Card or a tuple-like
|
|
103
|
+
try:
|
|
104
|
+
k = str(getattr(c, "keyword", c[0]))
|
|
105
|
+
v = getattr(c, "value", c[1] if len(c) > 1 else "")
|
|
106
|
+
cm = getattr(c, "comment", c[2] if len(c) > 2 else "")
|
|
107
|
+
except Exception:
|
|
108
|
+
# ultra defensive
|
|
109
|
+
k = str(getattr(c, "keyword", ""))
|
|
110
|
+
v = getattr(c, "value", "")
|
|
111
|
+
cm = getattr(c, "comment", "")
|
|
112
|
+
out.append([k, _json_sanitize(v), str(cm)])
|
|
113
|
+
return {"format": "fits-cards", "cards": out}
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# dict-like fallback
|
|
118
|
+
try:
|
|
119
|
+
if isinstance(hdr, dict):
|
|
120
|
+
return {"format": "dict", "items": {str(k): _json_sanitize(v) for k, v in hdr.items()}}
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
# last resort
|
|
125
|
+
try:
|
|
126
|
+
return {"format": "repr", "text": repr(hdr)}
|
|
127
|
+
except Exception:
|
|
128
|
+
return {"format": "unknown", "text": str(type(hdr))}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _np_load_from_bytes(data: bytes) -> np.ndarray:
|
|
132
|
+
"""
|
|
133
|
+
Reads both npz-with-{'img'} and raw npy.
|
|
134
|
+
Detect format by magic header.
|
|
135
|
+
"""
|
|
136
|
+
# ZIP magic for .npz
|
|
137
|
+
if data[:4] == b'PK\x03\x04':
|
|
138
|
+
bio = io.BytesIO(data)
|
|
139
|
+
with np.load(bio, allow_pickle=False) as z:
|
|
140
|
+
return z["img"].astype(np.float32, copy=False)
|
|
141
|
+
# .npy magic: \x93NUMPY
|
|
142
|
+
bio = io.BytesIO(data)
|
|
143
|
+
arr = np.load(bio, allow_pickle=False)
|
|
144
|
+
return arr.astype(np.float32, copy=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _now_iso() -> str:
|
|
148
|
+
try:
|
|
149
|
+
import datetime as _dt
|
|
150
|
+
return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
|
151
|
+
except Exception:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
def _json_sanitize(obj):
|
|
155
|
+
"""
|
|
156
|
+
Make arbitrary metadata JSON-serializable.
|
|
157
|
+
- numpy scalars/arrays -> lists or float/int
|
|
158
|
+
- objects we can't encode -> string repr
|
|
159
|
+
"""
|
|
160
|
+
import numpy as _np
|
|
161
|
+
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
162
|
+
return obj
|
|
163
|
+
if isinstance(obj, dict):
|
|
164
|
+
return {str(k): _json_sanitize(v) for k, v in obj.items()}
|
|
165
|
+
if isinstance(obj, (list, tuple)):
|
|
166
|
+
return [_json_sanitize(x) for x in obj]
|
|
167
|
+
if isinstance(obj, _np.ndarray):
|
|
168
|
+
# avoid massive JSON; store shape/dtype only
|
|
169
|
+
return {"__nd__": True, "shape": list(obj.shape), "dtype": str(obj.dtype)}
|
|
170
|
+
# numpy scalar
|
|
171
|
+
if hasattr(obj, "item"):
|
|
172
|
+
try:
|
|
173
|
+
return obj.item()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
# astropy header or others -> repr
|
|
177
|
+
try:
|
|
178
|
+
return repr(obj)
|
|
179
|
+
except Exception:
|
|
180
|
+
return str(type(obj))
|
|
181
|
+
|
|
182
|
+
# ---------- main IO ----------
|
|
183
|
+
# pro/project_io.py
|
|
184
|
+
import zipfile
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ProjectWriter:
|
|
188
|
+
VERSION = 1
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def write(path: str, *, docs: list, shortcuts=None, mdi=None, compress: bool = True, shelf=None):
|
|
192
|
+
"""
|
|
193
|
+
Write a .sas project.
|
|
194
|
+
compress=False → much faster saves (bigger file).
|
|
195
|
+
Embeds:
|
|
196
|
+
• current image
|
|
197
|
+
• undo/redo stacks
|
|
198
|
+
• original header (if available) → views/<doc_id>/original_header.json
|
|
199
|
+
• source file copy (if available) → views/<doc_id>/source/<basename>
|
|
200
|
+
and records a pointer in meta so ProjectReader can extract + repoint.
|
|
201
|
+
"""
|
|
202
|
+
import zipfile
|
|
203
|
+
from PyQt6.QtCore import QRect, Qt
|
|
204
|
+
|
|
205
|
+
docs = list(docs or [])
|
|
206
|
+
id_map = {doc: uuid.uuid4().hex for doc in docs}
|
|
207
|
+
|
|
208
|
+
# --- UI / subwindow geometry ---
|
|
209
|
+
ui = {"views": [], "active_doc_id": None}
|
|
210
|
+
minimized_set = set()
|
|
211
|
+
saved_state = {}
|
|
212
|
+
if shelf is not None:
|
|
213
|
+
try:
|
|
214
|
+
minimized_set = set(getattr(shelf, "_item2sub", {}).values())
|
|
215
|
+
saved_state = getattr(shelf, "_saved_state", {})
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
if mdi is not None:
|
|
220
|
+
try:
|
|
221
|
+
active_sw = mdi.activeSubWindow()
|
|
222
|
+
except Exception:
|
|
223
|
+
active_sw = None
|
|
224
|
+
|
|
225
|
+
for sw in getattr(mdi, "subWindowList", lambda: [])():
|
|
226
|
+
try:
|
|
227
|
+
view = sw.widget()
|
|
228
|
+
doc = getattr(view, "document", None)
|
|
229
|
+
if doc not in id_map:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
is_min = sw in minimized_set
|
|
233
|
+
# choose the rectangle we want to persist
|
|
234
|
+
rect = None
|
|
235
|
+
was_max = False
|
|
236
|
+
if is_min:
|
|
237
|
+
st = saved_state.get(sw, {})
|
|
238
|
+
if isinstance(st.get("geom"), QRect):
|
|
239
|
+
rect = QRect(st["geom"])
|
|
240
|
+
was_max = bool(st.get("max", False))
|
|
241
|
+
if rect is None:
|
|
242
|
+
# normal/maximized windows
|
|
243
|
+
was_max = was_max or bool(sw.isMaximized())
|
|
244
|
+
rect = sw.normalGeometry() if was_max else sw.geometry()
|
|
245
|
+
|
|
246
|
+
ui["views"].append({
|
|
247
|
+
"doc_id": id_map[doc],
|
|
248
|
+
"x": rect.x(), "y": rect.y(),
|
|
249
|
+
"w": rect.width(), "h": rect.height(),
|
|
250
|
+
"minimized": bool(is_min),
|
|
251
|
+
"was_max": bool(was_max),
|
|
252
|
+
})
|
|
253
|
+
if sw is active_sw and not is_min:
|
|
254
|
+
ui["active_doc_id"] = id_map[doc]
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# --- Shortcuts dump ---
|
|
259
|
+
sc_dump = []
|
|
260
|
+
if shortcuts is not None:
|
|
261
|
+
for sid, w in list(getattr(shortcuts, "widgets", {}).items()):
|
|
262
|
+
try:
|
|
263
|
+
if hasattr(w, "isVisible") and not w.isVisible():
|
|
264
|
+
continue
|
|
265
|
+
p = w.pos()
|
|
266
|
+
try:
|
|
267
|
+
preset = w._load_preset()
|
|
268
|
+
except Exception:
|
|
269
|
+
preset = None
|
|
270
|
+
sc_dump.append({
|
|
271
|
+
"id": sid,
|
|
272
|
+
"command_id": w.command_id,
|
|
273
|
+
"label": w.text(),
|
|
274
|
+
"x": p.x(), "y": p.y(),
|
|
275
|
+
"preset": preset or None,
|
|
276
|
+
})
|
|
277
|
+
except Exception:
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
# --- Manifest + zip mode ---
|
|
281
|
+
manifest = {
|
|
282
|
+
"version": ProjectWriter.VERSION,
|
|
283
|
+
"created": _now_iso(),
|
|
284
|
+
"doc_count": len(docs),
|
|
285
|
+
}
|
|
286
|
+
zip_mode = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
|
|
287
|
+
|
|
288
|
+
with zipfile.ZipFile(path, "w", compression=zip_mode, allowZip64=True) as z:
|
|
289
|
+
z.writestr("manifest.json", json.dumps(manifest, indent=2))
|
|
290
|
+
z.writestr("ui.json", json.dumps(ui, indent=2))
|
|
291
|
+
z.writestr("shortcuts.json", json.dumps(sc_dump, indent=2))
|
|
292
|
+
|
|
293
|
+
# per-document payloads
|
|
294
|
+
cur_ext = "npz" if compress else "npy"
|
|
295
|
+
hist_ext = cur_ext
|
|
296
|
+
|
|
297
|
+
for doc in docs:
|
|
298
|
+
doc_id = id_map[doc]
|
|
299
|
+
base = f"views/{doc_id}"
|
|
300
|
+
|
|
301
|
+
# ---- gather + sanitize metadata (we'll augment it before writing) ----
|
|
302
|
+
meta = dict(getattr(doc, "metadata", {}) or {})
|
|
303
|
+
meta.setdefault("display_name", doc.display_name())
|
|
304
|
+
|
|
305
|
+
# pull possible header + file path BEFORE we sanitize
|
|
306
|
+
hdr_obj = getattr(doc, "original_header", None)
|
|
307
|
+
if hdr_obj is None:
|
|
308
|
+
# if someone stuffed the raw header object into metadata, remove it to avoid
|
|
309
|
+
# dumping an unreadable repr into meta.json
|
|
310
|
+
try:
|
|
311
|
+
hdr_obj = meta.pop("original_header", None)
|
|
312
|
+
except Exception:
|
|
313
|
+
hdr_obj = None
|
|
314
|
+
|
|
315
|
+
src_path = (
|
|
316
|
+
getattr(doc, "file_path", None)
|
|
317
|
+
or meta.get("file_path")
|
|
318
|
+
or meta.get("source_path")
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# --- embed header (if available) ------------------------------------
|
|
322
|
+
if hdr_obj is not None:
|
|
323
|
+
try:
|
|
324
|
+
hdr_payload = _serialize_header_any(hdr_obj)
|
|
325
|
+
z.writestr(f"{base}/original_header.json", json.dumps(hdr_payload, indent=2))
|
|
326
|
+
meta["_embedded_header"] = "original_header.json"
|
|
327
|
+
except Exception:
|
|
328
|
+
pass # non-fatal
|
|
329
|
+
|
|
330
|
+
# --- embed source file copy (if present on disk) --------------------
|
|
331
|
+
if isinstance(src_path, str) and os.path.isfile(src_path):
|
|
332
|
+
try:
|
|
333
|
+
arc = f"{base}/source/{os.path.basename(src_path)}"
|
|
334
|
+
z.write(src_path, arcname=arc)
|
|
335
|
+
meta["_embedded_source"] = arc
|
|
336
|
+
meta["_original_source_path"] = src_path # for reference only
|
|
337
|
+
except Exception:
|
|
338
|
+
pass # non-fatal
|
|
339
|
+
|
|
340
|
+
# only now create the JSON-safe version and write meta.json
|
|
341
|
+
safe_meta = _json_sanitize(meta)
|
|
342
|
+
z.writestr(f"{base}/meta.json", json.dumps(safe_meta, indent=2))
|
|
343
|
+
|
|
344
|
+
# --- current image ---------------------------------------------------
|
|
345
|
+
if getattr(doc, "image", None) is not None:
|
|
346
|
+
z.writestr(f"{base}/current.{cur_ext}", _np_save_to_bytes(doc.image, compress=compress))
|
|
347
|
+
|
|
348
|
+
# --- history stacks --------------------------------------------------
|
|
349
|
+
# --- history stacks --------------------------------------------------
|
|
350
|
+
undo_list = []
|
|
351
|
+
for i, (img, m, name) in enumerate(getattr(doc, "_undo", []) or []):
|
|
352
|
+
fname = f"history/undo_{i:04d}.{hist_ext}"
|
|
353
|
+
try:
|
|
354
|
+
payload = _np_save_to_bytes(img, compress=compress)
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
# Skip bad entries but keep saving the rest of the project
|
|
357
|
+
# (optional: log exc somewhere)
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
undo_list.append({
|
|
361
|
+
"name": name or "Edit",
|
|
362
|
+
"meta": _json_sanitize(m or {}),
|
|
363
|
+
"file": fname
|
|
364
|
+
})
|
|
365
|
+
z.writestr(f"{base}/{fname}", payload)
|
|
366
|
+
|
|
367
|
+
redo_list = []
|
|
368
|
+
for i, (img, m, name) in enumerate(getattr(doc, "_redo", []) or []):
|
|
369
|
+
fname = f"history/redo_{i:04d}.{hist_ext}"
|
|
370
|
+
try:
|
|
371
|
+
payload = _np_save_to_bytes(img, compress=compress)
|
|
372
|
+
except Exception:
|
|
373
|
+
# Same logic: skip broken entries
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
redo_list.append({
|
|
377
|
+
"name": name or "Edit",
|
|
378
|
+
"meta": _json_sanitize(m or {}),
|
|
379
|
+
"file": fname
|
|
380
|
+
})
|
|
381
|
+
z.writestr(f"{base}/{fname}", payload)
|
|
382
|
+
|
|
383
|
+
z.writestr(
|
|
384
|
+
f"{base}/history/stack.json",
|
|
385
|
+
json.dumps({"undo": undo_list, "redo": redo_list}, indent=2),
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class ProjectReader:
|
|
392
|
+
def __init__(self, main_window):
|
|
393
|
+
self.mw = main_window
|
|
394
|
+
# Prefer the new name, fall back to the old one if present
|
|
395
|
+
self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
|
|
396
|
+
self.sc = getattr(main_window, "shortcuts", None)
|
|
397
|
+
|
|
398
|
+
def read(self, path: str):
|
|
399
|
+
if self.dm is None:
|
|
400
|
+
raise RuntimeError("No DocManager available")
|
|
401
|
+
|
|
402
|
+
if not zipfile.is_zipfile(path):
|
|
403
|
+
LegacyProjectReader(self.mw).read(path)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
407
|
+
# Ensure we have a ShortcutManager and restore shortcuts ONCE
|
|
408
|
+
if not getattr(self.mw, "shortcuts", None):
|
|
409
|
+
from setiastro.saspro.doc_manager import ShortcutManager
|
|
410
|
+
self.mw.shortcuts = ShortcutManager(self.mw.mdi, self.mw)
|
|
411
|
+
self.sc = self.mw.shortcuts
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
self._restore_shortcuts(z) # ← do this ONCE, before docs
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
doc_id_map = {}
|
|
419
|
+
# now iterate docs only
|
|
420
|
+
for name in z.namelist():
|
|
421
|
+
if not name.startswith("views/") or not name.endswith("/meta.json"):
|
|
422
|
+
continue
|
|
423
|
+
base = os.path.dirname(name)
|
|
424
|
+
doc_id = base.split("/")[1]
|
|
425
|
+
|
|
426
|
+
# meta
|
|
427
|
+
try:
|
|
428
|
+
meta = json.loads(z.read(f"{base}/meta.json").decode("utf-8"))
|
|
429
|
+
except Exception:
|
|
430
|
+
meta = {}
|
|
431
|
+
|
|
432
|
+
# current (try npz then npy)
|
|
433
|
+
img = None
|
|
434
|
+
try:
|
|
435
|
+
img = _np_load_from_bytes(z.read(f"{base}/current.npz"))
|
|
436
|
+
except Exception:
|
|
437
|
+
try:
|
|
438
|
+
img = _np_load_from_bytes(z.read(f"{base}/current.npy"))
|
|
439
|
+
except Exception:
|
|
440
|
+
img = None
|
|
441
|
+
|
|
442
|
+
disp = meta.get("display_name") or "Untitled"
|
|
443
|
+
doc = self.dm.create_document(img, metadata=meta, name=disp)
|
|
444
|
+
doc_id_map[doc_id] = doc
|
|
445
|
+
|
|
446
|
+
# --- restore embedded header, if present --------------------------
|
|
447
|
+
try:
|
|
448
|
+
# Prefer explicit file; if not flagged, still try the default path
|
|
449
|
+
hdr_path = meta.get("_embedded_header", "original_header.json")
|
|
450
|
+
if f"{base}/{hdr_path}".replace("//", "/") in z.namelist():
|
|
451
|
+
hdr_json = json.loads(z.read(f"{base}/{hdr_path}").decode("utf-8"))
|
|
452
|
+
setattr(doc, "original_header", hdr_json)
|
|
453
|
+
try:
|
|
454
|
+
doc.metadata["original_header"] = hdr_json
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
|
|
460
|
+
# --- extract embedded source + repoint file_path -------------------
|
|
461
|
+
try:
|
|
462
|
+
# 1) From meta pointer
|
|
463
|
+
arc = meta.get("_embedded_source")
|
|
464
|
+
# 2) If not in meta (older saves), try to find any file under views/<id>/source/
|
|
465
|
+
if not arc:
|
|
466
|
+
prefix = f"{base}/source/"
|
|
467
|
+
for n in z.namelist():
|
|
468
|
+
if n.startswith(prefix) and not n.endswith("/"):
|
|
469
|
+
arc = n
|
|
470
|
+
break
|
|
471
|
+
if arc and arc in z.namelist():
|
|
472
|
+
cache_root = self._ensure_project_cache(os.path.abspath(path))
|
|
473
|
+
extract_path = z.extract(arc, path=cache_root)
|
|
474
|
+
setattr(doc, "file_path", extract_path)
|
|
475
|
+
try:
|
|
476
|
+
doc.metadata["file_path"] = extract_path
|
|
477
|
+
doc.metadata["_extracted_from_project"] = True
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
else:
|
|
481
|
+
# If meta points to a non-existent external file, clear it so we don't
|
|
482
|
+
# spam error dialogs elsewhere.
|
|
483
|
+
fp = meta.get("file_path")
|
|
484
|
+
if isinstance(fp, str) and not os.path.exists(fp):
|
|
485
|
+
setattr(doc, "file_path", None)
|
|
486
|
+
try:
|
|
487
|
+
doc.metadata["_missing_original_source"] = True
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
# --- history -------------------------------------------------------
|
|
494
|
+
try:
|
|
495
|
+
stack = json.loads(z.read(f"{base}/history/stack.json").decode("utf-8"))
|
|
496
|
+
except Exception:
|
|
497
|
+
stack = {"undo": [], "redo": []}
|
|
498
|
+
|
|
499
|
+
undo_tuples = []
|
|
500
|
+
for entry in stack.get("undo", []):
|
|
501
|
+
fname = entry.get("file")
|
|
502
|
+
try:
|
|
503
|
+
arr = _np_load_from_bytes(z.read(f"{base}/{fname}"))
|
|
504
|
+
undo_tuples.append((arr, entry.get("meta") or {}, entry.get("name") or "Edit"))
|
|
505
|
+
except Exception:
|
|
506
|
+
continue
|
|
507
|
+
doc._undo = undo_tuples
|
|
508
|
+
|
|
509
|
+
redo_tuples = []
|
|
510
|
+
for entry in stack.get("redo", []):
|
|
511
|
+
fname = entry.get("file")
|
|
512
|
+
try:
|
|
513
|
+
arr = _np_load_from_bytes(z.read(f"{base}/{fname}"))
|
|
514
|
+
redo_tuples.append((arr, entry.get("meta") or {}, entry.get("name") or "Edit"))
|
|
515
|
+
except Exception:
|
|
516
|
+
continue
|
|
517
|
+
doc._redo = redo_tuples
|
|
518
|
+
|
|
519
|
+
# restore UI geometry/minimized
|
|
520
|
+
try:
|
|
521
|
+
ui = json.loads(z.read("ui.json").decode("utf-8"))
|
|
522
|
+
except Exception:
|
|
523
|
+
ui = None
|
|
524
|
+
|
|
525
|
+
def _do_restore():
|
|
526
|
+
# bail out if the main window got closed/destroyed in the meantime
|
|
527
|
+
if _is_dead(self.mw) or _is_dead(getattr(self.mw, "mdi", None)):
|
|
528
|
+
return
|
|
529
|
+
if ui is not None:
|
|
530
|
+
try:
|
|
531
|
+
self._restore_ui(ui, doc_id_map)
|
|
532
|
+
except Exception:
|
|
533
|
+
# fallback: at least open all docs
|
|
534
|
+
for doc in doc_id_map.values():
|
|
535
|
+
try:
|
|
536
|
+
self.mw._spawn_subwindow_for(doc)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
else:
|
|
540
|
+
# no ui.json — still open all docs
|
|
541
|
+
for doc in doc_id_map.values():
|
|
542
|
+
try:
|
|
543
|
+
self.mw._spawn_subwindow_for(doc)
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
# shortcuts canvas finalization (also guarded)
|
|
547
|
+
self._post_restore_shortcuts()
|
|
548
|
+
|
|
549
|
+
# Defer to avoid racing with dock/MDI state changes during project open/close
|
|
550
|
+
QTimer.singleShot(0, _do_restore)
|
|
551
|
+
|
|
552
|
+
# --- NEW: cache folder for extracted sources ------------------------------
|
|
553
|
+
def _ensure_project_cache(self, project_path: str) -> str:
|
|
554
|
+
"""
|
|
555
|
+
Returns a stable cache directory next to the project for extracted assets.
|
|
556
|
+
e.g. <project_dir>/.sas_cache/<project_filename>/
|
|
557
|
+
"""
|
|
558
|
+
proj_dir = os.path.dirname(project_path)
|
|
559
|
+
proj_name = os.path.splitext(os.path.basename(project_path))[0]
|
|
560
|
+
cache = os.path.join(proj_dir, ".sas_cache", proj_name)
|
|
561
|
+
try:
|
|
562
|
+
os.makedirs(cache, exist_ok=True)
|
|
563
|
+
except Exception:
|
|
564
|
+
pass
|
|
565
|
+
return cache
|
|
566
|
+
|
|
567
|
+
def _post_restore_shortcuts(self):
|
|
568
|
+
"""Ensure the shortcuts canvas is interactive and on top after restore."""
|
|
569
|
+
sc = getattr(self, "sc", None)
|
|
570
|
+
if not sc:
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------- helpers ----------
|
|
575
|
+
def _restore_shortcuts(self, z: zipfile.ZipFile):
|
|
576
|
+
if not self.sc:
|
|
577
|
+
return
|
|
578
|
+
data = []
|
|
579
|
+
try:
|
|
580
|
+
data = json.loads(z.read("shortcuts.json").decode("utf-8"))
|
|
581
|
+
except Exception:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
# Clear existing canvas
|
|
585
|
+
try:
|
|
586
|
+
self.sc.clear()
|
|
587
|
+
except Exception:
|
|
588
|
+
pass
|
|
589
|
+
|
|
590
|
+
# Recreate
|
|
591
|
+
for entry in data:
|
|
592
|
+
cid = entry.get("command_id")
|
|
593
|
+
sid = entry.get("id") or uuid.uuid4().hex
|
|
594
|
+
label = entry.get("label") or cid
|
|
595
|
+
x = int(entry.get("x", 10)); y = int(entry.get("y", 10))
|
|
596
|
+
w = self.sc.add_shortcut(
|
|
597
|
+
cid,
|
|
598
|
+
QPoint(x, y),
|
|
599
|
+
label=label,
|
|
600
|
+
shortcut_id=sid,
|
|
601
|
+
)
|
|
602
|
+
# move exact
|
|
603
|
+
try:
|
|
604
|
+
w = self.sc.widgets.get(sid)
|
|
605
|
+
if w:
|
|
606
|
+
w.move(x, y)
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
# preset
|
|
610
|
+
preset = entry.get("preset")
|
|
611
|
+
if preset is not None:
|
|
612
|
+
try:
|
|
613
|
+
w = self.sc.widgets.get(sid)
|
|
614
|
+
if w:
|
|
615
|
+
w._save_preset(preset)
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
# persist
|
|
619
|
+
try:
|
|
620
|
+
self.sc.save_shortcuts()
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
|
|
624
|
+
def _restore_ui(self, ui: dict, id_map: dict):
|
|
625
|
+
# Validate window & MDI — avoid calling into deleted C++ objects
|
|
626
|
+
if _is_dead(self.mw) or _is_dead(getattr(self.mw, "mdi", None)):
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
views = ui.get("views", [])
|
|
630
|
+
active_id = ui.get("active_doc_id")
|
|
631
|
+
active_sw = None
|
|
632
|
+
shelf = getattr(self.mw, "window_shelf", None)
|
|
633
|
+
|
|
634
|
+
for v in views:
|
|
635
|
+
if _is_dead(self.mw): # recheck per-iteration
|
|
636
|
+
return
|
|
637
|
+
doc = id_map.get(v.get("doc_id"))
|
|
638
|
+
if not doc:
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
sw = self.mw._spawn_subwindow_for(doc)
|
|
643
|
+
except Exception:
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
# geometry from project
|
|
647
|
+
try:
|
|
648
|
+
is_min = bool(v.get("minimized", False))
|
|
649
|
+
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
652
|
+
|
|
653
|
+
if v.get("doc_id") == active_id and not is_min:
|
|
654
|
+
active_sw = sw
|
|
655
|
+
|
|
656
|
+
if active_sw and not _is_dead(self.mw):
|
|
657
|
+
try:
|
|
658
|
+
self.mw.mdi.setActiveSubWindow(active_sw)
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
class LegacyProjectReader:
|
|
664
|
+
"""
|
|
665
|
+
Reads SASv2 pickle projects and coerces them into SASpro documents.
|
|
666
|
+
This class is completely separate so SASpro loading behavior is unchanged.
|
|
667
|
+
"""
|
|
668
|
+
def __init__(self, main_window):
|
|
669
|
+
self.mw = main_window
|
|
670
|
+
self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
|
|
671
|
+
self.sc = getattr(main_window, "shortcuts", None)
|
|
672
|
+
|
|
673
|
+
def read(self, path: str):
|
|
674
|
+
import pickle
|
|
675
|
+
import numpy as np
|
|
676
|
+
|
|
677
|
+
if self.dm is None:
|
|
678
|
+
raise RuntimeError("No DocManager available")
|
|
679
|
+
|
|
680
|
+
with open(path, "rb") as f:
|
|
681
|
+
try:
|
|
682
|
+
data = pickle.load(f)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
raise RuntimeError(f"Not a SASpro project and failed to parse legacy SASv2 pickle: {e}")
|
|
685
|
+
|
|
686
|
+
images: dict = data.get("images") or {}
|
|
687
|
+
meta_by_slot: dict = data.get("metadata") or {}
|
|
688
|
+
slot_names: dict = data.get("slot_names") or {}
|
|
689
|
+
undo_by_slot: dict = data.get("undo_stacks") or {}
|
|
690
|
+
redo_by_slot: dict = data.get("redo_stacks") or {}
|
|
691
|
+
masks: dict = data.get("masks") or {}
|
|
692
|
+
current_slot = data.get("current_slot", None)
|
|
693
|
+
|
|
694
|
+
# Legacy projects had no shortcuts; ensure manager exists but don't restore anything
|
|
695
|
+
if not getattr(self.mw, "shortcuts", None):
|
|
696
|
+
try:
|
|
697
|
+
from setiastro.saspro.doc_manager import ShortcutManager
|
|
698
|
+
self.mw.shortcuts = ShortcutManager(self.mw.mdi, self.mw)
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
self.sc = getattr(self.mw, "shortcuts", None)
|
|
702
|
+
|
|
703
|
+
_log = getattr(self.mw, "update_status", None)
|
|
704
|
+
|
|
705
|
+
doc_for_slot = {}
|
|
706
|
+
active_sw = None
|
|
707
|
+
first_sw = None
|
|
708
|
+
|
|
709
|
+
for slot, arr in sorted(images.items(), key=lambda kv: kv[0]):
|
|
710
|
+
# Convert to array
|
|
711
|
+
try:
|
|
712
|
+
img = np.asarray(arr)
|
|
713
|
+
except Exception:
|
|
714
|
+
if _log: _log(f"Skipping slot {slot}: unreadable image payload.")
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
# Skip empty/tiny (≤ 10×10)
|
|
718
|
+
if self._is_tiny_or_empty(img):
|
|
719
|
+
if _log: _log(f"Skipping slot {slot}: empty/tiny image (≤ 10×10).")
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
# Normalize dtype
|
|
723
|
+
if img.dtype != np.float32:
|
|
724
|
+
img = img.astype(np.float32, copy=False)
|
|
725
|
+
|
|
726
|
+
disp = slot_names.get(slot) or f"Slot {slot}"
|
|
727
|
+
meta = dict(meta_by_slot.get(slot, {}) or {})
|
|
728
|
+
meta.setdefault("source", "SASv2")
|
|
729
|
+
if slot in masks and masks[slot] is not None:
|
|
730
|
+
meta["legacy_mask_present"] = True
|
|
731
|
+
|
|
732
|
+
doc = self.dm.create_document(img, metadata=meta, name=disp)
|
|
733
|
+
|
|
734
|
+
# Attach legacy mask (in-memory only)
|
|
735
|
+
try:
|
|
736
|
+
if slot in masks and masks[slot] is not None:
|
|
737
|
+
setattr(doc, "_legacy_mask", np.asarray(masks[slot]))
|
|
738
|
+
except Exception:
|
|
739
|
+
pass
|
|
740
|
+
|
|
741
|
+
# Undo/Redo
|
|
742
|
+
doc._undo = self._coerce_legacy_stack(undo_by_slot.get(slot) or [])
|
|
743
|
+
doc._redo = self._coerce_legacy_stack(redo_by_slot.get(slot) or [])
|
|
744
|
+
|
|
745
|
+
doc_for_slot[slot] = doc
|
|
746
|
+
|
|
747
|
+
if _is_dead(self.mw) or _is_dead(getattr(self.mw, "mdi", None)):
|
|
748
|
+
return
|
|
749
|
+
|
|
750
|
+
# Open subwindow
|
|
751
|
+
try:
|
|
752
|
+
sw = self.mw._spawn_subwindow_for(doc)
|
|
753
|
+
if first_sw is None:
|
|
754
|
+
first_sw = sw
|
|
755
|
+
if current_slot is not None and slot == current_slot:
|
|
756
|
+
active_sw = sw
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
|
|
760
|
+
if not doc_for_slot:
|
|
761
|
+
if _log: _log("No non-empty slots found in legacy project.")
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
try:
|
|
765
|
+
self.mw.mdi.setActiveSubWindow(active_sw or first_sw)
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
@staticmethod
|
|
770
|
+
def _is_tiny_or_empty(img) -> bool:
|
|
771
|
+
import numpy as _np
|
|
772
|
+
if img is None:
|
|
773
|
+
return True
|
|
774
|
+
if not isinstance(img, _np.ndarray):
|
|
775
|
+
return True
|
|
776
|
+
if img.ndim < 2:
|
|
777
|
+
return True
|
|
778
|
+
h, w = img.shape[:2]
|
|
779
|
+
return (h <= 10 or w <= 10)
|
|
780
|
+
|
|
781
|
+
def _coerce_legacy_stack(self, stack_list):
|
|
782
|
+
import numpy as np
|
|
783
|
+
out = []
|
|
784
|
+
for entry in stack_list:
|
|
785
|
+
try:
|
|
786
|
+
if isinstance(entry, tuple):
|
|
787
|
+
arr = entry[0]
|
|
788
|
+
meta = entry[1] if len(entry) >= 2 and isinstance(entry[1], dict) else {}
|
|
789
|
+
name = entry[2] if len(entry) >= 3 and isinstance(entry[2], str) else "Edit"
|
|
790
|
+
else:
|
|
791
|
+
arr = entry
|
|
792
|
+
meta, name = {}, "Edit"
|
|
793
|
+
arr = np.asarray(arr).astype(np.float32, copy=False)
|
|
794
|
+
out.append((arr, meta, name))
|
|
795
|
+
except Exception:
|
|
796
|
+
continue
|
|
797
|
+
return out
|