242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""Deterministic alignment of a frame stack onto its target instant.
|
|
|
|
The learned filler's job is *selection*, not physics. Everything about this archive
|
|
that has a closed form is applied here, before any network sees a pixel:
|
|
|
|
* **Rotation.** The Sun's differential rotation is known (Snodgrass 1983), so a frame
|
|
at dt != 0 is warped forward or back to the target instant -- the same field
|
|
:func:`suvi.fillers.solar_rotation` uses, ported to torch so one implementation
|
|
serves 640-pixel training shards and 1280-pixel bench frames, on CPU or GPU.
|
|
* **Photometry.** GOES-16 and GOES-18 SUVI differ by a band-dependent affine transfer
|
|
(gain 0.81-1.49, drifting ~12%/week). A cross-satellite frame is put on the target
|
|
instrument's scale using a (gain, offset) fitted from a simultaneous clean pair --
|
|
the corrected-`crosssat` estimator, verified at +16.5 dB over the stale-bracket fit.
|
|
|
|
After this stage every stack entry is *an estimate of the target frame*, and the two
|
|
views' geometry needs nothing more: the spacecraft sit 0.11 px of parallax apart on
|
|
identical WCS grids, so there is no disparity to solve.
|
|
|
|
Shards do not store WCS headers, so the geometry (solar B0 angle and apparent disc
|
|
radius) comes from the analytic ephemeris below. Both are smooth annual functions of
|
|
the date; the accuracy required is loose -- a 0.1 deg B0 error moves a 20-hour warp by
|
|
under a tenth of a pixel.
|
|
"""
|
|
|
|
import math
|
|
|
|
import numpy as np
|
|
|
|
from . import dataset
|
|
|
|
#: Snodgrass (1983) sidereal differential rotation, degrees per day, by latitude.
|
|
#: Defined here rather than in :mod:`suvi.fillers` because this module must import
|
|
#: inside the ROCm training container, which has torch but not OpenCV; `fillers`
|
|
#: re-exports them so its callers see no change.
|
|
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
|
|
#: Mean apparent solar radius at 1 AU, arcseconds (IAU 2015 nominal radius).
|
|
RADIUS_ARCSEC_1AU = 959.63
|
|
#: SUVI L2 plate scale, arcsec/pixel. Constant across the archive: every frame is
|
|
#: reprojected onto the same grid (CDELT1 = CDELT2 = 2.5, CROTA = 0).
|
|
PLATE_SCALE = 2.5
|
|
#: Native SUVI L2 frame width, pixels. Geometry is expressed as a *fraction* of the
|
|
#: frame so the same numbers serve 640-pixel shards and 1280-pixel archive frames.
|
|
NATIVE_SIZE = 1280
|
|
#: Inclination of the solar equator to the ecliptic, degrees (Carrington).
|
|
SOLAR_INCLINATION = 7.25
|
|
#: Unix time of the J2000.0 epoch.
|
|
J2000_UNIX = 946728000.0
|
|
|
|
|
|
def solar_ephemeris(t_unix):
|
|
"""(b0 radians, disc radius as a fraction of frame width) for a unix time.
|
|
|
|
Low-precision solar position (Meeus, Astronomical Algorithms ch. 25) -- good to
|
|
~0.1 deg in B0 and ~0.1% in distance, far inside what the rotation warp needs.
|
|
B0 is the heliographic latitude of the disc centre: the Earth rides 7.25 deg
|
|
above and below the solar equator over the year, and ignoring that tilts every
|
|
latitude the differential-rotation profile is evaluated at.
|
|
"""
|
|
n = (t_unix - J2000_UNIX) / SECONDS_PER_DAY
|
|
mean_longitude = math.radians((280.460 + 0.9856474 * n) % 360.0)
|
|
mean_anomaly = math.radians((357.528 + 0.9856003 * n) % 360.0)
|
|
ecliptic_longitude = mean_longitude + math.radians(
|
|
1.915 * math.sin(mean_anomaly) + 0.020 * math.sin(2 * mean_anomaly)
|
|
)
|
|
distance_au = 1.00014 - 0.01671 * math.cos(mean_anomaly) \
|
|
- 0.00014 * math.cos(2 * mean_anomaly)
|
|
|
|
# Ascending node of the solar equator on the ecliptic, precessing slowly.
|
|
node = math.radians(73.6667 + 1.395833 * (n / 36525.0 + 1.5))
|
|
b0 = math.asin(
|
|
math.sin(ecliptic_longitude - node) * math.sin(math.radians(SOLAR_INCLINATION))
|
|
)
|
|
|
|
radius_arcsec = RADIUS_ARCSEC_1AU / distance_au
|
|
radius_fraction = radius_arcsec / PLATE_SCALE / NATIVE_SIZE
|
|
return b0, radius_fraction
|
|
|
|
|
|
# ------------------------------------------------------------------ rotation warp
|
|
|
|
|
|
def rotation_grid(size, dt_seconds, b0, radius_fraction, synodic=True):
|
|
"""Sampling grids that undo `dt_seconds` of differential rotation.
|
|
|
|
Torch port of :func:`suvi.fillers._rotation_map`, batched: `dt_seconds`, `b0` and
|
|
`radius_fraction` are 1-D tensors of N frames, and the result is an (N, H, W, 2)
|
|
grid in the normalised align_corners=False convention `grid_sample` expects.
|
|
|
|
Where the source point is off-disc or behind the limb the grid holds the pixel's
|
|
*own* centre, so sampling returns the unwarped value there -- the corona above the
|
|
limb does not co-rotate with the photosphere, matching `solar_rotation`'s
|
|
behaviour exactly.
|
|
"""
|
|
import torch
|
|
|
|
height = width = int(size)
|
|
dt = dt_seconds.reshape(-1, 1, 1).to(torch.float32)
|
|
b0 = b0.reshape(-1, 1, 1).to(torch.float32)
|
|
radius = (radius_fraction.reshape(-1, 1, 1) * width).to(torch.float32)
|
|
device = dt.device
|
|
|
|
centre_x = (width - 1) / 2.0
|
|
centre_y = (height - 1) / 2.0
|
|
grid_y, grid_x = torch.meshgrid(
|
|
torch.arange(height, device=device, dtype=torch.float32),
|
|
torch.arange(width, device=device, dtype=torch.float32),
|
|
indexing="ij",
|
|
)
|
|
x = (grid_x - centre_x) / radius
|
|
y = (grid_y - centre_y) / radius
|
|
|
|
rho2 = x**2 + y**2
|
|
on_disc = rho2 < 1.0
|
|
z = torch.sqrt((1.0 - rho2).clamp(min=0.0))
|
|
|
|
sin_b0, cos_b0 = torch.sin(b0), torch.cos(b0)
|
|
sin_lat = (y * cos_b0 + z * sin_b0).clamp(-1.0, 1.0)
|
|
latitude = torch.asin(sin_lat)
|
|
longitude = torch.atan2(x, z * cos_b0 - y * sin_b0)
|
|
|
|
sin2 = sin_lat**2
|
|
rate = SNODGRASS_A + SNODGRASS_B * sin2 + SNODGRASS_C * sin2**2
|
|
if synodic:
|
|
rate = rate - EARTH_ORBIT_DEG_PER_DAY
|
|
source_longitude = longitude - torch.deg2rad(rate) * (dt / SECONDS_PER_DAY)
|
|
|
|
cos_lat = torch.cos(latitude)
|
|
source_x = cos_lat * torch.sin(source_longitude)
|
|
source_y = sin_lat * cos_b0 - cos_lat * torch.cos(source_longitude) * sin_b0
|
|
source_z = sin_lat * sin_b0 + cos_lat * torch.cos(source_longitude) * cos_b0
|
|
visible = on_disc & (source_z > 0)
|
|
|
|
map_x = torch.where(visible, source_x * radius + centre_x, grid_x)
|
|
map_y = torch.where(visible, source_y * radius + centre_y, grid_y)
|
|
# Pixel-centre normalisation: (2p + 1)/n - 1 is the align_corners=False
|
|
# convention; linspace(-1, 1) would shift everything by half a pixel and blur.
|
|
grid = torch.stack(
|
|
[(2 * map_x + 1) / width - 1, (2 * map_y + 1) / height - 1], dim=-1
|
|
)
|
|
return grid
|
|
|
|
|
|
def rotate(frames, dt_seconds, b0, radius_fraction, synodic=True):
|
|
"""Warp (N, C, H, W) frames by their per-frame dt. Zero dt is the identity."""
|
|
import torch.nn.functional as F
|
|
|
|
grid = rotation_grid(frames.shape[-1], dt_seconds, b0, radius_fraction, synodic)
|
|
return F.grid_sample(frames, grid.to(frames.dtype), mode="bilinear",
|
|
padding_mode="border", align_corners=False)
|
|
|
|
|
|
# -------------------------------------------------------------------- photometry
|
|
|
|
|
|
def gain_fit(source, reference):
|
|
"""Least-squares (gain, offset) putting `source` on `reference`'s scale."""
|
|
x = np.nan_to_num(np.asarray(source, dtype=np.float64), nan=0.0,
|
|
posinf=0.0, neginf=0.0).ravel()
|
|
y = np.nan_to_num(np.asarray(reference, dtype=np.float64), nan=0.0,
|
|
posinf=0.0, neginf=0.0).ravel()
|
|
variance = float(((x - x.mean()) ** 2).sum())
|
|
if variance <= 0:
|
|
return 1.0, 0.0
|
|
gain = float(((x - x.mean()) * (y - y.mean())).sum() / variance)
|
|
offset = float(y.mean() - gain * x.mean())
|
|
return gain, offset
|
|
|
|
|
|
def apply_photometry(coded, gain, offset):
|
|
"""Apply a per-band radiance-space affine transfer to asinh-coded frames.
|
|
|
|
`coded` is (N, BANDS, H, W) in the model's asinh space; `gain`/`offset` are
|
|
(N, BANDS) in radiance units. The transfer is defined on radiance -- a gain
|
|
multiplies a physical quantity -- so this computes
|
|
``asinh(sinh(x * R) * g + o / S) / R`` rather than scaling the coded values,
|
|
which would model a different (and wrong) transform.
|
|
"""
|
|
import torch
|
|
|
|
scaled = torch.sinh(coded.clamp(-1.0, 1.0) * dataset.ASINH_RANGE)
|
|
moved = scaled * gain[..., None, None] + offset[..., None, None] / dataset.ASINH_SCALE
|
|
return (torch.asinh(moved) / dataset.ASINH_RANGE).clamp(-1.0, 1.0)
|
|
|
|
|
|
def fit_photometry(counterpart, local):
|
|
"""Per-band (gain, offset) putting `counterpart` on `local`'s radiance scale.
|
|
|
|
Least squares on a simultaneous pair of (BANDS, H, W) radiance arrays -- the two
|
|
spacecraft observe the same Sun at the same instant, so the fit isolates the
|
|
instrument difference with no solar evolution mixed in. numpy, because it runs
|
|
in the CPU data path (sampler and bench), once per day rather than per frame.
|
|
"""
|
|
pairs = [gain_fit(c, l) for c, l in zip(counterpart, local)]
|
|
gains = np.array([g for g, _ in pairs], dtype=np.float32)
|
|
offsets = np.array([o for _, o in pairs], dtype=np.float32)
|
|
return gains, offsets
|
|
|
|
|
|
# ------------------------------------------------------------------- whole stacks
|
|
|
|
|
|
def align_stack(stack, dts, valid, gains, offsets, b0, radius_fraction, synodic=True):
|
|
"""Align every frame of a batch of stacks onto its target instant.
|
|
|
|
`stack` is (B, S, BANDS, H, W) in asinh space; `dts` (B, S) seconds; `valid`
|
|
(B, S) with 1 where a frame carries pixels; `gains`/`offsets` (B, S, BANDS) in
|
|
radiance units (identity rows for same-satellite frames); `b0`/`radius_fraction`
|
|
(B,) from :func:`solar_ephemeris` at the target instant.
|
|
|
|
Photometry first (calibrate the instrument), then rotation (account for time).
|
|
Invalid frames are forced to the identity transfer -- an offset applied to a
|
|
frame of zeros would manufacture a constant image out of nothing.
|
|
"""
|
|
batch, stack_depth, bands = stack.shape[:3]
|
|
flat = stack.reshape(batch * stack_depth, bands, *stack.shape[-2:])
|
|
keep = valid.reshape(-1, 1).to(flat.dtype)
|
|
gain = gains.reshape(-1, bands) * keep + (1.0 - keep)
|
|
offset = offsets.reshape(-1, bands) * keep
|
|
|
|
needs_transfer = ((gain != 1.0) | (offset != 0.0)).any(dim=1)
|
|
if bool(needs_transfer.any()):
|
|
moved = apply_photometry(flat[needs_transfer], gain[needs_transfer],
|
|
offset[needs_transfer])
|
|
flat = flat.clone()
|
|
flat[needs_transfer] = moved.to(flat.dtype)
|
|
|
|
expand = lambda values: values.reshape(batch, 1).expand(batch, stack_depth).reshape(-1)
|
|
# `dts` is (frame time - target time); the warp must advance each frame by the
|
|
# *negation* of that, (target - frame time), to land on the target instant.
|
|
# Passing dts unnegated rotates every candidate AWAY from the target, doubling
|
|
# the displacement instead of cancelling it -- sub-pixel at short gaps, which is
|
|
# how it slipped past the identity and parity tests, and ~7 dB of candidate
|
|
# quality at a 300-slot gap, which is how it was caught: the aligned anchors
|
|
# scored far below plain solar_rotation on the same frames.
|
|
aligned = rotate(flat, -dts.reshape(-1), expand(b0), expand(radius_fraction), synodic)
|
|
return aligned.view_as(stack)
|