setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0__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 (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +218 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +769 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +68 -0
  34. setiastro/saspro/ser_stacker.py +2245 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1242 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
@@ -12,18 +12,96 @@ except Exception:
12
12
  _HAS_PCHIP = False
13
13
 
14
14
 
15
-
16
15
  # ---------------------- preset schema ----------------------
16
+ # Legacy (v1 single-curve) preset:
17
17
  # {
18
+ # "name": "MyPreset",
18
19
  # "mode": "K (Brightness)" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation" | aliases ("rgb","k","lum"…),
19
- # "shape": "linear" | "s_mild" | "s_med" | "s_strong" | "lift_shadows" | "crush_shadows"
20
- # | "fade_blacks" | "rolloff_highlights" | "flatten" | "custom",
21
- # "amount": 0..1 (intensity, ignored for custom),
22
- # "points_norm": [[x,y], ...] # optional when shape="custom" (normalized 0..1 domain/range)
20
+ # "shape": "linear" | "s_mild" | ... | "custom",
21
+ # "amount": 0..1,
22
+ # "points_norm": [[x,y], ...] # normalized 0..1 domain/range
23
23
  # }
24
24
  #
25
- # Default if missing: mode="K (Brightness)", shape="linear", amount=0.5
25
+ # New (v2 multi-curve) preset:
26
+ # {
27
+ # "name": "MyPreset",
28
+ # "kind": "curves_multi",
29
+ # "version": 2,
30
+ # "active": "K" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation",
31
+ # "modes": {
32
+ # "K": [[x,y], ...],
33
+ # "R": [[x,y], ...],
34
+ # ...
35
+ # }
36
+ # }
37
+
38
+ _MODE_KEY_TO_LABEL = {
39
+ "K": "K (Brightness)",
40
+ "R": "R",
41
+ "G": "G",
42
+ "B": "B",
43
+ "L*": "L*",
44
+ "a*": "a*",
45
+ "b*": "b*",
46
+ "Chroma": "Chroma",
47
+ "Saturation": "Saturation",
48
+ }
26
49
 
50
+
51
+ _LABEL_TO_MODE_KEY = {v: k for k, v in _MODE_KEY_TO_LABEL.items()}
52
+
53
+ _APPLY_ORDER_KEYS = ["K", "R", "G", "B", "L*", "a*", "b*", "Chroma", "Saturation"]
54
+
55
+ def _is_linear_points_norm(pts) -> bool:
56
+ try:
57
+ return (
58
+ isinstance(pts, (list, tuple)) and len(pts) == 2
59
+ and tuple(pts[0]) == (0.0, 0.0)
60
+ and tuple(pts[1]) == (1.0, 1.0)
61
+ )
62
+ except Exception:
63
+ return False
64
+
65
+ def _coerce_points_norm(raw) -> List[Tuple[float, float]] | None:
66
+ if not isinstance(raw, (list, tuple)) or len(raw) < 2:
67
+ return None
68
+ out: List[Tuple[float, float]] = []
69
+ for e in raw:
70
+ if isinstance(e, (list, tuple)) and len(e) >= 2:
71
+ x, y = e[0], e[1]
72
+ elif isinstance(e, dict):
73
+ x, y = e.get("x"), e.get("y")
74
+ else:
75
+ continue
76
+ if x is None or y is None:
77
+ continue
78
+ out.append((float(x), float(y)))
79
+ return out if len(out) >= 2 else None
80
+
81
+ def _coerce_modes_dict(raw) -> Dict[str, List[Tuple[float, float]]]:
82
+ """
83
+ Return {mode_key: points_norm}, filtering junk but keeping backward-compat.
84
+ Accepts keys as internal mode keys ("K") or labels ("K (Brightness)").
85
+ """
86
+ modes: Dict[str, List[Tuple[float, float]]] = {}
87
+ if not isinstance(raw, dict):
88
+ return modes
89
+
90
+ for k, v in raw.items():
91
+ key = str(k)
92
+ # allow labels or keys
93
+ if key in _MODE_KEY_TO_LABEL:
94
+ mode_key = key
95
+ else:
96
+ mode_label = _norm_mode(key) # converts aliases -> proper label
97
+ mode_key = _LABEL_TO_MODE_KEY.get(mode_label, "K")
98
+
99
+ pts = _coerce_points_norm(v)
100
+ if pts is None:
101
+ continue
102
+ modes[mode_key] = pts
103
+
104
+ return modes
27
105
  # ---------------------- shape library (normalized) ----------------------
28
106
  def _shape_points_norm(shape: str, amount: float) -> List[Tuple[float, float]]:
29
107
  a = float(max(0.0, min(1.0, amount)))
@@ -141,32 +219,38 @@ def _unwrap_preset_dict(preset: Dict) -> Dict:
141
219
  """
142
220
  Accept a variety of containers and peel down to the actual curve data.
143
221
 
144
- Examples we handle:
145
- {"step_name":"Curves","mode":..., "preset":{...}}
146
- {"curves": {...}}
147
- {"state": {...}} # if state contains curve points
222
+ Handles:
223
+ - {"preset": {...}}
224
+ - {"curves": {...}}
225
+ - {"state": {...}}
226
+ - multi presets with {"modes": {...}}
148
227
  """
149
228
  p = dict(preset or {})
150
229
 
151
- # Case 1: full metadata from doc.apply_edit: {"step_name":"Curves", "mode":..., "preset": {...}}
230
+ def _looks_like_curve_dict(d: dict) -> bool:
231
+ return (
232
+ isinstance(d, dict)
233
+ and (
234
+ "points_norm" in d
235
+ or "handles" in d
236
+ or "points_scene" in d
237
+ or "scene_points" in d
238
+ or "modes" in d # <-- multi
239
+ )
240
+ )
241
+
152
242
  inner = p.get("preset")
153
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
154
- or "points_scene" in inner or "scene_points" in inner):
243
+ if _looks_like_curve_dict(inner):
155
244
  return inner
156
245
 
157
- # Case 2: payloads like {"curves": {...}}
158
246
  inner = p.get("curves")
159
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
160
- or "points_scene" in inner or "scene_points" in inner):
247
+ if _looks_like_curve_dict(inner):
161
248
  return inner
162
249
 
163
- # Case 3: {"state": {...}} (if you stored the curve state under that key)
164
250
  inner = p.get("state")
165
- if isinstance(inner, dict) and ("points_norm" in inner or "handles" in inner
166
- or "points_scene" in inner or "scene_points" in inner):
251
+ if _looks_like_curve_dict(inner):
167
252
  return inner
168
253
 
169
- # Otherwise assume p already *is* the preset
170
254
  return p
171
255
 
172
256
 
@@ -179,7 +263,9 @@ def _settings() -> QSettings | None:
179
263
  return None
180
264
 
181
265
  def list_custom_presets() -> list[dict]:
182
- """Return a list of dicts: {"name", "mode", "shape":"custom", "points_norm":[[x,y],...]}"""
266
+ """
267
+ Returns a list of preset dicts (v1 single-curve or v2 multi-curve).
268
+ """
183
269
  s = _settings()
184
270
  if not s:
185
271
  return []
@@ -187,29 +273,82 @@ def list_custom_presets() -> list[dict]:
187
273
  try:
188
274
  lst = json.loads(raw)
189
275
  if isinstance(lst, list):
190
- return [p for p in lst if isinstance(p, dict)]
276
+ out = [p for p in lst if isinstance(p, dict)]
277
+ # normalize missing name fields (defensive)
278
+ for p in out:
279
+ if "name" not in p:
280
+ p["name"] = "(unnamed)"
281
+ return out
191
282
  except Exception:
192
283
  pass
193
284
  return []
194
285
 
195
- def save_custom_preset(name: str, mode: str, points_norm: list[tuple[float,float]]) -> bool:
196
- """Create/overwrite by name."""
286
+ def save_custom_preset(name: str, mode_or_preset, points_norm: list[tuple[float, float]] | None = None) -> bool:
287
+ """
288
+ Save a custom preset. Supports:
289
+ - legacy: save_custom_preset(name, mode, points_norm)
290
+ - new: save_custom_preset(name, preset_dict)
291
+ """
197
292
  s = _settings()
198
293
  if not s:
199
294
  return False
295
+
200
296
  name = (name or "").strip()
201
297
  if not name:
202
298
  return False
203
- preset = {
204
- "name": name,
205
- "mode": _norm_mode(mode),
206
- "shape": "custom",
207
- "amount": 1.0,
208
- "points_norm": [(float(x), float(y)) for (x, y) in points_norm],
209
- }
299
+
300
+ # --- build preset dict ---
301
+ preset: dict
302
+
303
+ if isinstance(mode_or_preset, dict):
304
+ # v2 (or v1 dict passed directly)
305
+ preset = dict(mode_or_preset)
306
+ preset["name"] = name # enforce
307
+
308
+ # If it's a multi preset, sanitize modes
309
+ if preset.get("kind") == "curves_multi" or isinstance(preset.get("modes"), dict):
310
+ modes = _coerce_modes_dict(preset.get("modes", {}))
311
+ # fill missing keys with linear so UI always has a full set
312
+ full_modes = {}
313
+ for k in _MODE_KEY_TO_LABEL.keys():
314
+ full_modes[k] = modes.get(k, [(0.0, 0.0), (1.0, 1.0)])
315
+ preset = {
316
+ "name": name,
317
+ "kind": "curves_multi",
318
+ "version": 2,
319
+ "active": str(preset.get("active") or "K"),
320
+ "modes": {k: [(float(x), float(y)) for (x, y) in v] for k, v in full_modes.items()},
321
+ }
322
+
323
+ else:
324
+ # treat as v1 single-curve dict
325
+ mode_label = _norm_mode(preset.get("mode"))
326
+ pts = _coerce_points_norm(preset.get("points_norm")) or [(0.0, 0.0), (1.0, 1.0)]
327
+ preset = {
328
+ "name": name,
329
+ "mode": mode_label,
330
+ "shape": str(preset.get("shape", "custom")),
331
+ "amount": float(preset.get("amount", 1.0)),
332
+ "points_norm": [(float(x), float(y)) for (x, y) in pts],
333
+ }
334
+
335
+ else:
336
+ # legacy signature
337
+ mode = _norm_mode(str(mode_or_preset))
338
+ pts = points_norm or [(0.0, 0.0), (1.0, 1.0)]
339
+ preset = {
340
+ "name": name,
341
+ "mode": mode,
342
+ "shape": "custom",
343
+ "amount": 1.0,
344
+ "points_norm": [(float(x), float(y)) for (x, y) in pts],
345
+ }
346
+
347
+ # --- upsert by name (case-insensitive) ---
210
348
  lst = list_custom_presets()
211
- lst = [p for p in lst if (p.get("name","").lower() != name.lower())]
349
+ lst = [p for p in lst if (p.get("name", "").lower() != name.lower())]
212
350
  lst.append(preset)
351
+
213
352
  s.setValue(_SETTINGS_KEY, json.dumps(lst))
214
353
  s.sync()
215
354
  return True
@@ -218,8 +357,11 @@ def delete_custom_preset(name: str) -> bool:
218
357
  s = _settings()
219
358
  if not s:
220
359
  return False
360
+ nm = (name or "").strip().lower()
361
+ if not nm:
362
+ return False
221
363
  lst = list_custom_presets()
222
- lst = [p for p in lst if (p.get("name","").lower() != (name or "").strip().lower())]
364
+ lst = [p for p in lst if (p.get("name", "").strip().lower() != nm)]
223
365
  s.setValue(_SETTINGS_KEY, json.dumps(lst))
224
366
  s.sync()
225
367
  return True
@@ -228,7 +370,7 @@ def delete_custom_preset(name: str) -> bool:
228
370
  # ---------------------- headless apply ----------------------
229
371
  def apply_curves_via_preset(main_window, doc, preset: Dict):
230
372
  import numpy as _np
231
- from setiastro.saspro.curves_preset import _lut_from_preset, _unwrap_preset_dict # self
373
+ from setiastro.saspro.curves_preset import _unwrap_preset_dict # self
232
374
  # lazy import to avoid cycle
233
375
  from setiastro.saspro.curve_editor_pro import _apply_mode_any
234
376
 
@@ -236,8 +378,7 @@ def apply_curves_via_preset(main_window, doc, preset: Dict):
236
378
  if img is None:
237
379
  return
238
380
 
239
- # Accept full last-action dicts and unwrap down to the actual curve definition
240
- core_preset = _unwrap_preset_dict(preset or {})
381
+ core = _unwrap_preset_dict(preset or {})
241
382
 
242
383
  arr = _np.asarray(img)
243
384
  if arr.dtype.kind in "ui":
@@ -248,18 +389,52 @@ def apply_curves_via_preset(main_window, doc, preset: Dict):
248
389
  else:
249
390
  arr01 = arr.astype(_np.float32)
250
391
 
251
- lut01, mode = _lut_from_preset(core_preset)
392
+ # -------- MULTI --------
393
+ if core.get("kind") == "curves_multi" or isinstance(core.get("modes"), dict):
394
+ modes = _coerce_modes_dict(core.get("modes", {}))
395
+
396
+ out01 = arr01
397
+ used_any = False
398
+
399
+ for mode_key in _APPLY_ORDER_KEYS:
400
+ pts = modes.get(mode_key)
401
+ if not pts:
402
+ continue
403
+ if _is_linear_points_norm(pts):
404
+ continue
405
+
406
+ pts_scene = _points_norm_to_scene(pts)
407
+ fn = _interpolator_from_scene_points(pts_scene)
408
+ lut01 = build_curve_lut(fn, size=65536)
409
+
410
+ mode_label = _MODE_KEY_TO_LABEL.get(mode_key, "K (Brightness)")
411
+ out01 = _apply_mode_any(out01, mode_label, lut01)
412
+ used_any = True
413
+
414
+ if not used_any:
415
+ return
416
+
417
+ meta = {
418
+ "step_name": "Curves",
419
+ "mode": _MODE_KEY_TO_LABEL.get(str(core.get("active") or "K"), "K (Brightness)"),
420
+ "preset": dict(core),
421
+ }
422
+ doc.apply_edit(out01, metadata=meta, step_name="Curves")
423
+ return
424
+
425
+ # -------- LEGACY SINGLE --------
426
+ lut01, mode = _lut_from_preset(core)
252
427
  out01 = _apply_mode_any(arr01, mode, lut01)
253
428
 
254
429
  meta = {
255
430
  "step_name": "Curves",
256
431
  "mode": mode,
257
- "preset": dict(core_preset), # store the normalized core preset
432
+ "preset": dict(core),
258
433
  }
259
434
  doc.apply_edit(out01, metadata=meta, step_name="Curves")
260
435
 
261
436
 
262
- # ---------------------- open UI with preset ----------------------
437
+
263
438
  # ---------------------- open UI with preset ----------------------
264
439
  def open_curves_with_preset(main_window, preset: Dict | None = None):
265
440
  # lazy import UI to avoid cycle
@@ -274,20 +449,47 @@ def open_curves_with_preset(main_window, preset: Dict | None = None):
274
449
 
275
450
  dlg = CurvesDialogPro(main_window, doc)
276
451
 
277
- # Peel down any wrapper (metadata / last-action container) to the actual curve definition
278
- core_preset = _unwrap_preset_dict(preset or {})
452
+ core = _unwrap_preset_dict(preset or {})
453
+
454
+ # -------- MULTI --------
455
+ if core.get("kind") == "curves_multi" or isinstance(core.get("modes"), dict):
456
+ modes = _coerce_modes_dict(core.get("modes", {}))
457
+
458
+ # Fill dialog store for every key
459
+ for k in getattr(dlg, "_curves_store", {}).keys():
460
+ dlg._curves_store[k] = modes.get(k, [(0.0, 0.0), (1.0, 1.0)])
461
+
462
+ # Choose active key
463
+ active_key = str(core.get("active") or "K")
464
+ if active_key not in dlg._curves_store:
465
+ active_key = "K"
466
+ dlg._current_mode_key = active_key
467
+
468
+ # Set the radio to match active
469
+ want_label = _MODE_KEY_TO_LABEL.get(active_key, "K (Brightness)")
470
+ for b in dlg.mode_group.buttons():
471
+ if b.text().lower() == want_label.lower():
472
+ b.setChecked(True)
473
+ break
474
+
475
+ # Push active curve into editor
476
+ dlg._editor_set_from_norm(dlg._curves_store.get(active_key, [(0.0, 0.0), (1.0, 1.0)]))
477
+ dlg._refresh_overlays()
478
+ dlg._quick_preview()
479
+
480
+ dlg.show()
481
+ dlg.raise_()
482
+ dlg.activateWindow()
483
+ return
279
484
 
280
- # set mode radio from the *core* preset
281
- want = _norm_mode(core_preset.get("mode"))
485
+ # -------- LEGACY SINGLE --------
486
+ want = _norm_mode(core.get("mode"))
282
487
  for b in dlg.mode_group.buttons():
283
488
  if b.text().lower() == want.lower():
284
489
  b.setChecked(True)
285
490
  break
286
491
 
287
- # Seed control handles from the same logic used by LUT building
288
- pts_scene = _scene_points_from_preset(core_preset)
289
-
290
- # remove exact endpoints if present; editor expects control handles only
492
+ pts_scene = _scene_points_from_preset(core)
291
493
  filt = [(x, y) for (x, y) in pts_scene if x > 0.0 + 1e-6 and x < 360.0 - 1e-6]
292
494
  dlg.editor.setControlHandles(filt)
293
495
 
@@ -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)
@@ -2433,7 +2548,58 @@ class DocManager(QObject):
2433
2548
  def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
2434
2549
  return self.open_array(image, metadata=metadata, title=name)
2435
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
+
2436
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)
2437
2603
  if doc in self._docs:
2438
2604
  self._docs.remove(doc)
2439
2605
  try:
@@ -2450,6 +2616,7 @@ class DocManager(QObject):
2450
2616
  print(f"[DocManager] Failed to close document {doc}: {e}")
2451
2617
 
2452
2618
  self.documentRemoved.emit(doc)
2619
+ self._hard_memory_cleanup()
2453
2620
 
2454
2621
  # --- Active-document helpers (NEW) ---------------------------------
2455
2622
  def all_documents(self):