setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__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/TextureClarity.svg +56 -0
- 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/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -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 +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- 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 +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- 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 +706 -264
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- 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 +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -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 +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- 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 +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- 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/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- 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 +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- 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 +209 -111
- 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.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.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
|
|
|
@@ -519,6 +590,7 @@ class ImageSubWindow(QWidget):
|
|
|
519
590
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
520
591
|
self.customContextMenuRequested.connect(self._show_ctx_menu)
|
|
521
592
|
QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
|
|
593
|
+
QShortcut(QKeySequence("F3"), self, activated=self._rename_document)
|
|
522
594
|
#QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
|
|
523
595
|
QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
|
|
524
596
|
QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
|
|
@@ -549,6 +621,9 @@ class ImageSubWindow(QWidget):
|
|
|
549
621
|
self._history_doc = None
|
|
550
622
|
self._install_history_watchers()
|
|
551
623
|
|
|
624
|
+
QTimer.singleShot(0, self._install_mdi_state_watch)
|
|
625
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
626
|
+
|
|
552
627
|
# ----- link drag payload -----
|
|
553
628
|
def _start_link_drag(self):
|
|
554
629
|
"""
|
|
@@ -680,7 +755,60 @@ class ImageSubWindow(QWidget):
|
|
|
680
755
|
except Exception:
|
|
681
756
|
pass
|
|
682
757
|
|
|
683
|
-
|
|
758
|
+
# ------------------------------------------------------------
|
|
759
|
+
# MDI maximize handling: show inline title + avoid duplicate buttons
|
|
760
|
+
# ------------------------------------------------------------
|
|
761
|
+
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
762
|
+
w = self.parentWidget()
|
|
763
|
+
while w is not None and not isinstance(w, QMdiSubWindow):
|
|
764
|
+
w = w.parentWidget()
|
|
765
|
+
return w
|
|
766
|
+
|
|
767
|
+
def _install_mdi_state_watch(self):
|
|
768
|
+
sw = self._mdi_subwindow()
|
|
769
|
+
if sw is None:
|
|
770
|
+
return
|
|
771
|
+
# Watch maximize/restore changes on the hosting QMdiSubWindow
|
|
772
|
+
sw.installEventFilter(self)
|
|
773
|
+
|
|
774
|
+
def _is_mdi_maximized(self) -> bool:
|
|
775
|
+
sw = self._mdi_subwindow()
|
|
776
|
+
if sw is None:
|
|
777
|
+
return False
|
|
778
|
+
try:
|
|
779
|
+
return sw.isMaximized()
|
|
780
|
+
except Exception:
|
|
781
|
+
return False
|
|
782
|
+
|
|
783
|
+
def _set_mdi_minmax_buttons_enabled(self, enabled: bool):
|
|
784
|
+
return # leave Qt default buttons alone
|
|
785
|
+
|
|
786
|
+
def _current_view_title_for_inline(self) -> str:
|
|
787
|
+
# Prefer your already-pretty title (strip decorations if needed).
|
|
788
|
+
try:
|
|
789
|
+
# If you have _current_view_title_for_drag already, reuse it:
|
|
790
|
+
return self._current_view_title_for_drag()
|
|
791
|
+
except Exception:
|
|
792
|
+
pass
|
|
793
|
+
try:
|
|
794
|
+
return (self.windowTitle() or "").strip()
|
|
795
|
+
except Exception:
|
|
796
|
+
return ""
|
|
797
|
+
|
|
798
|
+
def _update_inline_title_and_buttons(self):
|
|
799
|
+
maximized = self._is_mdi_maximized()
|
|
800
|
+
|
|
801
|
+
# Show inline title only when maximized (optional)
|
|
802
|
+
try:
|
|
803
|
+
self._inline_title.setVisible(maximized)
|
|
804
|
+
if maximized:
|
|
805
|
+
self._inline_title.setText(self._current_view_title_for_inline() or "Untitled")
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
# IMPORTANT: do NOT change QMdiSubWindow window flags.
|
|
810
|
+
# Leaving them alone restores the default Qt "double button" behavior.
|
|
811
|
+
|
|
684
812
|
#------ Replay helpers------
|
|
685
813
|
def _update_replay_button(self):
|
|
686
814
|
"""
|
|
@@ -741,15 +869,6 @@ class ImageSubWindow(QWidget):
|
|
|
741
869
|
enabled = bool(has_preview and (has_history or has_last))
|
|
742
870
|
btn.setEnabled(enabled)
|
|
743
871
|
|
|
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
872
|
|
|
754
873
|
def _replay_history_index(self, index: int):
|
|
755
874
|
"""
|
|
@@ -809,11 +928,7 @@ class ImageSubWindow(QWidget):
|
|
|
809
928
|
except Exception:
|
|
810
929
|
pass
|
|
811
930
|
|
|
812
|
-
|
|
813
|
-
try:
|
|
814
|
-
print(f"[Replay] Emitting replayOnBaseRequested from view id={id(self)}")
|
|
815
|
-
except Exception:
|
|
816
|
-
pass
|
|
931
|
+
|
|
817
932
|
self.replayOnBaseRequested.emit(self)
|
|
818
933
|
|
|
819
934
|
|
|
@@ -823,13 +938,20 @@ class ImageSubWindow(QWidget):
|
|
|
823
938
|
self._emit_view_transform()
|
|
824
939
|
|
|
825
940
|
def set_view_transform(self, scale, hval, vval, from_link=False):
|
|
826
|
-
# Avoid storms while we mutate scrollbars/scale
|
|
827
941
|
self._suppress_link_emit = True
|
|
828
942
|
try:
|
|
829
943
|
scale = float(max(self._min_scale, min(scale, self._max_scale)))
|
|
830
|
-
|
|
944
|
+
|
|
945
|
+
scale_changed = (abs(scale - self.scale) > 1e-9)
|
|
946
|
+
if scale_changed:
|
|
831
947
|
self.scale = scale
|
|
832
|
-
self._render(rebuild=False)
|
|
948
|
+
self._render(rebuild=False) # fast present for responsiveness
|
|
949
|
+
|
|
950
|
+
# ✅ NEW: schedule the final smooth redraw (same as main zoom path)
|
|
951
|
+
try:
|
|
952
|
+
self._request_zoom_redraw()
|
|
953
|
+
except Exception:
|
|
954
|
+
pass
|
|
833
955
|
|
|
834
956
|
hbar = self.scroll.horizontalScrollBar()
|
|
835
957
|
vbar = self.scroll.verticalScrollBar()
|
|
@@ -841,14 +963,14 @@ class ImageSubWindow(QWidget):
|
|
|
841
963
|
finally:
|
|
842
964
|
self._suppress_link_emit = False
|
|
843
965
|
|
|
844
|
-
# IMPORTANT: if this came from a linked peer, do NOT broadcast again.
|
|
845
966
|
if not from_link:
|
|
846
967
|
self._schedule_emit_view_transform()
|
|
847
968
|
|
|
969
|
+
|
|
848
970
|
def _on_toggle_wcs_grid(self, on: bool):
|
|
849
971
|
self._show_wcs_grid = bool(on)
|
|
850
972
|
QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
|
|
851
|
-
self._render(rebuild=
|
|
973
|
+
self._render(rebuild=True) # repaint current frame
|
|
852
974
|
|
|
853
975
|
|
|
854
976
|
|
|
@@ -880,33 +1002,32 @@ class ImageSubWindow(QWidget):
|
|
|
880
1002
|
# make the buttons correct right now
|
|
881
1003
|
self._refresh_local_undo_buttons()
|
|
882
1004
|
|
|
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
|
|
1005
|
+
def _drag_identity_fields(self) -> dict:
|
|
1006
|
+
st = {}
|
|
891
1007
|
|
|
892
|
-
#
|
|
893
|
-
dm = getattr(self, "_docman", None)
|
|
1008
|
+
# existing identity (whatever you already do)
|
|
894
1009
|
try:
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1010
|
+
doc = getattr(self, "document", None)
|
|
1011
|
+
st["doc_ptr"] = id(doc) if doc is not None else None
|
|
1012
|
+
st["doc_uid"] = getattr(doc, "uid", None)
|
|
1013
|
+
meta = getattr(doc, "metadata", {}) or {}
|
|
1014
|
+
st["file_path"] = (meta.get("file_path") or "").strip()
|
|
1015
|
+
st["base_doc_uid"] = meta.get("base_doc_uid") or st["doc_uid"]
|
|
1016
|
+
st["source_kind"] = meta.get("source_kind") or "full"
|
|
899
1017
|
except Exception:
|
|
900
1018
|
pass
|
|
901
1019
|
|
|
902
|
-
|
|
903
|
-
|
|
1020
|
+
# ✅ NEW: add the current user-visible view title
|
|
1021
|
+
st["source_view_title"] = self._current_view_title_for_drag()
|
|
904
1022
|
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
"
|
|
909
|
-
|
|
1023
|
+
# (optional) also include the subwindow title raw, for debugging/forensics
|
|
1024
|
+
try:
|
|
1025
|
+
sw = self._mdi_subwindow()
|
|
1026
|
+
st["source_sw_title_raw"] = (sw.windowTitle() if sw is not None else "")
|
|
1027
|
+
except Exception:
|
|
1028
|
+
st["source_sw_title_raw"] = ""
|
|
1029
|
+
|
|
1030
|
+
return st
|
|
910
1031
|
|
|
911
1032
|
|
|
912
1033
|
def _on_local_undo(self):
|
|
@@ -1188,18 +1309,6 @@ class ImageSubWindow(QWidget):
|
|
|
1188
1309
|
except Exception as e:
|
|
1189
1310
|
print("[ImageSubWindow] apply_layer_stack error:", e)
|
|
1190
1311
|
|
|
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
1312
|
def keyPressEvent(self, ev):
|
|
1204
1313
|
if ev.key() == Qt.Key.Key_Space:
|
|
1205
1314
|
# only the first time we enter probe mode
|
|
@@ -1321,51 +1430,116 @@ class ImageSubWindow(QWidget):
|
|
|
1321
1430
|
except Exception as e:
|
|
1322
1431
|
print("[ImageSubWindow] _on_layer_source_changed error:", e)
|
|
1323
1432
|
|
|
1433
|
+
def _collect_layer_docs(self):
|
|
1434
|
+
"""
|
|
1435
|
+
Collect unique ImageDocument objects referenced by the layer stack:
|
|
1436
|
+
- layer src_doc (if doc-backed)
|
|
1437
|
+
- layer mask_doc (if any)
|
|
1438
|
+
Raster/baked layers may have src_doc=None; those are ignored.
|
|
1439
|
+
Returns a LIST in a stable order (bottom→top traversal order), de-duped.
|
|
1440
|
+
"""
|
|
1441
|
+
out = []
|
|
1442
|
+
seen = set()
|
|
1443
|
+
|
|
1444
|
+
layers = getattr(self, "_layers", None) or []
|
|
1445
|
+
for L in layers:
|
|
1446
|
+
# 1) source doc (may be None for raster/baked layers)
|
|
1447
|
+
d = getattr(L, "src_doc", None)
|
|
1448
|
+
if d is not None:
|
|
1449
|
+
k = id(d)
|
|
1450
|
+
if k not in seen:
|
|
1451
|
+
seen.add(k)
|
|
1452
|
+
out.append(d)
|
|
1453
|
+
|
|
1454
|
+
# 2) mask doc (also may be None)
|
|
1455
|
+
md = getattr(L, "mask_doc", None)
|
|
1456
|
+
if md is not None:
|
|
1457
|
+
k = id(md)
|
|
1458
|
+
if k not in seen:
|
|
1459
|
+
seen.add(k)
|
|
1460
|
+
out.append(md)
|
|
1461
|
+
|
|
1462
|
+
return out
|
|
1463
|
+
|
|
1464
|
+
|
|
1324
1465
|
def _reinstall_layer_watchers(self):
|
|
1466
|
+
"""
|
|
1467
|
+
Reconnect layer source/mask document watchers to trigger live layer recomposite.
|
|
1468
|
+
Safe against:
|
|
1469
|
+
- raster/baked layers (src_doc=None)
|
|
1470
|
+
- deleted docs / partially-torn-down Qt objects
|
|
1471
|
+
- repeated calls
|
|
1472
|
+
"""
|
|
1473
|
+
# Previous watchers
|
|
1474
|
+
olddocs = list(getattr(self, "_watched_docs", []) or [])
|
|
1475
|
+
|
|
1325
1476
|
# Disconnect old
|
|
1326
|
-
for d in
|
|
1477
|
+
for d in olddocs:
|
|
1327
1478
|
try:
|
|
1479
|
+
# Doc may already be deleted or signal gone
|
|
1328
1480
|
d.changed.disconnect(self._on_layer_source_changed)
|
|
1329
1481
|
except Exception:
|
|
1330
1482
|
pass
|
|
1331
|
-
|
|
1483
|
+
|
|
1484
|
+
# Collect new
|
|
1332
1485
|
newdocs = self._collect_layer_docs()
|
|
1486
|
+
|
|
1487
|
+
# Connect new
|
|
1333
1488
|
for d in newdocs:
|
|
1334
1489
|
try:
|
|
1335
1490
|
d.changed.connect(self._on_layer_source_changed)
|
|
1336
1491
|
except Exception:
|
|
1337
1492
|
pass
|
|
1493
|
+
|
|
1494
|
+
# Store as list (stable)
|
|
1338
1495
|
self._watched_docs = newdocs
|
|
1339
1496
|
|
|
1340
1497
|
|
|
1498
|
+
|
|
1341
1499
|
def toggle_mask_overlay(self):
|
|
1342
1500
|
self.show_mask_overlay = not self.show_mask_overlay
|
|
1343
1501
|
self._render(rebuild=True)
|
|
1344
1502
|
|
|
1345
1503
|
def _rebuild_title(self, *, base: str | None = None):
|
|
1346
1504
|
sub = self._mdi_subwindow()
|
|
1347
|
-
if not sub:
|
|
1505
|
+
if not sub:
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1348
1508
|
if base is None:
|
|
1349
1509
|
base = self._effective_title() or self.tr("Untitled")
|
|
1350
1510
|
|
|
1351
|
-
#
|
|
1511
|
+
# Strip badges (🔗, ■, etc) AND "Active View:" prefix
|
|
1352
1512
|
core, _ = self._strip_decorations(base)
|
|
1353
1513
|
|
|
1354
|
-
|
|
1514
|
+
# ALSO strip file extensions if it looks like a filename
|
|
1515
|
+
# (this prevents .tiff/.fit coming back via any fallback path)
|
|
1516
|
+
try:
|
|
1517
|
+
b, ext = os.path.splitext(core)
|
|
1518
|
+
if ext and len(ext) <= 10 and not core.endswith("..."):
|
|
1519
|
+
core = b
|
|
1520
|
+
except Exception:
|
|
1521
|
+
pass
|
|
1522
|
+
|
|
1523
|
+
# Build the displayed title with badges
|
|
1524
|
+
shown = core
|
|
1355
1525
|
if getattr(self, "_link_badge_on", False):
|
|
1356
|
-
|
|
1526
|
+
shown = f"{LINK_PREFIX}{shown}"
|
|
1357
1527
|
if self._mask_dot_enabled:
|
|
1358
|
-
|
|
1528
|
+
shown = f"{MASK_GLYPH} {shown}"
|
|
1529
|
+
|
|
1530
|
+
# Update chrome
|
|
1531
|
+
if shown != sub.windowTitle():
|
|
1532
|
+
sub.setWindowTitle(shown)
|
|
1533
|
+
sub.setToolTip(shown)
|
|
1534
|
+
|
|
1535
|
+
# IMPORTANT: emit ONLY the clean core (no badges, no extensions)
|
|
1536
|
+
if core != self._last_title_for_emit:
|
|
1537
|
+
self._last_title_for_emit = core
|
|
1538
|
+
try:
|
|
1539
|
+
self.viewTitleChanged.emit(self, core)
|
|
1540
|
+
except Exception:
|
|
1541
|
+
pass
|
|
1359
1542
|
|
|
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
1543
|
|
|
1370
1544
|
|
|
1371
1545
|
def _strip_decorations(self, title: str) -> tuple[str, bool]:
|
|
@@ -1394,21 +1568,7 @@ class ImageSubWindow(QWidget):
|
|
|
1394
1568
|
def set_active_highlight(self, on: bool):
|
|
1395
1569
|
self._is_active_flag = bool(on)
|
|
1396
1570
|
return
|
|
1397
|
-
sub = self._mdi_subwindow()
|
|
1398
|
-
if not sub:
|
|
1399
|
-
return
|
|
1400
|
-
|
|
1401
|
-
core, had_glyph = self._strip_decorations(sub.windowTitle())
|
|
1402
1571
|
|
|
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
1572
|
|
|
1413
1573
|
def _set_mask_highlight(self, on: bool):
|
|
1414
1574
|
self._mask_dot_enabled = bool(on)
|
|
@@ -1516,20 +1676,58 @@ class ImageSubWindow(QWidget):
|
|
|
1516
1676
|
def is_hard_autostretch(self) -> bool:
|
|
1517
1677
|
return self.autostretch_profile == "hard"
|
|
1518
1678
|
|
|
1519
|
-
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1679
|
+
#def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1680
|
+
# w = self.parent()
|
|
1681
|
+
# while w is not None and not isinstance(w, QMdiSubWindow):
|
|
1682
|
+
# w = w.parent()
|
|
1683
|
+
# return w
|
|
1524
1684
|
|
|
1525
1685
|
def _effective_title(self) -> str:
|
|
1526
|
-
|
|
1527
|
-
|
|
1686
|
+
"""
|
|
1687
|
+
Returns the *core* title for this view (no UI badges like 🔗/■, and no file extension).
|
|
1688
|
+
Badges are added later by _rebuild_title().
|
|
1689
|
+
"""
|
|
1690
|
+
# 1) Prefer per-view override if set
|
|
1691
|
+
t = (self._view_title_override or "").strip()
|
|
1692
|
+
|
|
1693
|
+
# 2) Else prefer metadata display_name (what duplicate/rename should set)
|
|
1694
|
+
if not t:
|
|
1695
|
+
try:
|
|
1696
|
+
md = getattr(self.document, "metadata", {}) or {}
|
|
1697
|
+
t = (md.get("display_name") or "").strip()
|
|
1698
|
+
except Exception:
|
|
1699
|
+
t = ""
|
|
1700
|
+
|
|
1701
|
+
# 3) Else fall back to doc.display_name()
|
|
1702
|
+
if not t:
|
|
1703
|
+
try:
|
|
1704
|
+
t = (self.document.display_name() or "").strip()
|
|
1705
|
+
except Exception:
|
|
1706
|
+
t = ""
|
|
1707
|
+
|
|
1708
|
+
t = t or self.tr("Untitled")
|
|
1709
|
+
|
|
1710
|
+
# Strip UI decorations (🔗, ■, Active View:, etc.)
|
|
1711
|
+
try:
|
|
1712
|
+
t, _ = self._strip_decorations(t)
|
|
1713
|
+
except Exception:
|
|
1714
|
+
pass
|
|
1715
|
+
|
|
1716
|
+
# Strip extension if it looks like a filename
|
|
1717
|
+
try:
|
|
1718
|
+
base, ext = os.path.splitext(t)
|
|
1719
|
+
if ext and len(ext) <= 10:
|
|
1720
|
+
t = base
|
|
1721
|
+
except Exception:
|
|
1722
|
+
pass
|
|
1723
|
+
|
|
1724
|
+
return t
|
|
1725
|
+
|
|
1528
1726
|
|
|
1529
1727
|
def _show_ctx_menu(self, pos):
|
|
1530
1728
|
menu = QMenu(self)
|
|
1531
1729
|
a_view = menu.addAction(self.tr("Rename View… (F2)"))
|
|
1532
|
-
a_doc = menu.addAction(self.tr("Rename Document…"))
|
|
1730
|
+
a_doc = menu.addAction(self.tr("Rename Document… (F3)"))
|
|
1533
1731
|
menu.addSeparator()
|
|
1534
1732
|
a_min = menu.addAction(self.tr("Send to Shelf"))
|
|
1535
1733
|
a_clear = menu.addAction(self.tr("Clear View Name (use doc name)"))
|
|
@@ -1603,13 +1801,20 @@ class ImageSubWindow(QWidget):
|
|
|
1603
1801
|
pass
|
|
1604
1802
|
|
|
1605
1803
|
def set_scale(self, s: float):
|
|
1804
|
+
# Programmatic scale changes must schedule final smooth redraw
|
|
1606
1805
|
s = float(max(self._min_scale, min(s, self._max_scale)))
|
|
1607
1806
|
if abs(s - self.scale) < 1e-9:
|
|
1608
1807
|
return
|
|
1609
1808
|
self.scale = s
|
|
1610
|
-
self._render() #
|
|
1809
|
+
self._render() # fast present happens here
|
|
1611
1810
|
self._schedule_emit_view_transform()
|
|
1612
1811
|
|
|
1812
|
+
# ✅ NEW: ensure we do the final smooth redraw (same as manual zoom)
|
|
1813
|
+
try:
|
|
1814
|
+
self._request_zoom_redraw()
|
|
1815
|
+
except Exception:
|
|
1816
|
+
pass
|
|
1817
|
+
|
|
1613
1818
|
|
|
1614
1819
|
|
|
1615
1820
|
# ---- view state API (center in image coords + scale) ----
|
|
@@ -1634,20 +1839,19 @@ class ImageSubWindow(QWidget):
|
|
|
1634
1839
|
vbar = self.scroll.verticalScrollBar()
|
|
1635
1840
|
|
|
1636
1841
|
state = {
|
|
1637
|
-
"doc_ptr": id(self.document),
|
|
1842
|
+
"doc_ptr": id(self.document),
|
|
1638
1843
|
"scale": float(self.scale),
|
|
1639
1844
|
"hval": int(hbar.value()),
|
|
1640
1845
|
"vval": int(vbar.value()),
|
|
1641
1846
|
"autostretch": bool(self.autostretch_enabled),
|
|
1642
1847
|
"autostretch_target": float(self.autostretch_target),
|
|
1643
1848
|
}
|
|
1644
|
-
state.update(self._drag_identity_fields())
|
|
1849
|
+
state.update(self._drag_identity_fields())
|
|
1645
1850
|
|
|
1646
|
-
# --- NEW: annotate ROI/source_kind so drop knows this came from a Preview tab
|
|
1647
1851
|
roi = None
|
|
1648
1852
|
try:
|
|
1649
1853
|
if hasattr(self, "has_active_preview") and self.has_active_preview():
|
|
1650
|
-
r = self.current_preview_roi()
|
|
1854
|
+
r = self.current_preview_roi()
|
|
1651
1855
|
if r and len(r) == 4:
|
|
1652
1856
|
roi = tuple(map(int, r))
|
|
1653
1857
|
except Exception:
|
|
@@ -1665,14 +1869,29 @@ class ImageSubWindow(QWidget):
|
|
|
1665
1869
|
else:
|
|
1666
1870
|
state["source_kind"] = "full"
|
|
1667
1871
|
|
|
1872
|
+
if _DEBUG_DND_DUP:
|
|
1873
|
+
_dnd_dbg_dump_state("DRAG_START:dragtab", state)
|
|
1874
|
+
|
|
1875
|
+
|
|
1668
1876
|
md = QMimeData()
|
|
1669
1877
|
md.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1670
1878
|
|
|
1671
1879
|
drag = QDrag(self)
|
|
1672
1880
|
drag.setMimeData(md)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1881
|
+
|
|
1882
|
+
pm = self.label.pixmap()
|
|
1883
|
+
if pm and not pm.isNull():
|
|
1884
|
+
drag.setPixmap(
|
|
1885
|
+
pm.scaled(
|
|
1886
|
+
96, 96,
|
|
1887
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
1888
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
1889
|
+
)
|
|
1890
|
+
)
|
|
1891
|
+
drag.setHotSpot(QPoint(16, 16)) # optional, but feels nicer
|
|
1892
|
+
|
|
1893
|
+
drag.exec(Qt.DropAction.CopyAction)
|
|
1894
|
+
|
|
1676
1895
|
|
|
1677
1896
|
|
|
1678
1897
|
|
|
@@ -1775,6 +1994,55 @@ class ImageSubWindow(QWidget):
|
|
|
1775
1994
|
|
|
1776
1995
|
|
|
1777
1996
|
# ---- DnD 'view tab' -------------------------------------------------
|
|
1997
|
+
|
|
1998
|
+
def _mdi_subwindow(self):
|
|
1999
|
+
"""Return the QMdiSubWindow that hosts this view, or None."""
|
|
2000
|
+
try:
|
|
2001
|
+
from PyQt6.QtWidgets import QMdiSubWindow
|
|
2002
|
+
p = self.parent()
|
|
2003
|
+
while p is not None:
|
|
2004
|
+
if isinstance(p, QMdiSubWindow):
|
|
2005
|
+
return p
|
|
2006
|
+
p = p.parent()
|
|
2007
|
+
except Exception:
|
|
2008
|
+
pass
|
|
2009
|
+
return None
|
|
2010
|
+
|
|
2011
|
+
def _current_view_title_for_drag(self) -> str:
|
|
2012
|
+
"""
|
|
2013
|
+
The *actual* user-visible view title (what they renamed to),
|
|
2014
|
+
NOT the document/file name.
|
|
2015
|
+
"""
|
|
2016
|
+
title = ""
|
|
2017
|
+
try:
|
|
2018
|
+
sw = self._mdi_subwindow()
|
|
2019
|
+
if sw is not None:
|
|
2020
|
+
title = (sw.windowTitle() or "").strip()
|
|
2021
|
+
except Exception:
|
|
2022
|
+
title = ""
|
|
2023
|
+
|
|
2024
|
+
if not title:
|
|
2025
|
+
try:
|
|
2026
|
+
title = (self.windowTitle() or "").strip()
|
|
2027
|
+
except Exception:
|
|
2028
|
+
title = ""
|
|
2029
|
+
|
|
2030
|
+
if not title:
|
|
2031
|
+
# absolute fallback
|
|
2032
|
+
try:
|
|
2033
|
+
title = (self.document.display_name() or "").strip()
|
|
2034
|
+
except Exception:
|
|
2035
|
+
title = ""
|
|
2036
|
+
|
|
2037
|
+
# Optional: strip [LINK], glyphs, etc if your title includes those
|
|
2038
|
+
try:
|
|
2039
|
+
title = _strip_ui_decorations(title)
|
|
2040
|
+
except Exception:
|
|
2041
|
+
pass
|
|
2042
|
+
|
|
2043
|
+
return title or "Untitled"
|
|
2044
|
+
|
|
2045
|
+
|
|
1778
2046
|
def _install_view_tab(self):
|
|
1779
2047
|
self._view_tab = QToolButton(self)
|
|
1780
2048
|
self._view_tab.setText(self.tr("View"))
|
|
@@ -1794,19 +2062,28 @@ class ImageSubWindow(QWidget):
|
|
|
1794
2062
|
if ev.button() != Qt.MouseButton.LeftButton:
|
|
1795
2063
|
return QToolButton.mousePressEvent(self._view_tab, ev)
|
|
1796
2064
|
|
|
1797
|
-
# build the SAME payload schema used by _start_viewstate_drag()
|
|
1798
2065
|
hbar = self.scroll.horizontalScrollBar()
|
|
1799
2066
|
vbar = self.scroll.verticalScrollBar()
|
|
2067
|
+
|
|
2068
|
+
# NEW: capture the *current view title* the user sees
|
|
2069
|
+
view_title = self._current_view_display_name()
|
|
2070
|
+
|
|
1800
2071
|
state = {
|
|
1801
2072
|
"doc_ptr": id(self.document),
|
|
2073
|
+
"doc_uid": getattr(self.document, "uid", None), # harmless even if None
|
|
2074
|
+
"file_path": (getattr(self.document, "metadata", {}) or {}).get("file_path", ""),
|
|
1802
2075
|
"scale": float(self.scale),
|
|
1803
2076
|
"hval": int(hbar.value()),
|
|
1804
2077
|
"vval": int(vbar.value()),
|
|
1805
2078
|
"autostretch": bool(self.autostretch_enabled),
|
|
1806
2079
|
"autostretch_target": float(self.autostretch_target),
|
|
2080
|
+
|
|
2081
|
+
# NEW: this is what we will use for naming duplicates
|
|
2082
|
+
"source_view_title": view_title,
|
|
1807
2083
|
}
|
|
1808
2084
|
state.update(self._drag_identity_fields())
|
|
1809
|
-
|
|
2085
|
+
if _DEBUG_DND_DUP:
|
|
2086
|
+
_dnd_dbg_dump_state("DRAG_START:viewtab", state)
|
|
1810
2087
|
mime = QMimeData()
|
|
1811
2088
|
mime.setData(MIME_VIEWSTATE, QByteArray(json.dumps(state).encode("utf-8")))
|
|
1812
2089
|
|
|
@@ -1815,10 +2092,13 @@ class ImageSubWindow(QWidget):
|
|
|
1815
2092
|
|
|
1816
2093
|
pm = self.label.pixmap()
|
|
1817
2094
|
if pm:
|
|
1818
|
-
drag.setPixmap(pm.scaled(
|
|
1819
|
-
|
|
1820
|
-
|
|
2095
|
+
drag.setPixmap(pm.scaled(
|
|
2096
|
+
96, 96,
|
|
2097
|
+
Qt.AspectRatioMode.KeepAspectRatio,
|
|
2098
|
+
Qt.TransformationMode.SmoothTransformation
|
|
2099
|
+
))
|
|
1821
2100
|
drag.setHotSpot(QCursor.pos() - self.mapToGlobal(self._view_tab.pos()))
|
|
2101
|
+
|
|
1822
2102
|
drag.exec(Qt.DropAction.CopyAction)
|
|
1823
2103
|
|
|
1824
2104
|
def _viewtab_mouse_double(self, _ev):
|
|
@@ -1924,6 +2204,42 @@ class ImageSubWindow(QWidget):
|
|
|
1924
2204
|
|
|
1925
2205
|
ev.ignore()
|
|
1926
2206
|
|
|
2207
|
+
def _current_view_display_name(self) -> str:
|
|
2208
|
+
"""
|
|
2209
|
+
Best-effort: the exact title the user sees for THIS subwindow/view.
|
|
2210
|
+
Prefer QMdiSubWindow title, fallback to document display_name.
|
|
2211
|
+
"""
|
|
2212
|
+
# 1) QMdiSubWindow title (what user sees)
|
|
2213
|
+
try:
|
|
2214
|
+
sw = self._mdi_subwindow()
|
|
2215
|
+
if sw is not None:
|
|
2216
|
+
t = (sw.windowTitle() or "").strip()
|
|
2217
|
+
if t:
|
|
2218
|
+
return t
|
|
2219
|
+
except Exception:
|
|
2220
|
+
pass
|
|
2221
|
+
|
|
2222
|
+
# 2) This widget's own windowTitle (sometimes used)
|
|
2223
|
+
try:
|
|
2224
|
+
t = (self.windowTitle() or "").strip()
|
|
2225
|
+
if t:
|
|
2226
|
+
return t
|
|
2227
|
+
except Exception:
|
|
2228
|
+
pass
|
|
2229
|
+
|
|
2230
|
+
# 3) Document display name fallback
|
|
2231
|
+
try:
|
|
2232
|
+
d = getattr(self, "document", None)
|
|
2233
|
+
if d is not None and hasattr(d, "display_name"):
|
|
2234
|
+
t = (d.display_name() or "").strip()
|
|
2235
|
+
if t:
|
|
2236
|
+
return t
|
|
2237
|
+
except Exception:
|
|
2238
|
+
pass
|
|
2239
|
+
|
|
2240
|
+
return "Untitled"
|
|
2241
|
+
|
|
2242
|
+
|
|
1927
2243
|
# keep the tab visible if the widget resizes
|
|
1928
2244
|
def resizeEvent(self, ev):
|
|
1929
2245
|
super().resizeEvent(ev)
|
|
@@ -2075,9 +2391,16 @@ class ImageSubWindow(QWidget):
|
|
|
2075
2391
|
|
|
2076
2392
|
# ---------- rendering ----------
|
|
2077
2393
|
def _render(self, rebuild: bool = False):
|
|
2394
|
+
#print("[ImageSubWindow] _render called, rebuild =", rebuild)
|
|
2078
2395
|
"""
|
|
2079
2396
|
Render the current view.
|
|
2080
2397
|
|
|
2398
|
+
Fast path:
|
|
2399
|
+
- rebuild=False: only rescale already-built pixmap/QImage (NO numpy work).
|
|
2400
|
+
Slow path:
|
|
2401
|
+
- rebuild=True: rebuild visualization (autostretch, 8-bit conversion, overlays),
|
|
2402
|
+
refresh QImage/QPixmap cache, then present.
|
|
2403
|
+
|
|
2081
2404
|
Rules:
|
|
2082
2405
|
- If a Preview is active, FIRST sync that preview's stored arr from the
|
|
2083
2406
|
DocManager's ROI document (the thing tools actually modify), then render.
|
|
@@ -2087,46 +2410,53 @@ class ImageSubWindow(QWidget):
|
|
|
2087
2410
|
# ---- GUARD: widget/label may be deleted but document.changed still fires ----
|
|
2088
2411
|
try:
|
|
2089
2412
|
from PyQt6 import sip as _sip
|
|
2090
|
-
# If the whole widget or its label is gone, bail immediately
|
|
2091
2413
|
if _sip.isdeleted(self):
|
|
2092
2414
|
return
|
|
2093
2415
|
lbl = getattr(self, "label", None)
|
|
2094
2416
|
if lbl is None or _sip.isdeleted(lbl):
|
|
2095
2417
|
return
|
|
2096
2418
|
except Exception:
|
|
2097
|
-
# If sip or label is missing for any reason, play it safe
|
|
2098
2419
|
if not hasattr(self, "label"):
|
|
2099
2420
|
return
|
|
2100
|
-
# ---------------------------------------------------------------------------
|
|
2421
|
+
# ---------------------------------------------------------------------------
|
|
2422
|
+
|
|
2423
|
+
# ---------------------------------------------------------------------------
|
|
2424
|
+
# FAST PATH: if we're not rebuilding content and we already have a source pixmap,
|
|
2425
|
+
# just present scaled (fast). This is the key to smooth zoom.
|
|
2426
|
+
# ---------------------------------------------------------------------------
|
|
2427
|
+
if (not rebuild) and getattr(self, "_pm_src", None) is not None:
|
|
2428
|
+
self._present_scaled(interactive=True)
|
|
2429
|
+
return
|
|
2430
|
+
|
|
2101
2431
|
# ---------------------------
|
|
2102
2432
|
# 1) Choose & sync source arr
|
|
2103
2433
|
# ---------------------------
|
|
2104
2434
|
base_img = None
|
|
2105
2435
|
if self._active_source_kind == "preview" and self._active_preview_id is not None:
|
|
2106
2436
|
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
2437
|
if src is not None:
|
|
2109
2438
|
# Pull the *edited* ROI image from DocManager, if available
|
|
2110
2439
|
if hasattr(self, "_docman") and self._docman is not None:
|
|
2111
|
-
#print("[ImageSubWindow] _render: pulling edited ROI from DocManager")
|
|
2112
2440
|
try:
|
|
2113
2441
|
roi_doc = self._docman.get_document_for_view(self)
|
|
2114
2442
|
roi_img = getattr(roi_doc, "image", None)
|
|
2443
|
+
# IMPORTANT: only copy on rebuild; zoom should not trigger a copy
|
|
2115
2444
|
if roi_img is not None:
|
|
2116
|
-
|
|
2117
|
-
|
|
2445
|
+
if rebuild or ("arr" not in src) or (src.get("arr") is None):
|
|
2446
|
+
src["arr"] = np.asarray(roi_img).copy()
|
|
2118
2447
|
except Exception:
|
|
2119
2448
|
print("[ImageSubWindow] _render: failed to pull edited ROI from DocManager")
|
|
2120
|
-
pass
|
|
2121
2449
|
base_img = src.get("arr", None)
|
|
2122
2450
|
else:
|
|
2123
|
-
#print("[ImageSubWindow] _render: full image mode")
|
|
2124
2451
|
base_img = self._display_override if (self._display_override is not None) else (
|
|
2125
2452
|
getattr(self.document, "image", None)
|
|
2126
2453
|
)
|
|
2127
2454
|
|
|
2128
2455
|
if base_img is None:
|
|
2129
2456
|
self._qimg_src = None
|
|
2457
|
+
self._pm_src = None
|
|
2458
|
+
self._pm_src_wcs = None
|
|
2459
|
+
self._buf8 = None
|
|
2130
2460
|
self.label.clear()
|
|
2131
2461
|
return
|
|
2132
2462
|
|
|
@@ -2135,7 +2465,6 @@ class ImageSubWindow(QWidget):
|
|
|
2135
2465
|
# ---------------------------------------
|
|
2136
2466
|
# 2) Normalize dimensionality and dtype
|
|
2137
2467
|
# ---------------------------------------
|
|
2138
|
-
# Scalar → 1x1; 1D → 1xN; (H,W,1) → mono (H,W)
|
|
2139
2468
|
if arr.ndim == 0:
|
|
2140
2469
|
arr = arr.reshape(1, 1)
|
|
2141
2470
|
elif arr.ndim == 1:
|
|
@@ -2156,7 +2485,7 @@ class ImageSubWindow(QWidget):
|
|
|
2156
2485
|
else:
|
|
2157
2486
|
arr_f = arr.astype(np.float32, copy=False)
|
|
2158
2487
|
mx = float(arr_f.max()) if arr_f.size else 1.0
|
|
2159
|
-
if mx > 5.0:
|
|
2488
|
+
if mx > 5.0:
|
|
2160
2489
|
arr_f = arr_f / mx
|
|
2161
2490
|
|
|
2162
2491
|
vis = autostretch(
|
|
@@ -2192,7 +2521,7 @@ class ImageSubWindow(QWidget):
|
|
|
2192
2521
|
buf8 = np.stack([buf8.squeeze()] * 3, axis=-1)
|
|
2193
2522
|
|
|
2194
2523
|
# ---------------------------------------
|
|
2195
|
-
# 5) Optional mask overlay
|
|
2524
|
+
# 5) Optional mask overlay (baked into buf8)
|
|
2196
2525
|
# ---------------------------------------
|
|
2197
2526
|
if getattr(self, "show_mask_overlay", False):
|
|
2198
2527
|
m = self._active_mask_array()
|
|
@@ -2215,9 +2544,9 @@ class ImageSubWindow(QWidget):
|
|
|
2215
2544
|
# ---------------------------------------
|
|
2216
2545
|
if buf8.dtype != np.uint8:
|
|
2217
2546
|
buf8 = buf8.astype(np.uint8)
|
|
2547
|
+
|
|
2218
2548
|
buf8 = ensure_contiguous(buf8)
|
|
2219
2549
|
h, w, c = buf8.shape
|
|
2220
|
-
# Be explicit. RGB888 means 3 bytes per pixel, full stop.
|
|
2221
2550
|
bytes_per_line = int(w * 3)
|
|
2222
2551
|
|
|
2223
2552
|
self._buf8 = buf8 # keep alive
|
|
@@ -2226,11 +2555,9 @@ class ImageSubWindow(QWidget):
|
|
|
2226
2555
|
addr = int(self._buf8.ctypes.data)
|
|
2227
2556
|
ptr = sip.voidptr(addr)
|
|
2228
2557
|
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
2558
|
if qimg is None or qimg.isNull():
|
|
2231
2559
|
raise RuntimeError("QImage null")
|
|
2232
2560
|
except Exception:
|
|
2233
|
-
# One safe fall-back copy (still fast, avoids crashes)
|
|
2234
2561
|
buf8c = np.array(self._buf8, copy=True, order="C")
|
|
2235
2562
|
self._buf8 = buf8c
|
|
2236
2563
|
addr = int(self._buf8.ctypes.data)
|
|
@@ -2239,244 +2566,263 @@ class ImageSubWindow(QWidget):
|
|
|
2239
2566
|
|
|
2240
2567
|
self._qimg_src = qimg
|
|
2241
2568
|
if qimg is None or qimg.isNull():
|
|
2569
|
+
self._pm_src = None
|
|
2570
|
+
self._pm_src_wcs = None
|
|
2242
2571
|
self.label.clear()
|
|
2243
2572
|
return
|
|
2244
2573
|
|
|
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
|
-
)
|
|
2574
|
+
# Cache unscaled pixmap ONCE per rebuild
|
|
2575
|
+
self._pm_src = QPixmap.fromImage(self._qimg_src)
|
|
2255
2576
|
|
|
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
|
|
2577
|
+
# Invalidate any cached “WCS baked” pixmap on rebuild
|
|
2578
|
+
self._pm_src_wcs = None
|
|
2279
2579
|
|
|
2280
|
-
|
|
2580
|
+
# Present final-quality after rebuild
|
|
2581
|
+
self._present_scaled(interactive=False)
|
|
2281
2582
|
|
|
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]))
|
|
2583
|
+
rebuild = False # done
|
|
2285
2584
|
|
|
2286
|
-
H_full, W_full = display_h, display_w
|
|
2287
|
-
fov_deg = px_deg * float(max(W_full, H_full))
|
|
2288
2585
|
|
|
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
|
|
2586
|
+
def _present_scaled(self, interactive: bool):
|
|
2587
|
+
"""
|
|
2588
|
+
Present the cached source pixmap scaled to current self.scale.
|
|
2589
|
+
|
|
2590
|
+
interactive=True:
|
|
2591
|
+
- Fast scaling
|
|
2592
|
+
- No WCS draw
|
|
2593
|
+
interactive=False:
|
|
2594
|
+
- Smooth scaling
|
|
2595
|
+
- Optionally draw WCS overlay once
|
|
2596
|
+
"""
|
|
2597
|
+
if getattr(self, "_pm_src", None) is None:
|
|
2598
|
+
return
|
|
2430
2599
|
|
|
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
|
|
2600
|
+
pm_base = self._pm_src
|
|
2443
2601
|
|
|
2444
|
-
|
|
2445
|
-
|
|
2602
|
+
sw = max(1, int(pm_base.width() * self.scale))
|
|
2603
|
+
sh = max(1, int(pm_base.height() * self.scale))
|
|
2446
2604
|
|
|
2447
|
-
|
|
2605
|
+
mode = Qt.TransformationMode.FastTransformation if interactive else Qt.TransformationMode.SmoothTransformation
|
|
2606
|
+
pm_scaled = pm_base.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio, mode)
|
|
2448
2607
|
|
|
2449
|
-
|
|
2450
|
-
|
|
2608
|
+
# If interactive, skip WCS overlay entirely (this is the biggest speed win)
|
|
2609
|
+
if interactive:
|
|
2610
|
+
self.label.setPixmap(pm_scaled)
|
|
2611
|
+
self.label.resize(pm_scaled.size())
|
|
2612
|
+
return
|
|
2451
2613
|
|
|
2614
|
+
# Non-interactive: (optionally) draw WCS grid.
|
|
2615
|
+
if getattr(self, "_show_wcs_grid", False):
|
|
2616
|
+
# Cache a baked WCS pixmap at *this* scale to avoid re-drawing
|
|
2617
|
+
# if _present_scaled(False) is called multiple times at same scale.
|
|
2618
|
+
cache_key = (sw, sh, float(self.scale))
|
|
2619
|
+
if getattr(self, "_pm_src_wcs_key", None) != cache_key or getattr(self, "_pm_src_wcs", None) is None:
|
|
2620
|
+
pm_scaled = self._draw_wcs_grid_on_pixmap(pm_scaled)
|
|
2621
|
+
self._pm_src_wcs = pm_scaled
|
|
2622
|
+
self._pm_src_wcs_key = cache_key
|
|
2623
|
+
else:
|
|
2624
|
+
pm_scaled = self._pm_src_wcs
|
|
2452
2625
|
|
|
2626
|
+
self.label.setPixmap(pm_scaled)
|
|
2627
|
+
self.label.resize(pm_scaled.size())
|
|
2453
2628
|
|
|
2454
|
-
def has_active_preview(self) -> bool:
|
|
2455
|
-
return self._active_source_kind == "preview" and self._active_preview_id is not None
|
|
2456
2629
|
|
|
2457
|
-
def
|
|
2630
|
+
def _draw_wcs_grid_on_pixmap(self, pm_scaled: QPixmap) -> QPixmap:
|
|
2458
2631
|
"""
|
|
2459
|
-
|
|
2632
|
+
Your existing WCS painter logic, moved to operate on a QPixmap (already scaled).
|
|
2633
|
+
Runs ONLY on non-interactive redraw.
|
|
2460
2634
|
"""
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2635
|
+
wcs2 = self._get_celestial_wcs()
|
|
2636
|
+
if wcs2 is None:
|
|
2637
|
+
return pm_scaled
|
|
2638
|
+
|
|
2639
|
+
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QBrush
|
|
2640
|
+
from PyQt6.QtCore import QSettings, QRect
|
|
2641
|
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
|
2642
|
+
import numpy as _np
|
|
2643
|
+
|
|
2644
|
+
_settings = getattr(self, "_settings", None) or QSettings()
|
|
2645
|
+
pref_enabled = _settings.value("wcs_grid/enabled", True, type=bool)
|
|
2646
|
+
pref_mode = _settings.value("wcs_grid/mode", "auto", type=str)
|
|
2647
|
+
pref_step_unit = _settings.value("wcs_grid/step_unit", "deg", type=str)
|
|
2648
|
+
pref_step_val = _settings.value("wcs_grid/step_value", 1.0, type=float)
|
|
2649
|
+
|
|
2650
|
+
if not pref_enabled:
|
|
2651
|
+
return pm_scaled
|
|
2652
|
+
|
|
2653
|
+
# Determine full image geometry from the CURRENT SOURCE buffer (not pm_scaled)
|
|
2654
|
+
# We can infer W/H from qimg src (original)
|
|
2655
|
+
if getattr(self, "_qimg_src", None) is None:
|
|
2656
|
+
return pm_scaled
|
|
2657
|
+
H_full = int(self._qimg_src.height())
|
|
2658
|
+
W_full = int(self._qimg_src.width())
|
|
2659
|
+
|
|
2660
|
+
# Pixel scales/FOV
|
|
2661
|
+
px_scales_deg = proj_plane_pixel_scales(wcs2)
|
|
2662
|
+
px_deg = float(max(px_scales_deg[0], px_scales_deg[1]))
|
|
2663
|
+
fov_deg = px_deg * float(max(W_full, H_full))
|
|
2664
|
+
|
|
2665
|
+
if pref_mode == "fixed":
|
|
2666
|
+
step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
|
|
2667
|
+
step_deg = max(1e-6, min(step_deg, 90.0))
|
|
2668
|
+
else:
|
|
2669
|
+
nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
|
|
2670
|
+
target_lines = 8
|
|
2671
|
+
desired = max(fov_deg / target_lines, px_deg * 100)
|
|
2672
|
+
step_deg = min((n for n in nice if n >= desired), default=30)
|
|
2465
2673
|
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2674
|
+
# World bounds from corners
|
|
2675
|
+
corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
|
|
2676
|
+
try:
|
|
2677
|
+
ra_c, dec_c = wcs2.pixel_to_world_values(corners[:,0], corners[:,1])
|
|
2678
|
+
ra_min = float(_np.nanmin(ra_c)); ra_max = float(_np.nanmax(ra_c))
|
|
2679
|
+
dec_min = float(_np.nanmin(dec_c)); dec_max = float(_np.nanmax(dec_c))
|
|
2680
|
+
if ra_max - ra_min > 300:
|
|
2681
|
+
ra_c_wrapped = _np.mod(ra_c + 180.0, 360.0)
|
|
2682
|
+
ra_min = float(_np.nanmin(ra_c_wrapped)); ra_max = float(_np.nanmax(ra_c_wrapped))
|
|
2683
|
+
ra_shift = 180.0
|
|
2684
|
+
else:
|
|
2685
|
+
ra_shift = 0.0
|
|
2686
|
+
except Exception:
|
|
2687
|
+
ra_min, ra_max, dec_min, dec_max, ra_shift = 0.0, 360.0, -90.0, 90.0, 0.0
|
|
2688
|
+
|
|
2689
|
+
pm = QPixmap(pm_scaled) # copy so we don’t mutate caller
|
|
2690
|
+
p = QPainter(pm)
|
|
2691
|
+
pen = QPen(QColor(255, 255, 255, 140))
|
|
2692
|
+
pen.setWidth(1)
|
|
2693
|
+
p.setPen(pen)
|
|
2694
|
+
|
|
2695
|
+
# Scale factor between full-res image and pm_scaled
|
|
2696
|
+
s = float(pm.width()) / float(max(1, W_full))
|
|
2697
|
+
|
|
2698
|
+
Wf, Hf = float(W_full), float(H_full)
|
|
2699
|
+
|
|
2700
|
+
def draw_world_poly(xs_world, ys_world):
|
|
2701
|
+
try:
|
|
2702
|
+
px, py = wcs2.world_to_pixel_values(xs_world, ys_world)
|
|
2703
|
+
except Exception:
|
|
2704
|
+
return
|
|
2705
|
+
|
|
2706
|
+
px = _np.asarray(px, dtype=float)
|
|
2707
|
+
py = _np.asarray(py, dtype=float)
|
|
2708
|
+
|
|
2709
|
+
ok = _np.isfinite(px) & _np.isfinite(py)
|
|
2710
|
+
margin = float(max(Wf, Hf) * 2.0)
|
|
2711
|
+
ok &= (px > -margin) & (px < (Wf - 1.0 + margin))
|
|
2712
|
+
ok &= (py > -margin) & (py < (Hf - 1.0 + margin))
|
|
2713
|
+
|
|
2714
|
+
for i in range(1, len(px)):
|
|
2715
|
+
if not (ok[i-1] and ok[i]):
|
|
2716
|
+
continue
|
|
2717
|
+
x0 = float(px[i-1]) * s
|
|
2718
|
+
y0 = float(py[i-1]) * s
|
|
2719
|
+
x1 = float(px[i]) * s
|
|
2720
|
+
y1 = float(py[i]) * s
|
|
2721
|
+
if max(abs(x0), abs(y0), abs(x1), abs(y1)) > 2.0e9:
|
|
2722
|
+
continue
|
|
2723
|
+
p.drawLine(int(x0), int(y0), int(x1), int(y1))
|
|
2724
|
+
|
|
2725
|
+
ra_samples = _np.linspace(ra_min, ra_max, 512, dtype=float)
|
|
2726
|
+
ra_samples_wrapped = _np.mod(ra_samples + ra_shift, 360.0) if ra_shift else ra_samples
|
|
2727
|
+
dec_samples = _np.linspace(dec_min, dec_max, 512, dtype=float)
|
|
2728
|
+
|
|
2729
|
+
def _frange(a, b, sstep):
|
|
2730
|
+
out = []
|
|
2731
|
+
x = a
|
|
2732
|
+
while x <= b + 1e-9:
|
|
2733
|
+
out.append(x)
|
|
2734
|
+
x += sstep
|
|
2735
|
+
return out
|
|
2736
|
+
|
|
2737
|
+
def _round_to(x, sstep):
|
|
2738
|
+
return sstep * round(x / sstep)
|
|
2739
|
+
|
|
2740
|
+
ra_start = _round_to(ra_min, step_deg)
|
|
2741
|
+
dec_start = _round_to(dec_min, step_deg)
|
|
2742
|
+
|
|
2743
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2744
|
+
dec_arr = _np.full_like(ra_samples_wrapped, dec)
|
|
2745
|
+
draw_world_poly(ra_samples_wrapped, dec_arr)
|
|
2746
|
+
|
|
2747
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2748
|
+
ra_arr = _np.full_like(dec_samples, (ra + ra_shift) % 360.0)
|
|
2749
|
+
draw_world_poly(ra_arr, dec_samples)
|
|
2750
|
+
|
|
2751
|
+
# Labels
|
|
2752
|
+
font = QFont()
|
|
2753
|
+
font.setPixelSize(11)
|
|
2754
|
+
p.setFont(font)
|
|
2755
|
+
text_pen = QPen(QColor(255, 255, 255, 230))
|
|
2756
|
+
box_brush = QBrush(QColor(0, 0, 0, 140))
|
|
2757
|
+
p.setPen(text_pen)
|
|
2758
|
+
|
|
2759
|
+
img_w = pm.width()
|
|
2760
|
+
img_h = pm.height()
|
|
2761
|
+
|
|
2762
|
+
def _draw_label(x, y, txt, anchor="lt"):
|
|
2763
|
+
if not _np.isfinite([x, y]).all():
|
|
2764
|
+
return
|
|
2765
|
+
fm = p.fontMetrics()
|
|
2766
|
+
wtxt = fm.horizontalAdvance(txt) + 6
|
|
2767
|
+
htxt = fm.height() + 4
|
|
2768
|
+
|
|
2769
|
+
if anchor == "lt":
|
|
2770
|
+
rx, ry = int(x) + 4, int(y) + 3
|
|
2771
|
+
elif anchor == "rt":
|
|
2772
|
+
rx, ry = int(x) - wtxt - 4, int(y) + 3
|
|
2773
|
+
elif anchor == "lb":
|
|
2774
|
+
rx, ry = int(x) + 4, int(y) - htxt - 3
|
|
2775
|
+
else:
|
|
2776
|
+
rx, ry = int(x) - wtxt // 2, int(y) + 3
|
|
2777
|
+
|
|
2778
|
+
rx = max(0, min(rx, img_w - wtxt - 1))
|
|
2779
|
+
ry = max(0, min(ry, img_h - htxt - 1))
|
|
2780
|
+
|
|
2781
|
+
rect = QRect(rx, ry, wtxt, htxt)
|
|
2782
|
+
p.save()
|
|
2783
|
+
p.setBrush(box_brush)
|
|
2784
|
+
p.setPen(Qt.PenStyle.NoPen)
|
|
2785
|
+
p.drawRoundedRect(rect, 4, 4)
|
|
2786
|
+
p.restore()
|
|
2787
|
+
p.drawText(rect.adjusted(3, 2, -3, -2),
|
|
2788
|
+
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, txt)
|
|
2789
|
+
|
|
2790
|
+
# DEC labels on left edge
|
|
2791
|
+
for dec in _frange(dec_start, dec_max, step_deg):
|
|
2792
|
+
try:
|
|
2793
|
+
x_pix, y_pix = wcs2.world_to_pixel_values((ra_min + ra_shift) % 360.0, dec)
|
|
2794
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2795
|
+
continue
|
|
2796
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2797
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2798
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_dms(dec), anchor="lt")
|
|
2799
|
+
except Exception:
|
|
2800
|
+
pass
|
|
2801
|
+
|
|
2802
|
+
# RA labels on top edge
|
|
2803
|
+
for ra in _frange(ra_start, ra_max, step_deg):
|
|
2804
|
+
ra_wrapped = (ra + ra_shift) % 360.0
|
|
2805
|
+
try:
|
|
2806
|
+
x_pix, y_pix = wcs2.world_to_pixel_values(ra_wrapped, dec_min)
|
|
2807
|
+
if not _np.isfinite([x_pix, y_pix]).all():
|
|
2808
|
+
continue
|
|
2809
|
+
x_pix = min(max(x_pix, 0.0), Wf - 1.0)
|
|
2810
|
+
y_pix = min(max(y_pix, 0.0), Hf - 1.0)
|
|
2811
|
+
_draw_label(x_pix * s, y_pix * s, self._deg_to_hms(ra_wrapped), anchor="ct")
|
|
2812
|
+
except Exception:
|
|
2813
|
+
pass
|
|
2814
|
+
|
|
2815
|
+
p.end()
|
|
2816
|
+
return pm
|
|
2471
2817
|
|
|
2472
2818
|
|
|
2473
2819
|
# ---------- interaction ----------
|
|
2474
2820
|
def _zoom_at_anchor(self, factor: float):
|
|
2475
|
-
if self
|
|
2821
|
+
if getattr(self, "_qimg_src", None) is None and getattr(self, "_pm_src", None) is None:
|
|
2476
2822
|
return
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
new_scale = max(self._min_scale, min(old_scale * factor, self._max_scale))
|
|
2823
|
+
|
|
2824
|
+
old_scale = float(self.scale)
|
|
2825
|
+
new_scale = max(self._min_scale, min(old_scale * float(factor), self._max_scale))
|
|
2480
2826
|
if abs(new_scale - old_scale) < 1e-8:
|
|
2481
2827
|
return
|
|
2482
2828
|
|
|
@@ -2484,7 +2830,6 @@ class ImageSubWindow(QWidget):
|
|
|
2484
2830
|
hbar = self.scroll.horizontalScrollBar()
|
|
2485
2831
|
vbar = self.scroll.verticalScrollBar()
|
|
2486
2832
|
|
|
2487
|
-
# Anchor in viewport coordinates via global cursor (robust)
|
|
2488
2833
|
try:
|
|
2489
2834
|
anchor_vp = vp.mapFromGlobal(QCursor.pos())
|
|
2490
2835
|
except Exception:
|
|
@@ -2493,34 +2838,77 @@ class ImageSubWindow(QWidget):
|
|
|
2493
2838
|
if (anchor_vp is None) or (not vp.rect().contains(anchor_vp)):
|
|
2494
2839
|
anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
|
|
2495
2840
|
|
|
2496
|
-
# Current label coords under the anchor
|
|
2497
2841
|
x_label_pre = hbar.value() + anchor_vp.x()
|
|
2498
2842
|
y_label_pre = vbar.value() + anchor_vp.y()
|
|
2499
2843
|
|
|
2500
|
-
# Convert to image coords at old scale
|
|
2501
2844
|
xi = x_label_pre / max(old_scale, 1e-12)
|
|
2502
2845
|
yi = y_label_pre / max(old_scale, 1e-12)
|
|
2503
2846
|
|
|
2504
|
-
# Apply scale
|
|
2847
|
+
# Apply new scale
|
|
2505
2848
|
self.scale = new_scale
|
|
2506
|
-
self._render(rebuild=False)
|
|
2507
2849
|
|
|
2508
|
-
#
|
|
2850
|
+
# FAST present (no rebuild)
|
|
2851
|
+
self._present_scaled(interactive=True)
|
|
2852
|
+
|
|
2853
|
+
# Keep anchor stable
|
|
2509
2854
|
x_label_post = xi * new_scale
|
|
2510
2855
|
y_label_post = yi * new_scale
|
|
2511
2856
|
|
|
2512
|
-
# Desired scrollbar values to keep point under the cursor
|
|
2513
2857
|
new_h = int(round(x_label_post - anchor_vp.x()))
|
|
2514
2858
|
new_v = int(round(y_label_post - anchor_vp.y()))
|
|
2515
2859
|
|
|
2516
|
-
# Clamp to valid range
|
|
2517
2860
|
new_h = max(hbar.minimum(), min(new_h, hbar.maximum()))
|
|
2518
2861
|
new_v = max(vbar.minimum(), min(new_v, vbar.maximum()))
|
|
2519
2862
|
|
|
2520
|
-
# Apply
|
|
2521
2863
|
hbar.setValue(new_h)
|
|
2522
2864
|
vbar.setValue(new_v)
|
|
2523
|
-
|
|
2865
|
+
|
|
2866
|
+
# Defer one final smooth redraw (and WCS overlay) after the burst
|
|
2867
|
+
self._request_zoom_redraw()
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
def _request_zoom_redraw(self):
|
|
2871
|
+
if getattr(self, "_zoom_timer", None) is None:
|
|
2872
|
+
self._zoom_timer = QTimer(self)
|
|
2873
|
+
self._zoom_timer.setSingleShot(True)
|
|
2874
|
+
self._zoom_timer.timeout.connect(self._apply_zoom_redraw)
|
|
2875
|
+
|
|
2876
|
+
# 60–120ms feels better than 16ms for “zoom burst collapse”
|
|
2877
|
+
# but keep your 16ms if you prefer.
|
|
2878
|
+
self._zoom_timer.start(90)
|
|
2879
|
+
|
|
2880
|
+
|
|
2881
|
+
def _apply_zoom_redraw(self):
|
|
2882
|
+
"""
|
|
2883
|
+
Final “settled” redraw:
|
|
2884
|
+
- SmoothTransformation
|
|
2885
|
+
- Optional WCS grid overlay
|
|
2886
|
+
"""
|
|
2887
|
+
if getattr(self, "_pm_src", None) is None:
|
|
2888
|
+
return
|
|
2889
|
+
self._present_scaled(interactive=False)
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
|
|
2893
|
+
def has_active_preview(self) -> bool:
|
|
2894
|
+
return self._active_source_kind == "preview" and self._active_preview_id is not None
|
|
2895
|
+
|
|
2896
|
+
def current_preview_roi(self) -> tuple[int,int,int,int] | None:
|
|
2897
|
+
"""
|
|
2898
|
+
Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
|
|
2899
|
+
"""
|
|
2900
|
+
if not self.has_active_preview():
|
|
2901
|
+
return None
|
|
2902
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2903
|
+
return None if src is None else tuple(src["roi"])
|
|
2904
|
+
|
|
2905
|
+
def current_preview_name(self) -> str | None:
|
|
2906
|
+
if not self.has_active_preview():
|
|
2907
|
+
return None
|
|
2908
|
+
src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
|
|
2909
|
+
return None if src is None else src["name"]
|
|
2910
|
+
|
|
2911
|
+
|
|
2524
2912
|
|
|
2525
2913
|
def _find_main_window(self):
|
|
2526
2914
|
p = self.parent()
|
|
@@ -2653,37 +3041,75 @@ class ImageSubWindow(QWidget):
|
|
|
2653
3041
|
return True
|
|
2654
3042
|
return False
|
|
2655
3043
|
|
|
3044
|
+
sw = self._mdi_subwindow()
|
|
3045
|
+
if sw is not None and obj is sw:
|
|
3046
|
+
et = ev.type()
|
|
3047
|
+
if et in (QEvent.Type.WindowStateChange, QEvent.Type.Show, QEvent.Type.Resize):
|
|
3048
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
3049
|
+
|
|
2656
3050
|
return super().eventFilter(obj, ev)
|
|
2657
3051
|
|
|
3052
|
+
def _viewport_pos_to_image_xy(self, vp_pos: QPoint) -> tuple[int, int] | None:
|
|
3053
|
+
"""
|
|
3054
|
+
Convert a point in viewport coordinates to FULL image pixel coordinates.
|
|
3055
|
+
Returns None if the point is outside the displayed pixmap (in margins).
|
|
3056
|
+
"""
|
|
3057
|
+
pm = self.label.pixmap()
|
|
3058
|
+
if pm is None:
|
|
3059
|
+
return None
|
|
3060
|
+
|
|
3061
|
+
# Convert viewport point into label coordinates
|
|
3062
|
+
p_label = self.label.mapFrom(self.scroll.viewport(), vp_pos)
|
|
3063
|
+
|
|
3064
|
+
# If label is larger than pixmap, pixmap may be centered inside label.
|
|
3065
|
+
pm_w, pm_h = pm.width(), pm.height()
|
|
3066
|
+
lbl_w, lbl_h = self.label.width(), self.label.height()
|
|
3067
|
+
|
|
3068
|
+
off_x = max(0, (lbl_w - pm_w) // 2)
|
|
3069
|
+
off_y = max(0, (lbl_h - pm_h) // 2)
|
|
3070
|
+
|
|
3071
|
+
px = p_label.x() - off_x
|
|
3072
|
+
py = p_label.y() - off_y
|
|
3073
|
+
|
|
3074
|
+
# Outside the drawn pixmap area → clamp
|
|
3075
|
+
px = max(0, min(px, pm_w - 1))
|
|
3076
|
+
py = max(0, min(py, pm_h - 1))
|
|
3077
|
+
|
|
3078
|
+
s = max(float(self.scale), 1e-12)
|
|
3079
|
+
|
|
3080
|
+
# pixmap pixels -> image pixels (pm = image * scale)
|
|
3081
|
+
xi = int(round(px / s))
|
|
3082
|
+
yi = int(round(py / s))
|
|
3083
|
+
return xi, yi
|
|
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]):
|
|
@@ -2761,8 +3187,6 @@ class ImageSubWindow(QWidget):
|
|
|
2761
3187
|
|
|
2762
3188
|
super().mousePressEvent(e)
|
|
2763
3189
|
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
3190
|
def _show_readout(self, xi, yi, sample):
|
|
2767
3191
|
mw = self._find_main_window()
|
|
2768
3192
|
if mw is None:
|