setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__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/colorwheel.svg +97 -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/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.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
|
|
|
@@ -538,6 +590,7 @@ class ImageSubWindow(QWidget):
|
|
|
538
590
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
539
591
|
self.customContextMenuRequested.connect(self._show_ctx_menu)
|
|
540
592
|
QShortcut(QKeySequence("F2"), self, activated=self._rename_view)
|
|
593
|
+
QShortcut(QKeySequence("F3"), self, activated=self._rename_document)
|
|
541
594
|
#QShortcut(QKeySequence("A"), self, activated=self.toggle_autostretch)
|
|
542
595
|
QShortcut(QKeySequence("Ctrl+Space"), self, activated=self.toggle_autostretch)
|
|
543
596
|
QShortcut(QKeySequence("Alt+Shift+A"), self, activated=self.toggle_autostretch)
|
|
@@ -568,6 +621,9 @@ class ImageSubWindow(QWidget):
|
|
|
568
621
|
self._history_doc = None
|
|
569
622
|
self._install_history_watchers()
|
|
570
623
|
|
|
624
|
+
QTimer.singleShot(0, self._install_mdi_state_watch)
|
|
625
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
626
|
+
|
|
571
627
|
# ----- link drag payload -----
|
|
572
628
|
def _start_link_drag(self):
|
|
573
629
|
"""
|
|
@@ -699,7 +755,60 @@ class ImageSubWindow(QWidget):
|
|
|
699
755
|
except Exception:
|
|
700
756
|
pass
|
|
701
757
|
|
|
702
|
-
|
|
758
|
+
# ------------------------------------------------------------
|
|
759
|
+
# MDI maximize handling: show inline title + avoid duplicate buttons
|
|
760
|
+
# ------------------------------------------------------------
|
|
761
|
+
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
762
|
+
w = self.parentWidget()
|
|
763
|
+
while w is not None and not isinstance(w, QMdiSubWindow):
|
|
764
|
+
w = w.parentWidget()
|
|
765
|
+
return w
|
|
766
|
+
|
|
767
|
+
def _install_mdi_state_watch(self):
|
|
768
|
+
sw = self._mdi_subwindow()
|
|
769
|
+
if sw is None:
|
|
770
|
+
return
|
|
771
|
+
# Watch maximize/restore changes on the hosting QMdiSubWindow
|
|
772
|
+
sw.installEventFilter(self)
|
|
773
|
+
|
|
774
|
+
def _is_mdi_maximized(self) -> bool:
|
|
775
|
+
sw = self._mdi_subwindow()
|
|
776
|
+
if sw is None:
|
|
777
|
+
return False
|
|
778
|
+
try:
|
|
779
|
+
return sw.isMaximized()
|
|
780
|
+
except Exception:
|
|
781
|
+
return False
|
|
782
|
+
|
|
783
|
+
def _set_mdi_minmax_buttons_enabled(self, enabled: bool):
|
|
784
|
+
return # leave Qt default buttons alone
|
|
785
|
+
|
|
786
|
+
def _current_view_title_for_inline(self) -> str:
|
|
787
|
+
# Prefer your already-pretty title (strip decorations if needed).
|
|
788
|
+
try:
|
|
789
|
+
# If you have _current_view_title_for_drag already, reuse it:
|
|
790
|
+
return self._current_view_title_for_drag()
|
|
791
|
+
except Exception:
|
|
792
|
+
pass
|
|
793
|
+
try:
|
|
794
|
+
return (self.windowTitle() or "").strip()
|
|
795
|
+
except Exception:
|
|
796
|
+
return ""
|
|
797
|
+
|
|
798
|
+
def _update_inline_title_and_buttons(self):
|
|
799
|
+
maximized = self._is_mdi_maximized()
|
|
800
|
+
|
|
801
|
+
# Show inline title only when maximized (optional)
|
|
802
|
+
try:
|
|
803
|
+
self._inline_title.setVisible(maximized)
|
|
804
|
+
if maximized:
|
|
805
|
+
self._inline_title.setText(self._current_view_title_for_inline() or "Untitled")
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
# IMPORTANT: do NOT change QMdiSubWindow window flags.
|
|
810
|
+
# Leaving them alone restores the default Qt "double button" behavior.
|
|
811
|
+
|
|
703
812
|
#------ Replay helpers------
|
|
704
813
|
def _update_replay_button(self):
|
|
705
814
|
"""
|
|
@@ -829,13 +938,20 @@ class ImageSubWindow(QWidget):
|
|
|
829
938
|
self._emit_view_transform()
|
|
830
939
|
|
|
831
940
|
def set_view_transform(self, scale, hval, vval, from_link=False):
|
|
832
|
-
# Avoid storms while we mutate scrollbars/scale
|
|
833
941
|
self._suppress_link_emit = True
|
|
834
942
|
try:
|
|
835
943
|
scale = float(max(self._min_scale, min(scale, self._max_scale)))
|
|
836
|
-
|
|
944
|
+
|
|
945
|
+
scale_changed = (abs(scale - self.scale) > 1e-9)
|
|
946
|
+
if scale_changed:
|
|
837
947
|
self.scale = scale
|
|
838
|
-
self._render(rebuild=False)
|
|
948
|
+
self._render(rebuild=False) # fast present for responsiveness
|
|
949
|
+
|
|
950
|
+
# ✅ NEW: schedule the final smooth redraw (same as main zoom path)
|
|
951
|
+
try:
|
|
952
|
+
self._request_zoom_redraw()
|
|
953
|
+
except Exception:
|
|
954
|
+
pass
|
|
839
955
|
|
|
840
956
|
hbar = self.scroll.horizontalScrollBar()
|
|
841
957
|
vbar = self.scroll.verticalScrollBar()
|
|
@@ -847,14 +963,14 @@ class ImageSubWindow(QWidget):
|
|
|
847
963
|
finally:
|
|
848
964
|
self._suppress_link_emit = False
|
|
849
965
|
|
|
850
|
-
# IMPORTANT: if this came from a linked peer, do NOT broadcast again.
|
|
851
966
|
if not from_link:
|
|
852
967
|
self._schedule_emit_view_transform()
|
|
853
968
|
|
|
969
|
+
|
|
854
970
|
def _on_toggle_wcs_grid(self, on: bool):
|
|
855
971
|
self._show_wcs_grid = bool(on)
|
|
856
972
|
QSettings().setValue("display/show_wcs_grid", self._show_wcs_grid)
|
|
857
|
-
self._render(rebuild=
|
|
973
|
+
self._render(rebuild=True) # repaint current frame
|
|
858
974
|
|
|
859
975
|
|
|
860
976
|
|
|
@@ -1193,18 +1309,6 @@ class ImageSubWindow(QWidget):
|
|
|
1193
1309
|
except Exception as e:
|
|
1194
1310
|
print("[ImageSubWindow] apply_layer_stack error:", e)
|
|
1195
1311
|
|
|
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
1312
|
def keyPressEvent(self, ev):
|
|
1209
1313
|
if ev.key() == Qt.Key.Key_Space:
|
|
1210
1314
|
# only the first time we enter probe mode
|
|
@@ -1326,51 +1430,116 @@ class ImageSubWindow(QWidget):
|
|
|
1326
1430
|
except Exception as e:
|
|
1327
1431
|
print("[ImageSubWindow] _on_layer_source_changed error:", e)
|
|
1328
1432
|
|
|
1433
|
+
def _collect_layer_docs(self):
|
|
1434
|
+
"""
|
|
1435
|
+
Collect unique ImageDocument objects referenced by the layer stack:
|
|
1436
|
+
- layer src_doc (if doc-backed)
|
|
1437
|
+
- layer mask_doc (if any)
|
|
1438
|
+
Raster/baked layers may have src_doc=None; those are ignored.
|
|
1439
|
+
Returns a LIST in a stable order (bottom→top traversal order), de-duped.
|
|
1440
|
+
"""
|
|
1441
|
+
out = []
|
|
1442
|
+
seen = set()
|
|
1443
|
+
|
|
1444
|
+
layers = getattr(self, "_layers", None) or []
|
|
1445
|
+
for L in layers:
|
|
1446
|
+
# 1) source doc (may be None for raster/baked layers)
|
|
1447
|
+
d = getattr(L, "src_doc", None)
|
|
1448
|
+
if d is not None:
|
|
1449
|
+
k = id(d)
|
|
1450
|
+
if k not in seen:
|
|
1451
|
+
seen.add(k)
|
|
1452
|
+
out.append(d)
|
|
1453
|
+
|
|
1454
|
+
# 2) mask doc (also may be None)
|
|
1455
|
+
md = getattr(L, "mask_doc", None)
|
|
1456
|
+
if md is not None:
|
|
1457
|
+
k = id(md)
|
|
1458
|
+
if k not in seen:
|
|
1459
|
+
seen.add(k)
|
|
1460
|
+
out.append(md)
|
|
1461
|
+
|
|
1462
|
+
return out
|
|
1463
|
+
|
|
1464
|
+
|
|
1329
1465
|
def _reinstall_layer_watchers(self):
|
|
1466
|
+
"""
|
|
1467
|
+
Reconnect layer source/mask document watchers to trigger live layer recomposite.
|
|
1468
|
+
Safe against:
|
|
1469
|
+
- raster/baked layers (src_doc=None)
|
|
1470
|
+
- deleted docs / partially-torn-down Qt objects
|
|
1471
|
+
- repeated calls
|
|
1472
|
+
"""
|
|
1473
|
+
# Previous watchers
|
|
1474
|
+
olddocs = list(getattr(self, "_watched_docs", []) or [])
|
|
1475
|
+
|
|
1330
1476
|
# Disconnect old
|
|
1331
|
-
for d in
|
|
1477
|
+
for d in olddocs:
|
|
1332
1478
|
try:
|
|
1479
|
+
# Doc may already be deleted or signal gone
|
|
1333
1480
|
d.changed.disconnect(self._on_layer_source_changed)
|
|
1334
1481
|
except Exception:
|
|
1335
1482
|
pass
|
|
1336
|
-
|
|
1483
|
+
|
|
1484
|
+
# Collect new
|
|
1337
1485
|
newdocs = self._collect_layer_docs()
|
|
1486
|
+
|
|
1487
|
+
# Connect new
|
|
1338
1488
|
for d in newdocs:
|
|
1339
1489
|
try:
|
|
1340
1490
|
d.changed.connect(self._on_layer_source_changed)
|
|
1341
1491
|
except Exception:
|
|
1342
1492
|
pass
|
|
1493
|
+
|
|
1494
|
+
# Store as list (stable)
|
|
1343
1495
|
self._watched_docs = newdocs
|
|
1344
1496
|
|
|
1345
1497
|
|
|
1498
|
+
|
|
1346
1499
|
def toggle_mask_overlay(self):
|
|
1347
1500
|
self.show_mask_overlay = not self.show_mask_overlay
|
|
1348
1501
|
self._render(rebuild=True)
|
|
1349
1502
|
|
|
1350
1503
|
def _rebuild_title(self, *, base: str | None = None):
|
|
1351
1504
|
sub = self._mdi_subwindow()
|
|
1352
|
-
if not sub:
|
|
1505
|
+
if not sub:
|
|
1506
|
+
return
|
|
1507
|
+
|
|
1353
1508
|
if base is None:
|
|
1354
1509
|
base = self._effective_title() or self.tr("Untitled")
|
|
1355
1510
|
|
|
1356
|
-
#
|
|
1511
|
+
# Strip badges (🔗, ■, etc) AND "Active View:" prefix
|
|
1357
1512
|
core, _ = self._strip_decorations(base)
|
|
1358
1513
|
|
|
1359
|
-
|
|
1514
|
+
# ALSO strip file extensions if it looks like a filename
|
|
1515
|
+
# (this prevents .tiff/.fit coming back via any fallback path)
|
|
1516
|
+
try:
|
|
1517
|
+
b, ext = os.path.splitext(core)
|
|
1518
|
+
if ext and len(ext) <= 10 and not core.endswith("..."):
|
|
1519
|
+
core = b
|
|
1520
|
+
except Exception:
|
|
1521
|
+
pass
|
|
1522
|
+
|
|
1523
|
+
# Build the displayed title with badges
|
|
1524
|
+
shown = core
|
|
1360
1525
|
if getattr(self, "_link_badge_on", False):
|
|
1361
|
-
|
|
1526
|
+
shown = f"{LINK_PREFIX}{shown}"
|
|
1362
1527
|
if self._mask_dot_enabled:
|
|
1363
|
-
|
|
1528
|
+
shown = f"{MASK_GLYPH} {shown}"
|
|
1529
|
+
|
|
1530
|
+
# Update chrome
|
|
1531
|
+
if shown != sub.windowTitle():
|
|
1532
|
+
sub.setWindowTitle(shown)
|
|
1533
|
+
sub.setToolTip(shown)
|
|
1534
|
+
|
|
1535
|
+
# IMPORTANT: emit ONLY the clean core (no badges, no extensions)
|
|
1536
|
+
if core != self._last_title_for_emit:
|
|
1537
|
+
self._last_title_for_emit = core
|
|
1538
|
+
try:
|
|
1539
|
+
self.viewTitleChanged.emit(self, core)
|
|
1540
|
+
except Exception:
|
|
1541
|
+
pass
|
|
1364
1542
|
|
|
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
1543
|
|
|
1375
1544
|
|
|
1376
1545
|
def _strip_decorations(self, title: str) -> tuple[str, bool]:
|
|
@@ -1399,21 +1568,7 @@ class ImageSubWindow(QWidget):
|
|
|
1399
1568
|
def set_active_highlight(self, on: bool):
|
|
1400
1569
|
self._is_active_flag = bool(on)
|
|
1401
1570
|
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
1571
|
|
|
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
1572
|
|
|
1418
1573
|
def _set_mask_highlight(self, on: bool):
|
|
1419
1574
|
self._mask_dot_enabled = bool(on)
|
|
@@ -1521,20 +1676,58 @@ class ImageSubWindow(QWidget):
|
|
|
1521
1676
|
def is_hard_autostretch(self) -> bool:
|
|
1522
1677
|
return self.autostretch_profile == "hard"
|
|
1523
1678
|
|
|
1524
|
-
def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1679
|
+
#def _mdi_subwindow(self) -> QMdiSubWindow | None:
|
|
1680
|
+
# w = self.parent()
|
|
1681
|
+
# while w is not None and not isinstance(w, QMdiSubWindow):
|
|
1682
|
+
# w = w.parent()
|
|
1683
|
+
# return w
|
|
1529
1684
|
|
|
1530
1685
|
def _effective_title(self) -> str:
|
|
1531
|
-
|
|
1532
|
-
|
|
1686
|
+
"""
|
|
1687
|
+
Returns the *core* title for this view (no UI badges like 🔗/■, and no file extension).
|
|
1688
|
+
Badges are added later by _rebuild_title().
|
|
1689
|
+
"""
|
|
1690
|
+
# 1) Prefer per-view override if set
|
|
1691
|
+
t = (self._view_title_override or "").strip()
|
|
1692
|
+
|
|
1693
|
+
# 2) Else prefer metadata display_name (what duplicate/rename should set)
|
|
1694
|
+
if not t:
|
|
1695
|
+
try:
|
|
1696
|
+
md = getattr(self.document, "metadata", {}) or {}
|
|
1697
|
+
t = (md.get("display_name") or "").strip()
|
|
1698
|
+
except Exception:
|
|
1699
|
+
t = ""
|
|
1700
|
+
|
|
1701
|
+
# 3) Else fall back to doc.display_name()
|
|
1702
|
+
if not t:
|
|
1703
|
+
try:
|
|
1704
|
+
t = (self.document.display_name() or "").strip()
|
|
1705
|
+
except Exception:
|
|
1706
|
+
t = ""
|
|
1707
|
+
|
|
1708
|
+
t = t or self.tr("Untitled")
|
|
1709
|
+
|
|
1710
|
+
# Strip UI decorations (🔗, ■, Active View:, etc.)
|
|
1711
|
+
try:
|
|
1712
|
+
t, _ = self._strip_decorations(t)
|
|
1713
|
+
except Exception:
|
|
1714
|
+
pass
|
|
1715
|
+
|
|
1716
|
+
# Strip extension if it looks like a filename
|
|
1717
|
+
try:
|
|
1718
|
+
base, ext = os.path.splitext(t)
|
|
1719
|
+
if ext and len(ext) <= 10:
|
|
1720
|
+
t = base
|
|
1721
|
+
except Exception:
|
|
1722
|
+
pass
|
|
1723
|
+
|
|
1724
|
+
return t
|
|
1725
|
+
|
|
1533
1726
|
|
|
1534
1727
|
def _show_ctx_menu(self, pos):
|
|
1535
1728
|
menu = QMenu(self)
|
|
1536
1729
|
a_view = menu.addAction(self.tr("Rename View… (F2)"))
|
|
1537
|
-
a_doc = menu.addAction(self.tr("Rename Document…"))
|
|
1730
|
+
a_doc = menu.addAction(self.tr("Rename Document… (F3)"))
|
|
1538
1731
|
menu.addSeparator()
|
|
1539
1732
|
a_min = menu.addAction(self.tr("Send to Shelf"))
|
|
1540
1733
|
a_clear = menu.addAction(self.tr("Clear View Name (use doc name)"))
|
|
@@ -2848,37 +3041,75 @@ class ImageSubWindow(QWidget):
|
|
|
2848
3041
|
return True
|
|
2849
3042
|
return False
|
|
2850
3043
|
|
|
3044
|
+
sw = self._mdi_subwindow()
|
|
3045
|
+
if sw is not None and obj is sw:
|
|
3046
|
+
et = ev.type()
|
|
3047
|
+
if et in (QEvent.Type.WindowStateChange, QEvent.Type.Show, QEvent.Type.Resize):
|
|
3048
|
+
QTimer.singleShot(0, self._update_inline_title_and_buttons)
|
|
3049
|
+
|
|
2851
3050
|
return super().eventFilter(obj, ev)
|
|
2852
3051
|
|
|
3052
|
+
def _viewport_pos_to_image_xy(self, vp_pos: QPoint) -> tuple[int, int] | None:
|
|
3053
|
+
"""
|
|
3054
|
+
Convert a point in viewport coordinates to FULL image pixel coordinates.
|
|
3055
|
+
Returns None if the point is outside the displayed pixmap (in margins).
|
|
3056
|
+
"""
|
|
3057
|
+
pm = self.label.pixmap()
|
|
3058
|
+
if pm is None:
|
|
3059
|
+
return None
|
|
3060
|
+
|
|
3061
|
+
# Convert viewport point into label coordinates
|
|
3062
|
+
p_label = self.label.mapFrom(self.scroll.viewport(), vp_pos)
|
|
3063
|
+
|
|
3064
|
+
# If label is larger than pixmap, pixmap may be centered inside label.
|
|
3065
|
+
pm_w, pm_h = pm.width(), pm.height()
|
|
3066
|
+
lbl_w, lbl_h = self.label.width(), self.label.height()
|
|
3067
|
+
|
|
3068
|
+
off_x = max(0, (lbl_w - pm_w) // 2)
|
|
3069
|
+
off_y = max(0, (lbl_h - pm_h) // 2)
|
|
3070
|
+
|
|
3071
|
+
px = p_label.x() - off_x
|
|
3072
|
+
py = p_label.y() - off_y
|
|
3073
|
+
|
|
3074
|
+
# Outside the drawn pixmap area → clamp
|
|
3075
|
+
px = max(0, min(px, pm_w - 1))
|
|
3076
|
+
py = max(0, min(py, pm_h - 1))
|
|
3077
|
+
|
|
3078
|
+
s = max(float(self.scale), 1e-12)
|
|
3079
|
+
|
|
3080
|
+
# pixmap pixels -> image pixels (pm = image * scale)
|
|
3081
|
+
xi = int(round(px / s))
|
|
3082
|
+
yi = int(round(py / s))
|
|
3083
|
+
return xi, yi
|
|
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]):
|
|
@@ -2956,8 +3187,6 @@ class ImageSubWindow(QWidget):
|
|
|
2956
3187
|
|
|
2957
3188
|
super().mousePressEvent(e)
|
|
2958
3189
|
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
3190
|
def _show_readout(self, xi, yi, sample):
|
|
2962
3191
|
mw = self._find_main_window()
|
|
2963
3192
|
if mw is None:
|
setiastro/saspro/whitebalance.py
CHANGED
|
@@ -184,6 +184,30 @@ def apply_white_balance_to_doc(doc, preset: Optional[Dict] = None):
|
|
|
184
184
|
step_name="White Balance",
|
|
185
185
|
)
|
|
186
186
|
|
|
187
|
+
def apply_pivot_gain(img: np.ndarray, med: np.ndarray, gains: np.ndarray) -> np.ndarray:
|
|
188
|
+
# img: HxWx3 float32 in [0,1]
|
|
189
|
+
med3 = med.reshape(1, 1, 3).astype(np.float32)
|
|
190
|
+
g3 = gains.reshape(1, 1, 3).astype(np.float32)
|
|
191
|
+
|
|
192
|
+
# pivot around median; do not scale negative deltas
|
|
193
|
+
d = img - med3
|
|
194
|
+
d = np.maximum(d, 0.0)
|
|
195
|
+
out = d * g3 + med3
|
|
196
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
197
|
+
|
|
198
|
+
def smoothstep(edge0, edge1, x):
|
|
199
|
+
t = np.clip((x - edge0) / (edge1 - edge0 + 1e-12), 0.0, 1.0)
|
|
200
|
+
return t * t * (3.0 - 2.0 * t)
|
|
201
|
+
|
|
202
|
+
def apply_soft_protect(img: np.ndarray, out_pivot: np.ndarray, k: float = 0.02) -> np.ndarray:
|
|
203
|
+
# luminance-based fade-in above median luminance
|
|
204
|
+
L = 0.2126*img[...,0] + 0.7152*img[...,1] + 0.0722*img[...,2]
|
|
205
|
+
Lm = float(np.median(L))
|
|
206
|
+
w = smoothstep(Lm, Lm + k, L).astype(np.float32)
|
|
207
|
+
w3 = w[..., None]
|
|
208
|
+
out = img * (1.0 - w3) + out_pivot * w3
|
|
209
|
+
return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
|
|
210
|
+
|
|
187
211
|
|
|
188
212
|
# -------------------------
|
|
189
213
|
# Interactive dialog (UI)
|
|
@@ -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))
|