setiastrosuitepro 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (342) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.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