setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.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.

Potentially problematic release.


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

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -129,7 +129,7 @@ from PyQt6.QtGui import (QPixmap, QColor, QIcon, QKeySequence, QShortcut,
129
129
 
130
130
  # ----- QtCore -----
131
131
  from PyQt6.QtCore import (Qt, pyqtSignal, QCoreApplication, QTimer, QSize, QSignalBlocker, QModelIndex, QThread, QUrl, QSettings, QEvent, QByteArray, QObject,
132
- QPropertyAnimation, QEasingCurve, QElapsedTimer
132
+ QPropertyAnimation, QEasingCurve, QElapsedTimer, QPoint
133
133
  )
134
134
 
135
135
  from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
@@ -168,7 +168,7 @@ except Exception:
168
168
 
169
169
 
170
170
 
171
-
171
+ _DEBUG_DND_DUP = False
172
172
 
173
173
 
174
174
 
@@ -193,9 +193,9 @@ from setiastro.saspro.resources import (
193
193
  nbtorgb_path, freqsep_path, contsub_path, halo_path, cosmic_path,
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
- colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path,
197
- wimi_path, linearfit_path, debayer_path, aberration_path,
198
- functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path,
196
+ colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
197
+ wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
198
+ functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
199
199
  background_path, script_icon_path
200
200
  )
201
201
 
@@ -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)
@@ -465,7 +604,10 @@ class AstroSuiteProMainWindow(
465
604
  self.docman.documentAdded.connect(self._on_document_added)
466
605
  self.mdi.viewStateDropped.connect(self._on_mdi_viewstate_drop)
467
606
  self.mdi.linkViewDropped.connect(self._on_linkview_drop)
468
-
607
+ self._mdi_open_batch = 0
608
+ self._mdi_place_mode = "cascade" # or "tile"
609
+ self._mdi_next_pos = None # QPoint in MDI coords
610
+ self._mdi_cascade_step = 28
469
611
  self.doc_manager.set_mdi_area(self.mdi)
470
612
  # Coalesce undo/redo label refreshes
471
613
  self._undo_redo_refresh_pending = False
@@ -856,7 +998,18 @@ class AstroSuiteProMainWindow(
856
998
  dm = self.doc_manager
857
999
  doc = None
858
1000
 
1001
+ if _DEBUG_DND_DUP:
1002
+ import json
1003
+ print("\n[DNDDBG:DROP_ENTER] raw st dict:")
1004
+ try:
1005
+ # st is already a dict here
1006
+ for k in sorted(st.keys()):
1007
+ print(f" {k}: {st.get(k)!r}")
1008
+ except Exception as e:
1009
+ print("[DNDDBG:DROP_ENTER] failed printing st:", e)
859
1010
 
1011
+ # sanity: show which fields are present
1012
+ print("[DNDDBG:DROP_ENTER] has source_view_title?", "source_view_title" in st)
860
1013
 
861
1014
  # Prefer *stable* identifiers over the proxy pointer
862
1015
  uid = st.get("doc_uid")
@@ -959,7 +1112,21 @@ class AstroSuiteProMainWindow(
959
1112
  print("[VIEWSTATE_DROP] EXIT (no doc)")
960
1113
  return
961
1114
 
962
-
1115
+ if _DEBUG_DND_DUP:
1116
+ try:
1117
+ dname = doc.display_name() if hasattr(doc, "display_name") else None
1118
+ except Exception:
1119
+ dname = None
1120
+ try:
1121
+ meta = getattr(doc, "metadata", {}) or {}
1122
+ except Exception:
1123
+ meta = {}
1124
+ print("\n[DNDDBG:DOC_RESOLVED]")
1125
+ print(" doc_obj:", doc, "type:", type(doc).__name__, "id:", id(doc))
1126
+ print(" doc.uid:", getattr(doc, "uid", None))
1127
+ print(" doc.display_name():", dname)
1128
+ print(" meta.display_name:", meta.get("display_name"))
1129
+ print(" meta.file_path:", meta.get("file_path"))
963
1130
 
964
1131
  # ----------------------------------------
965
1132
  # 4) Peek at metadata to see if this is a
@@ -1050,34 +1217,35 @@ class AstroSuiteProMainWindow(
1050
1217
  # copy the view transform.
1051
1218
  # ----------------------------------------
1052
1219
  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
1220
  base_doc = doc
1057
1221
 
1058
- # 1) Duplicate the underlying document
1059
- try:
1060
- base_name = ""
1222
+ # 1) Prefer the dragged view's title
1223
+ base_name = (st.get("source_view_title") or "").strip()
1224
+
1225
+ # 2) Fallback to document display name
1226
+ if not base_name:
1061
1227
  try:
1062
1228
  base_name = base_doc.display_name() or "Untitled"
1063
1229
  except Exception:
1064
1230
  base_name = "Untitled"
1065
1231
 
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
1232
+ # 3) Clean it (strip glyphs / "Active View" / etc.)
1233
+ try:
1234
+ base_name = _strip_ui_decorations(base_name)
1235
+ except Exception:
1236
+ if base_name.startswith("Active View: "):
1237
+ base_name = base_name[len("Active View: "):]
1238
+
1239
+ if _DEBUG_DND_DUP:
1240
+ print("\n[DNDDBG:NAME_COMPUTE]")
1241
+ print(" st.source_view_title:", (st.get("source_view_title") or "").strip())
1242
+ print(" base_doc.display_name():", (base_doc.display_name() if hasattr(base_doc,"display_name") else None))
1243
+ print(" base_name(after fallbacks/strip):", base_name)
1244
+ print(" new_name passed:", f"{base_name}_duplicate")
1245
+
1246
+ new_doc = self.docman.duplicate_document(
1247
+ base_doc, new_name=f"{base_name}_duplicate"
1248
+ )
1081
1249
 
1082
1250
  # 2) Let doc_manager's documentAdded handler create the subwindow.
1083
1251
  # We just wait for it to show up and then apply the view state.
@@ -1203,14 +1371,6 @@ class AstroSuiteProMainWindow(
1203
1371
  return False
1204
1372
 
1205
1373
  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
1374
  self._spawn_subwindow_for(doc)
1215
1375
 
1216
1376
  # --- UI scaffolding ---
@@ -2503,10 +2663,27 @@ class AstroSuiteProMainWindow(
2503
2663
  return f"{name}{dims}"
2504
2664
 
2505
2665
  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))
2666
+ # Delegate to DockMixin implementation if present
2667
+ try:
2668
+ return super()._update_explorer_item_for_doc(doc)
2669
+ except Exception:
2670
+ pass
2671
+
2672
+ # Fallback: tree-safe implementation
2673
+ if not hasattr(self, "explorer") or self.explorer is None:
2674
+ return
2675
+ try:
2676
+ n = self.explorer.topLevelItemCount()
2677
+ except Exception:
2678
+ return
2679
+
2680
+ for i in range(n):
2681
+ it = self.explorer.topLevelItem(i)
2682
+ if it.data(0, Qt.ItemDataRole.UserRole) is doc:
2683
+ try:
2684
+ self._refresh_explorer_row(it, doc)
2685
+ except Exception:
2686
+ pass
2510
2687
  return
2511
2688
  #-----------FUNCTIONS----------------
2512
2689
 
@@ -3015,17 +3192,17 @@ class AstroSuiteProMainWindow(
3015
3192
 
3016
3193
  self.convo_window.show()
3017
3194
 
3018
-
3019
-
3020
3195
  def _apply_extract_luminance_preset_to_doc(self, doc, preset=None):
3021
-
3022
3196
  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
3197
+ from setiastro.saspro.luminancerecombine import (
3198
+ compute_luminance,
3199
+ resolve_luma_profile_weights,
3200
+ )
3201
+ from setiastro.saspro.headless_utils import unwrap_docproxy
3202
+ import numpy as np
3025
3203
 
3026
3204
  doc = unwrap_docproxy(doc)
3027
3205
  p = dict(preset or {})
3028
- mode = (p.get("mode") or "rec709").lower()
3029
3206
 
3030
3207
  if doc is None or getattr(doc, "image", None) is None:
3031
3208
  QMessageBox.information(self, "Extract Luminance", "No target image.")
@@ -3033,52 +3210,43 @@ class AstroSuiteProMainWindow(
3033
3210
 
3034
3211
  img = np.asarray(doc.image)
3035
3212
 
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
3213
+ mode = str(p.get("mode", "rec709")).strip()
3214
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3045
3215
 
3046
- L = compute_luminance(img, method=mode, weights=w)
3216
+ L = compute_luminance(img, method=resolved_method, weights=w)
3047
3217
 
3048
3218
  dm = getattr(self, "doc_manager", None)
3049
3219
  if dm is None:
3050
- # headless fallback: just overwrite active doc
3051
3220
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3052
3221
  return
3053
3222
 
3054
- # normal behavior: create a new mono document
3223
+ meta = {
3224
+ "step_name": "Extract Luminance",
3225
+ "luma_method": resolved_method,
3226
+ }
3227
+ if w is not None:
3228
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3229
+ if profile_name:
3230
+ meta["luma_profile"] = str(profile_name)
3231
+
3055
3232
  try:
3233
+ suffix = f"{profile_name}" if profile_name else resolved_method
3056
3234
  new_doc = dm.create_document_from_array(
3057
3235
  L.astype(np.float32),
3058
- name=f"{doc.display_name()} -- Luminance ({mode})",
3236
+ name=f"{doc.display_name()} -- Luminance ({suffix})",
3059
3237
  is_mono=True,
3060
- metadata={"step_name":"Extract Luminance", "luma_method":mode}
3238
+ metadata=meta,
3061
3239
  )
3062
3240
  dm.add_document(new_doc)
3063
3241
  except Exception:
3064
- # safe fallback
3065
3242
  doc.apply_edit(L.astype(np.float32), step_name="Extract Luminance")
3066
3243
 
3067
-
3068
3244
  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
3245
+ from PyQt6.QtWidgets import QMessageBox
3246
+ from PyQt6.QtGui import QIcon
3247
+ from setiastro.saspro.luminancerecombine import compute_luminance, resolve_luma_profile_weights
3248
+
3249
+
3082
3250
  sw = None
3083
3251
  if doc is None:
3084
3252
  sw = self.mdi.activeSubWindow()
@@ -3097,70 +3265,19 @@ class AstroSuiteProMainWindow(
3097
3265
  QMessageBox.information(self, "Extract Luminance", "Luminance extraction requires an RGB image.")
3098
3266
  return
3099
3267
 
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
3268
  p = dict(preset or {})
3110
- method = str(
3269
+ mode = str(
3111
3270
  p.get("mode",
3112
3271
  p.get("method",
3113
3272
  p.get("luma_method",
3114
3273
  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
3274
+ ).strip()
3275
+
3276
+ resolved_method, w, profile_name = resolve_luma_profile_weights(mode)
3277
+
3278
+ y = compute_luminance(img, method=resolved_method, weights=w)
3279
+
3280
+ # ---- metadata & title ----
3164
3281
  base_meta = {}
3165
3282
  try:
3166
3283
  base_meta = dict(getattr(doc, "metadata", {}) or {})
@@ -3172,13 +3289,16 @@ class AstroSuiteProMainWindow(
3172
3289
  "source": "ExtractLuminance",
3173
3290
  "is_mono": True,
3174
3291
  "bit_depth": "32f",
3175
- "luma_method": method,
3292
+ "luma_method": resolved_method,
3176
3293
  }
3177
- if luma_weights is not None:
3178
- meta["luma_weights"] = np.asarray(luma_weights, dtype=np.float32).tolist()
3294
+ if w is not None:
3295
+ meta["luma_weights"] = np.asarray(w, dtype=np.float32).tolist()
3296
+ if profile_name:
3297
+ meta["luma_profile"] = str(profile_name)
3179
3298
 
3180
3299
  base_title = sw.windowTitle() if sw else (getattr(doc, "title", getattr(doc, "name", "")) or "Untitled")
3181
- title = f"{base_title} -- Luminance"
3300
+ suffix = f"{profile_name}" if profile_name else resolved_method
3301
+ title = f"{base_title} -- Luminance ({suffix})"
3182
3302
 
3183
3303
  dm = getattr(self, "docman", None)
3184
3304
  if dm is None:
@@ -3206,19 +3326,19 @@ class AstroSuiteProMainWindow(
3206
3326
  except Exception:
3207
3327
  pass
3208
3328
 
3209
- # ðŸ" Remember for Replay (optional but consistent)
3210
3329
  try:
3211
3330
  remember = getattr(self, "remember_last_headless_command", None) or getattr(self, "_remember_last_headless_command", None)
3212
3331
  if callable(remember):
3213
- remember("extract_luminance", {"mode": method}, description="Extract Luminance")
3332
+ remember("extract_luminance", {"mode": mode}, description="Extract Luminance")
3214
3333
  except Exception:
3215
3334
  pass
3216
3335
 
3217
3336
  if hasattr(self, "_log"):
3218
- self._log(f"Extract Luminance ({method}) -> new mono document created.")
3337
+ self._log(f"Extract Luminance ({suffix}) -> new mono document created.")
3219
3338
 
3220
3339
  return new_doc
3221
3340
 
3341
+
3222
3342
  def _subwindow_docs(self):
3223
3343
  docs = []
3224
3344
  for sw in self.mdi.subWindowList():
@@ -3788,6 +3908,19 @@ class AstroSuiteProMainWindow(
3788
3908
 
3789
3909
  dlg.show()
3790
3910
 
3911
+ def _open_narrowband_normalization_tool(self):
3912
+ # Correct module import
3913
+ from setiastro.saspro.narrowband_normalization import NarrowbandNormalization
3914
+
3915
+ w = NarrowbandNormalization(doc_manager=self.docman, parent=self)
3916
+ w.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
3917
+ w.setWindowTitle("Narrowband Normalization")
3918
+ try:
3919
+ w.setWindowIcon(QIcon(narrowbandnormalization_path))
3920
+ except Exception:
3921
+ pass
3922
+ w.show()
3923
+
3791
3924
  def _open_ppp_tool(self):
3792
3925
  from setiastro.saspro.perfect_palette_picker import PerfectPalettePicker
3793
3926
  w = PerfectPalettePicker(doc_manager=self.docman) # parent gives access to _spawn_subwindow_for
@@ -4139,6 +4272,14 @@ class AstroSuiteProMainWindow(
4139
4272
  dlg.setWindowIcon(QIcon(livestacking_path))
4140
4273
  dlg.show()
4141
4274
 
4275
+ def _open_planetary_stacker(self):
4276
+ # import locally to avoid startup cost / circular imports
4277
+ from setiastro.saspro.serviewer import SERViewer
4278
+ dlg = SERViewer(self)
4279
+ dlg.setWindowFlag(Qt.WindowType.Window, True)
4280
+ dlg.setWindowIcon(QIcon(planetarystacker_path))
4281
+ dlg.show()
4282
+
4142
4283
  def _open_stacking_suite(self):
4143
4284
  # Reuse if we already have one
4144
4285
  dlg = getattr(self, "_stacking_suite", None)
@@ -4182,6 +4323,202 @@ class AstroSuiteProMainWindow(
4182
4323
  except Exception:
4183
4324
  pass
4184
4325
 
4326
+ def _convert_mono_to_rgb_active(self):
4327
+ """
4328
+ Convert active mono document to RGB by duplicating the channel.
4329
+ Updates the active document in-place (undoable).
4330
+ """
4331
+ dm = getattr(self, "docman", None)
4332
+ if dm is None:
4333
+ return
4334
+
4335
+ try:
4336
+ doc = dm.get_active_document()
4337
+ except Exception:
4338
+ doc = None
4339
+ if doc is None:
4340
+ return
4341
+
4342
+ img = getattr(doc, "image", None)
4343
+ if img is None:
4344
+ return
4345
+
4346
+ import numpy as np
4347
+
4348
+ x = np.asarray(img)
4349
+
4350
+ # Already RGB?
4351
+ if x.ndim == 3 and x.shape[-1] == 3:
4352
+ try:
4353
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4354
+ except Exception:
4355
+ name = "Active"
4356
+ if hasattr(self, "_log"):
4357
+ self._log(f"Mono → RGB: '{name}' is already RGB (shape={getattr(x,'shape',None)}).")
4358
+ return
4359
+
4360
+ # Determine what we're converting FROM
4361
+ src_desc = "unknown"
4362
+ if x.ndim == 2:
4363
+ mono = x
4364
+ src_desc = "mono (H×W)"
4365
+ elif x.ndim == 3 and x.shape[-1] == 1:
4366
+ mono = x[..., 0]
4367
+ src_desc = "mono (H×W×1)"
4368
+ else:
4369
+ # Unknown format (e.g., multi-channel >3)
4370
+ try:
4371
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4372
+ except Exception:
4373
+ name = "Active"
4374
+ if hasattr(self, "_log"):
4375
+ self._log(f"Mono → RGB: '{name}' not convertible (shape={getattr(x,'shape',None)}).")
4376
+ return
4377
+
4378
+ before_shape = getattr(x, "shape", None)
4379
+ before_dtype = getattr(x, "dtype", None)
4380
+
4381
+ mono = mono.astype(np.float32, copy=False)
4382
+ rgb = np.stack([mono, mono, mono], axis=-1)
4383
+
4384
+ # metadata: preserve existing, but force "not mono"
4385
+ try:
4386
+ md = dict(getattr(doc, "metadata", None) or {})
4387
+ except Exception:
4388
+ md = {}
4389
+
4390
+ md["is_mono"] = False
4391
+ md["color_model"] = "RGB"
4392
+ md["channels"] = 3
4393
+ md["source"] = (md.get("source") or "Edit")
4394
+
4395
+ # If you track op params for history explorer
4396
+ md["__op_params__"] = {
4397
+ "op": "mono_to_rgb",
4398
+ "mode": "triplicate",
4399
+ "from": str(src_desc),
4400
+ "from_shape": tuple(before_shape) if before_shape is not None else None,
4401
+ "to_shape": tuple(rgb.shape),
4402
+ }
4403
+
4404
+ # name for logging
4405
+ try:
4406
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4407
+ except Exception:
4408
+ name = "Active"
4409
+
4410
+ try:
4411
+ dm.update_active_document(
4412
+ rgb,
4413
+ metadata=md,
4414
+ step_name="Mono → RGB",
4415
+ doc=doc, # explicit is safer
4416
+ )
4417
+
4418
+ if hasattr(self, "_log"):
4419
+ self._log(
4420
+ f"Mono → RGB: '{name}' converted {src_desc} "
4421
+ f"(shape={before_shape}, dtype={before_dtype}) → "
4422
+ f"RGB (shape={rgb.shape}, dtype={rgb.dtype})."
4423
+ )
4424
+
4425
+ except Exception:
4426
+ import traceback
4427
+ try:
4428
+ from PyQt6.QtWidgets import QMessageBox
4429
+ QMessageBox.critical(self, "Mono → RGB", traceback.format_exc())
4430
+ except Exception:
4431
+ pass
4432
+
4433
+ def _swap_rb_active(self):
4434
+ """
4435
+ Swap R and B channels in the active RGB document (undoable).
4436
+ Intended for debayer/channel-order mismatches.
4437
+ """
4438
+ dm = getattr(self, "docman", None)
4439
+ if dm is None:
4440
+ return
4441
+
4442
+ try:
4443
+ doc = dm.get_active_document()
4444
+ except Exception:
4445
+ doc = None
4446
+ if doc is None:
4447
+ return
4448
+
4449
+ img = getattr(doc, "image", None)
4450
+ if img is None:
4451
+ return
4452
+
4453
+ import numpy as np
4454
+ x = np.asarray(img)
4455
+
4456
+ # Must be RGB
4457
+ if not (x.ndim == 3 and x.shape[-1] == 3):
4458
+ try:
4459
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4460
+ except Exception:
4461
+ name = "Active"
4462
+
4463
+ if hasattr(self, "_log"):
4464
+ self._log(f"Swap R/B: '{name}' is not RGB (shape={getattr(x,'shape',None)}).")
4465
+ return
4466
+
4467
+ before_shape = x.shape
4468
+ before_dtype = x.dtype
4469
+
4470
+ # swap channels without changing dtype
4471
+ # (copy is safest so we don't mutate shared views)
4472
+ out = x.copy()
4473
+ out[..., 0], out[..., 2] = x[..., 2], x[..., 0]
4474
+
4475
+ # metadata: preserve existing, but annotate operation
4476
+ try:
4477
+ md = dict(getattr(doc, "metadata", None) or {})
4478
+ except Exception:
4479
+ md = {}
4480
+
4481
+ md["color_model"] = md.get("color_model", "RGB")
4482
+ md["channels"] = 3
4483
+ md["is_mono"] = False
4484
+ md["source"] = (md.get("source") or "Edit")
4485
+
4486
+ # If you track op params for history explorer
4487
+ md["__op_params__"] = {
4488
+ "op": "swap_rb",
4489
+ "from_shape": tuple(before_shape),
4490
+ "to_shape": tuple(out.shape),
4491
+ "dtype": str(before_dtype),
4492
+ }
4493
+
4494
+ try:
4495
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4496
+ except Exception:
4497
+ name = "Active"
4498
+
4499
+ try:
4500
+ dm.update_active_document(
4501
+ out,
4502
+ metadata=md,
4503
+ step_name="Swap R ↔ B",
4504
+ doc=doc,
4505
+ )
4506
+
4507
+ if hasattr(self, "_log"):
4508
+ self._log(
4509
+ f"Swap R/B: '{name}' swapped channels "
4510
+ f"(shape={before_shape}, dtype={before_dtype})."
4511
+ )
4512
+
4513
+ except Exception:
4514
+ import traceback
4515
+ try:
4516
+ from PyQt6.QtWidgets import QMessageBox
4517
+ QMessageBox.critical(self, "Swap R/B", traceback.format_exc())
4518
+ except Exception:
4519
+ pass
4520
+
4521
+
4185
4522
  def _on_stackingsuite_relaunch(self, old_dir: str, new_dir: str):
4186
4523
  # Optional: respond to dialog's relaunch request
4187
4524
  try:
@@ -4282,9 +4619,16 @@ class AstroSuiteProMainWindow(
4282
4619
  # Create a callback to set the image back to the document
4283
4620
  def set_image_callback(image_data, step_name):
4284
4621
  """Apply the result image back to the active document."""
4285
- if active_doc and hasattr(active_doc, "set_image"):
4622
+ if active_doc and hasattr(active_doc, "apply_edit"):
4286
4623
  print(f"[AstroSpike] Setting image back to document, shape: {image_data.shape}")
4287
- # Pass metadata as empty dict and step_name separately
4624
+ # Use apply_edit for proper undo/redo integration
4625
+ meta = {
4626
+ "step_name": step_name,
4627
+ "astrospike": True
4628
+ }
4629
+ active_doc.apply_edit(image_data.astype(np.float32, copy=False), metadata=meta, step_name=step_name)
4630
+ elif active_doc and hasattr(active_doc, "set_image"):
4631
+ print(f"[AstroSpike] Setting image via set_image, shape: {image_data.shape}")
4288
4632
  active_doc.set_image(image_data, metadata={}, step_name=step_name)
4289
4633
  elif active_doc and hasattr(active_doc, "image"):
4290
4634
  print(f"[AstroSpike] Setting image directly, shape: {image_data.shape}")
@@ -4469,6 +4813,48 @@ class AstroSuiteProMainWindow(
4469
4813
  dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
4470
4814
  dlg.show()
4471
4815
 
4816
+ def _open_acv_exporter(self):
4817
+ from setiastro.saspro.acv_exporter import AstroCatalogueViewerExporterDialog
4818
+
4819
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
4820
+ if dm is None:
4821
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No document manager available.")
4822
+ return
4823
+
4824
+ sw = self.mdi.activeSubWindow()
4825
+ if not sw:
4826
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "Open an image first.")
4827
+ return
4828
+
4829
+ view = sw.widget()
4830
+ active_doc = None
4831
+
4832
+ # Prefer ROI-aware resolution
4833
+ try:
4834
+ if hasattr(dm, "get_document_for_view"):
4835
+ active_doc = dm.get_document_for_view(view)
4836
+ except Exception:
4837
+ active_doc = None
4838
+
4839
+ # Fallback
4840
+ if active_doc is None:
4841
+ try:
4842
+ active_doc = getattr(view, "document", None)
4843
+ except Exception:
4844
+ active_doc = None
4845
+
4846
+ if active_doc is None or getattr(active_doc, "image", None) is None:
4847
+ QMessageBox.information(self, "Astro Catalogue Viewer Exporter", "No active image.")
4848
+ return
4849
+
4850
+ dlg = AstroCatalogueViewerExporterDialog(self, dm, active_doc)
4851
+ try:
4852
+ dlg.setWindowIcon(QIcon(acv_icon_path))
4853
+ except Exception:
4854
+ pass
4855
+ dlg.show()
4856
+
4857
+
4472
4858
  def _open_linear_fit(self):
4473
4859
  from setiastro.saspro.linear_fit import LinearFitDialog
4474
4860
  dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
@@ -4585,12 +4971,6 @@ class AstroSuiteProMainWindow(
4585
4971
  if max_len and len(hist) > max_len:
4586
4972
  del hist[:-max_len]
4587
4973
 
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
4974
 
4595
4975
 
4596
4976
  def _remember_last_headless_command(self, command_id: str, preset: dict | None = None, description: str = ""):
@@ -4646,17 +5026,6 @@ class AstroSuiteProMainWindow(
4646
5026
  """
4647
5027
  payload = getattr(self, "_last_headless_command", None)
4648
5028
 
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
5029
 
4661
5030
  if not payload:
4662
5031
  QMessageBox.information(
@@ -4688,17 +5057,6 @@ class AstroSuiteProMainWindow(
4688
5057
  """
4689
5058
  payload = getattr(self, "_last_headless_command", None) or {}
4690
5059
 
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
5060
 
4703
5061
  if not payload:
4704
5062
  QMessageBox.information(
@@ -4724,17 +5082,6 @@ class AstroSuiteProMainWindow(
4724
5082
  QMessageBox.information(self, "Replay Last Action", "No base image to apply the action to.")
4725
5083
  return
4726
5084
 
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
5085
 
4739
5086
  # ---- Extract cid + preset from payload (support both old + new schemas) ----
4740
5087
  cid_raw = payload.get("command_id")
@@ -7645,12 +7992,33 @@ class AstroSuiteProMainWindow(
7645
7992
  pass
7646
7993
 
7647
7994
  def _pretty_title(self, doc, *, linked: bool | None = None) -> str:
7648
- name = getattr(doc, "display_name", lambda: "Untitled")()
7649
- name = name.replace("[LINK] ", "").strip()
7995
+ md = (getattr(doc, "metadata", {}) or {})
7996
+
7997
+ # ✅ 1) Prefer explicit display_name (what duplicate/rename intends)
7998
+ name = (md.get("display_name") or "").strip()
7999
+
8000
+ # 2) Fallback to file_path (but only if display_name is missing)
8001
+ if not name:
8002
+ fp = (md.get("file_path") or "").strip()
8003
+ if fp:
8004
+ name = os.path.splitext(os.path.basename(fp))[0]
8005
+
8006
+ # 3) Fallback to doc.display_name()
8007
+ if not name:
8008
+ name = getattr(doc, "display_name", lambda: "Untitled")()
8009
+ name = (name or "Untitled").replace("[LINK] ", "").strip()
8010
+
8011
+ # If it looks like a filename, drop extension
8012
+ base, ext = os.path.splitext(name)
8013
+ if ext and len(ext) <= 10:
8014
+ name = base
8015
+
8016
+ # linked marker logic
7650
8017
  if linked is None:
7651
- linked = hasattr(doc, "_parent_doc") # ROI proxy -> linked
8018
+ linked = hasattr(doc, "_parent_doc")
7652
8019
  return f"[LINK] {name}" if linked else name
7653
8020
 
8021
+
7654
8022
  def _build_subwindow_title_for_doc(self, doc) -> str:
7655
8023
  """
7656
8024
  Build a unique, human-friendly title for a QMdiSubWindow
@@ -7718,6 +8086,71 @@ class AstroSuiteProMainWindow(
7718
8086
  return cand
7719
8087
  n += 1
7720
8088
 
8089
+
8090
+ def _doc_window_title(self, doc) -> str:
8091
+ md = getattr(doc, "metadata", {}) or {}
8092
+
8093
+ t = (md.get("display_name") or "").strip()
8094
+ if not t:
8095
+ try:
8096
+ t = (doc.display_name() or "").strip()
8097
+ except Exception:
8098
+ t = ""
8099
+
8100
+ if not t:
8101
+ fp = (md.get("file_path") or "").strip()
8102
+ if fp:
8103
+ t = os.path.splitext(os.path.basename(fp))[0] # ✅ strip ext here too
8104
+
8105
+ t = t or "Untitled"
8106
+
8107
+ # strip glyphs etc
8108
+ try:
8109
+ t = _strip_ui_decorations(t)
8110
+ except Exception:
8111
+ pass
8112
+
8113
+ # ✅ ALWAYS strip filename-like extension at the very end
8114
+ t = _strip_filename_ext(t)
8115
+
8116
+ return t
8117
+
8118
+ def _mdi_begin_open_batch(self, mode: str = "cascade"):
8119
+ self._mdi_open_batch += 1
8120
+ self._mdi_place_mode = mode or "cascade"
8121
+ self._mdi_next_pos = None
8122
+
8123
+ def _mdi_end_open_batch(self):
8124
+ self._mdi_open_batch = max(0, self._mdi_open_batch - 1)
8125
+ if self._mdi_open_batch == 0:
8126
+ self._mdi_next_pos = None
8127
+
8128
+ def _mdi_compute_initial_pos(self) -> QPoint:
8129
+ area = (self.mdi.viewport().geometry() if self.mdi.viewport() else self.mdi.contentsRect())
8130
+ # Put first window a bit inset so titlebars don’t clip
8131
+ return QPoint(area.left() + 18, area.top() + 18)
8132
+
8133
+ def _mdi_place_subwindow(self, sw, target_w: int, target_h: int):
8134
+ """Deterministic placement. Uses a stable cursor during batch opens."""
8135
+ vp = self.mdi.viewport()
8136
+ area = vp.geometry() if vp else self.mdi.contentsRect()
8137
+
8138
+ if self._mdi_next_pos is None:
8139
+ self._mdi_next_pos = self._mdi_compute_initial_pos()
8140
+
8141
+ x = self._mdi_next_pos.x()
8142
+ y = self._mdi_next_pos.y()
8143
+
8144
+ # keep inside viewport; reset when we hit edge
8145
+ if (x + target_w > area.right() - 10) or (y + 40 > area.bottom() - 10):
8146
+ x = area.left() + 18
8147
+ y = area.top() + 18
8148
+
8149
+ sw.move(x, y)
8150
+
8151
+ # advance cursor
8152
+ step = int(self._mdi_cascade_step)
8153
+ self._mdi_next_pos = QPoint(x + step, y + step)
7721
8154
 
7722
8155
  def _spawn_subwindow_for(self, doc, *, force_new: bool = False):
7723
8156
  """
@@ -7821,10 +8254,7 @@ class AstroSuiteProMainWindow(
7821
8254
  if replay_sig is not None:
7822
8255
  try:
7823
8256
  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)}")
8257
+
7828
8258
  except Exception as e:
7829
8259
  try:
7830
8260
  self._log(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
@@ -7832,7 +8262,8 @@ class AstroSuiteProMainWindow(
7832
8262
  print(f"[Replay] FAILED to connect {sig_name_used} for view id={id(view)}: {e}")
7833
8263
 
7834
8264
  self._hook_preview_awareness(view)
7835
- base_title = self._pretty_title(doc, linked=False)
8265
+
8266
+ base_title = self._doc_window_title(doc) # ✅ use metadata display_name
7836
8267
  final_title = self._unique_window_title(base_title)
7837
8268
 
7838
8269
  # -- 6) Add subwindow and set chrome
@@ -7856,7 +8287,8 @@ class AstroSuiteProMainWindow(
7856
8287
  # We target ~60% of the viewport height, clamped to sane bounds.
7857
8288
  # -------------------------------------------------------------------------
7858
8289
  vp = self.mdi.viewport()
7859
- area = vp.rect() if vp else self.mdi.rect()
8290
+ # Use viewport geometry in MDI coordinates (NOT viewport-local rect)
8291
+ area = vp.geometry() if vp else self.mdi.contentsRect()
7860
8292
 
7861
8293
  # Determine aspect ratio
7862
8294
  img_w = img_h = None
@@ -7893,54 +8325,22 @@ class AstroSuiteProMainWindow(
7893
8325
  target_h = max(200, target_h)
7894
8326
 
7895
8327
  sw.resize(target_w, target_h)
7896
- sw.showNormal() # CRITICAL: clears any "maximized" flag from previous active window
8328
+ sw.showNormal() # clears any "maximized" flag from previous active window
7897
8329
 
7898
- # -------------------------------------------------------------------------
7899
- # Smart Cascade: Position relative to the *currently active* window
7900
- # (before we make the new one active).
7901
- # -------------------------------------------------------------------------
7902
- new_x, new_y = 0, 0
7903
-
7904
- # Get dominant/active window *before* we activate the new one
7905
- active = self.mdi.activeSubWindow()
7906
- if active and active.isVisible() and not (active.windowState() & Qt.WindowState.WindowMinimized):
7907
- # Cascade from the active window
7908
- geo = active.geometry()
7909
- new_x = geo.x() + 30
7910
- new_y = geo.y() + 30
7911
- else:
7912
- # Fallback: try to find the "last added" visible window to cascade from
7913
- # (useful if active is None but windows exist)
7914
- try:
7915
- subs = [s for s in self.mdi.subWindowList() if s.isVisible() and s is not sw]
7916
- if subs:
7917
- # simplistic "last created" might be at end of list
7918
- last = subs[-1]
7919
- geo = last.geometry()
7920
- new_x = geo.x() + 30
7921
- new_y = geo.y() + 30
8330
+ # Deterministic placement (batch-aware)
8331
+ try:
8332
+ self._mdi_place_subwindow(sw, target_w, target_h)
8333
+ except Exception:
8334
+ # absolute fallback: top-left-ish
8335
+ try:
8336
+ sw.move(area.left() + 18, area.top() + 18)
7922
8337
  except Exception:
7923
8338
  pass
7924
8339
 
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)
7934
-
7935
- sw.move(new_x, new_y)
7936
-
7937
- # ❌ removed the "fill MDI viewport" block - we *don't* want full-monitor first window
7938
-
7939
8340
  # Show / activate
7940
8341
  sw.show()
7941
8342
  sw.raise_()
7942
8343
  self.mdi.setActiveSubWindow(sw)
7943
- # (no second setWindowTitle() here)
7944
8344
 
7945
8345
  # Optional minimize/restore interceptor
7946
8346
  if hasattr(self, "_minimize_interceptor"):
@@ -8025,6 +8425,11 @@ class AstroSuiteProMainWindow(
8025
8425
  except Exception:
8026
8426
  pass
8027
8427
 
8428
+ try:
8429
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8430
+ except Exception:
8431
+ pass
8432
+
8028
8433
  # -- 11) If this is the first window and it's an image, mimic "Cascade Views"
8029
8434
  try:
8030
8435
  if first_window and not is_table:
@@ -8119,25 +8524,57 @@ class AstroSuiteProMainWindow(
8119
8524
  "autostretch_target": float(getattr(source_view, "autostretch_target", 0.25)),
8120
8525
  }
8121
8526
 
8122
- # 2) New name (strip UI decorations if any)
8123
- base_name = ""
8527
+ # 2) New name (normalized: NO decorators like 🔗■●◆▲▪▫•◼◻◾◽)
8124
8528
  try:
8125
- base_name = base_doc.display_name() or "Untitled"
8529
+ base_name = self._doc_window_title(base_doc) # might include decorations
8126
8530
  except Exception:
8127
8531
  base_name = "Untitled"
8128
8532
 
8533
+ # Normalize it so uniqueness checks don't miss decorated titles
8129
8534
  try:
8130
- base_name = _strip_ui_decorations(base_name)
8535
+ base_name = normalize_doc_title(base_name)
8536
+ except Exception:
8537
+ base_name = (base_name or "Untitled").strip()
8538
+
8539
+ # Build a set of existing document names (normalized)
8540
+ existing = set()
8541
+ try:
8542
+ dm = getattr(self, "doc_manager", None) or getattr(self, "docman", None)
8543
+ docs = []
8544
+
8545
+ # Prefer an official accessor if you have one
8546
+ if dm is not None:
8547
+ if hasattr(dm, "documents"):
8548
+ docs = list(dm.documents())
8549
+ elif hasattr(dm, "_docs"):
8550
+ docs = list(dm._docs)
8551
+
8552
+ for d in docs:
8553
+ try:
8554
+ dn = ""
8555
+ md = getattr(d, "metadata", {}) or {}
8556
+ dn = (md.get("display_name") or "").strip() or (d.display_name() or "").strip()
8557
+ dn = normalize_doc_title(dn)
8558
+ if dn:
8559
+ existing.add(dn)
8560
+ except Exception:
8561
+ pass
8131
8562
  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: "):]
8563
+ pass
8564
+
8565
+ # Pick a unique duplicate name: base_duplicate, base_duplicate2, ...
8566
+ candidate = f"{base_name}_duplicate"
8567
+ if candidate in existing:
8568
+ n = 2
8569
+ while True:
8570
+ cand = f"{base_name}_duplicate{n}"
8571
+ if cand not in existing:
8572
+ candidate = cand
8573
+ break
8574
+ n += 1
8137
8575
 
8138
8576
  # 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")
8577
+ new_doc = self.docman.duplicate_document(base_doc, new_name=candidate)
8141
8578
  print(f" Duplicated document ID {id(base_doc)} -> {id(new_doc)}")
8142
8579
 
8143
8580
  # 4) Ensure the duplicate starts mask-free (so we don't inherit mask UI state)
@@ -8287,26 +8724,21 @@ class AstroSuiteProMainWindow(
8287
8724
 
8288
8725
 
8289
8726
  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)
8727
+ doc = item.data(0, Qt.ItemDataRole.UserRole)
8728
+ if doc is None:
8729
+ return
8730
+ # you already have logic for this; typically:
8731
+ sw = self._find_subwindow_for_doc(doc)
8732
+ if sw:
8733
+ self.mdi.setActiveSubWindow(sw)
8734
+ sw.show()
8735
+ sw.raise_()
8736
+ return
8737
+ # else open it (if your app supports opening closed docs, otherwise no-op)
8738
+ try:
8739
+ self._open_subwindow_for_added_doc(doc)
8740
+ except Exception:
8741
+ pass
8310
8742
 
8311
8743
  def _set_linked_stretch_from_action(self, checked: bool):
8312
8744
  # persist as the default for *new* views
@@ -8432,7 +8864,10 @@ class AstroSuiteProMainWindow(
8432
8864
  self._refresh_mask_action_states()
8433
8865
  except Exception:
8434
8866
  pass
8435
-
8867
+ try:
8868
+ self._fix_mdi_titlebar_emboss("#dcdcdc" if self._theme_mode()=="dark" else "#f0f0f0")
8869
+ except Exception:
8870
+ pass
8436
8871
 
8437
8872
  def _sync_docman_active(self, doc):
8438
8873
  dm = self.doc_manager
@@ -8686,6 +9121,13 @@ class AstroSuiteProMainWindow(
8686
9121
 
8687
9122
  super().keyPressEvent(event)
8688
9123
 
9124
+ def _open_texture_clarity(self):
9125
+ try:
9126
+ from setiastro.saspro.texture_clarity import open_texture_clarity_dialog
9127
+ open_texture_clarity_dialog(self)
9128
+ except Exception as e:
9129
+ print(f"Error opening Texture & Clarity: {e}")
9130
+
8689
9131
  def _update_usage_stats(self):
8690
9132
  try:
8691
9133
  now = time.time()