noaa-goes-visualization/suvi/fillers.py

349 lines
14 KiB
Python

"""Methods for reconstructing a missing or rejected frame.
Every filler takes the same :class:`FillContext` and returns a replacement array, so
the bench can swap them without knowing which one it is holding. All are pure: they
read the context and return an array, nothing else.
The methods span a deliberate range of physical sophistication, from "repeat the last
good frame" (what the pipeline does today) to a differential-rotation warp that models
how the Sun actually moves. The bench exists to say which of them is worth the cost
at which gap length.
"""
from dataclasses import dataclass, field
import cv2 as cv
import numpy as np
#: Nominal solar radius in metres (IAU 2015).
R_SUN = 6.957e8
#: Snodgrass (1983) sidereal differential rotation, degrees per day, by latitude.
SNODGRASS_A = 14.713
SNODGRASS_B = -2.396
SNODGRASS_C = -1.787
#: Earth's mean orbital motion, subtracted to get the rotation an Earth-orbiting
#: observer actually sees.
EARTH_ORBIT_DEG_PER_DAY = 0.9856
SECONDS_PER_DAY = 86400.0
@dataclass
class FillContext:
"""Everything a filler may draw on to reconstruct one frame."""
#: Nearest good frame before the gap, and how many seconds back it sits.
before: np.ndarray | None = None
dt_before: float = 0.0
#: Nearest good frame after the gap, and how many seconds forward.
after: np.ndarray | None = None
dt_after: float = 0.0
#: The other satellite's view of this same instant, if it has one.
counterpart: np.ndarray | None = None
#: Header of the frame being reconstructed, for the WCS a rotation warp needs.
header: dict = field(default_factory=dict)
@property
def alpha(self):
"""Position within the gap: 0 at `before`, 1 at `after`."""
span = self.dt_before + self.dt_after
if span <= 0:
return 0.0
return self.dt_before / span
@property
def gap_frames(self):
"""Gap width in 4-minute slots, for reporting quality against gap length."""
return int(round((self.dt_before + self.dt_after) / 240.0))
def _finite(image):
return np.nan_to_num(np.asarray(image, dtype=np.float32), nan=0.0, posinf=0.0, neginf=0.0)
# ---------------------------------------------------------------- simple baselines
def hold_last(context):
"""Repeat the last good frame.
What ``merger_FITS.py`` does today (up to ``max_time_gap`` slots). Cheap, never
invents structure, but freezes the Sun and then jumps -- the visible stutter in
the current videos. The baseline every other method must beat.
"""
if context.before is not None:
return _finite(context.before)
if context.after is not None:
return _finite(context.after)
return None
def linear_blend(context):
"""Cross-fade between the frames bracketing the gap.
Removes the jump that ``hold_last`` leaves, at the cost of ghosting: moving
features appear twice, faintly, rather than moving.
"""
if context.before is None:
return hold_last(context)
if context.after is None or context.before.shape != context.after.shape:
# Frames of differing size cannot be mixed; the nearer one is the best
# available answer. This is also the fallback the other fillers unwind to.
return _finite(context.before)
alpha = context.alpha
return ((1.0 - alpha) * _finite(context.before) + alpha * _finite(context.after)).astype(
np.float32
)
# ------------------------------------------------------------------- optical flow
def _for_flow(image):
"""Compress radiance into a range optical flow can work with.
Radiance is heavy-tailed -- a flare can be 1000x the quiet corona -- so raw
values make flow chase the brightest pixels only. log1p plus a percentile
stretch keeps faint structure in play.
"""
scaled = np.log1p(np.clip(_finite(image), 0.0, None))
high = np.percentile(scaled, 99.5)
if high <= 0:
return np.zeros(scaled.shape, dtype=np.uint8)
return np.clip(scaled / high * 255.0, 0, 255).astype(np.uint8)
def optical_flow(context, use_dis=True):
"""Motion-compensated interpolation between the bracketing frames.
Estimates dense flow both ways and warps each bracket forward to the target
instant, then blends. Unlike ``linear_blend`` this moves features instead of
dissolving between them, which is what the eye reads as smooth motion.
"""
if context.before is None or context.after is None:
return linear_blend(context)
before, after = _finite(context.before), _finite(context.after)
if before.shape != after.shape:
return linear_blend(context)
first, second = _for_flow(before), _for_flow(after)
if use_dis:
engine = cv.DISOpticalFlow_create(cv.DISOPTICAL_FLOW_PRESET_MEDIUM)
forward = engine.calc(first, second, None)
backward = engine.calc(second, first, None)
else:
forward = cv.calcOpticalFlowFarneback(
first, second, None, 0.5, 3, 15, 3, 5, 1.2, 0
)
backward = cv.calcOpticalFlowFarneback(
second, first, None, 0.5, 3, 15, 3, 5, 1.2, 0
)
alpha = context.alpha
height, width = before.shape
grid_x, grid_y = np.meshgrid(
np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32)
)
# cv.remap samples the source *at* the map coordinates, so to place a feature
# where it should be at time alpha we read from where it was: a feature at x in
# `before` sits at x + forward(x) in `after`, hence at p - alpha*forward(p) when
# looking back from the interpolated frame. Adding the flow instead of
# subtracting it moves every feature the wrong way, which is worse than not
# compensating at all.
warped_before = cv.remap(
before,
grid_x - forward[..., 0] * alpha,
grid_y - forward[..., 1] * alpha,
cv.INTER_LINEAR,
borderMode=cv.BORDER_REPLICATE,
)
warped_after = cv.remap(
after,
grid_x - backward[..., 0] * (1.0 - alpha),
grid_y - backward[..., 1] * (1.0 - alpha),
cv.INTER_LINEAR,
borderMode=cv.BORDER_REPLICATE,
)
return ((1.0 - alpha) * warped_before + alpha * warped_after).astype(np.float32)
# ------------------------------------------------------------ cross-satellite fill
def gain_match(source, reference):
"""Put `source` on `reference`'s radiance scale by least squares.
GOES-16 and GOES-18 carry different SUVI flight models, so their radiances
differ by a roughly affine factor even when both are healthy.
"""
x = _finite(source).ravel().astype(np.float64)
y = _finite(reference).ravel().astype(np.float64)
variance = float(((x - x.mean()) ** 2).sum())
if variance <= 0:
return np.asarray(source, dtype=np.float32)
gain = float(((x - x.mean()) * (y - y.mean())).sum() / variance)
offset = float(y.mean() - gain * x.mean())
return (np.asarray(source, dtype=np.float32) * gain + offset).astype(np.float32)
def crosssat(context, align=True):
"""Substitute the other satellite's view of the same instant.
The two spacecraft see the same Sun from 1 AU, so the substitute is a real
observation of the real Sun at the right time -- not an interpolation. It
should dominate every temporal method whenever it is available, which is the
thing worth quantifying: it is unavailable in the 31% of slots where both
satellites are out simultaneously.
Residual differences are instrument calibration (removed by gain matching) and
a few pixels of geostationary parallax (removed by alignment).
"""
if context.counterpart is None:
return None
counterpart = _finite(context.counterpart)
reference = context.before if context.before is not None else context.after
if reference is None:
return counterpart
reference = _finite(reference)
if counterpart.shape != reference.shape:
return counterpart
if align:
window = cv.createHanningWindow(
(counterpart.shape[1], counterpart.shape[0]), cv.CV_64F
)
(dx, dy), _ = cv.phaseCorrelate(
counterpart.astype(np.float64), reference.astype(np.float64), window
)
matrix = np.array([[1.0, 0.0, dx], [0.0, 1.0, dy]], dtype=np.float32)
counterpart = cv.warpAffine(
counterpart,
matrix,
(counterpart.shape[1], counterpart.shape[0]),
flags=cv.INTER_LINEAR,
borderMode=cv.BORDER_REPLICATE,
)
return gain_match(counterpart, reference)
# --------------------------------------------------------- solar rotation warping
def rotation_rate(latitude_rad, synodic=True):
"""Snodgrass differential rotation in degrees per day at a given latitude."""
sin2 = np.sin(latitude_rad) ** 2
rate = SNODGRASS_A + SNODGRASS_B * sin2 + SNODGRASS_C * sin2**2
return rate - EARTH_ORBIT_DEG_PER_DAY if synodic else rate
def _disc_radius_pixels(header, shape):
"""Solar radius in pixels, from the header if possible."""
diameter = header.get("diam_sun")
if diameter:
return float(diameter) / 2.0
distance, scale = header.get("dsun_obs"), header.get("cdelt1")
if distance and scale:
return float(np.degrees(np.arcsin(R_SUN / distance)) * 3600.0 / scale)
return shape[0] * 0.3 # falls back to the archive's typical disc fraction
def _rotation_map(shape, header, delta_seconds, synodic=True):
"""Inverse map: for each output pixel, where in the input it came from.
Works in heliographic coordinates -- de-project each pixel onto the sphere, undo
the rotation that happened over `delta_seconds`, re-project. Returns
(map_x, map_y, on_disc) with NaN where the source point is not visible.
"""
height, width = shape
radius = _disc_radius_pixels(header, shape)
crpix1 = float(header.get("crpix1", (width + 1) / 2.0)) - 1.0
crpix2 = float(header.get("crpix2", (height + 1) / 2.0)) - 1.0
b0 = np.radians(float(header.get("solar_b0", 0.0)))
grid_x, grid_y = np.meshgrid(np.arange(width), np.arange(height))
x = (grid_x - crpix1) / radius
y = (grid_y - crpix2) / radius
rho2 = x**2 + y**2
on_disc = rho2 < 1.0
z = np.sqrt(np.clip(1.0 - rho2, 0.0, None))
# Plane-of-sky -> heliographic, undoing the observer's B0 tilt.
sin_lat = y * np.cos(b0) + z * np.sin(b0)
sin_lat = np.clip(sin_lat, -1.0, 1.0)
latitude = np.arcsin(sin_lat)
longitude = np.arctan2(x, z * np.cos(b0) - y * np.sin(b0))
# Step the longitude back to where this material was `delta_seconds` ago.
days = delta_seconds / SECONDS_PER_DAY
source_longitude = longitude - np.radians(rotation_rate(latitude, synodic)) * days
# Heliographic -> plane-of-sky.
cos_lat = np.cos(latitude)
sx = cos_lat * np.sin(source_longitude)
sy = sin_lat * np.cos(b0) - cos_lat * np.cos(source_longitude) * np.sin(b0)
sz = sin_lat * np.sin(b0) + cos_lat * np.cos(source_longitude) * np.cos(b0)
visible = on_disc & (sz > 0)
map_x = (sx * radius + crpix1).astype(np.float32)
map_y = (sy * radius + crpix2).astype(np.float32)
return map_x, map_y, visible
def solar_rotation(context, synodic=True):
"""Warp the bracketing frames by differential solar rotation, then blend.
The Sun is not a rigid body: the equator turns in about 25 days, the poles in
about 35. Over a short gap that is a sub-pixel effect, but across a multi-hour
outage it is the difference between features landing where they belong and
smearing. This is the only method here that uses a physical model of the scene.
Applies on-disc only. The corona above the limb does not co-rotate with the
photosphere, so off-disc pixels fall back to a plain cross-fade.
"""
if context.before is None and context.after is None:
return None
if context.before is None or context.after is None:
source = context.before if context.before is not None else context.after
delta = context.dt_before if context.before is not None else -context.dt_after
warped, visible = _warp(source, context.header, delta, synodic)
blended = np.where(visible, warped, _finite(source))
return blended.astype(np.float32)
before, after = _finite(context.before), _finite(context.after)
if before.shape != after.shape:
return linear_blend(context)
# Roll `before` forward to the target instant and `after` backward to it.
warped_before, visible_before = _warp(before, context.header, context.dt_before, synodic)
warped_after, visible_after = _warp(after, context.header, -context.dt_after, synodic)
alpha = context.alpha
rotated = (1.0 - alpha) * warped_before + alpha * warped_after
faded = (1.0 - alpha) * before + alpha * after
visible = visible_before & visible_after
return np.where(visible, rotated, faded).astype(np.float32)
def _warp(image, header, delta_seconds, synodic):
image = _finite(image)
map_x, map_y, visible = _rotation_map(image.shape, header, delta_seconds, synodic)
warped = cv.remap(
image, map_x, map_y, cv.INTER_LINEAR, borderMode=cv.BORDER_CONSTANT, borderValue=0.0
)
return warped, visible
FILLERS = {
"hold_last": hold_last,
"linear_blend": linear_blend,
"optical_flow": optical_flow,
"crosssat": crosssat,
"solar_rotation": solar_rotation,
}
# TODO: learned filler. Train a model to predict a frame from its preceding frames,
# following frames, and the other satellite's view, then evaluate it here across
# severities of missing data and prediction horizons (single-frame gaps through
# multi-hour outages, one satellite out versus both). It plugs in as another entry
# in FILLERS and reuses the bench's existing cases and metrics unchanged.