setiastrosuitepro 1.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/__init__.py +2 -0
- setiastro/data/SASP_data.fits +0 -0
- setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
- setiastro/data/catalogs/astrobin_filters.csv +890 -0
- setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
- setiastro/data/catalogs/cali2.csv +63 -0
- setiastro/data/catalogs/cali2color.csv +65 -0
- setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
- setiastro/data/catalogs/celestial_catalog.csv +24031 -0
- setiastro/data/catalogs/detected_stars.csv +24784 -0
- setiastro/data/catalogs/fits_header_data.csv +46 -0
- setiastro/data/catalogs/test.csv +8 -0
- setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
- setiastro/images/Astro_Spikes.png +0 -0
- setiastro/images/HRDiagram.png +0 -0
- setiastro/images/LExtract.png +0 -0
- setiastro/images/LInsert.png +0 -0
- setiastro/images/Oxygenation-atm-2.svg.png +0 -0
- setiastro/images/RGB080604.png +0 -0
- setiastro/images/abeicon.png +0 -0
- setiastro/images/aberration.png +0 -0
- setiastro/images/andromedatry.png +0 -0
- setiastro/images/andromedatry_satellited.png +0 -0
- setiastro/images/annotated.png +0 -0
- setiastro/images/aperture.png +0 -0
- setiastro/images/astrosuite.ico +0 -0
- setiastro/images/astrosuite.png +0 -0
- setiastro/images/astrosuitepro.icns +0 -0
- setiastro/images/astrosuitepro.ico +0 -0
- setiastro/images/astrosuitepro.png +0 -0
- setiastro/images/background.png +0 -0
- setiastro/images/background2.png +0 -0
- setiastro/images/benchmark.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline.png +0 -0
- setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
- setiastro/images/blaster.png +0 -0
- setiastro/images/blink.png +0 -0
- setiastro/images/clahe.png +0 -0
- setiastro/images/collage.png +0 -0
- setiastro/images/colorwheel.png +0 -0
- setiastro/images/contsub.png +0 -0
- setiastro/images/convo.png +0 -0
- setiastro/images/copyslot.png +0 -0
- setiastro/images/cosmic.png +0 -0
- setiastro/images/cosmicsat.png +0 -0
- setiastro/images/crop1.png +0 -0
- setiastro/images/cropicon.png +0 -0
- setiastro/images/curves.png +0 -0
- setiastro/images/cvs.png +0 -0
- setiastro/images/debayer.png +0 -0
- setiastro/images/denoise_cnn_custom.png +0 -0
- setiastro/images/denoise_cnn_graph.png +0 -0
- setiastro/images/disk.png +0 -0
- setiastro/images/dse.png +0 -0
- setiastro/images/exoicon.png +0 -0
- setiastro/images/eye.png +0 -0
- setiastro/images/fliphorizontal.png +0 -0
- setiastro/images/flipvertical.png +0 -0
- setiastro/images/font.png +0 -0
- setiastro/images/freqsep.png +0 -0
- setiastro/images/functionbundle.png +0 -0
- setiastro/images/graxpert.png +0 -0
- setiastro/images/green.png +0 -0
- setiastro/images/gridicon.png +0 -0
- setiastro/images/halo.png +0 -0
- setiastro/images/hdr.png +0 -0
- setiastro/images/histogram.png +0 -0
- setiastro/images/hubble.png +0 -0
- setiastro/images/imagecombine.png +0 -0
- setiastro/images/invert.png +0 -0
- setiastro/images/isophote.png +0 -0
- setiastro/images/isophote_demo_figure.png +0 -0
- setiastro/images/isophote_demo_image.png +0 -0
- setiastro/images/isophote_demo_model.png +0 -0
- setiastro/images/isophote_demo_residual.png +0 -0
- setiastro/images/jwstpupil.png +0 -0
- setiastro/images/linearfit.png +0 -0
- setiastro/images/livestacking.png +0 -0
- setiastro/images/mask.png +0 -0
- setiastro/images/maskapply.png +0 -0
- setiastro/images/maskcreate.png +0 -0
- setiastro/images/maskremove.png +0 -0
- setiastro/images/morpho.png +0 -0
- setiastro/images/mosaic.png +0 -0
- setiastro/images/multiscale_decomp.png +0 -0
- setiastro/images/nbtorgb.png +0 -0
- setiastro/images/neutral.png +0 -0
- setiastro/images/nuke.png +0 -0
- setiastro/images/openfile.png +0 -0
- setiastro/images/pedestal.png +0 -0
- setiastro/images/pen.png +0 -0
- setiastro/images/pixelmath.png +0 -0
- setiastro/images/platesolve.png +0 -0
- setiastro/images/ppp.png +0 -0
- setiastro/images/pro.png +0 -0
- setiastro/images/project.png +0 -0
- setiastro/images/psf.png +0 -0
- setiastro/images/redo.png +0 -0
- setiastro/images/redoicon.png +0 -0
- setiastro/images/rescale.png +0 -0
- setiastro/images/rgbalign.png +0 -0
- setiastro/images/rgbcombo.png +0 -0
- setiastro/images/rgbextract.png +0 -0
- setiastro/images/rotate180.png +0 -0
- setiastro/images/rotateclockwise.png +0 -0
- setiastro/images/rotatecounterclockwise.png +0 -0
- setiastro/images/satellite.png +0 -0
- setiastro/images/script.png +0 -0
- setiastro/images/selectivecolor.png +0 -0
- setiastro/images/simbad.png +0 -0
- setiastro/images/slot0.png +0 -0
- setiastro/images/slot1.png +0 -0
- setiastro/images/slot2.png +0 -0
- setiastro/images/slot3.png +0 -0
- setiastro/images/slot4.png +0 -0
- setiastro/images/slot5.png +0 -0
- setiastro/images/slot6.png +0 -0
- setiastro/images/slot7.png +0 -0
- setiastro/images/slot8.png +0 -0
- setiastro/images/slot9.png +0 -0
- setiastro/images/spcc.png +0 -0
- setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
- setiastro/images/spinner.gif +0 -0
- setiastro/images/stacking.png +0 -0
- setiastro/images/staradd.png +0 -0
- setiastro/images/staralign.png +0 -0
- setiastro/images/starnet.png +0 -0
- setiastro/images/starregistration.png +0 -0
- setiastro/images/starspike.png +0 -0
- setiastro/images/starstretch.png +0 -0
- setiastro/images/statstretch.png +0 -0
- setiastro/images/supernova.png +0 -0
- setiastro/images/uhs.png +0 -0
- setiastro/images/undoicon.png +0 -0
- setiastro/images/upscale.png +0 -0
- setiastro/images/viewbundle.png +0 -0
- setiastro/images/whitebalance.png +0 -0
- setiastro/images/wimi_icon_256x256.png +0 -0
- setiastro/images/wimilogo.png +0 -0
- setiastro/images/wims.png +0 -0
- setiastro/images/wrench_icon.png +0 -0
- setiastro/images/xisfliberator.png +0 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +809 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2926 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +178 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/common_tr.py +107 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +956 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2641 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +745 -0
- setiastro/saspro/fix_bom.py +32 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/generate_translations.py +2378 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8567 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +443 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +448 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/i18n.py +156 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1601 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +679 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1070 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2444 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +477 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1430 -0
- setiastro/saspro/shortcuts.py +3043 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +18181 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +506 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1716 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/translations/de_translations.py +3733 -0
- setiastro/saspro/translations/es_translations.py +3923 -0
- setiastro/saspro/translations/fr_translations.py +3842 -0
- setiastro/saspro/translations/integrate_translations.py +234 -0
- setiastro/saspro/translations/it_translations.py +3662 -0
- setiastro/saspro/translations/ja_translations.py +3585 -0
- setiastro/saspro/translations/pt_translations.py +3853 -0
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +253 -0
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +12520 -0
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +12514 -0
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +12520 -0
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +257 -0
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +257 -0
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +12520 -0
- setiastro/saspro/translations/zh_translations.py +3659 -0
- setiastro/saspro/versioning.py +71 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +101 -0
- setiastro/saspro/wavescalede.py +658 -0
- setiastro/saspro/wavescalede_preset.py +230 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +306 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
- setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
- setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,2641 @@
|
|
|
1
|
+
# pro/doc_manager.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from PyQt6.QtCore import QObject, pyqtSignal, Qt, QTimer
|
|
4
|
+
from PyQt6.QtWidgets import QApplication, QMessageBox
|
|
5
|
+
import os
|
|
6
|
+
import numpy as np
|
|
7
|
+
from setiastro.saspro.xisf import XISF as XISFReader
|
|
8
|
+
from astropy.io import fits # local import; optional dep
|
|
9
|
+
from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image, save_image as legacy_save_image
|
|
10
|
+
from setiastro.saspro.legacy.image_manager import list_fits_extensions, load_fits_extension
|
|
11
|
+
import uuid
|
|
12
|
+
from setiastro.saspro.legacy.image_manager import attach_wcs_to_metadata # or wherever you put it
|
|
13
|
+
from astropy.wcs import WCS # only if not already imported in this module
|
|
14
|
+
from setiastro.saspro.debug_utils import debug_dump_metadata
|
|
15
|
+
|
|
16
|
+
# Memory utilities for lazy loading and caching
|
|
17
|
+
try:
|
|
18
|
+
from setiastro.saspro.memory_utils import get_thumbnail_cache, LazyImage
|
|
19
|
+
except ImportError:
|
|
20
|
+
get_thumbnail_cache = None
|
|
21
|
+
LazyImage = None
|
|
22
|
+
|
|
23
|
+
from setiastro.saspro.swap_manager import get_swap_manager
|
|
24
|
+
from setiastro.saspro.widgets.image_utils import ensure_contiguous
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
# --- WCS DEBUGGING ------------------------------------------------------
|
|
28
|
+
_DEBUG_WCS = False # flip to False when you’re done debugging
|
|
29
|
+
|
|
30
|
+
def _debug_log_wcs_context(context: str, meta_or_hdr):
|
|
31
|
+
"""
|
|
32
|
+
Tiny helper to print key WCS bits:
|
|
33
|
+
- NAXIS1/2
|
|
34
|
+
- CRPIX1/2
|
|
35
|
+
- CRVAL1/2
|
|
36
|
+
- CDELT / CD if present
|
|
37
|
+
Works if you pass either a metadata dict or a FITS-like header dict.
|
|
38
|
+
"""
|
|
39
|
+
if not _DEBUG_WCS:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Try to resolve a header from a metadata dict
|
|
43
|
+
hdr = None
|
|
44
|
+
if isinstance(meta_or_hdr, dict):
|
|
45
|
+
# metadata dict with possible header keys
|
|
46
|
+
hdr = (meta_or_hdr.get("original_header")
|
|
47
|
+
or meta_or_hdr.get("fits_header")
|
|
48
|
+
or meta_or_hdr.get("header"))
|
|
49
|
+
if hdr is None:
|
|
50
|
+
# maybe you passed the header dict directly
|
|
51
|
+
hdr = meta_or_hdr
|
|
52
|
+
else:
|
|
53
|
+
hdr = meta_or_hdr
|
|
54
|
+
|
|
55
|
+
if hdr is None:
|
|
56
|
+
print(f"[WCS DEBUG] {context}: no header found")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Normalize dict-like header
|
|
60
|
+
if hasattr(hdr, "keys"): # astropy Header or dict
|
|
61
|
+
try:
|
|
62
|
+
keys = list(hdr.keys())
|
|
63
|
+
except Exception:
|
|
64
|
+
keys = []
|
|
65
|
+
else:
|
|
66
|
+
print(f"[WCS DEBUG] {context}: header is non-mapping type {type(hdr)}")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
def _get(k, default=None):
|
|
70
|
+
try:
|
|
71
|
+
return hdr.get(k, default)
|
|
72
|
+
except Exception:
|
|
73
|
+
try:
|
|
74
|
+
return hdr[k]
|
|
75
|
+
except Exception:
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
naxis1 = _get("NAXIS1")
|
|
79
|
+
naxis2 = _get("NAXIS2")
|
|
80
|
+
crpix1 = _get("CRPIX1")
|
|
81
|
+
crpix2 = _get("CRPIX2")
|
|
82
|
+
crval1 = _get("CRVAL1")
|
|
83
|
+
crval2 = _get("CRVAL2")
|
|
84
|
+
|
|
85
|
+
cd11 = _get("CD1_1")
|
|
86
|
+
cd12 = _get("CD1_2")
|
|
87
|
+
cd21 = _get("CD2_1")
|
|
88
|
+
cd22 = _get("CD2_2")
|
|
89
|
+
cdelt1 = _get("CDELT1")
|
|
90
|
+
cdelt2 = _get("CDELT2")
|
|
91
|
+
|
|
92
|
+
print(f"[WCS DEBUG] {context}:")
|
|
93
|
+
print(f" NAXIS1={naxis1} NAXIS2={naxis2}")
|
|
94
|
+
print(f" CRPIX1={crpix1} CRPIX2={crpix2}")
|
|
95
|
+
print(f" CRVAL1={crval1} CRVAL2={crval2}")
|
|
96
|
+
if any(v is not None for v in (cd11, cd12, cd21, cd22)):
|
|
97
|
+
print(f" CD = [[{cd11}, {cd12}], [{cd21}, {cd22}]]")
|
|
98
|
+
if cdelt1 is not None or cdelt2 is not None:
|
|
99
|
+
print(f" CDELT1={cdelt1} CDELT2={cdelt2}")
|
|
100
|
+
print("")
|
|
101
|
+
|
|
102
|
+
_DEBUG_UNDO = False # set True while chasing the GraXpert crash
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _debug_log_undo(context: str, **info):
|
|
106
|
+
"""
|
|
107
|
+
Lightweight logger for undo/redo/update activity.
|
|
108
|
+
Safe: never raises, even if repr() is weird.
|
|
109
|
+
"""
|
|
110
|
+
if not _DEBUG_UNDO:
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
bits = []
|
|
114
|
+
for k, v in info.items():
|
|
115
|
+
try:
|
|
116
|
+
s = str(v)
|
|
117
|
+
except Exception:
|
|
118
|
+
try:
|
|
119
|
+
s = repr(v)
|
|
120
|
+
except Exception:
|
|
121
|
+
s = f"<unrepr {type(v)}>"
|
|
122
|
+
bits.append(f"{k}={s}")
|
|
123
|
+
print(f"[UNDO DEBUG] {context}: " + ", ".join(bits))
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# Last-resort safety – don't let logging itself kill us
|
|
126
|
+
try:
|
|
127
|
+
print(f"[UNDO DEBUG] {context}: <logging failed: {e}>")
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
from setiastro.saspro.file_utils import _normalize_ext
|
|
132
|
+
|
|
133
|
+
def _normalize_image_01(arr: np.ndarray) -> np.ndarray:
|
|
134
|
+
"""
|
|
135
|
+
Normalize an image to [0,1] in-place-ish:
|
|
136
|
+
|
|
137
|
+
1. If min < 0 → shift so min becomes 0.
|
|
138
|
+
2. Then if max > 1 → divide by max.
|
|
139
|
+
|
|
140
|
+
NaNs/Infs are ignored when computing min/max.
|
|
141
|
+
Returns float32 array.
|
|
142
|
+
"""
|
|
143
|
+
if arr is None:
|
|
144
|
+
return arr
|
|
145
|
+
|
|
146
|
+
a = np.asarray(arr, dtype=np.float32)
|
|
147
|
+
finite = np.isfinite(a)
|
|
148
|
+
if not finite.any():
|
|
149
|
+
# completely bogus; give back zeros
|
|
150
|
+
return np.zeros_like(a, dtype=np.float32)
|
|
151
|
+
|
|
152
|
+
# Step 1: shift up if we have negatives
|
|
153
|
+
min_val = a[finite].min()
|
|
154
|
+
if min_val < 0.0:
|
|
155
|
+
a = a - min_val
|
|
156
|
+
finite = np.isfinite(a)
|
|
157
|
+
|
|
158
|
+
# Step 2: scale down if we exceed 1
|
|
159
|
+
max_val = a[finite].max()
|
|
160
|
+
if max_val > 1.0 and max_val > 0.0:
|
|
161
|
+
a = a / max_val
|
|
162
|
+
|
|
163
|
+
return a
|
|
164
|
+
|
|
165
|
+
_ALLOWED_DEPTHS = {
|
|
166
|
+
"png": {"8-bit"},
|
|
167
|
+
"jpg": {"8-bit"},
|
|
168
|
+
"fits": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
|
|
169
|
+
"fit": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
|
|
170
|
+
"tif": {"8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"},
|
|
171
|
+
"xisf": {"16-bit", "32-bit unsigned", "32-bit floating point"},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class TableDocument(QObject):
|
|
175
|
+
changed = pyqtSignal()
|
|
176
|
+
|
|
177
|
+
def __init__(self, rows: list[list], headers: list[str], metadata: dict | None = None, parent=None):
|
|
178
|
+
super().__init__(parent)
|
|
179
|
+
self.rows = rows # list of list (2D) for QAbstractTableModel
|
|
180
|
+
self.headers = headers # list of column names
|
|
181
|
+
self.metadata = dict(metadata or {})
|
|
182
|
+
self._undo = []
|
|
183
|
+
self._redo = []
|
|
184
|
+
|
|
185
|
+
def display_name(self) -> str:
|
|
186
|
+
dn = self.metadata.get("display_name")
|
|
187
|
+
if dn:
|
|
188
|
+
return dn
|
|
189
|
+
p = self.metadata.get("file_path")
|
|
190
|
+
return os.path.basename(p) if p else "Untitled Table"
|
|
191
|
+
|
|
192
|
+
def can_undo(self) -> bool: return False
|
|
193
|
+
def can_redo(self) -> bool: return False
|
|
194
|
+
def last_undo_name(self) -> str | None: return None
|
|
195
|
+
def last_redo_name(self) -> str | None: return None
|
|
196
|
+
def undo(self) -> str | None: return None
|
|
197
|
+
def redo(self) -> str | None: return None
|
|
198
|
+
|
|
199
|
+
class ImageDocument(QObject):
|
|
200
|
+
changed = pyqtSignal()
|
|
201
|
+
|
|
202
|
+
def __init__(self, image: np.ndarray, metadata: dict | None = None, parent=None):
|
|
203
|
+
super().__init__(parent)
|
|
204
|
+
self.image = image
|
|
205
|
+
self.metadata = dict(metadata or {})
|
|
206
|
+
self.mask = None
|
|
207
|
+
# _undo / _redo now store tuples: (swap_id: str, metadata: dict, step_name: str)
|
|
208
|
+
self._undo: list[tuple[str, dict, str]] = []
|
|
209
|
+
self._redo: list[tuple[str, dict, str]] = []
|
|
210
|
+
self.masks: dict[str, np.ndarray] = {}
|
|
211
|
+
self.active_mask_id: str | None = None
|
|
212
|
+
self.uid = uuid.uuid4().hex # stable identity for DnD, layers, masks, etc.
|
|
213
|
+
|
|
214
|
+
# NEW: operation log — list of simple dicts
|
|
215
|
+
# Each entry: {
|
|
216
|
+
# "id": str,
|
|
217
|
+
# "step": str,
|
|
218
|
+
# "params": dict,
|
|
219
|
+
# "roi": (x,y,w,h) | None,
|
|
220
|
+
# "source": "full" | "roi",
|
|
221
|
+
# "ts": float
|
|
222
|
+
# }
|
|
223
|
+
self._op_log: list[dict] = []
|
|
224
|
+
|
|
225
|
+
# Track unsaved changes explicitly
|
|
226
|
+
self.dirty: bool = False
|
|
227
|
+
|
|
228
|
+
# Copy-on-write support: if this document shares image data with another,
|
|
229
|
+
# _cow_source holds reference to the source. On first write (apply_edit),
|
|
230
|
+
# we copy the image data and clear _cow_source.
|
|
231
|
+
self._cow_source: 'ImageDocument | None' = None
|
|
232
|
+
# --- history helpers (NEW) ---
|
|
233
|
+
# --- operation log helpers (NEW) -----------------------------------
|
|
234
|
+
def record_operation(
|
|
235
|
+
self,
|
|
236
|
+
step_name: str,
|
|
237
|
+
params: dict | None = None,
|
|
238
|
+
roi: tuple[int, int, int, int] | None = None,
|
|
239
|
+
source: str = "full",
|
|
240
|
+
) -> str:
|
|
241
|
+
"""
|
|
242
|
+
Append a param-record for this edit. This is *lightweight* metadata
|
|
243
|
+
used for replaying ROI recipes etc; it does NOT affect undo/redo.
|
|
244
|
+
"""
|
|
245
|
+
import time as _time
|
|
246
|
+
op_id = uuid.uuid4().hex
|
|
247
|
+
entry = {
|
|
248
|
+
"id": op_id,
|
|
249
|
+
"step": step_name or "Edit",
|
|
250
|
+
"params": _dm_json_sanitize(params or {}),
|
|
251
|
+
"roi": tuple(roi) if roi else None,
|
|
252
|
+
"source": str(source or "full"),
|
|
253
|
+
"ts": float(_time.time()),
|
|
254
|
+
}
|
|
255
|
+
self._op_log.append(entry)
|
|
256
|
+
return op_id
|
|
257
|
+
|
|
258
|
+
def get_operation_log(self) -> list[dict]:
|
|
259
|
+
"""Return a copy of the operation log (for UI / replay)."""
|
|
260
|
+
return list(self._op_log)
|
|
261
|
+
|
|
262
|
+
def clear_operation_log(self):
|
|
263
|
+
"""Clear the operation log (does not touch pixel history)."""
|
|
264
|
+
self._op_log.clear()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def can_undo(self) -> bool:
|
|
268
|
+
return bool(self._undo)
|
|
269
|
+
|
|
270
|
+
def can_redo(self) -> bool:
|
|
271
|
+
return bool(self._redo)
|
|
272
|
+
|
|
273
|
+
def last_undo_name(self) -> str | None:
|
|
274
|
+
return self._undo[-1][2] if self._undo else None
|
|
275
|
+
|
|
276
|
+
def last_redo_name(self) -> str | None:
|
|
277
|
+
return self._redo[-1][2] if self._redo else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def add_mask(self, mask: Any, mask_id: str | None = None, make_active: bool = True) -> str:
|
|
281
|
+
"""
|
|
282
|
+
Store a mask on this document.
|
|
283
|
+
|
|
284
|
+
- `mask` can be a numpy array or any mask-like object.
|
|
285
|
+
- If `mask_id` is None, a random UUID is generated.
|
|
286
|
+
- Returns the mask_id used.
|
|
287
|
+
"""
|
|
288
|
+
if mask_id is None:
|
|
289
|
+
mask_id = getattr(mask, "id", None) or uuid.uuid4().hex
|
|
290
|
+
|
|
291
|
+
# If it's an array, normalize to float32; otherwise just store as-is.
|
|
292
|
+
try:
|
|
293
|
+
arr = np.asarray(mask, dtype=np.float32)
|
|
294
|
+
self.masks[mask_id] = arr
|
|
295
|
+
except Exception:
|
|
296
|
+
self.masks[mask_id] = mask
|
|
297
|
+
|
|
298
|
+
if make_active:
|
|
299
|
+
self.active_mask_id = mask_id
|
|
300
|
+
|
|
301
|
+
return mask_id
|
|
302
|
+
|
|
303
|
+
def remove_mask(self, mask_id: str):
|
|
304
|
+
self.masks.pop(mask_id, None)
|
|
305
|
+
if self.active_mask_id == mask_id:
|
|
306
|
+
self.active_mask_id = None
|
|
307
|
+
|
|
308
|
+
def get_active_mask(self):
|
|
309
|
+
return self.masks.get(self.active_mask_id) if self.active_mask_id else None
|
|
310
|
+
|
|
311
|
+
def close(self):
|
|
312
|
+
"""
|
|
313
|
+
Explicit cleanup of swap files.
|
|
314
|
+
"""
|
|
315
|
+
sm = get_swap_manager()
|
|
316
|
+
# Clean up undo stack
|
|
317
|
+
for swap_id, _, _ in self._undo:
|
|
318
|
+
sm.delete_state(swap_id)
|
|
319
|
+
self._undo.clear()
|
|
320
|
+
|
|
321
|
+
# Clean up redo stack
|
|
322
|
+
for swap_id, _, _ in self._redo:
|
|
323
|
+
sm.delete_state(swap_id)
|
|
324
|
+
self._redo.clear()
|
|
325
|
+
|
|
326
|
+
def __del__(self):
|
|
327
|
+
# Fallback cleanup if close() wasn't called (though explicit close is better)
|
|
328
|
+
try:
|
|
329
|
+
self.close()
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# in class ImageDocument
|
|
335
|
+
def apply_edit(self, new_image: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
|
|
336
|
+
"""
|
|
337
|
+
Smart edit:
|
|
338
|
+
- If this is an ROI view (has _roi_info), paste back into parent and emit region update.
|
|
339
|
+
- Else: push history on self and emit full-image update.
|
|
340
|
+
- IMPORTANT: merge metadata without nuking FITS/WCS headers.
|
|
341
|
+
"""
|
|
342
|
+
import numpy as np
|
|
343
|
+
|
|
344
|
+
def _merge_meta(old_meta: dict | None, new_meta: dict | None, step_name: str):
|
|
345
|
+
"""
|
|
346
|
+
Merge new_meta into old_meta but preserve critical header fields
|
|
347
|
+
unless they are explicitly overridden with non-None values.
|
|
348
|
+
"""
|
|
349
|
+
old = dict(old_meta or {})
|
|
350
|
+
incoming = dict(new_meta or {})
|
|
351
|
+
|
|
352
|
+
critical_keys = (
|
|
353
|
+
"original_header",
|
|
354
|
+
"fits_header",
|
|
355
|
+
"wcs_header",
|
|
356
|
+
"file_meta",
|
|
357
|
+
"image_meta",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Preserve critical keys unless caller *deliberately* overrides
|
|
361
|
+
for k in critical_keys:
|
|
362
|
+
if k in incoming:
|
|
363
|
+
if incoming[k] is not None:
|
|
364
|
+
old[k] = incoming[k]
|
|
365
|
+
# if not in incoming → leave old value alone
|
|
366
|
+
|
|
367
|
+
# Merge all remaining keys normally
|
|
368
|
+
for k, v in incoming.items():
|
|
369
|
+
if k in critical_keys:
|
|
370
|
+
continue
|
|
371
|
+
old[k] = v
|
|
372
|
+
|
|
373
|
+
if step_name:
|
|
374
|
+
old.setdefault("step_name", step_name)
|
|
375
|
+
return old
|
|
376
|
+
|
|
377
|
+
# ------ ROI-aware branch (auto-pasteback) ------
|
|
378
|
+
roi_info = getattr(self, "_roi_info", None)
|
|
379
|
+
if roi_info:
|
|
380
|
+
parent = roi_info.get("parent_doc")
|
|
381
|
+
roi = roi_info.get("roi")
|
|
382
|
+
if isinstance(parent, ImageDocument) and (getattr(parent, "image", None) is not None) and roi:
|
|
383
|
+
x, y, w, h = map(int, roi)
|
|
384
|
+
|
|
385
|
+
img = np.asarray(new_image)
|
|
386
|
+
if img.dtype != np.float32:
|
|
387
|
+
img = img.astype(np.float32, copy=False)
|
|
388
|
+
|
|
389
|
+
base = np.asarray(parent.image)
|
|
390
|
+
if img.shape[:2] != (h, w):
|
|
391
|
+
raise ValueError(f"Edited preview shape {img.shape[:2]} does not match ROI {(h, w)}")
|
|
392
|
+
|
|
393
|
+
# shape reconciliation
|
|
394
|
+
if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
|
|
395
|
+
img = img[..., 0]
|
|
396
|
+
if base.ndim == 3 and img.ndim == 2:
|
|
397
|
+
img = np.repeat(img[..., None], base.shape[2], axis=2)
|
|
398
|
+
|
|
399
|
+
new_full = base.copy()
|
|
400
|
+
new_full[y:y+h, x:x+w] = img
|
|
401
|
+
|
|
402
|
+
# push onto the PARENT’s history
|
|
403
|
+
if metadata:
|
|
404
|
+
parent.metadata = _merge_meta(parent.metadata, metadata, step_name)
|
|
405
|
+
else:
|
|
406
|
+
parent.metadata.setdefault("step_name", step_name)
|
|
407
|
+
|
|
408
|
+
sm = get_swap_manager()
|
|
409
|
+
sid = sm.save_state(parent.image)
|
|
410
|
+
if sid:
|
|
411
|
+
parent._undo.append((sid, parent.metadata.copy(), step_name))
|
|
412
|
+
|
|
413
|
+
for old_sid, _, _ in parent._redo:
|
|
414
|
+
sm.delete_state(old_sid)
|
|
415
|
+
parent._redo.clear()
|
|
416
|
+
|
|
417
|
+
parent.image = new_full
|
|
418
|
+
parent.dirty = True
|
|
419
|
+
parent.changed.emit()
|
|
420
|
+
|
|
421
|
+
dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
|
|
422
|
+
try:
|
|
423
|
+
if dm is not None and hasattr(dm, "imageRegionUpdated"):
|
|
424
|
+
dm.imageRegionUpdated.emit(parent, (x, y, w, h))
|
|
425
|
+
except Exception:
|
|
426
|
+
print(f"[DocManager] Failed to emit imageRegionUpdated for ROI.")
|
|
427
|
+
return # done
|
|
428
|
+
|
|
429
|
+
# ------ Normal (full-image) branch ------
|
|
430
|
+
|
|
431
|
+
# Copy-on-write
|
|
432
|
+
if self._cow_source is not None and self.image is not None:
|
|
433
|
+
self.image = self.image.copy()
|
|
434
|
+
self._cow_source = None
|
|
435
|
+
|
|
436
|
+
if self.image is not None:
|
|
437
|
+
# snapshot current image + metadata for undo
|
|
438
|
+
try:
|
|
439
|
+
curr = np.asarray(self.image, dtype=np.float32)
|
|
440
|
+
curr = ensure_contiguous(curr)
|
|
441
|
+
|
|
442
|
+
sm = get_swap_manager()
|
|
443
|
+
sid = sm.save_state(curr)
|
|
444
|
+
|
|
445
|
+
_debug_log_undo(
|
|
446
|
+
"ImageDocument.apply_edit.snapshot",
|
|
447
|
+
doc_id=id(self),
|
|
448
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
449
|
+
curr_shape=getattr(curr, "shape", None),
|
|
450
|
+
undo_len_before=len(self._undo),
|
|
451
|
+
redo_len_before=len(self._redo),
|
|
452
|
+
step_name=step_name,
|
|
453
|
+
swap_id=sid
|
|
454
|
+
)
|
|
455
|
+
if sid:
|
|
456
|
+
self._undo.append((sid, self.metadata.copy(), step_name))
|
|
457
|
+
except Exception as e:
|
|
458
|
+
print(f"[ImageDocument] apply_edit: failed to snapshot current image for undo: {e}")
|
|
459
|
+
|
|
460
|
+
# Clear redo stack and delete files
|
|
461
|
+
sm = get_swap_manager()
|
|
462
|
+
for old_sid, _, _ in self._redo:
|
|
463
|
+
sm.delete_state(old_sid)
|
|
464
|
+
self._redo.clear()
|
|
465
|
+
|
|
466
|
+
# --- header-safe metadata merge ---
|
|
467
|
+
if metadata:
|
|
468
|
+
self.metadata = _merge_meta(self.metadata, metadata, step_name)
|
|
469
|
+
else:
|
|
470
|
+
self.metadata.setdefault("step_name", step_name)
|
|
471
|
+
|
|
472
|
+
# normalize new image
|
|
473
|
+
img = np.asarray(new_image, dtype=np.float32)
|
|
474
|
+
if img.size == 0:
|
|
475
|
+
raise ValueError("apply_edit: new image is empty")
|
|
476
|
+
|
|
477
|
+
img = ensure_contiguous(img)
|
|
478
|
+
|
|
479
|
+
_debug_log_undo(
|
|
480
|
+
"ImageDocument.apply_edit.apply",
|
|
481
|
+
doc_id=id(self),
|
|
482
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
483
|
+
new_shape=getattr(img, "shape", None),
|
|
484
|
+
undo_len_after=len(self._undo),
|
|
485
|
+
redo_len_after=len(self._redo),
|
|
486
|
+
step_name=step_name,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
self.image = img
|
|
490
|
+
self.dirty = True
|
|
491
|
+
self.changed.emit()
|
|
492
|
+
|
|
493
|
+
dm = getattr(self, "_doc_manager", None)
|
|
494
|
+
try:
|
|
495
|
+
if dm is not None and hasattr(dm, "imageRegionUpdated"):
|
|
496
|
+
dm.imageRegionUpdated.emit(self, None)
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def undo(self) -> str | None:
|
|
503
|
+
# Extra-safe: if stack is empty, bail early.
|
|
504
|
+
if not self._undo:
|
|
505
|
+
_debug_log_undo(
|
|
506
|
+
"ImageDocument.undo.empty_stack",
|
|
507
|
+
doc_id=id(self),
|
|
508
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
509
|
+
)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
_debug_log_undo(
|
|
513
|
+
"ImageDocument.undo.entry",
|
|
514
|
+
doc_id=id(self),
|
|
515
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
516
|
+
undo_len=len(self._undo),
|
|
517
|
+
redo_len=len(self._redo),
|
|
518
|
+
top_step=self._undo[-1][2] if self._undo else None,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Pop with an extra guard in case something cleared _undo between
|
|
522
|
+
# the check above and this call (re-entrancy / threading).
|
|
523
|
+
try:
|
|
524
|
+
prev_sid, prev_meta, name = self._undo.pop()
|
|
525
|
+
except IndexError:
|
|
526
|
+
_debug_log_undo(
|
|
527
|
+
"ImageDocument.undo.pop_index_error",
|
|
528
|
+
doc_id=id(self),
|
|
529
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
530
|
+
undo_len=len(self._undo),
|
|
531
|
+
redo_len=len(self._redo),
|
|
532
|
+
)
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
# Load previous image from swap
|
|
536
|
+
sm = get_swap_manager()
|
|
537
|
+
prev_img = sm.load_state(prev_sid)
|
|
538
|
+
|
|
539
|
+
# We can delete the swap file now that we have it in RAM
|
|
540
|
+
# (unless we want to keep it for some reason, but standard undo consumes the state)
|
|
541
|
+
sm.delete_state(prev_sid)
|
|
542
|
+
|
|
543
|
+
if prev_img is None:
|
|
544
|
+
print(f"[ImageDocument] undo: failed to load swap state {prev_sid}")
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
# Normalize previous image before using it
|
|
548
|
+
try:
|
|
549
|
+
prev_arr = np.asarray(prev_img, dtype=np.float32)
|
|
550
|
+
if prev_arr.size == 0:
|
|
551
|
+
raise ValueError("undo: previous image is empty")
|
|
552
|
+
prev_arr = np.ascontiguousarray(prev_arr)
|
|
553
|
+
except Exception as e:
|
|
554
|
+
print(f"[ImageDocument] undo: invalid prev_img in stack ({type(prev_img)}): {e}")
|
|
555
|
+
# Put it back so we don't corrupt history further?
|
|
556
|
+
# Actually if load failed we are in trouble.
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
_debug_log_undo(
|
|
560
|
+
"ImageDocument.undo.normalized_prev",
|
|
561
|
+
doc_id=id(self),
|
|
562
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
563
|
+
prev_shape=getattr(prev_arr, "shape", None),
|
|
564
|
+
prev_dtype=getattr(prev_arr, "dtype", None),
|
|
565
|
+
step_name=name,
|
|
566
|
+
meta_step=prev_meta.get("step_name", None) if isinstance(prev_meta, dict) else None,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Snapshot current state for redo (best-effort)
|
|
570
|
+
curr_img = self.image
|
|
571
|
+
curr_meta = self.metadata
|
|
572
|
+
try:
|
|
573
|
+
if curr_img is not None:
|
|
574
|
+
curr_arr = np.asarray(curr_img, dtype=np.float32)
|
|
575
|
+
curr_arr = np.ascontiguousarray(curr_arr)
|
|
576
|
+
|
|
577
|
+
# Save to swap for Redo
|
|
578
|
+
sid = sm.save_state(curr_arr)
|
|
579
|
+
if sid:
|
|
580
|
+
self._redo.append((sid, dict(curr_meta), name))
|
|
581
|
+
else:
|
|
582
|
+
# Handle None image? Should not happen usually
|
|
583
|
+
pass
|
|
584
|
+
except Exception as e:
|
|
585
|
+
print(f"[ImageDocument] undo: failed to snapshot current image for redo: {e}")
|
|
586
|
+
|
|
587
|
+
_debug_log_undo(
|
|
588
|
+
"ImageDocument.undo.before_apply",
|
|
589
|
+
doc_id=id(self),
|
|
590
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
591
|
+
curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
|
|
592
|
+
curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
self.image = prev_arr
|
|
596
|
+
self.metadata = dict(prev_meta or {})
|
|
597
|
+
self.dirty = True
|
|
598
|
+
try:
|
|
599
|
+
self.changed.emit()
|
|
600
|
+
except Exception:
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
_debug_log_undo(
|
|
604
|
+
"ImageDocument.undo.after_apply",
|
|
605
|
+
doc_id=id(self),
|
|
606
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
607
|
+
new_shape=getattr(self.image, "shape", None),
|
|
608
|
+
new_dtype=getattr(self.image, "dtype", None),
|
|
609
|
+
undo_len=len(self._undo),
|
|
610
|
+
redo_len=len(self._redo),
|
|
611
|
+
)
|
|
612
|
+
return name
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def redo(self) -> str | None:
|
|
616
|
+
if not self._redo:
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
_debug_log_undo(
|
|
620
|
+
"ImageDocument.redo.entry",
|
|
621
|
+
doc_id=id(self),
|
|
622
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
623
|
+
redo_len=len(self._redo),
|
|
624
|
+
undo_len=len(self._undo),
|
|
625
|
+
top_step=self._redo[-1][2] if self._redo else None,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
nxt_sid, nxt_meta, name = self._redo.pop()
|
|
629
|
+
|
|
630
|
+
# Load next image from swap
|
|
631
|
+
sm = get_swap_manager()
|
|
632
|
+
nxt_img = sm.load_state(nxt_sid)
|
|
633
|
+
sm.delete_state(nxt_sid)
|
|
634
|
+
|
|
635
|
+
if nxt_img is None:
|
|
636
|
+
print(f"[ImageDocument] redo: failed to load swap state {nxt_sid}")
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
# Normalize next image before using it
|
|
640
|
+
try:
|
|
641
|
+
nxt_arr = np.asarray(nxt_img, dtype=np.float32)
|
|
642
|
+
if nxt_arr.size == 0:
|
|
643
|
+
raise ValueError("redo: next image is empty")
|
|
644
|
+
nxt_arr = np.ascontiguousarray(nxt_arr)
|
|
645
|
+
except Exception as e:
|
|
646
|
+
print(f"[ImageDocument] redo: invalid nxt_img in stack ({type(nxt_img)}): {e}")
|
|
647
|
+
return None
|
|
648
|
+
|
|
649
|
+
_debug_log_undo(
|
|
650
|
+
"ImageDocument.redo.normalized_next",
|
|
651
|
+
doc_id=id(self),
|
|
652
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
653
|
+
nxt_shape=getattr(nxt_arr, "shape", None),
|
|
654
|
+
nxt_dtype=getattr(nxt_arr, "dtype", None),
|
|
655
|
+
step_name=name,
|
|
656
|
+
meta_step=nxt_meta.get("step_name", None) if isinstance(nxt_meta, dict) else None,
|
|
657
|
+
)
|
|
658
|
+
curr_img = self.image
|
|
659
|
+
curr_meta = self.metadata
|
|
660
|
+
try:
|
|
661
|
+
if curr_img is not None:
|
|
662
|
+
curr_arr = np.asarray(curr_img, dtype=np.float32)
|
|
663
|
+
curr_arr = np.ascontiguousarray(curr_arr)
|
|
664
|
+
|
|
665
|
+
# Save current to swap for Undo
|
|
666
|
+
sid = sm.save_state(curr_arr)
|
|
667
|
+
if sid:
|
|
668
|
+
self._undo.append((sid, dict(curr_meta), name))
|
|
669
|
+
else:
|
|
670
|
+
pass
|
|
671
|
+
except Exception as e:
|
|
672
|
+
print(f"[ImageDocument] redo: failed to snapshot current image for undo: {e}")
|
|
673
|
+
_debug_log_undo(
|
|
674
|
+
"ImageDocument.redo.before_apply",
|
|
675
|
+
doc_id=id(self),
|
|
676
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
677
|
+
curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
|
|
678
|
+
curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
|
|
679
|
+
)
|
|
680
|
+
self.image = nxt_arr
|
|
681
|
+
self.metadata = dict(nxt_meta or {})
|
|
682
|
+
self.dirty = True
|
|
683
|
+
try:
|
|
684
|
+
self.changed.emit()
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
_debug_log_undo(
|
|
689
|
+
"ImageDocument.redo.after_apply",
|
|
690
|
+
doc_id=id(self),
|
|
691
|
+
name=getattr(self, "display_name", lambda: "<no-name>")(),
|
|
692
|
+
new_shape=getattr(self.image, "shape", None),
|
|
693
|
+
new_dtype=getattr(self.image, "dtype", None),
|
|
694
|
+
undo_len=len(self._undo),
|
|
695
|
+
redo_len=len(self._redo),
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
return name
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# existing methods unchanged below...
|
|
702
|
+
def set_image(self, img: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
|
|
703
|
+
"""
|
|
704
|
+
Treat set_image as an editing operation that records history.
|
|
705
|
+
(History previews and “Restore from History” call this.)
|
|
706
|
+
"""
|
|
707
|
+
self.apply_edit(img, metadata or {}, step_name=step_name)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# --- Add to ImageDocument (public history helpers) -------------------
|
|
711
|
+
|
|
712
|
+
def get_undo_stack(self):
|
|
713
|
+
"""
|
|
714
|
+
Oldest → newest *before* current image.
|
|
715
|
+
Returns [(swap_id, meta, name), ...]
|
|
716
|
+
"""
|
|
717
|
+
out = []
|
|
718
|
+
for sid, meta, name in self._undo:
|
|
719
|
+
out.append((sid, meta or {}, name or "Unnamed"))
|
|
720
|
+
return out
|
|
721
|
+
|
|
722
|
+
def display_name(self) -> str:
|
|
723
|
+
# Prefer an explicit display name if set
|
|
724
|
+
dn = self.metadata.get("display_name")
|
|
725
|
+
if dn:
|
|
726
|
+
return dn
|
|
727
|
+
p = self.metadata.get("file_path")
|
|
728
|
+
return os.path.basename(p) if p else "Untitled"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _dm_json_sanitize(obj):
|
|
732
|
+
"""Tiny, local JSON sanitizer: keeps size small & avoids numpy/astropy weirdness."""
|
|
733
|
+
import numpy as _np
|
|
734
|
+
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
735
|
+
return obj
|
|
736
|
+
if isinstance(obj, dict):
|
|
737
|
+
return {str(k): _dm_json_sanitize(v) for k, v in obj.items()}
|
|
738
|
+
if isinstance(obj, (list, tuple)):
|
|
739
|
+
return [_dm_json_sanitize(x) for x in obj]
|
|
740
|
+
# numpy array → small placeholder
|
|
741
|
+
try:
|
|
742
|
+
if isinstance(obj, _np.ndarray):
|
|
743
|
+
return {"__nd__": True, "shape": list(obj.shape), "dtype": str(obj.dtype)}
|
|
744
|
+
# numpy scalar
|
|
745
|
+
if hasattr(obj, "item"):
|
|
746
|
+
return obj.item()
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
try:
|
|
750
|
+
return repr(obj)
|
|
751
|
+
except Exception:
|
|
752
|
+
return str(type(obj))
|
|
753
|
+
|
|
754
|
+
def _compute_cropped_wcs(parent_hdr_like: dict | "fits.Header",
|
|
755
|
+
x: int, y: int, w: int, h: int):
|
|
756
|
+
"""
|
|
757
|
+
Returns a plain dict WCS header reflecting a pure pixel crop by (x,y,w,h).
|
|
758
|
+
Keeps CD/CDELT/PC/CRVAL as-is and shifts CRPIX by (+/-) the crop offset.
|
|
759
|
+
Also sets NAXIS1/2 to (w,h) and records custom CROPX/CROPY.
|
|
760
|
+
"""
|
|
761
|
+
try:
|
|
762
|
+
from astropy.io.fits import Header # type: ignore
|
|
763
|
+
except Exception:
|
|
764
|
+
Header = None # type: ignore
|
|
765
|
+
|
|
766
|
+
# Normalize to a dict of key->value (no comments needed for the drag payload)
|
|
767
|
+
if Header is not None and isinstance(parent_hdr_like, Header):
|
|
768
|
+
base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
|
|
769
|
+
elif isinstance(parent_hdr_like, dict):
|
|
770
|
+
# If it’s an XISF-like dict, try to pull a FITSKeywords block first
|
|
771
|
+
fk = parent_hdr_like.get("FITSKeywords")
|
|
772
|
+
if isinstance(fk, dict) and fk:
|
|
773
|
+
base = {}
|
|
774
|
+
for k, arr in fk.items():
|
|
775
|
+
try:
|
|
776
|
+
base[k] = (arr or [{}])[0].get("value", None)
|
|
777
|
+
except Exception:
|
|
778
|
+
pass
|
|
779
|
+
else:
|
|
780
|
+
base = dict(parent_hdr_like)
|
|
781
|
+
else:
|
|
782
|
+
base = {}
|
|
783
|
+
|
|
784
|
+
# Shift CRPIX by the crop offset (ROI origin is (x,y) in full-image pixels)
|
|
785
|
+
crpix1 = base.get("CRPIX1")
|
|
786
|
+
crpix2 = base.get("CRPIX2")
|
|
787
|
+
if isinstance(crpix1, (int, float)) and isinstance(crpix2, (int, float)):
|
|
788
|
+
new_crpix1 = float(crpix1) - float(x)
|
|
789
|
+
new_crpix2 = float(crpix2) - float(y)
|
|
790
|
+
base["CRPIX1"] = new_crpix1
|
|
791
|
+
base["CRPIX2"] = new_crpix2
|
|
792
|
+
else:
|
|
793
|
+
new_crpix1 = crpix1
|
|
794
|
+
new_crpix2 = crpix2
|
|
795
|
+
|
|
796
|
+
# Update image size keys
|
|
797
|
+
base["NAXIS1"] = int(w)
|
|
798
|
+
base["NAXIS2"] = int(h)
|
|
799
|
+
|
|
800
|
+
# Optional helpful tags
|
|
801
|
+
base["CROPX"] = int(x)
|
|
802
|
+
base["CROPY"] = int(y)
|
|
803
|
+
base["SASKIND"] = "ROI-CROP"
|
|
804
|
+
|
|
805
|
+
# DEBUG: show how CRPIX changed for this crop
|
|
806
|
+
if _DEBUG_WCS:
|
|
807
|
+
print(f"[WCS DEBUG] _compute_cropped_wcs: roi=({x},{y},{w},{h})")
|
|
808
|
+
print(f" CRPIX1: {crpix1} -> {new_crpix1}")
|
|
809
|
+
print(f" CRPIX2: {crpix2} -> {new_crpix2}")
|
|
810
|
+
print("")
|
|
811
|
+
|
|
812
|
+
return base
|
|
813
|
+
|
|
814
|
+
import logging
|
|
815
|
+
|
|
816
|
+
log = logging.getLogger(__name__)
|
|
817
|
+
|
|
818
|
+
def _pick_header_for_save(meta: dict) -> fits.Header | None:
|
|
819
|
+
"""
|
|
820
|
+
Choose the best header to write to disk.
|
|
821
|
+
|
|
822
|
+
Priority:
|
|
823
|
+
1. 'wcs_header' – if you stash a solved header here
|
|
824
|
+
2. 'fits_header' – common name after ASTAP / plate solve
|
|
825
|
+
3. 'original_header' – whatever came from disk
|
|
826
|
+
4. 'header' – older code paths
|
|
827
|
+
"""
|
|
828
|
+
if not isinstance(meta, dict):
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
for key in ("wcs_header", "fits_header", "original_header", "header"):
|
|
832
|
+
hdr = meta.get(key)
|
|
833
|
+
if isinstance(hdr, fits.Header):
|
|
834
|
+
log.debug("[_pick_header_for_save] using %s (%d cards)", key, len(hdr))
|
|
835
|
+
return hdr
|
|
836
|
+
|
|
837
|
+
log.debug("[_pick_header_for_save] no fits.Header found in metadata; "
|
|
838
|
+
"will let legacy_save_image fall back.")
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
def _snapshot_header_for_metadata(meta: dict):
|
|
842
|
+
"""
|
|
843
|
+
If meta contains a header under common keys, add a JSON-safe snapshot at
|
|
844
|
+
meta["__header_snapshot__"] so viewers/project IO never choke.
|
|
845
|
+
"""
|
|
846
|
+
if not isinstance(meta, dict):
|
|
847
|
+
return
|
|
848
|
+
if "__header_snapshot__" in meta:
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
hdr = (meta.get("original_header")
|
|
852
|
+
or meta.get("fits_header")
|
|
853
|
+
or meta.get("header"))
|
|
854
|
+
|
|
855
|
+
if hdr is None:
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
snap = None
|
|
859
|
+
|
|
860
|
+
# Try astropy Header (without hard dependency)
|
|
861
|
+
try:
|
|
862
|
+
from astropy.io.fits import Header # type: ignore
|
|
863
|
+
except Exception:
|
|
864
|
+
Header = None # type: ignore
|
|
865
|
+
|
|
866
|
+
try:
|
|
867
|
+
if Header is not None and isinstance(hdr, Header):
|
|
868
|
+
cards = []
|
|
869
|
+
for k in hdr.keys():
|
|
870
|
+
try:
|
|
871
|
+
val = hdr[k]
|
|
872
|
+
except Exception:
|
|
873
|
+
val = ""
|
|
874
|
+
try:
|
|
875
|
+
cmt = hdr.comments[k] if hasattr(hdr, "comments") else ""
|
|
876
|
+
except Exception:
|
|
877
|
+
cmt = ""
|
|
878
|
+
cards.append([str(k), _dm_json_sanitize(val), str(cmt)])
|
|
879
|
+
snap = {"format": "fits-cards", "cards": cards}
|
|
880
|
+
elif isinstance(hdr, dict):
|
|
881
|
+
# Already a dict-like header (e.g., XISF style)
|
|
882
|
+
snap = {"format": "dict",
|
|
883
|
+
"items": {str(k): _dm_json_sanitize(v) for k, v in hdr.items()}}
|
|
884
|
+
else:
|
|
885
|
+
# Last resort string
|
|
886
|
+
snap = {"format": "repr", "text": repr(hdr)}
|
|
887
|
+
except Exception:
|
|
888
|
+
try:
|
|
889
|
+
snap = {"format": "repr", "text": str(hdr)}
|
|
890
|
+
except Exception:
|
|
891
|
+
snap = None
|
|
892
|
+
|
|
893
|
+
if snap:
|
|
894
|
+
meta["__header_snapshot__"] = snap
|
|
895
|
+
|
|
896
|
+
def _safe_str(x) -> str:
|
|
897
|
+
try:
|
|
898
|
+
return str(x)
|
|
899
|
+
except Exception:
|
|
900
|
+
try:
|
|
901
|
+
return repr(x)
|
|
902
|
+
except Exception:
|
|
903
|
+
return "<unrepr>"
|
|
904
|
+
|
|
905
|
+
def _fits_table_to_csv(hdu, out_csv_path: str, max_rows: int = 250000):
|
|
906
|
+
"""
|
|
907
|
+
Convert a FITS (Bin)Table HDU to CSV. Returns the CSV path.
|
|
908
|
+
Limits to max_rows to avoid giant dumps.
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
data = hdu.data
|
|
912
|
+
if data is None:
|
|
913
|
+
raise RuntimeError("No table data")
|
|
914
|
+
|
|
915
|
+
# Astropy table→numpy recarray is fine; iterate to strings
|
|
916
|
+
rec = np.asarray(data)
|
|
917
|
+
nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
|
|
918
|
+
if nrows == 0:
|
|
919
|
+
# write headers only
|
|
920
|
+
names = [str(n) for n in (getattr(data, "names", None) or [])]
|
|
921
|
+
with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
|
|
922
|
+
if names:
|
|
923
|
+
f.write(",".join(names) + "\n")
|
|
924
|
+
return out_csv_path
|
|
925
|
+
|
|
926
|
+
# Column names (fallback to numeric if missing)
|
|
927
|
+
names = list(getattr(data, "names", [])) or [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else len(rec.dtype.names or []))]
|
|
928
|
+
|
|
929
|
+
import csv
|
|
930
|
+
with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
|
|
931
|
+
w = csv.writer(f)
|
|
932
|
+
w.writerow([_safe_str(n) for n in names])
|
|
933
|
+
|
|
934
|
+
# Decide how to iterate rows depending on structured vs 2D numeric
|
|
935
|
+
if rec.dtype.names: # structured/record array
|
|
936
|
+
for ri in range(min(nrows, max_rows)):
|
|
937
|
+
row = rec[ri]
|
|
938
|
+
w.writerow([_safe_str(row[name]) for name in rec.dtype.names])
|
|
939
|
+
else:
|
|
940
|
+
# plain 2D numeric table
|
|
941
|
+
if rec.ndim == 1:
|
|
942
|
+
for ri in range(min(nrows, max_rows)):
|
|
943
|
+
w.writerow([_safe_str(rec[ri])])
|
|
944
|
+
else:
|
|
945
|
+
for ri in range(min(nrows, max_rows)):
|
|
946
|
+
w.writerow([_safe_str(x) for x in rec[ri]])
|
|
947
|
+
|
|
948
|
+
return out_csv_path
|
|
949
|
+
except Exception as e:
|
|
950
|
+
raise
|
|
951
|
+
|
|
952
|
+
def _fits_table_to_rows_headers(hdu, max_rows: int = 500000) -> tuple[list[list], list[str]]:
|
|
953
|
+
"""
|
|
954
|
+
Convert a FITS (Bin)Table/Table HDU to (rows, headers).
|
|
955
|
+
Truncates to max_rows for safety.
|
|
956
|
+
"""
|
|
957
|
+
data = hdu.data
|
|
958
|
+
if data is None:
|
|
959
|
+
return [], []
|
|
960
|
+
rec = np.asarray(data)
|
|
961
|
+
# Column names
|
|
962
|
+
names = list(getattr(data, "names", [])) or (
|
|
963
|
+
list(rec.dtype.names) if rec.dtype.names else [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else 1)]
|
|
964
|
+
)
|
|
965
|
+
rows = []
|
|
966
|
+
nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
|
|
967
|
+
nrows = min(nrows, max_rows)
|
|
968
|
+
if rec.dtype.names: # structured array
|
|
969
|
+
for ri in range(nrows):
|
|
970
|
+
row = rec[ri]
|
|
971
|
+
rows.append([_safe_str(row[name]) for name in rec.dtype.names])
|
|
972
|
+
else:
|
|
973
|
+
# numeric 2D/1D table
|
|
974
|
+
if rec.ndim == 1:
|
|
975
|
+
for ri in range(nrows):
|
|
976
|
+
rows.append([_safe_str(rec[ri])])
|
|
977
|
+
else:
|
|
978
|
+
for ri in range(nrows):
|
|
979
|
+
rows.append([_safe_str(x) for x in rec[ri]])
|
|
980
|
+
return rows, [str(n) for n in names]
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
_shown_raw_preview_paths: set[str] = set()
|
|
984
|
+
_raw_preview_boxes: list[QMessageBox] = [] # prevent GC while shown
|
|
985
|
+
|
|
986
|
+
def _show_raw_preview_warning_nonmodal(path: str):
|
|
987
|
+
parent = QApplication.activeWindow()
|
|
988
|
+
box = QMessageBox(parent)
|
|
989
|
+
box.setIcon(QMessageBox.Icon.Warning)
|
|
990
|
+
box.setWindowTitle("RAW preview loaded")
|
|
991
|
+
box.setText(
|
|
992
|
+
"Linear RAW decoding failed for:\n"
|
|
993
|
+
f"{path}\n\n"
|
|
994
|
+
"Showing the camera’s embedded JPEG preview instead "
|
|
995
|
+
"(8-bit, non-linear). Some processing tools may be limited."
|
|
996
|
+
)
|
|
997
|
+
box.setStandardButtons(QMessageBox.StandardButton.Ok)
|
|
998
|
+
box.setWindowModality(Qt.WindowModality.NonModal) # ← fix here
|
|
999
|
+
box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
1000
|
+
|
|
1001
|
+
_raw_preview_boxes.append(box)
|
|
1002
|
+
box.finished.connect(lambda _=None, b=box: _raw_preview_boxes.remove(b))
|
|
1003
|
+
box.show()
|
|
1004
|
+
|
|
1005
|
+
def maybe_warn_raw_preview(path: str, header):
|
|
1006
|
+
if not header or not bool(header.get("RAW_PREV", False)):
|
|
1007
|
+
return
|
|
1008
|
+
if path in _shown_raw_preview_paths:
|
|
1009
|
+
return
|
|
1010
|
+
_shown_raw_preview_paths.add(path)
|
|
1011
|
+
QTimer.singleShot(0, lambda p=path: _show_raw_preview_warning_nonmodal(p))
|
|
1012
|
+
|
|
1013
|
+
_np = np
|
|
1014
|
+
|
|
1015
|
+
class _RoiViewDocument(ImageDocument):
|
|
1016
|
+
def __init__(self, parent_doc: ImageDocument, roi: tuple[int,int,int,int], name_suffix: str = " (Preview)"):
|
|
1017
|
+
x, y, w, h = roi
|
|
1018
|
+
meta = dict(parent_doc.metadata or {})
|
|
1019
|
+
base = parent_doc.display_name()
|
|
1020
|
+
meta["display_name"] = f"{base}{name_suffix}"
|
|
1021
|
+
meta.setdefault("image_meta", {})
|
|
1022
|
+
meta["image_meta"] = dict(meta["image_meta"], readonly=True, view_kind="roi-preview")
|
|
1023
|
+
|
|
1024
|
+
super().__init__(_np.zeros((max(1,h), max(1,w), 3), dtype=_np.float32), meta, parent=parent_doc.parent())
|
|
1025
|
+
|
|
1026
|
+
self._parent_doc = parent_doc
|
|
1027
|
+
self._roi = ( x, y, w, h )
|
|
1028
|
+
self._roi_info = {"parent_doc": parent_doc, "roi": tuple(self._roi)}
|
|
1029
|
+
self.metadata["_roi_bounds"] = tuple(self._roi)
|
|
1030
|
+
imi = dict(self.metadata.get("image_meta") or {})
|
|
1031
|
+
imi.update({"roi": tuple(self._roi), "view_kind": "roi-preview"})
|
|
1032
|
+
self.metadata["image_meta"] = imi
|
|
1033
|
+
|
|
1034
|
+
# build and store an ROI-shifted WCS header snapshot to use if detached
|
|
1035
|
+
try:
|
|
1036
|
+
phdr = (parent_doc.metadata.get("original_header")
|
|
1037
|
+
or parent_doc.metadata.get("fits_header")
|
|
1038
|
+
or parent_doc.metadata.get("header"))
|
|
1039
|
+
rx, ry, rw, rh = self._roi
|
|
1040
|
+
roi_wcs = _compute_cropped_wcs(phdr, rx, ry, rw, rh)
|
|
1041
|
+
self.metadata["roi_wcs_header"] = roi_wcs # plain dict, drop-in safe
|
|
1042
|
+
|
|
1043
|
+
# 🔴 KEY FIX: for a standalone ROI doc, treat this cropped WCS
|
|
1044
|
+
# as the "original_header" so view-drops / duplicates inherit it.
|
|
1045
|
+
if phdr is not None:
|
|
1046
|
+
# optional: preserve the full parent header
|
|
1047
|
+
self.metadata.setdefault("parent_full_header", phdr)
|
|
1048
|
+
self.metadata["original_header"] = roi_wcs
|
|
1049
|
+
try:
|
|
1050
|
+
from .doc_manager import _snapshot_header_for_metadata # if you move it, adjust import
|
|
1051
|
+
except Exception:
|
|
1052
|
+
_snapshot_header_for_metadata = None
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
if _snapshot_header_for_metadata is not None:
|
|
1056
|
+
_snapshot_header_for_metadata(self.metadata)
|
|
1057
|
+
except Exception:
|
|
1058
|
+
pass
|
|
1059
|
+
|
|
1060
|
+
# DEBUG: log parent vs ROI WCS
|
|
1061
|
+
if _DEBUG_WCS:
|
|
1062
|
+
base_name = parent_doc.display_name() if hasattr(parent_doc, "display_name") else "<parent>"
|
|
1063
|
+
print(f"[WCS DEBUG] _RoiViewDocument.__init__: parent='{base_name}' roi={self._roi}")
|
|
1064
|
+
_debug_log_wcs_context(" parent_header", phdr)
|
|
1065
|
+
_debug_log_wcs_context(" roi_header", self.metadata)
|
|
1066
|
+
except Exception as e:
|
|
1067
|
+
if _DEBUG_WCS:
|
|
1068
|
+
print(f"[WCS DEBUG] _RoiViewDocument.__init__ exception: {e}")
|
|
1069
|
+
pass
|
|
1070
|
+
self.metadata["image_meta"] = imi
|
|
1071
|
+
# NEW: transient preview overlay for this ROI (None means "show parent slice")
|
|
1072
|
+
self._preview_override: _np.ndarray | None = None
|
|
1073
|
+
|
|
1074
|
+
self._pundo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
|
|
1075
|
+
self._predo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
|
|
1076
|
+
|
|
1077
|
+
@property
|
|
1078
|
+
def image(self):
|
|
1079
|
+
p = self._parent_doc
|
|
1080
|
+
if p is None or getattr(p, "image", None) is None:
|
|
1081
|
+
return None
|
|
1082
|
+
x, y, w, h = self._roi
|
|
1083
|
+
# If a preview override exists, show it; else show the live parent slice
|
|
1084
|
+
return self._preview_override if self._preview_override is not None else p.image[y:y+h, x:x+w]
|
|
1085
|
+
|
|
1086
|
+
@image.setter
|
|
1087
|
+
def image(self, _val):
|
|
1088
|
+
# ignore: writes should use DocManager(update/commit) paths
|
|
1089
|
+
pass
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def commit_to_parent(self, new_image: _np.ndarray | None = None,
|
|
1093
|
+
metadata: dict | None = None, step_name: str = "Edit"):
|
|
1094
|
+
"""
|
|
1095
|
+
Paste current preview (or provided new_image) back into the parent image
|
|
1096
|
+
with proper undo and region repaint.
|
|
1097
|
+
"""
|
|
1098
|
+
parent = getattr(self, "_parent_doc", None)
|
|
1099
|
+
if parent is None or parent.image is None:
|
|
1100
|
+
return
|
|
1101
|
+
|
|
1102
|
+
x, y, w, h = self._roi
|
|
1103
|
+
# choose source
|
|
1104
|
+
src = new_image
|
|
1105
|
+
if src is None:
|
|
1106
|
+
src = self._preview_override if self._preview_override is not None else parent.image[y:y+h, x:x+w]
|
|
1107
|
+
|
|
1108
|
+
img = _np.asarray(src, dtype=_np.float32, copy=False)
|
|
1109
|
+
base = parent.image
|
|
1110
|
+
|
|
1111
|
+
# channel reconciliation
|
|
1112
|
+
if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
|
|
1113
|
+
img = img[..., 0]
|
|
1114
|
+
if base.ndim == 3 and img.ndim == 2:
|
|
1115
|
+
img = _np.repeat(img[..., None], base.shape[2], axis=2)
|
|
1116
|
+
if img.shape[:2] != (h, w):
|
|
1117
|
+
raise ValueError(f"Commit shape {img.shape[:2]} does not match ROI {(h, w)}")
|
|
1118
|
+
|
|
1119
|
+
# push undo on parent and paste
|
|
1120
|
+
parent._undo.append((base.copy(), parent.metadata.copy(), step_name))
|
|
1121
|
+
parent._redo.clear()
|
|
1122
|
+
if metadata: parent.metadata.update(metadata)
|
|
1123
|
+
parent.metadata.setdefault("step_name", step_name)
|
|
1124
|
+
|
|
1125
|
+
new_full = base.copy()
|
|
1126
|
+
new_full[y:y+h, x:x+w] = img
|
|
1127
|
+
parent.image = new_full
|
|
1128
|
+
try: parent.changed.emit()
|
|
1129
|
+
except Exception as e:
|
|
1130
|
+
import logging
|
|
1131
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1132
|
+
|
|
1133
|
+
# notify region update + repaint
|
|
1134
|
+
dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
|
|
1135
|
+
if dm is not None:
|
|
1136
|
+
try: dm.imageRegionUpdated.emit(parent, (x, y, w, h))
|
|
1137
|
+
except Exception as e:
|
|
1138
|
+
import logging
|
|
1139
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
# --- helper to snapshot what's currently visible in the Preview
|
|
1143
|
+
def _current_preview_copy(self) -> _np.ndarray:
|
|
1144
|
+
img = self.image # property: returns override or parent slice
|
|
1145
|
+
if img is None:
|
|
1146
|
+
return _np.zeros((1, 1), dtype=_np.float32)
|
|
1147
|
+
arr = _np.asarray(img, dtype=_np.float32)
|
|
1148
|
+
return _np.ascontiguousarray(arr)
|
|
1149
|
+
|
|
1150
|
+
# === KEEP YOUR WORKING BODY; only 3 added lines are marked "NEW" ===
|
|
1151
|
+
def apply_edit(self, new_image, metadata=None, step_name="Edit"):
|
|
1152
|
+
x, y, w, h = self._roi
|
|
1153
|
+
img = np.asarray(new_image, dtype=np.float32, copy=False)
|
|
1154
|
+
base = self._parent_doc.image
|
|
1155
|
+
|
|
1156
|
+
_debug_log_undo(
|
|
1157
|
+
"_RoiViewDocument.apply_edit.entry",
|
|
1158
|
+
roi=(x, y, w, h),
|
|
1159
|
+
parent_id=id(self._parent_doc) if self._parent_doc is not None else None,
|
|
1160
|
+
roi_doc_id=id(self),
|
|
1161
|
+
new_shape=getattr(img, "shape", None),
|
|
1162
|
+
step_name=step_name,
|
|
1163
|
+
pundo_len=len(self._pundo),
|
|
1164
|
+
predo_len=len(self._predo),
|
|
1165
|
+
)
|
|
1166
|
+
if base is not None:
|
|
1167
|
+
if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
|
|
1168
|
+
img = img[..., 0]
|
|
1169
|
+
if base.ndim == 3 and img.ndim == 2:
|
|
1170
|
+
img = np.repeat(img[..., None], base.shape[2], axis=2)
|
|
1171
|
+
if img.shape[:2] != (h, w):
|
|
1172
|
+
raise ValueError(f"Preview edit shape {img.shape[:2]} != ROI {(h, w)}")
|
|
1173
|
+
|
|
1174
|
+
img = np.ascontiguousarray(img)
|
|
1175
|
+
|
|
1176
|
+
# snapshot current visible preview for local undo
|
|
1177
|
+
self._pundo.append((self._current_preview_copy(), dict(self.metadata), step_name))
|
|
1178
|
+
self._predo.clear()
|
|
1179
|
+
|
|
1180
|
+
self._preview_override = img
|
|
1181
|
+
_debug_log_undo(
|
|
1182
|
+
"_RoiViewDocument.apply_edit.after",
|
|
1183
|
+
roi=(x, y, w, h),
|
|
1184
|
+
preview_shape=getattr(self._preview_override, "shape", None),
|
|
1185
|
+
pundo_len=len(self._pundo),
|
|
1186
|
+
predo_len=len(self._predo),
|
|
1187
|
+
step_name=step_name,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
if metadata:
|
|
1191
|
+
self.metadata.update(metadata)
|
|
1192
|
+
self.metadata.setdefault("step_name", step_name)
|
|
1193
|
+
|
|
1194
|
+
# 1) notify ROI listeners (e.g. the main window via _on_roi_changed)
|
|
1195
|
+
try:
|
|
1196
|
+
self.changed.emit()
|
|
1197
|
+
except Exception:
|
|
1198
|
+
pass
|
|
1199
|
+
|
|
1200
|
+
# 2) optionally: tell DocManager "ROI preview changed" using base doc + ROI
|
|
1201
|
+
dm = getattr(self, "_doc_manager", None)
|
|
1202
|
+
if dm is not None and hasattr(dm, "previewRepaintRequested"):
|
|
1203
|
+
try:
|
|
1204
|
+
dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
|
|
1205
|
+
except Exception:
|
|
1206
|
+
pass
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _parent(self):
|
|
1211
|
+
return getattr(self, "_parent_doc", None)
|
|
1212
|
+
|
|
1213
|
+
def can_undo(self) -> bool:
|
|
1214
|
+
# Prefer local preview history if present
|
|
1215
|
+
if self._pundo:
|
|
1216
|
+
return True
|
|
1217
|
+
# Otherwise mirror parent’s history
|
|
1218
|
+
p = getattr(self, "_parent_doc", None)
|
|
1219
|
+
if p is not None and hasattr(p, "can_undo"):
|
|
1220
|
+
try:
|
|
1221
|
+
return bool(p.can_undo())
|
|
1222
|
+
except Exception:
|
|
1223
|
+
return False
|
|
1224
|
+
return False
|
|
1225
|
+
|
|
1226
|
+
def can_redo(self) -> bool:
|
|
1227
|
+
if self._predo:
|
|
1228
|
+
return True
|
|
1229
|
+
p = getattr(self, "_parent_doc", None)
|
|
1230
|
+
if p is not None and hasattr(p, "can_redo"):
|
|
1231
|
+
try:
|
|
1232
|
+
return bool(p.can_redo())
|
|
1233
|
+
except Exception:
|
|
1234
|
+
return False
|
|
1235
|
+
return False
|
|
1236
|
+
|
|
1237
|
+
def last_undo_name(self) -> str | None:
|
|
1238
|
+
if self._pundo:
|
|
1239
|
+
return self._pundo[-1][2]
|
|
1240
|
+
p = getattr(self, "_parent_doc", None)
|
|
1241
|
+
if p is not None and hasattr(p, "last_undo_name"):
|
|
1242
|
+
try:
|
|
1243
|
+
return p.last_undo_name()
|
|
1244
|
+
except Exception:
|
|
1245
|
+
return None
|
|
1246
|
+
return None
|
|
1247
|
+
|
|
1248
|
+
def last_redo_name(self) -> str | None:
|
|
1249
|
+
if self._predo:
|
|
1250
|
+
return self._predo[-1][2]
|
|
1251
|
+
p = getattr(self, "_parent_doc", None)
|
|
1252
|
+
if p is not None and hasattr(p, "last_redo_name"):
|
|
1253
|
+
try:
|
|
1254
|
+
return p.last_redo_name()
|
|
1255
|
+
except Exception:
|
|
1256
|
+
return None
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
def undo(self) -> str | None:
|
|
1260
|
+
# --- Case 1: ROI-local preview history ---
|
|
1261
|
+
if self._pundo:
|
|
1262
|
+
_debug_log_undo(
|
|
1263
|
+
"_RoiViewDocument.undo.local.entry",
|
|
1264
|
+
roi=self._roi,
|
|
1265
|
+
roi_doc_id=id(self),
|
|
1266
|
+
pundo_len=len(self._pundo),
|
|
1267
|
+
predo_len=len(self._predo),
|
|
1268
|
+
)
|
|
1269
|
+
# move current → redo; pop undo → current
|
|
1270
|
+
curr = self._current_preview_copy()
|
|
1271
|
+
self._predo.append((curr, dict(self.metadata), self._pundo[-1][2]))
|
|
1272
|
+
|
|
1273
|
+
prev_img, prev_meta, name = self._pundo.pop()
|
|
1274
|
+
self._preview_override = prev_img
|
|
1275
|
+
self.metadata = dict(prev_meta)
|
|
1276
|
+
_debug_log_undo(
|
|
1277
|
+
"_RoiViewDocument.undo.local.apply",
|
|
1278
|
+
roi=self._roi,
|
|
1279
|
+
new_preview_shape=getattr(prev_img, "shape", None),
|
|
1280
|
+
pundo_len=len(self._pundo),
|
|
1281
|
+
predo_len=len(self._predo),
|
|
1282
|
+
name=name,
|
|
1283
|
+
)
|
|
1284
|
+
try:
|
|
1285
|
+
self.changed.emit()
|
|
1286
|
+
except Exception:
|
|
1287
|
+
pass
|
|
1288
|
+
|
|
1289
|
+
dm = getattr(self, "_doc_manager", None)
|
|
1290
|
+
if dm is not None and hasattr(dm, "previewRepaintRequested"):
|
|
1291
|
+
try:
|
|
1292
|
+
dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
|
|
1293
|
+
except Exception:
|
|
1294
|
+
pass
|
|
1295
|
+
return name
|
|
1296
|
+
|
|
1297
|
+
# --- Case 2: no ROI-local history → delegate to parent ---
|
|
1298
|
+
parent = getattr(self, "_parent_doc", None)
|
|
1299
|
+
if parent is None or not hasattr(parent, "undo"):
|
|
1300
|
+
return None
|
|
1301
|
+
_debug_log_undo(
|
|
1302
|
+
"_RoiViewDocument.undo.parent.entry",
|
|
1303
|
+
roi=self._roi,
|
|
1304
|
+
roi_doc_id=id(self),
|
|
1305
|
+
parent_id=id(parent),
|
|
1306
|
+
parent_undo_len=len(getattr(parent, "_undo", [])),
|
|
1307
|
+
parent_redo_len=len(getattr(parent, "_redo", [])),
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
name = parent.undo()
|
|
1311
|
+
|
|
1312
|
+
# After parent changes, clear override so we show the new parent slice
|
|
1313
|
+
self._preview_override = None
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
self.changed.emit()
|
|
1317
|
+
except Exception:
|
|
1318
|
+
pass
|
|
1319
|
+
|
|
1320
|
+
dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
|
|
1321
|
+
if dm is not None and hasattr(dm, "previewRepaintRequested"):
|
|
1322
|
+
try:
|
|
1323
|
+
dm.previewRepaintRequested.emit(parent, self._roi)
|
|
1324
|
+
except Exception:
|
|
1325
|
+
pass
|
|
1326
|
+
return name
|
|
1327
|
+
|
|
1328
|
+
def redo(self) -> str | None:
|
|
1329
|
+
# --- Case 1: ROI-local preview history ---
|
|
1330
|
+
if self._predo:
|
|
1331
|
+
# move current → undo; pop redo → current
|
|
1332
|
+
curr = self._current_preview_copy()
|
|
1333
|
+
self._pundo.append((curr, dict(self.metadata), self._predo[-1][2]))
|
|
1334
|
+
|
|
1335
|
+
nxt_img, nxt_meta, name = self._predo.pop()
|
|
1336
|
+
self._preview_override = nxt_img
|
|
1337
|
+
self.metadata = dict(nxt_meta)
|
|
1338
|
+
|
|
1339
|
+
try:
|
|
1340
|
+
self.changed.emit()
|
|
1341
|
+
except Exception:
|
|
1342
|
+
pass
|
|
1343
|
+
|
|
1344
|
+
dm = getattr(self, "_doc_manager", None)
|
|
1345
|
+
if dm is not None and hasattr(dm, "previewRepaintRequested"):
|
|
1346
|
+
try:
|
|
1347
|
+
dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
|
|
1348
|
+
except Exception:
|
|
1349
|
+
pass
|
|
1350
|
+
return name
|
|
1351
|
+
|
|
1352
|
+
# --- Case 2: delegate to parent’s redo ---
|
|
1353
|
+
parent = getattr(self, "_parent_doc", None)
|
|
1354
|
+
if parent is None or not hasattr(parent, "redo"):
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
name = parent.redo()
|
|
1358
|
+
|
|
1359
|
+
# Parent changed → reset override and repaint
|
|
1360
|
+
self._preview_override = None
|
|
1361
|
+
|
|
1362
|
+
try:
|
|
1363
|
+
self.changed.emit()
|
|
1364
|
+
except Exception:
|
|
1365
|
+
pass
|
|
1366
|
+
|
|
1367
|
+
dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
|
|
1368
|
+
if dm is not None and hasattr(dm, "previewRepaintRequested"):
|
|
1369
|
+
try:
|
|
1370
|
+
dm.previewRepaintRequested.emit(parent, self._roi)
|
|
1371
|
+
except Exception:
|
|
1372
|
+
pass
|
|
1373
|
+
return name
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
class LiveViewDocument(QObject):
|
|
1378
|
+
"""
|
|
1379
|
+
Drop-in proxy that mirrors an ImageDocument API but always resolves
|
|
1380
|
+
via DocManager + view to the ROI-aware document (if a Preview tab is active).
|
|
1381
|
+
Reads: delegate to current resolved doc.
|
|
1382
|
+
Writes: use DocManager.update_active_document(...) so ROI is pasted back.
|
|
1383
|
+
"""
|
|
1384
|
+
changed = pyqtSignal()
|
|
1385
|
+
|
|
1386
|
+
def __init__(self, doc_manager: "DocManager", view, base_doc: "ImageDocument"):
|
|
1387
|
+
super().__init__(parent=base_doc.parent())
|
|
1388
|
+
self._dm = doc_manager
|
|
1389
|
+
self._view = view # ImageSubWindow widget
|
|
1390
|
+
self._base = base_doc # true ImageDocument
|
|
1391
|
+
|
|
1392
|
+
# Bridge base document change signals (ROI wrappers rarely emit)
|
|
1393
|
+
try:
|
|
1394
|
+
base_doc.changed.connect(self.changed.emit)
|
|
1395
|
+
except Exception:
|
|
1396
|
+
pass
|
|
1397
|
+
|
|
1398
|
+
# ---- core resolver ----
|
|
1399
|
+
def _current(self):
|
|
1400
|
+
try:
|
|
1401
|
+
d = self._dm.get_document_for_view(self._view)
|
|
1402
|
+
return d or self._base
|
|
1403
|
+
except Exception:
|
|
1404
|
+
return self._base
|
|
1405
|
+
|
|
1406
|
+
# ---- common API surface (reads) ----
|
|
1407
|
+
@property
|
|
1408
|
+
def image(self):
|
|
1409
|
+
d = self._current()
|
|
1410
|
+
return getattr(d, "image", None)
|
|
1411
|
+
|
|
1412
|
+
@property
|
|
1413
|
+
def metadata(self):
|
|
1414
|
+
d = self._current()
|
|
1415
|
+
return getattr(d, "metadata", {}) or {}
|
|
1416
|
+
|
|
1417
|
+
def display_name(self):
|
|
1418
|
+
d = self._current()
|
|
1419
|
+
if hasattr(d, "display_name"):
|
|
1420
|
+
try:
|
|
1421
|
+
return d.display_name()
|
|
1422
|
+
except Exception:
|
|
1423
|
+
pass
|
|
1424
|
+
return self._base.display_name() if hasattr(self._base, "display_name") else "Untitled"
|
|
1425
|
+
|
|
1426
|
+
# Mask access stays consistent with whichever doc is current
|
|
1427
|
+
def get_active_mask(self):
|
|
1428
|
+
d = self._current()
|
|
1429
|
+
if hasattr(d, "get_active_mask"):
|
|
1430
|
+
try:
|
|
1431
|
+
return d.get_active_mask()
|
|
1432
|
+
except Exception:
|
|
1433
|
+
return None
|
|
1434
|
+
return None
|
|
1435
|
+
|
|
1436
|
+
@property
|
|
1437
|
+
def masks(self):
|
|
1438
|
+
d = self._current()
|
|
1439
|
+
return getattr(d, "masks", {})
|
|
1440
|
+
|
|
1441
|
+
@property
|
|
1442
|
+
def active_mask_id(self):
|
|
1443
|
+
d = self._current()
|
|
1444
|
+
return getattr(d, "active_mask_id", None)
|
|
1445
|
+
|
|
1446
|
+
# ---- writes route through DocManager so ROI is honored ----
|
|
1447
|
+
def apply_edit(self, new_image, metadata=None, step_name="Edit"):
|
|
1448
|
+
#print("[LiveViewDocument] apply_edit called, routing via DocManager")
|
|
1449
|
+
self._dm.update_active_document(new_image, dict(metadata or {}), step_name)
|
|
1450
|
+
|
|
1451
|
+
# ---- history helpers (optional pass-throughs) ----
|
|
1452
|
+
def can_undo(self):
|
|
1453
|
+
d = self._current()
|
|
1454
|
+
return bool(getattr(d, "can_undo", lambda: False)())
|
|
1455
|
+
|
|
1456
|
+
def can_redo(self):
|
|
1457
|
+
d = self._current()
|
|
1458
|
+
return bool(getattr(d, "can_redo", lambda: False)())
|
|
1459
|
+
|
|
1460
|
+
def last_undo_name(self):
|
|
1461
|
+
d = self._current()
|
|
1462
|
+
return getattr(d, "last_undo_name", lambda: None)()
|
|
1463
|
+
|
|
1464
|
+
def last_redo_name(self):
|
|
1465
|
+
d = self._current()
|
|
1466
|
+
return getattr(d, "last_redo_name", lambda: None)()
|
|
1467
|
+
|
|
1468
|
+
def undo(self):
|
|
1469
|
+
d = self._current()
|
|
1470
|
+
_debug_log_undo(
|
|
1471
|
+
"LiveViewDocument.undo.call",
|
|
1472
|
+
live_id=id(self),
|
|
1473
|
+
resolved_type=type(d).__name__ if d is not None else None,
|
|
1474
|
+
resolved_id=id(d) if d is not None else None,
|
|
1475
|
+
is_roi=isinstance(d, _RoiViewDocument),
|
|
1476
|
+
has_undo=getattr(d, "can_undo", lambda: False)(),
|
|
1477
|
+
)
|
|
1478
|
+
return getattr(d, "undo", lambda: None)()
|
|
1479
|
+
|
|
1480
|
+
def redo(self):
|
|
1481
|
+
d = self._current()
|
|
1482
|
+
_debug_log_undo(
|
|
1483
|
+
"LiveViewDocument.redo.call",
|
|
1484
|
+
live_id=id(self),
|
|
1485
|
+
resolved_type=type(d).__name__ if d is not None else None,
|
|
1486
|
+
resolved_id=id(d) if d is not None else None,
|
|
1487
|
+
is_roi=isinstance(d, _RoiViewDocument),
|
|
1488
|
+
has_redo=getattr(d, "can_redo", lambda: False)(),
|
|
1489
|
+
)
|
|
1490
|
+
return getattr(d, "redo", lambda: None)()
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# ---- generic fallback so existing attributes keep working ----
|
|
1494
|
+
def __getattr__(self, name):
|
|
1495
|
+
# Prefer the current resolved doc, then base_doc
|
|
1496
|
+
d = object.__getattribute__(self, "_current")()
|
|
1497
|
+
if hasattr(d, name):
|
|
1498
|
+
return getattr(d, name)
|
|
1499
|
+
return getattr(self._base, name)
|
|
1500
|
+
|
|
1501
|
+
def _xisf_meta_to_fits_header(m: dict) -> fits.Header | None:
|
|
1502
|
+
"""
|
|
1503
|
+
Best-effort: pull common WCS keys out of XISF FITSKeywords into a fits.Header.
|
|
1504
|
+
Returns None if nothing usable found.
|
|
1505
|
+
"""
|
|
1506
|
+
fk = m.get("FITSKeywords", {}) if isinstance(m, dict) else {}
|
|
1507
|
+
if not fk:
|
|
1508
|
+
return None
|
|
1509
|
+
|
|
1510
|
+
want = (
|
|
1511
|
+
"WCSAXES", "CTYPE1", "CTYPE2", "CRPIX1", "CRPIX2",
|
|
1512
|
+
"CRVAL1", "CRVAL2", "CD1_1", "CD1_2", "CD2_1", "CD2_2",
|
|
1513
|
+
"CDELT1", "CDELT2", "PC1_1", "PC1_2", "PC2_1", "PC2_2",
|
|
1514
|
+
"A_ORDER", "B_ORDER"
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
hdr = fits.Header()
|
|
1518
|
+
found = False
|
|
1519
|
+
for k in want:
|
|
1520
|
+
vlist = fk.get(k)
|
|
1521
|
+
if vlist and isinstance(vlist, list) and vlist[0].get("value") is not None:
|
|
1522
|
+
hdr[k] = vlist[0]["value"]
|
|
1523
|
+
found = True
|
|
1524
|
+
|
|
1525
|
+
# also pull SIP coeffs if present
|
|
1526
|
+
for k, vlist in fk.items():
|
|
1527
|
+
if k.startswith(("A_", "B_", "AP_", "BP_")) and vlist and vlist[0].get("value") is not None:
|
|
1528
|
+
try:
|
|
1529
|
+
hdr[k] = float(vlist[0]["value"])
|
|
1530
|
+
found = True
|
|
1531
|
+
except Exception:
|
|
1532
|
+
pass
|
|
1533
|
+
|
|
1534
|
+
return hdr if found else None
|
|
1535
|
+
|
|
1536
|
+
DEBUG_SAVE_DOCUMENT = False
|
|
1537
|
+
|
|
1538
|
+
def debug_dump_metadata_print(meta: dict, context: str = ""):
|
|
1539
|
+
if DEBUG_SAVE_DOCUMENT:
|
|
1540
|
+
print(f"\n===== METADATA DUMP ({context}) =====")
|
|
1541
|
+
if not isinstance(meta, dict):
|
|
1542
|
+
print(" (not a dict) ->", type(meta))
|
|
1543
|
+
print("====================================")
|
|
1544
|
+
return
|
|
1545
|
+
|
|
1546
|
+
keys = sorted(str(k) for k in meta.keys())
|
|
1547
|
+
print(" keys:", ", ".join(keys))
|
|
1548
|
+
|
|
1549
|
+
for key in keys:
|
|
1550
|
+
val = meta[key]
|
|
1551
|
+
if isinstance(val, fits.Header):
|
|
1552
|
+
print(f" {key}: fits.Header with {len(val.cards)} cards")
|
|
1553
|
+
else:
|
|
1554
|
+
print(f" {key}: {val!r} ({type(val).__name__})")
|
|
1555
|
+
|
|
1556
|
+
print("===== END METADATA DUMP ({}) =====".format(context))
|
|
1557
|
+
|
|
1558
|
+
class DocManager(QObject):
|
|
1559
|
+
documentAdded = pyqtSignal(object) # ImageDocument
|
|
1560
|
+
documentRemoved = pyqtSignal(object) # ImageDocument
|
|
1561
|
+
imageRegionUpdated = pyqtSignal(object, object) # (doc, roi_tuple_or_None)
|
|
1562
|
+
previewRepaintRequested = pyqtSignal(object, object)
|
|
1563
|
+
|
|
1564
|
+
activeBaseChanged = pyqtSignal(object) # emits ImageDocument | None
|
|
1565
|
+
|
|
1566
|
+
def __init__(self, image_manager=None, parent=None):
|
|
1567
|
+
super().__init__(parent)
|
|
1568
|
+
self.image_manager = image_manager
|
|
1569
|
+
self._roi_doc_cache = {}
|
|
1570
|
+
self._docs: list[ImageDocument] = []
|
|
1571
|
+
self._active_doc: ImageDocument | None = None
|
|
1572
|
+
self._mdi: "QMdiArea | None" = None # type: ignore
|
|
1573
|
+
def _on_region_updated(doc, roi):
|
|
1574
|
+
vw = self._active_view_widget()
|
|
1575
|
+
if vw is not None:
|
|
1576
|
+
try:
|
|
1577
|
+
if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
|
|
1578
|
+
vw.refresh_from_docman(doc=doc, roi=roi)
|
|
1579
|
+
else:
|
|
1580
|
+
vw._render()
|
|
1581
|
+
except Exception:
|
|
1582
|
+
pass
|
|
1583
|
+
|
|
1584
|
+
self.imageRegionUpdated.connect(_on_region_updated)
|
|
1585
|
+
self._by_uid = {}
|
|
1586
|
+
self._focused_base_doc: ImageDocument | None = None # <— NEW
|
|
1587
|
+
|
|
1588
|
+
def _do_preview_repaint(doc, roi):
|
|
1589
|
+
vw = self._active_view_widget()
|
|
1590
|
+
if vw is not None:
|
|
1591
|
+
try:
|
|
1592
|
+
if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
|
|
1593
|
+
vw.refresh_from_docman(doc=doc, roi=roi)
|
|
1594
|
+
else:
|
|
1595
|
+
vw._render()
|
|
1596
|
+
except Exception:
|
|
1597
|
+
pass
|
|
1598
|
+
self.previewRepaintRequested.connect(_do_preview_repaint)
|
|
1599
|
+
|
|
1600
|
+
def get_document_for_view(self, view):
|
|
1601
|
+
"""
|
|
1602
|
+
Given an ImageSubWindow widget, return either:
|
|
1603
|
+
- the full base ImageDocument
|
|
1604
|
+
- or a cached ROI-wrapper doc if a Preview/ROI tab is active
|
|
1605
|
+
Works with both old (has_active_preview/current_preview_roi) and
|
|
1606
|
+
new (_active_roi_tuple) view APIs. Falls back to view.document.
|
|
1607
|
+
"""
|
|
1608
|
+
# 1) Resolve a base document from the view
|
|
1609
|
+
base = (
|
|
1610
|
+
getattr(view, "base_document", None)
|
|
1611
|
+
or getattr(view, "_base_document", None)
|
|
1612
|
+
or getattr(view, "document", None)
|
|
1613
|
+
)
|
|
1614
|
+
if base is None:
|
|
1615
|
+
return None
|
|
1616
|
+
|
|
1617
|
+
# 2) Try to discover an ROI (support both APIs)
|
|
1618
|
+
roi = None
|
|
1619
|
+
try:
|
|
1620
|
+
if hasattr(view, "has_active_preview") and callable(view.has_active_preview):
|
|
1621
|
+
if view.has_active_preview():
|
|
1622
|
+
# preferred old API
|
|
1623
|
+
try:
|
|
1624
|
+
roi = view.current_preview_roi() # (x,y,w,h)
|
|
1625
|
+
except Exception:
|
|
1626
|
+
roi = None
|
|
1627
|
+
except Exception:
|
|
1628
|
+
pass
|
|
1629
|
+
|
|
1630
|
+
if roi is None:
|
|
1631
|
+
# new API candidate
|
|
1632
|
+
for attr in ("_active_roi_tuple", "current_roi_tuple", "selected_roi", "roi"):
|
|
1633
|
+
try:
|
|
1634
|
+
fn = getattr(view, attr, None)
|
|
1635
|
+
if callable(fn):
|
|
1636
|
+
r = fn()
|
|
1637
|
+
if r and len(r) == 4:
|
|
1638
|
+
roi = r
|
|
1639
|
+
break
|
|
1640
|
+
except Exception:
|
|
1641
|
+
pass
|
|
1642
|
+
|
|
1643
|
+
# 3) If no ROI, return the base doc
|
|
1644
|
+
if not roi:
|
|
1645
|
+
return base
|
|
1646
|
+
|
|
1647
|
+
# 4) Cache and return a lightweight ROI view doc
|
|
1648
|
+
try:
|
|
1649
|
+
x, y, w, h = map(int, roi)
|
|
1650
|
+
key = (id(base), id(view), (x, y, w, h))
|
|
1651
|
+
roi_doc = self._roi_doc_cache.get(key)
|
|
1652
|
+
if roi_doc is None:
|
|
1653
|
+
roi_doc = self._build_roi_document(base, (x, y, w, h))
|
|
1654
|
+
self._roi_doc_cache[key] = roi_doc
|
|
1655
|
+
return roi_doc
|
|
1656
|
+
except Exception:
|
|
1657
|
+
# If anything about ROI construction fails, fall back
|
|
1658
|
+
return base
|
|
1659
|
+
|
|
1660
|
+
def _invalidate_roi_cache(self, parent_doc, roi_tuple):
|
|
1661
|
+
"""Drop cached ROI docs that overlap an updated region of parent_doc."""
|
|
1662
|
+
if not roi_tuple:
|
|
1663
|
+
# full-image change -> drop all for this parent
|
|
1664
|
+
dead = [k for k in self._roi_doc_cache.keys() if k[0] == id(parent_doc)]
|
|
1665
|
+
else:
|
|
1666
|
+
px, py, pw, ph = roi_tuple
|
|
1667
|
+
def _overlaps(a, b):
|
|
1668
|
+
ax, ay, aw, ah = a; bx, by, bw, bh = b
|
|
1669
|
+
return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
|
|
1670
|
+
dead = []
|
|
1671
|
+
for (parent_id, _view_id, aroi), _doc in list(self._roi_doc_cache.items()):
|
|
1672
|
+
if parent_id != id(parent_doc):
|
|
1673
|
+
continue
|
|
1674
|
+
if _overlaps(aroi, (px, py, pw, ph)):
|
|
1675
|
+
dead.append((parent_id, _view_id, aroi))
|
|
1676
|
+
for k in dead:
|
|
1677
|
+
self._roi_doc_cache.pop(k, None)
|
|
1678
|
+
|
|
1679
|
+
def get_document_by_uid(self, uid: str):
|
|
1680
|
+
return self._by_uid.get(uid)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _register_doc(self, doc):
|
|
1684
|
+
import weakref
|
|
1685
|
+
# Only ImageDocument needs the backref; tables can ignore it.
|
|
1686
|
+
if hasattr(doc, "image") or hasattr(doc, "apply_edit"):
|
|
1687
|
+
try:
|
|
1688
|
+
doc._doc_manager = weakref.proxy(self) # avoid cycles
|
|
1689
|
+
except Exception:
|
|
1690
|
+
doc._doc_manager = self # fallback
|
|
1691
|
+
self._docs.append(doc)
|
|
1692
|
+
if hasattr(doc, "uid"):
|
|
1693
|
+
self._by_uid[doc.uid] = doc
|
|
1694
|
+
self.documentAdded.emit(doc)
|
|
1695
|
+
|
|
1696
|
+
def _build_roi_document(self, base_doc, roi):
|
|
1697
|
+
#print("[DocManager] Building ROI view document")
|
|
1698
|
+
doc = _RoiViewDocument(base_doc, roi, name_suffix=" (Preview)")
|
|
1699
|
+
try:
|
|
1700
|
+
import weakref
|
|
1701
|
+
doc._doc_manager = weakref.proxy(self)
|
|
1702
|
+
except Exception:
|
|
1703
|
+
doc._doc_manager = self
|
|
1704
|
+
|
|
1705
|
+
# Repaint the active view on ROI preview changes, but DO NOT invalidate cache.
|
|
1706
|
+
try:
|
|
1707
|
+
#print("[DocManager] Connecting ROI view document change signal")
|
|
1708
|
+
import weakref
|
|
1709
|
+
dm_ref = weakref.ref(self)
|
|
1710
|
+
roi_tuple = tuple(map(int, roi))
|
|
1711
|
+
|
|
1712
|
+
def _on_roi_changed():
|
|
1713
|
+
dm = dm_ref()
|
|
1714
|
+
if dm is None:
|
|
1715
|
+
return
|
|
1716
|
+
vw = dm._active_view_widget()
|
|
1717
|
+
if vw is not None:
|
|
1718
|
+
try:
|
|
1719
|
+
# IMPORTANT: use the *parent* doc here, not the ROI wrapper
|
|
1720
|
+
base = getattr(doc, "_parent_doc", None) or doc
|
|
1721
|
+
if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
|
|
1722
|
+
vw.refresh_from_docman(doc=base, roi=roi_tuple)
|
|
1723
|
+
else:
|
|
1724
|
+
vw._render()
|
|
1725
|
+
except Exception:
|
|
1726
|
+
pass
|
|
1727
|
+
|
|
1728
|
+
doc.changed.connect(_on_roi_changed)
|
|
1729
|
+
|
|
1730
|
+
#print("[DocManager] ROI view document change signal connected")
|
|
1731
|
+
except Exception:
|
|
1732
|
+
print("[DocManager] Failed to connect ROI view document change signal")
|
|
1733
|
+
pass
|
|
1734
|
+
|
|
1735
|
+
return doc
|
|
1736
|
+
|
|
1737
|
+
def commit_active_preview_to_parent(self, metadata: dict | None = None, step_name: str = "Edit"):
|
|
1738
|
+
doc = self.get_active_document()
|
|
1739
|
+
if isinstance(doc, _RoiViewDocument):
|
|
1740
|
+
doc.commit_to_parent(None, metadata=metadata or {}, step_name=step_name)
|
|
1741
|
+
# after commit, force an immediate view repaint
|
|
1742
|
+
vw = self._active_view_widget()
|
|
1743
|
+
if vw is not None:
|
|
1744
|
+
try:
|
|
1745
|
+
if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
|
|
1746
|
+
vw.refresh_from_docman(doc=doc._parent_doc, roi=None)
|
|
1747
|
+
else:
|
|
1748
|
+
vw._render()
|
|
1749
|
+
except Exception:
|
|
1750
|
+
pass
|
|
1751
|
+
|
|
1752
|
+
def wrap_document_for_view(self, view, base_doc: ImageDocument) -> LiveViewDocument:
|
|
1753
|
+
"""Return a live, ROI-aware proxy for this view."""
|
|
1754
|
+
return LiveViewDocument(self, view, base_doc)
|
|
1755
|
+
|
|
1756
|
+
def open_path(self, path: str):
|
|
1757
|
+
ext = os.path.splitext(path)[1].lower().lstrip('.')
|
|
1758
|
+
norm_ext = _normalize_ext(ext)
|
|
1759
|
+
|
|
1760
|
+
lower_path = path.lower()
|
|
1761
|
+
is_fits = lower_path.endswith((".fit", ".fits", ".fit.gz", ".fits.gz", ".fz"))
|
|
1762
|
+
is_xisf = (norm_ext == "xisf")
|
|
1763
|
+
|
|
1764
|
+
primary_doc = None
|
|
1765
|
+
created_any = False
|
|
1766
|
+
|
|
1767
|
+
# ---------- 1) Try the universal loader first (ALL formats) ----------
|
|
1768
|
+
img = header = bit_depth = is_mono = None
|
|
1769
|
+
meta = None
|
|
1770
|
+
try:
|
|
1771
|
+
# NEW: prefer metadata-aware return
|
|
1772
|
+
out = legacy_load_image(path, return_metadata=True)
|
|
1773
|
+
if out and len(out) == 5:
|
|
1774
|
+
img, header, bit_depth, is_mono, meta = out
|
|
1775
|
+
else:
|
|
1776
|
+
img, header, bit_depth, is_mono = out
|
|
1777
|
+
except TypeError:
|
|
1778
|
+
# legacy_load_image older signature → fall back
|
|
1779
|
+
try:
|
|
1780
|
+
img, header, bit_depth, is_mono = legacy_load_image(path)
|
|
1781
|
+
except Exception as e:
|
|
1782
|
+
print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
|
|
1783
|
+
except Exception as e:
|
|
1784
|
+
print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
|
|
1785
|
+
|
|
1786
|
+
maybe_warn_raw_preview(path, header)
|
|
1787
|
+
|
|
1788
|
+
if img is not None:
|
|
1789
|
+
if meta is None:
|
|
1790
|
+
meta = {
|
|
1791
|
+
"file_path": path,
|
|
1792
|
+
"original_header": header,
|
|
1793
|
+
"bit_depth": bit_depth,
|
|
1794
|
+
"is_mono": is_mono,
|
|
1795
|
+
"original_format": norm_ext,
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
# NEW: attach WCS even for old loader
|
|
1799
|
+
meta = attach_wcs_to_metadata(meta, header)
|
|
1800
|
+
|
|
1801
|
+
_snapshot_header_for_metadata(meta)
|
|
1802
|
+
|
|
1803
|
+
img = _normalize_image_01(img)
|
|
1804
|
+
primary_doc = ImageDocument(img, meta)
|
|
1805
|
+
self._register_doc(primary_doc)
|
|
1806
|
+
created_any = True
|
|
1807
|
+
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
# ---------- 2) FITS: enumerate HDUs (tables + extra images + ICC) ----------
|
|
1811
|
+
if is_fits:
|
|
1812
|
+
try:
|
|
1813
|
+
with fits.open(path, memmap=True) as hdul:
|
|
1814
|
+
base = os.path.basename(path)
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
for i, hdu in enumerate(hdul):
|
|
1818
|
+
name_up = (getattr(hdu, "name", "PRIMARY") or "PRIMARY").upper()
|
|
1819
|
+
if primary_doc is not None and (i == 0 or name_up == "PRIMARY"):
|
|
1820
|
+
|
|
1821
|
+
continue
|
|
1822
|
+
|
|
1823
|
+
ext_hdr = hdu.header
|
|
1824
|
+
try:
|
|
1825
|
+
en = str(ext_hdr.get("EXTNAME", "")).strip()
|
|
1826
|
+
ev = ext_hdr.get("EXTVER", None)
|
|
1827
|
+
extname = f"{en}[{int(ev)}]" if (en and isinstance(ev, (int, np.integer))) else (en or "")
|
|
1828
|
+
except Exception:
|
|
1829
|
+
extname = ""
|
|
1830
|
+
|
|
1831
|
+
# --- Tables → TableDocument ---
|
|
1832
|
+
if isinstance(hdu, (fits.BinTableHDU, fits.TableHDU)):
|
|
1833
|
+
key_str = extname or f"HDU{i}"
|
|
1834
|
+
nice = key_str
|
|
1835
|
+
#print(f"[DocManager] HDU {i}: {type(hdu).__name__} '{nice}' → Table")
|
|
1836
|
+
|
|
1837
|
+
# Optional CSV export
|
|
1838
|
+
csv_name = f"{os.path.splitext(path)[0]}_{key_str}.csv".replace(" ", "_")
|
|
1839
|
+
try:
|
|
1840
|
+
_ = _fits_table_to_csv(hdu, csv_name)
|
|
1841
|
+
except Exception as e_csv:
|
|
1842
|
+
print(f"[DocManager] Table CSV export failed ({nice}): {e_csv}")
|
|
1843
|
+
csv_name = None
|
|
1844
|
+
|
|
1845
|
+
# Build in-app table
|
|
1846
|
+
try:
|
|
1847
|
+
rows, headers = _fits_table_to_rows_headers(hdu, max_rows=500000)
|
|
1848
|
+
tmeta = {
|
|
1849
|
+
"file_path": f"{path}::{key_str}",
|
|
1850
|
+
"original_header": ext_hdr,
|
|
1851
|
+
"original_format": "fits",
|
|
1852
|
+
"display_name": f"{base} {key_str} (Table)",
|
|
1853
|
+
"doc_type": "table",
|
|
1854
|
+
"table_csv": csv_name if (csv_name and os.path.exists(csv_name)) else None,
|
|
1855
|
+
}
|
|
1856
|
+
_snapshot_header_for_metadata(tmeta)
|
|
1857
|
+
tdoc = TableDocument(rows, headers, tmeta, parent=self.parent())
|
|
1858
|
+
self._register_doc(tdoc)
|
|
1859
|
+
try: tdoc.changed.emit()
|
|
1860
|
+
except Exception as e:
|
|
1861
|
+
import logging
|
|
1862
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1863
|
+
created_any = True
|
|
1864
|
+
#print(f"[DocManager] Added TableDocument: rows={len(rows)} cols={len(headers)} title='{tdoc.display_name()}'")
|
|
1865
|
+
except Exception as e_tab:
|
|
1866
|
+
print(f"[DocManager] Table HDU {nice} → in-app view failed: {e_tab}")
|
|
1867
|
+
continue # IMPORTANT: don’t treat a table as an image
|
|
1868
|
+
|
|
1869
|
+
# --- Not a table: ICC or image ---
|
|
1870
|
+
if hdu.data is None:
|
|
1871
|
+
#print(f"[DocManager] HDU {i} '{extname or f'HDU{i}'}' has no data — noted as aux")
|
|
1872
|
+
continue
|
|
1873
|
+
|
|
1874
|
+
arr = np.asanyarray(hdu.data)
|
|
1875
|
+
en_up = (extname or "").upper()
|
|
1876
|
+
is_probable_icc = ("ICC" in en_up or "PROFILE" in en_up)
|
|
1877
|
+
|
|
1878
|
+
# ICC ONLY if name suggests ICC/profile AND data is 1-D uint8
|
|
1879
|
+
if arr.ndim == 1 and arr.dtype == np.uint8 and is_probable_icc:
|
|
1880
|
+
try:
|
|
1881
|
+
icc_path = f"{os.path.splitext(path)[0]}_{extname or f'HDU{i}'}_.icc".replace(" ", "_")
|
|
1882
|
+
with open(icc_path, "wb") as f:
|
|
1883
|
+
f.write(arr.tobytes())
|
|
1884
|
+
#print(f"[DocManager] Extracted ICC profile → {icc_path}")
|
|
1885
|
+
created_any = True
|
|
1886
|
+
continue
|
|
1887
|
+
except Exception as e_icc:
|
|
1888
|
+
print(f"[DocManager] ICC export failed: {e_icc} — will try as image")
|
|
1889
|
+
|
|
1890
|
+
# Otherwise: treat as image doc
|
|
1891
|
+
try:
|
|
1892
|
+
if arr.dtype.kind in "ui":
|
|
1893
|
+
a = arr.astype(np.float32, copy=False) # NO normalization
|
|
1894
|
+
# optional: if you want to record original scale:
|
|
1895
|
+
ext_depth = f"{arr.dtype.itemsize*8}-bit {'unsigned' if arr.dtype.kind=='u' else 'signed'}"
|
|
1896
|
+
else:
|
|
1897
|
+
a = arr.astype(np.float32, copy=False) # floats preserved
|
|
1898
|
+
ext_depth = "32-bit floating point" if arr.dtype == np.float32 else "64-bit floating point"
|
|
1899
|
+
|
|
1900
|
+
ext_mono = bool(a.ndim == 2 or (a.ndim == 3 and a.shape[2] == 1))
|
|
1901
|
+
key_str = extname or f"HDU {i}"
|
|
1902
|
+
disp = f"{base} {key_str}"
|
|
1903
|
+
|
|
1904
|
+
aux_meta = {
|
|
1905
|
+
"file_path": f"{path}::{key_str}",
|
|
1906
|
+
"original_header": ext_hdr,
|
|
1907
|
+
"bit_depth": ext_depth,
|
|
1908
|
+
"is_mono": bool(ext_mono),
|
|
1909
|
+
"original_format": "fits",
|
|
1910
|
+
"image_meta": {"derived_from": path, "layer": key_str, "readonly": True},
|
|
1911
|
+
"display_name": disp,
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
# NEW: attach WCS from this HDU header
|
|
1915
|
+
aux_meta = attach_wcs_to_metadata(aux_meta, ext_hdr)
|
|
1916
|
+
|
|
1917
|
+
_snapshot_header_for_metadata(aux_meta)
|
|
1918
|
+
a = _normalize_image_01(a)
|
|
1919
|
+
aux_doc = ImageDocument(a, aux_meta)
|
|
1920
|
+
|
|
1921
|
+
self._register_doc(aux_doc)
|
|
1922
|
+
try: aux_doc.changed.emit()
|
|
1923
|
+
except Exception as e:
|
|
1924
|
+
import logging
|
|
1925
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1926
|
+
created_any = True
|
|
1927
|
+
|
|
1928
|
+
except Exception as e_img:
|
|
1929
|
+
print(f"[DocManager] FITS HDU {i} image build failed: {e_img}")
|
|
1930
|
+
except Exception as _e:
|
|
1931
|
+
print(f"[DocManager] FITS HDU enumeration failed: {_e}")
|
|
1932
|
+
|
|
1933
|
+
# ---------- 3) XISF: create primary if needed, then enumerate extras ----------
|
|
1934
|
+
if is_xisf:
|
|
1935
|
+
try:
|
|
1936
|
+
# helpers
|
|
1937
|
+
def _bit_depth_from_dtype(dt: np.dtype) -> str:
|
|
1938
|
+
dt = np.dtype(dt)
|
|
1939
|
+
if dt == np.float32: return "32-bit floating point"
|
|
1940
|
+
if dt == np.float64: return "64-bit floating point"
|
|
1941
|
+
if dt == np.uint8: return "8-bit"
|
|
1942
|
+
if dt == np.uint16: return "16-bit"
|
|
1943
|
+
if dt == np.uint32: return "32-bit unsigned"
|
|
1944
|
+
return "32-bit floating point"
|
|
1945
|
+
|
|
1946
|
+
def _to_float32_01(arr: np.ndarray) -> np.ndarray:
|
|
1947
|
+
a = np.asarray(arr)
|
|
1948
|
+
if a.dtype == np.float32:
|
|
1949
|
+
return a
|
|
1950
|
+
if a.dtype.kind in "iu":
|
|
1951
|
+
return (a.astype(np.float32) / np.iinfo(a.dtype).max).clip(0.0, 1.0)
|
|
1952
|
+
return a.astype(np.float32, copy=False)
|
|
1953
|
+
|
|
1954
|
+
def _to_float32_preserve(arr: np.ndarray) -> np.ndarray:
|
|
1955
|
+
a = np.asarray(arr)
|
|
1956
|
+
return a if a.dtype == np.float32 else a.astype(np.float32, copy=False)
|
|
1957
|
+
|
|
1958
|
+
xisf = XISFReader(path)
|
|
1959
|
+
metas = xisf.get_images_metadata() or []
|
|
1960
|
+
base = os.path.basename(path)
|
|
1961
|
+
|
|
1962
|
+
# If legacy did NOT create a primary, build image #0 now
|
|
1963
|
+
if primary_doc is None and len(metas) >= 1:
|
|
1964
|
+
try:
|
|
1965
|
+
arr0 = xisf.read_image(0, data_format="channels_last")
|
|
1966
|
+
arr0_f32 = _to_float32_preserve(arr0)
|
|
1967
|
+
arr0_f32 = _normalize_image_01(arr0_f32)
|
|
1968
|
+
bd0 = _bit_depth_from_dtype(metas[0].get("dtype", arr0.dtype))
|
|
1969
|
+
is_mono0 = (arr0_f32.ndim == 2) or (arr0_f32.ndim == 3 and arr0_f32.shape[2] == 1)
|
|
1970
|
+
|
|
1971
|
+
# Friendly label for #0
|
|
1972
|
+
label0 = metas[0].get("id") or "Image[0]"
|
|
1973
|
+
md0 = {
|
|
1974
|
+
"file_path": f"{path}::XISF[0]",
|
|
1975
|
+
"original_header": metas[0], # will be sanitized
|
|
1976
|
+
"bit_depth": bd0,
|
|
1977
|
+
"is_mono": is_mono0,
|
|
1978
|
+
"original_format": "xisf",
|
|
1979
|
+
"image_meta": {"derived_from": path, "layer_index": 0, "readonly": True},
|
|
1980
|
+
"display_name": f"{base} {label0}",
|
|
1981
|
+
}
|
|
1982
|
+
# NEW: attach WCS if possible
|
|
1983
|
+
hdr0 = _xisf_meta_to_fits_header(metas[0])
|
|
1984
|
+
if hdr0 is not None:
|
|
1985
|
+
md0 = attach_wcs_to_metadata(md0, hdr0)
|
|
1986
|
+
|
|
1987
|
+
_snapshot_header_for_metadata(md0)
|
|
1988
|
+
primary_doc = ImageDocument(arr0_f32, md0)
|
|
1989
|
+
self._register_doc(primary_doc)
|
|
1990
|
+
try: primary_doc.changed.emit()
|
|
1991
|
+
except Exception as e:
|
|
1992
|
+
import logging
|
|
1993
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1994
|
+
created_any = True
|
|
1995
|
+
|
|
1996
|
+
except Exception as e0:
|
|
1997
|
+
print(f"[DocManager] XISF primary (index 0) open failed: {e0}")
|
|
1998
|
+
|
|
1999
|
+
# Add images 1..N-1 as siblings (even if primary came from legacy)
|
|
2000
|
+
for i in range(1, len(metas)):
|
|
2001
|
+
try:
|
|
2002
|
+
m = metas[i]
|
|
2003
|
+
arr = xisf.read_image(i, data_format="channels_last")
|
|
2004
|
+
arr_f32 = _to_float32_preserve(arr)
|
|
2005
|
+
arr_f32 = _normalize_image_01(arr_f32)
|
|
2006
|
+
|
|
2007
|
+
bd = _bit_depth_from_dtype(m.get("dtype", arr.dtype))
|
|
2008
|
+
is_mono_i = (arr_f32.ndim == 2) or (arr_f32.ndim == 3 and arr_f32.shape[2] == 1)
|
|
2009
|
+
|
|
2010
|
+
# Friendly label: prefer id, else EXTNAME/EXTVER in FITSKeywords, else index
|
|
2011
|
+
label = m.get("id") or None
|
|
2012
|
+
if not label:
|
|
2013
|
+
try:
|
|
2014
|
+
fk = m.get("FITSKeywords", {})
|
|
2015
|
+
en = (fk.get("EXTNAME") or [{}])[0].get("value", "")
|
|
2016
|
+
ev = (fk.get("EXTVER") or [{}])[0].get("value", "")
|
|
2017
|
+
if en:
|
|
2018
|
+
label = f"{en}[{ev}]" if ev else en
|
|
2019
|
+
except Exception:
|
|
2020
|
+
pass
|
|
2021
|
+
if not label:
|
|
2022
|
+
label = f"Image[{i}]"
|
|
2023
|
+
|
|
2024
|
+
md = {
|
|
2025
|
+
"file_path": f"{path}::XISF[{i}]",
|
|
2026
|
+
"original_header": m, # snapshot; sanitized below
|
|
2027
|
+
"bit_depth": bd,
|
|
2028
|
+
"is_mono": is_mono_i,
|
|
2029
|
+
"original_format": "xisf",
|
|
2030
|
+
"image_meta": {"derived_from": path, "layer_index": i, "readonly": True},
|
|
2031
|
+
"display_name": f"{base} {label}",
|
|
2032
|
+
}
|
|
2033
|
+
hdri = _xisf_meta_to_fits_header(m)
|
|
2034
|
+
if hdri is not None:
|
|
2035
|
+
md = attach_wcs_to_metadata(md, hdri)
|
|
2036
|
+
|
|
2037
|
+
_snapshot_header_for_metadata(md)
|
|
2038
|
+
sib = ImageDocument(arr_f32, md)
|
|
2039
|
+
self._register_doc(sib)
|
|
2040
|
+
try: sib.changed.emit()
|
|
2041
|
+
except Exception as e:
|
|
2042
|
+
import logging
|
|
2043
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
2044
|
+
created_any = True
|
|
2045
|
+
|
|
2046
|
+
except Exception as _e:
|
|
2047
|
+
print(f"[DocManager] XISF image {i} skipped: {_e}")
|
|
2048
|
+
except Exception as _e:
|
|
2049
|
+
print(f"[DocManager] XISF open/enumeration failed: {_e}")
|
|
2050
|
+
|
|
2051
|
+
# ---------- 4) Return sensible doc or raise ----------
|
|
2052
|
+
if primary_doc is not None:
|
|
2053
|
+
return primary_doc
|
|
2054
|
+
if created_any:
|
|
2055
|
+
return self._docs[-1] # e.g., a table-only FITS or extra XISF image
|
|
2056
|
+
|
|
2057
|
+
raise IOError(f"Could not load: {path}")
|
|
2058
|
+
|
|
2059
|
+
# --- Subwindow / ROI awareness -------------------------------------
|
|
2060
|
+
def _active_subwindow(self):
|
|
2061
|
+
"""Return the active QMdiSubWindow (if any)."""
|
|
2062
|
+
if self._mdi is None:
|
|
2063
|
+
return None
|
|
2064
|
+
try:
|
|
2065
|
+
return self._mdi.activeSubWindow()
|
|
2066
|
+
except Exception:
|
|
2067
|
+
return None
|
|
2068
|
+
|
|
2069
|
+
def _active_view_widget(self):
|
|
2070
|
+
"""Return the active view widget (ImageSubWindow or TableSubWindow)."""
|
|
2071
|
+
sw = self._active_subwindow()
|
|
2072
|
+
if not sw:
|
|
2073
|
+
return None
|
|
2074
|
+
try:
|
|
2075
|
+
return sw.widget()
|
|
2076
|
+
except Exception:
|
|
2077
|
+
return None
|
|
2078
|
+
|
|
2079
|
+
def _active_preview_roi(self):
|
|
2080
|
+
"""
|
|
2081
|
+
Returns (x,y,w,h) if the active view is an ImageSubWindow with a selected Preview tab.
|
|
2082
|
+
Else returns None.
|
|
2083
|
+
"""
|
|
2084
|
+
#print("[DocManager] Checking for active preview ROI")
|
|
2085
|
+
vw = self._active_view_widget()
|
|
2086
|
+
if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
|
|
2087
|
+
try:
|
|
2088
|
+
return vw.current_preview_roi()
|
|
2089
|
+
except Exception:
|
|
2090
|
+
return None
|
|
2091
|
+
return None
|
|
2092
|
+
|
|
2093
|
+
def get_active_image(self, prefer_preview: bool = True):
|
|
2094
|
+
"""
|
|
2095
|
+
Unified read: returns the ndarray a tool should operate on.
|
|
2096
|
+
If a Preview tab is active and prefer_preview=True, return that crop.
|
|
2097
|
+
Otherwise return the full document image.
|
|
2098
|
+
"""
|
|
2099
|
+
doc = self.get_active_document()
|
|
2100
|
+
if doc is None or doc.image is None:
|
|
2101
|
+
return None
|
|
2102
|
+
roi = self._active_preview_roi() if prefer_preview else None
|
|
2103
|
+
if roi is None:
|
|
2104
|
+
return doc.image
|
|
2105
|
+
x, y, w, h = roi
|
|
2106
|
+
return doc.image[y:y+h, x:x+w]
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
# --- Slot -> Document ---
|
|
2110
|
+
def open_from_slot(self, slot_idx: int | None = None) -> "ImageDocument | None":
|
|
2111
|
+
if not self.image_manager:
|
|
2112
|
+
return None
|
|
2113
|
+
|
|
2114
|
+
if slot_idx is None:
|
|
2115
|
+
slot_idx = getattr(self.image_manager, "current_slot", 0)
|
|
2116
|
+
|
|
2117
|
+
img = self.image_manager.get_image_for_slot(slot_idx)
|
|
2118
|
+
if img is None:
|
|
2119
|
+
return None
|
|
2120
|
+
|
|
2121
|
+
meta = {}
|
|
2122
|
+
try:
|
|
2123
|
+
meta = dict(self.image_manager._metadata.get(slot_idx, {}))
|
|
2124
|
+
except Exception:
|
|
2125
|
+
pass
|
|
2126
|
+
|
|
2127
|
+
meta.setdefault("file_path", f"Slot {slot_idx}")
|
|
2128
|
+
meta.setdefault("bit_depth", "32-bit floating point")
|
|
2129
|
+
meta.setdefault("is_mono", (img.ndim == 2))
|
|
2130
|
+
meta.setdefault("original_header", meta.get("original_header")) # whatever SASv2 had
|
|
2131
|
+
meta.setdefault("original_format", "fits")
|
|
2132
|
+
|
|
2133
|
+
_snapshot_header_for_metadata(meta)
|
|
2134
|
+
|
|
2135
|
+
doc = ImageDocument(img, meta)
|
|
2136
|
+
self._register_doc(doc)
|
|
2137
|
+
return doc
|
|
2138
|
+
|
|
2139
|
+
# --- Save ---
|
|
2140
|
+
def _infer_bit_depth_for_format(self, img: np.ndarray, ext: str, current_bit_depth: str | None) -> str:
|
|
2141
|
+
# Previous heuristic fallback (used only if no override provided).
|
|
2142
|
+
if ext in ("png", "jpg"):
|
|
2143
|
+
return "8-bit"
|
|
2144
|
+
if ext in ("fits", "fit"):
|
|
2145
|
+
return "32-bit floating point"
|
|
2146
|
+
if ext == "tif":
|
|
2147
|
+
if current_bit_depth in _ALLOWED_DEPTHS["tif"]:
|
|
2148
|
+
return current_bit_depth
|
|
2149
|
+
return "16-bit" if np.issubdtype(img.dtype, np.floating) else "8-bit"
|
|
2150
|
+
if ext == "xisf":
|
|
2151
|
+
return current_bit_depth if current_bit_depth in _ALLOWED_DEPTHS["xisf"] else "32-bit floating point"
|
|
2152
|
+
return "32-bit floating point"
|
|
2153
|
+
|
|
2154
|
+
def save_document(
|
|
2155
|
+
self,
|
|
2156
|
+
doc: "ImageDocument",
|
|
2157
|
+
path: str,
|
|
2158
|
+
bit_depth: str | None = None,
|
|
2159
|
+
*,
|
|
2160
|
+
bit_depth_override: str | None = None,
|
|
2161
|
+
):
|
|
2162
|
+
"""
|
|
2163
|
+
Save the given ImageDocument to 'path'.
|
|
2164
|
+
|
|
2165
|
+
bit_depth_override:
|
|
2166
|
+
New-style explicit choice from a dialog.
|
|
2167
|
+
|
|
2168
|
+
bit_depth:
|
|
2169
|
+
Legacy positional argument; still honored if override is None.
|
|
2170
|
+
"""
|
|
2171
|
+
ext = _normalize_ext(os.path.splitext(path)[1])
|
|
2172
|
+
img = doc.image
|
|
2173
|
+
meta = doc.metadata or {}
|
|
2174
|
+
|
|
2175
|
+
# ── MASSIVE DEBUG: show everything we know coming in ───────────────
|
|
2176
|
+
debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
|
|
2177
|
+
|
|
2178
|
+
# --- Decide final bit depth ---------------------------------------
|
|
2179
|
+
requested = bit_depth_override or bit_depth or meta.get("bit_depth")
|
|
2180
|
+
|
|
2181
|
+
if requested:
|
|
2182
|
+
allowed = _ALLOWED_DEPTHS.get(ext, set())
|
|
2183
|
+
if allowed and requested not in allowed:
|
|
2184
|
+
print(f"[save_document] Requested bit depth {requested!r} "
|
|
2185
|
+
f"not in allowed {allowed}, falling back to first.")
|
|
2186
|
+
final_bit_depth = next(iter(allowed))
|
|
2187
|
+
else:
|
|
2188
|
+
final_bit_depth = requested
|
|
2189
|
+
else:
|
|
2190
|
+
final_bit_depth = self._infer_bit_depth_for_format(
|
|
2191
|
+
img, ext, meta.get("bit_depth")
|
|
2192
|
+
)
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
# --- Clip if needed for integer encodes ---------------------------
|
|
2197
|
+
needs_clip = (
|
|
2198
|
+
ext in ("png", "jpg", "jpeg", "tif", "tiff")
|
|
2199
|
+
and final_bit_depth in ("8-bit", "16-bit", "32-bit unsigned")
|
|
2200
|
+
)
|
|
2201
|
+
if needs_clip:
|
|
2202
|
+
print("[save_document] Clipping image to [0,1] for integer encode.")
|
|
2203
|
+
img_to_save = np.clip(img, 0.0, 1.0) if needs_clip else img
|
|
2204
|
+
|
|
2205
|
+
# --- PICK THE HEADER EXPLICITLY -----------------------------------
|
|
2206
|
+
# Priority:
|
|
2207
|
+
# 1) wcs_header
|
|
2208
|
+
# 2) fits_header
|
|
2209
|
+
# 3) original_header
|
|
2210
|
+
# 4) header
|
|
2211
|
+
effective_header = None
|
|
2212
|
+
for key in ("original_header", "fits_header", "wcs_header", "header"):
|
|
2213
|
+
val = meta.get(key)
|
|
2214
|
+
if isinstance(val, fits.Header):
|
|
2215
|
+
effective_header = val
|
|
2216
|
+
|
|
2217
|
+
break
|
|
2218
|
+
|
|
2219
|
+
#if effective_header is None:
|
|
2220
|
+
# print("[save_document] WARNING: No fits.Header in metadata, "
|
|
2221
|
+
# "legacy_save_image will pick a default header.")
|
|
2222
|
+
#else:
|
|
2223
|
+
# # Print first few cards so we can confirm we have the SIP stuff
|
|
2224
|
+
# print("[save_document] effective_header preview (first 25 cards):")
|
|
2225
|
+
# for i, card in enumerate(effective_header.cards):
|
|
2226
|
+
# if i >= 25:
|
|
2227
|
+
# print(" ... (truncated)")
|
|
2228
|
+
# break
|
|
2229
|
+
# print(f" {card.keyword:8s} = {card.value!r}")
|
|
2230
|
+
|
|
2231
|
+
# ── Call the legacy saver ─────────────────────────────────────────
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
legacy_save_image(
|
|
2235
|
+
img_array=img_to_save,
|
|
2236
|
+
filename=path,
|
|
2237
|
+
original_format=ext,
|
|
2238
|
+
bit_depth=final_bit_depth,
|
|
2239
|
+
original_header=effective_header,
|
|
2240
|
+
is_mono=meta.get("mono", img.ndim == 2),
|
|
2241
|
+
image_meta=meta.get("image_meta"),
|
|
2242
|
+
file_meta=meta.get("file_meta"),
|
|
2243
|
+
wcs_header=meta.get("wcs_header"),
|
|
2244
|
+
)
|
|
2245
|
+
|
|
2246
|
+
# ── Update metadata in memory to match what we just wrote ─────────
|
|
2247
|
+
meta["file_path"] = path
|
|
2248
|
+
meta["original_format"] = ext
|
|
2249
|
+
meta["bit_depth"] = final_bit_depth
|
|
2250
|
+
|
|
2251
|
+
if isinstance(effective_header, fits.Header):
|
|
2252
|
+
meta["original_header"] = effective_header
|
|
2253
|
+
|
|
2254
|
+
# If you have this helper, keep it; if not, you can skip it
|
|
2255
|
+
try:
|
|
2256
|
+
_snapshot_header_for_metadata(meta)
|
|
2257
|
+
except Exception as e:
|
|
2258
|
+
print("[save_document] _snapshot_header_for_metadata error:", e)
|
|
2259
|
+
|
|
2260
|
+
doc.metadata = meta
|
|
2261
|
+
|
|
2262
|
+
# reset dirty flag
|
|
2263
|
+
if hasattr(doc, "dirty"):
|
|
2264
|
+
doc.dirty = False
|
|
2265
|
+
|
|
2266
|
+
if hasattr(doc, "changed"):
|
|
2267
|
+
doc.changed.emit()
|
|
2268
|
+
|
|
2269
|
+
def duplicate_document(self, source_doc: ImageDocument, new_name: str | None = None) -> ImageDocument:
|
|
2270
|
+
# DEBUG: log the source doc WCS before we touch anything
|
|
2271
|
+
if _DEBUG_WCS:
|
|
2272
|
+
try:
|
|
2273
|
+
name = source_doc.display_name() if hasattr(source_doc, "display_name") else "<src>"
|
|
2274
|
+
except Exception:
|
|
2275
|
+
name = "<src>"
|
|
2276
|
+
|
|
2277
|
+
_debug_log_wcs_context(" source.metadata", getattr(source_doc, "metadata", {}))
|
|
2278
|
+
|
|
2279
|
+
# COPY-ON-WRITE: Share the source image instead of copying immediately.
|
|
2280
|
+
# The duplicate's apply_edit will copy when it first modifies the image.
|
|
2281
|
+
# This saves memory when duplicates are created but not modified.
|
|
2282
|
+
img_ref = source_doc.image # Shared reference, no copy
|
|
2283
|
+
|
|
2284
|
+
meta = dict(source_doc.metadata or {})
|
|
2285
|
+
base = source_doc.display_name()
|
|
2286
|
+
dup_title = (new_name or f"{base}_duplicate")
|
|
2287
|
+
# 🚫 strip any lingering emojis / link markers
|
|
2288
|
+
dup_title = dup_title.replace("🔗", "").strip()
|
|
2289
|
+
meta["display_name"] = dup_title
|
|
2290
|
+
|
|
2291
|
+
# Remove anything that makes the view look "linked/preview"
|
|
2292
|
+
imi = dict(meta.get("image_meta") or {})
|
|
2293
|
+
for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
|
|
2294
|
+
imi.pop(k, None)
|
|
2295
|
+
meta["image_meta"] = imi
|
|
2296
|
+
for k in list(meta.keys()):
|
|
2297
|
+
if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
|
|
2298
|
+
meta.pop(k, None)
|
|
2299
|
+
|
|
2300
|
+
# NOTE: we intentionally DO NOT remove "roi_wcs_header" or "original_header"
|
|
2301
|
+
# so that a ROI doc keeps its cropped WCS in the duplicate.
|
|
2302
|
+
|
|
2303
|
+
# Safe bit depth / mono flags
|
|
2304
|
+
meta.setdefault("original_format", meta.get("original_format", "fits"))
|
|
2305
|
+
if isinstance(img_ref, np.ndarray):
|
|
2306
|
+
meta["is_mono"] = (img_ref.ndim == 2 or (img_ref.ndim == 3 and img_ref.shape[2] == 1))
|
|
2307
|
+
|
|
2308
|
+
_snapshot_header_for_metadata(meta)
|
|
2309
|
+
|
|
2310
|
+
dup = ImageDocument(img_ref, meta, parent=self.parent())
|
|
2311
|
+
# Mark this duplicate as sharing image data with source
|
|
2312
|
+
dup._cow_source = source_doc
|
|
2313
|
+
self._register_doc(dup)
|
|
2314
|
+
|
|
2315
|
+
# DEBUG: log the duplicate doc WCS
|
|
2316
|
+
if _DEBUG_WCS:
|
|
2317
|
+
try:
|
|
2318
|
+
dname = dup.display_name()
|
|
2319
|
+
except Exception:
|
|
2320
|
+
dname = "<dup>"
|
|
2321
|
+
|
|
2322
|
+
_debug_log_wcs_context(" duplicate.metadata", dup.metadata)
|
|
2323
|
+
|
|
2324
|
+
return dup
|
|
2325
|
+
|
|
2326
|
+
#def open_array(self, arr, metadata: dict | None = None, title: str | None = None) -> ImageDocument:
|
|
2327
|
+
# import numpy as np
|
|
2328
|
+
## if arr is None:
|
|
2329
|
+
# raise ValueError("open_array: arr is None")
|
|
2330
|
+
# img = np.asarray(arr)
|
|
2331
|
+
# if img.dtype != np.float32:
|
|
2332
|
+
# img = img.astype(np.float32, copy=False)
|
|
2333
|
+
|
|
2334
|
+
# meta = dict(metadata or {})
|
|
2335
|
+
# meta.setdefault("bit_depth", "32-bit floating point")
|
|
2336
|
+
# meta.setdefault("is_mono", img.ndim == 2)
|
|
2337
|
+
# meta.setdefault("original_header", meta.get("original_header"))
|
|
2338
|
+
# meta.setdefault("original_format", meta.get("original_format", "fits"))
|
|
2339
|
+
# if title:
|
|
2340
|
+
# meta.setdefault("display_name", title)
|
|
2341
|
+
|
|
2342
|
+
# doc = ImageDocument(img, meta, parent=self.parent())
|
|
2343
|
+
# self._docs.append(doc)
|
|
2344
|
+
# self.documentAdded.emit(doc)
|
|
2345
|
+
# return doc
|
|
2346
|
+
|
|
2347
|
+
# convenient aliases used by your tool code
|
|
2348
|
+
def open_array(self, img: np.ndarray, metadata: dict | None = None, title: str | None = None) -> "ImageDocument":
|
|
2349
|
+
meta = dict(metadata or {})
|
|
2350
|
+
if title:
|
|
2351
|
+
meta["display_name"] = title
|
|
2352
|
+
# normalize a few expected fields if missing
|
|
2353
|
+
try:
|
|
2354
|
+
if "is_mono" not in meta and isinstance(img, np.ndarray):
|
|
2355
|
+
meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
|
|
2356
|
+
except Exception:
|
|
2357
|
+
pass
|
|
2358
|
+
meta.setdefault("bit_depth", meta.get("bit_depth", "32-bit floating point"))
|
|
2359
|
+
|
|
2360
|
+
_snapshot_header_for_metadata(meta)
|
|
2361
|
+
|
|
2362
|
+
doc = ImageDocument(img, meta, parent=self.parent())
|
|
2363
|
+
self._register_doc(doc)
|
|
2364
|
+
return doc
|
|
2365
|
+
|
|
2366
|
+
# (optional alias for old code)
|
|
2367
|
+
open_numpy = open_array
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
|
|
2371
|
+
return self.open_array(image, metadata=metadata, title=name)
|
|
2372
|
+
|
|
2373
|
+
def close_document(self, doc):
|
|
2374
|
+
if doc in self._docs:
|
|
2375
|
+
self._docs.remove(doc)
|
|
2376
|
+
try:
|
|
2377
|
+
if hasattr(doc, "uid"):
|
|
2378
|
+
self._by_uid.pop(doc.uid, None)
|
|
2379
|
+
except Exception:
|
|
2380
|
+
pass
|
|
2381
|
+
|
|
2382
|
+
# Cleanup swap files
|
|
2383
|
+
if hasattr(doc, "close"):
|
|
2384
|
+
try:
|
|
2385
|
+
doc.close()
|
|
2386
|
+
except Exception as e:
|
|
2387
|
+
print(f"[DocManager] Failed to close document {doc}: {e}")
|
|
2388
|
+
|
|
2389
|
+
self.documentRemoved.emit(doc)
|
|
2390
|
+
|
|
2391
|
+
# --- Active-document helpers (NEW) ---------------------------------
|
|
2392
|
+
def all_documents(self):
|
|
2393
|
+
return list(self._docs)
|
|
2394
|
+
|
|
2395
|
+
def _find_main_window(self):
|
|
2396
|
+
from PyQt6.QtWidgets import QMainWindow, QApplication
|
|
2397
|
+
w = self.parent()
|
|
2398
|
+
while w is not None and not isinstance(w, QMainWindow):
|
|
2399
|
+
w = w.parent()
|
|
2400
|
+
if w:
|
|
2401
|
+
return w
|
|
2402
|
+
for tlw in QApplication.topLevelWidgets():
|
|
2403
|
+
if isinstance(tlw, QMainWindow):
|
|
2404
|
+
return tlw
|
|
2405
|
+
return None
|
|
2406
|
+
|
|
2407
|
+
def set_active_document(self, doc: ImageDocument | None):
|
|
2408
|
+
if doc is not None and doc not in self._docs:
|
|
2409
|
+
return
|
|
2410
|
+
# ensure backref for legacy docs
|
|
2411
|
+
if doc is not None and not hasattr(doc, "_doc_manager"):
|
|
2412
|
+
try:
|
|
2413
|
+
import weakref
|
|
2414
|
+
doc._doc_manager = weakref.proxy(self)
|
|
2415
|
+
except Exception:
|
|
2416
|
+
doc._doc_manager = self
|
|
2417
|
+
self._active_doc = doc
|
|
2418
|
+
|
|
2419
|
+
def set_mdi_area(self, mdi):
|
|
2420
|
+
"""Call this once from MainWindow after MDI is created."""
|
|
2421
|
+
self._mdi = mdi
|
|
2422
|
+
try:
|
|
2423
|
+
mdi.subWindowActivated.connect(self._on_subwindow_activated)
|
|
2424
|
+
except Exception:
|
|
2425
|
+
pass
|
|
2426
|
+
|
|
2427
|
+
def _base_from_subwindow(self, sw):
|
|
2428
|
+
"""Best-effort: unwrap to the base ImageDocument bound to a subwindow."""
|
|
2429
|
+
if sw is None:
|
|
2430
|
+
return None
|
|
2431
|
+
try:
|
|
2432
|
+
w = sw.widget()
|
|
2433
|
+
base = (getattr(w, "base_document", None)
|
|
2434
|
+
or getattr(w, "_base_document", None)
|
|
2435
|
+
or getattr(w, "document", None))
|
|
2436
|
+
# unwrap ROI wrappers if any
|
|
2437
|
+
p = getattr(base, "_parent_doc", None)
|
|
2438
|
+
return p if isinstance(p, ImageDocument) else base
|
|
2439
|
+
except Exception:
|
|
2440
|
+
return None
|
|
2441
|
+
|
|
2442
|
+
def _on_subwindow_activated(self, sw):
|
|
2443
|
+
# existing logic (keep it)
|
|
2444
|
+
doc = None
|
|
2445
|
+
try:
|
|
2446
|
+
if sw is not None:
|
|
2447
|
+
w = sw.widget()
|
|
2448
|
+
doc = getattr(w, "document", None) or getattr(sw, "document", None)
|
|
2449
|
+
except Exception:
|
|
2450
|
+
doc = None
|
|
2451
|
+
self.set_active_document(doc)
|
|
2452
|
+
|
|
2453
|
+
# NEW: compute focused *base* doc and emit change only when different
|
|
2454
|
+
new_base = self._base_from_subwindow(sw)
|
|
2455
|
+
if new_base is not self._focused_base_doc:
|
|
2456
|
+
self._focused_base_doc = new_base
|
|
2457
|
+
try:
|
|
2458
|
+
self.activeBaseChanged.emit(new_base)
|
|
2459
|
+
except Exception:
|
|
2460
|
+
pass
|
|
2461
|
+
|
|
2462
|
+
def get_focused_base_document(self):
|
|
2463
|
+
"""
|
|
2464
|
+
Returns the last *activated* subwindow's base ImageDocument (sticky),
|
|
2465
|
+
ignoring hover/preview wrappers.
|
|
2466
|
+
"""
|
|
2467
|
+
return self._focused_base_doc
|
|
2468
|
+
|
|
2469
|
+
def get_active_document(self):
|
|
2470
|
+
"""
|
|
2471
|
+
Return the active document-like object.
|
|
2472
|
+
If a Preview tab is selected on the active ImageSubWindow, return a cached
|
|
2473
|
+
_RoiViewDocument so tools and the Preview tab share the same instance.
|
|
2474
|
+
Otherwise return the real ImageDocument.
|
|
2475
|
+
"""
|
|
2476
|
+
# Prefer cached (if set and still valid)
|
|
2477
|
+
if self._active_doc is not None and self._active_doc in self._docs:
|
|
2478
|
+
base_doc = self._active_doc
|
|
2479
|
+
else:
|
|
2480
|
+
base_doc = None
|
|
2481
|
+
try:
|
|
2482
|
+
if self._mdi is not None:
|
|
2483
|
+
sw = self._mdi.activeSubWindow()
|
|
2484
|
+
if sw is not None:
|
|
2485
|
+
w = sw.widget()
|
|
2486
|
+
base_doc = getattr(w, "document", None) or getattr(sw, "document", None)
|
|
2487
|
+
if base_doc is not None:
|
|
2488
|
+
self._active_doc = base_doc
|
|
2489
|
+
except Exception:
|
|
2490
|
+
pass
|
|
2491
|
+
if base_doc is None:
|
|
2492
|
+
base_doc = self._docs[-1] if self._docs else None
|
|
2493
|
+
|
|
2494
|
+
# Non-image docs just pass through
|
|
2495
|
+
if base_doc is None or not isinstance(base_doc, ImageDocument) or base_doc.image is None:
|
|
2496
|
+
return base_doc
|
|
2497
|
+
|
|
2498
|
+
# ✅ ROI-aware, CACHED preview doc
|
|
2499
|
+
vw = self._active_view_widget()
|
|
2500
|
+
if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
|
|
2501
|
+
try:
|
|
2502
|
+
roi_doc = self.get_document_for_view(vw) # <-- uses _roi_doc_cache
|
|
2503
|
+
if isinstance(roi_doc, _RoiViewDocument):
|
|
2504
|
+
try:
|
|
2505
|
+
name_suffix = f" (Preview {vw.current_preview_name() or ''})"
|
|
2506
|
+
roi_doc.metadata["display_name"] = f"{base_doc.display_name()}{name_suffix}"
|
|
2507
|
+
except Exception:
|
|
2508
|
+
pass
|
|
2509
|
+
return roi_doc
|
|
2510
|
+
except Exception:
|
|
2511
|
+
return base_doc
|
|
2512
|
+
|
|
2513
|
+
return base_doc
|
|
2514
|
+
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
def update_active_document(
|
|
2518
|
+
self,
|
|
2519
|
+
updated_image,
|
|
2520
|
+
metadata=None,
|
|
2521
|
+
step_name: str = "Edit",
|
|
2522
|
+
doc=None, # 👈 NEW optional parameter
|
|
2523
|
+
):
|
|
2524
|
+
|
|
2525
|
+
# Prefer explicit doc if given; otherwise fall back to "active"
|
|
2526
|
+
view_doc = doc or self.get_active_document()
|
|
2527
|
+
if view_doc is None:
|
|
2528
|
+
raise RuntimeError("No active document")
|
|
2529
|
+
|
|
2530
|
+
old_img = getattr(view_doc, "image", None)
|
|
2531
|
+
old_shape = getattr(old_img, "shape", None)
|
|
2532
|
+
|
|
2533
|
+
img = np.asarray(updated_image)
|
|
2534
|
+
if img.dtype != np.float32:
|
|
2535
|
+
img = img.astype(np.float32, copy=False)
|
|
2536
|
+
|
|
2537
|
+
_debug_log_undo(
|
|
2538
|
+
"DocManager.update_active_document.entry",
|
|
2539
|
+
step_name=step_name,
|
|
2540
|
+
view_doc_type=type(view_doc).__name__,
|
|
2541
|
+
view_doc_id=id(view_doc),
|
|
2542
|
+
is_roi=isinstance(view_doc, _RoiViewDocument),
|
|
2543
|
+
old_shape=old_shape,
|
|
2544
|
+
new_shape=getattr(img, "shape", None),
|
|
2545
|
+
)
|
|
2546
|
+
|
|
2547
|
+
# --- Extract operation parameters (if any) from metadata --------
|
|
2548
|
+
md = dict(metadata or {})
|
|
2549
|
+
op_params = md.pop("__op_params__", None)
|
|
2550
|
+
|
|
2551
|
+
# If this is an ROI view doc, keep track of where this happened
|
|
2552
|
+
roi_tuple = None
|
|
2553
|
+
source_kind = "full"
|
|
2554
|
+
if isinstance(view_doc, _RoiViewDocument):
|
|
2555
|
+
roi_tuple = getattr(view_doc, "_roi", None)
|
|
2556
|
+
source_kind = "roi"
|
|
2557
|
+
|
|
2558
|
+
# --- ROI preview branch: only update preview, no parent paste ----
|
|
2559
|
+
if isinstance(view_doc, _RoiViewDocument):
|
|
2560
|
+
# Update ONLY the preview; view repaint is driven by signals
|
|
2561
|
+
view_doc.apply_edit(img, md, step_name)
|
|
2562
|
+
|
|
2563
|
+
# Record operation on the ROI doc itself
|
|
2564
|
+
if hasattr(view_doc, "record_operation"):
|
|
2565
|
+
try:
|
|
2566
|
+
view_doc.record_operation(
|
|
2567
|
+
step_name=step_name,
|
|
2568
|
+
params=op_params,
|
|
2569
|
+
roi=roi_tuple,
|
|
2570
|
+
source=source_kind,
|
|
2571
|
+
)
|
|
2572
|
+
except Exception:
|
|
2573
|
+
pass
|
|
2574
|
+
_debug_log_undo(
|
|
2575
|
+
"DocManager.update_active_document.roi_after",
|
|
2576
|
+
step_name=step_name,
|
|
2577
|
+
view_doc_id=id(view_doc),
|
|
2578
|
+
roi=getattr(view_doc, "_roi", None),
|
|
2579
|
+
pundo_len=len(getattr(view_doc, "_pundo", [])),
|
|
2580
|
+
predo_len=len(getattr(view_doc, "_predo", [])),
|
|
2581
|
+
)
|
|
2582
|
+
return
|
|
2583
|
+
|
|
2584
|
+
# --- Full image branch ------------------------------------------
|
|
2585
|
+
if isinstance(view_doc, ImageDocument):
|
|
2586
|
+
view_doc.apply_edit(img, md, step_name)
|
|
2587
|
+
try:
|
|
2588
|
+
self.imageRegionUpdated.emit(view_doc, None)
|
|
2589
|
+
except Exception:
|
|
2590
|
+
pass
|
|
2591
|
+
|
|
2592
|
+
_debug_log_undo(
|
|
2593
|
+
"DocManager.update_active_document.full_after",
|
|
2594
|
+
step_name=step_name,
|
|
2595
|
+
view_doc_id=id(view_doc),
|
|
2596
|
+
undo_len=len(getattr(view_doc, "_undo", [])),
|
|
2597
|
+
redo_len=len(getattr(view_doc, "_redo", [])),
|
|
2598
|
+
final_shape=getattr(view_doc.image, "shape", None),
|
|
2599
|
+
)
|
|
2600
|
+
# Record operation on the full document
|
|
2601
|
+
if hasattr(view_doc, "record_operation"):
|
|
2602
|
+
try:
|
|
2603
|
+
view_doc.record_operation(
|
|
2604
|
+
step_name=step_name,
|
|
2605
|
+
params=op_params,
|
|
2606
|
+
roi=None,
|
|
2607
|
+
source=source_kind,
|
|
2608
|
+
)
|
|
2609
|
+
|
|
2610
|
+
except Exception:
|
|
2611
|
+
pass
|
|
2612
|
+
else:
|
|
2613
|
+
raise RuntimeError("Active document is not an image")
|
|
2614
|
+
|
|
2615
|
+
def get_active_operation_log(self) -> list[dict]:
|
|
2616
|
+
"""
|
|
2617
|
+
Return the operation log for the *currently active* document-like
|
|
2618
|
+
(full image or ROI-preview). Empty list if none.
|
|
2619
|
+
"""
|
|
2620
|
+
doc = self.get_active_document()
|
|
2621
|
+
if doc is None:
|
|
2622
|
+
return []
|
|
2623
|
+
get_log = getattr(doc, "get_operation_log", None)
|
|
2624
|
+
if callable(get_log):
|
|
2625
|
+
try:
|
|
2626
|
+
return get_log()
|
|
2627
|
+
except Exception:
|
|
2628
|
+
return []
|
|
2629
|
+
return []
|
|
2630
|
+
|
|
2631
|
+
|
|
2632
|
+
|
|
2633
|
+
# Back-compat/aliases so tools can call any of these:
|
|
2634
|
+
def update_image(self, updated_image, metadata=None, step_name: str = "Edit"):
|
|
2635
|
+
self.update_active_document(updated_image, metadata, step_name)
|
|
2636
|
+
|
|
2637
|
+
def set_image(self, img, metadata=None, step_name: str = "Edit"):
|
|
2638
|
+
self.update_active_document(img, metadata, step_name)
|
|
2639
|
+
|
|
2640
|
+
def apply_edit_to_active(self, img, step_name: str = "Edit", metadata=None):
|
|
2641
|
+
self.update_active_document(img, metadata, step_name)
|