350 lines
14 KiB
Python
350 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.
|