Removes tracking drift from a solar-eclipse timelapse by locking the sun's disc to a fixed point in frame, working from the finished video alone — no original stills required.
Written for a DWARF mini smart-telescope capture of the 12 August 2026 eclipse (1280×720, 30 fps, 75 s, HEVC), where the mount tracked imperfectly: slow drift punctuated by discrete correction jumps, the largest a 173 px lurch.
During an eclipse the centroid of the lit area is not the centre of the sun. As the moon advances, the centroid migrates away from the true centre — by deep partial phase it is off by a large fraction of the radius. Locking onto it bakes a slow systematic wobble into the video that follows the eclipse progression.
So this fits the solar limb instead. The outer edge of the crescent is still an arc of the sun's own circle, and the sun's angular radius is effectively constant across a session.
- Fixed-radius gradient Hough. Every strong edge pixel votes for a centre at
p + R·ĝ(the intensity gradient points inward at the solar limb). Solar-limb pixels agree on one point; lunar-limb, cloud, tree and horizon pixels scatter. The accumulator is padded so the centre can be found even when it lies outside the frame — which happens once the sun is partly below the horizon. - Robust refinement. Inliers are selected by radius and by radial agreement of the gradient direction,
then least-squares fitted. Near the horizon, differential refraction squashes the disc vertically while
leaving its horizontal extent alone, so the model is an ellipse with the horizontal semi-axis pinned at
Rand the vertical one free. On the source clip that ratio fell smoothly from 1.005 to 0.824 — the physically expected amount.
This is inherently immune to partial occlusion. Tree branches across the disc merely remove some limb points, and the fit uses whatever arc remains; it stayed accurate down to 36% arc coverage.
Don't smooth the track. The mount corrects in discrete jumps. Those are real image motion, and a linear
smoother smears each one across a second, leaving a visible slide. Worse, a blanket median/Hampel filter
rejects them: it discarded a real 173 px lurch backed by 7,300 inlier limb points. clean.py therefore
overrules a sample only when it both departs from its neighbours and rests on measurably weaker evidence
than they do.
Do smooth two specific regions. Where the crescent is thin near totality, or the disc sits near the horizon, the apparent centre wanders because turbulent refraction deforms the limb. That is shape change, not rigid motion — translation cannot correct it, and following it only injects jitter.
Requires ffmpeg/ffprobe and Python with opencv-python, numpy, scipy.
export ECLIPSE_SRC=/path/to/clip.mp4
python calibrate.py "$ECLIPSE_SRC" 0 120 240 # frames where the disc is still whole
python track.py "$ECLIPSE_SRC" # -> track_raw.npy
python clean.py "$ECLIPSE_SRC" # -> track_clean.npy
python render.py "$ECLIPSE_SRC" master # -> eclipse_locked_master.mkv
python render.py "$ECLIPSE_SRC" delivery # -> eclipse_locked_720p.mp4
python verify.py eclipse_locked_master.mkv
python phaseverify.py eclipse_locked_master.mkvcalibrate.py needs frame indices where the sun is unobstructed — check the spread it reports.
The whole geometric change is folded into a single affine, so every output pixel is resampled exactly once.
Decoding goes straight to yuv444p16le: chroma is upsampled once, in 16 bit, and RGB is never involved, so
there is no colour-model round trip. Full colour range is preserved end to end (verified by an identity
round trip: mean offset −0.03 units, max deviation exactly the 16→10 bit quantisation step).
Lanczos4 was chosen after measuring on the sharpest limb in the clip: it matched bicubic for sharpness and showed overshoot identical to bilinear, i.e. no ringing penalty, while being the better choice for the upscaled variant.
render.py sizes the crop from the p0.5–p99.5 shift range rather than the absolute extremes, because a few
extreme frames would otherwise cost ~12% of the width across the entire clip. The overflowing frames run off
the edge instead. Check before relying on this that every overflowing frame sits against a dark
background — clean.py reports how many there are, and they must not fall in a segment where landscape is
visible.
| Measure | Before | After |
|---|---|---|
| Solar centre wander | ±40–80 px, with jumps | median residual 0.02 px, p95 0.29 px |
| Frames within 1 px | — | 96.9% |
| Frame-to-frame motion (phase correlation) | 1.166 px | 0.543 px |
33 frames (1.5%) retain up to ~30 px of wobble, in two clusters: t=40.7–47.9 s (thin, cloud-broken crescent near maximum) and t=73.6–74.2 s (near the horizon). Median arc coverage there is 0.42 against 0.61 for the clip. Those are the deliberately smoothed stretches described above.
- Field rotation is not corrected. An alt-az mount rotates the image over a session. Sunspot correlation suggested ~0.5° over the first 10 s, but that is at the measurement floor, and past frame 300 the correlation collapses — the spots are too faint at 3.5 Mbit/s once the moon covers the disc. Recentring does not address rotation; derotation would need feature tracking.
- Refraction flattening is measured but not undone. The disc is genuinely non-circular near the horizon.
b/Ris recorded intrack_raw.npyif you want to restore it. - Source compression artifacts around the limb are baked in and cannot be recovered.
verify.pyre-runs the same estimator on the output, so it cannot detect a consistently wrong fit. Usephaseverify.pyalongside it, and check fits visually.