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.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +60 -39
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +131 -31
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/live_stacking.py +158 -70
- setiastro/saspro/multiscale_decomp.py +47 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/shortcuts.py +122 -12
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +523 -316
- setiastro/saspro/stat_stretch.py +688 -130
- setiastro/saspro/subwindow.py +302 -71
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +7 -7
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/subwindow.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
#
|
|
1510
|
+
# Strip badges (🔗, ■, etc) AND "Active View:" prefix
|
|
1357
1511
|
core, _ = self._strip_decorations(base)
|
|
1358
1512
|
|
|
1359
|
-
|
|
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
|
-
|
|
1525
|
+
shown = f"{LINK_PREFIX}{shown}"
|
|
1362
1526
|
if self._mask_dot_enabled:
|
|
1363
|
-
|
|
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
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
2861
|
-
|
|
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
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
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
|
-
|
|
3100
|
+
x0, y0 = p0
|
|
3101
|
+
x1, y1 = p1
|
|
2870
3102
|
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
3103
|
+
x = min(x0, x1)
|
|
3104
|
+
y = min(y0, y1)
|
|
3105
|
+
w = abs(x1 - x0)
|
|
3106
|
+
h = abs(y1 - y0)
|
|
2875
3107
|
|
|
2876
|
-
if
|
|
3108
|
+
if w < 1 or h < 1:
|
|
2877
3109
|
self._cancel_rubber()
|
|
2878
3110
|
return
|
|
2879
3111
|
|
|
2880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"$
|
|
58
|
-
"-ErrorAction SilentlyContinue
|
|
59
|
-
"
|
|
60
|
-
"$
|
|
61
|
-
"$
|
|
62
|
-
"
|
|
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=
|
|
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.
|
|
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
|