chasepy 2.0.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.
chase/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """chase — a modular CHASE/HIS calibration + flare-analysis pipeline.
2
+
3
+ Nothing here is a forced pipeline: every stage is an importable function you can
4
+ call on its own, *and* there is a one-call orchestrator (:func:`run_pipeline`), a
5
+ config-file workflow (:class:`Config`), a CLI (``chase``), and a TUI
6
+ (``chase-tui``) layered on top of the exact same functions.
7
+
8
+ Quick start (SDK)
9
+ -----------------
10
+ >>> import chase
11
+ >>> cube, hdr, wav = chase.load_cube("RSM..._HA.fits")
12
+ >>> seq = chase.load_flare_sequence("/path/to/fits", patch=[940, 1080, 1870, 2040])
13
+ >>> aligned = chase.optical_flow_align(seq["ha_cubes"], reference=seq["align_ref"])
14
+ >>> contrast, _ = chase.contrast_profile(aligned, background=seq["ha_bg"])
15
+
16
+ Quick start (lazy)
17
+ ------------------
18
+ >>> from chase import Config, run_pipeline
19
+ >>> run_pipeline(Config(fits_dir="/path/to/fits", patch=[940, 1080, 1870, 2040],
20
+ ... contrast=True, temperature="double"))
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from . import _compat # noqa: F401 (lzma stub + headless matplotlib)
26
+
27
+ __version__ = "2.0.0"
28
+
29
+ # --- I/O -------------------------------------------------------------------
30
+ from .io import (
31
+ discover_fits,
32
+ download_file,
33
+ extract_disk_center,
34
+ load_cube,
35
+ load_fits_data,
36
+ load_flare_sequence,
37
+ resolve_inputs,
38
+ save_cube_fits,
39
+ save_npz,
40
+ )
41
+ download = download_file # SDK alias
42
+
43
+ # --- Calibration -----------------------------------------------------------
44
+ from .calib import (
45
+ align_spectrum,
46
+ atlas_calibrate,
47
+ correct_spectral_drift,
48
+ create_optical_flow_solver,
49
+ find_disk_center,
50
+ hough_disk_center,
51
+ integral_scaling,
52
+ normalize_intensity,
53
+ optical_flow_align,
54
+ phase_shift,
55
+ planck_inversion,
56
+ recenter,
57
+ resample_wavelength,
58
+ track_and_crop,
59
+ warp_cube,
60
+ )
61
+
62
+ # --- Analysis --------------------------------------------------------------
63
+ from .analysis import (
64
+ contrast_profile,
65
+ fe_eb_temperature,
66
+ fe_planck_temperature,
67
+ fe_voigt_temperature,
68
+ halpha_width_map,
69
+ halpha_width_temperature,
70
+ width_to_temperature,
71
+ )
72
+
73
+ # --- Visualisation ---------------------------------------------------------
74
+ from .viz import (
75
+ make_animation,
76
+ make_contrast_animation,
77
+ make_temperature_animation,
78
+ )
79
+
80
+ # --- Orchestration ---------------------------------------------------------
81
+ from .config import Config, load_config
82
+ from .pipeline import run_pipeline
83
+
84
+ __all__ = [
85
+ "__version__",
86
+ # io
87
+ "download", "download_file", "resolve_inputs", "discover_fits",
88
+ "load_cube", "load_fits_data", "load_flare_sequence", "extract_disk_center",
89
+ "save_cube_fits", "save_npz",
90
+ # calib
91
+ "find_disk_center", "hough_disk_center", "recenter", "phase_shift",
92
+ "track_and_crop", "optical_flow_align", "warp_cube",
93
+ "create_optical_flow_solver", "resample_wavelength", "correct_spectral_drift",
94
+ "align_spectrum", "normalize_intensity", "integral_scaling",
95
+ "atlas_calibrate", "planck_inversion",
96
+ # analysis
97
+ "contrast_profile", "halpha_width_map", "halpha_width_temperature",
98
+ "width_to_temperature", "fe_planck_temperature", "fe_eb_temperature",
99
+ "fe_voigt_temperature",
100
+ # viz
101
+ "make_animation", "make_contrast_animation", "make_temperature_animation",
102
+ # orchestration
103
+ "Config", "load_config", "run_pipeline",
104
+ ]
chase/_compat.py ADDED
@@ -0,0 +1,78 @@
1
+ """Environment-compatibility shims.
2
+
3
+ Importing :mod:`chase._compat` (which happens automatically when you ``import
4
+ chase``) makes the package usable in two awkward-but-common situations:
5
+
6
+ 1. **Python builds without the ``_lzma`` C extension.** ``astropy`` imports
7
+ :mod:`lzma` at load time, so on a pyenv/conda interpreter compiled without
8
+ the xz headers an ``import astropy`` explodes with ``ModuleNotFoundError:
9
+ _lzma``. We install a minimal stub *before* astropy is imported. The stub
10
+ carries a real ``__spec__`` because :mod:`importlib` refuses to treat a bare
11
+ ``ModuleType`` as a package.
12
+
13
+ 2. **Headless matplotlib.** We default ``MPLBACKEND`` to ``Agg`` and point
14
+ ``MPLCONFIGDIR`` at a writable per-user cache so figure generation works on a
15
+ server / in CI with no ``$HOME/.config`` write access.
16
+
17
+ Both shims are idempotent and only activate when needed, so importing this on a
18
+ fully-featured interpreter is a no-op.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ __all__ = ["ensure_lzma_stub", "ensure_headless_matplotlib"]
29
+
30
+
31
+ def ensure_lzma_stub() -> bool:
32
+ """Install a stub :mod:`lzma` module if the real one is unavailable.
33
+
34
+ Returns
35
+ -------
36
+ bool
37
+ ``True`` if a stub was installed, ``False`` if the real module works.
38
+ """
39
+ try: # pragma: no cover - depends on interpreter build
40
+ import lzma # noqa: F401
41
+
42
+ return False
43
+ except ImportError:
44
+ from importlib.machinery import ModuleSpec
45
+ from types import ModuleType
46
+
47
+ stub = ModuleType("lzma")
48
+ stub.__spec__ = ModuleSpec("lzma", loader=None)
49
+
50
+ class _MissingLZMAFile: # noqa: D401 - mirror of v50 stub
51
+ def __init__(self, *args, **kwargs):
52
+ raise RuntimeError(
53
+ "lzma is unavailable in this Python build; rebuild Python "
54
+ "with the xz/liblzma headers to read .xz-compressed FITS."
55
+ )
56
+
57
+ stub.LZMAFile = _MissingLZMAFile
58
+ sys.modules["lzma"] = stub
59
+ return True
60
+
61
+
62
+ def ensure_headless_matplotlib(config_dir: str | os.PathLike | None = None) -> None:
63
+ """Configure matplotlib for headless rendering (Agg + writable cache)."""
64
+ os.environ.setdefault("MPLBACKEND", "Agg")
65
+ if "MPLCONFIGDIR" not in os.environ:
66
+ if config_dir is None:
67
+ config_dir = Path(tempfile.gettempdir()) / "chase_mplconfig"
68
+ cfg = Path(config_dir)
69
+ try:
70
+ cfg.mkdir(parents=True, exist_ok=True)
71
+ os.environ["MPLCONFIGDIR"] = str(cfg)
72
+ except OSError: # pragma: no cover - fall back to matplotlib default
73
+ pass
74
+
75
+
76
+ # Apply on import; safe and idempotent.
77
+ ensure_lzma_stub()
78
+ ensure_headless_matplotlib()
@@ -0,0 +1,23 @@
1
+ """Science analysis: contrast profiles and temperature maps."""
2
+
3
+ from .contrast import contrast_profile
4
+ from .temperature import (
5
+ fe_eb_temperature,
6
+ fe_planck_temperature,
7
+ fe_voigt_temperature,
8
+ halpha_width_map,
9
+ halpha_width_temperature,
10
+ measure_halpha_width,
11
+ width_to_temperature,
12
+ )
13
+
14
+ __all__ = [
15
+ "contrast_profile",
16
+ "halpha_width_map",
17
+ "halpha_width_temperature",
18
+ "measure_halpha_width",
19
+ "width_to_temperature",
20
+ "fe_planck_temperature",
21
+ "fe_eb_temperature",
22
+ "fe_voigt_temperature",
23
+ ]
@@ -0,0 +1,70 @@
1
+ """Spectral contrast profile (wavelength vs time).
2
+
3
+ The contrast map averages an aligned Hα cube over the spatial axes to get one
4
+ spectrum per frame, then expresses each as a fractional excess over a quiet-Sun
5
+ baseline. The physically correct definition uses a **separate background patch**::
6
+
7
+ contrast(t, λ) = (flare(t, λ) − bg(t, λ)) / bg(t, λ) − [same at frame 0]
8
+
9
+ which removes instrumental throughput drift that a naive ``frame_t/frame_0 − 1``
10
+ leaves in. When no background patch is supplied it falls back to the naive form.
11
+
12
+ In the resulting wavelength-vs-time image the erupting CME shows up as a red
13
+ Doppler feature in the Hα wing — the science hook for the A&A letter.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+
22
+ __all__ = ["contrast_profile"]
23
+
24
+
25
+ def contrast_profile(
26
+ ha_cubes: np.ndarray,
27
+ background: Optional[np.ndarray] = None,
28
+ baseline_frame: int = 0,
29
+ correct_drift: bool = True,
30
+ ):
31
+ """Compute the wavelength-vs-time contrast profile of an aligned cube.
32
+
33
+ Parameters
34
+ ----------
35
+ ha_cubes : ndarray (nframes, nchannels, H, W)
36
+ Aligned Hα spectral stack.
37
+ background : ndarray (nframes, nchannels, H, W) or None, optional
38
+ Quiet-Sun background patch (same shape). If given, the
39
+ ``(flare − bg)/bg`` form is used; otherwise ``frame_t/frame_0 − 1``.
40
+ baseline_frame : int, optional
41
+ Frame whose profile is subtracted as the pre-flare baseline.
42
+ correct_drift : bool, optional
43
+ Apply 1-D spectral-drift correction before averaging.
44
+
45
+ Returns
46
+ -------
47
+ contrast : ndarray (nframes, nchannels)
48
+ The contrast map (time along axis 0, wavelength along axis 1).
49
+ mean_spec : ndarray (nframes, nchannels)
50
+ Spatially-averaged flare spectra (post drift-correction).
51
+ """
52
+ cube = ha_cubes
53
+ if correct_drift:
54
+ from ..calib.wavelength import correct_spectral_drift
55
+
56
+ cube, _ = correct_spectral_drift(cube, verbose=False)
57
+
58
+ mean_spec = np.mean(cube, axis=(2, 3)) # (nframes, nch)
59
+
60
+ if background is not None:
61
+ mean_bg = np.mean(background, axis=(2, 3))
62
+ mean_bg = np.where(mean_bg == 0, 1.0, mean_bg)
63
+ contrast = (mean_spec - mean_bg) / mean_bg
64
+ contrast = contrast - contrast[baseline_frame]
65
+ else:
66
+ ref = mean_spec[baseline_frame].copy()
67
+ ref[ref == 0] = 1.0
68
+ contrast = mean_spec / ref[np.newaxis, :] - 1.0
69
+
70
+ return contrast, mean_spec
@@ -0,0 +1,246 @@
1
+ """Temperature maps — split by line physics.
2
+
3
+ Two regimes, two methods:
4
+
5
+ * **Hα → chromospheric temperature** via the line *width* (Molnar et al. 2019,
6
+ ApJ 881, 99). Hα forms out of LTE, so a Planck inversion is invalid; instead
7
+ the half-depth width maps onto an ALMA brightness temperature,
8
+ ``T[K] = (width[Å] − 0.553) / 6.12e-5``.
9
+
10
+ * **Fe I → photospheric temperature** via a Planck inversion. Fe I 6569 Å is a
11
+ photospheric LTE line, so ``B_ν(T) = I_core`` holds once the data is on an
12
+ absolute radiance scale (see :func:`chase.calib.atlas_calibrate`). Three core
13
+ estimators are offered:
14
+ - :func:`fe_planck_temperature` — Gaussian fit to the line core (default);
15
+ - :func:`fe_eb_temperature` — Eddington–Barbier per-wavelength inversion;
16
+ - :func:`fe_voigt_temperature` — Voigt fit (robust when the Fe line is
17
+ under-sampled).
18
+
19
+ References
20
+ ----------
21
+ Molnar et al. 2019, ApJ 881, 99 (10.3847/1538-4357/ab2ba3).
22
+ Eddington–Barbier: aa21259-13 (A&A 2013).
23
+ ISPy FTS atlas: ISP-SST/ISPy.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Tuple
29
+
30
+ import numpy as np
31
+
32
+ from ..calib.intensity import planck_inversion
33
+
34
+ __all__ = [
35
+ "measure_halpha_width",
36
+ "halpha_width_map",
37
+ "width_to_temperature",
38
+ "halpha_width_temperature",
39
+ "fe_planck_temperature",
40
+ "fe_eb_temperature",
41
+ "fe_voigt_temperature",
42
+ ]
43
+
44
+ _MOLNAR_OFFSET = 0.553
45
+ _MOLNAR_SLOPE = 6.12e-5
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Hα width → chromospheric temperature (Molnar 2019)
50
+ # ---------------------------------------------------------------------------
51
+ def _find_crossing(spectrum, wavelengths, indices, level):
52
+ for j in range(len(indices) - 1):
53
+ i0, i1 = indices[j], indices[j + 1]
54
+ s0, s1 = spectrum[i0], spectrum[i1]
55
+ if (s0 <= level <= s1) or (s1 <= level <= s0):
56
+ if s1 == s0:
57
+ return wavelengths[i0]
58
+ frac = (level - s0) / (s1 - s0)
59
+ return wavelengths[i0] + frac * (wavelengths[i1] - wavelengths[i0])
60
+ return np.nan
61
+
62
+
63
+ def measure_halpha_width(spectrum, wavelengths, core_wavelength=6562.8, wing_offset=1.0):
64
+ """Half-depth Hα width (Å) of one spectrum (Molnar 2019 definition).
65
+
66
+ Finds the core minimum, measures the depth relative to the mean intensity at
67
+ ``±wing_offset`` Å, and returns the wavelength separation where the profile
68
+ crosses the half-depth level. Returns NaN if the measurement is ill-defined.
69
+ """
70
+ from scipy.interpolate import interp1d
71
+
72
+ core_mask = np.abs(wavelengths - core_wavelength) < 0.5
73
+ if core_mask.sum() < 3:
74
+ return np.nan
75
+ region = np.where(core_mask)[0]
76
+ local_min = region[np.argmin(spectrum[region])]
77
+ core_wav = wavelengths[local_min]
78
+ i_core = spectrum[local_min]
79
+
80
+ wb, wr = core_wav - wing_offset, core_wav + wing_offset
81
+ if wb < wavelengths[0] or wr > wavelengths[-1]:
82
+ return np.nan
83
+ f = interp1d(wavelengths, spectrum, kind="linear")
84
+ depth = 0.5 * (float(f(wb)) + float(f(wr))) - i_core
85
+ if depth <= 0:
86
+ return np.nan
87
+ level = i_core + 0.5 * depth
88
+
89
+ blue = np.where(wavelengths < core_wav)[0][::-1]
90
+ red = np.where(wavelengths > core_wav)[0]
91
+ if len(blue) < 2 or len(red) < 2:
92
+ return np.nan
93
+ bx = _find_crossing(spectrum, wavelengths, blue, level)
94
+ rx = _find_crossing(spectrum, wavelengths, red, level)
95
+ if np.isnan(bx) or np.isnan(rx):
96
+ return np.nan
97
+ return rx - bx
98
+
99
+
100
+ def width_to_temperature(width):
101
+ """Molnar 2019: ``T[K] = (width[Å] − 0.553) / 6.12e-5``."""
102
+ return (width - _MOLNAR_OFFSET) / _MOLNAR_SLOPE
103
+
104
+
105
+ def halpha_width_map(ha_cube, wavelengths, core_wavelength=6562.8) -> np.ndarray:
106
+ """Per-pixel Hα width map (Å) for one frame's cube ``(nch, H, W)``."""
107
+ nch, H, W = ha_cube.shape
108
+ width = np.full((H, W), np.nan, dtype=np.float32)
109
+ for y in range(H):
110
+ for x in range(W):
111
+ spec = ha_cube[:, y, x]
112
+ if np.any(np.isnan(spec)) or np.all(spec == 0):
113
+ continue
114
+ width[y, x] = measure_halpha_width(spec, wavelengths, core_wavelength)
115
+ return width
116
+
117
+
118
+ def halpha_width_temperature(
119
+ ha_cube, wavelengths, core_wavelength=6562.8, t_min=4000.0, t_max=20000.0
120
+ ) -> Tuple[np.ndarray, np.ndarray]:
121
+ """Chromospheric temperature map from Hα width (Molnar 2019).
122
+
123
+ Returns ``(temperature_map, width_map)``; temperatures outside
124
+ ``[t_min, t_max]`` are masked to NaN.
125
+ """
126
+ width = halpha_width_map(ha_cube, wavelengths, core_wavelength)
127
+ temp = width_to_temperature(width)
128
+ temp[(temp < t_min) | (temp > t_max)] = np.nan
129
+ return temp, width
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Fe I → photospheric temperature (Planck) — three estimators
134
+ # ---------------------------------------------------------------------------
135
+ def _gaussian(x, amp, mu, sigma, offset):
136
+ return offset + amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
137
+
138
+
139
+ def _fit_fe_core_gaussian(spectrum, wavelengths, n_lowest=6):
140
+ from scipy.optimize import curve_fit
141
+
142
+ if np.any(np.isnan(spectrum)) or np.all(spectrum == 0):
143
+ return np.nan
144
+ lowest = np.sort(np.argsort(spectrum)[:n_lowest])
145
+ wav_fit, spec_fit = wavelengths[lowest], spectrum[lowest]
146
+ cont = spectrum.max()
147
+ try:
148
+ popt, _ = curve_fit(
149
+ _gaussian, wav_fit, spec_fit,
150
+ p0=[spec_fit.min() - cont, wav_fit[np.argmin(spec_fit)], 0.05, cont],
151
+ maxfev=2000,
152
+ )
153
+ i_core = popt[3] + popt[0]
154
+ return float(i_core) if i_core > 0 else np.nan
155
+ except (RuntimeError, ValueError):
156
+ return np.nan
157
+
158
+
159
+ def fe_planck_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
160
+ """Photospheric T from a Gaussian-core Planck inversion of Fe I.
161
+
162
+ For each pixel: Gaussian-fit the line core to the ~6 lowest channels, scale
163
+ DN→radiance with ``ifact`` (from :func:`chase.calib.atlas_calibrate`), and
164
+ invert the Planck function at the Fe core wavelength.
165
+
166
+ Returns ``(temperature_map, core_intensity_map)``.
167
+ """
168
+ nch, H, W = fe_cube.shape
169
+ core_int = np.full((H, W), np.nan, dtype=np.float32)
170
+ for y in range(H):
171
+ for x in range(W):
172
+ core_int[y, x] = _fit_fe_core_gaussian(fe_cube[:, y, x], wavelengths)
173
+ fe_core_wav = wavelengths[np.argmin(np.mean(fe_cube, axis=(1, 2)))]
174
+ temp = planck_inversion(core_int * ifact, wavelength_ang=fe_core_wav).astype(np.float32)
175
+ temp[(temp < t_min) | (temp > t_max)] = np.nan
176
+ return temp, core_int
177
+
178
+
179
+ def fe_eb_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
180
+ """Eddington–Barbier photospheric T (per-wavelength Planck inversion).
181
+
182
+ At disk centre (μ=1) ``I(λ) = B(T(τ_λ=1), λ)``, so every wavelength probes a
183
+ different height. Returns ``(temp_cube, temp_core, temp_continuum)`` where
184
+ ``temp_cube`` is ``(nch, H, W)``, ``temp_core`` samples the per-pixel minimum,
185
+ and ``temp_continuum`` averages the last three channels.
186
+ """
187
+ nch, H, W = fe_cube.shape
188
+ I_cgs = fe_cube.astype(np.float64) * ifact
189
+ temp_cube = np.empty_like(I_cgs, dtype=np.float32)
190
+ for ich in range(nch):
191
+ temp_cube[ich] = planck_inversion(I_cgs[ich], wavelengths[ich]).astype(np.float32)
192
+ core_ch = np.argmin(fe_cube, axis=0)
193
+ temp_core = np.take_along_axis(temp_cube, core_ch[np.newaxis], axis=0)[0]
194
+ temp_cont = np.mean(temp_cube[-3:], axis=0)
195
+ for arr in (temp_core, temp_cont):
196
+ arr[(arr < t_min) | (arr > t_max)] = np.nan
197
+ return temp_cube, temp_core, temp_cont
198
+
199
+
200
+ def _fit_fe_voigt(spectrum, wavelengths):
201
+ from scipy.optimize import curve_fit
202
+ from scipy.special import voigt_profile
203
+
204
+ if np.any(np.isnan(spectrum)) or np.all(spectrum == 0):
205
+ return np.nan, np.nan
206
+ cont = float(np.mean(spectrum[-3:]))
207
+ core_idx = int(np.argmin(spectrum))
208
+
209
+ def model(wav, amp, center, sigma, gamma, continuum, slope):
210
+ v = voigt_profile(wav - center, sigma, gamma)
211
+ vpeak = voigt_profile(0.0, sigma, gamma)
212
+ return (continuum + slope * (wav - center)) - amp * v / vpeak
213
+
214
+ try:
215
+ popt, _ = curve_fit(
216
+ model, wavelengths, spectrum,
217
+ p0=[cont - spectrum[core_idx], wavelengths[core_idx], 0.05, 0.02, cont, 0.0],
218
+ bounds=(
219
+ [0, wavelengths[0], 0.001, 0.001, 0, -2000],
220
+ [cont * 2, wavelengths[-1], 0.5, 0.5, cont * 2, 2000],
221
+ ),
222
+ maxfev=5000,
223
+ )
224
+ i_core = popt[4] - popt[0]
225
+ return (float(i_core), float(popt[1])) if i_core > 0 else (np.nan, np.nan)
226
+ except (RuntimeError, ValueError):
227
+ return np.nan, np.nan
228
+
229
+
230
+ def fe_voigt_temperature(fe_cube, wavelengths, ifact, t_min=3000.0, t_max=8000.0):
231
+ """Photospheric T from a Voigt-core Planck inversion of Fe I.
232
+
233
+ Robust when the Fe I line is under-sampled (a Gaussian can miss the true
234
+ core). Returns ``(temperature_map, core_intensity_map, center_map)``.
235
+ """
236
+ nch, H, W = fe_cube.shape
237
+ core_map = np.full((H, W), np.nan, dtype=np.float32)
238
+ center_map = np.full((H, W), np.nan, dtype=np.float32)
239
+ for y in range(H):
240
+ for x in range(W):
241
+ ic, cen = _fit_fe_voigt(fe_cube[:, y, x].astype(np.float64), wavelengths)
242
+ core_map[y, x], center_map[y, x] = ic, cen
243
+ median_center = float(np.nanmedian(center_map))
244
+ temp = planck_inversion(core_map * ifact, median_center).astype(np.float32)
245
+ temp[(temp < t_min) | (temp > t_max)] = np.nan
246
+ return temp, core_map, center_map
@@ -0,0 +1,48 @@
1
+ """Calibration stages: spatial, alignment, wavelength, intensity.
2
+
3
+ Every function here is usable on its own — nothing forces a full pipeline.
4
+ """
5
+
6
+ from .spatial import (
7
+ find_disk_center,
8
+ hough_disk_center,
9
+ phase_shift,
10
+ recenter,
11
+ track_and_crop,
12
+ )
13
+ from .align import create_optical_flow_solver, optical_flow_align, warp_cube
14
+ from .wavelength import (
15
+ align_spectrum,
16
+ correct_spectral_drift,
17
+ cross_correlation_shift,
18
+ resample_wavelength,
19
+ )
20
+ from .intensity import (
21
+ atlas_calibrate,
22
+ integral_scaling,
23
+ normalize_intensity,
24
+ planck_inversion,
25
+ )
26
+
27
+ __all__ = [
28
+ # spatial
29
+ "find_disk_center",
30
+ "hough_disk_center",
31
+ "phase_shift",
32
+ "recenter",
33
+ "track_and_crop",
34
+ # align
35
+ "create_optical_flow_solver",
36
+ "optical_flow_align",
37
+ "warp_cube",
38
+ # wavelength
39
+ "align_spectrum",
40
+ "correct_spectral_drift",
41
+ "cross_correlation_shift",
42
+ "resample_wavelength",
43
+ # intensity
44
+ "atlas_calibrate",
45
+ "integral_scaling",
46
+ "normalize_intensity",
47
+ "planck_inversion",
48
+ ]
chase/calib/align.py ADDED
@@ -0,0 +1,127 @@
1
+ """Fine alignment via dense optical flow.
2
+
3
+ Integer crop-tracking (:func:`chase.calib.spatial.track_and_crop`) removes whole
4
+ pixel jitter; residual sub-pixel wobble is removed here with a dense optical-flow
5
+ warp.
6
+
7
+ The flow field is computed **once per frame** from a single flare-free reference
8
+ channel converted to ``uint8`` (TV-L1 if OpenCV's ``optflow`` module is present,
9
+ else Farneback). The resulting displacement map is then applied with
10
+ ``cv2.remap`` (cubic) to **every** wavelength channel of the float32 science
11
+ cubes — the science data never leaves float precision.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Optional
17
+
18
+ import numpy as np
19
+
20
+ __all__ = ["create_optical_flow_solver", "calculate_optical_flow", "warp_cube", "optical_flow_align"]
21
+
22
+
23
+ def create_optical_flow_solver():
24
+ """Return a TV-L1 optical-flow solver, or ``None`` to signal Farneback."""
25
+ import cv2
26
+
27
+ try:
28
+ return cv2.optflow.DualTVL1OpticalFlow_create()
29
+ except AttributeError:
30
+ try:
31
+ return cv2.DualTVL1OpticalFlow_create()
32
+ except AttributeError:
33
+ return None
34
+
35
+
36
+ def calculate_optical_flow(ref_u8: np.ndarray, cur_u8: np.ndarray, solver=None) -> np.ndarray:
37
+ """Compute the dense flow field mapping ``cur`` onto ``ref`` (both uint8)."""
38
+ import cv2
39
+
40
+ if solver is not None:
41
+ return solver.calc(ref_u8, cur_u8, None)
42
+ return cv2.calcOpticalFlowFarneback(
43
+ ref_u8, cur_u8, None,
44
+ pyr_scale=0.5, levels=3, winsize=15, iterations=3,
45
+ poly_n=5, poly_sigma=1.2, flags=0,
46
+ )
47
+
48
+
49
+ def _to_u8(img: np.ndarray) -> np.ndarray:
50
+ import cv2
51
+
52
+ return cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
53
+
54
+
55
+ def warp_cube(cube: np.ndarray, map_x: np.ndarray, map_y: np.ndarray) -> np.ndarray:
56
+ """Apply a precomputed remap to every channel of a cube (cubic, reflect)."""
57
+ import cv2
58
+
59
+ out = np.empty_like(cube)
60
+ for ch in range(cube.shape[0]):
61
+ out[ch] = cv2.remap(
62
+ cube[ch], map_x, map_y,
63
+ interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REFLECT,
64
+ )
65
+ return out
66
+
67
+
68
+ def optical_flow_align(
69
+ cubes: np.ndarray,
70
+ reference: Optional[np.ndarray] = None,
71
+ ref_idx: int = 0,
72
+ method: str = "tvl1",
73
+ extra_cubes: Optional[np.ndarray] = None,
74
+ verbose: bool = False,
75
+ ):
76
+ """Stabilise a frame sequence with optical-flow warping.
77
+
78
+ Parameters
79
+ ----------
80
+ cubes : ndarray (nframes, nchannels, H, W)
81
+ Science cubes to warp (e.g. the Hα stack). Stays float32.
82
+ reference : ndarray (nframes, H, W) or None, optional
83
+ Per-frame flare-free image used to *compute* the flow. If None, the last
84
+ channel of ``cubes`` is used. Pass the Fe I last wavelength here to
85
+ avoid stabilising the eruption away.
86
+ ref_idx : int, optional
87
+ Index of the reference frame everything is aligned to.
88
+ method : {"tvl1", "farneback"}, optional
89
+ Optical-flow algorithm (TV-L1 falls back to Farneback if unavailable).
90
+ extra_cubes : ndarray (nframes, nchannels2, H, W) or None, optional
91
+ A second stack (e.g. Fe I) warped with the *same* per-frame map.
92
+ verbose : bool, optional
93
+
94
+ Returns
95
+ -------
96
+ aligned : ndarray, same shape as ``cubes``
97
+ aligned_extra : ndarray or None
98
+ Present only when ``extra_cubes`` is given.
99
+ """
100
+ import cv2 # noqa: F401 (ensure available early)
101
+
102
+ nframes, nch, H, W = cubes.shape
103
+ if reference is None:
104
+ reference = cubes[:, -1, :, :]
105
+
106
+ solver = create_optical_flow_solver() if method == "tvl1" else None
107
+ ref_u8 = _to_u8(reference[ref_idx])
108
+
109
+ X, Y = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32))
110
+
111
+ aligned = np.empty_like(cubes)
112
+ aligned_extra = np.empty_like(extra_cubes) if extra_cubes is not None else None
113
+
114
+ for i in range(nframes):
115
+ cur_u8 = _to_u8(reference[i])
116
+ flow = calculate_optical_flow(ref_u8, cur_u8, solver)
117
+ map_x = (X + flow[..., 0]).astype(np.float32)
118
+ map_y = (Y + flow[..., 1]).astype(np.float32)
119
+ aligned[i] = warp_cube(cubes[i], map_x, map_y)
120
+ if aligned_extra is not None:
121
+ aligned_extra[i] = warp_cube(extra_cubes[i], map_x, map_y)
122
+ if verbose:
123
+ print(f" optical-flow frame {i}/{nframes - 1} aligned")
124
+
125
+ if aligned_extra is not None:
126
+ return aligned, aligned_extra
127
+ return aligned