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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/acv_icon.png +0 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/first_quarter.png +0 -0
  6. setiastro/images/full_moon.png +0 -0
  7. setiastro/images/graxpert.svg +19 -0
  8. setiastro/images/last_quarter.png +0 -0
  9. setiastro/images/linearfit.svg +32 -0
  10. setiastro/images/new_moon.png +0 -0
  11. setiastro/images/pixelmath.svg +42 -0
  12. setiastro/images/waning_crescent_1.png +0 -0
  13. setiastro/images/waning_crescent_2.png +0 -0
  14. setiastro/images/waning_crescent_3.png +0 -0
  15. setiastro/images/waning_crescent_4.png +0 -0
  16. setiastro/images/waning_crescent_5.png +0 -0
  17. setiastro/images/waning_gibbous_1.png +0 -0
  18. setiastro/images/waning_gibbous_2.png +0 -0
  19. setiastro/images/waning_gibbous_3.png +0 -0
  20. setiastro/images/waning_gibbous_4.png +0 -0
  21. setiastro/images/waning_gibbous_5.png +0 -0
  22. setiastro/images/waxing_crescent_1.png +0 -0
  23. setiastro/images/waxing_crescent_2.png +0 -0
  24. setiastro/images/waxing_crescent_3.png +0 -0
  25. setiastro/images/waxing_crescent_4.png +0 -0
  26. setiastro/images/waxing_crescent_5.png +0 -0
  27. setiastro/images/waxing_gibbous_1.png +0 -0
  28. setiastro/images/waxing_gibbous_2.png +0 -0
  29. setiastro/images/waxing_gibbous_3.png +0 -0
  30. setiastro/images/waxing_gibbous_4.png +0 -0
  31. setiastro/images/waxing_gibbous_5.png +0 -0
  32. setiastro/qml/ResourceMonitor.qml +84 -82
  33. setiastro/saspro/__main__.py +19 -0
  34. setiastro/saspro/_generated/build_info.py +2 -2
  35. setiastro/saspro/abe.py +37 -4
  36. setiastro/saspro/aberration_ai.py +237 -21
  37. setiastro/saspro/acv_exporter.py +379 -0
  38. setiastro/saspro/add_stars.py +33 -6
  39. setiastro/saspro/backgroundneutral.py +35 -7
  40. setiastro/saspro/blemish_blaster.py +4 -1
  41. setiastro/saspro/blink_comparator_pro.py +74 -24
  42. setiastro/saspro/clahe.py +4 -1
  43. setiastro/saspro/continuum_subtract.py +4 -1
  44. setiastro/saspro/convo.py +4 -1
  45. setiastro/saspro/cosmicclarity.py +129 -18
  46. setiastro/saspro/crop_dialog_pro.py +123 -7
  47. setiastro/saspro/curve_editor_pro.py +109 -42
  48. setiastro/saspro/doc_manager.py +67 -4
  49. setiastro/saspro/exoplanet_detector.py +120 -28
  50. setiastro/saspro/frequency_separation.py +1158 -204
  51. setiastro/saspro/ghs_dialog_pro.py +81 -16
  52. setiastro/saspro/graxpert.py +1 -0
  53. setiastro/saspro/gui/main_window.py +393 -204
  54. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  55. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  56. setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
  57. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  58. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  59. setiastro/saspro/halobgon.py +4 -0
  60. setiastro/saspro/histogram.py +5 -1
  61. setiastro/saspro/image_combine.py +4 -0
  62. setiastro/saspro/image_peeker_pro.py +4 -0
  63. setiastro/saspro/imageops/stretch.py +531 -62
  64. setiastro/saspro/isophote.py +4 -0
  65. setiastro/saspro/layers.py +13 -9
  66. setiastro/saspro/layers_dock.py +183 -3
  67. setiastro/saspro/legacy/image_manager.py +154 -20
  68. setiastro/saspro/legacy/numba_utils.py +43 -0
  69. setiastro/saspro/legacy/xisf.py +240 -98
  70. setiastro/saspro/live_stacking.py +180 -79
  71. setiastro/saspro/luminancerecombine.py +228 -27
  72. setiastro/saspro/mask_creation.py +174 -15
  73. setiastro/saspro/mfdeconv.py +113 -35
  74. setiastro/saspro/mfdeconvcudnn.py +119 -70
  75. setiastro/saspro/mfdeconvsport.py +112 -35
  76. setiastro/saspro/morphology.py +4 -0
  77. setiastro/saspro/multiscale_decomp.py +51 -12
  78. setiastro/saspro/numba_utils.py +72 -2
  79. setiastro/saspro/ops/commands.py +18 -18
  80. setiastro/saspro/ops/script_editor.py +5 -2
  81. setiastro/saspro/ops/scripts.py +3 -0
  82. setiastro/saspro/perfect_palette_picker.py +37 -3
  83. setiastro/saspro/plate_solver.py +84 -49
  84. setiastro/saspro/psf_viewer.py +119 -37
  85. setiastro/saspro/resources.py +67 -0
  86. setiastro/saspro/rgbalign.py +4 -0
  87. setiastro/saspro/selective_color.py +4 -1
  88. setiastro/saspro/sfcc.py +60 -2
  89. setiastro/saspro/shortcuts.py +142 -23
  90. setiastro/saspro/signature_insert.py +692 -33
  91. setiastro/saspro/stacking_suite.py +1017 -400
  92. setiastro/saspro/star_alignment.py +4 -1
  93. setiastro/saspro/star_spikes.py +4 -0
  94. setiastro/saspro/star_stretch.py +38 -3
  95. setiastro/saspro/stat_stretch.py +702 -128
  96. setiastro/saspro/subwindow.py +786 -360
  97. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  98. setiastro/saspro/wavescale_hdr.py +4 -1
  99. setiastro/saspro/wavescalede.py +4 -1
  100. setiastro/saspro/whitebalance.py +60 -12
  101. setiastro/saspro/widgets/common_utilities.py +28 -21
  102. setiastro/saspro/widgets/resource_monitor.py +109 -59
  103. setiastro/saspro/widgets/spinboxes.py +10 -13
  104. setiastro/saspro/wimi.py +27 -656
  105. setiastro/saspro/wims.py +13 -3
  106. setiastro/saspro/xisf.py +101 -11
  107. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
  108. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
  109. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  110. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  111. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  112. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -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 ---
@@ -3015,17 +3172,17 @@ class AstroSuiteProMainWindow(
3015
3172
 
3016
3173
  self.convo_window.show()
3017
3174
 
3018
-
3019
-
3020
3175
  def _apply_extract_luminance_preset_to_doc(self, doc, preset=None):
3021
-
3022
3176
  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
3177
+ from setiastro.saspro.luminancerecombine import (
3178
+ compute_luminance,
3179
+ resolve_luma_profile_weights,
3180
+ )
3181
+ from setiastro.saspro.headless_utils import unwrap_docproxy
3182
+ import numpy as np
3025
3183
 
3026
3184
  doc = unwrap_docproxy(doc)
3027
3185
  p = dict(preset or {})
3028
- mode = (p.get("mode") or "rec709").lower()
3029
3186
 
3030
3187
  if doc is None or getattr(doc, "image", None) is None:
3031
3188
  QMessageBox.information(self, "Extract Luminance", "No target image.")
@@ -3033,52 +3190,43 @@ class AstroSuiteProMainWindow(
3033
3190
 
3034
3191
  img = np.asarray(doc.image)
3035
3192
 
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
3193
+ mode = str(p.get("mode", "rec709")).strip()
3194
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3045
3195
 
3046
- L = compute_luminance(img, method=mode, weights=w)
3196
+ L = compute_luminance(img, method=resolved_method, weights=w)
3047
3197
 
3048
3198
  dm = getattr(self, "doc_manager", None)
3049
3199
  if dm is None:
3050
- # headless fallback: just overwrite active doc
3051
3200
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3052
3201
  return
3053
3202
 
3054
- # normal behavior: create a new mono document
3203
+ meta = {
3204
+ "step_name": "Extract Luminance",
3205
+ "luma_method": resolved_method,
3206
+ }
3207
+ if w is not None:
3208
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3209
+ if profile_name:
3210
+ meta["luma_profile"] = str(profile_name)
3211
+
3055
3212
  try:
3213
+ suffix = f"{profile_name}" if profile_name else resolved_method
3056
3214
  new_doc = dm.create_document_from_array(
3057
3215
  L.astype(np.float32),
3058
- name=f"{doc.display_name()} -- Luminance ({mode})",
3216
+ name=f"{doc.display_name()} -- Luminance ({suffix})",
3059
3217
  is_mono=True,
3060
- metadata={"step_name":"Extract Luminance", "luma_method":mode}
3218
+ metadata=meta,
3061
3219
  )
3062
3220
  dm.add_document(new_doc)
3063
3221
  except Exception:
3064
- # safe fallback
3065
3222
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3066
3223
 
3067
-
3068
3224
  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
3225
+ from PyQt6.QtWidgets import QMessageBox
3226
+ from PyQt6.QtGui import QIcon
3227
+ from setiastro.saspro.luminancerecombine import compute_luminance, resolve_luma_profile_weights
3228
+
3229
+
3082
3230
  sw = None
3083
3231
  if doc is None:
3084
3232
  sw = self.mdi.activeSubWindow()
@@ -3097,70 +3245,19 @@ class AstroSuiteProMainWindow(
3097
3245
  QMessageBox.information(self, "Extract Luminance", "Luminance extraction requires an RGB image.")
3098
3246
  return
3099
3247
 
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
3248
  p = dict(preset or {})
3110
- method = str(
3249
+ mode = str(
3111
3250
  p.get("mode",
3112
3251
  p.get("method",
3113
3252
  p.get("luma_method",
3114
3253
  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
3254
+ ).strip()
3255
+
3256
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3257
+
3258
+ y = compute_luminance(img, method=resolved_method, weights=w)
3259
+
3260
+ # ---- metadata & title ----
3164
3261
  base_meta = {}
3165
3262
  try:
3166
3263
  base_meta = dict(getattr(doc, "metadata", {}) or {})
@@ -3172,13 +3269,16 @@ class AstroSuiteProMainWindow(
3172
3269
  "source": "ExtractLuminance",
3173
3270
  "is_mono": True,
3174
3271
  "bit_depth": "32f",
3175
- "luma_method": method,
3272
+ "luma_method": resolved_method,
3176
3273
  }
3177
- if luma_weights is not None:
3178
- meta["luma_weights"] = np.asarray(luma_weights, dtype=np.float32).tolist()
3274
+ if w is not None:
3275
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3276
+ if profile_name:
3277
+ meta["luma_profile"] = str(profile_name)
3179
3278
 
3180
3279
  base_title = sw.windowTitle() if sw else (getattr(doc, "title", getattr(doc, "name", "")) or "Untitled")
3181
- title = f"{base_title} -- Luminance"
3280
+ suffix = f"{profile_name}" if profile_name else resolved_method
3281
+ title = f"{base_title} -- Luminance ({suffix})"
3182
3282
 
3183
3283
  dm = getattr(self, "docman", None)
3184
3284
  if dm is None:
@@ -3206,19 +3306,19 @@ class AstroSuiteProMainWindow(
3206
3306
  except Exception:
3207
3307
  pass
3208
3308
 
3209
- # ðŸ" Remember for Replay (optional but consistent)
3210
3309
  try:
3211
3310
  remember = getattr(self, "remember_last_headless_command", None) or getattr(self, "_remember_last_headless_command", None)
3212
3311
  if callable(remember):
3213
- remember("extract_luminance", {"mode": method}, description="Extract Luminance")
3312
+ remember("extract_luminance", {"mode": mode}, description="Extract Luminance")
3214
3313
  except Exception:
3215
3314
  pass
3216
3315
 
3217
3316
  if hasattr(self, "_log"):
3218
- self._log(f"Extract Luminance ({method}) -> new mono document created.")
3317
+ self._log(f"Extract Luminance ({suffix}) -> new mono document created.")
3219
3318
 
3220
3319
  return new_doc
3221
3320
 
3321
+
3222
3322
  def _subwindow_docs(self):
3223
3323
  docs = []
3224
3324
  for sw in self.mdi.subWindowList():
@@ -4469,6 +4569,48 @@ class AstroSuiteProMainWindow(
4469
4569
  dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
4470
4570
  dlg.show()
4471
4571
 
4572
+ def _open_acv_exporter(self):
4573
+ from setiastro.saspro.acv_exporter import AstroCatalogueViewerExporterDialog
4574
+
4575
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
4576
+ if dm is None:
4577
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No document manager available.")
4578
+ return
4579
+
4580
+ sw = self.mdi.activeSubWindow()
4581
+ if not sw:
4582
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "Open an image first.")
4583
+ return
4584
+
4585
+ view = sw.widget()
4586
+ active_doc = None
4587
+
4588
+ # Prefer ROI-aware resolution
4589
+ try:
4590
+ if hasattr(dm, "get_document_for_view"):
4591
+ active_doc = dm.get_document_for_view(view)
4592
+ except Exception:
4593
+ active_doc = None
4594
+
4595
+ # Fallback
4596
+ if active_doc is None:
4597
+ try:
4598
+ active_doc = getattr(view, "document", None)
4599
+ except Exception:
4600
+ active_doc = None
4601
+
4602
+ if active_doc is None or getattr(active_doc, "image", None) is None:
4603
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No active image.")
4604
+ return
4605
+
4606
+ dlg = AstroCatalogueViewerExporterDialog(self, dm, active_doc)
4607
+ try:
4608
+ dlg.setWindowIcon(QIcon(acv_icon_path))
4609
+ except Exception:
4610
+ pass
4611
+ dlg.show()
4612
+
4613
+
4472
4614
  def _open_linear_fit(self):
4473
4615
  from setiastro.saspro.linear_fit import LinearFitDialog
4474
4616
  dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
@@ -4585,12 +4727,6 @@ class AstroSuiteProMainWindow(
4585
4727
  if max_len and len(hist) > max_len:
4586
4728
  del hist[:-max_len]
4587
4729
 
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
4730
 
4595
4731
 
4596
4732
  def _remember_last_headless_command(self, command_id: str, preset: dict | None = None, description: str = ""):
@@ -4646,17 +4782,6 @@ class AstroSuiteProMainWindow(
4646
4782
  """
4647
4783
  payload = getattr(self, "_last_headless_command", None)
4648
4784
 
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
4785
 
4661
4786
  if not payload:
4662
4787
  QMessageBox.information(
@@ -4688,17 +4813,6 @@ class AstroSuiteProMainWindow(
4688
4813
  """
4689
4814
  payload = getattr(self, "_last_headless_command", None) or {}
4690
4815
 
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
4816
 
4703
4817
  if not payload:
4704
4818
  QMessageBox.information(
@@ -4724,17 +4838,6 @@ class AstroSuiteProMainWindow(
4724
4838
  QMessageBox.information(self, "Replay Last Action", "No base image to apply the action to.")
4725
4839
  return
4726
4840
 
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
4841
 
4739
4842
  # ---- Extract cid + preset from payload (support both old + new schemas) ----
4740
4843
  cid_raw = payload.get("command_id")
@@ -7645,12 +7748,33 @@ class AstroSuiteProMainWindow(
7645
7748
  pass
7646
7749
 
7647
7750
  def _pretty_title(self, doc, *, linked: bool | None = None) -> str:
7648
- name = getattr(doc, "display_name", lambda: "Untitled")()
7649
- name = name.replace("[LINK] ", "").strip()
7751
+ md = (getattr(doc, "metadata", {}) or {})
7752
+
7753
+ # ✅ 1) Prefer explicit display_name (what duplicate/rename intends)
7754
+ name = (md.get("display_name") or "").strip()
7755
+
7756
+ # 2) Fallback to file_path (but only if display_name is missing)
7757
+ if not name:
7758
+ fp = (md.get("file_path") or "").strip()
7759
+ if fp:
7760
+ name = os.path.splitext(os.path.basename(fp))[0]
7761
+
7762
+ # 3) Fallback to doc.display_name()
7763
+ if not name:
7764
+ name = getattr(doc, "display_name", lambda: "Untitled")()
7765
+ name = (name or "Untitled").replace("[LINK] ", "").strip()
7766
+
7767
+ # If it looks like a filename, drop extension
7768
+ base, ext = os.path.splitext(name)
7769
+ if ext and len(ext) <= 10:
7770
+ name = base
7771
+
7772
+ # linked marker logic
7650
7773
  if linked is None:
7651
- linked = hasattr(doc, "_parent_doc") # ROI proxy -> linked
7774
+ linked = hasattr(doc, "_parent_doc")
7652
7775
  return f"[LINK] {name}" if linked else name
7653
7776
 
7777
+
7654
7778
  def _build_subwindow_title_for_doc(self, doc) -> str:
7655
7779
  """
7656
7780
  Build a unique, human-friendly title for a QMdiSubWindow
@@ -7718,6 +7842,34 @@ class AstroSuiteProMainWindow(
7718
7842
  return cand
7719
7843
  n += 1
7720
7844
 
7845
+
7846
+ def _doc_window_title(self, doc) -> str:
7847
+ md = getattr(doc, "metadata", {}) or {}
7848
+
7849
+ t = (md.get("display_name") or "").strip()
7850
+ if not t:
7851
+ try:
7852
+ t = (doc.display_name() or "").strip()
7853
+ except Exception:
7854
+ t = ""
7855
+
7856
+ if not t:
7857
+ fp = (md.get("file_path") or "").strip()
7858
+ if fp:
7859
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
7860
+
7861
+ t = t or "Untitled"
7862
+
7863
+ # strip glyphs etc
7864
+ try:
7865
+ t = _strip_ui_decorations(t)
7866
+ except Exception:
7867
+ pass
7868
+
7869
+ # ✅ ALWAYS strip filename-like extension at the very end
7870
+ t = _strip_filename_ext(t)
7871
+
7872
+ return t
7721
7873
 
7722
7874
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7723
7875
  """
@@ -7821,10 +7973,7 @@ class AstroSuiteProMainWindow(
7821
7973
  if replay_sig is not None:
7822
7974
  try:
7823
7975
  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)}")
7976
+
7828
7977
  except Exception as e:
7829
7978
  try:
7830
7979
  self._log(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
@@ -7832,7 +7981,8 @@ class AstroSuiteProMainWindow(
7832
7981
  print(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
7833
7982
 
7834
7983
  self._hook_preview_awareness(view)
7835
- base_title = self._pretty_title(doc, linked=False)
7984
+
7985
+ base_title = self._doc_window_title(doc) # ✅ use metadata display_name
7836
7986
  final_title = self._unique_window_title(base_title)
7837
7987
 
7838
7988
  # -- 6) Add subwindow and set chrome
@@ -7856,7 +8006,8 @@ class AstroSuiteProMainWindow(
7856
8006
  # We target ~60% of the viewport height, clamped to sane bounds.
7857
8007
  # -------------------------------------------------------------------------
7858
8008
  vp = self.mdi.viewport()
7859
- area = vp.rect() if vp else self.mdi.rect()
8009
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8010
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7860
8011
 
7861
8012
  # Determine aspect ratio
7862
8013
  img_w = img_h = None
@@ -7899,7 +8050,7 @@ class AstroSuiteProMainWindow(
7899
8050
  # Smart Cascade: Position relative to the *currently active* window
7900
8051
  # (before we make the new one active).
7901
8052
  # -------------------------------------------------------------------------
7902
- new_x, new_y = 0, 0
8053
+ new_x, new_y = area.left(), area.top()
7903
8054
 
7904
8055
  # Get dominant/active window *before* we activate the new one
7905
8056
  active = self.mdi.activeSubWindow()
@@ -7922,15 +8073,13 @@ class AstroSuiteProMainWindow(
7922
8073
  except Exception:
7923
8074
  pass
7924
8075
 
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)
8076
+ # Bounds check: keep titlebar visible and stay inside viewport
8077
+ if (new_x + target_w > area.right() - 10) or (new_y + 40 > area.bottom() - 10):
8078
+ new_x = area.left()
8079
+ new_y = area.top()
8080
+
8081
+ new_x = max(area.left(), new_x)
8082
+ new_y = max(area.top(), new_y)
7934
8083
 
7935
8084
  sw.move(new_x, new_y)
7936
8085
 
@@ -8025,6 +8174,11 @@ class AstroSuiteProMainWindow(
8025
8174
  except Exception:
8026
8175
  pass
8027
8176
 
8177
+ try:
8178
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8179
+ except Exception:
8180
+ pass
8181
+
8028
8182
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8029
8183
  try:
8030
8184
  if first_window and not is_table:
@@ -8119,25 +8273,57 @@ class AstroSuiteProMainWindow(
8119
8273
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8120
8274
  }
8121
8275
 
8122
- # 2) New name (strip UI decorations if any)
8123
- base_name = ""
8276
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8124
8277
  try:
8125
- base_name = base_doc.display_name() or "Untitled"
8278
+ base_name = self._doc_window_title(base_doc) # might include decorations
8126
8279
  except Exception:
8127
8280
  base_name = "Untitled"
8128
8281
 
8282
+ # Normalize it so uniqueness checks don't miss decorated titles
8129
8283
  try:
8130
- base_name = _strip_ui_decorations(base_name)
8284
+ base_name = normalize_doc_title(base_name)
8131
8285
  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: "):]
8286
+ base_name = (base_name or "Untitled").strip()
8287
+
8288
+ # Build a set of existing document names (normalized)
8289
+ existing = set()
8290
+ try:
8291
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8292
+ docs = []
8293
+
8294
+ # Prefer an official accessor if you have one
8295
+ if dm is not None:
8296
+ if hasattr(dm, "documents"):
8297
+ docs = list(dm.documents())
8298
+ elif hasattr(dm, "_docs"):
8299
+ docs = list(dm._docs)
8300
+
8301
+ for d in docs:
8302
+ try:
8303
+ dn = ""
8304
+ md = getattr(d, "metadata", {}) or {}
8305
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8306
+ dn = normalize_doc_title(dn)
8307
+ if dn:
8308
+ existing.add(dn)
8309
+ except Exception:
8310
+ pass
8311
+ except Exception:
8312
+ pass
8313
+
8314
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8315
+ candidate = f"{base_name}_duplicate"
8316
+ if candidate in existing:
8317
+ n = 2
8318
+ while True:
8319
+ cand = f"{base_name}_duplicate{n}"
8320
+ if cand not in existing:
8321
+ candidate = cand
8322
+ break
8323
+ n += 1
8137
8324
 
8138
8325
  # 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")
8326
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8141
8327
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8142
8328
 
8143
8329
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8432,7 +8618,10 @@ class AstroSuiteProMainWindow(
8432
8618
  self._refresh_mask_action_states()
8433
8619
  except Exception:
8434
8620
  pass
8435
-
8621
+ try:
8622
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8623
+ except Exception:
8624
+ pass
8436
8625
 
8437
8626
  def _sync_docman_active(self, doc):
8438
8627
  dm = self.doc_manager