setiastrosuitepro 1.7.1.post2__py3-none-any.whl → 1.7.4__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 (62) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/saspro/__init__.py +20 -8
  3. setiastro/saspro/__main__.py +349 -290
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/abe.py +4 -4
  6. setiastro/saspro/autostretch.py +29 -18
  7. setiastro/saspro/doc_manager.py +4 -1
  8. setiastro/saspro/gui/main_window.py +46 -7
  9. setiastro/saspro/gui/mixins/file_mixin.py +6 -2
  10. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  11. setiastro/saspro/gui/mixins/toolbar_mixin.py +9 -2
  12. setiastro/saspro/imageops/serloader.py +101 -17
  13. setiastro/saspro/layers.py +186 -10
  14. setiastro/saspro/layers_dock.py +198 -5
  15. setiastro/saspro/legacy/image_manager.py +10 -4
  16. setiastro/saspro/legacy/numba_utils.py +301 -119
  17. setiastro/saspro/numba_utils.py +998 -270
  18. setiastro/saspro/ops/settings.py +6 -6
  19. setiastro/saspro/pixelmath.py +1 -1
  20. setiastro/saspro/planetprojection.py +4059 -0
  21. setiastro/saspro/resources.py +2 -0
  22. setiastro/saspro/save_options.py +45 -13
  23. setiastro/saspro/ser_stack_config.py +21 -1
  24. setiastro/saspro/ser_stacker.py +8 -2
  25. setiastro/saspro/ser_stacker_dialog.py +37 -10
  26. setiastro/saspro/ser_tracking.py +57 -35
  27. setiastro/saspro/serviewer.py +164 -16
  28. setiastro/saspro/sfcc.py +14 -8
  29. setiastro/saspro/stacking_suite.py +292 -111
  30. setiastro/saspro/subwindow.py +64 -36
  31. setiastro/saspro/translations/all_source_strings.json +2 -2
  32. setiastro/saspro/translations/ar_translations.py +3 -3
  33. setiastro/saspro/translations/de_translations.py +2 -2
  34. setiastro/saspro/translations/es_translations.py +2 -2
  35. setiastro/saspro/translations/fr_translations.py +2 -2
  36. setiastro/saspro/translations/hi_translations.py +2 -2
  37. setiastro/saspro/translations/it_translations.py +2 -2
  38. setiastro/saspro/translations/ja_translations.py +2 -2
  39. setiastro/saspro/translations/pt_translations.py +2 -2
  40. setiastro/saspro/translations/ru_translations.py +2 -2
  41. setiastro/saspro/translations/saspro_ar.ts +2 -2
  42. setiastro/saspro/translations/saspro_de.ts +4 -4
  43. setiastro/saspro/translations/saspro_es.ts +2 -2
  44. setiastro/saspro/translations/saspro_fr.ts +2 -2
  45. setiastro/saspro/translations/saspro_hi.ts +2 -2
  46. setiastro/saspro/translations/saspro_it.ts +4 -4
  47. setiastro/saspro/translations/saspro_ja.ts +2 -2
  48. setiastro/saspro/translations/saspro_pt.ts +2 -2
  49. setiastro/saspro/translations/saspro_ru.ts +2 -2
  50. setiastro/saspro/translations/saspro_sw.ts +2 -2
  51. setiastro/saspro/translations/saspro_uk.ts +2 -2
  52. setiastro/saspro/translations/saspro_zh.ts +2 -2
  53. setiastro/saspro/translations/sw_translations.py +2 -2
  54. setiastro/saspro/translations/uk_translations.py +2 -2
  55. setiastro/saspro/translations/zh_translations.py +2 -2
  56. setiastro/saspro/window_shelf.py +62 -1
  57. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/METADATA +1 -1
  58. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/RECORD +62 -60
  59. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/entry_points.txt +1 -1
  60. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/WHEEL +0 -0
  61. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/LICENSE +0 -0
  62. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.4.dist-info}/licenses/license.txt +0 -0
@@ -246,6 +246,7 @@ class Icons:
246
246
  LIVE_STACKING = property(lambda self: _resource_path('livestacking.png'))
247
247
  IMAGE_COMBINE = property(lambda self: _resource_path('imagecombine.png'))
248
248
  PLANETARY_STACKER = property(lambda self: _resource_path('planetarystacker.png'))
249
+ PLANET_PROJECTION = property(lambda self: _resource_path('3dplanet.png'))
249
250
 
250
251
  # Moon phase (WIMS)
251
252
  MOON_NEW = property(lambda self: _resource_path('new_moon.png'))
@@ -551,6 +552,7 @@ def _init_legacy_paths():
551
552
  'rgbalign_path': get_icon_path('rgbalign.png'),
552
553
  'background_path': get_icon_path('background.png'),
553
554
  'script_icon_path': get_icon_path('script.png'),
555
+ 'planetprojection_path': get_icon_path('3dplanet.png'),
554
556
  }
555
557
 
556
558
 
@@ -1,8 +1,9 @@
1
1
  # pro/save_options.py
2
2
  from __future__ import annotations
3
- from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton
3
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QSpinBox, QWidget
4
4
  from PyQt6.QtCore import Qt
5
5
 
6
+
6
7
  from setiastro.saspro.file_utils import _normalize_ext
7
8
 
8
9
  # Allowed bit depths per output format (what your saver actually supports)
@@ -16,22 +17,28 @@ _BIT_DEPTHS = {
16
17
  }
17
18
 
18
19
  class SaveOptionsDialog(QDialog):
19
- def __init__(self, parent, target_ext: str, current_bit_depth: str | None):
20
+ def __init__(
21
+ self,
22
+ parent,
23
+ target_ext: str,
24
+ current_bit_depth: str | None,
25
+ current_jpeg_quality: int | None = None,
26
+ ):
20
27
  super().__init__(parent)
21
28
  self.setWindowTitle(self.tr("Save Options"))
22
29
  self.setWindowFlag(Qt.WindowType.Window, True)
23
30
  self.setWindowModality(Qt.WindowModality.NonModal)
24
31
  self.setModal(False)
25
- #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
26
32
 
27
- # Normalize extension aggressively so it matches _BIT_DEPTHS keys
28
- raw_ext = (target_ext or "").lower().strip()
33
+ self.jpeg_quality_spin = None
29
34
 
30
- # If it's like ".fits" or "image.fits" just keep the part after last dot
35
+ # -----------------------------
36
+ # Normalize extension FIRST
37
+ # -----------------------------
38
+ raw_ext = (target_ext or "").lower().strip()
31
39
  if "." in raw_ext:
32
40
  raw_ext = raw_ext.split(".")[-1]
33
41
 
34
- # Handle common synonyms / compressed variants
35
42
  if raw_ext in ("fit", "fits", "fz", "fits.gz", "fit.gz"):
36
43
  self._ext = "fits"
37
44
  elif raw_ext in ("tif", "tiff"):
@@ -39,19 +46,42 @@ class SaveOptionsDialog(QDialog):
39
46
  elif raw_ext in ("jpg", "jpeg"):
40
47
  self._ext = "jpg"
41
48
  else:
42
- # Fallback – already lowercase, no leading dot
43
49
  self._ext = raw_ext
44
50
 
45
51
  allowed = _BIT_DEPTHS.get(self._ext, ["32-bit floating point"])
46
52
 
53
+ # -----------------------------
54
+ # Build layout
55
+ # -----------------------------
56
+ lay = QVBoxLayout(self)
57
+
58
+ lbl = QLabel(self.tr("Choose bit depth for export:"))
59
+ lbl.setWordWrap(True)
60
+ lay.addWidget(lbl)
61
+
47
62
  self.combo = QComboBox(self)
48
63
  self.combo.addItems(allowed)
49
64
  if current_bit_depth in allowed:
50
65
  self.combo.setCurrentText(current_bit_depth)
66
+ lay.addWidget(self.combo)
51
67
 
52
- lbl = QLabel(self.tr("Choose bit depth for export:"))
53
- lbl.setWordWrap(True)
68
+ # -----------------------------
69
+ # JPEG quality (only for jpg)
70
+ # -----------------------------
71
+ if self._ext == "jpg":
72
+ qlbl = QLabel(self.tr("JPEG quality (1–100):"))
73
+ qlbl.setWordWrap(True)
74
+ lay.addWidget(qlbl)
75
+
76
+ self.jpeg_quality_spin = QSpinBox(self)
77
+ self.jpeg_quality_spin.setRange(1, 100)
78
+ default_q = int(current_jpeg_quality) if current_jpeg_quality is not None else 95
79
+ self.jpeg_quality_spin.setValue(max(1, min(100, default_q)))
80
+ lay.addWidget(self.jpeg_quality_spin)
54
81
 
82
+ # -----------------------------
83
+ # Buttons
84
+ # -----------------------------
55
85
  btn_ok = QPushButton(self.tr("OK"))
56
86
  btn_cancel = QPushButton(self.tr("Cancel"))
57
87
  btn_ok.clicked.connect(self.accept)
@@ -62,12 +92,14 @@ class SaveOptionsDialog(QDialog):
62
92
  row.addWidget(btn_ok)
63
93
  row.addWidget(btn_cancel)
64
94
 
65
- lay = QVBoxLayout(self)
66
- lay.addWidget(lbl)
67
- lay.addWidget(self.combo)
68
95
  lay.addStretch(1)
69
96
  lay.addLayout(row)
70
97
 
98
+
71
99
  def selected_bit_depth(self) -> str:
72
100
  return self.combo.currentText()
73
101
 
102
+ def selected_jpeg_quality(self) -> int | None:
103
+ if self.jpeg_quality_spin is None:
104
+ return None
105
+ return int(self.jpeg_quality_spin.value())
@@ -30,7 +30,12 @@ class SERStackConfig:
30
30
  ap_multiscale: bool = False
31
31
  ssd_refine_bruteforce: bool = False
32
32
  keep_mask: Optional[KeepMask] = None
33
-
33
+ planet_smooth_sigma: float = 1.5
34
+ planet_thresh_pct: float = 92.0
35
+ planet_use_norm: bool = True
36
+ planet_norm_lo_pct: float = 1.0
37
+ planet_norm_hi_pct: float = 99.5
38
+ planet_min_val: float = 0.02
34
39
  # ✅ Drizzle
35
40
  drizzle_scale: float = 1.0 # 1.0 = off, 1.5, 2.0
36
41
  drizzle_pixfrac: float = 0.80 # "drop shrink" in output pixels (roughly)
@@ -58,7 +63,22 @@ class SERStackConfig:
58
63
  self.ap_multiscale = bool(kwargs.pop("ap_multiscale", False))
59
64
  self.ssd_refine_bruteforce = bool(kwargs.pop("ssd_refine_bruteforce", False))
60
65
  self.keep_mask = kwargs.pop("keep_mask", None)
66
+ # Planetary centroid knobs (pure data, no UI references)
67
+ self.planet_smooth_sigma = float(kwargs.pop("planet_smooth_sigma", 1.5))
68
+ self.planet_thresh_pct = float(kwargs.pop("planet_thresh_pct", 92.0))
69
+ self.planet_min_val = float(kwargs.pop("planet_min_val", 0.02))
70
+ self.planet_use_norm = bool(kwargs.pop("planet_use_norm", True))
71
+ self.planet_norm_lo_pct = float(kwargs.pop("planet_norm_lo_pct", 1.0))
72
+ self.planet_norm_hi_pct = float(kwargs.pop("planet_norm_hi_pct", 99.5))
61
73
 
74
+ # sanitize
75
+ self.planet_smooth_sigma = max(0.0, self.planet_smooth_sigma)
76
+ self.planet_thresh_pct = float(np.clip(self.planet_thresh_pct, 0.0, 100.0)) if "np" in globals() else self.planet_thresh_pct
77
+ self.planet_min_val = float(max(0.0, min(1.0, self.planet_min_val)))
78
+ self.planet_norm_lo_pct = float(np.clip(self.planet_norm_lo_pct, 0.0, 100.0)) if "np" in globals() else self.planet_norm_lo_pct
79
+ self.planet_norm_hi_pct = float(np.clip(self.planet_norm_hi_pct, 0.0, 100.0)) if "np" in globals() else self.planet_norm_hi_pct
80
+ if self.planet_norm_hi_pct <= self.planet_norm_lo_pct:
81
+ self.planet_norm_hi_pct = min(100.0, self.planet_norm_lo_pct + 1.0)
62
82
  # ✅ NEW: Drizzle params
63
83
  self.drizzle_scale = float(kwargs.pop("drizzle_scale", 1.0))
64
84
  if self.drizzle_scale not in (1.0, 1.5, 2.0):
@@ -1601,6 +1601,9 @@ def analyze_ser(
1601
1601
  tracker = PlanetaryTracker(
1602
1602
  smooth_sigma=float(getattr(cfg, "planet_smooth_sigma", smooth_sigma)),
1603
1603
  thresh_pct=float(getattr(cfg, "planet_thresh_pct", thresh_pct)),
1604
+ min_val=float(getattr(cfg, "planet_min_val", 0.02)),
1605
+ use_norm=bool(getattr(cfg, "planet_use_norm", False)),
1606
+ norm_hi_pct=float(getattr(cfg, "planet_norm_hi_pct", 99.5)),
1604
1607
  )
1605
1608
 
1606
1609
  # IMPORTANT: reference center is computed from the SAME reference image that Analyze chose
@@ -1954,8 +1957,11 @@ def realign_ser(
1954
1957
  else:
1955
1958
  # planetary: centroid tracking (same as viewer)
1956
1959
  tracker = PlanetaryTracker(
1957
- smooth_sigma=float(getattr(cfg, "planet_smooth_sigma", 1.5)),
1958
- thresh_pct=float(getattr(cfg, "planet_thresh_pct", 92.0)),
1960
+ smooth_sigma=float(getattr(cfg, "planet_smooth_sigma", smooth_sigma)),
1961
+ thresh_pct=float(getattr(cfg, "planet_thresh_pct", thresh_pct)),
1962
+ min_val=float(getattr(cfg, "planet_min_val", 0.02)),
1963
+ use_norm=bool(getattr(cfg, "planet_use_norm", False)),
1964
+ norm_hi_pct=float(getattr(cfg, "planet_norm_hi_pct", 99.5)),
1959
1965
  )
1960
1966
 
1961
1967
  # Reference center comes from analysis.ref_image (same anchor as analyze_ser)
@@ -442,22 +442,29 @@ class APEditorDialog(QDialog):
442
442
  # ---------- drawing ----------
443
443
  def _render(self):
444
444
  u8 = self._base_u8
445
+ # ✅ memmap/FITS-safe: QImage assumes row-major contiguous when we pass bytesPerLine=w
446
+ if not u8.flags["C_CONTIGUOUS"]:
447
+ u8 = np.ascontiguousarray(u8)
448
+
445
449
  h, w = u8.shape[:2]
446
450
 
447
- qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
448
- base_pm = QPixmap.fromImage(qimg.copy()) # copy so backing store persists
451
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
452
+ base_pm = QPixmap.fromImage(qimg) # already detached above
449
453
 
450
454
  # scale to display zoom (keeps UI sane)
451
455
  zw = max(1, int(round(w * self._zoom)))
452
456
  zh = max(1, int(round(h * self._zoom)))
453
- pm = base_pm.scaled(zw, zh, Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
457
+ pm = base_pm.scaled(
458
+ zw, zh,
459
+ Qt.AspectRatioMode.IgnoreAspectRatio,
460
+ Qt.TransformationMode.SmoothTransformation
461
+ )
454
462
 
455
463
  # draw AP boxes in *display coords* so thickness doesn't scale
456
464
  p = QPainter(pm)
457
465
  p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
458
466
 
459
467
  s_img = int(max(8, self._ap_size))
460
- half_img = s_img // 2
461
468
  s_disp = max(2, int(round(s_img * self._zoom)))
462
469
  half_disp = s_disp // 2
463
470
 
@@ -832,6 +839,8 @@ class SERStackerDialog(QDialog):
832
839
  debayer: bool = True,
833
840
  keep_percent: float = 20.0,
834
841
  bayer_pattern: Optional[str] = None,
842
+ planet_min_val=0.02, planet_use_norm=False, planet_norm_hi_pct=99.5,
843
+ planet_thresh_pct=92.0, planet_smooth_sigma=1.5, **kwargs
835
844
  ):
836
845
  super().__init__(parent)
837
846
  self.setWindowTitle("Planetary Stacker - Beta")
@@ -840,6 +849,13 @@ class SERStackerDialog(QDialog):
840
849
  self.setModal(False)
841
850
  self._bayer_pattern = bayer_pattern
842
851
  self._keep_mask = None # np.ndarray bool shape (N,) or None
852
+
853
+ self._planet_min_val = float(planet_min_val)
854
+ self._planet_use_norm = bool(planet_use_norm)
855
+ self._planet_norm_hi_pct = float(planet_norm_hi_pct)
856
+ self._planet_thresh_pct = float(planet_thresh_pct)
857
+ self._planet_smooth_sigma = float(planet_smooth_sigma)
858
+
843
859
  # ---- Normalize inputs ------------------------------------------------
844
860
  # If caller provided only `source`, treat string-source as ser_path too.
845
861
  if source is None:
@@ -1445,7 +1461,7 @@ class SERStackerDialog(QDialog):
1445
1461
  surface_anchor=self._surface_anchor,
1446
1462
  keep_percent=float(self.spin_keep.value()),
1447
1463
  bayer_pattern=self._bayer_pattern,
1448
- keep_mask=getattr(self, "_keep_mask", None),
1464
+ keep_mask=getattr(self, "_keep_mask", None),
1449
1465
 
1450
1466
  ap_size=int(self.spin_ap_size.value()),
1451
1467
  ap_spacing=int(self.spin_ap_spacing.value()),
@@ -1455,7 +1471,14 @@ class SERStackerDialog(QDialog):
1455
1471
  getattr(self, "chk_ssd_bruteforce", None) and self.chk_ssd_bruteforce.isChecked()
1456
1472
  ),
1457
1473
 
1458
- # ✅ drizzle
1474
+ # ✅ NEW: planetary centroid knobs (add UI controls or set defaults)
1475
+ planet_smooth_sigma=self._planet_smooth_sigma,
1476
+ planet_thresh_pct=self._planet_thresh_pct,
1477
+ planet_min_val=self._planet_min_val,
1478
+ planet_use_norm=self._planet_use_norm,
1479
+ planet_norm_hi_pct=self._planet_norm_hi_pct,
1480
+
1481
+ # drizzle
1459
1482
  drizzle_scale=float(drizzle_scale),
1460
1483
  drizzle_pixfrac=float(self.spin_pixfrac.value()),
1461
1484
  drizzle_kernel=str(drizzle_kernel),
@@ -1783,7 +1806,7 @@ class BlinkKeepersDialog(QDialog):
1783
1806
  v = (mono - lo) / (hi - lo)
1784
1807
  v = np.clip(v, 0.0, 1.0)
1785
1808
  return (v * 255.0 + 0.5).astype(np.uint8)
1786
-
1809
+
1787
1810
  def _show_index(self, i: int):
1788
1811
  if self.keepers.size == 0:
1789
1812
  return
@@ -1810,9 +1833,14 @@ class BlinkKeepersDialog(QDialog):
1810
1833
  img = img[..., 0]
1811
1834
 
1812
1835
  u8 = self._disp_u8(img)
1836
+
1837
+ # ✅ memmap/FITS-safe: guarantee tight row stride for bytesPerLine=w
1838
+ if not u8.flags["C_CONTIGUOUS"]:
1839
+ u8 = np.ascontiguousarray(u8)
1840
+
1813
1841
  h, w = u8.shape
1814
- qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8)
1815
- pm = QPixmap.fromImage(qimg.copy())
1842
+ qimg = QImage(u8.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
1843
+ pm = QPixmap.fromImage(qimg)
1816
1844
 
1817
1845
  self.lbl.setPixmap(pm.scaled(
1818
1846
  self.lbl.size(),
@@ -1821,7 +1849,6 @@ class BlinkKeepersDialog(QDialog):
1821
1849
  ))
1822
1850
  self._update_labels()
1823
1851
 
1824
-
1825
1852
  def resizeEvent(self, e):
1826
1853
  super().resizeEvent(e)
1827
1854
  self._show_index(self.sld.value())
@@ -19,14 +19,22 @@ def _to_mono01(img: np.ndarray) -> np.ndarray:
19
19
 
20
20
 
21
21
  class PlanetaryTracker:
22
- """
23
- Tracks by centroid of the brightest connected component inside ROI.
24
- Good for: planets, full disk objects.
25
- """
26
- def __init__(self, smooth_sigma: float = 1.5, thresh_pct: float = 92.0):
22
+ def __init__(
23
+ self,
24
+ *,
25
+ smooth_sigma: float = 1.5,
26
+ thresh_pct: float = 92.0,
27
+ min_val: float = 0.02, # ✅ NEW
28
+ use_norm: bool = True,
29
+ norm_hi_pct: float = 99.5,
30
+ norm_lo_pct: float = 1.0,
31
+ ):
27
32
  self.smooth_sigma = float(smooth_sigma)
28
33
  self.thresh_pct = float(thresh_pct)
29
- self._ref_center = None # (cx, cy)
34
+ self.min_val = float(min_val) # store
35
+ self.use_norm = bool(use_norm)
36
+ self.norm_hi_pct = float(norm_hi_pct)
37
+ self.norm_lo_pct = float(norm_lo_pct)
30
38
 
31
39
  def reset(self):
32
40
  self._ref_center = None
@@ -71,23 +79,20 @@ class PlanetaryTracker:
71
79
  conf = float(np.clip(mm["m00"] / float(mask.size), 0.0, 1.0))
72
80
  return (cx, cy, conf)
73
81
 
82
+ def _normalize_for_detect(self, m: np.ndarray) -> np.ndarray:
83
+ if not self.use_norm:
84
+ return m
74
85
 
75
- def step(self, img01: np.ndarray) -> tuple[float, float, float]:
76
- """
77
- Returns (dx, dy, conf) where dx/dy shifts FROM current frame TO reference.
78
- """
79
- m = _to_mono01(img01)
80
- m2 = self._blur(m)
81
-
82
- # adaptive threshold by percentile
83
- t = float(np.percentile(m2, self.thresh_pct))
84
- if not np.isfinite(t):
85
- return 0.0, 0.0, 0.0
86
+ lo = float(np.percentile(m, self.norm_lo_pct))
87
+ hi = float(np.percentile(m, self.norm_hi_pct))
88
+ if (not np.isfinite(lo)) or (not np.isfinite(hi)) or (hi <= lo + 1e-12):
89
+ return m
86
90
 
87
- mask = (m2 >= t).astype(np.uint8) * 255
88
- mask = self._largest_component_mask(mask)
91
+ det = (m - lo) / (hi - lo)
92
+ return np.clip(det, 0.0, 1.0).astype(np.float32, copy=False)
89
93
 
90
- cx, cy, conf = self._centroid(m2, mask)
94
+ def step(self, img01: np.ndarray) -> tuple[float, float, float]:
95
+ cx, cy, conf = self.compute_center(img01)
91
96
  if conf <= 0.0:
92
97
  return 0.0, 0.0, 0.0
93
98
 
@@ -98,25 +103,42 @@ class PlanetaryTracker:
98
103
  rx, ry = self._ref_center
99
104
  dx = rx - cx
100
105
  dy = ry - cy
101
- return float(dx), float(dy), conf
106
+ return float(dx), float(dy), float(conf)
107
+
108
+ def _prep_mono01(self, img01: np.ndarray) -> np.ndarray:
109
+ m = _to_mono01(img01).astype(np.float32, copy=False)
110
+ m = np.nan_to_num(m, nan=0.0, posinf=0.0, neginf=0.0)
102
111
 
103
- def compute_center(self, img01: np.ndarray) -> tuple[float, float, float]:
104
- """
105
- Compute (cx, cy, conf) in pixels in the provided image coordinate system.
106
- Uses the same pipeline as step(): blur -> percentile thresh -> largest CC -> centroid.
107
- """
108
- m = _to_mono01(img01)
109
- m2 = self._blur(m)
112
+ # match step(): blur then lo/hi percentile normalize
113
+ if self.smooth_sigma > 0.0 and cv2 is not None:
114
+ m = cv2.GaussianBlur(m, (0, 0), float(self.smooth_sigma))
110
115
 
111
- t = float(np.percentile(m2, self.thresh_pct))
112
- if not np.isfinite(t):
113
- return 0.0, 0.0, 0.0
116
+ m = self._normalize_for_detect(m)
117
+ return np.clip(m, 0.0, 1.0).astype(np.float32, copy=False)
118
+
119
+ def compute_center(self, img01: np.ndarray):
120
+ m = self._prep_mono01(img01) # already blurred + normalized
121
+
122
+ thr = float(np.percentile(m, np.clip(self.thresh_pct, 0.0, 100.0)))
123
+ if not np.isfinite(thr):
124
+ return (m.shape[1] * 0.5), (m.shape[0] * 0.5), 0.0
125
+
126
+ # ✅ critical: threshold cannot be below min_val (same domain: normalized [0..1])
127
+ thr = max(thr, float(self.min_val))
128
+
129
+ mask = (m >= thr).astype(np.uint8)
130
+ if int(mask.sum()) < 10:
131
+ return (m.shape[1] * 0.5), (m.shape[0] * 0.5), 0.0
132
+
133
+ ys, xs = np.nonzero(mask)
134
+ w = m[ys, xs]
135
+ sw = float(w.sum()) + 1e-12
136
+ cx = float((xs * w).sum() / sw)
137
+ cy = float((ys * w).sum() / sw)
114
138
 
115
- mask = (m2 >= t).astype(np.uint8) * 255
116
- mask = self._largest_component_mask(mask)
139
+ conf = float(np.clip((float(np.mean(w)) - thr) / max(1e-6, (1.0 - thr)), 0.0, 1.0))
140
+ return cx, cy, conf
117
141
 
118
- cx, cy, conf = self._centroid(m2, mask)
119
- return float(cx), float(cy), float(conf)
120
142
 
121
143
  def shift_to_ref(self, img01: np.ndarray, ref_center: tuple[float, float]) -> tuple[float, float, float]:
122
144
  """