setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -1715,11 +1715,20 @@ class _MMFits:
1715
1715
  raise ValueError(f"Unsupported ndim={self.ndim} for {path}")
1716
1716
 
1717
1717
  def _apply_fixed_fits_scale(self, arr: np.ndarray) -> np.ndarray:
1718
+ """
1719
+ Map 8/16-bit FITS integer samples to [0,1] using a fixed divisor.
1720
+ IMPORTANT: Only do this for integer dtypes. If Astropy already returned
1721
+ float (e.g. BSCALE/BZERO applied), do NOT divide again.
1722
+ """
1723
+ # Only scale raw integer pixel arrays
1724
+ if arr.dtype.kind not in ("u", "i"):
1725
+ return arr
1726
+
1718
1727
  bitpix = getattr(self, "_bitpix", 0)
1719
1728
  if bitpix == 8:
1720
- arr /= 255.0
1729
+ return arr / 255.0
1721
1730
  elif bitpix == 16:
1722
- arr /= 65535.0
1731
+ return arr / 65535.0
1723
1732
  return arr
1724
1733
 
1725
1734
  def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
@@ -1847,9 +1856,9 @@ class ReferenceFrameReviewDialog(QDialog):
1847
1856
  self.initUI()
1848
1857
  self.loadImageArray() # Load the image into self.original_image
1849
1858
  if self.original_image is not None:
1850
- self.updatePreview(self.original_image) # Ensure the first image is shown
1851
- if self.original_image is not None:
1852
- QTimer.singleShot(0, self.zoomIn)
1859
+ QTimer.singleShot(0, lambda: self.updatePreview(self.original_image, fit=True))
1860
+ #if self.original_image is not None:
1861
+ # QTimer.singleShot(0, self.zoomIn)
1853
1862
 
1854
1863
 
1855
1864
  def initUI(self):
@@ -1907,6 +1916,89 @@ class ReferenceFrameReviewDialog(QDialog):
1907
1916
  self.setLayout(main_layout)
1908
1917
  self.zoomIn()
1909
1918
 
1919
+ def _ensure_hwc(self, x: np.ndarray) -> np.ndarray:
1920
+ """Ensure HWC for RGB, HW for mono."""
1921
+ if x is None:
1922
+ return None
1923
+ x = np.asarray(x)
1924
+ # CHW -> HWC
1925
+ if x.ndim == 3 and x.shape[0] == 3 and x.shape[-1] != 3:
1926
+ x = np.transpose(x, (1, 2, 0))
1927
+ # squeeze HWC with singleton
1928
+ if x.ndim == 3 and x.shape[-1] == 1:
1929
+ x = np.squeeze(x, axis=-1)
1930
+ return x
1931
+
1932
+
1933
+ def _robust_preview_stretch(self, img: np.ndarray,
1934
+ lo_pct: float = 0.25,
1935
+ hi_pct: float = 99.75,
1936
+ gamma: float = 0.65) -> np.ndarray:
1937
+ """
1938
+ Robust preview stretch:
1939
+ - nan/inf safe
1940
+ - pedestal remove per channel (img - min)
1941
+ - percentile clip to kill outliers
1942
+ - scale to 0..1
1943
+ - gentle gamma (default <1 brightens)
1944
+ Returns float32 in [0,1] and preserves mono vs RGB.
1945
+ """
1946
+ x = self._ensure_hwc(img)
1947
+ if x is None:
1948
+ return None
1949
+
1950
+ x = np.asarray(x, dtype=np.float32)
1951
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
1952
+
1953
+ # Mono
1954
+ if x.ndim == 2:
1955
+ x = x - float(x.min())
1956
+ # percentile clip on non-flat data
1957
+ p_lo = float(np.percentile(x, lo_pct))
1958
+ p_hi = float(np.percentile(x, hi_pct))
1959
+ if p_hi > p_lo:
1960
+ x = np.clip(x, p_lo, p_hi)
1961
+ x = (x - p_lo) / (p_hi - p_lo)
1962
+ else:
1963
+ mx = float(x.max())
1964
+ if mx > 0:
1965
+ x = x / mx
1966
+ if gamma is not None and gamma > 0:
1967
+ x = np.power(np.clip(x, 0.0, 1.0), gamma)
1968
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
1969
+
1970
+ # RGB (HWC)
1971
+ if x.ndim == 3 and x.shape[2] == 3:
1972
+ out = np.empty_like(x, dtype=np.float32)
1973
+ for c in range(3):
1974
+ ch = x[..., c]
1975
+ ch = ch - float(ch.min())
1976
+ p_lo = float(np.percentile(ch, lo_pct))
1977
+ p_hi = float(np.percentile(ch, hi_pct))
1978
+ if p_hi > p_lo:
1979
+ ch = np.clip(ch, p_lo, p_hi)
1980
+ ch = (ch - p_lo) / (p_hi - p_lo)
1981
+ else:
1982
+ mx = float(ch.max())
1983
+ if mx > 0:
1984
+ ch = ch / mx
1985
+ out[..., c] = ch
1986
+
1987
+ if gamma is not None and gamma > 0:
1988
+ out = np.power(np.clip(out, 0.0, 1.0), gamma)
1989
+
1990
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1991
+
1992
+ # Fallback: treat as scalar field
1993
+ x = x - float(x.min())
1994
+ mx = float(x.max())
1995
+ if mx > 0:
1996
+ x = x / mx
1997
+ if gamma is not None and gamma > 0:
1998
+ x = np.power(np.clip(x, 0.0, 1.0), gamma)
1999
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
2000
+
2001
+
1910
2002
  def fitToPreview(self):
1911
2003
  """Calculate and set the zoom factor so that the image fills the preview area."""
1912
2004
  if self.original_image is None:
@@ -1933,32 +2025,46 @@ class ReferenceFrameReviewDialog(QDialog):
1933
2025
 
1934
2026
  def _normalize_preview_01(self, img: np.ndarray) -> np.ndarray:
1935
2027
  """
1936
- Normalize image to [0,1] for preview/stretch:
1937
-
1938
- 1. Handle NaNs/inf safely.
1939
- 2. If min < 0 or max > 1, do (img - min) / (max - min).
1940
- 3. Always return float32 in [0,1].
2028
+ Always normalize to [0,1]:
2029
+ img = img - min(img)
2030
+ img = img / max(img)
2031
+ Per-channel if RGB, global if mono.
1941
2032
  """
1942
2033
  if img is None:
1943
2034
  return None
1944
2035
 
1945
- img = np.asarray(img, dtype=np.float32)
1946
- img = np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0)
1947
-
1948
- finite = np.isfinite(img)
1949
- if not finite.any():
1950
- return np.zeros_like(img, dtype=np.float32)
1951
-
1952
- mn = float(img[finite].min())
1953
- mx = float(img[finite].max())
1954
- if mx == mn:
1955
- # flat frame → just zero it
1956
- return np.zeros_like(img, dtype=np.float32)
2036
+ x = np.asarray(img, dtype=np.float32)
2037
+ x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
1957
2038
 
1958
- if mn < 0.0 or mx > 1.0:
1959
- img = (img - mn) / (mx - mn)
2039
+ if x.ndim == 2:
2040
+ mn = float(x.min())
2041
+ x = x - mn
2042
+ mx = float(x.max())
2043
+ if mx > 0:
2044
+ x = x / mx
2045
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
2046
+
2047
+ if x.ndim == 3 and x.shape[2] == 3:
2048
+ # per-channel pedestal remove + normalize
2049
+ out = x.copy()
2050
+ for c in range(3):
2051
+ ch = out[..., c]
2052
+ mn = float(ch.min())
2053
+ ch = ch - mn
2054
+ mx = float(ch.max())
2055
+ if mx > 0:
2056
+ ch = ch / mx
2057
+ out[..., c] = ch
2058
+ return np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
2059
+
2060
+ # fallback
2061
+ mn = float(x.min())
2062
+ x = x - mn
2063
+ mx = float(x.max())
2064
+ if mx > 0:
2065
+ x = x / mx
2066
+ return np.clip(x, 0.0, 1.0).astype(np.float32, copy=False)
1960
2067
 
1961
- return np.clip(img, 0.0, 1.0)
1962
2068
 
1963
2069
 
1964
2070
  def loadImageArray(self):
@@ -1981,22 +2087,44 @@ class ReferenceFrameReviewDialog(QDialog):
1981
2087
 
1982
2088
  self.original_image = img
1983
2089
 
2090
+ def _fit_zoom_to_viewport(self, image: np.ndarray):
2091
+ """Set zoom_factor so image fits inside the scrollArea viewport."""
2092
+ if image is None:
2093
+ return
2094
+
2095
+ img = self._ensure_hwc(image)
2096
+
2097
+ if img.ndim == 2:
2098
+ h, w = img.shape
2099
+ elif img.ndim == 3 and img.shape[2] == 3:
2100
+ h, w = img.shape[:2]
2101
+ else:
2102
+ return
2103
+
2104
+ vp = self.scrollArea.viewport().size()
2105
+ if vp.width() <= 0 or vp.height() <= 0 or w <= 0 or h <= 0:
2106
+ return
2107
+
2108
+ # Fit-to-viewport zoom
2109
+ self.zoom_factor = min(vp.width() / w, vp.height() / h)
1984
2110
 
1985
- def updatePreview(self, image):
1986
- """
1987
- Convert a given image array to a QPixmap and update the preview label.
1988
- """
2111
+ def updatePreview(self, image, *, fit: bool = False):
1989
2112
  self.current_preview_image = image
2113
+
2114
+ if fit:
2115
+ self._fit_zoom_to_viewport(image)
2116
+
1990
2117
  pixmap = self.convertArrayToPixmap(image)
1991
2118
  if pixmap is None or pixmap.isNull():
1992
2119
  self.previewLabel.setText(self.tr("Unable to load preview."))
1993
- else:
1994
- available_size = self.scrollArea.viewport().size()
1995
- new_size = QSize(int(available_size.width() * self.zoom_factor),
1996
- int(available_size.height() * self.zoom_factor))
1997
- scaled_pixmap = pixmap.scaled(new_size, Qt.AspectRatioMode.KeepAspectRatio,
1998
- Qt.TransformationMode.SmoothTransformation)
1999
- self.previewLabel.setPixmap(scaled_pixmap)
2120
+ return
2121
+
2122
+ scaled = pixmap.scaled(
2123
+ pixmap.size() * self.zoom_factor,
2124
+ Qt.AspectRatioMode.KeepAspectRatio,
2125
+ Qt.TransformationMode.SmoothTransformation
2126
+ )
2127
+ self.previewLabel.setPixmap(scaled)
2000
2128
 
2001
2129
  def _preview_boost(self, img: np.ndarray) -> np.ndarray:
2002
2130
  """Robust, very gentle stretch for display when image would quantize to black."""
@@ -2014,62 +2142,48 @@ class ReferenceFrameReviewDialog(QDialog):
2014
2142
  if image is None:
2015
2143
  return None
2016
2144
 
2017
- img = image.astype(np.float32, copy=False)
2018
-
2019
- # If image is so dim or flat that 8-bit will zero-out, boost for preview
2020
- ptp = float(img.max() - img.min())
2021
- needs_boost = (float(img.max()) <= (1.0 / 255.0)) or (ptp < 1e-6) or (not np.isfinite(img).all())
2022
- if needs_boost:
2023
- img = self._preview_boost(np.nan_to_num(img, nan=0.0, posinf=0.0, neginf=0.0))
2145
+ # ALWAYS normalize to [0,1]
2146
+ img = self._normalize_preview_01(image)
2024
2147
 
2025
2148
  # Convert to 8-bit for QImage
2026
2149
  display_image = (img * 255.0).clip(0, 255).astype(np.uint8)
2027
2150
 
2151
+ # IMPORTANT: ensure contiguous memory
2152
+ display_image = np.ascontiguousarray(display_image)
2153
+
2154
+ # Keep a reference so Qt's QImage always has valid backing memory
2155
+ self._last_preview_u8 = display_image
2156
+
2028
2157
  if display_image.ndim == 2:
2029
2158
  h, w = display_image.shape
2030
- q_image = QImage(display_image.data, w, h, w, QImage.Format.Format_Grayscale8)
2159
+ q_image = QImage(self._last_preview_u8.data, w, h, w, QImage.Format.Format_Grayscale8)
2160
+ q_image = q_image.copy() # detach from numpy buffer (extra safety)
2031
2161
  elif display_image.ndim == 3 and display_image.shape[2] == 3:
2032
2162
  h, w, _ = display_image.shape
2033
- q_image = QImage(display_image.data, w, h, 3 * w, QImage.Format.Format_RGB888)
2163
+ q_image = QImage(self._last_preview_u8.data, w, h, 3 * w, QImage.Format.Format_RGB888)
2164
+ q_image = q_image.copy() # detach
2034
2165
  else:
2035
2166
  return None
2167
+
2036
2168
  return QPixmap.fromImage(q_image)
2037
-
2169
+
2038
2170
  def toggleAutostretch(self):
2039
2171
  if self.original_image is None:
2040
2172
  QMessageBox.warning(self, self.tr("Error"), self.tr("Reference image not loaded."))
2041
2173
  return
2042
2174
 
2043
- # 🔹 Ensure the image we feed to Statistical Stretch is in [0,1]
2044
- base = self._normalize_preview_01(self.original_image)
2045
-
2046
2175
  self.autostretch_enabled = not self.autostretch_enabled
2176
+
2047
2177
  if self.autostretch_enabled:
2048
- if base.ndim == 2:
2049
- new_image = stretch_mono_image(
2050
- base,
2051
- target_median=0.3,
2052
- normalize=True,
2053
- apply_curves=False
2054
- )
2055
- elif base.ndim == 3 and base.shape[2] == 3:
2056
- new_image = stretch_color_image(
2057
- base,
2058
- target_median=0.3,
2059
- linked=False,
2060
- normalize=True,
2061
- apply_curves=False
2062
- )
2063
- else:
2064
- new_image = base
2178
+ new_image = self._robust_preview_stretch(self.original_image)
2065
2179
  self.toggleAutoStretchButton.setText(self.tr("Disable Autostretch"))
2066
2180
  else:
2067
- new_image = base
2181
+ new_image = self._normalize_preview_01(self.original_image)
2068
2182
  self.toggleAutoStretchButton.setText(self.tr("Enable Autostretch"))
2069
2183
 
2070
- self.updatePreview(new_image)
2184
+ self.updatePreview(new_image, fit=True)
2071
2185
 
2072
-
2186
+
2073
2187
  def zoomIn(self):
2074
2188
  self.zoom_factor *= 1.2
2075
2189
  if self.current_preview_image is not None:
@@ -3532,7 +3646,8 @@ class _MMImage:
3532
3646
  self._orig_dtype = None
3533
3647
  self._color_axis = None
3534
3648
  self._spat_axes = (0, 1)
3535
-
3649
+ self._dbg = bool(os.environ.get("SASPRO_MMIMAGE_DEBUG", "0") == "1")
3650
+ self._dbg_count = 0
3536
3651
  self._xisf = None
3537
3652
  self._xisf_memmap = None # np.memmap when possible
3538
3653
  self._xisf_arr = None # decompressed ndarray when needed
@@ -3554,6 +3669,11 @@ class _MMImage:
3554
3669
  self._open_fits(path)
3555
3670
  self._kind = "fits"
3556
3671
 
3672
+ def _dbg_log(self, msg: str):
3673
+ if not getattr(self, "_dbg", False):
3674
+ return
3675
+ print(msg) # or your logger
3676
+
3557
3677
  # ---------------- FITS ----------------
3558
3678
  def _open_fits(self, path: str):
3559
3679
  """
@@ -3659,17 +3779,27 @@ class _MMImage:
3659
3779
  # ---------------- common API ----------------
3660
3780
  def _apply_fixed_fits_scale(self, arr: np.ndarray) -> np.ndarray:
3661
3781
  """
3662
- Map 8/16-bit FITS (already BZERO/BSCALE-scaled by Astropy) to [0,1]
3663
- using a fixed divisor. No per-frame img/max(img) normalization.
3782
+ Map 8/16-bit FITS integer samples to [0,1] using a fixed divisor.
3783
+ IMPORTANT: Only do this for integer dtypes. If Astropy already returned
3784
+ float (e.g. BSCALE/BZERO applied), do NOT divide again.
3664
3785
  """
3786
+ # Only scale raw integer pixel arrays
3787
+ if arr.dtype.kind not in ("u", "i"):
3788
+ return arr
3789
+
3665
3790
  bitpix = getattr(self, "_bitpix", 0)
3666
3791
  if bitpix == 8:
3667
- arr /= 255.0
3792
+ return arr / 255.0
3668
3793
  elif bitpix == 16:
3669
- arr /= 65535.0
3794
+ return arr / 65535.0
3670
3795
  return arr
3671
3796
 
3797
+
3672
3798
  def read_tile(self, y0, y1, x0, x1) -> np.ndarray:
3799
+ import os
3800
+ import numpy as np
3801
+
3802
+ # ---- FITS / XISF tile read (unchanged) ----
3673
3803
  if self._kind == "fits":
3674
3804
  d = self._fits_data
3675
3805
  if self.ndim == 2:
@@ -3683,23 +3813,23 @@ class _MMImage:
3683
3813
  tile = np.moveaxis(tile, self._color_axis, -1)
3684
3814
  else:
3685
3815
  if self._xisf_memmap is not None:
3686
- # memmapped (C,H,W) → slice, then move to (H,W,C)
3687
3816
  C = 1 if self.ndim == 2 else self.shape[2]
3688
3817
  if C == 1:
3689
3818
  tile = self._xisf_memmap[0, y0:y1, x0:x1]
3690
3819
  else:
3691
- tile = np.moveaxis(
3692
- self._xisf_memmap[:, y0:y1, x0:x1], 0, -1
3693
- )
3820
+ tile = np.moveaxis(self._xisf_memmap[:, y0:y1, x0:x1], 0, -1)
3694
3821
  else:
3695
3822
  tile = self._xisf_arr[y0:y1, x0:x1]
3696
3823
 
3697
- # Cast to float32
3824
+ # Cast to float32 copy (what you actually feed the stacker)
3698
3825
  out = np.array(tile, dtype=np.float32, copy=True, order="C")
3699
3826
 
3700
- # For FITS, apply fixed 8/16-bit normalization
3827
+
3828
+ # ---- APPLY FIXED SCALE (your real suspect) ----
3701
3829
  if self._kind == "fits":
3702
- out = self._apply_fixed_fits_scale(out)
3830
+ out2 = self._apply_fixed_fits_scale(out)
3831
+
3832
+ out = out2
3703
3833
 
3704
3834
  # ensure (h,w,3) or (h,w)
3705
3835
  if out.ndim == 3 and out.shape[-1] not in (1, 3):
@@ -3707,6 +3837,7 @@ class _MMImage:
3707
3837
  out = np.moveaxis(out, 0, -1)
3708
3838
  if out.ndim == 3 and out.shape[-1] == 1:
3709
3839
  out = np.squeeze(out, axis=-1)
3840
+
3710
3841
  return out
3711
3842
 
3712
3843
  def read_full(self) -> np.ndarray:
@@ -4177,6 +4308,94 @@ def _temp_to_stem_tag(temp_c: float, *, prefix: str = "") -> str:
4177
4308
  s = s.replace(".", "p") # e.g. "10p0"
4178
4309
  return f"{prefix}{sign}{s}C"
4179
4310
 
4311
+
4312
+ def _arr_stats(a: np.ndarray):
4313
+ a = np.asarray(a)
4314
+ fin = np.isfinite(a)
4315
+ if fin.any():
4316
+ v = a[fin]
4317
+ return dict(
4318
+ dtype=str(a.dtype),
4319
+ shape=tuple(a.shape),
4320
+ finite=int(fin.sum()),
4321
+ nan=int(np.isnan(a).sum()),
4322
+ inf=int(np.isinf(a).sum()),
4323
+ min=float(v.min()),
4324
+ max=float(v.max()),
4325
+ p01=float(np.percentile(v, 1)),
4326
+ p50=float(np.percentile(v, 50)),
4327
+ )
4328
+ return dict(dtype=str(a.dtype), shape=tuple(a.shape), finite=0, nan=int(np.isnan(a).sum()), inf=int(np.isinf(a).sum()))
4329
+
4330
+ def _print_stats(tag: str, a: np.ndarray, *, bit_depth=None, hdr=None):
4331
+ s = _arr_stats(a)
4332
+ bd = f", bit_depth={bit_depth}" if bit_depth is not None else ""
4333
+ print(f"🧪 {tag}{bd} dtype={s['dtype']} shape={s['shape']} finite={s['finite']} nan={s['nan']} inf={s['inf']}")
4334
+ if s["finite"] > 0:
4335
+ print(f" min={s['min']:.6f} p01={s['p01']:.6f} p50={s['p50']:.6f} max={s['max']:.6f}")
4336
+ # Header hints (best-effort)
4337
+ if hdr is not None:
4338
+ try:
4339
+ # FITS-ish
4340
+ if hasattr(hdr, "get"):
4341
+ print(f" hdr: BITPIX={hdr.get('BITPIX', 'NA')} BSCALE={hdr.get('BSCALE', 'NA')} BZERO={hdr.get('BZERO', 'NA')}")
4342
+ except Exception:
4343
+ pass
4344
+
4345
+ def _warn_if_units_mismatch(light: np.ndarray, dark: np.ndarray | None, flat: np.ndarray | None):
4346
+ # Heuristic: if one is ~0..1 and another is hundreds/thousands, you’ve got mixed scaling.
4347
+ def _range_kind(a):
4348
+ if a is None:
4349
+ return None
4350
+ fin = np.isfinite(a)
4351
+ if not fin.any():
4352
+ return None
4353
+ mx = float(np.max(a[fin]))
4354
+ mn = float(np.min(a[fin]))
4355
+ return (mn, mx)
4356
+
4357
+ lr = _range_kind(light)
4358
+ dr = _range_kind(dark)
4359
+ fr = _range_kind(flat)
4360
+
4361
+ def _is_01(r):
4362
+ if r is None: return False
4363
+ mn, mx = r
4364
+ return mx <= 2.5 and mn >= -0.5
4365
+
4366
+ def _is_aduish(r):
4367
+ if r is None: return False
4368
+ mn, mx = r
4369
+ return mx >= 50.0 # conservative
4370
+
4371
+ if lr and dr and _is_01(lr) and _is_aduish(dr):
4372
+ print("🚨 UNITS MISMATCH: light looks ~0–1, but dark looks like ADU (tens/hundreds/thousands). Expect huge negatives after subtraction.")
4373
+ if lr and fr and _is_01(lr) and _is_aduish(fr):
4374
+ print("🚨 UNITS MISMATCH: light looks ~0–1, but flat looks like ADU. Flat division will be wrong unless normalized to ~1 first.")
4375
+
4376
+ def _maybe_normalize_16bit_float(a: np.ndarray, *, name: str = "") -> np.ndarray:
4377
+ """
4378
+ Fast guard:
4379
+ - If float array has max > 10, assume it's really 16-bit ADU data stored as float,
4380
+ and normalize to 0..1 by dividing by 65535.
4381
+ """
4382
+ if a is None:
4383
+ return a
4384
+ if not np.issubdtype(a.dtype, np.floating):
4385
+ return a
4386
+
4387
+ fin = np.isfinite(a)
4388
+ if not fin.any():
4389
+ return a
4390
+
4391
+ mx = float(a[fin].max()) # fast reduction
4392
+
4393
+ if mx > 10.0:
4394
+ print(f"🛡️ Units-guard: {name or 'array'} max={mx:.3f} (>10). Assuming 16-bit ADU-in-float; normalizing /65535.")
4395
+ return (a / 65535.0).astype(np.float32, copy=False)
4396
+
4397
+ return a
4398
+
4180
4399
  class StackingSuiteDialog(QDialog):
4181
4400
  requestRelaunch = pyqtSignal(str, str) # old_dir, new_dir
4182
4401
  status_signal = pyqtSignal(str)
@@ -5362,6 +5581,20 @@ class StackingSuiteDialog(QDialog):
5362
5581
 
5363
5582
  left_col.addWidget(gb_general)
5364
5583
 
5584
+ self.temp_group_step_spin = QDoubleSpinBox()
5585
+ self.temp_group_step_spin.setRange(0.0, 20.0) # 0 disables grouping-by-temp (optional behavior)
5586
+ self.temp_group_step_spin.setDecimals(2)
5587
+ self.temp_group_step_spin.setSingleStep(0.1)
5588
+ self.temp_group_step_spin.setValue(
5589
+ self.settings.value("stacking/temp_group_step", 1.0, type=float)
5590
+ )
5591
+ self.temp_group_step_spin.setToolTip(
5592
+ self.tr("Temperature grouping tolerance in °C.\n"
5593
+ "Frames within ±step are grouped together.\n"
5594
+ "Set 0 to disable temperature-based grouping.")
5595
+ )
5596
+ fl_general.addRow(self.tr("Temp grouping step (°C):"), self.temp_group_step_spin)
5597
+
5365
5598
  # --- Distortion / Transform model ---
5366
5599
  # --- Distortion / Transform model ---
5367
5600
  disto_box = QGroupBox(self.tr("Distortion / Transform"))
@@ -6139,7 +6372,8 @@ class StackingSuiteDialog(QDialog):
6139
6372
  self.settings.setValue("stacking/chunk_width", self.chunk_width)
6140
6373
  self.settings.setValue("stacking/autocrop_enabled", self.autocrop_cb.isChecked())
6141
6374
  self.settings.setValue("stacking/autocrop_pct", float(self.autocrop_pct.value()))
6142
-
6375
+ self.temp_group_step = float(self.temp_group_step_spin.value())
6376
+ self.settings.setValue("stacking/temp_group_step", self.temp_group_step)
6143
6377
  # ----- alignment model (affine | homography | poly3 | poly4) -----
6144
6378
  model_idx = self.align_model_combo.currentIndex()
6145
6379
  if model_idx == 0: model_name = "affine"
@@ -10607,7 +10841,8 @@ class StackingSuiteDialog(QDialog):
10607
10841
  set_t = _get_key_float(header, "SET-TEMP")
10608
10842
  chosen_t = ccd_t if ccd_t is not None else set_t
10609
10843
 
10610
- temp_step = self.settings.value("stacking/temp_group_step", 1.0, type=float)
10844
+ temp_step = float(self.settings.value("stacking/temp_group_step", 1.0, type=float) or 1.0)
10845
+ temp_step = max(0.0, temp_step)
10611
10846
  temp_bucket = self._bucket_temp(chosen_t, step=temp_step)
10612
10847
  temp_label = self._temp_label(temp_bucket, step=temp_step)
10613
10848
 
@@ -10836,7 +11071,8 @@ class StackingSuiteDialog(QDialog):
10836
11071
  # Group darks by (exposure +/- tolerance, image size, session, temp_bucket)
10837
11072
  # TEMP_STEP is the rounding bucket (1.0C default)
10838
11073
  # -------------------------------------------------------------------------
10839
- TEMP_STEP = self.settings.value("stacking/temp_group_step", 1.0, type=float)
11074
+ TEMP_STEP = float(self.settings.value("stacking/temp_group_step", 1.0, type=float) or 1.0)
11075
+ TEMP_STEP = max(0.0, TEMP_STEP)
10840
11076
 
10841
11077
  dark_files_by_group: dict[tuple[float, str, str, float | None], list[str]] = {} # (exp,size,session,temp)->list
10842
11078
 
@@ -13178,6 +13414,7 @@ class StackingSuiteDialog(QDialog):
13178
13414
 
13179
13415
  # ---------- LOAD LIGHT ----------
13180
13416
  light_data, hdr, bit_depth, is_mono = load_image(light_file)
13417
+ #_print_stats("LIGHT raw", light_data, bit_depth=bit_depth, hdr=hdr)
13181
13418
  if light_data is None or hdr is None:
13182
13419
  self.update_status(self.tr(f"❌ ERROR: Failed to load {os.path.basename(light_file)}"))
13183
13420
  continue
@@ -13202,7 +13439,10 @@ class StackingSuiteDialog(QDialog):
13202
13439
 
13203
13440
  # ---------- APPLY DARK (if resolved) ----------
13204
13441
  if master_dark_path:
13205
- dark_data, _, _, dark_is_mono = load_image(master_dark_path)
13442
+ dark_data, _, dark_bit_depth, dark_is_mono = load_image(master_dark_path)
13443
+ #_print_stats("DARK raw", dark_data, bit_depth=dark_bit_depth)
13444
+ dark_data = _maybe_normalize_16bit_float(dark_data, name=os.path.basename(master_dark_path))
13445
+ #_print_stats("DARK normalized", dark_data, bit_depth=dark_bit_depth)
13206
13446
  if dark_data is not None:
13207
13447
  if not dark_is_mono and dark_data.ndim == 3 and dark_data.shape[-1] == 3:
13208
13448
  dark_data = dark_data.transpose(2, 0, 1) # HWC -> CHW
@@ -13218,7 +13458,10 @@ class StackingSuiteDialog(QDialog):
13218
13458
 
13219
13459
  # ---------- APPLY FLAT (if resolved) ----------
13220
13460
  if master_flat_path:
13221
- flat_data, _, _, flat_is_mono = load_image(master_flat_path)
13461
+ flat_data, _, flat_bit_depth, flat_is_mono = load_image(master_flat_path)
13462
+ #_print_stats("FLAT raw", flat_data, bit_depth=flat_bit_depth)
13463
+ flat_data = _maybe_normalize_16bit_float(flat_data, name=os.path.basename(master_flat_path))
13464
+ #_print_stats("FLAT normalized", flat_data, bit_depth=flat_bit_depth)
13222
13465
  if flat_data is not None:
13223
13466
 
13224
13467
  # Make flat layout match your working light layout:
@@ -13332,8 +13575,10 @@ class StackingSuiteDialog(QDialog):
13332
13575
  max_val = float(np.max(light_data))
13333
13576
  self.update_status(self.tr(f"Before saving: min = {min_val:.4f}, max = {max_val:.4f}"))
13334
13577
  print(f"Before saving: min = {min_val:.4f}, max = {max_val:.4f}")
13578
+
13579
+ _warn_if_units_mismatch(light_data, dark_data if master_dark_path else None, flat_data if master_flat_path else None)
13580
+ _print_stats("LIGHT final", light_data)
13335
13581
  QApplication.processEvents()
13336
-
13337
13582
  # Annotate header
13338
13583
  try:
13339
13584
  if hasattr(hdr, "add_history"):