setiastrosuitepro 1.6.7__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.

Potentially problematic release.


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

Files changed (37) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/cosmic.svg +40 -0
  3. setiastro/images/cosmicsat.svg +24 -0
  4. setiastro/images/graxpert.svg +19 -0
  5. setiastro/images/linearfit.svg +32 -0
  6. setiastro/images/pixelmath.svg +42 -0
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/add_stars.py +29 -5
  9. setiastro/saspro/blink_comparator_pro.py +74 -24
  10. setiastro/saspro/cosmicclarity.py +125 -18
  11. setiastro/saspro/crop_dialog_pro.py +96 -2
  12. setiastro/saspro/curve_editor_pro.py +60 -39
  13. setiastro/saspro/frequency_separation.py +1159 -208
  14. setiastro/saspro/gui/main_window.py +131 -31
  15. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  16. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  17. setiastro/saspro/imageops/stretch.py +531 -62
  18. setiastro/saspro/layers.py +13 -9
  19. setiastro/saspro/layers_dock.py +183 -3
  20. setiastro/saspro/legacy/numba_utils.py +43 -0
  21. setiastro/saspro/live_stacking.py +158 -70
  22. setiastro/saspro/multiscale_decomp.py +47 -12
  23. setiastro/saspro/numba_utils.py +72 -2
  24. setiastro/saspro/ops/commands.py +18 -18
  25. setiastro/saspro/shortcuts.py +122 -12
  26. setiastro/saspro/signature_insert.py +688 -33
  27. setiastro/saspro/stacking_suite.py +523 -316
  28. setiastro/saspro/stat_stretch.py +688 -130
  29. setiastro/saspro/subwindow.py +302 -71
  30. setiastro/saspro/widgets/common_utilities.py +28 -21
  31. setiastro/saspro/widgets/resource_monitor.py +7 -7
  32. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
  33. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
  34. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
  35. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
  36. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
  37. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
@@ -9,6 +9,7 @@ import json
9
9
  import math
10
10
  import weakref
11
11
  import os
12
+ import re
12
13
  try:
13
14
  from PyQt6.QtCore import QSignalBlocker
14
15
  except Exception:
@@ -136,6 +137,22 @@ DECORATION_PREFIXES = (
136
137
  "Active View: ", # legacy
137
138
  )
138
139
 
140
+ _GLYPH_RE = re.compile(f"[{re.escape(GLYPHS)}]")
141
+
142
+ def _strip_ui_decorations(title: str) -> str:
143
+ if not title:
144
+ return ""
145
+ s = str(title).strip()
146
+
147
+ # Remove common prefix tag(s)
148
+ s = s.replace("[LINK]", "").strip()
149
+
150
+ # Remove glyphs anywhere (often used as status markers in titles)
151
+ s = _GLYPH_RE.sub("", s)
152
+
153
+ # Collapse repeated whitespace
154
+ s = re.sub(r"\s+", " ", s).strip()
155
+ return s
139
156
 
140
157
  from astropy.wcs import WCS as _AstroWCS
141
158
  from astropy.io.fits import Header as _FitsHeader
@@ -332,6 +349,15 @@ def _dnd_dbg_dump_state(tag: str, state: dict):
332
349
 
333
350
  _DEBUG_DND_DUP = False
334
351
 
352
+ def _strip_ext_if_filename(s: str) -> str:
353
+ s = (s or "").strip()
354
+ if not s:
355
+ return s
356
+ base, ext = os.path.splitext(s)
357
+ if ext and len(ext) <= 10:
358
+ return base
359
+ return s
360
+
335
361
  class ImageSubWindow(QWidget):
336
362
  aboutToClose = pyqtSignal(object)
337
363
  autostretchChanged = pyqtSignal(bool)
@@ -488,6 +514,32 @@ class ImageSubWindow(QWidget):
488
514
  row.addWidget(self._btn_wcs, 0, Qt.AlignmentFlag.AlignLeft)
489
515
  # ─────────────────────────────────────────────────────────────────
490
516
 
517
+ # ---- Inline view title (shown when the MDI subwindow is maximized) ----
518
+ self._inline_title = QLabel(self)
519
+ self._inline_title.setText("")
520
+ self._inline_title.setToolTip(self.tr("Active view"))
521
+ self._inline_title.setVisible(False)
522
+ self._inline_title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
523
+ self._inline_title.setStyleSheet("""
524
+ QLabel {
525
+ padding-left: 8px;
526
+ padding-right: 6px;
527
+ font-size: 11px;
528
+ color: rgba(255,255,255,0.80);
529
+ }
530
+ """)
531
+ self._inline_title.setSizePolicy(
532
+ self._inline_title.sizePolicy().horizontalPolicy(),
533
+ self._inline_title.sizePolicy().verticalPolicy(),
534
+ )
535
+
536
+ # Push everything after this to the far right
537
+ row.addStretch(1)
538
+ row.addWidget(self._inline_title, 0, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
539
+
540
+ # (optional) tiny spacing to the edge
541
+ row.addSpacing(6)
542
+
491
543
  row.addStretch(1)
492
544
  lyt.addLayout(row)
493
545
 
@@ -568,6 +620,9 @@ class ImageSubWindow(QWidget):
568
620
  self._history_doc = None
569
621
  self._install_history_watchers()
570
622
 
623
+ QTimer.singleShot(0, self._install_mdi_state_watch)
624
+ QTimer.singleShot(0, self._update_inline_title_and_buttons)
625
+
571
626
  # ----- link drag payload -----
572
627
  def _start_link_drag(self):
573
628
  """
@@ -699,7 +754,60 @@ class ImageSubWindow(QWidget):
699
754
  except Exception:
700
755
  pass
701
756
 
702
- #------ Replay helpers------
757
+ # ------------------------------------------------------------
758
+ # MDI maximize handling: show inline title + avoid duplicate buttons
759
+ # ------------------------------------------------------------
760
+ def _mdi_subwindow(self) -> QMdiSubWindow | None:
761
+ w = self.parentWidget()
762
+ while w is not None and not isinstance(w, QMdiSubWindow):
763
+ w = w.parentWidget()
764
+ return w
765
+
766
+ def _install_mdi_state_watch(self):
767
+ sw = self._mdi_subwindow()
768
+ if sw is None:
769
+ return
770
+ # Watch maximize/restore changes on the hosting QMdiSubWindow
771
+ sw.installEventFilter(self)
772
+
773
+ def _is_mdi_maximized(self) -> bool:
774
+ sw = self._mdi_subwindow()
775
+ if sw is None:
776
+ return False
777
+ try:
778
+ return sw.isMaximized()
779
+ except Exception:
780
+ return False
781
+
782
+ def _set_mdi_minmax_buttons_enabled(self, enabled: bool):
783
+ return # leave Qt default buttons alone
784
+
785
+ def _current_view_title_for_inline(self) -> str:
786
+ # Prefer your already-pretty title (strip decorations if needed).
787
+ try:
788
+ # If you have _current_view_title_for_drag already, reuse it:
789
+ return self._current_view_title_for_drag()
790
+ except Exception:
791
+ pass
792
+ try:
793
+ return (self.windowTitle() or "").strip()
794
+ except Exception:
795
+ return ""
796
+
797
+ def _update_inline_title_and_buttons(self):
798
+ maximized = self._is_mdi_maximized()
799
+
800
+ # Show inline title only when maximized (optional)
801
+ try:
802
+ self._inline_title.setVisible(maximized)
803
+ if maximized:
804
+ self._inline_title.setText(self._current_view_title_for_inline() or "Untitled")
805
+ except Exception:
806
+ pass
807
+
808
+ # IMPORTANT: do NOT change QMdiSubWindow window flags.
809
+ # Leaving them alone restores the default Qt "double button" behavior.
810
+
703
811
  #------ Replay helpers------
704
812
  def _update_replay_button(self):
705
813
  """
@@ -829,13 +937,20 @@ class ImageSubWindow(QWidget):
829
937
  self._emit_view_transform()
830
938
 
831
939
  def set_view_transform(self, scale, hval, vval, from_link=False):
832
- # Avoid storms while we mutate scrollbars/scale
833
940
  self._suppress_link_emit = True
834
941
  try:
835
942
  scale = float(max(self._min_scale, min(scale, self._max_scale)))
836
- if abs(scale - self.scale) > 1e-9:
943
+
944
+ scale_changed = (abs(scale - self.scale) > 1e-9)
945
+ if scale_changed:
837
946
  self.scale = scale
838
- self._render(rebuild=False)
947
+ self._render(rebuild=False) # fast present for responsiveness
948
+
949
+ # ✅ NEW: schedule the final smooth redraw (same as main zoom path)
950
+ try:
951
+ self._request_zoom_redraw()
952
+ except Exception:
953
+ pass
839
954
 
840
955
  hbar = self.scroll.horizontalScrollBar()
841
956
  vbar = self.scroll.verticalScrollBar()
@@ -847,14 +962,14 @@ class ImageSubWindow(QWidget):
847
962
  finally:
848
963
  self._suppress_link_emit = False
849
964
 
850
- # IMPORTANT: if this came from a linked peer, do NOT broadcast again.
851
965
  if not from_link:
852
966
  self._schedule_emit_view_transform()
853
967
 
968
+
854
969
  def _on_toggle_wcs_grid(self, on: bool):
855
970
  self._show_wcs_grid = bool(on)
856
971
  QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
857
- self._render(rebuild=False) # repaint current frame
972
+ self._render(rebuild=True) # repaint current frame
858
973
 
859
974
 
860
975
 
@@ -1193,18 +1308,6 @@ class ImageSubWindow(QWidget):
1193
1308
  except Exception as e:
1194
1309
  print("[ImageSubWindow] apply_layer_stack error:", e)
1195
1310
 
1196
- # --- add to ImageSubWindow ---
1197
- def _collect_layer_docs(self):
1198
- docs = set()
1199
- for L in getattr(self, "_layers", []):
1200
- d = getattr(L, "src_doc", None)
1201
- if d is not None:
1202
- docs.add(d)
1203
- md = getattr(L, "mask_doc", None)
1204
- if md is not None:
1205
- docs.add(md)
1206
- return docs
1207
-
1208
1311
  def keyPressEvent(self, ev):
1209
1312
  if ev.key() == Qt.Key.Key_Space:
1210
1313
  # only the first time we enter probe mode
@@ -1326,51 +1429,116 @@ class ImageSubWindow(QWidget):
1326
1429
  except Exception as e:
1327
1430
  print("[ImageSubWindow] _on_layer_source_changed error:", e)
1328
1431
 
1432
+ def _collect_layer_docs(self):
1433
+ """
1434
+ Collect unique ImageDocument objects referenced by the layer stack:
1435
+ - layer src_doc (if doc-backed)
1436
+ - layer mask_doc (if any)
1437
+ Raster/baked layers may have src_doc=None; those are ignored.
1438
+ Returns a LIST in a stable order (bottom→top traversal order), de-duped.
1439
+ """
1440
+ out = []
1441
+ seen = set()
1442
+
1443
+ layers = getattr(self, "_layers", None) or []
1444
+ for L in layers:
1445
+ # 1) source doc (may be None for raster/baked layers)
1446
+ d = getattr(L, "src_doc", None)
1447
+ if d is not None:
1448
+ k = id(d)
1449
+ if k not in seen:
1450
+ seen.add(k)
1451
+ out.append(d)
1452
+
1453
+ # 2) mask doc (also may be None)
1454
+ md = getattr(L, "mask_doc", None)
1455
+ if md is not None:
1456
+ k = id(md)
1457
+ if k not in seen:
1458
+ seen.add(k)
1459
+ out.append(md)
1460
+
1461
+ return out
1462
+
1463
+
1329
1464
  def _reinstall_layer_watchers(self):
1465
+ """
1466
+ Reconnect layer source/mask document watchers to trigger live layer recomposite.
1467
+ Safe against:
1468
+ - raster/baked layers (src_doc=None)
1469
+ - deleted docs / partially-torn-down Qt objects
1470
+ - repeated calls
1471
+ """
1472
+ # Previous watchers
1473
+ olddocs = list(getattr(self, "_watched_docs", []) or [])
1474
+
1330
1475
  # Disconnect old
1331
- for d in list(self._watched_docs):
1476
+ for d in olddocs:
1332
1477
  try:
1478
+ # Doc may already be deleted or signal gone
1333
1479
  d.changed.disconnect(self._on_layer_source_changed)
1334
1480
  except Exception:
1335
1481
  pass
1336
- # Connect new
1482
+
1483
+ # Collect new
1337
1484
  newdocs = self._collect_layer_docs()
1485
+
1486
+ # Connect new
1338
1487
  for d in newdocs:
1339
1488
  try:
1340
1489
  d.changed.connect(self._on_layer_source_changed)
1341
1490
  except Exception:
1342
1491
  pass
1492
+
1493
+ # Store as list (stable)
1343
1494
  self._watched_docs = newdocs
1344
1495
 
1345
1496
 
1497
+
1346
1498
  def toggle_mask_overlay(self):
1347
1499
  self.show_mask_overlay = not self.show_mask_overlay
1348
1500
  self._render(rebuild=True)
1349
1501
 
1350
1502
  def _rebuild_title(self, *, base: str | None = None):
1351
1503
  sub = self._mdi_subwindow()
1352
- if not sub: return
1504
+ if not sub:
1505
+ return
1506
+
1353
1507
  if base is None:
1354
1508
  base = self._effective_title() or self.tr("Untitled")
1355
1509
 
1356
- # strip any carried-over glyphs (🔗, ■, Active View: ”) from overrides/doc names
1510
+ # Strip badges (🔗, ■, etc) AND "Active View:" prefix
1357
1511
  core, _ = self._strip_decorations(base)
1358
1512
 
1359
- title = core
1513
+ # ALSO strip file extensions if it looks like a filename
1514
+ # (this prevents .tiff/.fit coming back via any fallback path)
1515
+ try:
1516
+ b, ext = os.path.splitext(core)
1517
+ if ext and len(ext) <= 10 and not core.endswith("..."):
1518
+ core = b
1519
+ except Exception:
1520
+ pass
1521
+
1522
+ # Build the displayed title with badges
1523
+ shown = core
1360
1524
  if getattr(self, "_link_badge_on", False):
1361
- title = f"{LINK_PREFIX}{title}"
1525
+ shown = f"{LINK_PREFIX}{shown}"
1362
1526
  if self._mask_dot_enabled:
1363
- title = f"{MASK_GLYPH} {title}"
1527
+ shown = f"{MASK_GLYPH} {shown}"
1528
+
1529
+ # Update chrome
1530
+ if shown != sub.windowTitle():
1531
+ sub.setWindowTitle(shown)
1532
+ sub.setToolTip(shown)
1533
+
1534
+ # IMPORTANT: emit ONLY the clean core (no badges, no extensions)
1535
+ if core != self._last_title_for_emit:
1536
+ self._last_title_for_emit = core
1537
+ try:
1538
+ self.viewTitleChanged.emit(self, core)
1539
+ except Exception:
1540
+ pass
1364
1541
 
1365
- if title != sub.windowTitle():
1366
- sub.setWindowTitle(title)
1367
- sub.setToolTip(title)
1368
- if title != self._last_title_for_emit:
1369
- self._last_title_for_emit = title
1370
- try: self.viewTitleChanged.emit(self, title)
1371
- except Exception as e:
1372
- import logging
1373
- logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1374
1542
 
1375
1543
 
1376
1544
  def _strip_decorations(self, title: str) -> tuple[str, bool]:
@@ -1399,21 +1567,7 @@ class ImageSubWindow(QWidget):
1399
1567
  def set_active_highlight(self, on: bool):
1400
1568
  self._is_active_flag = bool(on)
1401
1569
  return
1402
- sub = self._mdi_subwindow()
1403
- if not sub:
1404
- return
1405
-
1406
- core, had_glyph = self._strip_decorations(sub.windowTitle())
1407
-
1408
- if on and not getattr(self, "_suppress_active_once", False):
1409
- core = ACTIVE_PREFIX + core
1410
- self._suppress_active_once = False
1411
1570
 
1412
- # recompose: glyph (from flag), then active prefix, then base/core
1413
- if getattr(self, "_mask_dot_enabled", False):
1414
- core = "■ " + core
1415
- #sub.setWindowTitle(core)
1416
- sub.setToolTip(core)
1417
1571
 
1418
1572
  def _set_mask_highlight(self, on: bool):
1419
1573
  self._mask_dot_enabled = bool(on)
@@ -1521,15 +1675,53 @@ class ImageSubWindow(QWidget):
1521
1675
  def is_hard_autostretch(self) -> bool:
1522
1676
  return self.autostretch_profile == "hard"
1523
1677
 
1524
- def _mdi_subwindow(self) -> QMdiSubWindow | None:
1525
- w = self.parent()
1526
- while w is not None and not isinstance(w, QMdiSubWindow):
1527
- w = w.parent()
1528
- return w
1678
+ #def _mdi_subwindow(self) -> QMdiSubWindow | None:
1679
+ # w = self.parent()
1680
+ # while w is not None and not isinstance(w, QMdiSubWindow):
1681
+ # w = w.parent()
1682
+ # return w
1529
1683
 
1530
1684
  def _effective_title(self) -> str:
1531
- # Prefer a per-view override; otherwise doc display name
1532
- return self._view_title_override or self.document.display_name()
1685
+ """
1686
+ Returns the *core* title for this view (no UI badges like 🔗/■, and no file extension).
1687
+ Badges are added later by _rebuild_title().
1688
+ """
1689
+ # 1) Prefer per-view override if set
1690
+ t = (self._view_title_override or "").strip()
1691
+
1692
+ # 2) Else prefer metadata display_name (what duplicate/rename should set)
1693
+ if not t:
1694
+ try:
1695
+ md = getattr(self.document, "metadata", {}) or {}
1696
+ t = (md.get("display_name") or "").strip()
1697
+ except Exception:
1698
+ t = ""
1699
+
1700
+ # 3) Else fall back to doc.display_name()
1701
+ if not t:
1702
+ try:
1703
+ t = (self.document.display_name() or "").strip()
1704
+ except Exception:
1705
+ t = ""
1706
+
1707
+ t = t or self.tr("Untitled")
1708
+
1709
+ # Strip UI decorations (🔗, ■, Active View:, etc.)
1710
+ try:
1711
+ t, _ = self._strip_decorations(t)
1712
+ except Exception:
1713
+ pass
1714
+
1715
+ # Strip extension if it looks like a filename
1716
+ try:
1717
+ base, ext = os.path.splitext(t)
1718
+ if ext and len(ext) <= 10:
1719
+ t = base
1720
+ except Exception:
1721
+ pass
1722
+
1723
+ return t
1724
+
1533
1725
 
1534
1726
  def _show_ctx_menu(self, pos):
1535
1727
  menu = QMenu(self)
@@ -2848,37 +3040,76 @@ class ImageSubWindow(QWidget):
2848
3040
  return True
2849
3041
  return False
2850
3042
 
3043
+ sw = self._mdi_subwindow()
3044
+ if sw is not None and obj is sw:
3045
+ et = ev.type()
3046
+ if et in (QEvent.Type.WindowStateChange, QEvent.Type.Show, QEvent.Type.Resize):
3047
+ QTimer.singleShot(0, self._update_inline_title_and_buttons)
3048
+
2851
3049
  return super().eventFilter(obj, ev)
2852
3050
 
3051
+ def _viewport_pos_to_image_xy(self, vp_pos: QPoint) -> tuple[int, int] | None:
3052
+ """
3053
+ Convert a point in viewport coordinates to FULL image pixel coordinates.
3054
+ Returns None if the point is outside the displayed pixmap (in margins).
3055
+ """
3056
+ pm = self.label.pixmap()
3057
+ if pm is None:
3058
+ return None
3059
+
3060
+ # Convert viewport point into label coordinates
3061
+ p_label = self.label.mapFrom(self.scroll.viewport(), vp_pos)
3062
+
3063
+ # If label is larger than pixmap, pixmap may be centered inside label.
3064
+ pm_w, pm_h = pm.width(), pm.height()
3065
+ lbl_w, lbl_h = self.label.width(), self.label.height()
3066
+
3067
+ off_x = max(0, (lbl_w - pm_w) // 2)
3068
+ off_y = max(0, (lbl_h - pm_h) // 2)
3069
+
3070
+ px = p_label.x() - off_x
3071
+ py = p_label.y() - off_y
3072
+
3073
+ # Outside the drawn pixmap area → clamp
3074
+ px = max(0, min(px, pm_w - 1))
3075
+ py = max(0, min(py, pm_h - 1))
3076
+
3077
+ s = max(float(self.scale), 1e-12)
3078
+
3079
+ # pixmap pixels -> image pixels (pm = image * scale)
3080
+ xi = int(round(px / s))
3081
+ yi = int(round(py / s))
3082
+ return xi, yi
3083
+
2853
3084
 
2854
3085
  def _finish_preview_rect(self, vp_rect: QRect):
2855
- # Map viewport rectangle into image coordinates
2856
3086
  if vp_rect.width() < 4 or vp_rect.height() < 4:
2857
3087
  self._cancel_rubber()
2858
3088
  return
2859
3089
 
2860
- hbar = self.scroll.horizontalScrollBar()
2861
- vbar = self.scroll.verticalScrollBar()
3090
+ # Convert the two corners from viewport space to image space
3091
+ p0 = self._viewport_pos_to_image_xy(vp_rect.topLeft())
3092
+ p1 = self._viewport_pos_to_image_xy(vp_rect.bottomRight())
2862
3093
 
2863
- # Upper-left in label coords
2864
- x_label0 = hbar.value() + vp_rect.left()
2865
- y_label0 = vbar.value() + vp_rect.top()
2866
- x_label1 = hbar.value() + vp_rect.right()
2867
- y_label1 = vbar.value() + vp_rect.bottom()
3094
+ if p0 is None or p1 is None:
3095
+ # User dragged into margins; you can either cancel or clamp.
3096
+ # Cancel is simplest:
3097
+ self._cancel_rubber()
3098
+ return
2868
3099
 
2869
- s = max(self.scale, 1e-12)
3100
+ x0, y0 = p0
3101
+ x1, y1 = p1
2870
3102
 
2871
- x0 = int(round(x_label0 / s))
2872
- y0 = int(round(y_label0 / s))
2873
- x1 = int(round(x_label1 / s))
2874
- y1 = int(round(y_label1 / s))
3103
+ x = min(x0, x1)
3104
+ y = min(y0, y1)
3105
+ w = abs(x1 - x0)
3106
+ h = abs(y1 - y0)
2875
3107
 
2876
- if x1 <= x0 or y1 <= y0:
3108
+ if w < 1 or h < 1:
2877
3109
  self._cancel_rubber()
2878
3110
  return
2879
3111
 
2880
- roi = (x0, y0, x1 - x0, y1 - y0)
2881
- self._create_preview_from_roi(roi)
3112
+ self._create_preview_from_roi((x, y, w, h))
2882
3113
  self._cancel_rubber()
2883
3114
 
2884
3115
  def _create_preview_from_roi(self, roi: tuple[int,int,int,int]):
@@ -1,3 +1,4 @@
1
+ #src/setiastro/saspro/widgets/common_utilities.py
1
2
  """
2
3
  Common UI utilities and shared components.
3
4
 
@@ -220,32 +221,38 @@ _strip_ui_decorations = strip_ui_decorations
220
221
  # ---------------------------------------------------------------------------
221
222
 
222
223
  def install_crash_handlers(app: 'QApplication') -> None:
223
- """
224
- Install global crash and exception handlers for the application.
225
-
226
- Sets up:
227
- 1. faulthandler for hard crashes (segfaults) → saspro_crash.log
228
- 2. sys.excepthook for uncaught main thread exceptions
229
- 3. threading.excepthook for uncaught background thread exceptions
230
-
231
- All exceptions are logged and displayed in a dialog to the user.
232
-
233
- Args:
234
- app: The QApplication instance
235
-
236
- Example:
237
- app = QApplication(sys.argv)
238
- install_crash_handlers(app)
239
- """
240
224
  import faulthandler
241
-
242
- # 1) Hard crashes (segfaults, access violations) → saspro_crash.log
225
+ import tempfile
226
+ from pathlib import Path
227
+
228
+ def _get_crash_log_path() -> str:
229
+ try:
230
+ if hasattr(sys, "_MEIPASS"):
231
+ if sys.platform.startswith("win"):
232
+ log_dir = Path(os.path.expandvars("%APPDATA%")) / "SetiAstroSuitePro" / "logs"
233
+ elif sys.platform.startswith("darwin"):
234
+ log_dir = Path.home() / "Library" / "Logs" / "SetiAstroSuitePro"
235
+ else:
236
+ log_dir = Path.home() / ".local" / "share" / "SetiAstroSuitePro" / "logs"
237
+ else:
238
+ # dev fallback
239
+ log_dir = Path("logs")
240
+
241
+ log_dir.mkdir(parents=True, exist_ok=True)
242
+ return str(log_dir / "saspro_crash.log")
243
+ except Exception:
244
+ return str(Path(tempfile.gettempdir()) / "saspro_crash.log")
245
+
246
+ # 1) Hard crashes → saspro_crash.log
243
247
  try:
244
- _crash_log = open("saspro_crash.log", "w", encoding="utf-8", errors="replace")
248
+ crash_path = _get_crash_log_path()
249
+ _crash_log = open(crash_path, "a", encoding="utf-8", errors="replace")
245
250
  faulthandler.enable(file=_crash_log, all_threads=True)
246
251
  atexit.register(_crash_log.close)
252
+ logging.info("Faulthandler crash log: %s", crash_path)
247
253
  except Exception:
248
254
  logging.exception("Failed to enable faulthandler")
255
+
249
256
 
250
257
  def _show_dialog(title: str, head: str, details: str) -> None:
251
258
  """Show error dialog marshaled to main thread."""
@@ -261,7 +268,7 @@ def install_crash_handlers(app: 'QApplication') -> None:
261
268
  m.setStandardButtons(QMessageBox.StandardButton.Ok)
262
269
  m.exec()
263
270
  QTimer.singleShot(0, _ui)
264
-
271
+
265
272
  # 2) Any uncaught exception on the main thread
266
273
  def _excepthook(exc_type, exc_value, exc_tb):
267
274
  tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
@@ -54,19 +54,19 @@ class GPUWorker(QThread):
54
54
  "-ExecutionPolicy", "Bypass",
55
55
  "-Command",
56
56
  (
57
- "$groups = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine "
58
- "-ErrorAction SilentlyContinue | "
59
- "Group-Object -Property { $_.Name -replace '^pid_\\d+_', '' }; "
60
- "$res_list = $groups | ForEach-Object { ($_.Group | Measure-Object -Property UtilizationPercentage -Sum).Sum }; "
61
- "$max_val = ($res_list | Measure-Object -Maximum).Maximum; "
62
- "if ($max_val) { [math]::Round($max_val, 1) } else { 0 }"
57
+ "$x = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine "
58
+ "-ErrorAction SilentlyContinue; "
59
+ "if (-not $x) { 0 } else { "
60
+ " $m = ($x | Measure-Object -Property UtilizationPercentage -Maximum).Maximum; "
61
+ " if ($m) { [math]::Round([double]$m, 1) } else { 0 } "
62
+ "}"
63
63
  ),
64
64
  ]
65
65
 
66
66
  out = subprocess.check_output(
67
67
  cmd,
68
68
  startupinfo=self._startupinfo_hidden(),
69
- timeout=1.0, # IMPORTANT: don’t allow 5s hangs
69
+ timeout=2.0, # IMPORTANT: don’t allow 5s hangs
70
70
  stderr=subprocess.DEVNULL, # keep it quiet
71
71
  )
72
72
  val_str = out.decode("utf-8", errors="ignore").strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setiastrosuitepro
3
- Version: 1.6.7
3
+ Version: 1.6.10
4
4
  Summary: Seti Astro Suite Pro - Advanced astrophotography toolkit for image calibration, stacking, registration, photometry, and visualization
5
5
  License: GPL-3.0
6
6
  License-File: LICENSE