setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__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/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +429 -228
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +67 -47
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +51 -12
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +160 -29
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1331 -484
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +743 -128
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +109 -59
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/subwindow.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# src/setiastro/saspro/subwindow.py
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
from PyQt6.QtCore import Qt, QPoint, pyqtSignal, QSize, QEvent, QByteArray, QMimeData, QSettings, QTimer, QRect, QPoint, QMargins
|
|
4
4
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea, QLabel, QToolButton, QHBoxLayout, QMessageBox, QMdiSubWindow, QMenu, QInputDialog, QApplication, QTabWidget, QRubberBand
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import math
|
|
10
10
|
import weakref
|
|
11
11
|
import os
|
|
12
|
+
import re
|
|
12
13
|
try:
|
|
13
14
|
from PyQt6.QtCore import QSignalBlocker
|
|
14
15
|
except Exception:
|
|
@@ -136,6 +137,22 @@ DECORATION_PREFIXES = (
|
|
|
136
137
|
"Active View: ", # legacy
|
|
137
138
|
)
|
|
138
139
|
|
|
140
|
+
_GLYPH_RE = re.compile(f"[{re.escape(GLYPHS)}]")
|
|
141
|
+
|
|
142
|
+
def _strip_ui_decorations(title: str) -> str:
|
|
143
|
+
if not title:
|
|
144
|
+
return ""
|
|
145
|
+
s = str(title).strip()
|
|
146
|
+
|
|
147
|
+
# Remove common prefix tag(s)
|
|
148
|
+
s = s.replace("[LINK]", "").strip()
|
|
149
|
+
|
|
150
|
+
# Remove glyphs anywhere (often used as status markers in titles)
|
|
151
|
+
s = _GLYPH_RE.sub("", s)
|
|
152
|
+
|
|
153
|
+
# Collapse repeated whitespace
|
|
154
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
155
|
+
return s
|
|
139
156
|
|
|
140
157
|
from astropy.wcs import WCS as _AstroWCS
|
|
141
158
|
from astropy.io.fits import Header as _FitsHeader
|
|
@@ -311,7 +328,35 @@ def _compute_cropped_wcs(parent_hdr_like, x, y, w, h):
|
|
|
311
328
|
base["SASKIND"] = "ROI-CROP"
|
|
312
329
|
return base
|
|
313
330
|
|
|
314
|
-
|
|
331
|
+
def _dnd_dbg_dump_state(tag: str, state: dict):
|
|
332
|
+
try:
|
|
333
|
+
import json
|
|
334
|
+
# Keep it readable but complete
|
|
335
|
+
keys = sorted(state.keys())
|
|
336
|
+
print(f"\n[DNDDBG:{tag}] keys={keys}")
|
|
337
|
+
for k in keys:
|
|
338
|
+
v = state.get(k)
|
|
339
|
+
# avoid huge blobs
|
|
340
|
+
if isinstance(v, (dict, list)) and len(str(v)) > 400:
|
|
341
|
+
print(f" {k} = <{type(v).__name__} len={len(v)}>")
|
|
342
|
+
else:
|
|
343
|
+
print(f" {k} = {v!r}")
|
|
344
|
+
# show json size
|
|
345
|
+
raw = json.dumps(state).encode("utf-8")
|
|
346
|
+
print(f"[DNDDBG:{tag}] json_bytes={len(raw)} head={raw[:120]!r}")
|
|
347
|
+
except Exception as e:
|
|
348
|
+
print(f"[DNDDBG:{tag}] dump failed: {e}")
|
|
349
|
+
|
|
350
|
+
_DEBUG_DND_DUP = False
|
|
351
|
+
|
|
352
|
+
def _strip_ext_if_filename(s: str) -> str:
|
|
353
|
+
s = (s or "").strip()
|
|
354
|
+
if not s:
|
|
355
|
+
return s
|
|
356
|
+
base, ext = os.path.splitext(s)
|
|
357
|
+
if ext and len(ext) <= 10:
|
|
358
|
+
return base
|
|
359
|
+
return s
|
|
315
360
|
|
|
316
361
|
class ImageSubWindow(QWidget):
|
|
317
362
|
aboutToClose = pyqtSignal(object)
|
|
@@ -469,6 +514,32 @@ class ImageSubWindow(QWidget):
|
|
|
469
514
|
row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
|
|
470
515
|
# ─────────────────────────────────────────────────────────────────
|
|
471
516
|
|
|
517
|
+
# ---- Inline view title (shown when the MDI subwindow is maximized) ----
|
|
518
|
+
self._inline_title = QLabel(self)
|
|
519
|
+
self._inline_title.setText("")
|
|
520
|
+
self._inline_title.setToolTip(self.tr("Active view"))
|
|
521
|
+
self._inline_title.setVisible(False)
|
|
522
|
+
self._inline_title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
523
|
+
self._inline_title.setStyleSheet("""
|
|
524
|
+
QLabel {
|
|
525
|
+
padding-left: 8px;
|
|
526
|
+
padding-right: 6px;
|
|
527
|
+
font-size: 11px;
|
|
528
|
+
color: rgba(255,255,255,0.80);
|
|
529
|
+
}
|
|
530
|
+
""")
|
|
531
|
+
self._inline_title.setSizePolicy(
|
|
532
|
+
self._inline_title.sizePolicy().horizontalPolicy(),
|
|
533
|
+
self._inline_title.sizePolicy().verticalPolicy(),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Push everything after this to the far right
|
|
537
|
+
row.addStretch(1)
|
|
538
|
+
row.addWidget(self._inline_title, 0, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
|
|
539
|
+
|
|
540
|
+
# (optional) tiny spacing to the edge
|
|
541
|
+
row.addSpacing(6)
|
|
542
|
+
|
|
472
543
|
row.addStretch(1)
|
|
473
544
|
lyt.addLayout(row)
|
|
474
545
|
|
|
@@ -549,6 +620,9 @@ class ImageSubWindow(QWidget):
|
|
|
549
620
|
self._history_doc = None
|
|
550
621
|
self._install_history_watchers()
|
|
551
622
|
|
|
623
|
+
QTimer.singleShot(0, self._install_mdi_state_watch)
|
|
624
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
625
|
+
|
|
552
626
|
# ----- link drag payload -----
|
|
553
627
|
def _start_link_drag(self):
|
|
554
628
|
"""
|
|
@@ -680,7 +754,60 @@ class ImageSubWindow(QWidget):
|
|
|
680
754
|
except Exception:
|
|
681
755
|
pass
|
|
682
756
|
|
|
683
|
-
|
|
757
|
+
# ------------------------------------------------------------
|
|
758
|
+
# MDI maximize handling: show inline title + avoid duplicate buttons
|
|
759
|
+
# ------------------------------------------------------------
|
|
760
|
+
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
761
|
+
w = self.parentWidget()
|
|
762
|
+
while w is not None and not isinstance(w, QMdiSubWindow):
|
|
763
|
+
w = w.parentWidget()
|
|
764
|
+
return w
|
|
765
|
+
|
|
766
|
+
def _install_mdi_state_watch(self):
|
|
767
|
+
sw = self._mdi_subwindow()
|
|
768
|
+
if sw is None:
|
|
769
|
+
return
|
|
770
|
+
# Watch maximize/restore changes on the hosting QMdiSubWindow
|
|
771
|
+
sw.installEventFilter(self)
|
|
772
|
+
|
|
773
|
+
def _is_mdi_maximized(self) -> bool:
|
|
774
|
+
sw = self._mdi_subwindow()
|
|
775
|
+
if sw is None:
|
|
776
|
+
return False
|
|
777
|
+
try:
|
|
778
|
+
return sw.isMaximized()
|
|
779
|
+
except Exception:
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
def _set_mdi_minmax_buttons_enabled(self, enabled: bool):
|
|
783
|
+
return # leave Qt default buttons alone
|
|
784
|
+
|
|
785
|
+
def _current_view_title_for_inline(self) -> str:
|
|
786
|
+
# Prefer your already-pretty title (strip decorations if needed).
|
|
787
|
+
try:
|
|
788
|
+
# If you have _current_view_title_for_drag already, reuse it:
|
|
789
|
+
return self._current_view_title_for_drag()
|
|
790
|
+
except Exception:
|
|
791
|
+
pass
|
|
792
|
+
try:
|
|
793
|
+
return (self.windowTitle() or "").strip()
|
|
794
|
+
except Exception:
|
|
795
|
+
return ""
|
|
796
|
+
|
|
797
|
+
def _update_inline_title_and_buttons(self):
|
|
798
|
+
maximized = self._is_mdi_maximized()
|
|
799
|
+
|
|
800
|
+
# Show inline title only when maximized (optional)
|
|
801
|
+
try:
|
|
802
|
+
self._inline_title.setVisible(maximized)
|
|
803
|
+
if maximized:
|
|
804
|
+
self._inline_title.setText(self._current_view_title_for_inline() or "Untitled")
|
|
805
|
+
except Exception:
|
|
806
|
+
pass
|
|
807
|
+
|
|
808
|
+
# IMPORTANT: do NOT change QMdiSubWindow window flags.
|
|
809
|
+
# Leaving them alone restores the default Qt "double button" behavior.
|
|
810
|
+
|
|
684
811
|
#------ Replay helpers------
|
|
685
812
|
def _update_replay_button(self):
|
|
686
813
|
"""
|
|
@@ -741,15 +868,6 @@ class ImageSubWindow(QWidget):
|
|
|
741
868
|
enabled = bool(has_preview and (has_history or has_last))
|
|
742
869
|
btn.setEnabled(enabled)
|
|
743
870
|
|
|
744
|
-
# DEBUG:
|
|
745
|
-
try:
|
|
746
|
-
print(
|
|
747
|
-
f"[Replay] _update_replay_button: view id={id(self)} "
|
|
748
|
-
f"enabled={enabled}, has_preview={has_preview}, "
|
|
749
|
-
f"history_len={len(history)}"
|
|
750
|
-
)
|
|
751
|
-
except Exception:
|
|
752
|
-
pass
|
|
753
871
|
|
|
754
872
|
def _replay_history_index(self, index: int):
|
|
755
873
|
"""
|
|
@@ -809,11 +927,7 @@ class ImageSubWindow(QWidget):
|
|
|
809
927
|
except Exception:
|
|
810
928
|
pass
|
|
811
929
|
|
|
812
|
-
|
|
813
|
-
try:
|
|
814
|
-
print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
|
|
815
|
-
except Exception:
|
|
816
|
-
pass
|
|
930
|
+
|
|
817
931
|
self.replayOnBaseRequested.emit(self)
|
|
818
932
|
|
|
819
933
|
|
|
@@ -823,13 +937,20 @@ class ImageSubWindow(QWidget):
|
|
|
823
937
|
self._emit_view_transform()
|
|
824
938
|
|
|
825
939
|
def set_view_transform(self, scale, hval, vval, from_link=False):
|
|
826
|
-
# Avoid storms while we mutate scrollbars/scale
|
|
827
940
|
self._suppress_link_emit = True
|
|
828
941
|
try:
|
|
829
942
|
scale = float(max(self._min_scale, min(scale, self._max_scale)))
|
|
830
|
-
|
|
943
|
+
|
|
944
|
+
scale_changed = (abs(scale - self.scale) > 1e-9)
|
|
945
|
+
if scale_changed:
|
|
831
946
|
self.scale = scale
|
|
832
|
-
self._render(rebuild=False)
|
|
947
|
+
self._render(rebuild=False) # fast present for responsiveness
|
|
948
|
+
|
|
949
|
+
# ✅ NEW: schedule the final smooth redraw (same as main zoom path)
|
|
950
|
+
try:
|
|
951
|
+
self._request_zoom_redraw()
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
833
954
|
|
|
834
955
|
hbar = self.scroll.horizontalScrollBar()
|
|
835
956
|
vbar = self.scroll.verticalScrollBar()
|
|
@@ -841,14 +962,14 @@ class ImageSubWindow(QWidget):
|
|
|
841
962
|
finally:
|
|
842
963
|
self._suppress_link_emit = False
|
|
843
964
|
|
|
844
|
-
# IMPORTANT: if this came from a linked peer, do NOT broadcast again.
|
|
845
965
|
if not from_link:
|
|
846
966
|
self._schedule_emit_view_transform()
|
|
847
967
|
|
|
968
|
+
|
|
848
969
|
def _on_toggle_wcs_grid(self, on: bool):
|
|
849
970
|
self._show_wcs_grid = bool(on)
|
|
850
971
|
QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
|
|
851
|
-
self._render(rebuild=
|
|
972
|
+
self._render(rebuild=True) # repaint current frame
|
|
852
973
|
|
|
853
974
|
|
|
854
975
|
|
|
@@ -880,33 +1001,32 @@ class ImageSubWindow(QWidget):
|
|
|
880
1001
|
# make the buttons correct right now
|
|
881
1002
|
self._refresh_local_undo_buttons()
|
|
882
1003
|
|
|
883
|
-
def _drag_identity_fields(self):
|
|
884
|
-
|
|
885
|
-
Returns a dict with identity hints for DnD:
|
|
886
|
-
doc_uid (preferred), base_doc_uid (parent/full), and file_path.
|
|
887
|
-
Falls back gracefully if fields are missing.
|
|
888
|
-
"""
|
|
889
|
-
doc = getattr(self, "document", None)
|
|
890
|
-
base = getattr(self, "base_document", None) or doc
|
|
1004
|
+
def _drag_identity_fields(self) -> dict:
|
|
1005
|
+
st = {}
|
|
891
1006
|
|
|
892
|
-
#
|
|
893
|
-
dm = getattr(self, "_docman", None)
|
|
1007
|
+
# existing identity (whatever you already do)
|
|
894
1008
|
try:
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1009
|
+
doc = getattr(self, "document", None)
|
|
1010
|
+
st["doc_ptr"] = id(doc) if doc is not None else None
|
|
1011
|
+
st["doc_uid"] = getattr(doc, "uid", None)
|
|
1012
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
1013
|
+
st["file_path"] = (meta.get("file_path") or "").strip()
|
|
1014
|
+
st["base_doc_uid"] = meta.get("base_doc_uid") or st["doc_uid"]
|
|
1015
|
+
st["source_kind"] = meta.get("source_kind") or "full"
|
|
899
1016
|
except Exception:
|
|
900
1017
|
pass
|
|
901
1018
|
|
|
902
|
-
|
|
903
|
-
|
|
1019
|
+
# ✅ NEW: add the current user-visible view title
|
|
1020
|
+
st["source_view_title"] = self._current_view_title_for_drag()
|
|
904
1021
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
"
|
|
909
|
-
|
|
1022
|
+
# (optional) also include the subwindow title raw, for debugging/forensics
|
|
1023
|
+
try:
|
|
1024
|
+
sw = self._mdi_subwindow()
|
|
1025
|
+
st["source_sw_title_raw"] = (sw.windowTitle() if sw is not None else "")
|
|
1026
|
+
except Exception:
|
|
1027
|
+
st["source_sw_title_raw"] = ""
|
|
1028
|
+
|
|
1029
|
+
return st
|
|
910
1030
|
|
|
911
1031
|
|
|
912
1032
|
def _on_local_undo(self):
|
|
@@ -1188,18 +1308,6 @@ class ImageSubWindow(QWidget):
|
|
|
1188
1308
|
except Exception as e:
|
|
1189
1309
|
print("[ImageSubWindow] apply_layer_stack error:", e)
|
|
1190
1310
|
|
|
1191
|
-
# --- add to ImageSubWindow ---
|
|
1192
|
-
def _collect_layer_docs(self):
|
|
1193
|
-
docs = set()
|
|
1194
|
-
for L in getattr(self, "_layers", []):
|
|
1195
|
-
d = getattr(L, "src_doc", None)
|
|
1196
|
-
if d is not None:
|
|
1197
|
-
docs.add(d)
|
|
1198
|
-
md = getattr(L, "mask_doc", None)
|
|
1199
|
-
if md is not None:
|
|
1200
|
-
docs.add(md)
|
|
1201
|
-
return docs
|
|
1202
|
-
|
|
1203
1311
|
def keyPressEvent(self, ev):
|
|
1204
1312
|
if ev.key() == Qt.Key.Key_Space:
|
|
1205
1313
|
# only the first time we enter probe mode
|
|
@@ -1321,51 +1429,116 @@ class ImageSubWindow(QWidget):
|
|
|
1321
1429
|
except Exception as e:
|
|
1322
1430
|
print("[ImageSubWindow] _on_layer_source_changed error:", e)
|
|
1323
1431
|
|
|
1432
|
+
def _collect_layer_docs(self):
|
|
1433
|
+
"""
|
|
1434
|
+
Collect unique ImageDocument objects referenced by the layer stack:
|
|
1435
|
+
- layer src_doc (if doc-backed)
|
|
1436
|
+
- layer mask_doc (if any)
|
|
1437
|
+
Raster/baked layers may have src_doc=None; those are ignored.
|
|
1438
|
+
Returns a LIST in a stable order (bottom→top traversal order), de-duped.
|
|
1439
|
+
"""
|
|
1440
|
+
out = []
|
|
1441
|
+
seen = set()
|
|
1442
|
+
|
|
1443
|
+
layers = getattr(self, "_layers", None) or []
|
|
1444
|
+
for L in layers:
|
|
1445
|
+
# 1) source doc (may be None for raster/baked layers)
|
|
1446
|
+
d = getattr(L, "src_doc", None)
|
|
1447
|
+
if d is not None:
|
|
1448
|
+
k = id(d)
|
|
1449
|
+
if k not in seen:
|
|
1450
|
+
seen.add(k)
|
|
1451
|
+
out.append(d)
|
|
1452
|
+
|
|
1453
|
+
# 2) mask doc (also may be None)
|
|
1454
|
+
md = getattr(L, "mask_doc", None)
|
|
1455
|
+
if md is not None:
|
|
1456
|
+
k = id(md)
|
|
1457
|
+
if k not in seen:
|
|
1458
|
+
seen.add(k)
|
|
1459
|
+
out.append(md)
|
|
1460
|
+
|
|
1461
|
+
return out
|
|
1462
|
+
|
|
1463
|
+
|
|
1324
1464
|
def _reinstall_layer_watchers(self):
|
|
1465
|
+
"""
|
|
1466
|
+
Reconnect layer source/mask document watchers to trigger live layer recomposite.
|
|
1467
|
+
Safe against:
|
|
1468
|
+
- raster/baked layers (src_doc=None)
|
|
1469
|
+
- deleted docs / partially-torn-down Qt objects
|
|
1470
|
+
- repeated calls
|
|
1471
|
+
"""
|
|
1472
|
+
# Previous watchers
|
|
1473
|
+
olddocs = list(getattr(self, "_watched_docs", []) or [])
|
|
1474
|
+
|
|
1325
1475
|
# Disconnect old
|
|
1326
|
-
for d in
|
|
1476
|
+
for d in olddocs:
|
|
1327
1477
|
try:
|
|
1478
|
+
# Doc may already be deleted or signal gone
|
|
1328
1479
|
d.changed.disconnect(self._on_layer_source_changed)
|
|
1329
1480
|
except Exception:
|
|
1330
1481
|
pass
|
|
1331
|
-
|
|
1482
|
+
|
|
1483
|
+
# Collect new
|
|
1332
1484
|
newdocs = self._collect_layer_docs()
|
|
1485
|
+
|
|
1486
|
+
# Connect new
|
|
1333
1487
|
for d in newdocs:
|
|
1334
1488
|
try:
|
|
1335
1489
|
d.changed.connect(self._on_layer_source_changed)
|
|
1336
1490
|
except Exception:
|
|
1337
1491
|
pass
|
|
1492
|
+
|
|
1493
|
+
# Store as list (stable)
|
|
1338
1494
|
self._watched_docs = newdocs
|
|
1339
1495
|
|
|
1340
1496
|
|
|
1497
|
+
|
|
1341
1498
|
def toggle_mask_overlay(self):
|
|
1342
1499
|
self.show_mask_overlay = not self.show_mask_overlay
|
|
1343
1500
|
self._render(rebuild=True)
|
|
1344
1501
|
|
|
1345
1502
|
def _rebuild_title(self, *, base: str | None = None):
|
|
1346
1503
|
sub = self._mdi_subwindow()
|
|
1347
|
-
if not sub:
|
|
1504
|
+
if not sub:
|
|
1505
|
+
return
|
|
1506
|
+
|
|
1348
1507
|
if base is None:
|
|
1349
1508
|
base = self._effective_title() or self.tr("Untitled")
|
|
1350
1509
|
|
|
1351
|
-
#
|
|
1510
|
+
# Strip badges (🔗, ■, etc) AND "Active View:" prefix
|
|
1352
1511
|
core, _ = self._strip_decorations(base)
|
|
1353
1512
|
|
|
1354
|
-
|
|
1513
|
+
# ALSO strip file extensions if it looks like a filename
|
|
1514
|
+
# (this prevents .tiff/.fit coming back via any fallback path)
|
|
1515
|
+
try:
|
|
1516
|
+
b, ext = os.path.splitext(core)
|
|
1517
|
+
if ext and len(ext) <= 10 and not core.endswith("..."):
|
|
1518
|
+
core = b
|
|
1519
|
+
except Exception:
|
|
1520
|
+
pass
|
|
1521
|
+
|
|
1522
|
+
# Build the displayed title with badges
|
|
1523
|
+
shown = core
|
|
1355
1524
|
if getattr(self, "_link_badge_on", False):
|
|
1356
|
-
|
|
1525
|
+
shown = f"{LINK_PREFIX}{shown}"
|
|
1357
1526
|
if self._mask_dot_enabled:
|
|
1358
|
-
|
|
1527
|
+
shown = f"{MASK_GLYPH} {shown}"
|
|
1528
|
+
|
|
1529
|
+
# Update chrome
|
|
1530
|
+
if shown != sub.windowTitle():
|
|
1531
|
+
sub.setWindowTitle(shown)
|
|
1532
|
+
sub.setToolTip(shown)
|
|
1533
|
+
|
|
1534
|
+
# IMPORTANT: emit ONLY the clean core (no badges, no extensions)
|
|
1535
|
+
if core != self._last_title_for_emit:
|
|
1536
|
+
self._last_title_for_emit = core
|
|
1537
|
+
try:
|
|
1538
|
+
self.viewTitleChanged.emit(self, core)
|
|
1539
|
+
except Exception:
|
|
1540
|
+
pass
|
|
1359
1541
|
|
|
1360
|
-
if title != sub.windowTitle():
|
|
1361
|
-
sub.setWindowTitle(title)
|
|
1362
|
-
sub.setToolTip(title)
|
|
1363
|
-
if title != self._last_title_for_emit:
|
|
1364
|
-
self._last_title_for_emit = title
|
|
1365
|
-
try: self.viewTitleChanged.emit(self, title)
|
|
1366
|
-
except Exception as e:
|
|
1367
|
-
import logging
|
|
1368
|
-
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
1369
1542
|
|
|
1370
1543
|
|
|
1371
1544
|
def _strip_decorations(self, title: str) -> tuple[str, bool]:
|
|
@@ -1394,21 +1567,7 @@ class ImageSubWindow(QWidget):
|
|
|
1394
1567
|
def set_active_highlight(self, on: bool):
|
|
1395
1568
|
self._is_active_flag = bool(on)
|
|
1396
1569
|
return
|
|
1397
|
-
sub = self._mdi_subwindow()
|
|
1398
|
-
if not sub:
|
|
1399
|
-
return
|
|
1400
|
-
|
|
1401
|
-
core, had_glyph = self._strip_decorations(sub.windowTitle())
|
|
1402
1570
|
|
|
1403
|
-
if on and not getattr(self, "_suppress_active_once", False):
|
|
1404
|
-
core = ACTIVE_PREFIX + core
|
|
1405
|
-
self._suppress_active_once = False
|
|
1406
|
-
|
|
1407
|
-
# recompose: glyph (from flag), then active prefix, then base/core
|
|
1408
|
-
if getattr(self, "_mask_dot_enabled", False):
|
|
1409
|
-
core = "■ " + core
|
|
1410
|
-
#sub.setWindowTitle(core)
|
|
1411
|
-
sub.setToolTip(core)
|
|
1412
1571
|
|
|
1413
1572
|
def _set_mask_highlight(self, on: bool):
|
|
1414
1573
|
self._mask_dot_enabled = bool(on)
|
|
@@ -1516,15 +1675,53 @@ class ImageSubWindow(QWidget):
|
|
|
1516
1675
|
def is_hard_autostretch(self) -> bool:
|
|
1517
1676
|
return self.autostretch_profile == "hard"
|
|
1518
1677
|
|
|
1519
|
-
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1678
|
+
#def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1679
|
+
# w = self.parent()
|
|
1680
|
+
# while w is not None and not isinstance(w, QMdiSubWindow):
|
|
1681
|
+
# w = w.parent()
|
|
1682
|
+
# return w
|
|
1524
1683
|
|
|
1525
1684
|
def _effective_title(self) -> str:
|
|
1526
|
-
|
|
1527
|
-
|
|
1685
|
+
"""
|
|
1686
|
+
Returns the *core* title for this view (no UI badges like 🔗/■, and no file extension).
|
|
1687
|
+
Badges are added later by _rebuild_title().
|
|
1688
|
+
"""
|
|
1689
|
+
# 1) Prefer per-view override if set
|
|
1690
|
+
t = (self._view_title_override or "").strip()
|
|
1691
|
+
|
|
1692
|
+
# 2) Else prefer metadata display_name (what duplicate/rename should set)
|
|
1693
|
+
if not t:
|
|
1694
|
+
try:
|
|
1695
|
+
md = getattr(self.document, "metadata", {}) or {}
|
|
1696
|
+
t = (md.get("display_name") or "").strip()
|
|
1697
|
+
except Exception:
|
|
1698
|
+
t = ""
|
|
1699
|
+
|
|
1700
|
+
# 3) Else fall back to doc.display_name()
|
|
1701
|
+
if not t:
|
|
1702
|
+
try:
|
|
1703
|
+
t = (self.document.display_name() or "").strip()
|
|
1704
|
+
except Exception:
|
|
1705
|
+
t = ""
|
|
1706
|
+
|
|
1707
|
+
t = t or self.tr("Untitled")
|
|
1708
|
+
|
|
1709
|
+
# Strip UI decorations (🔗, ■, Active View:, etc.)
|
|
1710
|
+
try:
|
|
1711
|
+
t, _ = self._strip_decorations(t)
|
|
1712
|
+
except Exception:
|
|
1713
|
+
pass
|
|
1714
|
+
|
|
1715
|
+
# Strip extension if it looks like a filename
|
|
1716
|
+
try:
|
|
1717
|
+
base, ext = os.path.splitext(t)
|
|
1718
|
+
if ext and len(ext) <= 10:
|
|
1719
|
+
t = base
|
|
1720
|
+
except Exception:
|
|
1721
|
+
pass
|
|
1722
|
+
|
|
1723
|
+
return t
|
|
1724
|
+
|
|
1528
1725
|
|
|
1529
1726
|
def _show_ctx_menu(self, pos):
|
|
1530
1727
|
menu = QMenu(self)
|
|
@@ -1603,13 +1800,20 @@ class ImageSubWindow(QWidget):
|
|
|
1603
1800
|
pass
|
|
1604
1801
|
|
|
1605
1802
|
def set_scale(self, s: float):
|
|
1803
|
+
# Programmatic scale changes must schedule final smooth redraw
|
|
1606
1804
|
s = float(max(self._min_scale, min(s, self._max_scale)))
|
|
1607
1805
|
if abs(s - self.scale) < 1e-9:
|
|
1608
1806
|
return
|
|
1609
1807
|
self.scale = s
|
|
1610
|
-
self._render() #
|
|
1808
|
+
self._render() # fast present happens here
|
|
1611
1809
|
self._schedule_emit_view_transform()
|
|
1612
1810
|
|
|
1811
|
+
# ✅ NEW: ensure we do the final smooth redraw (same as manual zoom)
|
|
1812
|
+
try:
|
|
1813
|
+
self._request_zoom_redraw()
|
|
1814
|
+
except Exception:
|
|
1815
|
+
pass
|
|
1816
|
+
|
|
1613
1817
|
|
|
1614
1818
|
|
|
1615
1819
|
# ---- view state API (center in image coords + scale) ----
|
|
@@ -1634,20 +1838,19 @@ class ImageSubWindow(QWidget):
|
|
|
1634
1838
|
vbar = self.scroll.verticalScrollBar()
|
|
1635
1839
|
|
|
1636
1840
|
state = {
|
|
1637
|
-
"doc_ptr": id(self.document),
|
|
1841
|
+
"doc_ptr": id(self.document),
|
|
1638
1842
|
"scale": float(self.scale),
|
|
1639
1843
|
"hval": int(hbar.value()),
|
|
1640
1844
|
"vval": int(vbar.value()),
|
|
1641
1845
|
"autostretch": bool(self.autostretch_enabled),
|
|
1642
1846
|
"autostretch_target": float(self.autostretch_target),
|
|
1643
1847
|
}
|
|
1644
|
-
state.update(self._drag_identity_fields())
|
|
1848
|
+
state.update(self._drag_identity_fields())
|
|
1645
1849
|
|
|
1646
|
-
# --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
|
|
1647
1850
|
roi = None
|
|
1648
1851
|
try:
|
|
1649
1852
|
if hasattr(self, "has_active_preview") and self.has_active_preview():
|
|
1650
|
-
r = self.current_preview_roi()
|
|
1853
|
+
r = self.current_preview_roi()
|
|
1651
1854
|
if r and len(r) == 4:
|
|
1652
1855
|
roi = tuple(map(int, r))
|
|
1653
1856
|
except Exception:
|
|
@@ -1665,14 +1868,29 @@ class ImageSubWindow(QWidget):
|
|
|
1665
1868
|
else:
|
|
1666
1869
|
state["source_kind"] = "full"
|
|
1667
1870
|
|
|
1871
|
+
if _DEBUG_DND_DUP:
|
|
1872
|
+
_dnd_dbg_dump_state("DRAG_START:dragtab", state)
|
|
1873
|
+
|
|
1874
|
+
|
|
1668
1875
|
md = QMimeData()
|
|
1669
1876
|
md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1670
1877
|
|
|
1671
1878
|
drag = QDrag(self)
|
|
1672
1879
|
drag.setMimeData(md)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1880
|
+
|
|
1881
|
+
pm = self.label.pixmap()
|
|
1882
|
+
if pm and not pm.isNull():
|
|
1883
|
+
drag.setPixmap(
|
|
1884
|
+
pm.scaled(
|
|
1885
|
+
96, 96,
|
|
1886
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1887
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
1888
|
+
)
|
|
1889
|
+
)
|
|
1890
|
+
drag.setHotSpot(QPoint(16, 16)) # optional, but feels nicer
|
|
1891
|
+
|
|
1892
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
1893
|
+
|
|
1676
1894
|
|
|
1677
1895
|
|
|
1678
1896
|
|
|
@@ -1775,6 +1993,55 @@ class ImageSubWindow(QWidget):
|
|
|
1775
1993
|
|
|
1776
1994
|
|
|
1777
1995
|
# ---- DnD 'view tab' -------------------------------------------------
|
|
1996
|
+
|
|
1997
|
+
def _mdi_subwindow(self):
|
|
1998
|
+
"""Return the QMdiSubWindow that hosts this view, or None."""
|
|
1999
|
+
try:
|
|
2000
|
+
from PyQt6.QtWidgets import QMdiSubWindow
|
|
2001
|
+
p = self.parent()
|
|
2002
|
+
while p is not None:
|
|
2003
|
+
if isinstance(p, QMdiSubWindow):
|
|
2004
|
+
return p
|
|
2005
|
+
p = p.parent()
|
|
2006
|
+
except Exception:
|
|
2007
|
+
pass
|
|
2008
|
+
return None
|
|
2009
|
+
|
|
2010
|
+
def _current_view_title_for_drag(self) -> str:
|
|
2011
|
+
"""
|
|
2012
|
+
The *actual* user-visible view title (what they renamed to),
|
|
2013
|
+
NOT the document/file name.
|
|
2014
|
+
"""
|
|
2015
|
+
title = ""
|
|
2016
|
+
try:
|
|
2017
|
+
sw = self._mdi_subwindow()
|
|
2018
|
+
if sw is not None:
|
|
2019
|
+
title = (sw.windowTitle() or "").strip()
|
|
2020
|
+
except Exception:
|
|
2021
|
+
title = ""
|
|
2022
|
+
|
|
2023
|
+
if not title:
|
|
2024
|
+
try:
|
|
2025
|
+
title = (self.windowTitle() or "").strip()
|
|
2026
|
+
except Exception:
|
|
2027
|
+
title = ""
|
|
2028
|
+
|
|
2029
|
+
if not title:
|
|
2030
|
+
# absolute fallback
|
|
2031
|
+
try:
|
|
2032
|
+
title = (self.document.display_name() or "").strip()
|
|
2033
|
+
except Exception:
|
|
2034
|
+
title = ""
|
|
2035
|
+
|
|
2036
|
+
# Optional: strip [LINK], glyphs, etc if your title includes those
|
|
2037
|
+
try:
|
|
2038
|
+
title = _strip_ui_decorations(title)
|
|
2039
|
+
except Exception:
|
|
2040
|
+
pass
|
|
2041
|
+
|
|
2042
|
+
return title or "Untitled"
|
|
2043
|
+
|
|
2044
|
+
|
|
1778
2045
|
def _install_view_tab(self):
|
|
1779
2046
|
self._view_tab = QToolButton(self)
|
|
1780
2047
|
self._view_tab.setText(self.tr("View"))
|
|
@@ -1794,19 +2061,28 @@ class ImageSubWindow(QWidget):
|
|
|
1794
2061
|
if ev.button() != Qt.MouseButton.LeftButton:
|
|
1795
2062
|
return QToolButton.mousePressEvent(self._view_tab, ev)
|
|
1796
2063
|
|
|
1797
|
-
# build the SAME payload schema used by _start_viewstate_drag()
|
|
1798
2064
|
hbar = self.scroll.horizontalScrollBar()
|
|
1799
2065
|
vbar = self.scroll.verticalScrollBar()
|
|
2066
|
+
|
|
2067
|
+
# NEW: capture the *current view title* the user sees
|
|
2068
|
+
view_title = self._current_view_display_name()
|
|
2069
|
+
|
|
1800
2070
|
state = {
|
|
1801
2071
|
"doc_ptr": id(self.document),
|
|
2072
|
+
"doc_uid": getattr(self.document, "uid", None), # harmless even if None
|
|
2073
|
+
"file_path": (getattr(self.document, "metadata", {}) or {}).get("file_path", ""),
|
|
1802
2074
|
"scale": float(self.scale),
|
|
1803
2075
|
"hval": int(hbar.value()),
|
|
1804
2076
|
"vval": int(vbar.value()),
|
|
1805
2077
|
"autostretch": bool(self.autostretch_enabled),
|
|
1806
2078
|
"autostretch_target": float(self.autostretch_target),
|
|
2079
|
+
|
|
2080
|
+
# NEW: this is what we will use for naming duplicates
|
|
2081
|
+
"source_view_title": view_title,
|
|
1807
2082
|
}
|
|
1808
2083
|
state.update(self._drag_identity_fields())
|
|
1809
|
-
|
|
2084
|
+
if _DEBUG_DND_DUP:
|
|
2085
|
+
_dnd_dbg_dump_state("DRAG_START:viewtab", state)
|
|
1810
2086
|
mime = QMimeData()
|
|
1811
2087
|
mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1812
2088
|
|
|
@@ -1815,10 +2091,13 @@ class ImageSubWindow(QWidget):
|
|
|
1815
2091
|
|
|
1816
2092
|
pm = self.label.pixmap()
|
|
1817
2093
|
if pm:
|
|
1818
|
-
drag.setPixmap(pm.scaled(
|
|
1819
|
-
|
|
1820
|
-
|
|
2094
|
+
drag.setPixmap(pm.scaled(
|
|
2095
|
+
96, 96,
|
|
2096
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
2097
|
+
Qt.TransformationMode.SmoothTransformation
|
|
2098
|
+
))
|
|
1821
2099
|
drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
|
|
2100
|
+
|
|
1822
2101
|
drag.exec(Qt.DropAction.CopyAction)
|
|
1823
2102
|
|
|
1824
2103
|
def _viewtab_mouse_double(self, _ev):
|
|
@@ -1924,6 +2203,42 @@ class ImageSubWindow(QWidget):
|
|
|
1924
2203
|
|
|
1925
2204
|
ev.ignore()
|
|
1926
2205
|
|
|
2206
|
+
def _current_view_display_name(self) -> str:
|
|
2207
|
+
"""
|
|
2208
|
+
Best-effort: the exact title the user sees for THIS subwindow/view.
|
|
2209
|
+
Prefer QMdiSubWindow title, fallback to document display_name.
|
|
2210
|
+
"""
|
|
2211
|
+
# 1) QMdiSubWindow title (what user sees)
|
|
2212
|
+
try:
|
|
2213
|
+
sw = self._mdi_subwindow()
|
|
2214
|
+
if sw is not None:
|
|
2215
|
+
t = (sw.windowTitle() or "").strip()
|
|
2216
|
+
if t:
|
|
2217
|
+
return t
|
|
2218
|
+
except Exception:
|
|
2219
|
+
pass
|
|
2220
|
+
|
|
2221
|
+
# 2) This widget's own windowTitle (sometimes used)
|
|
2222
|
+
try:
|
|
2223
|
+
t = (self.windowTitle() or "").strip()
|
|
2224
|
+
if t:
|
|
2225
|
+
return t
|
|
2226
|
+
except Exception:
|
|
2227
|
+
pass
|
|
2228
|
+
|
|
2229
|
+
# 3) Document display name fallback
|
|
2230
|
+
try:
|
|
2231
|
+
d = getattr(self, "document", None)
|
|
2232
|
+
if d is not None and hasattr(d, "display_name"):
|
|
2233
|
+
t = (d.display_name() or "").strip()
|
|
2234
|
+
if t:
|
|
2235
|
+
return t
|
|
2236
|
+
except Exception:
|
|
2237
|
+
pass
|
|
2238
|
+
|
|
2239
|
+
return "Untitled"
|
|
2240
|
+
|
|
2241
|
+
|
|
1927
2242
|
# keep the tab visible if the widget resizes
|
|
1928
2243
|
def resizeEvent(self, ev):
|
|
1929
2244
|
super().resizeEvent(ev)
|
|
@@ -2075,9 +2390,16 @@ class ImageSubWindow(QWidget):
|
|
|
2075
2390
|
|
|
2076
2391
|
# ---------- rendering ----------
|
|
2077
2392
|
def _render(self, rebuild: bool = False):
|
|
2393
|
+
#print("[ImageSubWindow] _render called, rebuild =", rebuild)
|
|
2078
2394
|
"""
|
|
2079
2395
|
Render the current view.
|
|
2080
2396
|
|
|
2397
|
+
Fast path:
|
|
2398
|
+
- rebuild=False: only rescale already-built pixmap/QImage (NO numpy work).
|
|
2399
|
+
Slow path:
|
|
2400
|
+
- rebuild=True: rebuild visualization (autostretch, 8-bit conversion, overlays),
|
|
2401
|
+
refresh QImage/QPixmap cache, then present.
|
|
2402
|
+
|
|
2081
2403
|
Rules:
|
|
2082
2404
|
- If a Preview is active, FIRST sync that preview's stored arr from the
|
|
2083
2405
|
DocManager's ROI document (the thing tools actually modify), then render.
|
|
@@ -2087,46 +2409,53 @@ class ImageSubWindow(QWidget):
|
|
|
2087
2409
|
# ---- GUARD: widget/label may be deleted but document.changed still fires ----
|
|
2088
2410
|
try:
|
|
2089
2411
|
from PyQt6 import sip as _sip
|
|
2090
|
-
# If the whole widget or its label is gone, bail immediately
|
|
2091
2412
|
if _sip.isdeleted(self):
|
|
2092
2413
|
return
|
|
2093
2414
|
lbl = getattr(self, "label", None)
|
|
2094
2415
|
if lbl is None or _sip.isdeleted(lbl):
|
|
2095
2416
|
return
|
|
2096
2417
|
except Exception:
|
|
2097
|
-
# If sip or label is missing for any reason, play it safe
|
|
2098
2418
|
if not hasattr(self, "label"):
|
|
2099
2419
|
return
|
|
2100
|
-
# ---------------------------------------------------------------------------
|
|
2420
|
+
# ---------------------------------------------------------------------------
|
|
2421
|
+
|
|
2422
|
+
# ---------------------------------------------------------------------------
|
|
2423
|
+
# FAST PATH: if we're not rebuilding content and we already have a source pixmap,
|
|
2424
|
+
# just present scaled (fast). This is the key to smooth zoom.
|
|
2425
|
+
# ---------------------------------------------------------------------------
|
|
2426
|
+
if (not rebuild) and getattr(self, "_pm_src", None) is not None:
|
|
2427
|
+
self._present_scaled(interactive=True)
|
|
2428
|
+
return
|
|
2429
|
+
|
|
2101
2430
|
# ---------------------------
|
|
2102
2431
|
# 1) Choose & sync source arr
|
|
2103
2432
|
# ---------------------------
|
|
2104
2433
|
base_img = None
|
|
2105
2434
|
if self._active_source_kind == "preview" and self._active_preview_id is not None:
|
|
2106
2435
|
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2107
|
-
#print("[ImageSubWindow] _render: preview mode, id =", self._active_preview_id, "src =", src is not None)
|
|
2108
2436
|
if src is not None:
|
|
2109
2437
|
# Pull the *edited* ROI image from DocManager, if available
|
|
2110
2438
|
if hasattr(self, "_docman") and self._docman is not None:
|
|
2111
|
-
#print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
|
|
2112
2439
|
try:
|
|
2113
2440
|
roi_doc = self._docman.get_document_for_view(self)
|
|
2114
2441
|
roi_img = getattr(roi_doc, "image", None)
|
|
2442
|
+
# IMPORTANT: only copy on rebuild; zoom should not trigger a copy
|
|
2115
2443
|
if roi_img is not None:
|
|
2116
|
-
|
|
2117
|
-
|
|
2444
|
+
if rebuild or ("arr" not in src) or (src.get("arr") is None):
|
|
2445
|
+
src["arr"] = np.asarray(roi_img).copy()
|
|
2118
2446
|
except Exception:
|
|
2119
2447
|
print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
|
|
2120
|
-
pass
|
|
2121
2448
|
base_img = src.get("arr", None)
|
|
2122
2449
|
else:
|
|
2123
|
-
#print("[ImageSubWindow] _render: full image mode")
|
|
2124
2450
|
base_img = self._display_override if (self._display_override is not None) else (
|
|
2125
2451
|
getattr(self.document, "image", None)
|
|
2126
2452
|
)
|
|
2127
2453
|
|
|
2128
2454
|
if base_img is None:
|
|
2129
2455
|
self._qimg_src = None
|
|
2456
|
+
self._pm_src = None
|
|
2457
|
+
self._pm_src_wcs = None
|
|
2458
|
+
self._buf8 = None
|
|
2130
2459
|
self.label.clear()
|
|
2131
2460
|
return
|
|
2132
2461
|
|
|
@@ -2135,7 +2464,6 @@ class ImageSubWindow(QWidget):
|
|
|
2135
2464
|
# ---------------------------------------
|
|
2136
2465
|
# 2) Normalize dimensionality and dtype
|
|
2137
2466
|
# ---------------------------------------
|
|
2138
|
-
# Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
|
|
2139
2467
|
if arr.ndim == 0:
|
|
2140
2468
|
arr = arr.reshape(1, 1)
|
|
2141
2469
|
elif arr.ndim == 1:
|
|
@@ -2156,7 +2484,7 @@ class ImageSubWindow(QWidget):
|
|
|
2156
2484
|
else:
|
|
2157
2485
|
arr_f = arr.astype(np.float32, copy=False)
|
|
2158
2486
|
mx = float(arr_f.max()) if arr_f.size else 1.0
|
|
2159
|
-
if mx > 5.0:
|
|
2487
|
+
if mx > 5.0:
|
|
2160
2488
|
arr_f = arr_f / mx
|
|
2161
2489
|
|
|
2162
2490
|
vis = autostretch(
|
|
@@ -2192,7 +2520,7 @@ class ImageSubWindow(QWidget):
|
|
|
2192
2520
|
buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
|
|
2193
2521
|
|
|
2194
2522
|
# ---------------------------------------
|
|
2195
|
-
# 5) Optional mask overlay
|
|
2523
|
+
# 5) Optional mask overlay (baked into buf8)
|
|
2196
2524
|
# ---------------------------------------
|
|
2197
2525
|
if getattr(self, "show_mask_overlay", False):
|
|
2198
2526
|
m = self._active_mask_array()
|
|
@@ -2215,9 +2543,9 @@ class ImageSubWindow(QWidget):
|
|
|
2215
2543
|
# ---------------------------------------
|
|
2216
2544
|
if buf8.dtype != np.uint8:
|
|
2217
2545
|
buf8 = buf8.astype(np.uint8)
|
|
2546
|
+
|
|
2218
2547
|
buf8 = ensure_contiguous(buf8)
|
|
2219
2548
|
h, w, c = buf8.shape
|
|
2220
|
-
# Be explicit. RGB888 means 3 bytes per pixel, full stop.
|
|
2221
2549
|
bytes_per_line = int(w * 3)
|
|
2222
2550
|
|
|
2223
2551
|
self._buf8 = buf8 # keep alive
|
|
@@ -2226,11 +2554,9 @@ class ImageSubWindow(QWidget):
|
|
|
2226
2554
|
addr = int(self._buf8.ctypes.data)
|
|
2227
2555
|
ptr = sip.voidptr(addr)
|
|
2228
2556
|
qimg = QImage(ptr, w, h, bytes_per_line, QImage.Format.Format_RGB888)
|
|
2229
|
-
# Defensive: if Qt ever decides the buffer looks wrong, force-copy once
|
|
2230
2557
|
if qimg is None or qimg.isNull():
|
|
2231
2558
|
raise RuntimeError("QImage null")
|
|
2232
2559
|
except Exception:
|
|
2233
|
-
# One safe fall-back copy (still fast, avoids crashes)
|
|
2234
2560
|
buf8c = np.array(self._buf8, copy=True, order="C")
|
|
2235
2561
|
self._buf8 = buf8c
|
|
2236
2562
|
addr = int(self._buf8.ctypes.data)
|
|
@@ -2239,244 +2565,263 @@ class ImageSubWindow(QWidget):
|
|
|
2239
2565
|
|
|
2240
2566
|
self._qimg_src = qimg
|
|
2241
2567
|
if qimg is None or qimg.isNull():
|
|
2568
|
+
self._pm_src = None
|
|
2569
|
+
self._pm_src_wcs = None
|
|
2242
2570
|
self.label.clear()
|
|
2243
2571
|
return
|
|
2244
2572
|
|
|
2245
|
-
#
|
|
2246
|
-
|
|
2247
|
-
# ---------------------------------------
|
|
2248
|
-
sw = max(1, int(qimg.width() * self.scale))
|
|
2249
|
-
sh = max(1, int(qimg.height() * self.scale))
|
|
2250
|
-
scaled = qimg.scaled(
|
|
2251
|
-
sw, sh,
|
|
2252
|
-
Qt.AspectRatioMode.KeepAspectRatio,
|
|
2253
|
-
Qt.TransformationMode.SmoothTransformation
|
|
2254
|
-
)
|
|
2573
|
+
# Cache unscaled pixmap ONCE per rebuild
|
|
2574
|
+
self._pm_src = QPixmap.fromImage(self._qimg_src)
|
|
2255
2575
|
|
|
2256
|
-
#
|
|
2257
|
-
|
|
2258
|
-
wcs2 = self._get_celestial_wcs()
|
|
2259
|
-
if wcs2 is not None:
|
|
2260
|
-
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
|
|
2261
|
-
from PyQt6.QtCore import QSettings
|
|
2262
|
-
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
2263
|
-
import numpy as _np
|
|
2264
|
-
|
|
2265
|
-
pm = QPixmap.fromImage(scaled)
|
|
2266
|
-
|
|
2267
|
-
# Read user prefs (fallback to defaults if not set)
|
|
2268
|
-
_settings = getattr(self, "_settings", None) or QSettings()
|
|
2269
|
-
pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
|
|
2270
|
-
pref_mode = _settings.value("wcs_grid/mode", "auto", type=str) # "auto" | "fixed"
|
|
2271
|
-
pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str) # "deg" | "arcmin"
|
|
2272
|
-
pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
|
|
2273
|
-
|
|
2274
|
-
if not pref_enabled:
|
|
2275
|
-
# User disabled the grid in Preferences — skip overlay
|
|
2276
|
-
self.label.setPixmap(QPixmap.fromImage(scaled))
|
|
2277
|
-
self.label.resize(scaled.size())
|
|
2278
|
-
return
|
|
2576
|
+
# Invalidate any cached “WCS baked” pixmap on rebuild
|
|
2577
|
+
self._pm_src_wcs = None
|
|
2279
2578
|
|
|
2280
|
-
|
|
2579
|
+
# Present final-quality after rebuild
|
|
2580
|
+
self._present_scaled(interactive=False)
|
|
2281
2581
|
|
|
2282
|
-
|
|
2283
|
-
px_scales_deg = proj_plane_pixel_scales(wcs2) # deg/pix for the two celestial axes
|
|
2284
|
-
px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
|
|
2582
|
+
rebuild = False # done
|
|
2285
2583
|
|
|
2286
|
-
H_full, W_full = display_h, display_w
|
|
2287
|
-
fov_deg = px_deg * float(max(W_full, H_full))
|
|
2288
2584
|
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
try:
|
|
2303
|
-
ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
|
|
2304
|
-
ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
|
|
2305
|
-
dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
|
|
2306
|
-
if ra_max - ra_min > 300:
|
|
2307
|
-
ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
|
|
2308
|
-
ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
|
|
2309
|
-
ra_shift = 180.0
|
|
2310
|
-
else:
|
|
2311
|
-
ra_shift = 0.0
|
|
2312
|
-
except Exception:
|
|
2313
|
-
ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
|
|
2314
|
-
|
|
2315
|
-
p = QPainter(pm)
|
|
2316
|
-
pen = QPen(); pen.setWidth(1); pen.setColor(QColor(255, 255, 255, 140))
|
|
2317
|
-
p.setPen(pen)
|
|
2318
|
-
s = float(self.scale)
|
|
2319
|
-
img_w = int(W_full * s)
|
|
2320
|
-
img_h = int(H_full * s)
|
|
2321
|
-
Wf, Hf = float(W_full), float(H_full)
|
|
2322
|
-
margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
|
|
2323
|
-
def draw_world_poly(xs_world, ys_world):
|
|
2324
|
-
try:
|
|
2325
|
-
px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
|
|
2326
|
-
except Exception:
|
|
2327
|
-
return
|
|
2328
|
-
|
|
2329
|
-
px = _np.asarray(px, dtype=float)
|
|
2330
|
-
py = _np.asarray(py, dtype=float)
|
|
2331
|
-
|
|
2332
|
-
# --- validity mask ---
|
|
2333
|
-
ok = _np.isfinite(px) & _np.isfinite(py)
|
|
2334
|
-
|
|
2335
|
-
# Allow a margin around the image so near-edge lines still draw
|
|
2336
|
-
margin = float(max(Wf, Hf) * 2.0) # 2x image size margin
|
|
2337
|
-
ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
|
|
2338
|
-
ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
|
|
2339
|
-
|
|
2340
|
-
for i in range(1, len(px)):
|
|
2341
|
-
if not (ok[i-1] and ok[i]):
|
|
2342
|
-
continue
|
|
2343
|
-
|
|
2344
|
-
x0 = float(px[i-1]) * s
|
|
2345
|
-
y0 = float(py[i-1]) * s
|
|
2346
|
-
x1 = float(px[i]) * s
|
|
2347
|
-
y1 = float(py[i]) * s
|
|
2348
|
-
|
|
2349
|
-
# Final sanity gate before int() -> Qt 32-bit
|
|
2350
|
-
if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
|
|
2351
|
-
continue
|
|
2352
|
-
|
|
2353
|
-
p.drawLine(int(x0), int(y0), int(x1), int(y1))
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
|
|
2357
|
-
ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
|
|
2358
|
-
dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
|
|
2359
|
-
|
|
2360
|
-
# DEC lines (horiz-ish)
|
|
2361
|
-
def _frange(a,b,s):
|
|
2362
|
-
out=[]; x=a
|
|
2363
|
-
while x <= b + 1e-9:
|
|
2364
|
-
out.append(x); x += s
|
|
2365
|
-
return out
|
|
2366
|
-
def _round_to(x,s): return s * round(x/s)
|
|
2367
|
-
|
|
2368
|
-
ra_start = _round_to(ra_min, step_deg)
|
|
2369
|
-
dec_start = _round_to(dec_min, step_deg)
|
|
2370
|
-
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2371
|
-
dec_arr = _np.full_like(ra_samples_wrapped, dec)
|
|
2372
|
-
draw_world_poly(ra_samples_wrapped, dec_arr)
|
|
2373
|
-
|
|
2374
|
-
# RA lines (vert-ish)
|
|
2375
|
-
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2376
|
-
ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
|
|
2377
|
-
draw_world_poly(ra_arr, dec_samples)
|
|
2378
|
-
|
|
2379
|
-
# ── LABELS for RA/Dec lines ─────────────────────────────────
|
|
2380
|
-
# Font & box style
|
|
2381
|
-
font = QFont(); font.setPixelSize(11) # screen-consistent
|
|
2382
|
-
p.setFont(font)
|
|
2383
|
-
text_pen = QPen(QColor(255, 255, 255, 230))
|
|
2384
|
-
box_brush = QBrush(QColor(0, 0, 0, 140))
|
|
2385
|
-
p.setPen(text_pen)
|
|
2386
|
-
|
|
2387
|
-
def _draw_label(x, y, txt, anchor="lt"):
|
|
2388
|
-
if not _np.isfinite([x, y]).all():
|
|
2389
|
-
return
|
|
2390
|
-
fm = p.fontMetrics()
|
|
2391
|
-
wtxt = fm.horizontalAdvance(txt) + 6
|
|
2392
|
-
htxt = fm.height() + 4
|
|
2393
|
-
|
|
2394
|
-
# initial placement with a little padding
|
|
2395
|
-
if anchor == "lt": # left-top
|
|
2396
|
-
rx, ry = int(x) + 4, int(y) + 3
|
|
2397
|
-
elif anchor == "rt": # right-top
|
|
2398
|
-
rx, ry = int(x) - wtxt - 4, int(y) + 3
|
|
2399
|
-
elif anchor == "lb": # left-bottom
|
|
2400
|
-
rx, ry = int(x) + 4, int(y) - htxt - 3
|
|
2401
|
-
else: # center-top
|
|
2402
|
-
rx, ry = int(x) - wtxt // 2, int(y) + 3
|
|
2403
|
-
|
|
2404
|
-
# clamp entirely inside the image
|
|
2405
|
-
rx = max(0, min(rx, img_w - wtxt - 1))
|
|
2406
|
-
ry = max(0, min(ry, img_h - htxt - 1))
|
|
2407
|
-
|
|
2408
|
-
rect = QRect(rx, ry, wtxt, htxt)
|
|
2409
|
-
p.save()
|
|
2410
|
-
p.setBrush(box_brush)
|
|
2411
|
-
p.setPen(Qt.PenStyle.NoPen)
|
|
2412
|
-
p.drawRoundedRect(rect, 4, 4)
|
|
2413
|
-
p.restore()
|
|
2414
|
-
p.drawText(rect.adjusted(3, 2, -3, -2),
|
|
2415
|
-
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
# DEC labels on left edge
|
|
2419
|
-
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2420
|
-
try:
|
|
2421
|
-
x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
|
|
2422
|
-
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2423
|
-
continue
|
|
2424
|
-
# clamp to image bounds before scaling
|
|
2425
|
-
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2426
|
-
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2427
|
-
_draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
|
|
2428
|
-
except Exception:
|
|
2429
|
-
pass
|
|
2585
|
+
def _present_scaled(self, interactive: bool):
|
|
2586
|
+
"""
|
|
2587
|
+
Present the cached source pixmap scaled to current self.scale.
|
|
2588
|
+
|
|
2589
|
+
interactive=True:
|
|
2590
|
+
- Fast scaling
|
|
2591
|
+
- No WCS draw
|
|
2592
|
+
interactive=False:
|
|
2593
|
+
- Smooth scaling
|
|
2594
|
+
- Optionally draw WCS overlay once
|
|
2595
|
+
"""
|
|
2596
|
+
if getattr(self, "_pm_src", None) is None:
|
|
2597
|
+
return
|
|
2430
2598
|
|
|
2431
|
-
|
|
2432
|
-
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2433
|
-
ra_wrapped = (ra + ra_shift) % 360.0
|
|
2434
|
-
try:
|
|
2435
|
-
x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
|
|
2436
|
-
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2437
|
-
continue
|
|
2438
|
-
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2439
|
-
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2440
|
-
_draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
|
|
2441
|
-
except Exception:
|
|
2442
|
-
pass
|
|
2599
|
+
pm_base = self._pm_src
|
|
2443
2600
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2601
|
+
sw = max(1, int(pm_base.width() * self.scale))
|
|
2602
|
+
sh = max(1, int(pm_base.height() * self.scale))
|
|
2446
2603
|
|
|
2447
|
-
|
|
2604
|
+
mode = Qt.TransformationMode.FastTransformation if interactive else Qt.TransformationMode.SmoothTransformation
|
|
2605
|
+
pm_scaled = pm_base.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio, mode)
|
|
2448
2606
|
|
|
2449
|
-
|
|
2450
|
-
|
|
2607
|
+
# If interactive, skip WCS overlay entirely (this is the biggest speed win)
|
|
2608
|
+
if interactive:
|
|
2609
|
+
self.label.setPixmap(pm_scaled)
|
|
2610
|
+
self.label.resize(pm_scaled.size())
|
|
2611
|
+
return
|
|
2451
2612
|
|
|
2613
|
+
# Non-interactive: (optionally) draw WCS grid.
|
|
2614
|
+
if getattr(self, "_show_wcs_grid", False):
|
|
2615
|
+
# Cache a baked WCS pixmap at *this* scale to avoid re-drawing
|
|
2616
|
+
# if _present_scaled(False) is called multiple times at same scale.
|
|
2617
|
+
cache_key = (sw, sh, float(self.scale))
|
|
2618
|
+
if getattr(self, "_pm_src_wcs_key", None) != cache_key or getattr(self, "_pm_src_wcs", None) is None:
|
|
2619
|
+
pm_scaled = self._draw_wcs_grid_on_pixmap(pm_scaled)
|
|
2620
|
+
self._pm_src_wcs = pm_scaled
|
|
2621
|
+
self._pm_src_wcs_key = cache_key
|
|
2622
|
+
else:
|
|
2623
|
+
pm_scaled = self._pm_src_wcs
|
|
2452
2624
|
|
|
2625
|
+
self.label.setPixmap(pm_scaled)
|
|
2626
|
+
self.label.resize(pm_scaled.size())
|
|
2453
2627
|
|
|
2454
|
-
def has_active_preview(self) -> bool:
|
|
2455
|
-
return self._active_source_kind == "preview" and self._active_preview_id is not None
|
|
2456
2628
|
|
|
2457
|
-
def
|
|
2629
|
+
def _draw_wcs_grid_on_pixmap(self, pm_scaled: QPixmap) -> QPixmap:
|
|
2458
2630
|
"""
|
|
2459
|
-
|
|
2631
|
+
Your existing WCS painter logic, moved to operate on a QPixmap (already scaled).
|
|
2632
|
+
Runs ONLY on non-interactive redraw.
|
|
2460
2633
|
"""
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2634
|
+
wcs2 = self._get_celestial_wcs()
|
|
2635
|
+
if wcs2 is None:
|
|
2636
|
+
return pm_scaled
|
|
2637
|
+
|
|
2638
|
+
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
|
|
2639
|
+
from PyQt6.QtCore import QSettings, QRect
|
|
2640
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
2641
|
+
import numpy as _np
|
|
2642
|
+
|
|
2643
|
+
_settings = getattr(self, "_settings", None) or QSettings()
|
|
2644
|
+
pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
|
|
2645
|
+
pref_mode = _settings.value("wcs_grid/mode", "auto", type=str)
|
|
2646
|
+
pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str)
|
|
2647
|
+
pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
|
|
2648
|
+
|
|
2649
|
+
if not pref_enabled:
|
|
2650
|
+
return pm_scaled
|
|
2651
|
+
|
|
2652
|
+
# Determine full image geometry from the CURRENT SOURCE buffer (not pm_scaled)
|
|
2653
|
+
# We can infer W/H from qimg src (original)
|
|
2654
|
+
if getattr(self, "_qimg_src", None) is None:
|
|
2655
|
+
return pm_scaled
|
|
2656
|
+
H_full = int(self._qimg_src.height())
|
|
2657
|
+
W_full = int(self._qimg_src.width())
|
|
2658
|
+
|
|
2659
|
+
# Pixel scales/FOV
|
|
2660
|
+
px_scales_deg = proj_plane_pixel_scales(wcs2)
|
|
2661
|
+
px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
|
|
2662
|
+
fov_deg = px_deg * float(max(W_full, H_full))
|
|
2663
|
+
|
|
2664
|
+
if pref_mode == "fixed":
|
|
2665
|
+
step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
|
|
2666
|
+
step_deg = max(1e-6, min(step_deg, 90.0))
|
|
2667
|
+
else:
|
|
2668
|
+
nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
|
|
2669
|
+
target_lines = 8
|
|
2670
|
+
desired = max(fov_deg / target_lines, px_deg * 100)
|
|
2671
|
+
step_deg = min((n for n in nice if n >= desired), default=30)
|
|
2465
2672
|
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2673
|
+
# World bounds from corners
|
|
2674
|
+
corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
|
|
2675
|
+
try:
|
|
2676
|
+
ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
|
|
2677
|
+
ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
|
|
2678
|
+
dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
|
|
2679
|
+
if ra_max - ra_min > 300:
|
|
2680
|
+
ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
|
|
2681
|
+
ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
|
|
2682
|
+
ra_shift = 180.0
|
|
2683
|
+
else:
|
|
2684
|
+
ra_shift = 0.0
|
|
2685
|
+
except Exception:
|
|
2686
|
+
ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
|
|
2687
|
+
|
|
2688
|
+
pm = QPixmap(pm_scaled) # copy so we don’t mutate caller
|
|
2689
|
+
p = QPainter(pm)
|
|
2690
|
+
pen = QPen(QColor(255, 255, 255, 140))
|
|
2691
|
+
pen.setWidth(1)
|
|
2692
|
+
p.setPen(pen)
|
|
2693
|
+
|
|
2694
|
+
# Scale factor between full-res image and pm_scaled
|
|
2695
|
+
s = float(pm.width()) / float(max(1, W_full))
|
|
2696
|
+
|
|
2697
|
+
Wf, Hf = float(W_full), float(H_full)
|
|
2698
|
+
|
|
2699
|
+
def draw_world_poly(xs_world, ys_world):
|
|
2700
|
+
try:
|
|
2701
|
+
px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
|
|
2702
|
+
except Exception:
|
|
2703
|
+
return
|
|
2704
|
+
|
|
2705
|
+
px = _np.asarray(px, dtype=float)
|
|
2706
|
+
py = _np.asarray(py, dtype=float)
|
|
2707
|
+
|
|
2708
|
+
ok = _np.isfinite(px) & _np.isfinite(py)
|
|
2709
|
+
margin = float(max(Wf, Hf) * 2.0)
|
|
2710
|
+
ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
|
|
2711
|
+
ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
|
|
2712
|
+
|
|
2713
|
+
for i in range(1, len(px)):
|
|
2714
|
+
if not (ok[i-1] and ok[i]):
|
|
2715
|
+
continue
|
|
2716
|
+
x0 = float(px[i-1]) * s
|
|
2717
|
+
y0 = float(py[i-1]) * s
|
|
2718
|
+
x1 = float(px[i]) * s
|
|
2719
|
+
y1 = float(py[i]) * s
|
|
2720
|
+
if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
|
|
2721
|
+
continue
|
|
2722
|
+
p.drawLine(int(x0), int(y0), int(x1), int(y1))
|
|
2723
|
+
|
|
2724
|
+
ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
|
|
2725
|
+
ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
|
|
2726
|
+
dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
|
|
2727
|
+
|
|
2728
|
+
def _frange(a, b, sstep):
|
|
2729
|
+
out = []
|
|
2730
|
+
x = a
|
|
2731
|
+
while x <= b + 1e-9:
|
|
2732
|
+
out.append(x)
|
|
2733
|
+
x += sstep
|
|
2734
|
+
return out
|
|
2735
|
+
|
|
2736
|
+
def _round_to(x, sstep):
|
|
2737
|
+
return sstep * round(x / sstep)
|
|
2738
|
+
|
|
2739
|
+
ra_start = _round_to(ra_min, step_deg)
|
|
2740
|
+
dec_start = _round_to(dec_min, step_deg)
|
|
2741
|
+
|
|
2742
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2743
|
+
dec_arr = _np.full_like(ra_samples_wrapped, dec)
|
|
2744
|
+
draw_world_poly(ra_samples_wrapped, dec_arr)
|
|
2745
|
+
|
|
2746
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2747
|
+
ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
|
|
2748
|
+
draw_world_poly(ra_arr, dec_samples)
|
|
2749
|
+
|
|
2750
|
+
# Labels
|
|
2751
|
+
font = QFont()
|
|
2752
|
+
font.setPixelSize(11)
|
|
2753
|
+
p.setFont(font)
|
|
2754
|
+
text_pen = QPen(QColor(255, 255, 255, 230))
|
|
2755
|
+
box_brush = QBrush(QColor(0, 0, 0, 140))
|
|
2756
|
+
p.setPen(text_pen)
|
|
2757
|
+
|
|
2758
|
+
img_w = pm.width()
|
|
2759
|
+
img_h = pm.height()
|
|
2760
|
+
|
|
2761
|
+
def _draw_label(x, y, txt, anchor="lt"):
|
|
2762
|
+
if not _np.isfinite([x, y]).all():
|
|
2763
|
+
return
|
|
2764
|
+
fm = p.fontMetrics()
|
|
2765
|
+
wtxt = fm.horizontalAdvance(txt) + 6
|
|
2766
|
+
htxt = fm.height() + 4
|
|
2767
|
+
|
|
2768
|
+
if anchor == "lt":
|
|
2769
|
+
rx, ry = int(x) + 4, int(y) + 3
|
|
2770
|
+
elif anchor == "rt":
|
|
2771
|
+
rx, ry = int(x) - wtxt - 4, int(y) + 3
|
|
2772
|
+
elif anchor == "lb":
|
|
2773
|
+
rx, ry = int(x) + 4, int(y) - htxt - 3
|
|
2774
|
+
else:
|
|
2775
|
+
rx, ry = int(x) - wtxt // 2, int(y) + 3
|
|
2776
|
+
|
|
2777
|
+
rx = max(0, min(rx, img_w - wtxt - 1))
|
|
2778
|
+
ry = max(0, min(ry, img_h - htxt - 1))
|
|
2779
|
+
|
|
2780
|
+
rect = QRect(rx, ry, wtxt, htxt)
|
|
2781
|
+
p.save()
|
|
2782
|
+
p.setBrush(box_brush)
|
|
2783
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
2784
|
+
p.drawRoundedRect(rect, 4, 4)
|
|
2785
|
+
p.restore()
|
|
2786
|
+
p.drawText(rect.adjusted(3, 2, -3, -2),
|
|
2787
|
+
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
|
|
2788
|
+
|
|
2789
|
+
# DEC labels on left edge
|
|
2790
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2791
|
+
try:
|
|
2792
|
+
x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
|
|
2793
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2794
|
+
continue
|
|
2795
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2796
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2797
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
|
|
2798
|
+
except Exception:
|
|
2799
|
+
pass
|
|
2800
|
+
|
|
2801
|
+
# RA labels on top edge
|
|
2802
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2803
|
+
ra_wrapped = (ra + ra_shift) % 360.0
|
|
2804
|
+
try:
|
|
2805
|
+
x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
|
|
2806
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2807
|
+
continue
|
|
2808
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2809
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2810
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
|
|
2811
|
+
except Exception:
|
|
2812
|
+
pass
|
|
2813
|
+
|
|
2814
|
+
p.end()
|
|
2815
|
+
return pm
|
|
2471
2816
|
|
|
2472
2817
|
|
|
2473
2818
|
# ---------- interaction ----------
|
|
2474
2819
|
def _zoom_at_anchor(self, factor: float):
|
|
2475
|
-
if self
|
|
2820
|
+
if getattr(self, "_qimg_src", None) is None and getattr(self, "_pm_src", None) is None:
|
|
2476
2821
|
return
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
|
|
2822
|
+
|
|
2823
|
+
old_scale = float(self.scale)
|
|
2824
|
+
new_scale = max(self._min_scale, min(old_scale * float(factor), self._max_scale))
|
|
2480
2825
|
if abs(new_scale - old_scale) < 1e-8:
|
|
2481
2826
|
return
|
|
2482
2827
|
|
|
@@ -2484,7 +2829,6 @@ class ImageSubWindow(QWidget):
|
|
|
2484
2829
|
hbar = self.scroll.horizontalScrollBar()
|
|
2485
2830
|
vbar = self.scroll.verticalScrollBar()
|
|
2486
2831
|
|
|
2487
|
-
# Anchor in viewport coordinates via global cursor (robust)
|
|
2488
2832
|
try:
|
|
2489
2833
|
anchor_vp = vp.mapFromGlobal(QCursor.pos())
|
|
2490
2834
|
except Exception:
|
|
@@ -2493,34 +2837,77 @@ class ImageSubWindow(QWidget):
|
|
|
2493
2837
|
if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
|
|
2494
2838
|
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
2495
2839
|
|
|
2496
|
-
# Current label coords under the anchor
|
|
2497
2840
|
x_label_pre = hbar.value() + anchor_vp.x()
|
|
2498
2841
|
y_label_pre = vbar.value() + anchor_vp.y()
|
|
2499
2842
|
|
|
2500
|
-
# Convert to image coords at old scale
|
|
2501
2843
|
xi = x_label_pre / max(old_scale, 1e-12)
|
|
2502
2844
|
yi = y_label_pre / max(old_scale, 1e-12)
|
|
2503
2845
|
|
|
2504
|
-
# Apply scale
|
|
2846
|
+
# Apply new scale
|
|
2505
2847
|
self.scale = new_scale
|
|
2506
|
-
self._render(rebuild=False)
|
|
2507
2848
|
|
|
2508
|
-
#
|
|
2849
|
+
# FAST present (no rebuild)
|
|
2850
|
+
self._present_scaled(interactive=True)
|
|
2851
|
+
|
|
2852
|
+
# Keep anchor stable
|
|
2509
2853
|
x_label_post = xi * new_scale
|
|
2510
2854
|
y_label_post = yi * new_scale
|
|
2511
2855
|
|
|
2512
|
-
# Desired scrollbar values to keep point under the cursor
|
|
2513
2856
|
new_h = int(round(x_label_post - anchor_vp.x()))
|
|
2514
2857
|
new_v = int(round(y_label_post - anchor_vp.y()))
|
|
2515
2858
|
|
|
2516
|
-
# Clamp to valid range
|
|
2517
2859
|
new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
|
|
2518
2860
|
new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
|
|
2519
2861
|
|
|
2520
|
-
# Apply
|
|
2521
2862
|
hbar.setValue(new_h)
|
|
2522
2863
|
vbar.setValue(new_v)
|
|
2523
|
-
|
|
2864
|
+
|
|
2865
|
+
# Defer one final smooth redraw (and WCS overlay) after the burst
|
|
2866
|
+
self._request_zoom_redraw()
|
|
2867
|
+
|
|
2868
|
+
|
|
2869
|
+
def _request_zoom_redraw(self):
|
|
2870
|
+
if getattr(self, "_zoom_timer", None) is None:
|
|
2871
|
+
self._zoom_timer = QTimer(self)
|
|
2872
|
+
self._zoom_timer.setSingleShot(True)
|
|
2873
|
+
self._zoom_timer.timeout.connect(self._apply_zoom_redraw)
|
|
2874
|
+
|
|
2875
|
+
# 60–120ms feels better than 16ms for “zoom burst collapse”
|
|
2876
|
+
# but keep your 16ms if you prefer.
|
|
2877
|
+
self._zoom_timer.start(90)
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def _apply_zoom_redraw(self):
|
|
2881
|
+
"""
|
|
2882
|
+
Final “settled” redraw:
|
|
2883
|
+
- SmoothTransformation
|
|
2884
|
+
- Optional WCS grid overlay
|
|
2885
|
+
"""
|
|
2886
|
+
if getattr(self, "_pm_src", None) is None:
|
|
2887
|
+
return
|
|
2888
|
+
self._present_scaled(interactive=False)
|
|
2889
|
+
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
def has_active_preview(self) -> bool:
|
|
2893
|
+
return self._active_source_kind == "preview" and self._active_preview_id is not None
|
|
2894
|
+
|
|
2895
|
+
def current_preview_roi(self) -> tuple[int,int,int,int] | None:
|
|
2896
|
+
"""
|
|
2897
|
+
Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
|
|
2898
|
+
"""
|
|
2899
|
+
if not self.has_active_preview():
|
|
2900
|
+
return None
|
|
2901
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2902
|
+
return None if src is None else tuple(src["roi"])
|
|
2903
|
+
|
|
2904
|
+
def current_preview_name(self) -> str | None:
|
|
2905
|
+
if not self.has_active_preview():
|
|
2906
|
+
return None
|
|
2907
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2908
|
+
return None if src is None else src["name"]
|
|
2909
|
+
|
|
2910
|
+
|
|
2524
2911
|
|
|
2525
2912
|
def _find_main_window(self):
|
|
2526
2913
|
p = self.parent()
|
|
@@ -2653,37 +3040,76 @@ class ImageSubWindow(QWidget):
|
|
|
2653
3040
|
return True
|
|
2654
3041
|
return False
|
|
2655
3042
|
|
|
3043
|
+
sw = self._mdi_subwindow()
|
|
3044
|
+
if sw is not None and obj is sw:
|
|
3045
|
+
et = ev.type()
|
|
3046
|
+
if et in (QEvent.Type.WindowStateChange, QEvent.Type.Show, QEvent.Type.Resize):
|
|
3047
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
3048
|
+
|
|
2656
3049
|
return super().eventFilter(obj, ev)
|
|
2657
3050
|
|
|
3051
|
+
def _viewport_pos_to_image_xy(self, vp_pos: QPoint) -> tuple[int, int] | None:
|
|
3052
|
+
"""
|
|
3053
|
+
Convert a point in viewport coordinates to FULL image pixel coordinates.
|
|
3054
|
+
Returns None if the point is outside the displayed pixmap (in margins).
|
|
3055
|
+
"""
|
|
3056
|
+
pm = self.label.pixmap()
|
|
3057
|
+
if pm is None:
|
|
3058
|
+
return None
|
|
3059
|
+
|
|
3060
|
+
# Convert viewport point into label coordinates
|
|
3061
|
+
p_label = self.label.mapFrom(self.scroll.viewport(), vp_pos)
|
|
3062
|
+
|
|
3063
|
+
# If label is larger than pixmap, pixmap may be centered inside label.
|
|
3064
|
+
pm_w, pm_h = pm.width(), pm.height()
|
|
3065
|
+
lbl_w, lbl_h = self.label.width(), self.label.height()
|
|
3066
|
+
|
|
3067
|
+
off_x = max(0, (lbl_w - pm_w) // 2)
|
|
3068
|
+
off_y = max(0, (lbl_h - pm_h) // 2)
|
|
3069
|
+
|
|
3070
|
+
px = p_label.x() - off_x
|
|
3071
|
+
py = p_label.y() - off_y
|
|
3072
|
+
|
|
3073
|
+
# Outside the drawn pixmap area → clamp
|
|
3074
|
+
px = max(0, min(px, pm_w - 1))
|
|
3075
|
+
py = max(0, min(py, pm_h - 1))
|
|
3076
|
+
|
|
3077
|
+
s = max(float(self.scale), 1e-12)
|
|
3078
|
+
|
|
3079
|
+
# pixmap pixels -> image pixels (pm = image * scale)
|
|
3080
|
+
xi = int(round(px / s))
|
|
3081
|
+
yi = int(round(py / s))
|
|
3082
|
+
return xi, yi
|
|
3083
|
+
|
|
2658
3084
|
|
|
2659
3085
|
def _finish_preview_rect(self, vp_rect: QRect):
|
|
2660
|
-
# Map viewport rectangle into image coordinates
|
|
2661
3086
|
if vp_rect.width() < 4 or vp_rect.height() < 4:
|
|
2662
3087
|
self._cancel_rubber()
|
|
2663
3088
|
return
|
|
2664
3089
|
|
|
2665
|
-
|
|
2666
|
-
|
|
3090
|
+
# Convert the two corners from viewport space to image space
|
|
3091
|
+
p0 = self._viewport_pos_to_image_xy(vp_rect.topLeft())
|
|
3092
|
+
p1 = self._viewport_pos_to_image_xy(vp_rect.bottomRight())
|
|
2667
3093
|
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
3094
|
+
if p0 is None or p1 is None:
|
|
3095
|
+
# User dragged into margins; you can either cancel or clamp.
|
|
3096
|
+
# Cancel is simplest:
|
|
3097
|
+
self._cancel_rubber()
|
|
3098
|
+
return
|
|
2673
3099
|
|
|
2674
|
-
|
|
3100
|
+
x0, y0 = p0
|
|
3101
|
+
x1, y1 = p1
|
|
2675
3102
|
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
3103
|
+
x = min(x0, x1)
|
|
3104
|
+
y = min(y0, y1)
|
|
3105
|
+
w = abs(x1 - x0)
|
|
3106
|
+
h = abs(y1 - y0)
|
|
2680
3107
|
|
|
2681
|
-
if
|
|
3108
|
+
if w < 1 or h < 1:
|
|
2682
3109
|
self._cancel_rubber()
|
|
2683
3110
|
return
|
|
2684
3111
|
|
|
2685
|
-
|
|
2686
|
-
self._create_preview_from_roi(roi)
|
|
3112
|
+
self._create_preview_from_roi((x, y, w, h))
|
|
2687
3113
|
self._cancel_rubber()
|
|
2688
3114
|
|
|
2689
3115
|
def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
|