setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (115) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/colorwheel.svg +97 -0
  4. setiastro/images/cosmic.svg +40 -0
  5. setiastro/images/cosmicsat.svg +24 -0
  6. setiastro/images/first_quarter.png +0 -0
  7. setiastro/images/full_moon.png +0 -0
  8. setiastro/images/graxpert.svg +19 -0
  9. setiastro/images/last_quarter.png +0 -0
  10. setiastro/images/linearfit.svg +32 -0
  11. setiastro/images/new_moon.png +0 -0
  12. setiastro/images/pixelmath.svg +42 -0
  13. setiastro/images/waning_crescent_1.png +0 -0
  14. setiastro/images/waning_crescent_2.png +0 -0
  15. setiastro/images/waning_crescent_3.png +0 -0
  16. setiastro/images/waning_crescent_4.png +0 -0
  17. setiastro/images/waning_crescent_5.png +0 -0
  18. setiastro/images/waning_gibbous_1.png +0 -0
  19. setiastro/images/waning_gibbous_2.png +0 -0
  20. setiastro/images/waning_gibbous_3.png +0 -0
  21. setiastro/images/waning_gibbous_4.png +0 -0
  22. setiastro/images/waning_gibbous_5.png +0 -0
  23. setiastro/images/waxing_crescent_1.png +0 -0
  24. setiastro/images/waxing_crescent_2.png +0 -0
  25. setiastro/images/waxing_crescent_3.png +0 -0
  26. setiastro/images/waxing_crescent_4.png +0 -0
  27. setiastro/images/waxing_crescent_5.png +0 -0
  28. setiastro/images/waxing_gibbous_1.png +0 -0
  29. setiastro/images/waxing_gibbous_2.png +0 -0
  30. setiastro/images/waxing_gibbous_3.png +0 -0
  31. setiastro/images/waxing_gibbous_4.png +0 -0
  32. setiastro/images/waxing_gibbous_5.png +0 -0
  33. setiastro/qml/ResourceMonitor.qml +84 -82
  34. setiastro/saspro/__main__.py +20 -1
  35. setiastro/saspro/_generated/build_info.py +2 -2
  36. setiastro/saspro/abe.py +37 -4
  37. setiastro/saspro/aberration_ai.py +237 -21
  38. setiastro/saspro/acv_exporter.py +379 -0
  39. setiastro/saspro/add_stars.py +33 -6
  40. setiastro/saspro/backgroundneutral.py +108 -40
  41. setiastro/saspro/blemish_blaster.py +4 -1
  42. setiastro/saspro/blink_comparator_pro.py +74 -24
  43. setiastro/saspro/clahe.py +4 -1
  44. setiastro/saspro/continuum_subtract.py +4 -1
  45. setiastro/saspro/convo.py +13 -7
  46. setiastro/saspro/cosmicclarity.py +129 -18
  47. setiastro/saspro/crop_dialog_pro.py +123 -7
  48. setiastro/saspro/curve_editor_pro.py +109 -42
  49. setiastro/saspro/doc_manager.py +245 -15
  50. setiastro/saspro/exoplanet_detector.py +120 -28
  51. setiastro/saspro/frequency_separation.py +1158 -204
  52. setiastro/saspro/ghs_dialog_pro.py +81 -16
  53. setiastro/saspro/graxpert.py +1 -0
  54. setiastro/saspro/gui/main_window.py +429 -228
  55. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  56. setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
  57. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  58. setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
  59. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  60. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  61. setiastro/saspro/halobgon.py +4 -0
  62. setiastro/saspro/histogram.py +5 -1
  63. setiastro/saspro/image_combine.py +4 -0
  64. setiastro/saspro/image_peeker_pro.py +4 -0
  65. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  66. setiastro/saspro/imageops/stretch.py +582 -62
  67. setiastro/saspro/isophote.py +4 -0
  68. setiastro/saspro/layers.py +13 -9
  69. setiastro/saspro/layers_dock.py +183 -3
  70. setiastro/saspro/legacy/image_manager.py +154 -20
  71. setiastro/saspro/legacy/numba_utils.py +67 -47
  72. setiastro/saspro/legacy/xisf.py +240 -98
  73. setiastro/saspro/live_stacking.py +180 -79
  74. setiastro/saspro/luminancerecombine.py +228 -27
  75. setiastro/saspro/mask_creation.py +174 -15
  76. setiastro/saspro/mfdeconv.py +113 -35
  77. setiastro/saspro/mfdeconvcudnn.py +119 -70
  78. setiastro/saspro/mfdeconvsport.py +112 -35
  79. setiastro/saspro/morphology.py +4 -0
  80. setiastro/saspro/multiscale_decomp.py +51 -12
  81. setiastro/saspro/numba_utils.py +72 -57
  82. setiastro/saspro/ops/commands.py +18 -18
  83. setiastro/saspro/ops/script_editor.py +10 -2
  84. setiastro/saspro/ops/scripts.py +122 -0
  85. setiastro/saspro/perfect_palette_picker.py +37 -3
  86. setiastro/saspro/plate_solver.py +84 -49
  87. setiastro/saspro/psf_viewer.py +119 -37
  88. setiastro/saspro/resources.py +67 -0
  89. setiastro/saspro/rgbalign.py +4 -0
  90. setiastro/saspro/selective_color.py +4 -1
  91. setiastro/saspro/sfcc.py +364 -152
  92. setiastro/saspro/shortcuts.py +160 -29
  93. setiastro/saspro/signature_insert.py +692 -33
  94. setiastro/saspro/stacking_suite.py +1331 -484
  95. setiastro/saspro/star_alignment.py +247 -123
  96. setiastro/saspro/star_spikes.py +4 -0
  97. setiastro/saspro/star_stretch.py +38 -3
  98. setiastro/saspro/stat_stretch.py +743 -128
  99. setiastro/saspro/subwindow.py +786 -360
  100. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  101. setiastro/saspro/wavescale_hdr.py +4 -1
  102. setiastro/saspro/wavescalede.py +4 -1
  103. setiastro/saspro/whitebalance.py +84 -12
  104. setiastro/saspro/widgets/common_utilities.py +28 -21
  105. setiastro/saspro/widgets/resource_monitor.py +109 -59
  106. setiastro/saspro/widgets/spinboxes.py +10 -13
  107. setiastro/saspro/wimi.py +27 -656
  108. setiastro/saspro/wims.py +13 -3
  109. setiastro/saspro/xisf.py +101 -11
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +2 -1
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
  113. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
  114. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
  115. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
@@ -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):
@@ -310,18 +310,133 @@ class ImageDocument(QObject):
310
310
 
311
311
  def close(self):
312
312
  """
313
- Explicit cleanup of swap files.
313
+ Free all resources held by this document:
314
+ - delete swap states for undo/redo
315
+ - clear undo/redo stacks
316
+ - drop in-memory image array and any in-memory history
317
+ - clear heavy metadata (headers/WCS)
318
+ - clear any cached previews/pixmaps
319
+ - disconnect signals to help break reference cycles
314
320
  """
315
- sm = get_swap_manager()
316
- # Clean up undo stack
317
- for swap_id, _, _ in self._undo:
318
- sm.delete_state(swap_id)
319
- self._undo.clear()
320
-
321
- # Clean up redo stack
322
- for swap_id, _, _ in self._redo:
323
- sm.delete_state(swap_id)
324
- self._redo.clear()
321
+ # --- 0) Stop emitting while we tear down (best-effort) --------------
322
+ try:
323
+ self.blockSignals(True)
324
+ except Exception:
325
+ pass
326
+
327
+ # --- 1) Swap cleanup (your existing logic) --------------------------
328
+ try:
329
+ sm = get_swap_manager()
330
+ except Exception:
331
+ sm = None
332
+
333
+ # Undo stack
334
+ try:
335
+ for item in list(getattr(self, "_undo", [])):
336
+ try:
337
+ swap_id = item[0] # (swap_id, ..., ...)
338
+ except Exception:
339
+ swap_id = None
340
+ if sm is not None and swap_id is not None:
341
+ try:
342
+ sm.delete_state(swap_id)
343
+ except Exception:
344
+ pass
345
+ getattr(self, "_undo", []).clear()
346
+ except Exception:
347
+ pass
348
+
349
+ # Redo stack
350
+ try:
351
+ for item in list(getattr(self, "_redo", [])):
352
+ try:
353
+ swap_id = item[0]
354
+ except Exception:
355
+ swap_id = None
356
+ if sm is not None and swap_id is not None:
357
+ try:
358
+ sm.delete_state(swap_id)
359
+ except Exception:
360
+ pass
361
+ getattr(self, "_redo", []).clear()
362
+ except Exception:
363
+ pass
364
+
365
+ # ROI preview stacks if you have them (your code uses _pundo/_predo on ROI docs)
366
+ for attr in ("_pundo", "_predo"):
367
+ try:
368
+ lst = getattr(self, attr, None)
369
+ if isinstance(lst, list):
370
+ # If these also store swap states, delete them too (safe even if not)
371
+ if sm is not None:
372
+ for item in list(lst):
373
+ try:
374
+ swap_id = item[0]
375
+ except Exception:
376
+ swap_id = None
377
+ if swap_id is not None:
378
+ try:
379
+ sm.delete_state(swap_id)
380
+ except Exception:
381
+ pass
382
+ lst.clear()
383
+ except Exception:
384
+ pass
385
+
386
+ # --- 2) Drop the big in-memory image --------------------------------
387
+ # This is what actually frees the 2–10GB allocations (assuming no other refs).
388
+ try:
389
+ self.image = None
390
+ except Exception:
391
+ pass
392
+
393
+ # --- 3) Clear metadata that can keep large objects alive -------------
394
+ # fits.Header/WCS objects aren't huge like the image, but they can keep references
395
+ # and add up; also helps break cycles.
396
+ try:
397
+ md = getattr(self, "metadata", None)
398
+ if isinstance(md, dict):
399
+ for k in ("wcs", "original_header", "fits_header", "wcs_header", "header"):
400
+ md.pop(k, None)
401
+ # If you keep derived/cached headers anywhere:
402
+ for k in ("_header_snapshot", "_wcs_snapshot", "roi_wcs_header"):
403
+ md.pop(k, None)
404
+ except Exception:
405
+ pass
406
+
407
+ # --- 4) Clear any preview/pixmap/qimage caches -----------------------
408
+ # Adjust these attr names to match what your view/doc uses.
409
+ for attr in ("_qimage_cache", "_pixmap_cache", "_preview_cache", "_render_cache"):
410
+ try:
411
+ v = getattr(self, attr, None)
412
+ if isinstance(v, dict):
413
+ v.clear()
414
+ setattr(self, attr, None)
415
+ except Exception:
416
+ pass
417
+
418
+ # --- 5) Disconnect signals (helps Qt reference cycles) ---------------
419
+ # If you connect doc.changed to closures (like ROI docs do), this helps.
420
+ try:
421
+ self.changed.disconnect()
422
+ except Exception:
423
+ pass
424
+
425
+ # If you have other signals, disconnect them similarly:
426
+ for sig_name in ("imageChanged", "metadataChanged"):
427
+ try:
428
+ sig = getattr(self, sig_name, None)
429
+ if sig is not None:
430
+ sig.disconnect()
431
+ except Exception:
432
+ pass
433
+
434
+ # --- 6) Allow signals again (optional) -------------------------------
435
+ try:
436
+ self.blockSignals(False)
437
+ except Exception:
438
+ pass
439
+
325
440
 
326
441
  def __del__(self):
327
442
  # Fallback cleanup if close() wasn't called (though explicit close is better)
@@ -1555,6 +1670,9 @@ def debug_dump_metadata_print(meta: dict, context: str = ""):
1555
1670
 
1556
1671
  print("===== END METADATA DUMP ({}) =====".format(context))
1557
1672
 
1673
+ import time
1674
+ _DEBUG_DND_DUP = False
1675
+
1558
1676
  class DocManager(QObject):
1559
1677
  documentAdded = pyqtSignal(object) # ImageDocument
1560
1678
  documentRemoved = pyqtSignal(object) # ImageDocument
@@ -2266,6 +2384,46 @@ class DocManager(QObject):
2266
2384
  if hasattr(doc, "changed"):
2267
2385
  doc.changed.emit()
2268
2386
 
2387
+ def _current_view_title_for_doc(self, source_doc: ImageDocument) -> str | None:
2388
+ """
2389
+ If the active MDI subwindow is showing 'source_doc' (or its parent/base),
2390
+ return the current view's title (windowTitle), otherwise None.
2391
+ """
2392
+ sw = self._active_subwindow()
2393
+ if sw is None:
2394
+ return None
2395
+
2396
+ try:
2397
+ w = sw.widget()
2398
+ except Exception:
2399
+ w = None
2400
+
2401
+ # Resolve what doc the active view corresponds to (base doc)
2402
+ try:
2403
+ base = (
2404
+ getattr(w, "base_document", None)
2405
+ or getattr(w, "_base_document", None)
2406
+ or getattr(w, "document", None)
2407
+ or getattr(sw, "document", None)
2408
+ )
2409
+ parent = getattr(base, "_parent_doc", None)
2410
+ if isinstance(parent, ImageDocument):
2411
+ base = parent
2412
+ except Exception:
2413
+ base = None
2414
+
2415
+ if base is not source_doc:
2416
+ return None
2417
+
2418
+ # Prefer the actual subwindow title (includes [View N], etc.)
2419
+ try:
2420
+ title = sw.windowTitle()
2421
+ title = title.strip() if isinstance(title, str) else ""
2422
+ return title or None
2423
+ except Exception:
2424
+ return None
2425
+
2426
+
2269
2427
  def duplicate_document(self, source_doc: ImageDocument, new_name: str | None = None) -> ImageDocument:
2270
2428
  # DEBUG: log the source doc WCS before we touch anything
2271
2429
  if _DEBUG_WCS:
@@ -2275,19 +2433,33 @@ class DocManager(QObject):
2275
2433
  name = "<src>"
2276
2434
 
2277
2435
  _debug_log_wcs_context(" source.metadata", getattr(source_doc, "metadata", {}))
2278
-
2436
+ if _DEBUG_DND_DUP:
2437
+ try:
2438
+ src_dn = source_doc.display_name() if hasattr(source_doc, "display_name") else None
2439
+ except Exception:
2440
+ src_dn = None
2441
+ print("\n[DNDDBG:DUPLICATE_DOCUMENT]")
2442
+ print(" source_doc:", source_doc, "id:", id(source_doc), "uid:", getattr(source_doc,"uid",None))
2443
+ print(" source_doc.display_name():", src_dn)
2444
+ print(" new_name arg:", new_name)
2279
2445
  # COPY-ON-WRITE: Share the source image instead of copying immediately.
2280
2446
  # The duplicate's apply_edit will copy when it first modifies the image.
2281
2447
  # This saves memory when duplicates are created but not modified.
2282
2448
  img_ref = source_doc.image # Shared reference, no copy
2283
2449
 
2284
2450
  meta = dict(source_doc.metadata or {})
2285
- base = source_doc.display_name()
2451
+
2452
+ # ✅ Use CURRENT VIEW NAME if this doc is what's active; else fall back to doc display_name()
2453
+ base = self._current_view_title_for_doc(source_doc) or source_doc.display_name()
2454
+
2286
2455
  dup_title = (new_name or f"{base}_duplicate")
2456
+
2287
2457
  # 🚫 strip any lingering emojis / link markers
2288
2458
  dup_title = dup_title.replace("🔗", "").strip()
2289
- meta["display_name"] = dup_title
2290
2459
 
2460
+ meta["display_name"] = dup_title
2461
+ if _DEBUG_DND_DUP:
2462
+ print(" dup_title computed:", dup_title)
2291
2463
  # Remove anything that makes the view look "linked/preview"
2292
2464
  imi = dict(meta.get("image_meta") or {})
2293
2465
  for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
@@ -2311,7 +2483,13 @@ class DocManager(QObject):
2311
2483
  # Mark this duplicate as sharing image data with source
2312
2484
  dup._cow_source = source_doc
2313
2485
  self._register_doc(dup)
2314
-
2486
+ if _DEBUG_DND_DUP:
2487
+ try:
2488
+ dn = dup.display_name() if hasattr(dup, "display_name") else None
2489
+ except Exception:
2490
+ dn = None
2491
+ print(" dup.metadata.display_name:", (dup.metadata or {}).get("display_name"))
2492
+ print(" dup.display_name():", dn)
2315
2493
  # DEBUG: log the duplicate doc WCS
2316
2494
  if _DEBUG_WCS:
2317
2495
  try:
@@ -2370,7 +2548,58 @@ class DocManager(QObject):
2370
2548
  def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
2371
2549
  return self.open_array(image, metadata=metadata, title=name)
2372
2550
 
2551
+ def _drop_all_roi_for_parent(self, parent_doc):
2552
+ dead = [k for k in list(self._roi_doc_cache.keys()) if k[0] == id(parent_doc)]
2553
+ for k in dead:
2554
+ roi_doc = self._roi_doc_cache.pop(k, None)
2555
+ if roi_doc is not None:
2556
+ try:
2557
+ roi_doc.close() # you’ll implement doc.close() to release arrays
2558
+ except Exception:
2559
+ pass
2560
+
2561
+ def _hard_memory_cleanup(self):
2562
+ # 1) Drop Qt pixmap cache (can hold big chunks)
2563
+ try:
2564
+ from PyQt6.QtGui import QPixmapCache
2565
+ QPixmapCache.clear()
2566
+ except Exception:
2567
+ pass
2568
+
2569
+ # 2) Let pending deleteLater() actually execute
2570
+ try:
2571
+ from PyQt6.QtWidgets import QApplication
2572
+ QApplication.processEvents()
2573
+ except Exception:
2574
+ pass
2575
+
2576
+ # 3) Force Python GC to collect cycles (common with Qt signal/closure cycles)
2577
+ try:
2578
+ import gc
2579
+ gc.collect()
2580
+ except Exception:
2581
+ pass
2582
+
2583
+ # 4) Optional: Linux heap trim (only helps on Linux/glibc)
2584
+ try:
2585
+ import sys
2586
+ if sys.platform.startswith("linux"):
2587
+ import ctypes
2588
+ libc = ctypes.CDLL("libc.so.6")
2589
+ libc.malloc_trim(0)
2590
+ except Exception:
2591
+ pass
2592
+
2373
2593
  def close_document(self, doc):
2594
+ # If ROI wrapper, close parent; if parent, purge ROI cache
2595
+ try:
2596
+ parent = getattr(doc, "_parent_doc", None)
2597
+ if parent is not None:
2598
+ doc = parent
2599
+ except Exception:
2600
+ pass
2601
+
2602
+ self._drop_all_roi_for_parent(doc)
2374
2603
  if doc in self._docs:
2375
2604
  self._docs.remove(doc)
2376
2605
  try:
@@ -2387,6 +2616,7 @@ class DocManager(QObject):
2387
2616
  print(f"[DocManager] Failed to close document {doc}: {e}")
2388
2617
 
2389
2618
  self.documentRemoved.emit(doc)
2619
+ self._hard_memory_cleanup()
2390
2620
 
2391
2621
  # --- Active-document helpers (NEW) ---------------------------------
2392
2622
  def all_documents(self):