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.

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -1,4 +1,4 @@
1
- # pro/subwindow.py
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
- #------ Replay helpers------
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
- # Emit self so the main window can locate our QMdiSubWindow wrapper.
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
- if abs(scale - self.scale) > 1e-9:
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=False) # repaint current frame
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
- # If DocManager maps preview/ROI views, prefer the true backing doc as base
893
- dm = getattr(self, "_docman", None)
1008
+ # existing identity (whatever you already do)
894
1009
  try:
895
- if dm and hasattr(dm, "get_document_for_view"):
896
- back = dm.get_document_for_view(self)
897
- if back is not None:
898
- base = back
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
- meta = (getattr(doc, "metadata", None) or {})
903
- base_meta = (getattr(base, "metadata", None) or {})
1020
+ # NEW: add the current user-visible view title
1021
+ st["source_view_title"] = self._current_view_title_for_drag()
904
1022
 
905
- return {
906
- "doc_uid": getattr(doc, "uid", None),
907
- "base_doc_uid": getattr(base, "uid", None),
908
- "file_path": meta.get("file_path") or base_meta.get("file_path") or "",
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 list(self._watched_docs):
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
- # Connect new
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: return
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
- # strip any carried-over glyphs (🔗, ■, Active View: ”) from overrides/doc names
1511
+ # Strip badges (🔗, ■, etc) AND "Active View:" prefix
1352
1512
  core, _ = self._strip_decorations(base)
1353
1513
 
1354
- title = core
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
- title = f"{LINK_PREFIX}{title}"
1526
+ shown = f"{LINK_PREFIX}{shown}"
1357
1527
  if self._mask_dot_enabled:
1358
- title = f"{MASK_GLYPH} {title}"
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
- w = self.parent()
1521
- while w is not None and not isinstance(w, QMdiSubWindow):
1522
- w = w.parent()
1523
- return w
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
- # Prefer a per-view override; otherwise doc display name
1527
- return self._view_title_override or self.document.display_name()
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() # only scale needs a redraw
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), # legacy
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()) # uid + base_uid + file_path
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() # (x,y,w,h) in full-image coords
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
- if self.label.pixmap():
1674
- drag.setPixmap(self.label.pixmap())
1675
- drag.exec()
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(96, 96,
1819
- Qt.AspectRatioMode.KeepAspectRatio,
1820
- Qt.TransformationMode.SmoothTransformation))
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
- # Replace the preview’s static copy with the edited ROI buffer
2117
- src["arr"] = np.asarray(roi_img).copy()
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: # compress absurdly large ranges
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
- # 7) Scale & present
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
- # ── NEW: WCS grid overlay (draw on the scaled pixmap so lines stay 1px) ──
2257
- if getattr(self, "_show_wcs_grid", False):
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
- display_h, display_w = base_img.shape[:2]
2580
+ # Present final-quality after rebuild
2581
+ self._present_scaled(interactive=False)
2281
2582
 
2282
- # Pixel scales and FOV using celestial WCS
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
- # Choose grid spacing from prefs (or auto heuristic)
2290
- if pref_mode == "fixed":
2291
- step_deg = float(pref_step_val if pref_step_unit == "deg" else (pref_step_val / 60.0))
2292
- step_deg = max(1e-6, min(step_deg, 90.0)) # clamp to sane range
2293
- else:
2294
- # Auto spacing (your previous logic)
2295
- nice = [0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30]
2296
- target_lines = 8
2297
- desired = max(fov_deg / target_lines, px_deg * 100)
2298
- step_deg = min((n for n in nice if n >= desired), default=30)
2299
-
2300
- # World rect from image corners using celestial WCS
2301
- corners = _np.array([[0, 0], [W_full-1, 0], [0, H_full-1], [W_full-1, H_full-1]], dtype=float)
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
- # RA labels on top edge
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
- p.end()
2445
- scaled = pm.toImage()
2602
+ sw = max(1, int(pm_base.width() * self.scale))
2603
+ sh = max(1, int(pm_base.height() * self.scale))
2446
2604
 
2447
- # ── end WCS grid overlay ────────────────────────────────────────────────
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
- self.label.setPixmap(QPixmap.fromImage(scaled))
2450
- self.label.resize(scaled.size())
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 current_preview_roi(self) -> tuple[int,int,int,int] | None:
2630
+ def _draw_wcs_grid_on_pixmap(self, pm_scaled: QPixmap) -> QPixmap:
2458
2631
  """
2459
- Returns (x, y, w, h) in FULL image coordinates if a preview tab is active, else None.
2632
+ Your existing WCS painter logic, moved to operate on a QPixmap (already scaled).
2633
+ Runs ONLY on non-interactive redraw.
2460
2634
  """
2461
- if not self.has_active_preview():
2462
- return None
2463
- src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2464
- return None if src is None else tuple(src["roi"])
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
- def current_preview_name(self) -> str | None:
2467
- if not self.has_active_preview():
2468
- return None
2469
- src = next((p for p in self._previews if p["id"] == self._active_preview_id), None)
2470
- return None if src is None else src["name"]
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._qimg_src is None:
2821
+ if getattr(self, "_qimg_src", None) is None and getattr(self, "_pm_src", None) is None:
2476
2822
  return
2477
- old_scale = self.scale
2478
- # clamp with new max
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 and redraw (updates label size + scrollbar ranges)
2847
+ # Apply new scale
2505
2848
  self.scale = new_scale
2506
- self._render(rebuild=False)
2507
2849
 
2508
- # Reproject that image point to label coords at new scale
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
- self._schedule_emit_view_transform()
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
- hbar = self.scroll.horizontalScrollBar()
2666
- vbar = self.scroll.verticalScrollBar()
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
- # Upper-left in label coords
2669
- x_label0 = hbar.value() + vp_rect.left()
2670
- y_label0 = vbar.value() + vp_rect.top()
2671
- x_label1 = hbar.value() + vp_rect.right()
2672
- y_label1 = vbar.value() + vp_rect.bottom()
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
- s = max(self.scale, 1e-12)
3100
+ x0, y0 = p0
3101
+ x1, y1 = p1
2675
3102
 
2676
- x0 = int(round(x_label0 / s))
2677
- y0 = int(round(y_label0 / s))
2678
- x1 = int(round(x_label1 / s))
2679
- y1 = int(round(y_label1 / s))
3103
+ x = min(x0, x1)
3104
+ y = min(y0, y1)
3105
+ w = abs(x1 - x0)
3106
+ h = abs(y1 - y0)
2680
3107
 
2681
- if x1 <= x0 or y1 <= y0:
3108
+ if w < 1 or h < 1:
2682
3109
  self._cancel_rubber()
2683
3110
  return
2684
3111
 
2685
- roi = (x0, y0, x1 - x0, y1 - y0)
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: