setiastrosuitepro 1.6.10__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.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -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/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +218 -66
- 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/toolbar_mixin.py +132 -10
- 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 +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- 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 +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.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
|
|
1729
|
+
return arr / 255.0
|
|
1721
1730
|
elif bitpix == 16:
|
|
1722
|
-
arr
|
|
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)
|
|
1851
|
-
if self.original_image is not None:
|
|
1852
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
-
|
|
1946
|
-
|
|
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
|
|
1959
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
3663
|
-
|
|
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
|
|
3792
|
+
return arr / 255.0
|
|
3668
3793
|
elif bitpix == 16:
|
|
3669
|
-
arr
|
|
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
|
-
|
|
3827
|
+
|
|
3828
|
+
# ---- APPLY FIXED SCALE (your real suspect) ----
|
|
3701
3829
|
if self._kind == "fits":
|
|
3702
|
-
|
|
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, _,
|
|
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, _,
|
|
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"):
|