setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.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" |
|
|
20
|
-
#
|
|
21
|
-
# "
|
|
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
|
-
#
|
|
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
|
-
|
|
145
|
-
{"
|
|
146
|
-
{"curves": {...}}
|
|
147
|
-
{"state": {...}}
|
|
222
|
+
Handles:
|
|
223
|
+
- {"preset": {...}}
|
|
224
|
+
- {"curves": {...}}
|
|
225
|
+
- {"state": {...}}
|
|
226
|
+
- multi presets with {"modes": {...}}
|
|
148
227
|
"""
|
|
149
228
|
p = dict(preset or {})
|
|
150
229
|
|
|
151
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
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,
|
|
196
|
-
"""
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
432
|
+
"preset": dict(core),
|
|
258
433
|
}
|
|
259
434
|
doc.apply_edit(out01, metadata=meta, step_name="Curves")
|
|
260
435
|
|
|
261
436
|
|
|
262
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
#
|
|
281
|
-
want = _norm_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
|
-
|
|
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
|
|
setiastro/saspro/doc_manager.py
CHANGED
|
@@ -310,18 +310,133 @@ class ImageDocument(QObject):
|
|
|
310
310
|
|
|
311
311
|
def close(self):
|
|
312
312
|
"""
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
sm
|
|
324
|
-
|
|
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):
|