setiastrosuitepro 1.6.0__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/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -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 +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -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 +948 -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 +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -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/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 +8494 -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 +445 -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 +1324 -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 +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -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 +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -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 +472 -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 +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -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 +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -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 +305 -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.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
|
@@ -0,0 +1,3267 @@
|
|
|
1
|
+
# pro/subwindow.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QEvent, QByteArray, QMimeData, QSettings, QTimer, QRect, QPoint, QMargins
|
|
4
|
+
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel, QToolButton, QHBoxLayout, QMessageBox, QMdiSubWindow, QMenu, QInputDialog, QApplication, QTabWidget, QRubberBand
|
|
5
|
+
from PyQt6.QtGui import QPixmap, QImage, QWheelEvent, QShortcut, QKeySequence, QCursor, QDrag, QGuiApplication
|
|
6
|
+
from PyQt6 import sip
|
|
7
|
+
import numpy as np
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import weakref
|
|
11
|
+
import os
|
|
12
|
+
try:
|
|
13
|
+
from PyQt6.QtCore import QSignalBlocker
|
|
14
|
+
except Exception:
|
|
15
|
+
class QSignalBlocker:
|
|
16
|
+
def __init__(self, obj): self.obj = obj
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
try: self.obj.blockSignals(True)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
import logging
|
|
21
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
22
|
+
def __exit__(self, *exc):
|
|
23
|
+
try: self.obj.blockSignals(False)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
import logging
|
|
26
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
27
|
+
|
|
28
|
+
from .autostretch import autostretch # ← uses pro/imageops/stretch.py
|
|
29
|
+
|
|
30
|
+
from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_MASK, MIME_ASTROMETRY, MIME_CMD, MIME_LINKVIEW
|
|
31
|
+
from setiastro.saspro.shortcuts import _unpack_cmd_payload
|
|
32
|
+
from setiastro.saspro.widgets.image_utils import ensure_contiguous
|
|
33
|
+
|
|
34
|
+
from .layers import composite_stack, ImageLayer, BLEND_MODES
|
|
35
|
+
|
|
36
|
+
# --- NEW: simple table model for TableDocument ---
|
|
37
|
+
from PyQt6.QtCore import QAbstractTableModel, QModelIndex, Qt, QVariant
|
|
38
|
+
|
|
39
|
+
__all__ = ["ImageSubWindow", "TableSubWindow"]
|
|
40
|
+
|
|
41
|
+
class SimpleTableModel(QAbstractTableModel):
|
|
42
|
+
def __init__(self, rows: list[list], headers: list[str], parent=None):
|
|
43
|
+
super().__init__(parent)
|
|
44
|
+
self._rows = rows
|
|
45
|
+
self._headers = headers
|
|
46
|
+
|
|
47
|
+
def rowCount(self, parent=QModelIndex()) -> int:
|
|
48
|
+
return 0 if parent.isValid() else len(self._rows)
|
|
49
|
+
|
|
50
|
+
def columnCount(self, parent=QModelIndex()) -> int:
|
|
51
|
+
return 0 if parent.isValid() else (len(self._headers) if self._headers else (len(self._rows[0]) if self._rows else 0))
|
|
52
|
+
|
|
53
|
+
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
|
|
54
|
+
if not index.isValid():
|
|
55
|
+
return QVariant()
|
|
56
|
+
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
|
57
|
+
try:
|
|
58
|
+
return str(self._rows[index.row()][index.column()])
|
|
59
|
+
except Exception:
|
|
60
|
+
return ""
|
|
61
|
+
return QVariant()
|
|
62
|
+
|
|
63
|
+
def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.ItemDataRole.DisplayRole):
|
|
64
|
+
if role != Qt.ItemDataRole.DisplayRole:
|
|
65
|
+
return QVariant()
|
|
66
|
+
if orientation == Qt.Orientation.Horizontal:
|
|
67
|
+
try:
|
|
68
|
+
return self._headers[section] if self._headers and 0 <= section < len(self._headers) else f"C{section+1}"
|
|
69
|
+
except Exception:
|
|
70
|
+
return f"C{section+1}"
|
|
71
|
+
else:
|
|
72
|
+
return str(section + 1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _DragTab(QLabel):
|
|
76
|
+
"""
|
|
77
|
+
Little grab tab you can drag to copy/sync view state.
|
|
78
|
+
- Drag onto MDI background → duplicate view (same document)
|
|
79
|
+
- Drag onto another subwindow → copy zoom/pan/stretch to that view
|
|
80
|
+
"""
|
|
81
|
+
def __init__(self, owner, *args, **kwargs):
|
|
82
|
+
super().__init__(*args, **kwargs)
|
|
83
|
+
self.owner = owner
|
|
84
|
+
self._press_pos = None
|
|
85
|
+
self.setText("⧉")
|
|
86
|
+
self.setToolTip(
|
|
87
|
+
"Drag to duplicate/copy view.\n"
|
|
88
|
+
"Hold Alt while dragging to LINK this view with another (live pan/zoom sync).\n"
|
|
89
|
+
"Hold Shift while dragging to drop this image as a mask onto another view.\n"
|
|
90
|
+
"Hold Ctrl while dragging to copy the astrometric solution (WCS) to another view."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.setFixedSize(22, 18)
|
|
94
|
+
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
95
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
96
|
+
self.setStyleSheet(
|
|
97
|
+
"QLabel{background:rgba(255,255,255,30); "
|
|
98
|
+
"border:1px solid rgba(255,255,255,60); border-radius:4px;}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def mousePressEvent(self, ev):
|
|
102
|
+
if ev.button() == Qt.MouseButton.LeftButton:
|
|
103
|
+
self._press_pos = ev.position()
|
|
104
|
+
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def mouseMoveEvent(self, ev):
|
|
108
|
+
if self._press_pos is None:
|
|
109
|
+
return
|
|
110
|
+
if (ev.position() - self._press_pos).manhattanLength() > 6:
|
|
111
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
112
|
+
self._press_pos = None
|
|
113
|
+
mods = QApplication.keyboardModifiers()
|
|
114
|
+
if (mods & Qt.KeyboardModifier.AltModifier):
|
|
115
|
+
self.owner._start_link_drag()
|
|
116
|
+
elif (mods & Qt.KeyboardModifier.ShiftModifier):
|
|
117
|
+
print("[DragTab] Shift+drag → start_mask_drag() from", id(self.owner))
|
|
118
|
+
self.owner._start_mask_drag()
|
|
119
|
+
elif (mods & Qt.KeyboardModifier.ControlModifier):
|
|
120
|
+
self.owner._start_astrometry_drag()
|
|
121
|
+
else:
|
|
122
|
+
self.owner._start_viewstate_drag()
|
|
123
|
+
|
|
124
|
+
def mouseReleaseEvent(self, ev):
|
|
125
|
+
self._press_pos = None
|
|
126
|
+
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
127
|
+
|
|
128
|
+
MASK_GLYPH = "■"
|
|
129
|
+
#ACTIVE_PREFIX = "Active View: "
|
|
130
|
+
ACTIVE_PREFIX = ""
|
|
131
|
+
GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
|
|
132
|
+
LINK_PREFIX = "🔗 "
|
|
133
|
+
DECORATION_PREFIXES = (
|
|
134
|
+
LINK_PREFIX, # "🔗 "
|
|
135
|
+
f"{MASK_GLYPH} ", # "■ "
|
|
136
|
+
"Active View: ", # legacy
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
from astropy.wcs import WCS as _AstroWCS
|
|
141
|
+
from astropy.io.fits import Header as _FitsHeader
|
|
142
|
+
|
|
143
|
+
def build_celestial_wcs(header) -> _AstroWCS | None:
|
|
144
|
+
"""
|
|
145
|
+
Given a FITS-like header or a dict with FITS keywords, return a *2-D celestial*
|
|
146
|
+
astropy.wcs.WCS. Returns None if a sane celestial WCS cannot be recovered.
|
|
147
|
+
Resilient to 3rd axes (RGB/STOKES) and SIP distortions.
|
|
148
|
+
|
|
149
|
+
Accepted `header`:
|
|
150
|
+
* astropy.io.fits.Header
|
|
151
|
+
* dict of FITS cards (string->value)
|
|
152
|
+
* dict containing {"FITSKeywords": {NAME: [{value: ..., comment: ...}], ...}}
|
|
153
|
+
"""
|
|
154
|
+
if header is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
# (A) If we already got a WCS, try to coerce to celestial
|
|
158
|
+
if isinstance(header, _AstroWCS):
|
|
159
|
+
try:
|
|
160
|
+
wc = getattr(header, "celestial", None)
|
|
161
|
+
return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else header
|
|
162
|
+
except Exception:
|
|
163
|
+
return header
|
|
164
|
+
|
|
165
|
+
# (B) Ensure we have a bona-fide FITS Header
|
|
166
|
+
hdr_obj = None
|
|
167
|
+
if isinstance(header, _FitsHeader):
|
|
168
|
+
hdr_obj = header
|
|
169
|
+
elif isinstance(header, dict):
|
|
170
|
+
# XISF-style: {"FITSKeywords": {"CTYPE1":[{"value":"RA---TAN"}], ...}}
|
|
171
|
+
if "FITSKeywords" in header and isinstance(header["FITSKeywords"], dict):
|
|
172
|
+
from astropy.io.fits import Header
|
|
173
|
+
hdr_obj = Header()
|
|
174
|
+
for k, v in header["FITSKeywords"].items():
|
|
175
|
+
if isinstance(v, list) and v:
|
|
176
|
+
val = v[0].get("value")
|
|
177
|
+
com = v[0].get("comment", "")
|
|
178
|
+
if val is not None:
|
|
179
|
+
try: hdr_obj[str(k)] = (val, com)
|
|
180
|
+
except Exception: hdr_obj[str(k)] = val
|
|
181
|
+
elif v is not None:
|
|
182
|
+
try: hdr_obj[str(k)] = v
|
|
183
|
+
except Exception as e:
|
|
184
|
+
import logging
|
|
185
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
186
|
+
else:
|
|
187
|
+
# Flat dict of FITS-like cards
|
|
188
|
+
from astropy.io.fits import Header
|
|
189
|
+
hdr_obj = Header()
|
|
190
|
+
for k, v in header.items():
|
|
191
|
+
try: hdr_obj[str(k)] = v
|
|
192
|
+
except Exception as e:
|
|
193
|
+
import logging
|
|
194
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
195
|
+
|
|
196
|
+
if hdr_obj is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# (C) Try full WCS first
|
|
200
|
+
try:
|
|
201
|
+
w = _AstroWCS(hdr_obj, relax=True)
|
|
202
|
+
wc = getattr(w, "celestial", None)
|
|
203
|
+
if wc is not None and getattr(wc, "naxis", 2) == 2:
|
|
204
|
+
return wc
|
|
205
|
+
if getattr(w, "has_celestial", False):
|
|
206
|
+
return w.celestial
|
|
207
|
+
except Exception:
|
|
208
|
+
w = None
|
|
209
|
+
|
|
210
|
+
# (D) Force a 2-axis interpretation (drop e.g. RGB axis)
|
|
211
|
+
try:
|
|
212
|
+
w2 = _AstroWCS(hdr_obj, relax=True, naxis=2)
|
|
213
|
+
if getattr(w2, "has_celestial", False):
|
|
214
|
+
return w2.celestial
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# (E) As a last resort, scrub obvious axis-3 cards and retry
|
|
219
|
+
try:
|
|
220
|
+
hdr2 = hdr_obj.copy()
|
|
221
|
+
for k in ("CTYPE3","CUNIT3","CRVAL3","CRPIX3",
|
|
222
|
+
"CD3_1","CD3_2","CD3_3","PC3_1","PC3_2","PC3_3"):
|
|
223
|
+
if k in hdr2:
|
|
224
|
+
del hdr2[k]
|
|
225
|
+
w3 = _AstroWCS(hdr2, relax=True, naxis=2)
|
|
226
|
+
if getattr(w3, "has_celestial", False):
|
|
227
|
+
return w3.celestial
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _compute_cropped_wcs(parent_hdr_like, x, y, w, h):
|
|
234
|
+
"""
|
|
235
|
+
Build a cropped WCS header from parent_hdr_like and ROI (x,y,w,h).
|
|
236
|
+
|
|
237
|
+
IMPORTANT:
|
|
238
|
+
- If the parent header already describes a cropped ROI (NAXIS1/2 already
|
|
239
|
+
equal to w/h, or the ROI is obviously outside the parent NAXIS), we
|
|
240
|
+
*do not* shift CRPIX again. We just return a copy of the parent header,
|
|
241
|
+
marking it as ROI-CROP if needed.
|
|
242
|
+
"""
|
|
243
|
+
# Normalize ROI values to ints
|
|
244
|
+
x = int(x)
|
|
245
|
+
y = int(y)
|
|
246
|
+
w = int(w)
|
|
247
|
+
h = int(h)
|
|
248
|
+
|
|
249
|
+
# Same helper as before; safe on dict/FITS Header
|
|
250
|
+
try:
|
|
251
|
+
from astropy.io.fits import Header
|
|
252
|
+
except Exception:
|
|
253
|
+
Header = None
|
|
254
|
+
|
|
255
|
+
if Header is not None and isinstance(parent_hdr_like, Header):
|
|
256
|
+
base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
|
|
257
|
+
elif isinstance(parent_hdr_like, dict):
|
|
258
|
+
fk = parent_hdr_like.get("FITSKeywords")
|
|
259
|
+
if isinstance(fk, dict) and fk:
|
|
260
|
+
base = {}
|
|
261
|
+
for k, arr in fk.items():
|
|
262
|
+
try:
|
|
263
|
+
base[k] = (arr or [{}])[0].get("value", None)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
else:
|
|
267
|
+
base = dict(parent_hdr_like)
|
|
268
|
+
else:
|
|
269
|
+
base = {}
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# Detect "already cropped" headers to avoid double-shifting CRPIX.
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
nax1 = base.get("NAXIS1")
|
|
275
|
+
nax2 = base.get("NAXIS2")
|
|
276
|
+
|
|
277
|
+
if isinstance(nax1, (int, float)) and isinstance(nax2, (int, float)):
|
|
278
|
+
n1 = int(nax1)
|
|
279
|
+
n2 = int(nax2)
|
|
280
|
+
|
|
281
|
+
# Case A: parent already has same size as requested ROI,
|
|
282
|
+
# but x,y are non-zero → this smells like ROI-of-ROI.
|
|
283
|
+
if w == n1 and h == n2 and (x != 0 or y != 0):
|
|
284
|
+
|
|
285
|
+
base["NAXIS1"], base["NAXIS2"] = n1, n2
|
|
286
|
+
base.setdefault("CROPX", 0)
|
|
287
|
+
base.setdefault("CROPY", 0)
|
|
288
|
+
base.setdefault("SASKIND", "ROI-CROP")
|
|
289
|
+
return base
|
|
290
|
+
|
|
291
|
+
# Case B: ROI clearly outside parent dimensions → also treat as
|
|
292
|
+
# "already cropped, don't touch CRPIX".
|
|
293
|
+
if x >= n1 or y >= n2 or x + w > n1 or y + h > n2:
|
|
294
|
+
|
|
295
|
+
base["NAXIS1"], base["NAXIS2"] = n1, n2
|
|
296
|
+
base.setdefault("CROPX", 0)
|
|
297
|
+
base.setdefault("CROPY", 0)
|
|
298
|
+
base.setdefault("SASKIND", "ROI-CROP")
|
|
299
|
+
return base
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# Normal behavior: real crop relative to full-frame parent.
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
c1, c2 = base.get("CRPIX1"), base.get("CRPIX2")
|
|
305
|
+
if isinstance(c1, (int, float)) and isinstance(c2, (int, float)):
|
|
306
|
+
base["CRPIX1"] = float(c1) - float(x)
|
|
307
|
+
base["CRPIX2"] = float(c2) - float(y)
|
|
308
|
+
|
|
309
|
+
base["NAXIS1"], base["NAXIS2"] = w, h
|
|
310
|
+
base["CROPX"], base["CROPY"] = x, y
|
|
311
|
+
base["SASKIND"] = "ROI-CROP"
|
|
312
|
+
return base
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ImageSubWindow(QWidget):
|
|
317
|
+
aboutToClose = pyqtSignal(object)
|
|
318
|
+
autostretchChanged = pyqtSignal(bool)
|
|
319
|
+
requestDuplicate = pyqtSignal(object) # document
|
|
320
|
+
layers_changed = pyqtSignal()
|
|
321
|
+
autostretchProfileChanged = pyqtSignal(str)
|
|
322
|
+
viewTitleChanged = pyqtSignal(object, str)
|
|
323
|
+
activeSourceChanged = pyqtSignal(object) # None for full, or (x,y,w,h) for ROI
|
|
324
|
+
viewTransformChanged = pyqtSignal(float, int, int)
|
|
325
|
+
_registry = weakref.WeakValueDictionary()
|
|
326
|
+
resized = pyqtSignal()
|
|
327
|
+
replayOnBaseRequested = pyqtSignal(object)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def __init__(self, document, parent=None):
|
|
331
|
+
super().__init__(parent)
|
|
332
|
+
self._base_document = None
|
|
333
|
+
self.document = document
|
|
334
|
+
self._last_title_for_emit = None
|
|
335
|
+
|
|
336
|
+
# ─────────────────────────────────────────────────────────
|
|
337
|
+
# View / render state
|
|
338
|
+
# ─────────────────────────────────────────────────────────
|
|
339
|
+
self._min_scale = 0.02
|
|
340
|
+
self._max_scale = 3.00 # 300%
|
|
341
|
+
self.scale = 0.25
|
|
342
|
+
self._dragging = False
|
|
343
|
+
self._drag_start = QPoint()
|
|
344
|
+
self._autostretch_linked = QSettings().value("display/stretch_linked", False, type=bool)
|
|
345
|
+
self.autostretch_enabled = False
|
|
346
|
+
self.autostretch_target = 0.25
|
|
347
|
+
self.autostretch_sigma = 3.0
|
|
348
|
+
self.autostretch_profile = "normal"
|
|
349
|
+
self.show_mask_overlay = False
|
|
350
|
+
self._mask_overlay_alpha = 0.5 # 0..1
|
|
351
|
+
self._mask_overlay_invert = True
|
|
352
|
+
self._layers: list[ImageLayer] = []
|
|
353
|
+
self.layers_changed.connect(lambda: None)
|
|
354
|
+
self._display_override: np.ndarray | None = None
|
|
355
|
+
self._readout_hint_shown = False
|
|
356
|
+
self._link_emit_timer = QTimer(self)
|
|
357
|
+
self._link_emit_timer.setSingleShot(True)
|
|
358
|
+
self._link_emit_timer.setInterval(100) # tweak 120–250ms to taste
|
|
359
|
+
self._link_emit_timer.timeout.connect(self._emit_view_transform_now)
|
|
360
|
+
self._suppress_link_emit = False # guard while applying remote updates
|
|
361
|
+
self._link_squelch = False # prevents feedback on linked apply
|
|
362
|
+
self._pan_live = False
|
|
363
|
+
self._linked_views = weakref.WeakSet()
|
|
364
|
+
ImageSubWindow._registry[id(self)] = self
|
|
365
|
+
self._link_badge_on = False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# whenever we move/zoom, relay to linked peers
|
|
370
|
+
self.viewTransformChanged.connect(self._relay_to_linked)
|
|
371
|
+
# pixel readout live-probe state
|
|
372
|
+
self._space_down = False
|
|
373
|
+
self._readout_dragging = False
|
|
374
|
+
self._last_readout = None
|
|
375
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
376
|
+
|
|
377
|
+
# Title (doc/view) sync
|
|
378
|
+
self._view_title_override = None
|
|
379
|
+
self.document.changed.connect(self._sync_host_title)
|
|
380
|
+
self._sync_host_title()
|
|
381
|
+
self.document.changed.connect(self._refresh_local_undo_buttons)
|
|
382
|
+
|
|
383
|
+
# Cached display buffer
|
|
384
|
+
self._buf8 = None # backing np.uint8 [H,W,3]
|
|
385
|
+
self._qimg_src = None # QImage wrapping _buf8
|
|
386
|
+
|
|
387
|
+
# Keep mask visuals in sync when doc changes
|
|
388
|
+
self.document.changed.connect(self._on_doc_mask_changed)
|
|
389
|
+
|
|
390
|
+
# ─────────────────────────────────────────────────────────
|
|
391
|
+
# Preview tabs state
|
|
392
|
+
# ─────────────────────────────────────────────────────────
|
|
393
|
+
self._tabs: QTabWidget | None = None
|
|
394
|
+
self._previews: list[dict] = [] # {"id": int, "name": str, "roi": (x,y,w,h), "arr": np.ndarray}
|
|
395
|
+
self._active_source_kind = "full" # "full" | "preview"
|
|
396
|
+
self._active_preview_id: int | None = None
|
|
397
|
+
self._next_preview_id = 1
|
|
398
|
+
|
|
399
|
+
# Rubber-band / selection for previews
|
|
400
|
+
self._preview_select_mode = False
|
|
401
|
+
self._rubber: QRubberBand | None = None
|
|
402
|
+
self._rubber_origin: QPoint | None = None
|
|
403
|
+
|
|
404
|
+
# ─────────────────────────────────────────────────────────
|
|
405
|
+
# UI construction
|
|
406
|
+
# ─────────────────────────────────────────────────────────
|
|
407
|
+
lyt = QVBoxLayout(self)
|
|
408
|
+
|
|
409
|
+
# Top row: drag-tab + Preview button
|
|
410
|
+
row = QHBoxLayout()
|
|
411
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
412
|
+
self._drag_tab = _DragTab(self)
|
|
413
|
+
row.addWidget(self._drag_tab, 0, Qt.AlignmentFlag.AlignLeft)
|
|
414
|
+
|
|
415
|
+
self._preview_btn = QToolButton(self)
|
|
416
|
+
self._preview_btn.setText("⟂") # crosshair glyph
|
|
417
|
+
self._preview_btn.setToolTip("Create Preview: click, then drag on the image to define a preview rectangle.")
|
|
418
|
+
self._preview_btn.setCheckable(True)
|
|
419
|
+
self._preview_btn.clicked.connect(self._toggle_preview_select_mode)
|
|
420
|
+
row.addWidget(self._preview_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
|
421
|
+
# — Undo / Redo just for this subwindow —
|
|
422
|
+
self._btn_undo = QToolButton(self)
|
|
423
|
+
self._btn_undo.setText("↶") # or use an icon
|
|
424
|
+
self._btn_undo.setToolTip("Undo (this view)")
|
|
425
|
+
self._btn_undo.setEnabled(False)
|
|
426
|
+
self._btn_undo.clicked.connect(self._on_local_undo)
|
|
427
|
+
row.addWidget(self._btn_undo, 0, Qt.AlignmentFlag.AlignLeft)
|
|
428
|
+
|
|
429
|
+
self._btn_redo = QToolButton(self)
|
|
430
|
+
self._btn_redo.setText("↷")
|
|
431
|
+
self._btn_redo.setToolTip("Redo (this view)")
|
|
432
|
+
self._btn_redo.setEnabled(False)
|
|
433
|
+
self._btn_redo.clicked.connect(self._on_local_redo)
|
|
434
|
+
row.addWidget(self._btn_redo, 0, Qt.AlignmentFlag.AlignLeft)
|
|
435
|
+
|
|
436
|
+
self._btn_replay_main = QToolButton(self)
|
|
437
|
+
self._btn_replay_main.setText("⟳") # pick any glyph you like
|
|
438
|
+
self._btn_replay_main.setToolTip(
|
|
439
|
+
"Click: replay the last action on the base image.\n"
|
|
440
|
+
"Arrow: pick a specific past action to replay on the base image."
|
|
441
|
+
)
|
|
442
|
+
self._btn_replay_main.setEnabled(False) # enabled only when preview + history
|
|
443
|
+
|
|
444
|
+
# Left-click = your existing 'replay last on base'
|
|
445
|
+
self._btn_replay_main.clicked.connect(self._on_replay_last_clicked)
|
|
446
|
+
|
|
447
|
+
# NEW: dropdown menu listing all replayable actions
|
|
448
|
+
self._replay_menu = QMenu(self)
|
|
449
|
+
self._btn_replay_main.setMenu(self._replay_menu)
|
|
450
|
+
self._btn_replay_main.setPopupMode(
|
|
451
|
+
QToolButton.ToolButtonPopupMode.MenuButtonPopup
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
row.addWidget(self._btn_replay_main, 0, Qt.AlignmentFlag.AlignLeft)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ── NEW: WCS grid toggle ─────────────────────────────────────────
|
|
458
|
+
self._btn_wcs = QToolButton(self)
|
|
459
|
+
self._btn_wcs.setText("⌗")
|
|
460
|
+
self._btn_wcs.setToolTip("Toggle WCS grid overlay (if WCS exists)")
|
|
461
|
+
self._btn_wcs.setCheckable(True)
|
|
462
|
+
|
|
463
|
+
# Start OFF on every new view, regardless of WCS presence or past sessions
|
|
464
|
+
self._show_wcs_grid = False
|
|
465
|
+
self._btn_wcs.setChecked(False)
|
|
466
|
+
|
|
467
|
+
self._btn_wcs.toggled.connect(self._on_toggle_wcs_grid)
|
|
468
|
+
row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
|
|
469
|
+
# ─────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
row.addStretch(1)
|
|
472
|
+
lyt.addLayout(row)
|
|
473
|
+
|
|
474
|
+
# QTabWidget that hosts "Full" (real viewer) + any Preview tabs (placeholder widgets)
|
|
475
|
+
self._tabs = QTabWidget(self)
|
|
476
|
+
self._tabs.setTabsClosable(True)
|
|
477
|
+
self._tabs.setDocumentMode(True)
|
|
478
|
+
self._tabs.setMovable(True)
|
|
479
|
+
|
|
480
|
+
# Build the default "Full" tab, which contains the ONE real viewer (scroll+label)
|
|
481
|
+
full_host = QWidget(self)
|
|
482
|
+
full_v = QVBoxLayout(full_host)
|
|
483
|
+
full_v.setContentsMargins(0, 0, 0, 0)
|
|
484
|
+
|
|
485
|
+
self.scroll = QScrollArea(full_host)
|
|
486
|
+
self.scroll.setWidgetResizable(False)
|
|
487
|
+
self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
488
|
+
self.scroll.setWidget(self.label)
|
|
489
|
+
self.scroll.viewport().setMouseTracking(True)
|
|
490
|
+
self.label.setMouseTracking(True)
|
|
491
|
+
full_v.addWidget(self.scroll)
|
|
492
|
+
|
|
493
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
494
|
+
vbar = self.scroll.verticalScrollBar()
|
|
495
|
+
for bar in (hbar, vbar):
|
|
496
|
+
bar.valueChanged.connect(self._on_scroll_changed)
|
|
497
|
+
bar.sliderMoved.connect(self._on_scroll_changed)
|
|
498
|
+
bar.actionTriggered.connect(self._on_scroll_changed)
|
|
499
|
+
|
|
500
|
+
# IMPORTANT: add the tab BEFORE connecting signals so currentChanged can't fire early
|
|
501
|
+
self._full_tab_idx = self._tabs.addTab(full_host, "Full")
|
|
502
|
+
self._full_host = full_host
|
|
503
|
+
self._tabs.tabBar().setVisible(False) # hidden until a preview exists
|
|
504
|
+
lyt.addWidget(self._tabs)
|
|
505
|
+
|
|
506
|
+
# Now it’s safe to connect
|
|
507
|
+
self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
|
|
508
|
+
self._tabs.currentChanged.connect(self._on_tab_changed)
|
|
509
|
+
self._tabs.currentChanged.connect(lambda _=None: self._refresh_local_undo_buttons())
|
|
510
|
+
|
|
511
|
+
# DnD + event filters for the single viewer
|
|
512
|
+
self.setAcceptDrops(True)
|
|
513
|
+
self.scroll.viewport().installEventFilter(self)
|
|
514
|
+
self.label.installEventFilter(self)
|
|
515
|
+
|
|
516
|
+
# Context menu + shortcuts
|
|
517
|
+
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
518
|
+
self.customContextMenuRequested.connect(self._show_ctx_menu)
|
|
519
|
+
QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
|
|
520
|
+
#QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
|
|
521
|
+
QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
|
|
522
|
+
QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
|
|
523
|
+
QShortcut(QKeySequence("Ctrl+K"), self, activated=self.toggle_mask_overlay)
|
|
524
|
+
|
|
525
|
+
# Re-render when the document changes
|
|
526
|
+
self.document.changed.connect(lambda: self._render(rebuild=True))
|
|
527
|
+
self._render(rebuild=True)
|
|
528
|
+
QTimer.singleShot(0, self._maybe_announce_readout_help)
|
|
529
|
+
self._refresh_local_undo_buttons()
|
|
530
|
+
self._update_replay_button()
|
|
531
|
+
|
|
532
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
533
|
+
vbar = self.scroll.verticalScrollBar()
|
|
534
|
+
|
|
535
|
+
for bar in (hbar, vbar):
|
|
536
|
+
bar.valueChanged.connect(self._schedule_emit_view_transform)
|
|
537
|
+
bar.sliderMoved.connect(lambda _=None: self._schedule_emit_view_transform())
|
|
538
|
+
bar.actionTriggered.connect(lambda _=None: self._schedule_emit_view_transform())
|
|
539
|
+
|
|
540
|
+
# Mask/title adornments
|
|
541
|
+
self._mask_dot_enabled = self._active_mask_array() is not None
|
|
542
|
+
self._active_title_prefix = False
|
|
543
|
+
self._rebuild_title()
|
|
544
|
+
|
|
545
|
+
# Track docs used by layer stack (if any)
|
|
546
|
+
self._watched_docs = set()
|
|
547
|
+
self._history_doc = None
|
|
548
|
+
self._install_history_watchers()
|
|
549
|
+
|
|
550
|
+
# ----- link drag payload -----
|
|
551
|
+
def _start_link_drag(self):
|
|
552
|
+
"""
|
|
553
|
+
Alt + drag from ⧉: start a 'link these two views' drag.
|
|
554
|
+
"""
|
|
555
|
+
payload = {
|
|
556
|
+
"source_view_id": id(self),
|
|
557
|
+
}
|
|
558
|
+
# identity hints (not strictly required, but nice to have)
|
|
559
|
+
try:
|
|
560
|
+
payload.update(self._drag_identity_fields())
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
md = QMimeData()
|
|
565
|
+
md.setData(MIME_LINKVIEW, QByteArray(json.dumps(payload).encode("utf-8")))
|
|
566
|
+
drag = QDrag(self)
|
|
567
|
+
drag.setMimeData(md)
|
|
568
|
+
if self.label.pixmap():
|
|
569
|
+
drag.setPixmap(self.label.pixmap().scaled(
|
|
570
|
+
64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
|
|
571
|
+
drag.setHotSpot(QPoint(16, 16))
|
|
572
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
573
|
+
|
|
574
|
+
# ----- link management -----
|
|
575
|
+
def link_to(self, other: "ImageSubWindow"):
|
|
576
|
+
if other is self or other in self._linked_views:
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
# Gather the full sets (including each endpoint)
|
|
580
|
+
a_group = set(self._linked_views) | {self}
|
|
581
|
+
b_group = set(other._linked_views) | {other}
|
|
582
|
+
merged = a_group | b_group
|
|
583
|
+
|
|
584
|
+
# Clear old badges so we can reapply cleanly
|
|
585
|
+
for v in merged:
|
|
586
|
+
try:
|
|
587
|
+
v._linked_views.discard(v) # no-op safety
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
# Fully connect everyone to everyone
|
|
592
|
+
for v in merged:
|
|
593
|
+
v._linked_views.update(merged - {v})
|
|
594
|
+
try:
|
|
595
|
+
v._set_link_badge(True)
|
|
596
|
+
except Exception:
|
|
597
|
+
pass
|
|
598
|
+
|
|
599
|
+
# Snap everyone to the initiator’s transform immediately
|
|
600
|
+
try:
|
|
601
|
+
s, h, v = self._current_transform()
|
|
602
|
+
for peer in merged - {self}:
|
|
603
|
+
peer.set_view_transform(s, h, v, from_link=True)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def unlink_from(self, other: "ImageSubWindow"):
|
|
609
|
+
if other in self._linked_views:
|
|
610
|
+
self._linked_views.discard(other)
|
|
611
|
+
other._linked_views.discard(self)
|
|
612
|
+
# clear badge if both are now free
|
|
613
|
+
if not self._linked_views:
|
|
614
|
+
self._set_link_badge(False)
|
|
615
|
+
if not other._linked_views:
|
|
616
|
+
other._set_link_badge(False)
|
|
617
|
+
|
|
618
|
+
def unlink_all(self):
|
|
619
|
+
peers = list(self._linked_views)
|
|
620
|
+
for p in peers:
|
|
621
|
+
self.unlink_from(p)
|
|
622
|
+
|
|
623
|
+
def _relay_to_linked(self, scale: float, h: int, v: int):
|
|
624
|
+
"""
|
|
625
|
+
When this view pans/zooms, nudge all linked peers. Guarded to avoid loops.
|
|
626
|
+
"""
|
|
627
|
+
for peer in list(self._linked_views):
|
|
628
|
+
try:
|
|
629
|
+
peer.set_view_transform(scale, h, v, from_link=True)
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
def _set_link_badge(self, on: bool):
|
|
634
|
+
self._link_badge_on = bool(on)
|
|
635
|
+
self._rebuild_title()
|
|
636
|
+
|
|
637
|
+
def _on_scroll_changed(self, *_):
|
|
638
|
+
if self._suppress_link_emit:
|
|
639
|
+
return
|
|
640
|
+
# If we’re actively dragging, emit immediately for realtime follow
|
|
641
|
+
if self._dragging or self._pan_live:
|
|
642
|
+
self._emit_view_transform_now()
|
|
643
|
+
else:
|
|
644
|
+
self._schedule_emit_view_transform()
|
|
645
|
+
|
|
646
|
+
def _current_transform(self):
|
|
647
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
648
|
+
vbar = self.scroll.verticalScrollBar()
|
|
649
|
+
return float(self.scale), int(hbar.value()), int(vbar.value())
|
|
650
|
+
|
|
651
|
+
def _emit_view_transform(self):
|
|
652
|
+
try:
|
|
653
|
+
h = int(self.scroll.horizontalScrollBar().value())
|
|
654
|
+
v = int(self.scroll.verticalScrollBar().value())
|
|
655
|
+
except Exception:
|
|
656
|
+
h = v = 0
|
|
657
|
+
try:
|
|
658
|
+
self.viewTransformChanged.emit(float(self.scale), h, v)
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
|
|
662
|
+
def _schedule_emit_view_transform(self):
|
|
663
|
+
if self._suppress_link_emit:
|
|
664
|
+
return
|
|
665
|
+
# If we’re in a live pan, don’t debounce—emit now.
|
|
666
|
+
if self._dragging or self._pan_live:
|
|
667
|
+
self._emit_view_transform_now()
|
|
668
|
+
else:
|
|
669
|
+
self._link_emit_timer.start()
|
|
670
|
+
|
|
671
|
+
def _emit_view_transform_now(self):
|
|
672
|
+
if self._suppress_link_emit:
|
|
673
|
+
return
|
|
674
|
+
h = self.scroll.horizontalScrollBar().value()
|
|
675
|
+
v = self.scroll.verticalScrollBar().value()
|
|
676
|
+
try:
|
|
677
|
+
self.viewTransformChanged.emit(float(self.scale), int(h), int(v))
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
|
|
681
|
+
#------ Replay helpers------
|
|
682
|
+
#------ Replay helpers------
|
|
683
|
+
def _update_replay_button(self):
|
|
684
|
+
"""
|
|
685
|
+
Update the 'Replay on main image' button:
|
|
686
|
+
|
|
687
|
+
- Enabled only when a Preview/ROI is active.
|
|
688
|
+
- Populates the dropdown menu with all headless-history entries
|
|
689
|
+
from the main window (newest first).
|
|
690
|
+
"""
|
|
691
|
+
btn = getattr(self, "_btn_replay_main", None)
|
|
692
|
+
if not btn:
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
# Do we have an active preview in this view?
|
|
696
|
+
try:
|
|
697
|
+
has_preview = self.has_active_preview()
|
|
698
|
+
except Exception:
|
|
699
|
+
has_preview = False
|
|
700
|
+
|
|
701
|
+
mw = self._find_main_window()
|
|
702
|
+
menu = getattr(self, "_replay_menu", None)
|
|
703
|
+
|
|
704
|
+
history = []
|
|
705
|
+
has_history = False
|
|
706
|
+
|
|
707
|
+
# Pull history from main window if available
|
|
708
|
+
if mw is not None and hasattr(mw, "get_headless_history"):
|
|
709
|
+
try:
|
|
710
|
+
history = mw.get_headless_history() or []
|
|
711
|
+
has_history = bool(history)
|
|
712
|
+
except Exception:
|
|
713
|
+
history = []
|
|
714
|
+
has_history = False
|
|
715
|
+
|
|
716
|
+
# Rebuild the dropdown menu
|
|
717
|
+
if menu is not None:
|
|
718
|
+
menu.clear()
|
|
719
|
+
if has_history:
|
|
720
|
+
# We want newest actions at the *top* of the menu
|
|
721
|
+
for idx_from_end, entry in enumerate(reversed(history)):
|
|
722
|
+
real_index = len(history) - 1 - idx_from_end # index into original list
|
|
723
|
+
|
|
724
|
+
cid = entry.get("command_id", "") or ""
|
|
725
|
+
desc = entry.get("description") or cid or f"#{real_index+1}"
|
|
726
|
+
|
|
727
|
+
act = menu.addAction(desc)
|
|
728
|
+
if cid and cid != desc:
|
|
729
|
+
act.setToolTip(cid)
|
|
730
|
+
|
|
731
|
+
# Capture the index in a default arg so each action gets its own index
|
|
732
|
+
act.triggered.connect(
|
|
733
|
+
lambda _chk=False, i=real_index: self._replay_history_index(i)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
# Also allow left-click "last action" when main window still has a last payload
|
|
737
|
+
has_last = bool(mw and getattr(mw, "_last_headless_command", None))
|
|
738
|
+
|
|
739
|
+
enabled = bool(has_preview and (has_history or has_last))
|
|
740
|
+
btn.setEnabled(enabled)
|
|
741
|
+
|
|
742
|
+
# DEBUG:
|
|
743
|
+
try:
|
|
744
|
+
print(
|
|
745
|
+
f"[Replay] _update_replay_button: view id={id(self)} "
|
|
746
|
+
f"enabled={enabled}, has_preview={has_preview}, "
|
|
747
|
+
f"history_len={len(history)}"
|
|
748
|
+
)
|
|
749
|
+
except Exception:
|
|
750
|
+
pass
|
|
751
|
+
|
|
752
|
+
def _replay_history_index(self, index: int):
|
|
753
|
+
"""
|
|
754
|
+
Called when the user selects an entry from the replay dropdown.
|
|
755
|
+
|
|
756
|
+
We forward to MainWindow.replay_headless_history_entry_on_base(index, target_sw),
|
|
757
|
+
which reuses the big replay_last_action_on_base() switchboard.
|
|
758
|
+
"""
|
|
759
|
+
mw = self._find_main_window()
|
|
760
|
+
if mw is None or not hasattr(mw, "replay_headless_history_entry_on_base"):
|
|
761
|
+
try:
|
|
762
|
+
print("[Replay] _replay_history_index: main window or handler missing")
|
|
763
|
+
except Exception:
|
|
764
|
+
pass
|
|
765
|
+
return
|
|
766
|
+
|
|
767
|
+
target_sw = self._mdi_subwindow()
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
mw.replay_headless_history_entry_on_base(index, target_sw=target_sw)
|
|
771
|
+
try:
|
|
772
|
+
print(
|
|
773
|
+
f"[Replay] _replay_history_index: index={index}, "
|
|
774
|
+
f"view id={id(self)}, target_sw={id(target_sw) if target_sw else None}"
|
|
775
|
+
)
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
except Exception as e:
|
|
779
|
+
try:
|
|
780
|
+
print(f"[Replay] _replay_history_index failed: {e}")
|
|
781
|
+
except Exception:
|
|
782
|
+
pass
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _on_replay_last_clicked(self):
|
|
786
|
+
"""
|
|
787
|
+
User clicked the ⟳ button *main area* (not the arrow).
|
|
788
|
+
|
|
789
|
+
This still does the old behavior:
|
|
790
|
+
- Emit replayOnBaseRequested(view)
|
|
791
|
+
- Main window then replays the *last* action on the base doc
|
|
792
|
+
for this subwindow (via replay_last_action_on_base).
|
|
793
|
+
"""
|
|
794
|
+
# DEBUG: log that the button actually fired
|
|
795
|
+
try:
|
|
796
|
+
roi = None
|
|
797
|
+
if hasattr(self, "has_active_preview") and self.has_active_preview():
|
|
798
|
+
try:
|
|
799
|
+
roi = self.current_preview_roi()
|
|
800
|
+
except Exception:
|
|
801
|
+
roi = None
|
|
802
|
+
print(
|
|
803
|
+
f"[Replay] Button clicked in view id={id(self)}, "
|
|
804
|
+
f"has_active_preview={self.has_active_preview() if hasattr(self, 'has_active_preview') else 'n/a'}, "
|
|
805
|
+
f"roi={roi}"
|
|
806
|
+
)
|
|
807
|
+
except Exception:
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
# Emit self so the main window can locate our QMdiSubWindow wrapper.
|
|
811
|
+
try:
|
|
812
|
+
print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
self.replayOnBaseRequested.emit(self)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _on_pan_or_zoom_changed(self, *_):
|
|
820
|
+
# Debounce lightly if you want; for now, just emit
|
|
821
|
+
self._emit_view_transform()
|
|
822
|
+
|
|
823
|
+
def set_view_transform(self, scale, hval, vval, from_link=False):
|
|
824
|
+
# Avoid storms while we mutate scrollbars/scale
|
|
825
|
+
self._suppress_link_emit = True
|
|
826
|
+
try:
|
|
827
|
+
scale = float(max(self._min_scale, min(scale, self._max_scale)))
|
|
828
|
+
if abs(scale - self.scale) > 1e-9:
|
|
829
|
+
self.scale = scale
|
|
830
|
+
self._render(rebuild=False)
|
|
831
|
+
|
|
832
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
833
|
+
vbar = self.scroll.verticalScrollBar()
|
|
834
|
+
hv = int(hval); vv = int(vval)
|
|
835
|
+
if hv != hbar.value():
|
|
836
|
+
hbar.setValue(hv)
|
|
837
|
+
if vv != vbar.value():
|
|
838
|
+
vbar.setValue(vv)
|
|
839
|
+
finally:
|
|
840
|
+
self._suppress_link_emit = False
|
|
841
|
+
|
|
842
|
+
# IMPORTANT: if this came from a linked peer, do NOT broadcast again.
|
|
843
|
+
if not from_link:
|
|
844
|
+
self._schedule_emit_view_transform()
|
|
845
|
+
|
|
846
|
+
def _on_toggle_wcs_grid(self, on: bool):
|
|
847
|
+
self._show_wcs_grid = bool(on)
|
|
848
|
+
QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
|
|
849
|
+
self._render(rebuild=False) # repaint current frame
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _install_history_watchers(self):
|
|
854
|
+
# disconnect old history doc
|
|
855
|
+
hd = getattr(self, "_history_doc", None)
|
|
856
|
+
if hd is not None and hasattr(hd, "changed"):
|
|
857
|
+
try:
|
|
858
|
+
hd.changed.disconnect(self._on_history_doc_changed)
|
|
859
|
+
except Exception:
|
|
860
|
+
pass
|
|
861
|
+
# in case older builds were wired directly:
|
|
862
|
+
try:
|
|
863
|
+
hd.changed.disconnect(self._refresh_local_undo_buttons)
|
|
864
|
+
except Exception:
|
|
865
|
+
pass
|
|
866
|
+
|
|
867
|
+
# resolve new history doc (ROI when on Preview tab, else base)
|
|
868
|
+
new_hd = self._resolve_history_doc()
|
|
869
|
+
self._history_doc = new_hd
|
|
870
|
+
|
|
871
|
+
# connect new
|
|
872
|
+
if new_hd is not None and hasattr(new_hd, "changed"):
|
|
873
|
+
try:
|
|
874
|
+
new_hd.changed.connect(self._on_history_doc_changed)
|
|
875
|
+
except Exception:
|
|
876
|
+
pass
|
|
877
|
+
|
|
878
|
+
# make the buttons correct right now
|
|
879
|
+
self._refresh_local_undo_buttons()
|
|
880
|
+
|
|
881
|
+
def _drag_identity_fields(self):
|
|
882
|
+
"""
|
|
883
|
+
Returns a dict with identity hints for DnD:
|
|
884
|
+
doc_uid (preferred), base_doc_uid (parent/full), and file_path.
|
|
885
|
+
Falls back gracefully if fields are missing.
|
|
886
|
+
"""
|
|
887
|
+
doc = getattr(self, "document", None)
|
|
888
|
+
base = getattr(self, "base_document", None) or doc
|
|
889
|
+
|
|
890
|
+
# If DocManager maps preview/ROI views, prefer the true backing doc as base
|
|
891
|
+
dm = getattr(self, "_docman", None)
|
|
892
|
+
try:
|
|
893
|
+
if dm and hasattr(dm, "get_document_for_view"):
|
|
894
|
+
back = dm.get_document_for_view(self)
|
|
895
|
+
if back is not None:
|
|
896
|
+
base = back
|
|
897
|
+
except Exception:
|
|
898
|
+
pass
|
|
899
|
+
|
|
900
|
+
meta = (getattr(doc, "metadata", None) or {})
|
|
901
|
+
base_meta = (getattr(base, "metadata", None) or {})
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
"doc_uid": getattr(doc, "uid", None),
|
|
905
|
+
"base_doc_uid": getattr(base, "uid", None),
|
|
906
|
+
"file_path": meta.get("file_path") or base_meta.get("file_path") or "",
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _on_local_undo(self):
|
|
911
|
+
doc = self._resolve_history_doc()
|
|
912
|
+
if not doc or not hasattr(doc, "undo"):
|
|
913
|
+
return
|
|
914
|
+
try:
|
|
915
|
+
doc.undo()
|
|
916
|
+
# most ImageDocument implementations emit changed; belt-and-suspenders:
|
|
917
|
+
if hasattr(doc, "changed"): doc.changed.emit()
|
|
918
|
+
except Exception:
|
|
919
|
+
pass
|
|
920
|
+
# repaint and refresh our buttons
|
|
921
|
+
self._render(rebuild=True)
|
|
922
|
+
self._refresh_local_undo_buttons()
|
|
923
|
+
|
|
924
|
+
def _on_local_redo(self):
|
|
925
|
+
doc = self._resolve_history_doc()
|
|
926
|
+
if not doc or not hasattr(doc, "redo"):
|
|
927
|
+
return
|
|
928
|
+
try:
|
|
929
|
+
doc.redo()
|
|
930
|
+
if hasattr(doc, "changed"): doc.changed.emit()
|
|
931
|
+
except Exception:
|
|
932
|
+
pass
|
|
933
|
+
self._render(rebuild=True)
|
|
934
|
+
self._refresh_local_undo_buttons()
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def refresh_preview_roi(self, roi_tuple=None):
|
|
938
|
+
"""
|
|
939
|
+
Rebuild the active preview pixmap from the parent document’s data.
|
|
940
|
+
If roi_tuple is provided, it's the updated region (x,y,w,h).
|
|
941
|
+
"""
|
|
942
|
+
try:
|
|
943
|
+
if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
|
|
944
|
+
return
|
|
945
|
+
|
|
946
|
+
# Optional: sanity check that roi matches the current preview
|
|
947
|
+
if roi_tuple is not None:
|
|
948
|
+
cur = self.current_preview_roi()
|
|
949
|
+
if not (cur and tuple(map(int, cur)) == tuple(map(int, roi_tuple))):
|
|
950
|
+
return # different preview; no refresh needed
|
|
951
|
+
|
|
952
|
+
# Your own method that (re)generates the preview pixmap from the doc
|
|
953
|
+
if hasattr(self, "rebuild_preview_pixmap") and callable(self.rebuild_preview_pixmap):
|
|
954
|
+
self.rebuild_preview_pixmap()
|
|
955
|
+
elif hasattr(self, "_update_preview_layer") and callable(self._update_preview_layer):
|
|
956
|
+
self._update_preview_layer()
|
|
957
|
+
else:
|
|
958
|
+
# Fallback: repaint
|
|
959
|
+
self.update()
|
|
960
|
+
except Exception:
|
|
961
|
+
pass
|
|
962
|
+
|
|
963
|
+
def refresh_full(self):
|
|
964
|
+
"""Full-image redraw hook for non-ROI updates."""
|
|
965
|
+
try:
|
|
966
|
+
if hasattr(self, "rebuild_image_pixmap") and callable(self.rebuild_image_pixmap):
|
|
967
|
+
self.rebuild_image_pixmap()
|
|
968
|
+
else:
|
|
969
|
+
self.update()
|
|
970
|
+
except Exception:
|
|
971
|
+
pass
|
|
972
|
+
|
|
973
|
+
def refresh_preview_region(self, roi):
|
|
974
|
+
"""
|
|
975
|
+
roi: (x,y,w,h) in FULL image coords. Rebuild the active Preview tab’s pixmap
|
|
976
|
+
from self.document.image[y:y+h, x:x+w].
|
|
977
|
+
"""
|
|
978
|
+
if not (hasattr(self, "has_active_preview") and self.has_active_preview()):
|
|
979
|
+
# No preview active → fall back to full refresh
|
|
980
|
+
if hasattr(self, "refresh_from_document"):
|
|
981
|
+
self.refresh_from_document()
|
|
982
|
+
else:
|
|
983
|
+
self.update()
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
x, y, w, h = map(int, roi)
|
|
988
|
+
arr = self.document.image[y:y+h, x:x+w]
|
|
989
|
+
# Whatever your existing path is to update the preview tab from an ndarray:
|
|
990
|
+
# e.g., self._set_preview_from_array(arr) or self._update_preview_pixmap(arr)
|
|
991
|
+
if hasattr(self, "_set_preview_from_array"):
|
|
992
|
+
self._set_preview_from_array(arr)
|
|
993
|
+
elif hasattr(self, "update_preview_from_array"):
|
|
994
|
+
self.update_preview_from_array(arr)
|
|
995
|
+
else:
|
|
996
|
+
# Fallback: full refresh if you don’t expose a thin setter
|
|
997
|
+
if hasattr(self, "rebuild_active_preview"):
|
|
998
|
+
self.rebuild_active_preview()
|
|
999
|
+
elif hasattr(self, "refresh_from_document"):
|
|
1000
|
+
self.refresh_from_document()
|
|
1001
|
+
self.update()
|
|
1002
|
+
except Exception:
|
|
1003
|
+
# Safe fallback
|
|
1004
|
+
if hasattr(self, "rebuild_active_preview"):
|
|
1005
|
+
self.rebuild_active_preview()
|
|
1006
|
+
elif hasattr(self, "refresh_from_document"):
|
|
1007
|
+
self.refresh_from_document()
|
|
1008
|
+
else:
|
|
1009
|
+
self.update()
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _ensure_tabs(self):
|
|
1013
|
+
if self._tabs:
|
|
1014
|
+
return
|
|
1015
|
+
self._tabs = QTabWidget(self)
|
|
1016
|
+
self._tabs.setTabsClosable(True)
|
|
1017
|
+
self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
|
|
1018
|
+
self._tabs.currentChanged.connect(self._on_tab_changed)
|
|
1019
|
+
self._tabs.setDocumentMode(True)
|
|
1020
|
+
self._tabs.setMovable(True)
|
|
1021
|
+
|
|
1022
|
+
# Build the default "Full" tab: it contains your scroll+label
|
|
1023
|
+
full_host = QWidget(self)
|
|
1024
|
+
v = QVBoxLayout(full_host)
|
|
1025
|
+
v.setContentsMargins(QMargins(0,0,0,0))
|
|
1026
|
+
# Reuse your existing scroll/label as the content of the "Full" tab
|
|
1027
|
+
self.scroll = QScrollArea(full_host)
|
|
1028
|
+
self.scroll.setWidgetResizable(False)
|
|
1029
|
+
self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
|
|
1030
|
+
self.scroll.setWidget(self.label)
|
|
1031
|
+
v.addWidget(self.scroll)
|
|
1032
|
+
self._full_tab_idx = self._tabs.addTab(full_host, "Full")
|
|
1033
|
+
self._full_host = full_host
|
|
1034
|
+
self._tabs.tabBar().setVisible(False) # hidden until a first preview exists
|
|
1035
|
+
|
|
1036
|
+
def _on_tab_close_requested(self, idx: int):
|
|
1037
|
+
# Prevent closing "Full"
|
|
1038
|
+
if idx == self._full_tab_idx:
|
|
1039
|
+
return
|
|
1040
|
+
wid = self._tabs.widget(idx)
|
|
1041
|
+
prev_id = getattr(wid, "_preview_id", None)
|
|
1042
|
+
|
|
1043
|
+
# Remove model entry
|
|
1044
|
+
self._previews = [p for p in self._previews if p["id"] != prev_id]
|
|
1045
|
+
# If you closed the active one, fall back to full
|
|
1046
|
+
if self._active_preview_id == prev_id:
|
|
1047
|
+
self._active_source_kind = "full"
|
|
1048
|
+
self._active_preview_id = None
|
|
1049
|
+
self._render(True)
|
|
1050
|
+
|
|
1051
|
+
self._tabs.removeTab(idx)
|
|
1052
|
+
wid.deleteLater()
|
|
1053
|
+
|
|
1054
|
+
# Hide tabs if no more previews
|
|
1055
|
+
if not self._previews:
|
|
1056
|
+
self._tabs.tabBar().setVisible(False)
|
|
1057
|
+
|
|
1058
|
+
self._update_replay_button()
|
|
1059
|
+
|
|
1060
|
+
def _on_tab_changed(self, idx: int):
|
|
1061
|
+
if not hasattr(self, "_full_tab_idx"):
|
|
1062
|
+
return
|
|
1063
|
+
if idx == self._full_tab_idx:
|
|
1064
|
+
self._active_source_kind = "full"
|
|
1065
|
+
self._active_preview_id = None
|
|
1066
|
+
host = getattr(self, "_full_host", None) or self._tabs.widget(idx) # ← safe
|
|
1067
|
+
else:
|
|
1068
|
+
wid = self._tabs.widget(idx)
|
|
1069
|
+
self._active_source_kind = "preview"
|
|
1070
|
+
self._active_preview_id = getattr(wid, "_preview_id", None)
|
|
1071
|
+
host = wid
|
|
1072
|
+
|
|
1073
|
+
if host is not None:
|
|
1074
|
+
self._move_view_into(host)
|
|
1075
|
+
self._install_history_watchers()
|
|
1076
|
+
self._render(True)
|
|
1077
|
+
self._refresh_local_undo_buttons()
|
|
1078
|
+
self._update_replay_button()
|
|
1079
|
+
self._emit_view_transform()
|
|
1080
|
+
mw = self._find_main_window()
|
|
1081
|
+
if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
|
|
1082
|
+
try:
|
|
1083
|
+
mw._zoom_active_fit()
|
|
1084
|
+
except Exception:
|
|
1085
|
+
pass
|
|
1086
|
+
|
|
1087
|
+
def _toggle_preview_select_mode(self, on: bool):
|
|
1088
|
+
self._preview_select_mode = bool(on)
|
|
1089
|
+
self._set_preview_cursor(self._preview_select_mode)
|
|
1090
|
+
if self._preview_select_mode:
|
|
1091
|
+
mw = self._find_main_window()
|
|
1092
|
+
if mw and hasattr(mw, "statusBar"):
|
|
1093
|
+
mw.statusBar().showMessage("Preview mode: drag a rectangle on the image to create a preview.", 6000)
|
|
1094
|
+
else:
|
|
1095
|
+
self._cancel_rubber()
|
|
1096
|
+
|
|
1097
|
+
def _cancel_rubber(self):
|
|
1098
|
+
if self._rubber is not None:
|
|
1099
|
+
self._rubber.hide()
|
|
1100
|
+
self._rubber.deleteLater()
|
|
1101
|
+
self._rubber = None
|
|
1102
|
+
self._rubber_origin = None
|
|
1103
|
+
self._preview_select_mode = False
|
|
1104
|
+
self._set_preview_cursor(False)
|
|
1105
|
+
if self._preview_btn.isChecked():
|
|
1106
|
+
self._preview_btn.setChecked(False)
|
|
1107
|
+
|
|
1108
|
+
def _current_tab_host(self):
|
|
1109
|
+
# returns the QWidget inside the current tab
|
|
1110
|
+
return self._tabs.widget(self._tabs.currentIndex())
|
|
1111
|
+
|
|
1112
|
+
def _move_view_into(self, host_widget: QWidget):
|
|
1113
|
+
"""Reparent the single viewer (scroll+label) into host_widget's layout."""
|
|
1114
|
+
if self.scroll.parent() is host_widget:
|
|
1115
|
+
return
|
|
1116
|
+
# take it out of the old parent layout
|
|
1117
|
+
try:
|
|
1118
|
+
old_layout = self.scroll.parentWidget().layout()
|
|
1119
|
+
if old_layout:
|
|
1120
|
+
old_layout.removeWidget(self.scroll)
|
|
1121
|
+
except Exception:
|
|
1122
|
+
pass
|
|
1123
|
+
|
|
1124
|
+
# ensure host has a VBox layout
|
|
1125
|
+
lay = host_widget.layout()
|
|
1126
|
+
if lay is None:
|
|
1127
|
+
from PyQt6.QtWidgets import QVBoxLayout
|
|
1128
|
+
lay = QVBoxLayout(host_widget)
|
|
1129
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
1130
|
+
|
|
1131
|
+
# insert viewer; kill any placeholder child labels if present
|
|
1132
|
+
try:
|
|
1133
|
+
kids = host_widget.findChildren(QLabel, options=Qt.FindChildOption.FindDirectChildrenOnly)
|
|
1134
|
+
except Exception:
|
|
1135
|
+
kids = host_widget.findChildren(QLabel) # recursive fallback
|
|
1136
|
+
for ch in list(kids):
|
|
1137
|
+
if ch is not self.label:
|
|
1138
|
+
ch.deleteLater()
|
|
1139
|
+
|
|
1140
|
+
self.scroll.setParent(host_widget)
|
|
1141
|
+
lay.addWidget(self.scroll)
|
|
1142
|
+
self.scroll.show()
|
|
1143
|
+
|
|
1144
|
+
def _set_preview_cursor(self, active: bool):
|
|
1145
|
+
cur = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor
|
|
1146
|
+
for w in (self, getattr(self, "scroll", None) and self.scroll.viewport(), getattr(self, "label", None)):
|
|
1147
|
+
if not w:
|
|
1148
|
+
continue
|
|
1149
|
+
try:
|
|
1150
|
+
w.unsetCursor() # clear any prior override
|
|
1151
|
+
w.setCursor(cur) # then set desired cursor
|
|
1152
|
+
except Exception:
|
|
1153
|
+
pass
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _maybe_announce_readout_help(self):
|
|
1157
|
+
"""Show the readout hint only once automatically."""
|
|
1158
|
+
if self._readout_hint_shown:
|
|
1159
|
+
return
|
|
1160
|
+
self._announce_readout_help()
|
|
1161
|
+
self._readout_hint_shown = True
|
|
1162
|
+
|
|
1163
|
+
def _announce_readout_help(self):
|
|
1164
|
+
mw = self._find_main_window()
|
|
1165
|
+
if mw and hasattr(mw, "statusBar"):
|
|
1166
|
+
sb = mw.statusBar()
|
|
1167
|
+
if sb:
|
|
1168
|
+
sb.showMessage("Press Space + Click/Drag to probe pixels (WCS shown if available)", 8000)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def apply_layer_stack(self, layers):
|
|
1173
|
+
"""
|
|
1174
|
+
Rebuild the display override from base document + given layer stack.
|
|
1175
|
+
Does not mutate the underlying document.image.
|
|
1176
|
+
"""
|
|
1177
|
+
try:
|
|
1178
|
+
base = self.document.image
|
|
1179
|
+
if layers:
|
|
1180
|
+
comp = composite_stack(base, layers)
|
|
1181
|
+
self._display_override = comp
|
|
1182
|
+
else:
|
|
1183
|
+
self._display_override = None
|
|
1184
|
+
self.layers_changed.emit()
|
|
1185
|
+
self._render(rebuild=True)
|
|
1186
|
+
except Exception as e:
|
|
1187
|
+
print("[ImageSubWindow] apply_layer_stack error:", e)
|
|
1188
|
+
|
|
1189
|
+
# --- add to ImageSubWindow ---
|
|
1190
|
+
def _collect_layer_docs(self):
|
|
1191
|
+
docs = set()
|
|
1192
|
+
for L in getattr(self, "_layers", []):
|
|
1193
|
+
d = getattr(L, "src_doc", None)
|
|
1194
|
+
if d is not None:
|
|
1195
|
+
docs.add(d)
|
|
1196
|
+
md = getattr(L, "mask_doc", None)
|
|
1197
|
+
if md is not None:
|
|
1198
|
+
docs.add(md)
|
|
1199
|
+
return docs
|
|
1200
|
+
|
|
1201
|
+
def keyPressEvent(self, ev):
|
|
1202
|
+
if ev.key() == Qt.Key.Key_Space:
|
|
1203
|
+
# only the first time we enter probe mode
|
|
1204
|
+
if not self._space_down and not self._readout_hint_shown:
|
|
1205
|
+
self._announce_readout_help()
|
|
1206
|
+
self._readout_hint_shown = True
|
|
1207
|
+
self._space_down = True
|
|
1208
|
+
ev.accept()
|
|
1209
|
+
return
|
|
1210
|
+
super().keyPressEvent(ev)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def keyReleaseEvent(self, ev):
|
|
1215
|
+
if ev.key() == Qt.Key.Key_Space:
|
|
1216
|
+
self._space_down = False
|
|
1217
|
+
# DO NOT stop _readout_dragging here – mouse release will do that
|
|
1218
|
+
ev.accept()
|
|
1219
|
+
return
|
|
1220
|
+
super().keyReleaseEvent(ev)
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _sample_image_at_viewport_pos(self, vp_pos: QPoint):
|
|
1225
|
+
"""
|
|
1226
|
+
vp_pos: position in viewport coords (the visible part of the scroll area).
|
|
1227
|
+
Returns (x_img_int, y_img_int, sample_dict) or None if OOB.
|
|
1228
|
+
sample_dict is always raw float(s), never normalized.
|
|
1229
|
+
"""
|
|
1230
|
+
if self.document is None or self.document.image is None:
|
|
1231
|
+
return None
|
|
1232
|
+
|
|
1233
|
+
arr = np.asarray(self.document.image)
|
|
1234
|
+
|
|
1235
|
+
# detect shape
|
|
1236
|
+
if arr.ndim == 2:
|
|
1237
|
+
h, w = arr.shape
|
|
1238
|
+
channels = 1
|
|
1239
|
+
elif arr.ndim == 3:
|
|
1240
|
+
h, w, channels = arr.shape[:3]
|
|
1241
|
+
else:
|
|
1242
|
+
return None # unsupported shape
|
|
1243
|
+
|
|
1244
|
+
# current scroll offsets
|
|
1245
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1246
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1247
|
+
x_label = hbar.value() + vp_pos.x()
|
|
1248
|
+
y_label = vbar.value() + vp_pos.y()
|
|
1249
|
+
|
|
1250
|
+
scale = max(self.scale, 1e-12)
|
|
1251
|
+
x_img = x_label / scale
|
|
1252
|
+
y_img = y_label / scale
|
|
1253
|
+
|
|
1254
|
+
xi = int(round(x_img))
|
|
1255
|
+
yi = int(round(y_img))
|
|
1256
|
+
|
|
1257
|
+
if xi < 0 or yi < 0 or xi >= w or yi >= h:
|
|
1258
|
+
return None
|
|
1259
|
+
|
|
1260
|
+
# ---- mono cases ----
|
|
1261
|
+
if arr.ndim == 2 or channels == 1:
|
|
1262
|
+
# pure mono or (H, W, 1)
|
|
1263
|
+
if arr.ndim == 2:
|
|
1264
|
+
val = float(arr[yi, xi])
|
|
1265
|
+
else:
|
|
1266
|
+
val = float(arr[yi, xi, 0])
|
|
1267
|
+
sample = {"mono": val}
|
|
1268
|
+
return (xi, yi, sample)
|
|
1269
|
+
|
|
1270
|
+
# ---- color / 3+ channels ----
|
|
1271
|
+
pix = arr[yi, xi]
|
|
1272
|
+
|
|
1273
|
+
# make robust if pix is 1-D
|
|
1274
|
+
# expect at least 3 numbers, fallback to repeating R
|
|
1275
|
+
r = float(pix[0])
|
|
1276
|
+
g = float(pix[1]) if channels > 1 else r
|
|
1277
|
+
b = float(pix[2]) if channels > 2 else r
|
|
1278
|
+
|
|
1279
|
+
sample = {"r": r, "g": g, "b": b}
|
|
1280
|
+
return (xi, yi, sample)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def sizeHint(self) -> QSize:
|
|
1285
|
+
lbl = getattr(self, "image_label", None) or getattr(self, "label", None)
|
|
1286
|
+
sa = getattr(self, "scroll_area", None) or self.findChild(QScrollArea)
|
|
1287
|
+
if lbl and hasattr(lbl, "pixmap") and lbl.pixmap() and not lbl.pixmap().isNull():
|
|
1288
|
+
pm = lbl.pixmap()
|
|
1289
|
+
# logical pixels (HiDPI-safe)
|
|
1290
|
+
dpr = pm.devicePixelRatioF() if hasattr(pm, "devicePixelRatioF") else 1.0
|
|
1291
|
+
pm_w = int(math.ceil(pm.width() / dpr))
|
|
1292
|
+
pm_h = int(math.ceil(pm.height() / dpr))
|
|
1293
|
+
|
|
1294
|
+
# label margins
|
|
1295
|
+
lm = lbl.contentsMargins()
|
|
1296
|
+
w = pm_w + lm.left() + lm.right()
|
|
1297
|
+
h = pm_h + lm.top() + lm.bottom()
|
|
1298
|
+
|
|
1299
|
+
# scrollarea chrome (frame + reserve bar thickness)
|
|
1300
|
+
if sa:
|
|
1301
|
+
fw = sa.frameWidth()
|
|
1302
|
+
w += fw * 2 + sa.verticalScrollBar().sizeHint().width()
|
|
1303
|
+
h += fw * 2 + sa.horizontalScrollBar().sizeHint().height()
|
|
1304
|
+
|
|
1305
|
+
# this widget’s margins
|
|
1306
|
+
m = self.contentsMargins()
|
|
1307
|
+
w += m.left() + m.right() + 2
|
|
1308
|
+
h += m.top() + m.bottom() + 20
|
|
1309
|
+
|
|
1310
|
+
# tiny safety pad so bars never appear from rounding
|
|
1311
|
+
return QSize(w + 2, h + 8)
|
|
1312
|
+
|
|
1313
|
+
return super().sizeHint()
|
|
1314
|
+
|
|
1315
|
+
def _on_layer_source_changed(self):
|
|
1316
|
+
# Any source/mask doc changed → recomposite current stack
|
|
1317
|
+
try:
|
|
1318
|
+
self.apply_layer_stack(self._layers)
|
|
1319
|
+
except Exception as e:
|
|
1320
|
+
print("[ImageSubWindow] _on_layer_source_changed error:", e)
|
|
1321
|
+
|
|
1322
|
+
def _reinstall_layer_watchers(self):
|
|
1323
|
+
# Disconnect old
|
|
1324
|
+
for d in list(self._watched_docs):
|
|
1325
|
+
try:
|
|
1326
|
+
d.changed.disconnect(self._on_layer_source_changed)
|
|
1327
|
+
except Exception:
|
|
1328
|
+
pass
|
|
1329
|
+
# Connect new
|
|
1330
|
+
newdocs = self._collect_layer_docs()
|
|
1331
|
+
for d in newdocs:
|
|
1332
|
+
try:
|
|
1333
|
+
d.changed.connect(self._on_layer_source_changed)
|
|
1334
|
+
except Exception:
|
|
1335
|
+
pass
|
|
1336
|
+
self._watched_docs = newdocs
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def toggle_mask_overlay(self):
|
|
1340
|
+
self.show_mask_overlay = not self.show_mask_overlay
|
|
1341
|
+
self._render(rebuild=True)
|
|
1342
|
+
|
|
1343
|
+
def _rebuild_title(self, *, base: str | None = None):
|
|
1344
|
+
sub = self._mdi_subwindow()
|
|
1345
|
+
if not sub: return
|
|
1346
|
+
if base is None:
|
|
1347
|
+
base = self._effective_title() or "Untitled"
|
|
1348
|
+
|
|
1349
|
+
# ✅ strip any carried-over glyphs (🔗, ■, “Active View: ”) from overrides/doc names
|
|
1350
|
+
core, _ = self._strip_decorations(base)
|
|
1351
|
+
|
|
1352
|
+
title = core
|
|
1353
|
+
if getattr(self, "_link_badge_on", False):
|
|
1354
|
+
title = f"{LINK_PREFIX}{title}"
|
|
1355
|
+
if self._mask_dot_enabled:
|
|
1356
|
+
title = f"{MASK_GLYPH} {title}"
|
|
1357
|
+
|
|
1358
|
+
if title != sub.windowTitle():
|
|
1359
|
+
sub.setWindowTitle(title)
|
|
1360
|
+
sub.setToolTip(title)
|
|
1361
|
+
if title != self._last_title_for_emit:
|
|
1362
|
+
self._last_title_for_emit = title
|
|
1363
|
+
try: self.viewTitleChanged.emit(self, title)
|
|
1364
|
+
except Exception as e:
|
|
1365
|
+
import logging
|
|
1366
|
+
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _strip_decorations(self, title: str) -> tuple[str, bool]:
|
|
1370
|
+
had = False
|
|
1371
|
+
# loop to remove multiple stacked badges, in any order
|
|
1372
|
+
while True:
|
|
1373
|
+
changed = False
|
|
1374
|
+
|
|
1375
|
+
# A) explicit multi-char prefixes
|
|
1376
|
+
for pref in DECORATION_PREFIXES:
|
|
1377
|
+
if title.startswith(pref):
|
|
1378
|
+
title = title[len(pref):]
|
|
1379
|
+
had = changed = True
|
|
1380
|
+
|
|
1381
|
+
# B) generic 1-glyph + space (covers any stray glyph in GLYPHS)
|
|
1382
|
+
if len(title) >= 2 and title[1] == " " and title[0] in GLYPHS:
|
|
1383
|
+
title = title[2:]
|
|
1384
|
+
had = changed = True
|
|
1385
|
+
|
|
1386
|
+
if not changed:
|
|
1387
|
+
break
|
|
1388
|
+
|
|
1389
|
+
return title, had
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def set_active_highlight(self, on: bool):
|
|
1393
|
+
self._is_active_flag = bool(on)
|
|
1394
|
+
return
|
|
1395
|
+
sub = self._mdi_subwindow()
|
|
1396
|
+
if not sub:
|
|
1397
|
+
return
|
|
1398
|
+
|
|
1399
|
+
core, had_glyph = self._strip_decorations(sub.windowTitle())
|
|
1400
|
+
|
|
1401
|
+
if on and not getattr(self, "_suppress_active_once", False):
|
|
1402
|
+
core = ACTIVE_PREFIX + core
|
|
1403
|
+
self._suppress_active_once = False
|
|
1404
|
+
|
|
1405
|
+
# recompose: glyph (from flag), then active prefix, then base/core
|
|
1406
|
+
if getattr(self, "_mask_dot_enabled", False):
|
|
1407
|
+
core = "■ " + core
|
|
1408
|
+
#sub.setWindowTitle(core)
|
|
1409
|
+
sub.setToolTip(core)
|
|
1410
|
+
|
|
1411
|
+
def _set_mask_highlight(self, on: bool):
|
|
1412
|
+
self._mask_dot_enabled = bool(on)
|
|
1413
|
+
self._rebuild_title()
|
|
1414
|
+
|
|
1415
|
+
def _sync_host_title(self):
|
|
1416
|
+
# document renamed → rebuild from flags + new base
|
|
1417
|
+
self._rebuild_title()
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def base_doc_title(self) -> str:
|
|
1422
|
+
"""The clean, base title (document display name), no prefixes/suffixes."""
|
|
1423
|
+
return self.document.display_name() or "Untitled"
|
|
1424
|
+
|
|
1425
|
+
def _active_mask_array(self):
|
|
1426
|
+
"""Return the active mask ndarray (H,W) or None."""
|
|
1427
|
+
doc = getattr(self, "document", None)
|
|
1428
|
+
if not doc:
|
|
1429
|
+
return None
|
|
1430
|
+
mid = getattr(doc, "active_mask_id", None)
|
|
1431
|
+
if not mid:
|
|
1432
|
+
return None
|
|
1433
|
+
masks = getattr(doc, "masks", {}) or {}
|
|
1434
|
+
layer = masks.get(mid)
|
|
1435
|
+
if layer is None:
|
|
1436
|
+
return None
|
|
1437
|
+
data = getattr(layer, "data", None)
|
|
1438
|
+
if data is None:
|
|
1439
|
+
return None
|
|
1440
|
+
import numpy as np
|
|
1441
|
+
a = np.asarray(data)
|
|
1442
|
+
if a.ndim == 3 and a.shape[2] == 1:
|
|
1443
|
+
a = a[..., 0]
|
|
1444
|
+
if a.ndim != 2:
|
|
1445
|
+
return None
|
|
1446
|
+
# ensure 0..1 float
|
|
1447
|
+
a = a.astype(np.float32, copy=False)
|
|
1448
|
+
a = np.clip(a, 0.0, 1.0)
|
|
1449
|
+
return a
|
|
1450
|
+
|
|
1451
|
+
def refresh_mask_overlay(self):
|
|
1452
|
+
"""Recompute the source buffer (incl. red mask tint) and repaint."""
|
|
1453
|
+
self._render(rebuild=True)
|
|
1454
|
+
|
|
1455
|
+
def _apply_subwindow_style(self):
|
|
1456
|
+
"""No-op shim retained for backward compatibility."""
|
|
1457
|
+
pass
|
|
1458
|
+
|
|
1459
|
+
def _on_doc_mask_changed(self):
|
|
1460
|
+
"""Doc changed → refresh highlight and overlay if needed."""
|
|
1461
|
+
has_mask = self._active_mask_array() is not None
|
|
1462
|
+
self._set_mask_highlight(has_mask)
|
|
1463
|
+
if self.show_mask_overlay and has_mask:
|
|
1464
|
+
self._render(rebuild=True)
|
|
1465
|
+
elif self.show_mask_overlay and not has_mask:
|
|
1466
|
+
# overlay was on but mask went away → just redraw to clear
|
|
1467
|
+
self._render(rebuild=True)
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
# ---------- public API ----------
|
|
1471
|
+
def set_autostretch(self, on: bool):
|
|
1472
|
+
on = bool(on)
|
|
1473
|
+
if on == getattr(self, "autostretch_enabled", False):
|
|
1474
|
+
# still rebuild so linked profile changes can reflect immediately if desired
|
|
1475
|
+
pass
|
|
1476
|
+
self.autostretch_enabled = on
|
|
1477
|
+
try:
|
|
1478
|
+
self.autostretchChanged.emit(on)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
pass
|
|
1481
|
+
# keep your newer fast-path behavior
|
|
1482
|
+
self._recompute_autostretch_and_update()
|
|
1483
|
+
|
|
1484
|
+
def toggle_autostretch(self):
|
|
1485
|
+
self.set_autostretch(not self.autostretch_enabled)
|
|
1486
|
+
|
|
1487
|
+
def set_autostretch_target(self, target: float):
|
|
1488
|
+
self.autostretch_target = float(target)
|
|
1489
|
+
if self.autostretch_enabled:
|
|
1490
|
+
self._render(rebuild=True)
|
|
1491
|
+
|
|
1492
|
+
def set_autostretch_sigma(self, sigma: float):
|
|
1493
|
+
self.autostretch_sigma = float(sigma)
|
|
1494
|
+
if self.autostretch_enabled:
|
|
1495
|
+
self._render(rebuild=True)
|
|
1496
|
+
|
|
1497
|
+
def set_autostretch_profile(self, profile: str):
|
|
1498
|
+
"""'normal' => target=0.25, sigma=3 ; 'hard' => target=0.5, sigma=1"""
|
|
1499
|
+
p = (profile or "").lower()
|
|
1500
|
+
if p not in ("normal", "hard"):
|
|
1501
|
+
p = "normal"
|
|
1502
|
+
if p == self.autostretch_profile:
|
|
1503
|
+
return
|
|
1504
|
+
if p == "hard":
|
|
1505
|
+
self.autostretch_target = 0.5
|
|
1506
|
+
self.autostretch_sigma = 2
|
|
1507
|
+
else:
|
|
1508
|
+
self.autostretch_target = 0.3
|
|
1509
|
+
self.autostretch_sigma = 5
|
|
1510
|
+
self.autostretch_profile = p
|
|
1511
|
+
if self.autostretch_enabled:
|
|
1512
|
+
self._render(rebuild=True)
|
|
1513
|
+
|
|
1514
|
+
def is_hard_autostretch(self) -> bool:
|
|
1515
|
+
return self.autostretch_profile == "hard"
|
|
1516
|
+
|
|
1517
|
+
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1518
|
+
w = self.parent()
|
|
1519
|
+
while w is not None and not isinstance(w, QMdiSubWindow):
|
|
1520
|
+
w = w.parent()
|
|
1521
|
+
return w
|
|
1522
|
+
|
|
1523
|
+
def _effective_title(self) -> str:
|
|
1524
|
+
# Prefer a per-view override; otherwise doc display name
|
|
1525
|
+
return self._view_title_override or self.document.display_name()
|
|
1526
|
+
|
|
1527
|
+
def _show_ctx_menu(self, pos):
|
|
1528
|
+
menu = QMenu(self)
|
|
1529
|
+
a_view = menu.addAction("Rename View… (F2)")
|
|
1530
|
+
a_doc = menu.addAction("Rename Document…")
|
|
1531
|
+
menu.addSeparator()
|
|
1532
|
+
a_min = menu.addAction("Send to Shelf")
|
|
1533
|
+
a_clear = menu.addAction("Clear View Name (use doc name)")
|
|
1534
|
+
menu.addSeparator()
|
|
1535
|
+
a_unlink = menu.addAction("Unlink from Linked Views") # ← NEW
|
|
1536
|
+
menu.addSeparator()
|
|
1537
|
+
a_help = menu.addAction("Show pixel/WCS readout hint")
|
|
1538
|
+
menu.addSeparator()
|
|
1539
|
+
a_prev = menu.addAction("Create Preview (drag rectangle)")
|
|
1540
|
+
|
|
1541
|
+
act = menu.exec(self.mapToGlobal(pos))
|
|
1542
|
+
|
|
1543
|
+
if act == a_view:
|
|
1544
|
+
self._rename_view()
|
|
1545
|
+
elif act == a_doc:
|
|
1546
|
+
self._rename_document()
|
|
1547
|
+
elif act == a_min:
|
|
1548
|
+
self._send_to_shelf()
|
|
1549
|
+
elif act == a_clear:
|
|
1550
|
+
self._view_title_override = None
|
|
1551
|
+
self._sync_host_title()
|
|
1552
|
+
elif act == a_unlink:
|
|
1553
|
+
self.unlink_all()
|
|
1554
|
+
elif act == a_help:
|
|
1555
|
+
self._announce_readout_help()
|
|
1556
|
+
elif act == a_prev:
|
|
1557
|
+
self._preview_btn.setChecked(True)
|
|
1558
|
+
self._toggle_preview_select_mode(True)
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _send_to_shelf(self):
|
|
1563
|
+
sub = self._mdi_subwindow()
|
|
1564
|
+
mw = self._find_main_window()
|
|
1565
|
+
if sub and mw and hasattr(mw, "window_shelf"):
|
|
1566
|
+
sub.hide()
|
|
1567
|
+
mw.window_shelf.add_entry(sub)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _rename_view(self):
|
|
1571
|
+
current = self._view_title_override or self.document.display_name()
|
|
1572
|
+
new, ok = QInputDialog.getText(self, "Rename View", "New view name:", text=current)
|
|
1573
|
+
if ok and new.strip():
|
|
1574
|
+
self._view_title_override = new.strip()
|
|
1575
|
+
self._sync_host_title() # calls _rebuild_title → emits viewTitleChanged
|
|
1576
|
+
|
|
1577
|
+
# optional: directly ping layers dock (defensive)
|
|
1578
|
+
mw = self._find_main_window()
|
|
1579
|
+
if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
|
|
1580
|
+
try:
|
|
1581
|
+
mw.layers_dock._refresh_titles_only()
|
|
1582
|
+
except Exception:
|
|
1583
|
+
pass
|
|
1584
|
+
|
|
1585
|
+
def _rename_document(self):
|
|
1586
|
+
current = self.document.display_name()
|
|
1587
|
+
new, ok = QInputDialog.getText(self, "Rename Document", "New document name:", text=current)
|
|
1588
|
+
if ok and new.strip():
|
|
1589
|
+
# store on the doc so Explorer + other views update too
|
|
1590
|
+
self.document.metadata["display_name"] = new.strip()
|
|
1591
|
+
self.document.changed.emit() # triggers all listeners
|
|
1592
|
+
# If this view had an override equal to the old name, drop it
|
|
1593
|
+
if self._view_title_override and self._view_title_override == current:
|
|
1594
|
+
self._view_title_override = None
|
|
1595
|
+
self._sync_host_title()
|
|
1596
|
+
mw = self._find_main_window()
|
|
1597
|
+
if mw and hasattr(mw, "layers_dock") and mw.layers_dock:
|
|
1598
|
+
try:
|
|
1599
|
+
mw.layers_dock._refresh_titles_only()
|
|
1600
|
+
except Exception:
|
|
1601
|
+
pass
|
|
1602
|
+
|
|
1603
|
+
def set_scale(self, s: float):
|
|
1604
|
+
s = float(max(self._min_scale, min(s, self._max_scale)))
|
|
1605
|
+
if abs(s - self.scale) < 1e-9:
|
|
1606
|
+
return
|
|
1607
|
+
self.scale = s
|
|
1608
|
+
self._render() # only scale needs a redraw
|
|
1609
|
+
self._schedule_emit_view_transform()
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
# ---- view state API (center in image coords + scale) ----
|
|
1614
|
+
#def get_view_state(self) -> dict:
|
|
1615
|
+
# pm = self.label.pixmap()
|
|
1616
|
+
# if pm is None:
|
|
1617
|
+
# return {"scale": self.scale, "center": (0.0, 0.0)}
|
|
1618
|
+
# vp = self.scroll.viewport().size()
|
|
1619
|
+
# hbar = self.scroll.horizontalScrollBar()
|
|
1620
|
+
# vbar = self.scroll.verticalScrollBar()
|
|
1621
|
+
# cx_label = hbar.value() + vp.width() / 2.0
|
|
1622
|
+
# cy_label = vbar.value() + vp.height() / 2.0
|
|
1623
|
+
# return {
|
|
1624
|
+
# "scale": float(self.scale),
|
|
1625
|
+
# "center": (float(cx_label / max(1e-6, self.scale)),
|
|
1626
|
+
# float(cy_label / max(1e-6, self.scale)))
|
|
1627
|
+
# }
|
|
1628
|
+
|
|
1629
|
+
def _start_viewstate_drag(self):
|
|
1630
|
+
"""Package view state + robust doc identity into a drag."""
|
|
1631
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1632
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1633
|
+
|
|
1634
|
+
state = {
|
|
1635
|
+
"doc_ptr": id(self.document), # legacy
|
|
1636
|
+
"scale": float(self.scale),
|
|
1637
|
+
"hval": int(hbar.value()),
|
|
1638
|
+
"vval": int(vbar.value()),
|
|
1639
|
+
"autostretch": bool(self.autostretch_enabled),
|
|
1640
|
+
"autostretch_target": float(self.autostretch_target),
|
|
1641
|
+
}
|
|
1642
|
+
state.update(self._drag_identity_fields()) # uid + base_uid + file_path
|
|
1643
|
+
|
|
1644
|
+
# --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
|
|
1645
|
+
roi = None
|
|
1646
|
+
try:
|
|
1647
|
+
if hasattr(self, "has_active_preview") and self.has_active_preview():
|
|
1648
|
+
r = self.current_preview_roi() # (x,y,w,h) in full-image coords
|
|
1649
|
+
if r and len(r) == 4:
|
|
1650
|
+
roi = tuple(map(int, r))
|
|
1651
|
+
except Exception:
|
|
1652
|
+
roi = None
|
|
1653
|
+
|
|
1654
|
+
if roi:
|
|
1655
|
+
state["roi"] = roi
|
|
1656
|
+
state["source_kind"] = "roi-preview"
|
|
1657
|
+
try:
|
|
1658
|
+
pname = self.current_preview_name()
|
|
1659
|
+
except Exception:
|
|
1660
|
+
pname = None
|
|
1661
|
+
if pname:
|
|
1662
|
+
state["preview_name"] = str(pname)
|
|
1663
|
+
else:
|
|
1664
|
+
state["source_kind"] = "full"
|
|
1665
|
+
|
|
1666
|
+
md = QMimeData()
|
|
1667
|
+
md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1668
|
+
|
|
1669
|
+
drag = QDrag(self)
|
|
1670
|
+
drag.setMimeData(md)
|
|
1671
|
+
if self.label.pixmap():
|
|
1672
|
+
drag.setPixmap(self.label.pixmap())
|
|
1673
|
+
drag.exec()
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _start_mask_drag(self):
|
|
1678
|
+
"""
|
|
1679
|
+
Start a drag that carries 'this document is a mask' to drop targets.
|
|
1680
|
+
"""
|
|
1681
|
+
doc = self.document
|
|
1682
|
+
if doc is None:
|
|
1683
|
+
return
|
|
1684
|
+
|
|
1685
|
+
payload = {
|
|
1686
|
+
# New-style field
|
|
1687
|
+
"mask_doc_ptr": id(doc),
|
|
1688
|
+
|
|
1689
|
+
# Backward-compat field: many handlers still look for 'doc_ptr'
|
|
1690
|
+
"doc_ptr": id(doc),
|
|
1691
|
+
|
|
1692
|
+
"mode": "replace", # future: "union"/"intersect"/"diff"
|
|
1693
|
+
"invert": False,
|
|
1694
|
+
"feather": 0.0, # px
|
|
1695
|
+
"name": doc.display_name(),
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
# Add identity hints (uids, base uid, file_path)
|
|
1699
|
+
payload.update(self._drag_identity_fields())
|
|
1700
|
+
|
|
1701
|
+
md = QMimeData()
|
|
1702
|
+
md.setData(MIME_MASK, QByteArray(json.dumps(payload).encode("utf-8")))
|
|
1703
|
+
|
|
1704
|
+
drag = QDrag(self)
|
|
1705
|
+
drag.setMimeData(md)
|
|
1706
|
+
if self.label.pixmap():
|
|
1707
|
+
drag.setPixmap(
|
|
1708
|
+
self.label.pixmap().scaled(
|
|
1709
|
+
64, 64,
|
|
1710
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1711
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
1712
|
+
)
|
|
1713
|
+
)
|
|
1714
|
+
drag.setHotSpot(QPoint(16, 16))
|
|
1715
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
1716
|
+
|
|
1717
|
+
def _start_astrometry_drag(self):
|
|
1718
|
+
"""
|
|
1719
|
+
Start a drag that carries 'copy astrometric solution from this document'.
|
|
1720
|
+
We only send a pointer; the main window resolves + copies actual WCS.
|
|
1721
|
+
"""
|
|
1722
|
+
payload = {
|
|
1723
|
+
"wcs_from_doc_ptr": id(self.document),
|
|
1724
|
+
"name": self.document.display_name(),
|
|
1725
|
+
}
|
|
1726
|
+
payload.update(self._drag_identity_fields())
|
|
1727
|
+
md = QMimeData()
|
|
1728
|
+
md.setData(MIME_ASTROMETRY, QByteArray(json.dumps(payload).encode("utf-8")))
|
|
1729
|
+
|
|
1730
|
+
drag = QDrag(self)
|
|
1731
|
+
drag.setMimeData(md)
|
|
1732
|
+
if self.label.pixmap():
|
|
1733
|
+
drag.setPixmap(self.label.pixmap().scaled(
|
|
1734
|
+
64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
|
|
1735
|
+
drag.setHotSpot(QPoint(16, 16))
|
|
1736
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def apply_view_state(self, st: dict):
|
|
1740
|
+
try:
|
|
1741
|
+
new_scale = float(st.get("scale", self.scale))
|
|
1742
|
+
except Exception:
|
|
1743
|
+
new_scale = self.scale
|
|
1744
|
+
# clamp with new max
|
|
1745
|
+
self.scale = max(self._min_scale, min(new_scale, self._max_scale))
|
|
1746
|
+
self._render(rebuild=False)
|
|
1747
|
+
|
|
1748
|
+
vp = self.scroll.viewport().size()
|
|
1749
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1750
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1751
|
+
|
|
1752
|
+
if "hval" in st or "vval" in st:
|
|
1753
|
+
# direct scrollbar values (fast path)
|
|
1754
|
+
hv = int(st.get("hval", hbar.value()))
|
|
1755
|
+
vv = int(st.get("vval", vbar.value()))
|
|
1756
|
+
hbar.setValue(hv)
|
|
1757
|
+
vbar.setValue(vv)
|
|
1758
|
+
return
|
|
1759
|
+
|
|
1760
|
+
# fallback: center in image coordinates
|
|
1761
|
+
center = st.get("center")
|
|
1762
|
+
if center is None:
|
|
1763
|
+
return
|
|
1764
|
+
try:
|
|
1765
|
+
cx_img, cy_img = float(center[0]), float(center[1])
|
|
1766
|
+
except Exception:
|
|
1767
|
+
return
|
|
1768
|
+
cx_label = cx_img * self.scale
|
|
1769
|
+
cy_label = cy_img * self.scale
|
|
1770
|
+
hbar.setValue(int(cx_label - vp.width() / 2.0))
|
|
1771
|
+
vbar.setValue(int(cy_label - vp.height() / 2.0))
|
|
1772
|
+
self._emit_view_transform()
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
# ---- DnD 'view tab' -------------------------------------------------
|
|
1776
|
+
def _install_view_tab(self):
|
|
1777
|
+
self._view_tab = QToolButton(self)
|
|
1778
|
+
self._view_tab.setText("View")
|
|
1779
|
+
self._view_tab.setToolTip("Drag onto another window to copy zoom/pan.\n"
|
|
1780
|
+
"Double-click to duplicate this view.")
|
|
1781
|
+
self._view_tab.setCursor(Qt.CursorShape.OpenHandCursor)
|
|
1782
|
+
self._view_tab.setAutoRaise(True)
|
|
1783
|
+
self._view_tab.move(8, 8) # pinned near top-left of the subwindow
|
|
1784
|
+
self._view_tab.show()
|
|
1785
|
+
|
|
1786
|
+
# start drag on press
|
|
1787
|
+
self._view_tab.mousePressEvent = self._viewtab_mouse_press
|
|
1788
|
+
# duplicate on double-click
|
|
1789
|
+
self._view_tab.mouseDoubleClickEvent = self._viewtab_mouse_double
|
|
1790
|
+
|
|
1791
|
+
def _viewtab_mouse_press(self, ev):
|
|
1792
|
+
if ev.button() != Qt.MouseButton.LeftButton:
|
|
1793
|
+
return QToolButton.mousePressEvent(self._view_tab, ev)
|
|
1794
|
+
|
|
1795
|
+
# build the SAME payload schema used by _start_viewstate_drag()
|
|
1796
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
1797
|
+
vbar = self.scroll.verticalScrollBar()
|
|
1798
|
+
state = {
|
|
1799
|
+
"doc_ptr": id(self.document),
|
|
1800
|
+
"scale": float(self.scale),
|
|
1801
|
+
"hval": int(hbar.value()),
|
|
1802
|
+
"vval": int(vbar.value()),
|
|
1803
|
+
"autostretch": bool(self.autostretch_enabled),
|
|
1804
|
+
"autostretch_target": float(self.autostretch_target),
|
|
1805
|
+
}
|
|
1806
|
+
state.update(self._drag_identity_fields())
|
|
1807
|
+
|
|
1808
|
+
mime = QMimeData()
|
|
1809
|
+
mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1810
|
+
|
|
1811
|
+
drag = QDrag(self)
|
|
1812
|
+
drag.setMimeData(mime)
|
|
1813
|
+
|
|
1814
|
+
pm = self.label.pixmap()
|
|
1815
|
+
if pm:
|
|
1816
|
+
drag.setPixmap(pm.scaled(96, 96,
|
|
1817
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1818
|
+
Qt.TransformationMode.SmoothTransformation))
|
|
1819
|
+
drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
|
|
1820
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
1821
|
+
|
|
1822
|
+
def _viewtab_mouse_double(self, _ev):
|
|
1823
|
+
# ask main window to duplicate this subwindow
|
|
1824
|
+
self.requestDuplicate.emit(self)
|
|
1825
|
+
|
|
1826
|
+
# accept view-state drops anywhere in the view
|
|
1827
|
+
def dragEnterEvent(self, ev):
|
|
1828
|
+
md = ev.mimeData()
|
|
1829
|
+
|
|
1830
|
+
if (md.hasFormat(MIME_VIEWSTATE)
|
|
1831
|
+
or md.hasFormat(MIME_ASTROMETRY)
|
|
1832
|
+
or md.hasFormat(MIME_MASK)
|
|
1833
|
+
or md.hasFormat(MIME_CMD)
|
|
1834
|
+
or md.hasFormat(MIME_LINKVIEW)):
|
|
1835
|
+
ev.acceptProposedAction()
|
|
1836
|
+
else:
|
|
1837
|
+
ev.ignore()
|
|
1838
|
+
|
|
1839
|
+
def dragMoveEvent(self, ev):
|
|
1840
|
+
md = ev.mimeData()
|
|
1841
|
+
|
|
1842
|
+
if (md.hasFormat(MIME_VIEWSTATE)
|
|
1843
|
+
or md.hasFormat(MIME_ASTROMETRY)
|
|
1844
|
+
or md.hasFormat(MIME_MASK)
|
|
1845
|
+
or md.hasFormat(MIME_CMD)
|
|
1846
|
+
or md.hasFormat(MIME_LINKVIEW)):
|
|
1847
|
+
ev.acceptProposedAction()
|
|
1848
|
+
else:
|
|
1849
|
+
ev.ignore()
|
|
1850
|
+
|
|
1851
|
+
def dropEvent(self, ev):
|
|
1852
|
+
md = ev.mimeData()
|
|
1853
|
+
|
|
1854
|
+
# 0) Function/Action command → forward to main window for headless/UI routing
|
|
1855
|
+
if md.hasFormat(MIME_CMD):
|
|
1856
|
+
try:
|
|
1857
|
+
payload = _unpack_cmd_payload(bytes(md.data(MIME_CMD)))
|
|
1858
|
+
except Exception:
|
|
1859
|
+
ev.ignore(); return
|
|
1860
|
+
mw = self._find_main_window()
|
|
1861
|
+
sw = self._mdi_subwindow()
|
|
1862
|
+
if mw and sw and hasattr(mw, "_handle_command_drop"):
|
|
1863
|
+
mw._handle_command_drop(payload, sw)
|
|
1864
|
+
ev.acceptProposedAction()
|
|
1865
|
+
else:
|
|
1866
|
+
ev.ignore()
|
|
1867
|
+
return
|
|
1868
|
+
|
|
1869
|
+
# 1) view state (existing)
|
|
1870
|
+
if md.hasFormat(MIME_VIEWSTATE):
|
|
1871
|
+
try:
|
|
1872
|
+
st = json.loads(bytes(md.data(MIME_VIEWSTATE)).decode("utf-8"))
|
|
1873
|
+
self.apply_view_state(st)
|
|
1874
|
+
ev.acceptProposedAction()
|
|
1875
|
+
except Exception:
|
|
1876
|
+
ev.ignore()
|
|
1877
|
+
return
|
|
1878
|
+
|
|
1879
|
+
# 2) mask (NEW) → forward to main-window handler using this view as target
|
|
1880
|
+
if md.hasFormat(MIME_MASK):
|
|
1881
|
+
try:
|
|
1882
|
+
payload = json.loads(bytes(md.data(MIME_MASK)).decode("utf-8"))
|
|
1883
|
+
except Exception:
|
|
1884
|
+
ev.ignore(); return
|
|
1885
|
+
mw = self._find_main_window()
|
|
1886
|
+
sw = self._mdi_subwindow()
|
|
1887
|
+
if mw and sw and hasattr(mw, "_handle_mask_drop"):
|
|
1888
|
+
mw._handle_mask_drop(payload, sw)
|
|
1889
|
+
ev.acceptProposedAction()
|
|
1890
|
+
else:
|
|
1891
|
+
ev.ignore()
|
|
1892
|
+
return
|
|
1893
|
+
|
|
1894
|
+
# 3) astrometry (existing forwarding)
|
|
1895
|
+
if md.hasFormat(MIME_ASTROMETRY):
|
|
1896
|
+
try:
|
|
1897
|
+
payload = json.loads(bytes(md.data(MIME_ASTROMETRY)).decode("utf-8"))
|
|
1898
|
+
except Exception:
|
|
1899
|
+
ev.ignore(); return
|
|
1900
|
+
mw = self._find_main_window()
|
|
1901
|
+
sw = self._mdi_subwindow()
|
|
1902
|
+
if mw and hasattr(mw, "_on_astrometry_drop") and sw is not None:
|
|
1903
|
+
mw._on_astrometry_drop(payload, sw)
|
|
1904
|
+
ev.acceptProposedAction()
|
|
1905
|
+
else:
|
|
1906
|
+
ev.ignore()
|
|
1907
|
+
return
|
|
1908
|
+
|
|
1909
|
+
if md.hasFormat(MIME_LINKVIEW):
|
|
1910
|
+
try:
|
|
1911
|
+
payload = json.loads(bytes(md.data(MIME_LINKVIEW)).decode("utf-8"))
|
|
1912
|
+
sid = int(payload.get("source_view_id"))
|
|
1913
|
+
except Exception:
|
|
1914
|
+
ev.ignore(); return
|
|
1915
|
+
src = ImageSubWindow._registry.get(sid)
|
|
1916
|
+
if src is not None and src is not self:
|
|
1917
|
+
src.link_to(self)
|
|
1918
|
+
ev.acceptProposedAction()
|
|
1919
|
+
else:
|
|
1920
|
+
ev.ignore()
|
|
1921
|
+
return
|
|
1922
|
+
|
|
1923
|
+
ev.ignore()
|
|
1924
|
+
|
|
1925
|
+
# keep the tab visible if the widget resizes
|
|
1926
|
+
def resizeEvent(self, ev):
|
|
1927
|
+
super().resizeEvent(ev)
|
|
1928
|
+
try:
|
|
1929
|
+
self.resized.emit()
|
|
1930
|
+
except Exception:
|
|
1931
|
+
pass
|
|
1932
|
+
if hasattr(self, "_view_tab"):
|
|
1933
|
+
self._view_tab.raise_()
|
|
1934
|
+
|
|
1935
|
+
def is_autostretch_linked(self) -> bool:
|
|
1936
|
+
return bool(self._autostretch_linked)
|
|
1937
|
+
|
|
1938
|
+
def set_autostretch_linked(self, linked: bool):
|
|
1939
|
+
linked = bool(linked)
|
|
1940
|
+
if self._autostretch_linked == linked:
|
|
1941
|
+
return
|
|
1942
|
+
self._autostretch_linked = linked
|
|
1943
|
+
if self.autostretch_enabled:
|
|
1944
|
+
self._recompute_autostretch_and_update()
|
|
1945
|
+
|
|
1946
|
+
def _on_docman_nudge(self, *args):
|
|
1947
|
+
# Guard against late signals hitting after destruction/minimize
|
|
1948
|
+
try:
|
|
1949
|
+
from PyQt6 import sip as _sip
|
|
1950
|
+
if _sip.isdeleted(self):
|
|
1951
|
+
return
|
|
1952
|
+
except Exception:
|
|
1953
|
+
pass
|
|
1954
|
+
try:
|
|
1955
|
+
self._refresh_local_undo_buttons()
|
|
1956
|
+
except RuntimeError:
|
|
1957
|
+
# Buttons already gone; safe to ignore
|
|
1958
|
+
pass
|
|
1959
|
+
except Exception:
|
|
1960
|
+
pass
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
def _recompute_autostretch_and_update(self):
|
|
1964
|
+
self._qimg_src = None # force source rebuild
|
|
1965
|
+
self._render(True)
|
|
1966
|
+
|
|
1967
|
+
def set_doc_manager(self, docman):
|
|
1968
|
+
self._docman = docman
|
|
1969
|
+
try:
|
|
1970
|
+
docman.imageRegionUpdated.connect(self._on_doc_region_updated)
|
|
1971
|
+
docman.imageRegionUpdated.connect(self._on_docman_nudge)
|
|
1972
|
+
if hasattr(docman, "previewRepaintRequested"):
|
|
1973
|
+
docman.previewRepaintRequested.connect(self._on_docman_nudge)
|
|
1974
|
+
except Exception:
|
|
1975
|
+
pass
|
|
1976
|
+
|
|
1977
|
+
base = getattr(self, "base_document", None) or getattr(self, "document", None)
|
|
1978
|
+
if base is not None:
|
|
1979
|
+
try:
|
|
1980
|
+
base.changed.connect(self._on_base_doc_changed)
|
|
1981
|
+
except Exception:
|
|
1982
|
+
pass
|
|
1983
|
+
self._install_history_watchers()
|
|
1984
|
+
|
|
1985
|
+
def _on_base_doc_changed(self):
|
|
1986
|
+
# Full-image changes (or unknown) → rebuild our pixmap
|
|
1987
|
+
QTimer.singleShot(0, lambda: (self._render(rebuild=True), self._refresh_local_undo_buttons()))
|
|
1988
|
+
|
|
1989
|
+
def _on_history_doc_changed(self):
|
|
1990
|
+
"""
|
|
1991
|
+
Called when the current history document (full or ROI) changes.
|
|
1992
|
+
Ensures the pixmap is rebuilt immediately, including when a
|
|
1993
|
+
tool operates on a Preview/ROI doc.
|
|
1994
|
+
"""
|
|
1995
|
+
QTimer.singleShot(0, lambda: (self._render(rebuild=True),
|
|
1996
|
+
self._refresh_local_undo_buttons()))
|
|
1997
|
+
|
|
1998
|
+
def _on_doc_region_updated(self, doc, roi_tuple_or_none):
|
|
1999
|
+
# Only react if it’s our base doc
|
|
2000
|
+
base = getattr(self, "base_document", None) or getattr(self, "document", None)
|
|
2001
|
+
if doc is None or base is None or doc is not base:
|
|
2002
|
+
return
|
|
2003
|
+
|
|
2004
|
+
# If not on a Preview tab, just refresh.
|
|
2005
|
+
if not (getattr(self, "_active_source_kind", None) == "preview"
|
|
2006
|
+
and getattr(self, "_active_preview_id", None) is not None):
|
|
2007
|
+
QTimer.singleShot(0, lambda: self._render(rebuild=True))
|
|
2008
|
+
return
|
|
2009
|
+
|
|
2010
|
+
# We’re on a Preview tab: refresh only if the changed region overlaps our ROI.
|
|
2011
|
+
try:
|
|
2012
|
+
my_roi = self.current_preview_roi() # (x,y,w,h) in full-image coords
|
|
2013
|
+
except Exception:
|
|
2014
|
+
my_roi = None
|
|
2015
|
+
|
|
2016
|
+
if my_roi is None or roi_tuple_or_none is None:
|
|
2017
|
+
QTimer.singleShot(0, lambda: self._render(rebuild=True))
|
|
2018
|
+
return
|
|
2019
|
+
|
|
2020
|
+
if self._roi_intersects(my_roi, roi_tuple_or_none):
|
|
2021
|
+
QTimer.singleShot(0, lambda: self._render(rebuild=True))
|
|
2022
|
+
|
|
2023
|
+
@staticmethod
|
|
2024
|
+
def _roi_intersects(a, b):
|
|
2025
|
+
ax, ay, aw, ah = map(int, a)
|
|
2026
|
+
bx, by, bw, bh = map(int, b)
|
|
2027
|
+
if aw <= 0 or ah <= 0 or bw <= 0 or bh <= 0:
|
|
2028
|
+
return False
|
|
2029
|
+
return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
|
|
2030
|
+
|
|
2031
|
+
def refresh_from_docman(self):
|
|
2032
|
+
#print("[ImageSubWindow] refresh_from_docman called")
|
|
2033
|
+
"""
|
|
2034
|
+
Called by MainWindow when DocManager says the image changed.
|
|
2035
|
+
We nuke the cached QImage and rebuild from the current doc proxy
|
|
2036
|
+
(which resolves ROI vs full), so the Preview tab repaints correctly.
|
|
2037
|
+
"""
|
|
2038
|
+
try:
|
|
2039
|
+
# Invalidate any cached source so _render() fully rebuilds
|
|
2040
|
+
if hasattr(self, "_qimg_src"):
|
|
2041
|
+
self._qimg_src = None
|
|
2042
|
+
except Exception:
|
|
2043
|
+
pass
|
|
2044
|
+
self._render(rebuild=True)
|
|
2045
|
+
|
|
2046
|
+
def _deg_to_hms(self, ra_deg: float) -> str:
|
|
2047
|
+
"""RA in degrees → 'HH:MM:SS' (rounded secs, with carry)."""
|
|
2048
|
+
ra_h = ra_deg / 15.0
|
|
2049
|
+
hh = int(ra_h) % 24
|
|
2050
|
+
mmf = (ra_h - hh) * 60.0
|
|
2051
|
+
mm = int(mmf)
|
|
2052
|
+
ss = int(round((mmf - mm) * 60.0))
|
|
2053
|
+
if ss == 60:
|
|
2054
|
+
ss = 0; mm += 1
|
|
2055
|
+
if mm == 60:
|
|
2056
|
+
mm = 0; hh = (hh + 1) % 24
|
|
2057
|
+
return f"{hh:02d}:{mm:02d}:{ss:02d}"
|
|
2058
|
+
|
|
2059
|
+
def _deg_to_dms(self, dec_deg: float) -> str:
|
|
2060
|
+
"""Dec in degrees → '±DD:MM:SS' (rounded secs, with carry)."""
|
|
2061
|
+
sign = "+" if dec_deg >= 0 else "-"
|
|
2062
|
+
d = abs(dec_deg)
|
|
2063
|
+
dd = int(d)
|
|
2064
|
+
mf = (d - dd) * 60.0
|
|
2065
|
+
mm = int(mf)
|
|
2066
|
+
ss = int(round((mf - mm) * 60.0))
|
|
2067
|
+
if ss == 60:
|
|
2068
|
+
ss = 0; mm += 1
|
|
2069
|
+
if mm == 60:
|
|
2070
|
+
mm = 0; dd += 1
|
|
2071
|
+
return f"{sign}{dd:02d}:{mm:02d}:{ss:02d}"
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
# ---------- rendering ----------
|
|
2075
|
+
def _render(self, rebuild: bool = False):
|
|
2076
|
+
"""
|
|
2077
|
+
Render the current view.
|
|
2078
|
+
|
|
2079
|
+
Rules:
|
|
2080
|
+
- If a Preview is active, FIRST sync that preview's stored arr from the
|
|
2081
|
+
DocManager's ROI document (the thing tools actually modify), then render.
|
|
2082
|
+
- Never reslice from the parent/full image here.
|
|
2083
|
+
- Keep a strong reference to the numpy buffer that backs the QImage.
|
|
2084
|
+
"""
|
|
2085
|
+
# ---- GUARD: widget/label may be deleted but document.changed still fires ----
|
|
2086
|
+
try:
|
|
2087
|
+
from PyQt6 import sip as _sip
|
|
2088
|
+
# If the whole widget or its label is gone, bail immediately
|
|
2089
|
+
if _sip.isdeleted(self):
|
|
2090
|
+
return
|
|
2091
|
+
lbl = getattr(self, "label", None)
|
|
2092
|
+
if lbl is None or _sip.isdeleted(lbl):
|
|
2093
|
+
return
|
|
2094
|
+
except Exception:
|
|
2095
|
+
# If sip or label is missing for any reason, play it safe
|
|
2096
|
+
if not hasattr(self, "label"):
|
|
2097
|
+
return
|
|
2098
|
+
# ---------------------------------------------------------------------------
|
|
2099
|
+
# ---------------------------
|
|
2100
|
+
# 1) Choose & sync source arr
|
|
2101
|
+
# ---------------------------
|
|
2102
|
+
base_img = None
|
|
2103
|
+
if self._active_source_kind == "preview" and self._active_preview_id is not None:
|
|
2104
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2105
|
+
#print("[ImageSubWindow] _render: preview mode, id =", self._active_preview_id, "src =", src is not None)
|
|
2106
|
+
if src is not None:
|
|
2107
|
+
# Pull the *edited* ROI image from DocManager, if available
|
|
2108
|
+
if hasattr(self, "_docman") and self._docman is not None:
|
|
2109
|
+
#print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
|
|
2110
|
+
try:
|
|
2111
|
+
roi_doc = self._docman.get_document_for_view(self)
|
|
2112
|
+
roi_img = getattr(roi_doc, "image", None)
|
|
2113
|
+
if roi_img is not None:
|
|
2114
|
+
# Replace the preview’s static copy with the edited ROI buffer
|
|
2115
|
+
src["arr"] = np.asarray(roi_img).copy()
|
|
2116
|
+
except Exception:
|
|
2117
|
+
print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
|
|
2118
|
+
pass
|
|
2119
|
+
base_img = src.get("arr", None)
|
|
2120
|
+
else:
|
|
2121
|
+
#print("[ImageSubWindow] _render: full image mode")
|
|
2122
|
+
base_img = self._display_override if (self._display_override is not None) else (
|
|
2123
|
+
getattr(self.document, "image", None)
|
|
2124
|
+
)
|
|
2125
|
+
|
|
2126
|
+
if base_img is None:
|
|
2127
|
+
self._qimg_src = None
|
|
2128
|
+
self.label.clear()
|
|
2129
|
+
return
|
|
2130
|
+
|
|
2131
|
+
arr = np.asarray(base_img)
|
|
2132
|
+
|
|
2133
|
+
# ---------------------------------------
|
|
2134
|
+
# 2) Normalize dimensionality and dtype
|
|
2135
|
+
# ---------------------------------------
|
|
2136
|
+
# Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
|
|
2137
|
+
if arr.ndim == 0:
|
|
2138
|
+
arr = arr.reshape(1, 1)
|
|
2139
|
+
elif arr.ndim == 1:
|
|
2140
|
+
arr = arr[np.newaxis, :]
|
|
2141
|
+
elif arr.ndim == 3 and arr.shape[2] == 1:
|
|
2142
|
+
arr = arr[..., 0]
|
|
2143
|
+
|
|
2144
|
+
is_mono = (arr.ndim == 2)
|
|
2145
|
+
|
|
2146
|
+
# ---------------------------------------
|
|
2147
|
+
# 3) Visualization buffer (float32)
|
|
2148
|
+
# ---------------------------------------
|
|
2149
|
+
if self.autostretch_enabled:
|
|
2150
|
+
if np.issubdtype(arr.dtype, np.integer):
|
|
2151
|
+
info = np.iinfo(arr.dtype)
|
|
2152
|
+
denom = float(max(1, info.max))
|
|
2153
|
+
arr_f = (arr.astype(np.float32) / denom)
|
|
2154
|
+
else:
|
|
2155
|
+
arr_f = arr.astype(np.float32, copy=False)
|
|
2156
|
+
mx = float(arr_f.max()) if arr_f.size else 1.0
|
|
2157
|
+
if mx > 5.0: # compress absurdly large ranges
|
|
2158
|
+
arr_f = arr_f / mx
|
|
2159
|
+
|
|
2160
|
+
vis = autostretch(
|
|
2161
|
+
arr_f,
|
|
2162
|
+
target_median=self.autostretch_target,
|
|
2163
|
+
sigma=self.autostretch_sigma,
|
|
2164
|
+
linked=(not is_mono and self._autostretch_linked),
|
|
2165
|
+
use_16bit=None,
|
|
2166
|
+
)
|
|
2167
|
+
else:
|
|
2168
|
+
vis = arr
|
|
2169
|
+
|
|
2170
|
+
# ---------------------------------------
|
|
2171
|
+
# 4) Convert to 8-bit RGB for QImage
|
|
2172
|
+
# ---------------------------------------
|
|
2173
|
+
if vis.dtype == np.uint8:
|
|
2174
|
+
buf8 = vis
|
|
2175
|
+
elif vis.dtype == np.uint16:
|
|
2176
|
+
buf8 = (vis.astype(np.float32) / 65535.0 * 255.0).clip(0, 255).astype(np.uint8)
|
|
2177
|
+
else:
|
|
2178
|
+
buf8 = (np.clip(vis.astype(np.float32, copy=False), 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
2179
|
+
|
|
2180
|
+
# Force H×W×3
|
|
2181
|
+
if buf8.ndim == 2:
|
|
2182
|
+
buf8 = np.stack([buf8] * 3, axis=-1)
|
|
2183
|
+
elif buf8.ndim == 3:
|
|
2184
|
+
c = buf8.shape[2]
|
|
2185
|
+
if c == 1:
|
|
2186
|
+
buf8 = np.repeat(buf8, 3, axis=2)
|
|
2187
|
+
elif c > 3:
|
|
2188
|
+
buf8 = buf8[..., :3]
|
|
2189
|
+
else:
|
|
2190
|
+
buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
|
|
2191
|
+
|
|
2192
|
+
# ---------------------------------------
|
|
2193
|
+
# 5) Optional mask overlay
|
|
2194
|
+
# ---------------------------------------
|
|
2195
|
+
if getattr(self, "show_mask_overlay", False):
|
|
2196
|
+
m = self._active_mask_array()
|
|
2197
|
+
if m is not None:
|
|
2198
|
+
if getattr(self, "_mask_overlay_invert", True):
|
|
2199
|
+
m = 1.0 - m
|
|
2200
|
+
th, tw = buf8.shape[:2]
|
|
2201
|
+
sh, sw = m.shape[:2]
|
|
2202
|
+
if (sh, sw) != (th, tw):
|
|
2203
|
+
yi = (np.linspace(0, sh - 1, th)).astype(np.int32)
|
|
2204
|
+
xi = (np.linspace(0, sw - 1, tw)).astype(np.int32)
|
|
2205
|
+
m = m[yi][:, xi]
|
|
2206
|
+
a = m.astype(np.float32, copy=False) * float(getattr(self, "_mask_overlay_alpha", 0.35))
|
|
2207
|
+
bf = buf8.astype(np.float32, copy=False)
|
|
2208
|
+
bf[..., 0] = np.clip(bf[..., 0] + (255.0 - bf[..., 0]) * a, 0.0, 255.0)
|
|
2209
|
+
buf8 = bf.astype(np.uint8, copy=False)
|
|
2210
|
+
|
|
2211
|
+
# ---------------------------------------
|
|
2212
|
+
# 6) Wrap into QImage (keep buffer alive)
|
|
2213
|
+
# ---------------------------------------
|
|
2214
|
+
if buf8.dtype != np.uint8:
|
|
2215
|
+
buf8 = buf8.astype(np.uint8)
|
|
2216
|
+
buf8 = ensure_contiguous(buf8)
|
|
2217
|
+
h, w, c = buf8.shape
|
|
2218
|
+
# Be explicit. RGB888 means 3 bytes per pixel, full stop.
|
|
2219
|
+
bytes_per_line = int(w * 3)
|
|
2220
|
+
|
|
2221
|
+
self._buf8 = buf8 # keep alive
|
|
2222
|
+
|
|
2223
|
+
try:
|
|
2224
|
+
addr = int(self._buf8.ctypes.data)
|
|
2225
|
+
ptr = sip.voidptr(addr)
|
|
2226
|
+
qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
|
|
2227
|
+
# Defensive: if Qt ever decides the buffer looks wrong, force-copy once
|
|
2228
|
+
if qimg is None or qimg.isNull():
|
|
2229
|
+
raise RuntimeError("QImage null")
|
|
2230
|
+
except Exception:
|
|
2231
|
+
# One safe fall-back copy (still fast, avoids crashes)
|
|
2232
|
+
buf8c = np.array(self._buf8, copy=True, order="C")
|
|
2233
|
+
self._buf8 = buf8c
|
|
2234
|
+
addr = int(self._buf8.ctypes.data)
|
|
2235
|
+
ptr = sip.voidptr(addr)
|
|
2236
|
+
qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
|
|
2237
|
+
|
|
2238
|
+
self._qimg_src = qimg
|
|
2239
|
+
if qimg is None or qimg.isNull():
|
|
2240
|
+
self.label.clear()
|
|
2241
|
+
return
|
|
2242
|
+
|
|
2243
|
+
# ---------------------------------------
|
|
2244
|
+
# 7) Scale & present
|
|
2245
|
+
# ---------------------------------------
|
|
2246
|
+
sw = max(1, int(qimg.width() * self.scale))
|
|
2247
|
+
sh = max(1, int(qimg.height() * self.scale))
|
|
2248
|
+
scaled = qimg.scaled(
|
|
2249
|
+
sw, sh,
|
|
2250
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
2251
|
+
Qt.TransformationMode.SmoothTransformation
|
|
2252
|
+
)
|
|
2253
|
+
|
|
2254
|
+
# ── NEW: WCS grid overlay (draw on the scaled pixmap so lines stay 1px) ──
|
|
2255
|
+
if getattr(self, "_show_wcs_grid", False):
|
|
2256
|
+
wcs2 = self._get_celestial_wcs()
|
|
2257
|
+
if wcs2 is not None:
|
|
2258
|
+
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
|
|
2259
|
+
from PyQt6.QtCore import QSettings
|
|
2260
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
2261
|
+
import numpy as _np
|
|
2262
|
+
|
|
2263
|
+
pm = QPixmap.fromImage(scaled)
|
|
2264
|
+
|
|
2265
|
+
# Read user prefs (fallback to defaults if not set)
|
|
2266
|
+
_settings = getattr(self, "_settings", None) or QSettings()
|
|
2267
|
+
pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
|
|
2268
|
+
pref_mode = _settings.value("wcs_grid/mode", "auto", type=str) # "auto" | "fixed"
|
|
2269
|
+
pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str) # "deg" | "arcmin"
|
|
2270
|
+
pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
|
|
2271
|
+
|
|
2272
|
+
if not pref_enabled:
|
|
2273
|
+
# User disabled the grid in Preferences — skip overlay
|
|
2274
|
+
self.label.setPixmap(QPixmap.fromImage(scaled))
|
|
2275
|
+
self.label.resize(scaled.size())
|
|
2276
|
+
return
|
|
2277
|
+
|
|
2278
|
+
display_h, display_w = base_img.shape[:2]
|
|
2279
|
+
|
|
2280
|
+
# Pixel scales and FOV using celestial WCS
|
|
2281
|
+
px_scales_deg = proj_plane_pixel_scales(wcs2) # deg/pix for the two celestial axes
|
|
2282
|
+
px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
|
|
2283
|
+
|
|
2284
|
+
H_full, W_full = display_h, display_w
|
|
2285
|
+
fov_deg = px_deg * float(max(W_full, H_full))
|
|
2286
|
+
|
|
2287
|
+
# Choose grid spacing from prefs (or auto heuristic)
|
|
2288
|
+
if pref_mode == "fixed":
|
|
2289
|
+
step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
|
|
2290
|
+
step_deg = max(1e-6, min(step_deg, 90.0)) # clamp to sane range
|
|
2291
|
+
else:
|
|
2292
|
+
# Auto spacing (your previous logic)
|
|
2293
|
+
nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
|
|
2294
|
+
target_lines = 8
|
|
2295
|
+
desired = max(fov_deg / target_lines, px_deg * 100)
|
|
2296
|
+
step_deg = min((n for n in nice if n >= desired), default=30)
|
|
2297
|
+
|
|
2298
|
+
# World rect from image corners using celestial WCS
|
|
2299
|
+
corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
|
|
2300
|
+
try:
|
|
2301
|
+
ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
|
|
2302
|
+
ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
|
|
2303
|
+
dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
|
|
2304
|
+
if ra_max - ra_min > 300:
|
|
2305
|
+
ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
|
|
2306
|
+
ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
|
|
2307
|
+
ra_shift = 180.0
|
|
2308
|
+
else:
|
|
2309
|
+
ra_shift = 0.0
|
|
2310
|
+
except Exception:
|
|
2311
|
+
ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
|
|
2312
|
+
|
|
2313
|
+
p = QPainter(pm)
|
|
2314
|
+
pen = QPen(); pen.setWidth(1); pen.setColor(QColor(255, 255, 255, 140))
|
|
2315
|
+
p.setPen(pen)
|
|
2316
|
+
s = float(self.scale)
|
|
2317
|
+
img_w = int(W_full * s)
|
|
2318
|
+
img_h = int(H_full * s)
|
|
2319
|
+
Wf, Hf = float(W_full), float(H_full)
|
|
2320
|
+
margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
|
|
2321
|
+
def draw_world_poly(xs_world, ys_world):
|
|
2322
|
+
try:
|
|
2323
|
+
px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
|
|
2324
|
+
except Exception:
|
|
2325
|
+
return
|
|
2326
|
+
|
|
2327
|
+
px = _np.asarray(px, dtype=float)
|
|
2328
|
+
py = _np.asarray(py, dtype=float)
|
|
2329
|
+
|
|
2330
|
+
# --- validity mask ---
|
|
2331
|
+
ok = _np.isfinite(px) & _np.isfinite(py)
|
|
2332
|
+
|
|
2333
|
+
# Allow a margin around the image so near-edge lines still draw
|
|
2334
|
+
margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
|
|
2335
|
+
ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
|
|
2336
|
+
ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
|
|
2337
|
+
|
|
2338
|
+
for i in range(1, len(px)):
|
|
2339
|
+
if not (ok[i-1] and ok[i]):
|
|
2340
|
+
continue
|
|
2341
|
+
|
|
2342
|
+
x0 = float(px[i-1]) * s
|
|
2343
|
+
y0 = float(py[i-1]) * s
|
|
2344
|
+
x1 = float(px[i]) * s
|
|
2345
|
+
y1 = float(py[i]) * s
|
|
2346
|
+
|
|
2347
|
+
# Final sanity gate before int() -> Qt 32-bit
|
|
2348
|
+
if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
|
|
2349
|
+
continue
|
|
2350
|
+
|
|
2351
|
+
p.drawLine(int(x0), int(y0), int(x1), int(y1))
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
|
|
2355
|
+
ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
|
|
2356
|
+
dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
|
|
2357
|
+
|
|
2358
|
+
# DEC lines (horiz-ish)
|
|
2359
|
+
def _frange(a,b,s):
|
|
2360
|
+
out=[]; x=a
|
|
2361
|
+
while x <= b + 1e-9:
|
|
2362
|
+
out.append(x); x += s
|
|
2363
|
+
return out
|
|
2364
|
+
def _round_to(x,s): return s * round(x/s)
|
|
2365
|
+
|
|
2366
|
+
ra_start = _round_to(ra_min, step_deg)
|
|
2367
|
+
dec_start = _round_to(dec_min, step_deg)
|
|
2368
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2369
|
+
dec_arr = _np.full_like(ra_samples_wrapped, dec)
|
|
2370
|
+
draw_world_poly(ra_samples_wrapped, dec_arr)
|
|
2371
|
+
|
|
2372
|
+
# RA lines (vert-ish)
|
|
2373
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2374
|
+
ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
|
|
2375
|
+
draw_world_poly(ra_arr, dec_samples)
|
|
2376
|
+
|
|
2377
|
+
# ── LABELS for RA/Dec lines ─────────────────────────────────
|
|
2378
|
+
# Font & box style
|
|
2379
|
+
font = QFont(); font.setPixelSize(11) # screen-consistent
|
|
2380
|
+
p.setFont(font)
|
|
2381
|
+
text_pen = QPen(QColor(255, 255, 255, 230))
|
|
2382
|
+
box_brush = QBrush(QColor(0, 0, 0, 140))
|
|
2383
|
+
p.setPen(text_pen)
|
|
2384
|
+
|
|
2385
|
+
def _draw_label(x, y, txt, anchor="lt"):
|
|
2386
|
+
if not _np.isfinite([x, y]).all():
|
|
2387
|
+
return
|
|
2388
|
+
fm = p.fontMetrics()
|
|
2389
|
+
wtxt = fm.horizontalAdvance(txt) + 6
|
|
2390
|
+
htxt = fm.height() + 4
|
|
2391
|
+
|
|
2392
|
+
# initial placement with a little padding
|
|
2393
|
+
if anchor == "lt": # left-top
|
|
2394
|
+
rx, ry = int(x) + 4, int(y) + 3
|
|
2395
|
+
elif anchor == "rt": # right-top
|
|
2396
|
+
rx, ry = int(x) - wtxt - 4, int(y) + 3
|
|
2397
|
+
elif anchor == "lb": # left-bottom
|
|
2398
|
+
rx, ry = int(x) + 4, int(y) - htxt - 3
|
|
2399
|
+
else: # center-top
|
|
2400
|
+
rx, ry = int(x) - wtxt // 2, int(y) + 3
|
|
2401
|
+
|
|
2402
|
+
# clamp entirely inside the image
|
|
2403
|
+
rx = max(0, min(rx, img_w - wtxt - 1))
|
|
2404
|
+
ry = max(0, min(ry, img_h - htxt - 1))
|
|
2405
|
+
|
|
2406
|
+
rect = QRect(rx, ry, wtxt, htxt)
|
|
2407
|
+
p.save()
|
|
2408
|
+
p.setBrush(box_brush)
|
|
2409
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
2410
|
+
p.drawRoundedRect(rect, 4, 4)
|
|
2411
|
+
p.restore()
|
|
2412
|
+
p.drawText(rect.adjusted(3, 2, -3, -2),
|
|
2413
|
+
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
# DEC labels on left edge
|
|
2417
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2418
|
+
try:
|
|
2419
|
+
x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
|
|
2420
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2421
|
+
continue
|
|
2422
|
+
# clamp to image bounds before scaling
|
|
2423
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2424
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2425
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
|
|
2426
|
+
except Exception:
|
|
2427
|
+
pass
|
|
2428
|
+
|
|
2429
|
+
# RA labels on top edge
|
|
2430
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2431
|
+
ra_wrapped = (ra + ra_shift) % 360.0
|
|
2432
|
+
try:
|
|
2433
|
+
x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
|
|
2434
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2435
|
+
continue
|
|
2436
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2437
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2438
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
|
|
2439
|
+
except Exception:
|
|
2440
|
+
pass
|
|
2441
|
+
|
|
2442
|
+
p.end()
|
|
2443
|
+
scaled = pm.toImage()
|
|
2444
|
+
|
|
2445
|
+
# ── end WCS grid overlay ────────────────────────────────────────────────
|
|
2446
|
+
|
|
2447
|
+
self.label.setPixmap(QPixmap.fromImage(scaled))
|
|
2448
|
+
self.label.resize(scaled.size())
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
def has_active_preview(self) -> bool:
|
|
2453
|
+
return self._active_source_kind == "preview" and self._active_preview_id is not None
|
|
2454
|
+
|
|
2455
|
+
def current_preview_roi(self) -> tuple[int,int,int,int] | None:
|
|
2456
|
+
"""
|
|
2457
|
+
Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
|
|
2458
|
+
"""
|
|
2459
|
+
if not self.has_active_preview():
|
|
2460
|
+
return None
|
|
2461
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2462
|
+
return None if src is None else tuple(src["roi"])
|
|
2463
|
+
|
|
2464
|
+
def current_preview_name(self) -> str | None:
|
|
2465
|
+
if not self.has_active_preview():
|
|
2466
|
+
return None
|
|
2467
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2468
|
+
return None if src is None else src["name"]
|
|
2469
|
+
|
|
2470
|
+
|
|
2471
|
+
# ---------- interaction ----------
|
|
2472
|
+
def _zoom_at_anchor(self, factor: float):
|
|
2473
|
+
if self._qimg_src is None:
|
|
2474
|
+
return
|
|
2475
|
+
old_scale = self.scale
|
|
2476
|
+
# clamp with new max
|
|
2477
|
+
new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
|
|
2478
|
+
if abs(new_scale - old_scale) < 1e-8:
|
|
2479
|
+
return
|
|
2480
|
+
|
|
2481
|
+
vp = self.scroll.viewport()
|
|
2482
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
2483
|
+
vbar = self.scroll.verticalScrollBar()
|
|
2484
|
+
|
|
2485
|
+
# Anchor in viewport coordinates via global cursor (robust)
|
|
2486
|
+
try:
|
|
2487
|
+
anchor_vp = vp.mapFromGlobal(QCursor.pos())
|
|
2488
|
+
except Exception:
|
|
2489
|
+
anchor_vp = None
|
|
2490
|
+
|
|
2491
|
+
if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
|
|
2492
|
+
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
2493
|
+
|
|
2494
|
+
# Current label coords under the anchor
|
|
2495
|
+
x_label_pre = hbar.value() + anchor_vp.x()
|
|
2496
|
+
y_label_pre = vbar.value() + anchor_vp.y()
|
|
2497
|
+
|
|
2498
|
+
# Convert to image coords at old scale
|
|
2499
|
+
xi = x_label_pre / max(old_scale, 1e-12)
|
|
2500
|
+
yi = y_label_pre / max(old_scale, 1e-12)
|
|
2501
|
+
|
|
2502
|
+
# Apply scale and redraw (updates label size + scrollbar ranges)
|
|
2503
|
+
self.scale = new_scale
|
|
2504
|
+
self._render(rebuild=False)
|
|
2505
|
+
|
|
2506
|
+
# Reproject that image point to label coords at new scale
|
|
2507
|
+
x_label_post = xi * new_scale
|
|
2508
|
+
y_label_post = yi * new_scale
|
|
2509
|
+
|
|
2510
|
+
# Desired scrollbar values to keep point under the cursor
|
|
2511
|
+
new_h = int(round(x_label_post - anchor_vp.x()))
|
|
2512
|
+
new_v = int(round(y_label_post - anchor_vp.y()))
|
|
2513
|
+
|
|
2514
|
+
# Clamp to valid range
|
|
2515
|
+
new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
|
|
2516
|
+
new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
|
|
2517
|
+
|
|
2518
|
+
# Apply
|
|
2519
|
+
hbar.setValue(new_h)
|
|
2520
|
+
vbar.setValue(new_v)
|
|
2521
|
+
self._schedule_emit_view_transform()
|
|
2522
|
+
|
|
2523
|
+
def _find_main_window(self):
|
|
2524
|
+
p = self.parent()
|
|
2525
|
+
while p is not None and not hasattr(p, "docman"):
|
|
2526
|
+
p = p.parent()
|
|
2527
|
+
return p
|
|
2528
|
+
|
|
2529
|
+
def eventFilter(self, obj, ev):
|
|
2530
|
+
is_on_view = (obj is self.label) or (obj is self.scroll.viewport())
|
|
2531
|
+
|
|
2532
|
+
# 0) PREVIEW-SELECT MODE: consume mouse events first so earlier branches don't steal them
|
|
2533
|
+
if self._preview_select_mode and is_on_view:
|
|
2534
|
+
vp = self.scroll.viewport()
|
|
2535
|
+
if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
|
|
2536
|
+
vp_pos = obj.mapTo(vp, ev.pos())
|
|
2537
|
+
self._rubber_origin = vp_pos
|
|
2538
|
+
if self._rubber is None:
|
|
2539
|
+
self._rubber = QRubberBand(QRubberBand.Shape.Rectangle, vp)
|
|
2540
|
+
self._rubber.setGeometry(QRect(self._rubber_origin, QSize(1, 1)))
|
|
2541
|
+
self._rubber.show()
|
|
2542
|
+
ev.accept(); return True
|
|
2543
|
+
|
|
2544
|
+
if ev.type() == QEvent.Type.MouseMove and self._rubber is not None and self._rubber_origin is not None:
|
|
2545
|
+
vp_pos = obj.mapTo(vp, ev.pos())
|
|
2546
|
+
rect = QRect(self._rubber_origin, vp_pos).normalized()
|
|
2547
|
+
self._rubber.setGeometry(rect)
|
|
2548
|
+
ev.accept(); return True
|
|
2549
|
+
|
|
2550
|
+
if ev.type() == QEvent.Type.MouseButtonRelease and self._rubber is not None and self._rubber_origin is not None:
|
|
2551
|
+
vp_pos = obj.mapTo(vp, ev.pos())
|
|
2552
|
+
rect = QRect(self._rubber_origin, vp_pos).normalized()
|
|
2553
|
+
self._finish_preview_rect(rect)
|
|
2554
|
+
ev.accept(); return True
|
|
2555
|
+
# don’t swallow unrelated events
|
|
2556
|
+
|
|
2557
|
+
# 1) Ctrl + wheel → zoom
|
|
2558
|
+
if ev.type() == QEvent.Type.Wheel:
|
|
2559
|
+
if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
|
2560
|
+
factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
|
|
2561
|
+
self._zoom_at_anchor(factor)
|
|
2562
|
+
return True
|
|
2563
|
+
return False
|
|
2564
|
+
|
|
2565
|
+
# 2) Space+click → start readout
|
|
2566
|
+
if ev.type() == QEvent.Type.MouseButtonPress:
|
|
2567
|
+
if self._space_down and ev.button() == Qt.MouseButton.LeftButton:
|
|
2568
|
+
vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
|
|
2569
|
+
res = self._sample_image_at_viewport_pos(vp_pos)
|
|
2570
|
+
if res is not None:
|
|
2571
|
+
xi, yi, sample = res
|
|
2572
|
+
self._show_readout(xi, yi, sample)
|
|
2573
|
+
self._readout_dragging = True
|
|
2574
|
+
return True
|
|
2575
|
+
return False
|
|
2576
|
+
|
|
2577
|
+
# 3) Space+drag → live readout
|
|
2578
|
+
if ev.type() == QEvent.Type.MouseMove:
|
|
2579
|
+
if self._readout_dragging:
|
|
2580
|
+
vp_pos = obj.mapTo(self.scroll.viewport(), ev.pos())
|
|
2581
|
+
res = self._sample_image_at_viewport_pos(vp_pos)
|
|
2582
|
+
if res is not None:
|
|
2583
|
+
xi, yi, sample = res
|
|
2584
|
+
self._show_readout(xi, yi, sample)
|
|
2585
|
+
return True
|
|
2586
|
+
return False
|
|
2587
|
+
|
|
2588
|
+
# 4) Release → stop live readout
|
|
2589
|
+
if ev.type() == QEvent.Type.MouseButtonRelease:
|
|
2590
|
+
if self._readout_dragging:
|
|
2591
|
+
self._readout_dragging = False
|
|
2592
|
+
return True
|
|
2593
|
+
return False
|
|
2594
|
+
|
|
2595
|
+
return super().eventFilter(obj, ev)
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def _finish_preview_rect(self, vp_rect: QRect):
|
|
2599
|
+
# Map viewport rectangle into image coordinates
|
|
2600
|
+
if vp_rect.width() < 4 or vp_rect.height() < 4:
|
|
2601
|
+
self._cancel_rubber()
|
|
2602
|
+
return
|
|
2603
|
+
|
|
2604
|
+
hbar = self.scroll.horizontalScrollBar()
|
|
2605
|
+
vbar = self.scroll.verticalScrollBar()
|
|
2606
|
+
|
|
2607
|
+
# Upper-left in label coords
|
|
2608
|
+
x_label0 = hbar.value() + vp_rect.left()
|
|
2609
|
+
y_label0 = vbar.value() + vp_rect.top()
|
|
2610
|
+
x_label1 = hbar.value() + vp_rect.right()
|
|
2611
|
+
y_label1 = vbar.value() + vp_rect.bottom()
|
|
2612
|
+
|
|
2613
|
+
s = max(self.scale, 1e-12)
|
|
2614
|
+
|
|
2615
|
+
x0 = int(round(x_label0 / s))
|
|
2616
|
+
y0 = int(round(y_label0 / s))
|
|
2617
|
+
x1 = int(round(x_label1 / s))
|
|
2618
|
+
y1 = int(round(y_label1 / s))
|
|
2619
|
+
|
|
2620
|
+
if x1 <= x0 or y1 <= y0:
|
|
2621
|
+
self._cancel_rubber()
|
|
2622
|
+
return
|
|
2623
|
+
|
|
2624
|
+
roi = (x0, y0, x1 - x0, y1 - y0)
|
|
2625
|
+
self._create_preview_from_roi(roi)
|
|
2626
|
+
self._cancel_rubber()
|
|
2627
|
+
|
|
2628
|
+
def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
|
|
2629
|
+
"""
|
|
2630
|
+
roi: (x, y, w, h) in FULL IMAGE coordinates
|
|
2631
|
+
"""
|
|
2632
|
+
arr = np.asarray(self.document.image)
|
|
2633
|
+
H, W = (arr.shape[0], arr.shape[1]) if arr.ndim >= 2 else (0, 0)
|
|
2634
|
+
x, y, w, h = roi
|
|
2635
|
+
# clamp to image bounds
|
|
2636
|
+
x = max(0, min(x, max(0, W-1)))
|
|
2637
|
+
y = max(0, min(y, max(0, H-1)))
|
|
2638
|
+
w = max(1, min(w, W - x))
|
|
2639
|
+
h = max(1, min(h, H - y))
|
|
2640
|
+
|
|
2641
|
+
crop = arr[y:y+h, x:x+w].copy() # isolate for preview
|
|
2642
|
+
|
|
2643
|
+
pid = self._next_preview_id
|
|
2644
|
+
self._next_preview_id += 1
|
|
2645
|
+
name = f"Preview {pid} ({w}×{h})"
|
|
2646
|
+
|
|
2647
|
+
self._previews.append({"id": pid, "name": name, "roi": (x, y, w, h), "arr": crop})
|
|
2648
|
+
|
|
2649
|
+
# Build a tab with a simple QLabel viewer (reuses global rendering through _render)
|
|
2650
|
+
host = QWidget(self)
|
|
2651
|
+
l = QVBoxLayout(host); l.setContentsMargins(0,0,0,0)
|
|
2652
|
+
# For simplicity, we reuse the SAME scroll/label pipeline; the source image is switched in _render
|
|
2653
|
+
# but we still want a local label so the tab displays something. Make a tiny label holder:
|
|
2654
|
+
holder = QLabel(" ") # placeholder; we still render into self.label (single view)
|
|
2655
|
+
holder.setMinimumHeight(1)
|
|
2656
|
+
l.addWidget(holder)
|
|
2657
|
+
|
|
2658
|
+
host._preview_id = pid # attach id for lookups
|
|
2659
|
+
idx = self._tabs.addTab(host, name)
|
|
2660
|
+
self._tabs.setCurrentIndex(idx)
|
|
2661
|
+
self._tabs.tabBar().setVisible(True) # show tabs when first preview appears
|
|
2662
|
+
|
|
2663
|
+
# Switch active source and redraw
|
|
2664
|
+
self._active_source_kind = "preview"
|
|
2665
|
+
self._active_preview_id = pid
|
|
2666
|
+
self._render(True)
|
|
2667
|
+
self._update_replay_button()
|
|
2668
|
+
mw = self._find_main_window()
|
|
2669
|
+
if mw is not None and getattr(mw, "_auto_fit_on_resize", False):
|
|
2670
|
+
try:
|
|
2671
|
+
mw._zoom_active_fit()
|
|
2672
|
+
except Exception:
|
|
2673
|
+
pass
|
|
2674
|
+
|
|
2675
|
+
def mousePressEvent(self, e):
|
|
2676
|
+
# If we're defining a preview ROI, don't start panning here
|
|
2677
|
+
if self._preview_select_mode:
|
|
2678
|
+
e.ignore() # let the eventFilter (label/viewport) handle it
|
|
2679
|
+
return
|
|
2680
|
+
|
|
2681
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
2682
|
+
if self._space_down:
|
|
2683
|
+
vp = self.scroll.viewport()
|
|
2684
|
+
vp_pos = vp.mapFrom(self, e.pos())
|
|
2685
|
+
res = self._sample_image_at_viewport_pos(vp_pos)
|
|
2686
|
+
if res is not None:
|
|
2687
|
+
xi, yi, sample = res
|
|
2688
|
+
self._show_readout(xi, yi, sample)
|
|
2689
|
+
self._readout_dragging = True
|
|
2690
|
+
return
|
|
2691
|
+
|
|
2692
|
+
# normal pan mode
|
|
2693
|
+
self._dragging = True
|
|
2694
|
+
self._pan_live = True
|
|
2695
|
+
self._drag_start = e.pos()
|
|
2696
|
+
|
|
2697
|
+
# NEW: emit once at drag start so linked views sync instantly
|
|
2698
|
+
self._emit_view_transform()
|
|
2699
|
+
return
|
|
2700
|
+
|
|
2701
|
+
super().mousePressEvent(e)
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def _show_readout(self, xi, yi, sample):
|
|
2706
|
+
mw = self._find_main_window()
|
|
2707
|
+
if mw is None:
|
|
2708
|
+
return
|
|
2709
|
+
|
|
2710
|
+
# We want raw float prints, never 16-bit normalized
|
|
2711
|
+
r = g = b = None
|
|
2712
|
+
k = None
|
|
2713
|
+
|
|
2714
|
+
if isinstance(sample, dict):
|
|
2715
|
+
# 1) the clean mono path
|
|
2716
|
+
if "mono" in sample:
|
|
2717
|
+
try:
|
|
2718
|
+
k = float(sample["mono"])
|
|
2719
|
+
except Exception:
|
|
2720
|
+
k = sample["mono"]
|
|
2721
|
+
# 2) the clean RGB path
|
|
2722
|
+
elif all(ch in sample for ch in ("r", "g", "b")):
|
|
2723
|
+
try:
|
|
2724
|
+
r = float(sample["r"])
|
|
2725
|
+
g = float(sample["g"])
|
|
2726
|
+
b = float(sample["b"])
|
|
2727
|
+
except Exception:
|
|
2728
|
+
r = sample["r"]; g = sample["g"]; b = sample["b"]
|
|
2729
|
+
else:
|
|
2730
|
+
# 3) weird dict → just take the first numeric-looking value
|
|
2731
|
+
for v in sample.values():
|
|
2732
|
+
try:
|
|
2733
|
+
k = float(v)
|
|
2734
|
+
break
|
|
2735
|
+
except Exception:
|
|
2736
|
+
continue
|
|
2737
|
+
|
|
2738
|
+
elif isinstance(sample, (list, tuple)):
|
|
2739
|
+
if len(sample) == 1:
|
|
2740
|
+
try:
|
|
2741
|
+
k = float(sample[0])
|
|
2742
|
+
except Exception:
|
|
2743
|
+
k = sample[0]
|
|
2744
|
+
elif len(sample) >= 3:
|
|
2745
|
+
try:
|
|
2746
|
+
r = float(sample[0]); g = float(sample[1]); b = float(sample[2])
|
|
2747
|
+
except Exception:
|
|
2748
|
+
r, g, b = sample[0], sample[1], sample[2]
|
|
2749
|
+
|
|
2750
|
+
else:
|
|
2751
|
+
# numpy scalar / plain number
|
|
2752
|
+
try:
|
|
2753
|
+
k = float(sample)
|
|
2754
|
+
except Exception:
|
|
2755
|
+
k = sample
|
|
2756
|
+
|
|
2757
|
+
msg = f"x={xi} y={yi}"
|
|
2758
|
+
|
|
2759
|
+
if r is not None and g is not None and b is not None:
|
|
2760
|
+
msg += f" R={r:.6f} G={g:.6f} B={b:.6f}"
|
|
2761
|
+
elif k is not None:
|
|
2762
|
+
msg += f" K={k:.6f}"
|
|
2763
|
+
else:
|
|
2764
|
+
# final fallback if everything was weird
|
|
2765
|
+
msg += " K=?"
|
|
2766
|
+
|
|
2767
|
+
# ---- WCS ----
|
|
2768
|
+
wcs2 = self._get_celestial_wcs()
|
|
2769
|
+
if wcs2 is not None:
|
|
2770
|
+
try:
|
|
2771
|
+
ra_deg, dec_deg = map(float, wcs2.pixel_to_world_values(float(xi), float(yi)))
|
|
2772
|
+
|
|
2773
|
+
# RA
|
|
2774
|
+
ra_h = ra_deg / 15.0
|
|
2775
|
+
ra_hh = int(ra_h)
|
|
2776
|
+
ra_mm = int((ra_h - ra_hh) * 60.0)
|
|
2777
|
+
ra_ss = ((ra_h - ra_hh) * 60.0 - ra_mm) * 60.0
|
|
2778
|
+
|
|
2779
|
+
# Dec
|
|
2780
|
+
sign = "+" if dec_deg >= 0 else "-"
|
|
2781
|
+
d = abs(dec_deg)
|
|
2782
|
+
dec_dd = int(d)
|
|
2783
|
+
dec_mm = int((d - dec_dd) * 60.0)
|
|
2784
|
+
dec_ss = ((d - dec_dd) * 60.0 - dec_mm) * 60.0
|
|
2785
|
+
|
|
2786
|
+
msg += (
|
|
2787
|
+
f" RA={ra_hh:02d}:{ra_mm:02d}:{ra_ss:05.2f}"
|
|
2788
|
+
f" Dec={sign}{dec_dd:02d}:{dec_mm:02d}:{dec_ss:05.2f}"
|
|
2789
|
+
)
|
|
2790
|
+
except Exception:
|
|
2791
|
+
pass
|
|
2792
|
+
|
|
2793
|
+
mw.statusBar().showMessage(msg)
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
# 1) helper to build ROI-adjusted WCS (keeps projection/rotation/CD/PC intact)
|
|
2798
|
+
def _wcs_for_roi(self, base_wcs, roi, arr_shape=None):
|
|
2799
|
+
# roi = (x, y, w, h) in FULL-image pixel coords
|
|
2800
|
+
import numpy as np
|
|
2801
|
+
if base_wcs is None or roi is None:
|
|
2802
|
+
return base_wcs
|
|
2803
|
+
x, y, w, h = map(int, roi)
|
|
2804
|
+
wnew = base_wcs.deepcopy()
|
|
2805
|
+
# shift reference pixel into the cropped frame
|
|
2806
|
+
wnew.wcs.crpix = wnew.wcs.crpix - np.array([float(x), float(y)], dtype=float)
|
|
2807
|
+
# tell astropy the new image size for grid/edge computations
|
|
2808
|
+
try:
|
|
2809
|
+
wnew.array_shape = (h, w)
|
|
2810
|
+
wnew.pixel_shape = (w, h)
|
|
2811
|
+
except Exception:
|
|
2812
|
+
pass
|
|
2813
|
+
# prefer 2-D celestial
|
|
2814
|
+
try:
|
|
2815
|
+
cel = getattr(wnew, "celestial", None)
|
|
2816
|
+
if cel is not None and getattr(cel, "naxis", 2) == 2:
|
|
2817
|
+
return cel
|
|
2818
|
+
except Exception:
|
|
2819
|
+
pass
|
|
2820
|
+
return wnew
|
|
2821
|
+
|
|
2822
|
+
|
|
2823
|
+
# 2) make _get_celestial_wcs ROI-aware
|
|
2824
|
+
def _get_celestial_wcs(self):
|
|
2825
|
+
"""
|
|
2826
|
+
Return the *correct* celestial WCS for whatever the user is actually
|
|
2827
|
+
seeing in this view.
|
|
2828
|
+
|
|
2829
|
+
- On the Full tab: just use the document's WCS / header.
|
|
2830
|
+
- On a Preview tab: prefer the ROI backing doc's WCS from DocManager.
|
|
2831
|
+
If that's not available, synthesize a cropped header from the base
|
|
2832
|
+
header + preview ROI via _compute_cropped_wcs().
|
|
2833
|
+
"""
|
|
2834
|
+
doc = getattr(self, "document", None)
|
|
2835
|
+
if doc is None:
|
|
2836
|
+
return None
|
|
2837
|
+
|
|
2838
|
+
# -----------------------------
|
|
2839
|
+
# FULL IMAGE (no preview active)
|
|
2840
|
+
# -----------------------------
|
|
2841
|
+
if not self.has_active_preview():
|
|
2842
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
2843
|
+
w = meta.get("wcs")
|
|
2844
|
+
if isinstance(w, _AstroWCS):
|
|
2845
|
+
try:
|
|
2846
|
+
wc = getattr(w, "celestial", None)
|
|
2847
|
+
return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
|
|
2848
|
+
except Exception:
|
|
2849
|
+
return w
|
|
2850
|
+
|
|
2851
|
+
hdr = (
|
|
2852
|
+
meta.get("original_header")
|
|
2853
|
+
or meta.get("fits_header")
|
|
2854
|
+
or meta.get("header")
|
|
2855
|
+
)
|
|
2856
|
+
if hdr is None:
|
|
2857
|
+
return None
|
|
2858
|
+
|
|
2859
|
+
w = build_celestial_wcs(hdr)
|
|
2860
|
+
if w is not None:
|
|
2861
|
+
meta["wcs"] = w
|
|
2862
|
+
return w
|
|
2863
|
+
|
|
2864
|
+
# -----------------------------
|
|
2865
|
+
# PREVIEW TAB (ROI view)
|
|
2866
|
+
# -----------------------------
|
|
2867
|
+
roi = self.current_preview_roi()
|
|
2868
|
+
if roi is None:
|
|
2869
|
+
return None
|
|
2870
|
+
|
|
2871
|
+
# Base document is the full image doc; backing_doc may be the ROI doc
|
|
2872
|
+
base_doc = getattr(self, "base_document", None) or doc
|
|
2873
|
+
base_meta = getattr(base_doc, "metadata", {}) or {}
|
|
2874
|
+
|
|
2875
|
+
dm = getattr(self, "_docman", None)
|
|
2876
|
+
backing_doc = None
|
|
2877
|
+
if dm is not None:
|
|
2878
|
+
try:
|
|
2879
|
+
backing_doc = dm.get_document_for_view(self)
|
|
2880
|
+
except Exception:
|
|
2881
|
+
backing_doc = None
|
|
2882
|
+
|
|
2883
|
+
# 1) If DocManager has a separate ROI doc for this view, use ITS WCS
|
|
2884
|
+
if backing_doc is not None and backing_doc is not base_doc:
|
|
2885
|
+
bmeta = getattr(backing_doc, "metadata", {}) or {}
|
|
2886
|
+
w = bmeta.get("wcs")
|
|
2887
|
+
if isinstance(w, _AstroWCS):
|
|
2888
|
+
try:
|
|
2889
|
+
wc = getattr(w, "celestial", None)
|
|
2890
|
+
return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else w
|
|
2891
|
+
except Exception:
|
|
2892
|
+
return w
|
|
2893
|
+
|
|
2894
|
+
hdr = (
|
|
2895
|
+
bmeta.get("original_header")
|
|
2896
|
+
or bmeta.get("fits_header")
|
|
2897
|
+
or bmeta.get("header")
|
|
2898
|
+
)
|
|
2899
|
+
if hdr is not None:
|
|
2900
|
+
w = build_celestial_wcs(hdr)
|
|
2901
|
+
if w is not None:
|
|
2902
|
+
bmeta["wcs"] = w
|
|
2903
|
+
return w
|
|
2904
|
+
|
|
2905
|
+
# 2) Fallback: synthesize cropped WCS from base header + ROI
|
|
2906
|
+
hdr_full = (
|
|
2907
|
+
base_meta.get("original_header")
|
|
2908
|
+
or base_meta.get("fits_header")
|
|
2909
|
+
or base_meta.get("header")
|
|
2910
|
+
)
|
|
2911
|
+
if hdr_full is None:
|
|
2912
|
+
return None
|
|
2913
|
+
|
|
2914
|
+
cache_key = f"_preview_wcs_{self._active_preview_id}"
|
|
2915
|
+
cached = base_meta.get(cache_key)
|
|
2916
|
+
if isinstance(cached, _AstroWCS):
|
|
2917
|
+
try:
|
|
2918
|
+
wc = getattr(cached, "celestial", None)
|
|
2919
|
+
return wc if (wc is not None and getattr(wc, "naxis", 2) == 2) else cached
|
|
2920
|
+
except Exception:
|
|
2921
|
+
pass
|
|
2922
|
+
|
|
2923
|
+
try:
|
|
2924
|
+
x, y, w, h = map(int, roi)
|
|
2925
|
+
cropped_hdr = _compute_cropped_wcs(hdr_full, x, y, w, h)
|
|
2926
|
+
wcs = build_celestial_wcs(cropped_hdr)
|
|
2927
|
+
except Exception:
|
|
2928
|
+
wcs = None
|
|
2929
|
+
|
|
2930
|
+
if wcs is not None:
|
|
2931
|
+
base_meta[cache_key] = wcs
|
|
2932
|
+
return wcs
|
|
2933
|
+
|
|
2934
|
+
|
|
2935
|
+
def _extract_wcs_from_doc(self):
|
|
2936
|
+
"""
|
|
2937
|
+
Try to get an astropy WCS from the current document or a sensible parent.
|
|
2938
|
+
Caches the resolved WCS on whichever doc we pulled it from.
|
|
2939
|
+
"""
|
|
2940
|
+
doc = getattr(self, "document", None)
|
|
2941
|
+
if doc is None:
|
|
2942
|
+
return None
|
|
2943
|
+
|
|
2944
|
+
def _try_on_meta(meta: dict):
|
|
2945
|
+
# (1) literal WCS object stored?
|
|
2946
|
+
w = meta.get("wcs")
|
|
2947
|
+
if isinstance(w, _AstroWCS):
|
|
2948
|
+
return w
|
|
2949
|
+
# (2) any header-like thing present?
|
|
2950
|
+
hdr = meta.get("original_header") or meta.get("fits_header") or meta.get("header")
|
|
2951
|
+
return build_celestial_wcs(hdr)
|
|
2952
|
+
|
|
2953
|
+
# 1) current doc (+ cache)
|
|
2954
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
2955
|
+
if "_astropy_wcs" in meta:
|
|
2956
|
+
return meta["_astropy_wcs"]
|
|
2957
|
+
w = _try_on_meta(meta)
|
|
2958
|
+
if w is not None:
|
|
2959
|
+
meta["_astropy_wcs"] = w
|
|
2960
|
+
return w
|
|
2961
|
+
|
|
2962
|
+
# 2) likely parents/sources
|
|
2963
|
+
candidates = []
|
|
2964
|
+
|
|
2965
|
+
base = getattr(self, "base_document", None)
|
|
2966
|
+
if base is not None and base is not doc:
|
|
2967
|
+
candidates.append(base)
|
|
2968
|
+
|
|
2969
|
+
dm = getattr(self, "_docman", None)
|
|
2970
|
+
if dm is not None and hasattr(dm, "get_document_for_view"):
|
|
2971
|
+
try:
|
|
2972
|
+
src = dm.get_document_for_view(self)
|
|
2973
|
+
if src is not None and src is not doc and src is not base:
|
|
2974
|
+
candidates.append(src)
|
|
2975
|
+
except Exception:
|
|
2976
|
+
pass
|
|
2977
|
+
|
|
2978
|
+
src_uid = meta.get("wcs_source_doc_uid") or meta.get("base_doc_uid")
|
|
2979
|
+
if src_uid is not None:
|
|
2980
|
+
try:
|
|
2981
|
+
from setiastro.saspro.doc_manager import DocManager
|
|
2982
|
+
reg = getattr(DocManager, "_global_registry", {})
|
|
2983
|
+
by_uid = reg.get(src_uid)
|
|
2984
|
+
if by_uid and by_uid not in candidates and by_uid is not doc and by_uid is not base:
|
|
2985
|
+
candidates.append(by_uid)
|
|
2986
|
+
except Exception:
|
|
2987
|
+
pass
|
|
2988
|
+
|
|
2989
|
+
for cand in candidates:
|
|
2990
|
+
m = getattr(cand, "metadata", {}) or {}
|
|
2991
|
+
if "_astropy_wcs" in m:
|
|
2992
|
+
meta["_astropy_wcs"] = m["_astropy_wcs"]
|
|
2993
|
+
return m["_astropy_wcs"]
|
|
2994
|
+
w = _try_on_meta(m)
|
|
2995
|
+
if w is not None:
|
|
2996
|
+
m["_astropy_wcs"] = w
|
|
2997
|
+
meta["_astropy_wcs"] = w
|
|
2998
|
+
return w
|
|
2999
|
+
|
|
3000
|
+
return None
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
|
|
3004
|
+
def mouseMoveEvent(self, e):
|
|
3005
|
+
# While defining preview ROI, let the eventFilter drive the QRubberBand
|
|
3006
|
+
if self._preview_select_mode:
|
|
3007
|
+
e.ignore()
|
|
3008
|
+
return
|
|
3009
|
+
|
|
3010
|
+
if self._readout_dragging:
|
|
3011
|
+
vp = self.scroll.viewport()
|
|
3012
|
+
vp_pos = vp.mapFrom(self, e.pos())
|
|
3013
|
+
res = self._sample_image_at_viewport_pos(vp_pos)
|
|
3014
|
+
if res is not None:
|
|
3015
|
+
xi, yi, sample = res
|
|
3016
|
+
self._show_readout(xi, yi, sample)
|
|
3017
|
+
return
|
|
3018
|
+
|
|
3019
|
+
if self._dragging:
|
|
3020
|
+
delta = e.pos() - self._drag_start
|
|
3021
|
+
self.scroll.horizontalScrollBar().setValue(self.scroll.horizontalScrollBar().value() - delta.x())
|
|
3022
|
+
self.scroll.verticalScrollBar().setValue(self.scroll.verticalScrollBar().value() - delta.y())
|
|
3023
|
+
self._drag_start = e.pos()
|
|
3024
|
+
# live emit happens via _on_scroll_changed(), but this is a nice extra nudge:
|
|
3025
|
+
self._emit_view_transform_now()
|
|
3026
|
+
return
|
|
3027
|
+
|
|
3028
|
+
super().mouseMoveEvent(e)
|
|
3029
|
+
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
def mouseReleaseEvent(self, e):
|
|
3033
|
+
if self._preview_select_mode:
|
|
3034
|
+
e.ignore(); return
|
|
3035
|
+
if e.button() == Qt.MouseButton.LeftButton:
|
|
3036
|
+
self._dragging = False
|
|
3037
|
+
self._pan_live = False # ← back to debounced mode
|
|
3038
|
+
self._readout_dragging = False
|
|
3039
|
+
self._emit_view_transform()
|
|
3040
|
+
return
|
|
3041
|
+
super().mouseReleaseEvent(e)
|
|
3042
|
+
|
|
3043
|
+
|
|
3044
|
+
def closeEvent(self, e):
|
|
3045
|
+
mw = self._find_main_window()
|
|
3046
|
+
doc = getattr(self, "document", None)
|
|
3047
|
+
|
|
3048
|
+
# If main window is force-closing (global exit accepted), don't ask.
|
|
3049
|
+
force = bool(getattr(mw, "_force_close_all", False))
|
|
3050
|
+
|
|
3051
|
+
if not force and doc is not None:
|
|
3052
|
+
# Ask only if this doc has edits
|
|
3053
|
+
should_warn = False
|
|
3054
|
+
if mw and hasattr(mw, "_document_has_edits"):
|
|
3055
|
+
should_warn = mw._document_has_edits(doc)
|
|
3056
|
+
else:
|
|
3057
|
+
# Fallback if called standalone
|
|
3058
|
+
try:
|
|
3059
|
+
should_warn = bool(doc.can_undo())
|
|
3060
|
+
except Exception:
|
|
3061
|
+
should_warn = False
|
|
3062
|
+
|
|
3063
|
+
if should_warn:
|
|
3064
|
+
r = QMessageBox.question(
|
|
3065
|
+
self, "Close Image?",
|
|
3066
|
+
"This image has edits that aren’t applied/saved.\nClose anyway?",
|
|
3067
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
3068
|
+
QMessageBox.StandardButton.No
|
|
3069
|
+
)
|
|
3070
|
+
if r != QMessageBox.StandardButton.Yes:
|
|
3071
|
+
e.ignore()
|
|
3072
|
+
return
|
|
3073
|
+
|
|
3074
|
+
try:
|
|
3075
|
+
if hasattr(self, "_docman") and self._docman is not None:
|
|
3076
|
+
self._docman.imageRegionUpdated.disconnect(self._on_doc_region_updated)
|
|
3077
|
+
# NEW: also drop the nudge hook(s)
|
|
3078
|
+
try:
|
|
3079
|
+
self._docman.imageRegionUpdated.disconnect(self._on_docman_nudge)
|
|
3080
|
+
except Exception:
|
|
3081
|
+
pass
|
|
3082
|
+
if hasattr(self._docman, "previewRepaintRequested"):
|
|
3083
|
+
try:
|
|
3084
|
+
self._docman.previewRepaintRequested.disconnect(self._on_docman_nudge)
|
|
3085
|
+
except Exception:
|
|
3086
|
+
pass
|
|
3087
|
+
except Exception:
|
|
3088
|
+
pass
|
|
3089
|
+
try:
|
|
3090
|
+
base = getattr(self, "base_document", None) or getattr(self, "document", None)
|
|
3091
|
+
if base is not None:
|
|
3092
|
+
base.changed.disconnect(self._on_base_doc_changed)
|
|
3093
|
+
except Exception:
|
|
3094
|
+
pass
|
|
3095
|
+
try:
|
|
3096
|
+
self.unlink_all()
|
|
3097
|
+
except Exception:
|
|
3098
|
+
pass
|
|
3099
|
+
try:
|
|
3100
|
+
if id(self) in ImageSubWindow._registry:
|
|
3101
|
+
ImageSubWindow._registry.pop(id(self), None)
|
|
3102
|
+
except Exception:
|
|
3103
|
+
pass
|
|
3104
|
+
# proceed with your current teardown
|
|
3105
|
+
try:
|
|
3106
|
+
# emit your existing signal if you have it
|
|
3107
|
+
if hasattr(self, "aboutToClose"):
|
|
3108
|
+
self.aboutToClose.emit(doc)
|
|
3109
|
+
except Exception:
|
|
3110
|
+
pass
|
|
3111
|
+
super().closeEvent(e)
|
|
3112
|
+
|
|
3113
|
+
def _resolve_history_doc(self):
|
|
3114
|
+
"""
|
|
3115
|
+
Return the doc whose history we should mutate:
|
|
3116
|
+
- If a Preview tab is active → the ROI/proxy doc from DocManager
|
|
3117
|
+
- Otherwise → the base/full document
|
|
3118
|
+
"""
|
|
3119
|
+
# Prefer DocManager's ROI-aware mapping if present
|
|
3120
|
+
dm = getattr(self, "_docman", None)
|
|
3121
|
+
if (self._active_source_kind == "preview"
|
|
3122
|
+
and self._active_preview_id is not None
|
|
3123
|
+
and dm is not None
|
|
3124
|
+
and hasattr(dm, "get_document_for_view")):
|
|
3125
|
+
try:
|
|
3126
|
+
d = dm.get_document_for_view(self)
|
|
3127
|
+
if d is not None:
|
|
3128
|
+
return d
|
|
3129
|
+
except Exception:
|
|
3130
|
+
pass
|
|
3131
|
+
# Fallback to the main doc
|
|
3132
|
+
return getattr(self, "document", None)
|
|
3133
|
+
|
|
3134
|
+
|
|
3135
|
+
def _refresh_local_undo_buttons(self):
|
|
3136
|
+
"""Enable/disable the local Undo/Redo toolbuttons based on can_undo/can_redo."""
|
|
3137
|
+
try:
|
|
3138
|
+
doc = self._resolve_history_doc()
|
|
3139
|
+
can_u = bool(doc and hasattr(doc, "can_undo") and doc.can_undo())
|
|
3140
|
+
can_r = bool(doc and hasattr(doc, "can_redo") and doc.can_redo())
|
|
3141
|
+
except Exception:
|
|
3142
|
+
can_u = can_r = False
|
|
3143
|
+
|
|
3144
|
+
b_u = getattr(self, "_btn_undo", None)
|
|
3145
|
+
b_r = getattr(self, "_btn_redo", None)
|
|
3146
|
+
|
|
3147
|
+
try:
|
|
3148
|
+
if b_u: b_u.setEnabled(can_u)
|
|
3149
|
+
except RuntimeError:
|
|
3150
|
+
return
|
|
3151
|
+
except Exception:
|
|
3152
|
+
pass
|
|
3153
|
+
try:
|
|
3154
|
+
if b_r: b_r.setEnabled(can_r)
|
|
3155
|
+
except RuntimeError:
|
|
3156
|
+
return
|
|
3157
|
+
except Exception:
|
|
3158
|
+
pass
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
|
|
3162
|
+
# --- NEW: TableSubWindow -------------------------------------------------
|
|
3163
|
+
from PyQt6.QtWidgets import QTableView, QPushButton, QFileDialog
|
|
3164
|
+
|
|
3165
|
+
class TableSubWindow(QWidget):
|
|
3166
|
+
"""
|
|
3167
|
+
Lightweight subwindow to render TableDocument (rows/headers) in a QTableView.
|
|
3168
|
+
Provides: copy, export CSV, row count display.
|
|
3169
|
+
"""
|
|
3170
|
+
viewTitleChanged = pyqtSignal(object, str) # to mirror ImageSubWindow emissions (if needed)
|
|
3171
|
+
|
|
3172
|
+
def __init__(self, table_document, parent=None):
|
|
3173
|
+
super().__init__(parent)
|
|
3174
|
+
self.document = table_document
|
|
3175
|
+
self._last_title_for_emit = None
|
|
3176
|
+
|
|
3177
|
+
lyt = QVBoxLayout(self)
|
|
3178
|
+
title_row = QHBoxLayout()
|
|
3179
|
+
self.title_lbl = QLabel(self.document.display_name())
|
|
3180
|
+
title_row.addWidget(self.title_lbl)
|
|
3181
|
+
title_row.addStretch(1)
|
|
3182
|
+
|
|
3183
|
+
self.export_btn = QPushButton("Export CSV…")
|
|
3184
|
+
self.export_btn.clicked.connect(self._export_csv)
|
|
3185
|
+
title_row.addWidget(self.export_btn)
|
|
3186
|
+
lyt.addLayout(title_row)
|
|
3187
|
+
|
|
3188
|
+
self.table = QTableView(self)
|
|
3189
|
+
self.table.setSortingEnabled(True)
|
|
3190
|
+
self.table.setAlternatingRowColors(True)
|
|
3191
|
+
self.table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
|
|
3192
|
+
self.table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
|
|
3193
|
+
lyt.addWidget(self.table, 1)
|
|
3194
|
+
|
|
3195
|
+
rows = getattr(self.document, "rows", [])
|
|
3196
|
+
headers = getattr(self.document, "headers", [])
|
|
3197
|
+
self._model = SimpleTableModel(rows, headers, self)
|
|
3198
|
+
self.table.setModel(self._model)
|
|
3199
|
+
self.table.horizontalHeader().setStretchLastSection(True)
|
|
3200
|
+
self.table.resizeColumnsToContents()
|
|
3201
|
+
|
|
3202
|
+
self._sync_host_title()
|
|
3203
|
+
#print(f"[TableSubWindow] init rows={self._model.rowCount()} cols={self._model.columnCount()} title='{self.document.display_name()}'")
|
|
3204
|
+
# react to doc rename if you add such behavior later
|
|
3205
|
+
try:
|
|
3206
|
+
self.document.changed.connect(self._on_doc_changed)
|
|
3207
|
+
except Exception:
|
|
3208
|
+
pass
|
|
3209
|
+
|
|
3210
|
+
def _on_doc_changed(self):
|
|
3211
|
+
# if title changes or content updates in future
|
|
3212
|
+
self.title_lbl.setText(self.document.display_name())
|
|
3213
|
+
self._sync_host_title()
|
|
3214
|
+
|
|
3215
|
+
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
3216
|
+
w = self.parent()
|
|
3217
|
+
while w is not None and not isinstance(w, QMdiSubWindow):
|
|
3218
|
+
w = w.parent()
|
|
3219
|
+
return w
|
|
3220
|
+
|
|
3221
|
+
def _sync_host_title(self):
|
|
3222
|
+
sub = self._mdi_subwindow()
|
|
3223
|
+
if not sub:
|
|
3224
|
+
return
|
|
3225
|
+
title = self.document.display_name()
|
|
3226
|
+
if title != sub.windowTitle():
|
|
3227
|
+
sub.setWindowTitle(title)
|
|
3228
|
+
sub.setToolTip(title)
|
|
3229
|
+
if title != self._last_title_for_emit:
|
|
3230
|
+
self._last_title_for_emit = title
|
|
3231
|
+
try:
|
|
3232
|
+
self.viewTitleChanged.emit(self, title)
|
|
3233
|
+
except Exception:
|
|
3234
|
+
pass
|
|
3235
|
+
|
|
3236
|
+
def _export_csv(self):
|
|
3237
|
+
# Prefer already-exported CSV from metadata when available, otherwise prompt
|
|
3238
|
+
existing = self.document.metadata.get("table_csv")
|
|
3239
|
+
if existing and os.path.exists(existing):
|
|
3240
|
+
# Offer to open/save-as that CSV
|
|
3241
|
+
dst, ok = QFileDialog.getSaveFileName(self, "Save CSV As…", os.path.basename(existing), "CSV Files (*.csv)")
|
|
3242
|
+
if ok and dst:
|
|
3243
|
+
try:
|
|
3244
|
+
import shutil
|
|
3245
|
+
shutil.copyfile(existing, dst)
|
|
3246
|
+
except Exception as e:
|
|
3247
|
+
QMessageBox.warning(self, "Export CSV", f"Failed to copy CSV:\n{e}")
|
|
3248
|
+
return
|
|
3249
|
+
|
|
3250
|
+
# No pre-export → write one from the model
|
|
3251
|
+
dst, ok = QFileDialog.getSaveFileName(self, "Export CSV…", "table.csv", "CSV Files (*.csv)")
|
|
3252
|
+
if not ok or not dst:
|
|
3253
|
+
return
|
|
3254
|
+
try:
|
|
3255
|
+
import csv
|
|
3256
|
+
with open(dst, "w", encoding="utf-8", newline="") as f:
|
|
3257
|
+
w = csv.writer(f)
|
|
3258
|
+
# headers
|
|
3259
|
+
cols = self._model.columnCount()
|
|
3260
|
+
hdrs = [self._model.headerData(c, Qt.Orientation.Horizontal) for c in range(cols)]
|
|
3261
|
+
w.writerow([str(h) for h in hdrs])
|
|
3262
|
+
# rows
|
|
3263
|
+
rows = self._model.rowCount()
|
|
3264
|
+
for r in range(rows):
|
|
3265
|
+
w.writerow([self._model.data(self._model.index(r, c), Qt.ItemDataRole.DisplayRole) for c in range(cols)])
|
|
3266
|
+
except Exception as e:
|
|
3267
|
+
QMessageBox.warning(self, "Export CSV", f"Failed to export CSV:\n{e}")
|