setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.10__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.
Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/clahe.py CHANGED
@@ -122,7 +122,10 @@ class CLAHEDialogPro(QDialog):
122
122
  except Exception as e:
123
123
  import logging
124
124
  logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
125
-
125
+ try:
126
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
127
+ except Exception:
128
+ pass # older PyQt6 versions
126
129
  self.doc = doc
127
130
  self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
128
131
  disp = self.orig
@@ -105,7 +105,10 @@ class ContinuumSubtractTab(QWidget):
105
105
  self.processing_thread = None
106
106
  self.original_header = None
107
107
  self._clickable_images = {}
108
-
108
+ try:
109
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
110
+ except Exception:
111
+ pass # older PyQt6 versions
109
112
 
110
113
  def initUI(self):
111
114
  self.spinnerLabel = QLabel("") # starts empty
setiastro/saspro/convo.py CHANGED
@@ -151,7 +151,10 @@ class ConvoDeconvoDialog(QDialog):
151
151
  # Only follow global active-doc changes if we *weren't* given a doc
152
152
  if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
153
153
  self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
154
-
154
+ try:
155
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
156
+ except Exception:
157
+ pass # older PyQt6 versions
155
158
  self.setWindowTitle(self.tr("Convolution / Deconvolution"))
156
159
  self.setWindowFlag(Qt.WindowType.Window, True)
157
160
  self.setWindowModality(Qt.WindowModality.NonModal)
@@ -228,21 +228,116 @@ class WaitForFileWorker(QThread):
228
228
  fileFound = pyqtSignal(str)
229
229
  cancelled = pyqtSignal()
230
230
  error = pyqtSignal(str)
231
- def __init__(self, glob_pat: str, timeout_sec=1800, parent=None):
231
+
232
+ def __init__(
233
+ self,
234
+ glob_pat: str,
235
+ timeout_sec: int = 1800,
236
+ parent=None,
237
+ *,
238
+ poll_ms: int = 200,
239
+ stable_polls: int = 6, # 6 * 200ms = ~1.2s of stability
240
+ stable_timeout_sec: int = 120, # extra time after first detection
241
+ ):
232
242
  super().__init__(parent)
233
243
  self._glob = glob_pat
234
- self._timeout = timeout_sec
244
+ self._timeout = int(timeout_sec)
245
+ self._poll_ms = int(poll_ms)
246
+ self._stable_polls = int(stable_polls)
247
+ self._stable_timeout = int(stable_timeout_sec)
235
248
  self._running = True
249
+
250
+ def stop(self):
251
+ self._running = False
252
+
253
+ def _best_candidate(self, paths: list[str]) -> str | None:
254
+ if not paths:
255
+ return None
256
+ # prefer biggest file; tie-break by newest mtime
257
+ def key(p):
258
+ try:
259
+ st = os.stat(p)
260
+ return (st.st_size, st.st_mtime)
261
+ except Exception:
262
+ return (-1, -1)
263
+ paths.sort(key=key, reverse=True)
264
+ return paths[0]
265
+
266
+ def _is_stable_and_readable(self, path: str) -> bool:
267
+ """
268
+ Consider stable when size+mtime unchanged for N polls in a row AND file is readable.
269
+ Handles slow writers + Windows "file still locked" issues.
270
+ """
271
+ stable = 0
272
+ last = None
273
+
274
+ t0 = time.monotonic()
275
+ while self._running and (time.monotonic() - t0) < self._stable_timeout:
276
+ try:
277
+ st = os.stat(path)
278
+ cur = (st.st_size, st.st_mtime)
279
+ if st.st_size <= 0:
280
+ stable = 0
281
+ last = cur
282
+ elif cur == last:
283
+ stable += 1
284
+ else:
285
+ stable = 0
286
+ last = cur
287
+
288
+ if stable >= self._stable_polls:
289
+ # extra “is it readable?” check (important on Windows)
290
+ try:
291
+ with open(path, "rb") as f:
292
+ f.read(64)
293
+ return True
294
+ except PermissionError:
295
+ # still locked by writer, keep waiting
296
+ stable = 0
297
+ except Exception:
298
+ # transient weirdness: keep waiting, don’t declare failure yet
299
+ stable = 0
300
+
301
+ except FileNotFoundError:
302
+ stable = 0
303
+ last = None
304
+ except Exception:
305
+ # don't crash the worker for stat weirdness
306
+ stable = 0
307
+
308
+ time.sleep(self._poll_ms / 1000.0)
309
+
310
+ return False
311
+
236
312
  def run(self):
237
- start = time.time()
238
- while self._running and (time.time() - start < self._timeout):
239
- m = glob.glob(self._glob)
240
- if m:
241
- self.fileFound.emit(m[0]); return
242
- time.sleep(1)
243
- if self._running: self.error.emit("Output file not found within timeout.")
244
- else: self.cancelled.emit()
245
- def stop(self): self._running = False
313
+ t_start = time.monotonic()
314
+ seen_first_candidate_at = None
315
+
316
+ while self._running and (time.monotonic() - t_start) < self._timeout:
317
+ matches = glob.glob(self._glob)
318
+ cand = self._best_candidate(matches)
319
+
320
+ if cand:
321
+ if seen_first_candidate_at is None:
322
+ seen_first_candidate_at = time.monotonic()
323
+
324
+ if self._is_stable_and_readable(cand):
325
+ self.fileFound.emit(cand)
326
+ return
327
+
328
+ # If we've been seeing candidates for a while but none stabilize,
329
+ # keep looping until global timeout. (This is common on slow disks.)
330
+
331
+ time.sleep(self._poll_ms / 1000.0)
332
+
333
+ if not self._running:
334
+ self.cancelled.emit()
335
+ else:
336
+ extra = ""
337
+ if seen_first_candidate_at is not None:
338
+ extra = " (output appeared but never stabilized)"
339
+ self.error.emit("Output file not found within timeout." + extra)
340
+
246
341
 
247
342
 
248
343
  # =============================================================================
@@ -274,6 +369,10 @@ class CosmicClarityDialogPro(QDialog):
274
369
  self.setWindowFlag(Qt.WindowType.Window, True)
275
370
  self.setWindowModality(Qt.WindowModality.NonModal)
276
371
  self.setModal(False)
372
+ try:
373
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
374
+ except Exception:
375
+ pass # older PyQt6 versions
277
376
  if icon:
278
377
  try: self.setWindowIcon(icon)
279
378
  except Exception as e:
@@ -552,6 +651,12 @@ class CosmicClarityDialogPro(QDialog):
552
651
  QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
553
652
  return
554
653
 
654
+ # ✅ compute base early (we need it for purge + glob)
655
+ base = self._base_name()
656
+
657
+ # ✅ purge any stale outputs for THIS base name (avoids matching old files)
658
+ _purge_cc_io(self.cosmic_root, clear_input=False, clear_output=True, prefix=base)
659
+
555
660
  # Build args (SASv2 flags mirrored)
556
661
  args = []
557
662
  if mode == "sharpen":
@@ -595,7 +700,7 @@ class CosmicClarityDialogPro(QDialog):
595
700
 
596
701
  # Wait for output file
597
702
  base = self._base_name()
598
- out_glob = os.path.join(self.cosmic_root, "output", f"{base}{suffix}.*")
703
+ out_glob = os.path.join(self.cosmic_root, "output", f"{base}*{suffix}*.*")
599
704
  self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
600
705
  self._wait.cancelled.connect(self._cancel_all)
601
706
  self._wait.show()
@@ -611,19 +716,25 @@ class CosmicClarityDialogPro(QDialog):
611
716
 
612
717
  def _read_proc_output(self, proc: QProcess, which="main"):
613
718
  out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
614
- if not self._wait: return
719
+ if not self._wait:
720
+ return
721
+
615
722
  for line in out.splitlines():
616
723
  line = line.strip()
617
- if not line: continue
724
+ if not line:
725
+ continue
726
+
618
727
  if line.startswith("Progress:"):
619
728
  try:
620
- pct = float(line.split()[1].replace("%",""))
729
+ pct = float(line.split()[1].replace("%", ""))
621
730
  self._wait.set_progress(int(pct))
622
731
  except Exception:
623
732
  pass
624
- else:
625
- self._wait.append_output(line)
626
- print(f"[CC] {line}")
733
+ continue # <- skip echo
734
+
735
+ # non-progress lines: keep showing + printing
736
+ self._wait.append_output(line)
737
+ print(f"[CC] {line}")
627
738
 
628
739
  def _on_proc_finished(self, mode, suffix, code, status):
629
740
  if code != 0:
@@ -58,7 +58,8 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
58
58
  self._rotating = False
59
59
  self._angle0 = 0.0
60
60
  self._pivot_scene = QPointF()
61
-
61
+ self._bounds_scene: QRectF | None = None
62
+ self._clamp_eps_deg = 0.25 # treat as "unrotated" if |angle| < eps (deg)
62
63
  self._grab_pad = 20 # ← extra hit slop in screen px
63
64
  self._edge_pad_px = EDGE_GRAB_PX
64
65
  self.setZValue(100) # ← keep above pixmap
@@ -83,7 +84,26 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
83
84
  dx = p1.x() - p0.x()
84
85
  dy = p1.y() - p0.y()
85
86
  return math.hypot(dx, dy)
86
-
87
+ def setBoundsSceneRect(self, r: QRectF | None):
88
+ """Set the scene-rect bounds we should stay within when unrotated."""
89
+ self._bounds_scene = QRectF(r) if r is not None else None
90
+
91
+ def _is_unrotated(self) -> bool:
92
+ # normalize angle to [-180, 180]
93
+ a = float(self.rotation()) % 360.0
94
+ if a > 180.0:
95
+ a -= 360.0
96
+ return abs(a) < self._clamp_eps_deg
97
+
98
+ def _bounds_local(self) -> QRectF | None:
99
+ """Bounds rect mapped into the item's local coordinates (only valid when unrotated)."""
100
+ if self._bounds_scene is None:
101
+ return None
102
+ # When unrotated, this is safe and stable.
103
+ tl = self.mapFromScene(self._bounds_scene.topLeft())
104
+ br = self.mapFromScene(self._bounds_scene.bottomRight())
105
+ return QRectF(tl, br).normalized()
106
+
87
107
  def _edge_under_cursor(self, scene_pos: QPointF) -> Optional[str]:
88
108
  """
89
109
  Return 'l', 'r', 't', or 'b' if the pointer is near an edge (within px-tolerance),
@@ -218,12 +238,52 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
218
238
  QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
219
239
  ):
220
240
  self._sync_handles()
241
+
242
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
243
+ if self._bounds_scene is not None and self._is_unrotated():
244
+ new_pos = QPointF(value)
245
+
246
+ # current scene rect of the item (at current pos)
247
+ sr0 = self.mapRectToScene(self.rect()) # QRectF in scene coords
248
+
249
+ # shift it by the delta between proposed pos and current pos
250
+ d = new_pos - self.pos()
251
+ sr = sr0.translated(d)
252
+
253
+ b = self._bounds_scene
254
+ dx = 0.0
255
+ dy = 0.0
256
+
257
+ if sr.left() < b.left():
258
+ dx = b.left() - sr.left()
259
+ elif sr.right() > b.right():
260
+ dx = b.right() - sr.right()
261
+
262
+ if sr.top() < b.top():
263
+ dy = b.top() - sr.top()
264
+ elif sr.bottom() > b.bottom():
265
+ dy = b.bottom() - sr.bottom()
266
+
267
+ if dx != 0.0 or dy != 0.0:
268
+ return new_pos + QPointF(dx, dy)
269
+
270
+ return new_pos
271
+
221
272
  return super().itemChange(change, value)
222
273
 
223
274
  def _resize_via_handle(self, scene_pt: QPointF):
224
275
  r = self.rect()
225
276
  p = self.mapFromScene(scene_pt)
226
277
 
278
+ # Clamp handle drag to bounds only when unrotated.
279
+ if self._bounds_scene is not None and self._is_unrotated():
280
+ bL = self._bounds_local()
281
+ if bL is not None:
282
+ # NOTE: bL is in the same local coordinate space as r/p.
283
+ px = min(max(p.x(), bL.left()), bL.right())
284
+ py = min(max(p.y(), bL.top()), bL.bottom())
285
+ p = QPointF(px, py)
286
+
227
287
  # Corners
228
288
  if self._active == "tl": r.setTopLeft(p)
229
289
  elif self._active == "tr": r.setTopRight(p)
@@ -277,10 +337,19 @@ class CropDialogPro(QDialog):
277
337
  self._main = parent
278
338
  self.doc = document
279
339
 
280
- # Connect to active document change signal
340
+ self._follow_conn = False
281
341
  if hasattr(self._main, "currentDocumentChanged"):
282
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
342
+ try:
343
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
344
+ self._follow_conn = True
345
+ except Exception:
346
+ self._follow_conn = False
283
347
 
348
+ self.finished.connect(self._cleanup_connections)
349
+ try:
350
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
351
+ except Exception:
352
+ pass # older PyQt6 versions
284
353
  self._rect_item: Optional[ResizableRotatableRectItem] = None
285
354
  self._pix_item: Optional[QGraphicsPixmapItem] = None
286
355
  self._drawing = False
@@ -415,7 +484,7 @@ class CropDialogPro(QDialog):
415
484
  self.btn_prev.clicked.connect(self._load_previous)
416
485
  self.btn_apply.clicked.connect(self._apply_one)
417
486
  self.btn_batch.clicked.connect(self._apply_batch)
418
- self.btn_close.clicked.connect(self.accept)
487
+ self.btn_close.clicked.connect(self.close)
419
488
 
420
489
  # seed image
421
490
  self._load_from_doc()
@@ -607,6 +676,7 @@ class CropDialogPro(QDialog):
607
676
  if e.type() == QEvent.Type.MouseMove and self._drawing:
608
677
  r = QRectF(self._origin, scene_pt).normalized()
609
678
  r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
679
+ r = self._clamp_rect_to_pixmap(r)
610
680
  self._draw_live_rect(r)
611
681
 
612
682
  # ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
@@ -618,9 +688,12 @@ class CropDialogPro(QDialog):
618
688
  self._drawing = False
619
689
  r = QRectF(self._origin, scene_pt).normalized()
620
690
  r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
691
+ r = self._clamp_rect_to_pixmap(r)
621
692
  self._clear_live_rect()
693
+
622
694
  self._rect_item = ResizableRotatableRectItem(r)
623
695
  self._rect_item.setZValue(10)
696
+ self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
624
697
  self._rect_item.setFixedAspectRatio(self._current_ar_value())
625
698
  self.scene.addItem(self._rect_item)
626
699
 
@@ -706,6 +779,7 @@ class CropDialogPro(QDialog):
706
779
  if self._rect_item is None:
707
780
  self._rect_item = ResizableRotatableRectItem(r)
708
781
  self._rect_item.setZValue(10)
782
+ self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
709
783
  self.scene.addItem(self._rect_item)
710
784
  else:
711
785
  self._rect_item.setRotation(0.0)
@@ -749,6 +823,32 @@ class CropDialogPro(QDialog):
749
823
  if hasattr(self, "_live_rect") and self._live_rect:
750
824
  self.scene.removeItem(self._live_rect); self._live_rect = None
751
825
 
826
+ def _pixmap_scene_rect(self) -> QRectF | None:
827
+ """Scene rect occupied by the pixmap (image) item."""
828
+ if not self._pix_item:
829
+ return None
830
+ return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
831
+
832
+ def _clamp_rect_to_pixmap(self, r: QRectF) -> QRectF:
833
+ """Intersect an axis-aligned QRectF with the pixmap scene rect."""
834
+ bounds = self._pixmap_scene_rect()
835
+ if bounds is None:
836
+ return r.normalized()
837
+ rr = r.normalized().intersected(bounds)
838
+ # avoid empty rects (keep at least 1x1 scene unit)
839
+ if rr.isNull() or rr.width() <= 1e-6 or rr.height() <= 1e-6:
840
+ # fallback: clamp to a 1x1 rect at the nearest point inside bounds
841
+ x = min(max(r.center().x(), bounds.left()), bounds.right())
842
+ y = min(max(r.center().y(), bounds.top()), bounds.bottom())
843
+ rr = QRectF(x, y, 1.0, 1.0)
844
+ return rr.normalized()
845
+
846
+ def _bounds_scene_rect(self) -> QRectF | None:
847
+ if not self._pix_item:
848
+ return None
849
+ return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
850
+
851
+
752
852
  # ---------- preview toggles ----------
753
853
  def _toggle_autostretch(self):
754
854
  self._autostretch_on = not self._autostretch_on
@@ -768,6 +868,7 @@ class CropDialogPro(QDialog):
768
868
  r, ang, pos = state
769
869
  self._rect_item = ResizableRotatableRectItem(r)
770
870
  self._rect_item.setZValue(10)
871
+ self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
771
872
  self._rect_item.setFixedAspectRatio(self._current_ar_value())
772
873
  self._rect_item.setRotation(ang)
773
874
  self._rect_item.setPos(pos)
@@ -785,6 +886,7 @@ class CropDialogPro(QDialog):
785
886
  r = QRectF(CropDialogPro._prev_rect)
786
887
  self._rect_item = ResizableRotatableRectItem(r)
787
888
  self._rect_item.setZValue(10)
889
+ self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
788
890
  self._rect_item.setFixedAspectRatio(self._current_ar_value())
789
891
  self._rect_item.setRotation(CropDialogPro._prev_angle)
790
892
  self._rect_item.setPos(CropDialogPro._prev_pos)
@@ -803,6 +905,7 @@ class CropDialogPro(QDialog):
803
905
  sx, sy = w_img / pm.width(), h_img / pm.height()
804
906
  return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
805
907
 
908
+
806
909
  def _apply_one(self):
807
910
  if not self._rect_item:
808
911
  QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
@@ -865,7 +968,7 @@ class CropDialogPro(QDialog):
865
968
  self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
866
969
  self._maybe_notify_wcs_update(new_meta)
867
970
  self.crop_applied.emit(out)
868
- self.accept()
971
+ self.close()
869
972
  except Exception as e:
870
973
  QMessageBox.critical(self, self.tr("Apply failed"), str(e))
871
974
 
@@ -951,7 +1054,7 @@ class CropDialogPro(QDialog):
951
1054
  QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
952
1055
  if last_cropped is not None:
953
1056
  self.crop_applied.emit(last_cropped)
954
- self.accept()
1057
+ self.close()
955
1058
 
956
1059
  def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
957
1060
  dbg = (meta or {}).get("__wcs_debug__")
@@ -981,3 +1084,16 @@ class CropDialogPro(QDialog):
981
1084
  except Exception:
982
1085
  # Be quiet if formatting fails
983
1086
  pass
1087
+
1088
+ def _cleanup_connections(self):
1089
+ try:
1090
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
1091
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
1092
+ except Exception:
1093
+ pass
1094
+ self._follow_conn = False
1095
+
1096
+
1097
+ def closeEvent(self, ev):
1098
+ self._cleanup_connections()
1099
+ super().closeEvent(ev)
@@ -521,32 +521,6 @@ class CurveEditor(QGraphicsView):
521
521
  if ln is not None:
522
522
  ln.setVisible(False)
523
523
 
524
- def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
525
- """(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
526
- out = []
527
- lastx = -1e9
528
- for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
529
- x = float(np.clip(x, 0.0, 360.0))
530
- y = float(np.clip(y, 0.0, 360.0))
531
- # strictly increasing X
532
- if x <= lastx:
533
- x = lastx + 1e-3
534
- lastx = x
535
- out.append((x / 360.0, 1.0 - (y / 360.0)))
536
- # ensure endpoints
537
- if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
538
- if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
539
- # clamp
540
- return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
541
-
542
- def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
543
- """Take endpoints+handles from editor => normalized points."""
544
- pts_scene = []
545
- for p in (self.editor.end_points + self.editor.control_points):
546
- pos = p.scenePos()
547
- pts_scene.append((float(pos.x()), float(pos.y())))
548
- return self._scene_to_norm_points(pts_scene)
549
-
550
524
 
551
525
  def redistributeHandlesByPivot(self, u: float):
552
526
  """
@@ -1022,10 +996,18 @@ class CurvesDialogPro(QDialog):
1022
996
  self._main = parent
1023
997
  self.doc = document
1024
998
 
1025
- # Connect to active document change signal
999
+ self._follow_conn = False
1026
1000
  if hasattr(self._main, "currentDocumentChanged"):
1027
- self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
1028
-
1001
+ try:
1002
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
1003
+ self._follow_conn = True
1004
+ except Exception:
1005
+ self._follow_conn = False
1006
+ try:
1007
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
1008
+ except Exception:
1009
+ pass # older PyQt6 versions
1010
+ self.finished.connect(self._cleanup_connections)
1029
1011
  self._preview_img = None # downsampled float01
1030
1012
  self._full_img = None # full-res float01
1031
1013
  self._pix = None
@@ -1040,7 +1022,14 @@ class CurvesDialogPro(QDialog):
1040
1022
  self._cdf = None
1041
1023
  self._cdf_bins = 1024
1042
1024
  self._cdf_total = 0
1043
-
1025
+ # Debounce: coalesce rapid curve edits into one rebuild
1026
+ self._curve_debounce_ms = 120 # tweak: 80–200ms feels good
1027
+ self._curve_debounce = QTimer(self)
1028
+ self._curve_debounce.setSingleShot(True)
1029
+ self._curve_debounce.timeout.connect(self._rebuild_preview_from_curve_debounced)
1030
+
1031
+ # Optional: generation counter so stale results can't “win”
1032
+ self._curve_gen = 0
1044
1033
  self._clip_scale = 1.0 # preview→full multiplier
1045
1034
  self._cdf_total_full = 0 # total pixels in full image (H*W)
1046
1035
  self._cdf_total_preview = 0 # total pixels in preview (H*W)
@@ -1212,18 +1201,34 @@ class CurvesDialogPro(QDialog):
1212
1201
 
1213
1202
  def _on_editor_curve_changed(self, _lut8=None):
1214
1203
  """
1215
- Called on every editor redraw/drag. Persist the currently edited curve
1216
- into the store, refresh overlays, and do a realtime preview.
1204
+ Called on every editor redraw/drag. Persist points and refresh overlays.
1205
+ Preview rebuild is DEBOUNCED to avoid spamming.
1217
1206
  """
1218
1207
  try:
1219
1208
  self._curves_store[self._current_mode_key] = self._editor_points_norm()
1220
1209
  except Exception:
1221
1210
  pass
1222
- # show the true shapes of other channels too
1211
+
1212
+ # cheap: overlay redraw is fine every move (or you can debounce this too)
1223
1213
  self._refresh_overlays()
1224
- # now build from *all* current curves (including the just-edited one)
1225
- self._quick_preview()
1226
1214
 
1215
+ # expensive: debounce the preview rebuild
1216
+ self._curve_gen += 1
1217
+ self._curve_debounce.start(self._curve_debounce_ms)
1218
+
1219
+ def _rebuild_preview_from_curve_debounced(self):
1220
+ """
1221
+ Runs after the user pauses dragging for _curve_debounce_ms.
1222
+ Only rebuild if we have images loaded.
1223
+ """
1224
+ if self._preview_orig is None and self._preview_img is None:
1225
+ return
1226
+ # If your preview toggle is off, you may want to skip:
1227
+ if not getattr(self, "btn_preview", None) or not self.btn_preview.isChecked():
1228
+ return
1229
+
1230
+ # Do the real work (what you were doing before)
1231
+ self._quick_preview()
1227
1232
 
1228
1233
  def _active_mode_key(self) -> str:
1229
1234
  for b in self.mode_group.buttons():
@@ -1670,29 +1675,53 @@ class CurvesDialogPro(QDialog):
1670
1675
 
1671
1676
  # 1) Put this helper inside CurvesDialogPro (near other helpers)
1672
1677
  def _map_label_xy_to_image_ij(self, x: float, y: float):
1673
- """Map label-local coords (x,y) to _preview_img pixel (i,j). Returns (ix, iy) or None."""
1678
+ """
1679
+ Map label-local coords (x,y) to _preview_img pixel (ix, iy).
1680
+ Correct even when the pixmap is centered inside a larger label.
1681
+ Returns None if cursor is outside the displayed pixmap area.
1682
+ """
1674
1683
  if self._pix is None:
1675
1684
  return None
1685
+
1676
1686
  pm_disp = self.label.pixmap()
1677
1687
  if pm_disp is None or pm_disp.isNull():
1678
1688
  return None
1679
1689
 
1680
- src_w = self._pix.width() # size of the *source* pixmap (preview image)
1681
- src_h = self._pix.height()
1682
- disp_w = pm_disp.width() # size of the *displayed* pixmap on the label
1690
+ # Displayed pixmap size (after zoom)
1691
+ disp_w = pm_disp.width()
1683
1692
  disp_h = pm_disp.height()
1684
- if src_w <= 0 or src_h <= 0 or disp_w <= 0 or disp_h <= 0:
1693
+
1694
+ # Label may be bigger -> pixmap is centered with margins
1695
+ lbl_w = self.label.width()
1696
+ lbl_h = self.label.height()
1697
+
1698
+ off_x = max(0, (lbl_w - disp_w) // 2)
1699
+ off_y = max(0, (lbl_h - disp_h) // 2)
1700
+
1701
+ # Remove margins: label-local -> pixmap-local
1702
+ px = float(x) - float(off_x)
1703
+ py = float(y) - float(off_y)
1704
+
1705
+ if px < 0 or py < 0 or px >= disp_w or py >= disp_h:
1706
+ return None # outside actual image area
1707
+
1708
+ # Now convert displayed pixmap pixel -> source preview pixel
1709
+ src_w = self._pix.width()
1710
+ src_h = self._pix.height()
1711
+ if src_w <= 0 or src_h <= 0:
1685
1712
  return None
1686
1713
 
1687
1714
  sx = disp_w / float(src_w)
1688
1715
  sy = disp_h / float(src_h)
1689
1716
 
1690
- ix = int(x / sx)
1691
- iy = int(y / sy)
1717
+ ix = int(px / sx)
1718
+ iy = int(py / sy)
1719
+
1692
1720
  if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
1693
1721
  return None
1694
1722
  return ix, iy
1695
1723
 
1724
+
1696
1725
  def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
1697
1726
  """(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
1698
1727
  out = []
@@ -2178,6 +2207,44 @@ class CurvesDialogPro(QDialog):
2178
2207
 
2179
2208
  return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
2180
2209
 
2210
+ def closeEvent(self, ev):
2211
+ self._cleanup_connections()
2212
+ super().closeEvent(ev)
2213
+
2214
+ def _cleanup_connections(self):
2215
+ # disconnect the "follow active doc" hook
2216
+ try:
2217
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
2218
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
2219
+ except Exception:
2220
+ pass
2221
+ self._follow_conn = False
2222
+
2223
+ # stop/kill any running worker thread(s)
2224
+ try:
2225
+ thr = getattr(self, "_thr", None)
2226
+ if thr is not None:
2227
+ try:
2228
+ thr.requestInterruption()
2229
+ except Exception:
2230
+ pass
2231
+ try:
2232
+ thr.quit()
2233
+ except Exception:
2234
+ pass
2235
+ try:
2236
+ thr.wait(250)
2237
+ except Exception:
2238
+ pass
2239
+ except Exception:
2240
+ pass
2241
+
2242
+ # optional: drop refs that can keep things alive
2243
+ try:
2244
+ self._thr = None
2245
+ except Exception:
2246
+ pass
2247
+
2181
2248
 
2182
2249
  # zoom/pan
2183
2250
  def _apply_zoom(self):