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.

Files changed (68) hide show
  1. setiastro/images/abeicon.svg +16 -0
  2. setiastro/images/colorwheel.svg +97 -0
  3. setiastro/images/cosmic.svg +40 -0
  4. setiastro/images/cosmicsat.svg +24 -0
  5. setiastro/images/graxpert.svg +19 -0
  6. setiastro/images/linearfit.svg +32 -0
  7. setiastro/images/narrowbandnormalization.png +0 -0
  8. setiastro/images/pixelmath.svg +42 -0
  9. setiastro/images/planetarystacker.png +0 -0
  10. setiastro/saspro/__main__.py +1 -1
  11. setiastro/saspro/_generated/build_info.py +2 -2
  12. setiastro/saspro/aberration_ai.py +49 -11
  13. setiastro/saspro/aberration_ai_preset.py +29 -3
  14. setiastro/saspro/add_stars.py +29 -5
  15. setiastro/saspro/backgroundneutral.py +73 -33
  16. setiastro/saspro/blink_comparator_pro.py +150 -55
  17. setiastro/saspro/convo.py +9 -6
  18. setiastro/saspro/cosmicclarity.py +125 -18
  19. setiastro/saspro/crop_dialog_pro.py +96 -2
  20. setiastro/saspro/curve_editor_pro.py +132 -61
  21. setiastro/saspro/curves_preset.py +249 -47
  22. setiastro/saspro/doc_manager.py +178 -11
  23. setiastro/saspro/frequency_separation.py +1159 -208
  24. setiastro/saspro/gui/main_window.py +340 -88
  25. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  26. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  27. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  28. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  29. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  30. setiastro/saspro/gui/mixins/update_mixin.py +121 -33
  31. setiastro/saspro/histogram.py +179 -7
  32. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  33. setiastro/saspro/imageops/serloader.py +769 -0
  34. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  35. setiastro/saspro/imageops/stretch.py +582 -62
  36. setiastro/saspro/layers.py +13 -9
  37. setiastro/saspro/layers_dock.py +183 -3
  38. setiastro/saspro/legacy/numba_utils.py +68 -48
  39. setiastro/saspro/live_stacking.py +181 -73
  40. setiastro/saspro/multiscale_decomp.py +77 -29
  41. setiastro/saspro/narrowband_normalization.py +1618 -0
  42. setiastro/saspro/numba_utils.py +72 -57
  43. setiastro/saspro/ops/commands.py +18 -18
  44. setiastro/saspro/ops/script_editor.py +5 -0
  45. setiastro/saspro/ops/scripts.py +119 -0
  46. setiastro/saspro/remove_green.py +1 -1
  47. setiastro/saspro/resources.py +4 -0
  48. setiastro/saspro/ser_stack_config.py +68 -0
  49. setiastro/saspro/ser_stacker.py +2245 -0
  50. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  51. setiastro/saspro/ser_tracking.py +206 -0
  52. setiastro/saspro/serviewer.py +1242 -0
  53. setiastro/saspro/sfcc.py +602 -214
  54. setiastro/saspro/shortcuts.py +154 -25
  55. setiastro/saspro/signature_insert.py +688 -33
  56. setiastro/saspro/stacking_suite.py +853 -401
  57. setiastro/saspro/star_alignment.py +243 -122
  58. setiastro/saspro/stat_stretch.py +878 -131
  59. setiastro/saspro/subwindow.py +303 -74
  60. setiastro/saspro/whitebalance.py +24 -0
  61. setiastro/saspro/widgets/common_utilities.py +28 -21
  62. setiastro/saspro/widgets/resource_monitor.py +128 -80
  63. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  64. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
  65. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  66. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  67. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  68. {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.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
 
@@ -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
- #------ Replay helpers------
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
- if abs(scale - self.scale) > 1e-9:
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=False) # repaint current frame
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 list(self._watched_docs):
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
- # Connect new
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: return
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
- # strip any carried-over glyphs (🔗, ■, Active View: ”) from overrides/doc names
1511
+ # Strip badges (🔗, ■, etc) AND "Active View:" prefix
1357
1512
  core, _ = self._strip_decorations(base)
1358
1513
 
1359
- title = core
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
- title = f"{LINK_PREFIX}{title}"
1526
+ shown = f"{LINK_PREFIX}{shown}"
1362
1527
  if self._mask_dot_enabled:
1363
- title = f"{MASK_GLYPH} {title}"
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
- w = self.parent()
1526
- while w is not None and not isinstance(w, QMdiSubWindow):
1527
- w = w.parent()
1528
- return w
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
- # Prefer a per-view override; otherwise doc display name
1532
- return self._view_title_override or self.document.display_name()
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
- 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]):
@@ -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:
@@ -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
- # 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))