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
@@ -168,7 +168,7 @@ except Exception:
168
168
 
169
169
 
170
170
 
171
-
171
+ _DEBUG_DND_DUP = False
172
172
 
173
173
 
174
174
 
@@ -194,7 +194,7 @@ from setiastro.saspro.resources import (
194
194
  satellite_path, imagecombine_path, wrench_path, eye_icon_path,multiscale_decomp_path,
195
195
  disk_icon_path, nuke_path, hubble_path, collage_path, annotated_path,
196
196
  colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path,
197
- wimi_path, linearfit_path, debayer_path, aberration_path,
197
+ wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
198
  functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path,
199
199
  background_path, script_icon_path
200
200
  )
@@ -285,6 +285,144 @@ from setiastro.saspro.gui.mixins import (
285
285
  ThemeMixin, GeometryMixin, ViewMixin, HeaderMixin, MaskMixin, UpdateMixin
286
286
  )
287
287
 
288
+ import sys
289
+ import time
290
+ import threading
291
+ import traceback
292
+ from PyQt6.QtCore import QObject, QTimer
293
+
294
+ class UiStallDetector(QObject):
295
+ """
296
+ Detects UI stalls by watching QTimer tick drift.
297
+ On stall, prints stack traces for all threads using print().
298
+ (No faulthandler / no fileno() required.)
299
+ """
300
+
301
+ def __init__(self, parent=None, interval_ms: int = 50, threshold_ms: int = 300):
302
+ super().__init__(parent)
303
+ self.interval_ms = int(interval_ms)
304
+ self.threshold_ms = int(threshold_ms)
305
+ self._last = time.perf_counter()
306
+ self._stall_seq = 0
307
+
308
+ # cooldown state (instance-level)
309
+ self._last_dump_t = 0.0
310
+
311
+ self._timer = QTimer(self)
312
+ self._timer.setInterval(self.interval_ms)
313
+ self._timer.timeout.connect(self._tick)
314
+
315
+ def start(self):
316
+ self._last = time.perf_counter()
317
+ self._timer.start()
318
+
319
+ def stop(self):
320
+ self._timer.stop()
321
+
322
+ def _dump_all_threads_print(self):
323
+ now = time.perf_counter()
324
+ if now - self._last_dump_t < 2.0: # 2s cooldown
325
+ print("[UI STALL] dump skipped (cooldown)", flush=True)
326
+ return
327
+ self._last_dump_t = now
328
+
329
+ frames = sys._current_frames()
330
+ main_ident = threading.main_thread().ident
331
+
332
+ print("[UI STALL] ===== lightweight dump (all threads) =====", flush=True)
333
+
334
+ # Main thread: full stack
335
+ if main_ident in frames:
336
+ print("\n--- MainThread (full) ---", flush=True)
337
+ print("".join(traceback.format_stack(frames[main_ident])), flush=True)
338
+
339
+ # Other threads: only top-frame summary
340
+ for t in threading.enumerate():
341
+ if t.ident is None or t.ident == main_ident:
342
+ continue
343
+ f = frames.get(t.ident)
344
+ if not f:
345
+ continue
346
+ code = f.f_code
347
+ print(
348
+ f"--- Thread {t.ident} ({t.name}) top --- {code.co_filename}:{f.f_lineno} in {code.co_name}",
349
+ flush=True,
350
+ )
351
+
352
+ print("[UI STALL] ===== end lightweight dump =====", flush=True)
353
+
354
+ def _tick(self):
355
+ now = time.perf_counter()
356
+ elapsed_ms = (now - self._last) * 1000.0
357
+ self._last = now
358
+
359
+ late_ms = elapsed_ms - self.interval_ms
360
+ if late_ms >= self.threshold_ms:
361
+ print(f"[UI STALL] tick late by {late_ms:.0f} ms (elapsed={elapsed_ms:.0f} ms)", flush=True)
362
+ self._dump_all_threads_print()
363
+
364
+ def _strip_filename_ext(title: str) -> str:
365
+ t = (title or "").strip()
366
+ if not t:
367
+ return t
368
+ base, ext = os.path.splitext(t)
369
+ # treat as extension only if it looks like one: .fit .fits .tif .tiff .xisf etc
370
+ if ext and 1 <= len(ext) <= 10 and all(ch.isalnum() for ch in ext[1:]):
371
+ return base
372
+ return t
373
+
374
+
375
+
376
+ _DECOR_GLYPHS = "■●◆▲▪▫•◼◻◾◽🔗"
377
+
378
+ def normalize_doc_title(s: str) -> str:
379
+ s = (s or "").strip()
380
+
381
+ # remove our textual prefix too
382
+ if s.startswith("[LINK] "):
383
+ s = s[len("[LINK] "):].strip()
384
+
385
+ # strip common UI decorations if you already have this helper
386
+ try:
387
+ s = _strip_ui_decorations(s)
388
+ except Exception:
389
+ pass
390
+
391
+ # remove any leading decorator glyphs repeatedly: "🔗 ", "■ ", etc.
392
+ while len(s) >= 2 and s[0] in _DECOR_GLYPHS and s[1] == " ":
393
+ s = s[2:].lstrip()
394
+
395
+ # also remove any stray decorator glyphs that got embedded (rare but happens)
396
+ s = re.sub(rf"[{re.escape(_DECOR_GLYPHS)}]", "", s).strip()
397
+
398
+ return s
399
+
400
+ _VIEW_SUFFIX_RE = re.compile(r"\s+\[View\s+\d+\]\s*$")
401
+
402
+ def _normalize_title_for_compare(t: str) -> str:
403
+ t = (t or "").strip()
404
+ if not t:
405
+ return ""
406
+
407
+ # strip UI decorations (🔗, ■, etc)
408
+ try:
409
+ t = _strip_ui_decorations(t)
410
+ except Exception:
411
+ pass
412
+
413
+ # strip trailing "[View N]" if present
414
+ t = _VIEW_SUFFIX_RE.sub("", t).strip()
415
+
416
+ # strip filename-like extension
417
+ try:
418
+ t = _strip_filename_ext(t)
419
+ except Exception:
420
+ # fallback: only strip if it looks like an ext
421
+ base, ext = os.path.splitext(t)
422
+ if ext and len(ext) <= 10:
423
+ t = base
424
+
425
+ return t.strip()
288
426
 
289
427
  class AstroSuiteProMainWindow(
290
428
  DockMixin, MenuMixin, ToolbarMixin, FileMixin,
@@ -299,7 +437,8 @@ class AstroSuiteProMainWindow(
299
437
  # Prevent white flash: start strictly transparent and force dark bg
300
438
  self.setWindowOpacity(0.0)
301
439
  self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
302
-
440
+ #self._stall = UiStallDetector(self, interval_ms=50, threshold_ms=250)
441
+ #self._stall.start()
303
442
  # --- Usage Stats ---
304
443
  self._session_start_time = time.time()
305
444
  self._stats_timer = QTimer(self)
@@ -856,7 +995,18 @@ class AstroSuiteProMainWindow(
856
995
  dm = self.doc_manager
857
996
  doc = None
858
997
 
998
+ if _DEBUG_DND_DUP:
999
+ import json
1000
+ print("\n[DNDDBG:DROP_ENTER] raw st dict:")
1001
+ try:
1002
+ # st is already a dict here
1003
+ for k in sorted(st.keys()):
1004
+ print(f" {k}: {st.get(k)!r}")
1005
+ except Exception as e:
1006
+ print("[DNDDBG:DROP_ENTER] failed printing st:", e)
859
1007
 
1008
+ # sanity: show which fields are present
1009
+ print("[DNDDBG:DROP_ENTER] has source_view_title?", "source_view_title" in st)
860
1010
 
861
1011
  # Prefer *stable* identifiers over the proxy pointer
862
1012
  uid = st.get("doc_uid")
@@ -959,7 +1109,21 @@ class AstroSuiteProMainWindow(
959
1109
  print("[VIEWSTATE_DROP] EXIT (no doc)")
960
1110
  return
961
1111
 
962
-
1112
+ if _DEBUG_DND_DUP:
1113
+ try:
1114
+ dname = doc.display_name() if hasattr(doc, "display_name") else None
1115
+ except Exception:
1116
+ dname = None
1117
+ try:
1118
+ meta = getattr(doc, "metadata", {}) or {}
1119
+ except Exception:
1120
+ meta = {}
1121
+ print("\n[DNDDBG:DOC_RESOLVED]")
1122
+ print(" doc_obj:", doc, "type:", type(doc).__name__, "id:", id(doc))
1123
+ print(" doc.uid:", getattr(doc, "uid", None))
1124
+ print(" doc.display_name():", dname)
1125
+ print(" meta.display_name:", meta.get("display_name"))
1126
+ print(" meta.file_path:", meta.get("file_path"))
963
1127
 
964
1128
  # ----------------------------------------
965
1129
  # 4) Peek at metadata to see if this is a
@@ -1050,34 +1214,35 @@ class AstroSuiteProMainWindow(
1050
1214
  # copy the view transform.
1051
1215
  # ----------------------------------------
1052
1216
  if force_new:
1053
- # We're here only if:
1054
- # - it's NOT a preview (normal full or promoted ROI), or
1055
- # - ROI promotion didn't apply and we fell through.
1056
1217
  base_doc = doc
1057
1218
 
1058
- # 1) Duplicate the underlying document
1059
- try:
1060
- base_name = ""
1219
+ # 1) Prefer the dragged view's title
1220
+ base_name = (st.get("source_view_title") or "").strip()
1221
+
1222
+ # 2) Fallback to document display name
1223
+ if not base_name:
1061
1224
  try:
1062
1225
  base_name = base_doc.display_name() or "Untitled"
1063
1226
  except Exception:
1064
1227
  base_name = "Untitled"
1065
1228
 
1066
- try:
1067
- base_name = _strip_ui_decorations(base_name)
1068
- except Exception:
1069
- # minimal fallback: remove known glyph prefixes and "Active View: "
1070
- while len(base_name) >= 2 and base_name[1] == " " and base_name[0] in "â- â--â--†â-²â-ªâ-«â€¢â--¼â--»â--¾â--½":
1071
- base_name = base_name[2:]
1072
- if base_name.startswith("Active View: "):
1073
- base_name = base_name[len("Active View: "):]
1074
-
1075
- new_doc = self.docman.duplicate_document(
1076
- base_doc, new_name=f"{base_name}_duplicate"
1077
- )
1078
- except Exception as e:
1079
- print("[Main] viewstate_drop: duplicate_document failed, falling back to original doc:", e)
1080
- new_doc = base_doc # worst-case: still just reuse
1229
+ # 3) Clean it (strip glyphs / "Active View" / etc.)
1230
+ try:
1231
+ base_name = _strip_ui_decorations(base_name)
1232
+ except Exception:
1233
+ if base_name.startswith("Active View: "):
1234
+ base_name = base_name[len("Active View: "):]
1235
+
1236
+ if _DEBUG_DND_DUP:
1237
+ print("\n[DNDDBG:NAME_COMPUTE]")
1238
+ print(" st.source_view_title:", (st.get("source_view_title") or "").strip())
1239
+ print(" base_doc.display_name():", (base_doc.display_name() if hasattr(base_doc,"display_name") else None))
1240
+ print(" base_name(after fallbacks/strip):", base_name)
1241
+ print(" new_name passed:", f"{base_name}_duplicate")
1242
+
1243
+ new_doc = self.docman.duplicate_document(
1244
+ base_doc, new_name=f"{base_name}_duplicate"
1245
+ )
1081
1246
 
1082
1247
  # 2) Let doc_manager's documentAdded handler create the subwindow.
1083
1248
  # We just wait for it to show up and then apply the view state.
@@ -1203,14 +1368,6 @@ class AstroSuiteProMainWindow(
1203
1368
  return False
1204
1369
 
1205
1370
  def _on_document_added(self, doc):
1206
- # Helpful debug:
1207
- try:
1208
- is_table = (getattr(doc, "metadata", {}).get("doc_type") == "table") or \
1209
- (hasattr(doc, "rows") and hasattr(doc, "headers"))
1210
- self._log(f"[documentAdded] {type(doc).__name__} table={is_table} name={getattr(doc, 'display_name', lambda:'?')()}")
1211
- except Exception:
1212
- pass
1213
-
1214
1371
  self._spawn_subwindow_for(doc)
1215
1372
 
1216
1373
  # --- UI scaffolding ---
@@ -2503,10 +2660,27 @@ class AstroSuiteProMainWindow(
2503
2660
  return f"{name}{dims}"
2504
2661
 
2505
2662
  def _update_explorer_item_for_doc(self, doc):
2506
- for i in range(self.explorer.count()):
2507
- it = self.explorer.item(i)
2508
- if it.data(Qt.ItemDataRole.UserRole) is doc:
2509
- it.setText(self._format_explorer_title(doc))
2663
+ # Delegate to DockMixin implementation if present
2664
+ try:
2665
+ return super()._update_explorer_item_for_doc(doc)
2666
+ except Exception:
2667
+ pass
2668
+
2669
+ # Fallback: tree-safe implementation
2670
+ if not hasattr(self, "explorer") or self.explorer is None:
2671
+ return
2672
+ try:
2673
+ n = self.explorer.topLevelItemCount()
2674
+ except Exception:
2675
+ return
2676
+
2677
+ for i in range(n):
2678
+ it = self.explorer.topLevelItem(i)
2679
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
2680
+ try:
2681
+ self._refresh_explorer_row(it, doc)
2682
+ except Exception:
2683
+ pass
2510
2684
  return
2511
2685
  #-----------FUNCTIONS----------------
2512
2686
 
@@ -3015,17 +3189,17 @@ class AstroSuiteProMainWindow(
3015
3189
 
3016
3190
  self.convo_window.show()
3017
3191
 
3018
-
3019
-
3020
3192
  def _apply_extract_luminance_preset_to_doc(self, doc, preset=None):
3021
-
3022
3193
  from PyQt6.QtWidgets import QMessageBox
3023
- from setiastro.saspro.luminancerecombine import compute_luminance, _LUMA_REC709, _LUMA_REC601, _LUMA_REC2020
3024
- from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
3194
+ from setiastro.saspro.luminancerecombine import (
3195
+ compute_luminance,
3196
+ resolve_luma_profile_weights,
3197
+ )
3198
+ from setiastro.saspro.headless_utils import unwrap_docproxy
3199
+ import numpy as np
3025
3200
 
3026
3201
  doc = unwrap_docproxy(doc)
3027
3202
  p = dict(preset or {})
3028
- mode = (p.get("mode") or "rec709").lower()
3029
3203
 
3030
3204
  if doc is None or getattr(doc, "image", None) is None:
3031
3205
  QMessageBox.information(self, "Extract Luminance", "No target image.")
@@ -3033,52 +3207,43 @@ class AstroSuiteProMainWindow(
3033
3207
 
3034
3208
  img = np.asarray(doc.image)
3035
3209
 
3036
- # pick weights
3037
- if mode == "rec601":
3038
- w = _LUMA_REC601
3039
- elif mode == "rec2020":
3040
- w = _LUMA_REC2020
3041
- elif mode == "max":
3042
- w = None
3043
- else:
3044
- w = _LUMA_REC709
3210
+ mode = str(p.get("mode", "rec709")).strip()
3211
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3045
3212
 
3046
- L = compute_luminance(img, method=mode, weights=w)
3213
+ L = compute_luminance(img, method=resolved_method, weights=w)
3047
3214
 
3048
3215
  dm = getattr(self, "doc_manager", None)
3049
3216
  if dm is None:
3050
- # headless fallback: just overwrite active doc
3051
3217
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3052
3218
  return
3053
3219
 
3054
- # normal behavior: create a new mono document
3220
+ meta = {
3221
+ "step_name": "Extract Luminance",
3222
+ "luma_method": resolved_method,
3223
+ }
3224
+ if w is not None:
3225
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3226
+ if profile_name:
3227
+ meta["luma_profile"] = str(profile_name)
3228
+
3055
3229
  try:
3230
+ suffix = f"{profile_name}" if profile_name else resolved_method
3056
3231
  new_doc = dm.create_document_from_array(
3057
3232
  L.astype(np.float32),
3058
- name=f"{doc.display_name()} -- Luminance ({mode})",
3233
+ name=f"{doc.display_name()} -- Luminance ({suffix})",
3059
3234
  is_mono=True,
3060
- metadata={"step_name":"Extract Luminance", "luma_method":mode}
3235
+ metadata=meta,
3061
3236
  )
3062
3237
  dm.add_document(new_doc)
3063
3238
  except Exception:
3064
- # safe fallback
3065
3239
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3066
3240
 
3067
-
3068
3241
  def _extract_luminance(self, doc=None, preset: dict | None = None):
3069
- from setiastro.saspro.luminancerecombine import _LUMA_REC709, _LUMA_REC601, _LUMA_REC2020
3070
- """
3071
- If doc is None, uses the active subwindow's document.
3072
- Otherwise, run on the provided doc (for drag-and-drop to a specific view).
3073
- Creates a new mono document (float32, [0..1]) and spawns a subwindow.
3074
-
3075
- Preset schema:
3076
- {
3077
- "mode": "rec709" | "rec601" | "rec2020" | "max" | "snr" | "equal" | "median",
3078
- # aliases accepted: method, luma_method, nb_max -> "max", snr_unequal -> "snr"
3079
- }
3080
- """
3081
- # 1) resolve source document
3242
+ from PyQt6.QtWidgets import QMessageBox
3243
+ from PyQt6.QtGui import QIcon
3244
+ from setiastro.saspro.luminancerecombine import compute_luminance, resolve_luma_profile_weights
3245
+
3246
+
3082
3247
  sw = None
3083
3248
  if doc is None:
3084
3249
  sw = self.mdi.activeSubWindow()
@@ -3097,70 +3262,19 @@ class AstroSuiteProMainWindow(
3097
3262
  QMessageBox.information(self, "Extract Luminance", "Luminance extraction requires an RGB image.")
3098
3263
  return
3099
3264
 
3100
- # 2) normalize to [0,1] float32
3101
- a = img.astype(np.float32, copy=False)
3102
- if a.size:
3103
- m = float(np.nanmax(a))
3104
- if np.isfinite(m) and m > 1.0:
3105
- a = a / m
3106
- a = np.clip(a, 0.0, 1.0)
3107
-
3108
- # 3) choose luminance method
3109
3265
  p = dict(preset or {})
3110
- method = str(
3266
+ mode = str(
3111
3267
  p.get("mode",
3112
3268
  p.get("method",
3113
3269
  p.get("luma_method",
3114
3270
  getattr(self, "luma_method", "rec709"))))
3115
- ).strip().lower()
3116
-
3117
- # aliases
3118
- alias = {
3119
- "rec.709": "rec709",
3120
- "rec-709": "rec709",
3121
- "rgb": "rec709",
3122
- "k": "rec709",
3123
- "rec.601": "rec601",
3124
- "rec-601": "rec601",
3125
- "rec.2020": "rec2020",
3126
- "rec-2020": "rec2020",
3127
- "nb_max": "max",
3128
- "narrowband": "max",
3129
- "snr_unequal": "snr",
3130
- "unequal_noise": "snr",
3131
- }
3132
- method = alias.get(method, method)
3133
-
3134
- # 4) compute luminance per selected method
3135
- luma_weights = None
3136
- if method == "rec601":
3137
- luma_weights = _LUMA_REC601
3138
- y = np.tensordot(a, _LUMA_REC601, axes=([2],[0]))
3139
- elif method == "rec2020":
3140
- luma_weights = _LUMA_REC2020
3141
- y = np.tensordot(a, _LUMA_REC2020, axes=([2],[0]))
3142
- elif method == "max":
3143
- y = a.max(axis=2)
3144
- elif method == "median":
3145
- y = np.median(a, axis=2)
3146
- elif method == "equal":
3147
- luma_weights = np.array([1/3, 1/3, 1/3], dtype=np.float32)
3148
- y = a.mean(axis=2)
3149
- elif method == "snr":
3150
- from setiastro.saspro.luminancerecombine import _estimate_noise_sigma_per_channel
3151
- sigma = _estimate_noise_sigma_per_channel(a)
3152
- w = 1.0 / (sigma[:3]**2 + 1e-12)
3153
- w = w / w.sum()
3154
- luma_weights = w.astype(np.float32)
3155
- y = np.tensordot(a[..., :3], luma_weights, axes=([2],[0]))
3156
- else: # "rec709" default
3157
- method = "rec709"
3158
- luma_weights = _LUMA_REC709
3159
- y = np.tensordot(a, _LUMA_REC709, axes=([2],[0]))
3160
-
3161
- y = np.clip(y.astype(np.float32, copy=False), 0.0, 1.0)
3162
-
3163
- # 5) metadata & title
3271
+ ).strip()
3272
+
3273
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3274
+
3275
+ y = compute_luminance(img, method=resolved_method, weights=w)
3276
+
3277
+ # ---- metadata & title ----
3164
3278
  base_meta = {}
3165
3279
  try:
3166
3280
  base_meta = dict(getattr(doc, "metadata", {}) or {})
@@ -3172,13 +3286,16 @@ class AstroSuiteProMainWindow(
3172
3286
  "source": "ExtractLuminance",
3173
3287
  "is_mono": True,
3174
3288
  "bit_depth": "32f",
3175
- "luma_method": method,
3289
+ "luma_method": resolved_method,
3176
3290
  }
3177
- if luma_weights is not None:
3178
- meta["luma_weights"] = np.asarray(luma_weights, dtype=np.float32).tolist()
3291
+ if w is not None:
3292
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3293
+ if profile_name:
3294
+ meta["luma_profile"] = str(profile_name)
3179
3295
 
3180
3296
  base_title = sw.windowTitle() if sw else (getattr(doc, "title", getattr(doc, "name", "")) or "Untitled")
3181
- title = f"{base_title} -- Luminance"
3297
+ suffix = f"{profile_name}" if profile_name else resolved_method
3298
+ title = f"{base_title} -- Luminance ({suffix})"
3182
3299
 
3183
3300
  dm = getattr(self, "docman", None)
3184
3301
  if dm is None:
@@ -3206,19 +3323,19 @@ class AstroSuiteProMainWindow(
3206
3323
  except Exception:
3207
3324
  pass
3208
3325
 
3209
- # ðŸ" Remember for Replay (optional but consistent)
3210
3326
  try:
3211
3327
  remember = getattr(self, "remember_last_headless_command", None) or getattr(self, "_remember_last_headless_command", None)
3212
3328
  if callable(remember):
3213
- remember("extract_luminance", {"mode": method}, description="Extract Luminance")
3329
+ remember("extract_luminance", {"mode": mode}, description="Extract Luminance")
3214
3330
  except Exception:
3215
3331
  pass
3216
3332
 
3217
3333
  if hasattr(self, "_log"):
3218
- self._log(f"Extract Luminance ({method}) -> new mono document created.")
3334
+ self._log(f"Extract Luminance ({suffix}) -> new mono document created.")
3219
3335
 
3220
3336
  return new_doc
3221
3337
 
3338
+
3222
3339
  def _subwindow_docs(self):
3223
3340
  docs = []
3224
3341
  for sw in self.mdi.subWindowList():
@@ -4469,6 +4586,48 @@ class AstroSuiteProMainWindow(
4469
4586
  dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
4470
4587
  dlg.show()
4471
4588
 
4589
+ def _open_acv_exporter(self):
4590
+ from setiastro.saspro.acv_exporter import AstroCatalogueViewerExporterDialog
4591
+
4592
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
4593
+ if dm is None:
4594
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No document manager available.")
4595
+ return
4596
+
4597
+ sw = self.mdi.activeSubWindow()
4598
+ if not sw:
4599
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "Open an image first.")
4600
+ return
4601
+
4602
+ view = sw.widget()
4603
+ active_doc = None
4604
+
4605
+ # Prefer ROI-aware resolution
4606
+ try:
4607
+ if hasattr(dm, "get_document_for_view"):
4608
+ active_doc = dm.get_document_for_view(view)
4609
+ except Exception:
4610
+ active_doc = None
4611
+
4612
+ # Fallback
4613
+ if active_doc is None:
4614
+ try:
4615
+ active_doc = getattr(view, "document", None)
4616
+ except Exception:
4617
+ active_doc = None
4618
+
4619
+ if active_doc is None or getattr(active_doc, "image", None) is None:
4620
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No active image.")
4621
+ return
4622
+
4623
+ dlg = AstroCatalogueViewerExporterDialog(self, dm, active_doc)
4624
+ try:
4625
+ dlg.setWindowIcon(QIcon(acv_icon_path))
4626
+ except Exception:
4627
+ pass
4628
+ dlg.show()
4629
+
4630
+
4472
4631
  def _open_linear_fit(self):
4473
4632
  from setiastro.saspro.linear_fit import LinearFitDialog
4474
4633
  dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
@@ -4585,12 +4744,6 @@ class AstroSuiteProMainWindow(
4585
4744
  if max_len and len(hist) > max_len:
4586
4745
  del hist[:-max_len]
4587
4746
 
4588
- # Logging as before
4589
- try:
4590
- self._log(f"[Replay] Last action stored: {desc} (command_id={command_id})")
4591
- except Exception:
4592
- print(f"[Replay] Last action stored: {desc} (command_id={command_id})")
4593
-
4594
4747
 
4595
4748
 
4596
4749
  def _remember_last_headless_command(self, command_id: str, preset: dict | None = None, description: str = ""):
@@ -4646,17 +4799,6 @@ class AstroSuiteProMainWindow(
4646
4799
  """
4647
4800
  payload = getattr(self, "_last_headless_command", None)
4648
4801
 
4649
- # DEBUG
4650
- try:
4651
- self._log(
4652
- f"[Replay] replay_last_action_on_subwindow: payload={bool(payload)}, "
4653
- f"target_sw={id(target_sw) if target_sw else None}"
4654
- )
4655
- except Exception:
4656
- print(
4657
- f"[Replay] replay_last_action_on_subwindow: payload={bool(payload)}, "
4658
- f"target_sw={id(target_sw) if target_sw else None}"
4659
- )
4660
4802
 
4661
4803
  if not payload:
4662
4804
  QMessageBox.information(
@@ -4688,17 +4830,6 @@ class AstroSuiteProMainWindow(
4688
4830
  """
4689
4831
  payload = getattr(self, "_last_headless_command", None) or {}
4690
4832
 
4691
- # DEBUG
4692
- try:
4693
- self._log(
4694
- f"[Replay] replay_last_action_on_base: payload={bool(payload)}, "
4695
- f"target_sw={id(target_sw) if target_sw else None}"
4696
- )
4697
- except Exception:
4698
- print(
4699
- f"[Replay] replay_last_action_on_base: payload={bool(payload)}, "
4700
- f"target_sw={id(target_sw) if target_sw else None}"
4701
- )
4702
4833
 
4703
4834
  if not payload:
4704
4835
  QMessageBox.information(
@@ -4724,17 +4855,6 @@ class AstroSuiteProMainWindow(
4724
4855
  QMessageBox.information(self, "Replay Last Action", "No base image to apply the action to.")
4725
4856
  return
4726
4857
 
4727
- # Small debug about which doc we're hitting
4728
- try:
4729
- view = target_sw.widget()
4730
- cur_doc = getattr(view, "document", None)
4731
- self._log(
4732
- f"[Replay] base_doc id={id(base_doc)}, "
4733
- f"view.document id={id(cur_doc)}, "
4734
- f"same={base_doc is cur_doc}"
4735
- )
4736
- except Exception:
4737
- pass
4738
4858
 
4739
4859
  # ---- Extract cid + preset from payload (support both old + new schemas) ----
4740
4860
  cid_raw = payload.get("command_id")
@@ -7645,12 +7765,33 @@ class AstroSuiteProMainWindow(
7645
7765
  pass
7646
7766
 
7647
7767
  def _pretty_title(self, doc, *, linked: bool | None = None) -> str:
7648
- name = getattr(doc, "display_name", lambda: "Untitled")()
7649
- name = name.replace("[LINK] ", "").strip()
7768
+ md = (getattr(doc, "metadata", {}) or {})
7769
+
7770
+ # ✅ 1) Prefer explicit display_name (what duplicate/rename intends)
7771
+ name = (md.get("display_name") or "").strip()
7772
+
7773
+ # 2) Fallback to file_path (but only if display_name is missing)
7774
+ if not name:
7775
+ fp = (md.get("file_path") or "").strip()
7776
+ if fp:
7777
+ name = os.path.splitext(os.path.basename(fp))[0]
7778
+
7779
+ # 3) Fallback to doc.display_name()
7780
+ if not name:
7781
+ name = getattr(doc, "display_name", lambda: "Untitled")()
7782
+ name = (name or "Untitled").replace("[LINK] ", "").strip()
7783
+
7784
+ # If it looks like a filename, drop extension
7785
+ base, ext = os.path.splitext(name)
7786
+ if ext and len(ext) <= 10:
7787
+ name = base
7788
+
7789
+ # linked marker logic
7650
7790
  if linked is None:
7651
- linked = hasattr(doc, "_parent_doc") # ROI proxy -> linked
7791
+ linked = hasattr(doc, "_parent_doc")
7652
7792
  return f"[LINK] {name}" if linked else name
7653
7793
 
7794
+
7654
7795
  def _build_subwindow_title_for_doc(self, doc) -> str:
7655
7796
  """
7656
7797
  Build a unique, human-friendly title for a QMdiSubWindow
@@ -7718,6 +7859,34 @@ class AstroSuiteProMainWindow(
7718
7859
  return cand
7719
7860
  n += 1
7720
7861
 
7862
+
7863
+ def _doc_window_title(self, doc) -> str:
7864
+ md = getattr(doc, "metadata", {}) or {}
7865
+
7866
+ t = (md.get("display_name") or "").strip()
7867
+ if not t:
7868
+ try:
7869
+ t = (doc.display_name() or "").strip()
7870
+ except Exception:
7871
+ t = ""
7872
+
7873
+ if not t:
7874
+ fp = (md.get("file_path") or "").strip()
7875
+ if fp:
7876
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
7877
+
7878
+ t = t or "Untitled"
7879
+
7880
+ # strip glyphs etc
7881
+ try:
7882
+ t = _strip_ui_decorations(t)
7883
+ except Exception:
7884
+ pass
7885
+
7886
+ # ✅ ALWAYS strip filename-like extension at the very end
7887
+ t = _strip_filename_ext(t)
7888
+
7889
+ return t
7721
7890
 
7722
7891
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7723
7892
  """
@@ -7821,10 +7990,7 @@ class AstroSuiteProMainWindow(
7821
7990
  if replay_sig is not None:
7822
7991
  try:
7823
7992
  replay_sig.connect(self._on_view_replay_last_requested)
7824
- try:
7825
- self._log(f"[Replay] Connected {sig_name_used} for view id={id(view)}")
7826
- except Exception:
7827
- print(f"[Replay] Connected {sig_name_used} for view id={id(view)}")
7993
+
7828
7994
  except Exception as e:
7829
7995
  try:
7830
7996
  self._log(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
@@ -7832,7 +7998,8 @@ class AstroSuiteProMainWindow(
7832
7998
  print(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
7833
7999
 
7834
8000
  self._hook_preview_awareness(view)
7835
- base_title = self._pretty_title(doc, linked=False)
8001
+
8002
+ base_title = self._doc_window_title(doc) # ✅ use metadata display_name
7836
8003
  final_title = self._unique_window_title(base_title)
7837
8004
 
7838
8005
  # -- 6) Add subwindow and set chrome
@@ -7856,7 +8023,8 @@ class AstroSuiteProMainWindow(
7856
8023
  # We target ~60% of the viewport height, clamped to sane bounds.
7857
8024
  # -------------------------------------------------------------------------
7858
8025
  vp = self.mdi.viewport()
7859
- area = vp.rect() if vp else self.mdi.rect()
8026
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8027
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7860
8028
 
7861
8029
  # Determine aspect ratio
7862
8030
  img_w = img_h = None
@@ -7899,7 +8067,7 @@ class AstroSuiteProMainWindow(
7899
8067
  # Smart Cascade: Position relative to the *currently active* window
7900
8068
  # (before we make the new one active).
7901
8069
  # -------------------------------------------------------------------------
7902
- new_x, new_y = 0, 0
8070
+ new_x, new_y = area.left(), area.top()
7903
8071
 
7904
8072
  # Get dominant/active window *before* we activate the new one
7905
8073
  active = self.mdi.activeSubWindow()
@@ -7922,15 +8090,13 @@ class AstroSuiteProMainWindow(
7922
8090
  except Exception:
7923
8091
  pass
7924
8092
 
7925
- # Bounds check: don't let it drift completely off-screen
7926
- # (allow valid title bar to be visible at least)
7927
- if (new_x + target_w > area.width() + 50) or (new_y + 50 > area.height()):
7928
- new_x = 0
7929
- new_y = 0
7930
-
7931
- # Clamp to 0 if negative for some reason
7932
- new_x = max(0, new_x)
7933
- new_y = max(0, new_y)
8093
+ # Bounds check: keep titlebar visible and stay inside viewport
8094
+ if (new_x + target_w > area.right() - 10) or (new_y + 40 > area.bottom() - 10):
8095
+ new_x = area.left()
8096
+ new_y = area.top()
8097
+
8098
+ new_x = max(area.left(), new_x)
8099
+ new_y = max(area.top(), new_y)
7934
8100
 
7935
8101
  sw.move(new_x, new_y)
7936
8102
 
@@ -8025,6 +8191,11 @@ class AstroSuiteProMainWindow(
8025
8191
  except Exception:
8026
8192
  pass
8027
8193
 
8194
+ try:
8195
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8196
+ except Exception:
8197
+ pass
8198
+
8028
8199
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8029
8200
  try:
8030
8201
  if first_window and not is_table:
@@ -8119,25 +8290,57 @@ class AstroSuiteProMainWindow(
8119
8290
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8120
8291
  }
8121
8292
 
8122
- # 2) New name (strip UI decorations if any)
8123
- base_name = ""
8293
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8124
8294
  try:
8125
- base_name = base_doc.display_name() or "Untitled"
8295
+ base_name = self._doc_window_title(base_doc) # might include decorations
8126
8296
  except Exception:
8127
8297
  base_name = "Untitled"
8128
8298
 
8299
+ # Normalize it so uniqueness checks don't miss decorated titles
8129
8300
  try:
8130
- base_name = _strip_ui_decorations(base_name)
8301
+ base_name = normalize_doc_title(base_name)
8131
8302
  except Exception:
8132
- # minimal fallback: remove our known prefix/glyphs
8133
- while len(base_name) >= 2 and base_name[1] == " " and base_name[0] in "â- â--â--†â-²â-ªâ-«â€¢â--¼â--»â--¾â--½":
8134
- base_name = base_name[2:]
8135
- if base_name.startswith("Active View: "):
8136
- base_name = base_name[len("Active View: "):]
8303
+ base_name = (base_name or "Untitled").strip()
8304
+
8305
+ # Build a set of existing document names (normalized)
8306
+ existing = set()
8307
+ try:
8308
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8309
+ docs = []
8310
+
8311
+ # Prefer an official accessor if you have one
8312
+ if dm is not None:
8313
+ if hasattr(dm, "documents"):
8314
+ docs = list(dm.documents())
8315
+ elif hasattr(dm, "_docs"):
8316
+ docs = list(dm._docs)
8317
+
8318
+ for d in docs:
8319
+ try:
8320
+ dn = ""
8321
+ md = getattr(d, "metadata", {}) or {}
8322
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8323
+ dn = normalize_doc_title(dn)
8324
+ if dn:
8325
+ existing.add(dn)
8326
+ except Exception:
8327
+ pass
8328
+ except Exception:
8329
+ pass
8330
+
8331
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8332
+ candidate = f"{base_name}_duplicate"
8333
+ if candidate in existing:
8334
+ n = 2
8335
+ while True:
8336
+ cand = f"{base_name}_duplicate{n}"
8337
+ if cand not in existing:
8338
+ candidate = cand
8339
+ break
8340
+ n += 1
8137
8341
 
8138
8342
  # 3) Duplicate the *base* document (not the ROI proxy)
8139
- # NOTE: your project uses `self.docman` elsewhere for duplication.
8140
- new_doc = self.docman.duplicate_document(base_doc, new_name=f"{base_name}_duplicate")
8343
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8141
8344
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8142
8345
 
8143
8346
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8287,26 +8490,21 @@ class AstroSuiteProMainWindow(
8287
8490
 
8288
8491
 
8289
8492
  def _activate_or_open_from_explorer(self, item):
8290
- doc = item.data(Qt.ItemDataRole.UserRole)
8291
- base = self._normalize_base_doc(doc)
8292
-
8293
- # 1) Try to focus an existing view for this base
8294
- for sw in self.mdi.subWindowList():
8295
- w = sw.widget()
8296
- if getattr(w, "base_document", None) is base:
8297
- try:
8298
- sw.show(); w.show()
8299
- st = sw.windowState()
8300
- if st & Qt.WindowState.WindowMinimized:
8301
- sw.setWindowState(st & ~Qt.WindowState.WindowMinimized)
8302
- self.mdi.setActiveSubWindow(sw)
8303
- sw.raise_()
8304
- except Exception:
8305
- pass
8306
- return
8307
-
8308
- # 2) None exists -> open one
8309
- self._open_subwindow_for_added_doc(base)
8493
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
8494
+ if doc is None:
8495
+ return
8496
+ # you already have logic for this; typically:
8497
+ sw = self._find_subwindow_for_doc(doc)
8498
+ if sw:
8499
+ self.mdi.setActiveSubWindow(sw)
8500
+ sw.show()
8501
+ sw.raise_()
8502
+ return
8503
+ # else open it (if your app supports opening closed docs, otherwise no-op)
8504
+ try:
8505
+ self._open_subwindow_for_added_doc(doc)
8506
+ except Exception:
8507
+ pass
8310
8508
 
8311
8509
  def _set_linked_stretch_from_action(self, checked: bool):
8312
8510
  # persist as the default for *new* views
@@ -8432,7 +8630,10 @@ class AstroSuiteProMainWindow(
8432
8630
  self._refresh_mask_action_states()
8433
8631
  except Exception:
8434
8632
  pass
8435
-
8633
+ try:
8634
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8635
+ except Exception:
8636
+ pass
8436
8637
 
8437
8638
  def _sync_docman_active(self, doc):
8438
8639
  dm = self.doc_manager