dmead/solar-eclipse-timelapse

create pretty timelapses of your solar eclipse images and video

2

stars

64

commits

Python

primary language

Aug 22, 2026

updated

README

solar-eclipse-timelapse

tests

Second contact, 2024-04-08 - the last Baily's beads on the limb, with the panels the pipeline placed

Turns a total solar eclipse shoot — SER video from a planetary camera, or a folder of stills from a DSLR — into a tracked, annotated timelapse. It finds the exposure changes you made by hand, holds the Sun still while the mount drifts, stacks and drizzles what it can, and places labelled zoom panels on the features that are actually there: prominences, the cusps, Baily's beads, the lunar limb, a sunspot.

One required argument: the folder your captures are in.

python -m ecl.run "D:\eclipse\data"

Everything else is surveyed from the data and the machine on startup, written to an editable config, and reused from then on.

Everything on this page — the frame above, the preview below, and the alignment comparison further down — comes from one unmodified run of the command at the top of this README, on the 2024-04-08 data: 2299 frames, 76.6 s at 30 fps, all 22 captures. No flags, no hand-tuning, nothing placed by hand. The window, the panel size, the segments in the preview and the features every panel follows were all chosen by the pipeline from the data.

The full-resolution cut of that same run is on the v0.1.0 release — 2360x1760 at CRF 17 (93 MB), plus a 1080-wide cut sized to survive Instagram without a re-encode, a 2 MB preview, and the animated preview above.

timelapse.gif below is a pipeline output too, not a clip chosen by hand: nine seconds assembled from four segments it picked out of the run — the crescent at its thinnest, the filter coming off, second contact, and totality. Each chapter is located from what the earlier passes marked, so data that is totality only gets three chapters over the same budget and nothing is special-cased.

Preview: partial phases, the filter coming off, second contact, totality

Holding the Sun still

The same frames, through the same gains, cropped two ways. On the left the window never moves; on the right it follows the fitted disc track. Nothing else differs — the two configs are one file with one field changed, so every pair of sequence numbers is the same exposure at the same stack depth.

No tracking versus the disc track, the same frames side by side

Over the 45 minutes the Sun travels 523 × 277 px in an 1180 × 880 window — 44% of the frame width, 1.8 solar radii — and at the extremes the disc runs past the edge. Almost none of that is the mount losing the Sun: within any one capture the drift is 20–54 px. It is the boundaries, where the mount was nudged by hand between captures, and three of those move the Sun 544, 387 and 262 px in a single frame.

This is also why the tracking is a fitted disc track rather than frame-to-frame correlation. Correlation locks onto the brightest thing in the frame, which during the partial phases is the crescent — and a crescent's centroid is not the centre of the disc it was cut from, so it slides around the limb as the Moon advances. docs/NOTES.md has the measurements.


Written for one shoot — the 2024-04-08 totality from Cleveland, an ASI585MC on a 70 mm refractor — and then generalised. Every geometric constant is a fraction of the solar radius the survey measures, so the same config works on an 80 px disc and a 900 px one. docs/NOTES.md is the account of that shoot and what the data turned out to need; docs/STATE.md is the running engineering log.


Try it without any data

A fixture ships with the pipeline, so you can check an install end to end in about a minute rather than by waiting for an eclipse:

python tools/make_synthetic.py D:\eclipse-demo\data     # Windows
python -m ecl.run D:\eclipse-demo\data
python tools/make_synthetic.py ~/eclipse-demo/data       # Linux
python -m ecl.run ~/eclipse-demo/data

That writes three synthetic captures — partial phases, second contact, a three-step exposure ladder through totality, third contact, partial phases — and runs every pass over them, ending at an mp4 in D:\eclipse-demo\out\final. Add --format png or --format xisf to exercise the other readers.


1. Install

You need Python 3.11 or newer and ffmpeg. Nothing else is assumed. Windows and Linux are both tested; the only difference below is how you get those two.

Windows

winget install --id Python.Python.3.12 -e
winget install --id Gyan.FFmpeg -e

Close and reopen PowerShell so both land on PATH.

Linux (Debian/Ubuntu; python3-venv is a separate package on Debian and its derivatives, and leaving it out is the usual reason python3 -m venv fails with nothing but a suggestion to install it)

sudo apt update
sudo apt install -y python3 python3-venv python3-pip ffmpeg

On Fedora it is sudo dnf install python3 python3-pip ffmpeg, and on Arch sudo pacman -S python python-pip ffmpeg.

Then check, on either:

python3 --version
ffmpeg -version

Get the code and build a virtual environment beside it:

cd D:\projects
git clone https://github.com/dmead/solar-eclipse-timelapse.git
cd solar-eclipse-timelapse

python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e .

The same three commands on Linux, with the activate path Linux uses:

git clone https://github.com/dmead/solar-eclipse-timelapse.git
cd solar-eclipse-timelapse

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e .

A plain pip install . works too, and so does uv sync if you use uv — the lock file is committed. -e matters only if you intend to edit the code.

If Activate.ps1 is blocked, PowerShell is refusing to run local scripts. Allow it for your account only:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

That is the whole install. Every dependency comes from PyPI — there is no sibling repository to clone and no local path to configure. The numeric spine (FFT registration, sub-pixel warp, the drizzle stacker, SER and XISF I/O) is vendored in ecl/vendor/; see that package's docstring for its provenance.

The install also puts an eclipse-timelapse command on your PATH, which is python -m ecl.run under a shorter name. This README uses the module form throughout, because it works whether or not you activated the environment.

Optional readers, only if you have that kind of data:

python -m pip install ".[raw]"     # CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2
python -m pip install ".[fits]"    # FITS

The pipeline tells you which one you need if it meets a file it cannot open, and it does so on the first frame rather than four hundred frames in.


2. Lay out your data

Three layouts are accepted, because all three are what people actually have. Pick whichever matches; you do not configure this.

data\                     data\                      data\
  13_44_19.ser              13_44\                     IMG_0001.CR2
  13_52_58.ser                IMG_0001.CR2             IMG_0002.CR2
  14_13_00.ser                IMG_0002.CR2             IMG_0003.CR2
                            14_13\                     ...
one SER per capture           IMG_0100.CR2           one flat run of images
                          one folder per capture

Formats. SER, plus one-exposure-per-file in TIFF, XISF, FITS, PNG, JPEG, or camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2). TIFF is read through tifffile and XISF through the vendored reader, both at full depth; RAW needs [raw] and FITS needs [fits].

Shoot at more than 8 bits if you can. A corona needs a gain of several hundred before it is visible at all, and at 8 bits the quantisation step multiplied by that gain is salt-and-pepper across the whole frame. The pipeline will happily process 8-bit input and the result will look noisy for a reason that is not the pipeline's.

A capture is one uninterrupted run at one camera setting. Splitting your shoot into captures the way you actually shot it matters more than the format: the exposure normalisation, the gain chaining and the drift track are all per-capture, and one giant folder spanning a filter change will be normalised as though the filter never came off.

Files are ordered naturally, so IMG_9 sorts before IMG_10. A plain alphabetical sort gets that wrong once every power of ten and produces a scrambled sequence rather than an error.


3. Run

cd D:\projects\solar-eclipse-timelapse
.\.venv\Scripts\Activate.ps1

python -m ecl.run "D:\eclipse\data" --dry-run     # survey and plan, run nothing
python -m ecl.run "D:\eclipse\data"               # the whole thing

The passes, in the order they run and with what each leaves behind:

passwriteswhat it decides
surveysurvey.jsonsensor, disc radius, drizzle, worker count
segmentsegments.jsonwhere your exposure changed, to the frame
beadsdiag/beads.jsonthe Baily's bead window either side of contact
selectconfigs/timelapse.jsonwhich frames make the video, and their gains
centresdiag/centres.jsona disc centre on every frame
trackdiag/corona_track.jsonpointing through totality
driftdiag/drift.jsonwhere the Sun is while the Moon hides it
smooth(the config)the drift model, and which fits to distrust
insets(the config)what each zoom panel follows
phasesdiag/phases.jsonthe four contacts, and a phase per frame
renderframes/*.pngthe frames
encodefinal/*.mp4, final/timelapse.gifthree cuts and the preview

What the annotator can label

Six object types, in the priority order the slot allocator uses when two of them want the same corner (FEATURE_PRIORITY):

objectdetectorhow it is found
baily's beadsfind_bead_arcconnected components on the clipped photosphere. A diamond ring is ONE blob and beads are several with lunar ridges between, so shape separates them where brightness cannot - the clipped area falls smoothly through both.
sunspotfind_sunspot, best_sunspotdarkest spot on the photosphere, taken from the filtered frame where the Moon covers least of it
prominencefind_prominences, sized by size_to_arcpeaks in the H-alpha plane - R with the white corona scaled off - over an annulus at 0.90-1.25 r_moon. Cut off at SNR 8, because an argmax always has an answer and whether it is a prominence is a separate question.
upper cuspfind_cuspsthe ends of the lit arc, walked around the Sun's limb rather than solved from two circles: a nearly-tangent intersection slides a long way along the limb for a small error in either centre or radius
lower cuspfind_cuspsthe other end of that same arc
lunar limbemitted inlineno detector, because it is geometric: the Moon's limb point nearest the Sun's centre, emitted only while that point is still on the photosphere

Only prominence can appear more than once in a frame, up to panels.max_panels (six). The rest are singular by nature. Measured on the 2024-04-08 run:

objectpanelsframesmost in one framezoom
prominence372978961.50-3.00
lunar limb1240124013.00
upper cusp1240124012.29-2.64
lower cusp1240124012.29-2.64
sunspot29129113.00
baily's beads20520511.31

They sort themselves by phase, because each needs something only one phase has. Both cusps and the lunar limb appear on exactly the 1240 filtered frames - a cusp needs a crescent - and the sunspot is filtered-only because it sits on the photosphere. Prominences are 86% totality. Zoom varies wherever a panel is sized to the arc it has to hold rather than to a fixed magnification, which is why the bead panel is the widest view of the six.

Outputs go to D:\eclipse\out by default — beside the data, not inside it, so a read-only or network data folder works. Override with --out and --frames.

Running a single stage by hand takes its defaults from the environment rather than from any particular drive:

$env:ECLIPSE_DATA = "D:\eclipse\data"     # the captures
$env:ECLIPSE_OUT  = "D:\eclipse\out"      # survey.json, configs\, diag\

python -m ecl.progress            # no arguments needed
python -m ecl.beadwindow

ecl.run never reads those — it works both out from the data directory you give it — so they only matter for running a stage on its own.

Watch a long render from another window:

python -m ecl.progress --frames "D:\eclipse\out\frames" --watch

Re-run part of it:

python -m ecl.run "D:\eclipse\data" --from insets   # insets onward
python -m ecl.run "D:\eclipse\data" --only render,encode

--from re-runs that pass and everything after it, deliberately. Each pass rewrites configs\timelapse.json in place, adding what it measured to what the last one left, so running one pass against a config a later pass already touched is the standard way to corrupt a run. It has happened twice here.

If a render is interrupted, resume rather than restart:

python -m ecl.tl_render --config "D:\eclipse\out\configs\timelapse.json" `
  --data-dir "D:\eclipse\data" --out-dir "D:\eclipse\out\frames" --resume

4. What the survey decides for you

On startup ecl.survey reads a handful of frames from each capture and prints what it found:

surveying 22 capture(s) in Z:/solar-eclipse/Sun
  14_13_00.ser 3840x2160 CFA n=1391 23.13fps  r=291.8
  -> 29038 frames, plane 1920x1080, disc radius 291.8 px
  -> drizzle x2, 24 workers (24 cores, 219 by memory at 0.50 GB each, 182 GB free)
surveyedused for
format, bit depth, CFA or RGBwhich reader, and whether planes are half-size
sensor sizememory per worker
solar radius in pixelsevery geometric constant in the pipeline
frame count and cadencedwell lengths, drizzle group size
cores and free memoryworker count

The disc radius is the important one. Every box size, separation and tolerance in this pipeline was originally a pixel count measured on one camera, where the Sun happened to be 279 px across. They are now stored as fractions of the solar radius, so the same config gives sensible pixel values whether your disc is 80 px or 900:

r = 292 (this data)r = 900 (full frame)r = 80 (short lens)
prominence separation94 px291 px26 px
cusp box half-width42 px129 px11 px
alignment shift bound4.2 px12.9 px1.1 px
smallest bead44 px²416 px²3 px²

Memory. A worker holds several copies of the drizzled frame, so cost scales with sensor area and the square of the drizzle factor: about 0.5 GB per worker here, about 4.6 GB for a full-frame sensor at drizzle 2. The survey divides your free memory by that and takes the smaller of it and your physical core count.

Drizzle is chosen from how well sampled your disc already is. Drizzling exists to recover detail lost to coarse sampling; if your disc is already 900 px across, upsampling only multiplies the pixel count, so the survey drops it to 1.


5. Tuning

First run writes out\eclipse.toml with every setting in it and a comment on each. It is never overwritten — re-running reads your edits.

[dwell]
# screen seconds held on Baily's beads
beads_s = 10.0
# screen seconds on the corona proper
corona_s = 10.0

[geometry]
# 0 = use the radius measured by the survey
radius_plane_px = 0.0

[render]
# 0 = auto (physical cores, capped by free memory)
workers = 0

Conventions: keys ending _r are fractions of the solar radius, _r2 of its square, _s are seconds, and everything else is dimensionless. Delete any key to fall back to the built-in default.

Things worth changing first:

settingwhen
dwell.*_sthe video lingers too long or not long enough somewhere
render.workersyou want the machine back while it runs
geometry.radius_plane_pxthe survey measured the disc wrong
geometry.output_half_ryou want more or less corona in frame
panels.zoominset panels too tight or too loose
panels.min_clear_rpanels crowd the disc, or you want the top/bottom slots back
select.flatten_maxbrightness still steps inside a capture, or is over-corrected
render.group_level_tolthe renderer reports dropping a lot of frames from their groups
panels.size_fracpanels too large or too small for the output frame
panels.max_panelsyou want fewer things labelled at once
centres.r_search_*_rthe one global radius acquisition looked in the wrong range
caption.creditname the site and the gear - blank by default, drawn along the bottom
caption.show_phaseyou do not want the phase named across the top
caption.height_fraccaptions too small or too large for the output size
phases.bead_window_sthe beads caption starts or ends at the wrong moment
gif.seconds, gif.widththe animated preview is too long, too short, or too big a file

6. When it goes wrong

"no captures found" — the folder has no .ser and no images the loader recognises. Check you pointed at the folder containing the captures.

"no frame in this data has a measurable solar disc" — every frame sampled was blank or fully eclipsed. If your data is totality only, set geometry.radius_plane_px by hand; there is no full disc to measure.

A wrong disc radius throws off every box size at once. The survey measures it from the largest round bright region that does not touch the frame edge, on the least-eclipsed frame it samples. It is reported per capture — if the number looks wrong, override it in the config rather than fighting the detector.

"mixed capture kinds" — some captures are CFA and some already demosaiced. The pipeline needs one geometry throughout; run them separately.

Renders slow to a crawl — check the survey's memory line. If workers_by_memory is well under your core count you are memory-bound, and lowering render.drizzle to 1 costs a little detail and quarters the footprint.

Timestamps look wrong on image sequences — only some formats carry a capture time (EXIF DateTimeOriginal, FITS DATE-OBS, XISF Observation:Time:Start). Without one the file modification time is used, which orders and paces frames correctly but is not a real clock and will not survive some kinds of copying. The segmenter says so rather than pretending otherwise.

"no blown-out transition found; treating all as filtered" — totality is bracketed physically, by the frames the filter coming off blows out at both contacts, not by a brightness threshold. If you changed exposure before pulling the filter there may be no blown frame to find, and nothing will be treated as totality. Check segments.txt; the kind column is where to look.

Salt-and-pepper over the whole rendered frame — almost always 8-bit input. See the note on bit depth in section 2.


Platforms

Verified on both, from a clean clone through pip install . to an mp4:

Windows 11Python 3.12 and 3.14
Ubuntu 24.04 LTSPython 3.12

Nothing in the pipeline is Windows-specific. The one place the OS shows through is ecl.affinity, which pins render workers to physical cores through a Win32 call and is skipped anywhere else — --affinity is simply a no-op on Linux and the render is otherwise identical.

The synthetic fixture is byte-identical across the two (same seed, same md5), and segmentation agrees to five decimal places, so a disagreement between platforms is a real finding rather than expected drift.

One thing worth knowing if you are porting or debugging. The render pool's start method differs by platform, and it decides whether a worker inherits the parent's tuned settings or re-imports the module and gets its defaults: Windows and macOS spawn, Linux fork up to Python 3.13, and forkserver from 3.14 on. Only fork inherits. The passes therefore hand their tuned state to workers explicitly rather than relying on inheritance — see tl_render.TUNED and docs/STATE.md, which records what went wrong when they did not.


Layout

ecl/            the pipeline; every pass is `python -m ecl.<something>`
ecl/vendor/     numerics copied from lunation, unmodified — see its docstring
tools/          make_synthetic.py     a fixture, so an install can be checked
                make_alignment_demo.py rebuilds the comparison above
                measure_drizzle.py     what drizzle recovers, on the features
                measure_sun_hold.py    how still the render holds the Sun
tests/          pytest; `python -m pytest`
docs/NOTES.md   the 2024-04-08 shoot: the data, and what it turned out to need
docs/STATE.md   the running engineering log

Licence

GPL-3.0-or-later. See LICENSE. ecl/vendor/ is copied from the author's own lunation project and carries the same terms.

Contributors

dmead

64 commits

dmead/solar-eclipse-timelapse

create pretty timelapses of your solar eclipse images and video

2

stars

64

commits

Python

primary language

Aug 22, 2026

updated

README

solar-eclipse-timelapse

tests

Second contact, 2024-04-08 - the last Baily's beads on the limb, with the panels the pipeline placed

Turns a total solar eclipse shoot — SER video from a planetary camera, or a folder of stills from a DSLR — into a tracked, annotated timelapse. It finds the exposure changes you made by hand, holds the Sun still while the mount drifts, stacks and drizzles what it can, and places labelled zoom panels on the features that are actually there: prominences, the cusps, Baily's beads, the lunar limb, a sunspot.

One required argument: the folder your captures are in.

python -m ecl.run "D:\eclipse\data"

Everything else is surveyed from the data and the machine on startup, written to an editable config, and reused from then on.

Everything on this page — the frame above, the preview below, and the alignment comparison further down — comes from one unmodified run of the command at the top of this README, on the 2024-04-08 data: 2299 frames, 76.6 s at 30 fps, all 22 captures. No flags, no hand-tuning, nothing placed by hand. The window, the panel size, the segments in the preview and the features every panel follows were all chosen by the pipeline from the data.

The full-resolution cut of that same run is on the v0.1.0 release — 2360x1760 at CRF 17 (93 MB), plus a 1080-wide cut sized to survive Instagram without a re-encode, a 2 MB preview, and the animated preview above.

timelapse.gif below is a pipeline output too, not a clip chosen by hand: nine seconds assembled from four segments it picked out of the run — the crescent at its thinnest, the filter coming off, second contact, and totality. Each chapter is located from what the earlier passes marked, so data that is totality only gets three chapters over the same budget and nothing is special-cased.

Preview: partial phases, the filter coming off, second contact, totality

Holding the Sun still

The same frames, through the same gains, cropped two ways. On the left the window never moves; on the right it follows the fitted disc track. Nothing else differs — the two configs are one file with one field changed, so every pair of sequence numbers is the same exposure at the same stack depth.

No tracking versus the disc track, the same frames side by side

Over the 45 minutes the Sun travels 523 × 277 px in an 1180 × 880 window — 44% of the frame width, 1.8 solar radii — and at the extremes the disc runs past the edge. Almost none of that is the mount losing the Sun: within any one capture the drift is 20–54 px. It is the boundaries, where the mount was nudged by hand between captures, and three of those move the Sun 544, 387 and 262 px in a single frame.

This is also why the tracking is a fitted disc track rather than frame-to-frame correlation. Correlation locks onto the brightest thing in the frame, which during the partial phases is the crescent — and a crescent's centroid is not the centre of the disc it was cut from, so it slides around the limb as the Moon advances. docs/NOTES.md has the measurements.


Written for one shoot — the 2024-04-08 totality from Cleveland, an ASI585MC on a 70 mm refractor — and then generalised. Every geometric constant is a fraction of the solar radius the survey measures, so the same config works on an 80 px disc and a 900 px one. docs/NOTES.md is the account of that shoot and what the data turned out to need; docs/STATE.md is the running engineering log.


Try it without any data

A fixture ships with the pipeline, so you can check an install end to end in about a minute rather than by waiting for an eclipse:

python tools/make_synthetic.py D:\eclipse-demo\data     # Windows
python -m ecl.run D:\eclipse-demo\data
python tools/make_synthetic.py ~/eclipse-demo/data       # Linux
python -m ecl.run ~/eclipse-demo/data

That writes three synthetic captures — partial phases, second contact, a three-step exposure ladder through totality, third contact, partial phases — and runs every pass over them, ending at an mp4 in D:\eclipse-demo\out\final. Add --format png or --format xisf to exercise the other readers.


1. Install

You need Python 3.11 or newer and ffmpeg. Nothing else is assumed. Windows and Linux are both tested; the only difference below is how you get those two.

Windows

winget install --id Python.Python.3.12 -e
winget install --id Gyan.FFmpeg -e

Close and reopen PowerShell so both land on PATH.

Linux (Debian/Ubuntu; python3-venv is a separate package on Debian and its derivatives, and leaving it out is the usual reason python3 -m venv fails with nothing but a suggestion to install it)

sudo apt update
sudo apt install -y python3 python3-venv python3-pip ffmpeg

On Fedora it is sudo dnf install python3 python3-pip ffmpeg, and on Arch sudo pacman -S python python-pip ffmpeg.

Then check, on either:

python3 --version
ffmpeg -version

Get the code and build a virtual environment beside it:

cd D:\projects
git clone https://github.com/dmead/solar-eclipse-timelapse.git
cd solar-eclipse-timelapse

python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e .

The same three commands on Linux, with the activate path Linux uses:

git clone https://github.com/dmead/solar-eclipse-timelapse.git
cd solar-eclipse-timelapse

python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e .

A plain pip install . works too, and so does uv sync if you use uv — the lock file is committed. -e matters only if you intend to edit the code.

If Activate.ps1 is blocked, PowerShell is refusing to run local scripts. Allow it for your account only:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

That is the whole install. Every dependency comes from PyPI — there is no sibling repository to clone and no local path to configure. The numeric spine (FFT registration, sub-pixel warp, the drizzle stacker, SER and XISF I/O) is vendored in ecl/vendor/; see that package's docstring for its provenance.

The install also puts an eclipse-timelapse command on your PATH, which is python -m ecl.run under a shorter name. This README uses the module form throughout, because it works whether or not you activated the environment.

Optional readers, only if you have that kind of data:

python -m pip install ".[raw]"     # CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2
python -m pip install ".[fits]"    # FITS

The pipeline tells you which one you need if it meets a file it cannot open, and it does so on the first frame rather than four hundred frames in.


2. Lay out your data

Three layouts are accepted, because all three are what people actually have. Pick whichever matches; you do not configure this.

data\                     data\                      data\
  13_44_19.ser              13_44\                     IMG_0001.CR2
  13_52_58.ser                IMG_0001.CR2             IMG_0002.CR2
  14_13_00.ser                IMG_0002.CR2             IMG_0003.CR2
                            14_13\                     ...
one SER per capture           IMG_0100.CR2           one flat run of images
                          one folder per capture

Formats. SER, plus one-exposure-per-file in TIFF, XISF, FITS, PNG, JPEG, or camera RAW (CR2/CR3/NEF/ARW/DNG/RAF/ORF/RW2). TIFF is read through tifffile and XISF through the vendored reader, both at full depth; RAW needs [raw] and FITS needs [fits].

Shoot at more than 8 bits if you can. A corona needs a gain of several hundred before it is visible at all, and at 8 bits the quantisation step multiplied by that gain is salt-and-pepper across the whole frame. The pipeline will happily process 8-bit input and the result will look noisy for a reason that is not the pipeline's.

A capture is one uninterrupted run at one camera setting. Splitting your shoot into captures the way you actually shot it matters more than the format: the exposure normalisation, the gain chaining and the drift track are all per-capture, and one giant folder spanning a filter change will be normalised as though the filter never came off.

Files are ordered naturally, so IMG_9 sorts before IMG_10. A plain alphabetical sort gets that wrong once every power of ten and produces a scrambled sequence rather than an error.


3. Run

cd D:\projects\solar-eclipse-timelapse
.\.venv\Scripts\Activate.ps1

python -m ecl.run "D:\eclipse\data" --dry-run     # survey and plan, run nothing
python -m ecl.run "D:\eclipse\data"               # the whole thing

The passes, in the order they run and with what each leaves behind:

passwriteswhat it decides
surveysurvey.jsonsensor, disc radius, drizzle, worker count
segmentsegments.jsonwhere your exposure changed, to the frame
beadsdiag/beads.jsonthe Baily's bead window either side of contact
selectconfigs/timelapse.jsonwhich frames make the video, and their gains
centresdiag/centres.jsona disc centre on every frame
trackdiag/corona_track.jsonpointing through totality
driftdiag/drift.jsonwhere the Sun is while the Moon hides it
smooth(the config)the drift model, and which fits to distrust
insets(the config)what each zoom panel follows
phasesdiag/phases.jsonthe four contacts, and a phase per frame
renderframes/*.pngthe frames
encodefinal/*.mp4, final/timelapse.gifthree cuts and the preview

What the annotator can label

Six object types, in the priority order the slot allocator uses when two of them want the same corner (FEATURE_PRIORITY):

objectdetectorhow it is found
baily's beadsfind_bead_arcconnected components on the clipped photosphere. A diamond ring is ONE blob and beads are several with lunar ridges between, so shape separates them where brightness cannot - the clipped area falls smoothly through both.
sunspotfind_sunspot, best_sunspotdarkest spot on the photosphere, taken from the filtered frame where the Moon covers least of it
prominencefind_prominences, sized by size_to_arcpeaks in the H-alpha plane - R with the white corona scaled off - over an annulus at 0.90-1.25 r_moon. Cut off at SNR 8, because an argmax always has an answer and whether it is a prominence is a separate question.
upper cuspfind_cuspsthe ends of the lit arc, walked around the Sun's limb rather than solved from two circles: a nearly-tangent intersection slides a long way along the limb for a small error in either centre or radius
lower cuspfind_cuspsthe other end of that same arc
lunar limbemitted inlineno detector, because it is geometric: the Moon's limb point nearest the Sun's centre, emitted only while that point is still on the photosphere

Only prominence can appear more than once in a frame, up to panels.max_panels (six). The rest are singular by nature. Measured on the 2024-04-08 run:

objectpanelsframesmost in one framezoom
prominence372978961.50-3.00
lunar limb1240124013.00
upper cusp1240124012.29-2.64
lower cusp1240124012.29-2.64
sunspot29129113.00
baily's beads20520511.31

They sort themselves by phase, because each needs something only one phase has. Both cusps and the lunar limb appear on exactly the 1240 filtered frames - a cusp needs a crescent - and the sunspot is filtered-only because it sits on the photosphere. Prominences are 86% totality. Zoom varies wherever a panel is sized to the arc it has to hold rather than to a fixed magnification, which is why the bead panel is the widest view of the six.

Outputs go to D:\eclipse\out by default — beside the data, not inside it, so a read-only or network data folder works. Override with --out and --frames.

Running a single stage by hand takes its defaults from the environment rather than from any particular drive:

$env:ECLIPSE_DATA = "D:\eclipse\data"     # the captures
$env:ECLIPSE_OUT  = "D:\eclipse\out"      # survey.json, configs\, diag\

python -m ecl.progress            # no arguments needed
python -m ecl.beadwindow

ecl.run never reads those — it works both out from the data directory you give it — so they only matter for running a stage on its own.

Watch a long render from another window:

python -m ecl.progress --frames "D:\eclipse\out\frames" --watch

Re-run part of it:

python -m ecl.run "D:\eclipse\data" --from insets   # insets onward
python -m ecl.run "D:\eclipse\data" --only render,encode

--from re-runs that pass and everything after it, deliberately. Each pass rewrites configs\timelapse.json in place, adding what it measured to what the last one left, so running one pass against a config a later pass already touched is the standard way to corrupt a run. It has happened twice here.

If a render is interrupted, resume rather than restart:

python -m ecl.tl_render --config "D:\eclipse\out\configs\timelapse.json" `
  --data-dir "D:\eclipse\data" --out-dir "D:\eclipse\out\frames" --resume

4. What the survey decides for you

On startup ecl.survey reads a handful of frames from each capture and prints what it found:

surveying 22 capture(s) in Z:/solar-eclipse/Sun
  14_13_00.ser 3840x2160 CFA n=1391 23.13fps  r=291.8
  -> 29038 frames, plane 1920x1080, disc radius 291.8 px
  -> drizzle x2, 24 workers (24 cores, 219 by memory at 0.50 GB each, 182 GB free)
surveyedused for
format, bit depth, CFA or RGBwhich reader, and whether planes are half-size
sensor sizememory per worker
solar radius in pixelsevery geometric constant in the pipeline
frame count and cadencedwell lengths, drizzle group size
cores and free memoryworker count

The disc radius is the important one. Every box size, separation and tolerance in this pipeline was originally a pixel count measured on one camera, where the Sun happened to be 279 px across. They are now stored as fractions of the solar radius, so the same config gives sensible pixel values whether your disc is 80 px or 900:

r = 292 (this data)r = 900 (full frame)r = 80 (short lens)
prominence separation94 px291 px26 px
cusp box half-width42 px129 px11 px
alignment shift bound4.2 px12.9 px1.1 px
smallest bead44 px²416 px²3 px²

Memory. A worker holds several copies of the drizzled frame, so cost scales with sensor area and the square of the drizzle factor: about 0.5 GB per worker here, about 4.6 GB for a full-frame sensor at drizzle 2. The survey divides your free memory by that and takes the smaller of it and your physical core count.

Drizzle is chosen from how well sampled your disc already is. Drizzling exists to recover detail lost to coarse sampling; if your disc is already 900 px across, upsampling only multiplies the pixel count, so the survey drops it to 1.


5. Tuning

First run writes out\eclipse.toml with every setting in it and a comment on each. It is never overwritten — re-running reads your edits.

[dwell]
# screen seconds held on Baily's beads
beads_s = 10.0
# screen seconds on the corona proper
corona_s = 10.0

[geometry]
# 0 = use the radius measured by the survey
radius_plane_px = 0.0

[render]
# 0 = auto (physical cores, capped by free memory)
workers = 0

Conventions: keys ending _r are fractions of the solar radius, _r2 of its square, _s are seconds, and everything else is dimensionless. Delete any key to fall back to the built-in default.

Things worth changing first:

settingwhen
dwell.*_sthe video lingers too long or not long enough somewhere
render.workersyou want the machine back while it runs
geometry.radius_plane_pxthe survey measured the disc wrong
geometry.output_half_ryou want more or less corona in frame
panels.zoominset panels too tight or too loose
panels.min_clear_rpanels crowd the disc, or you want the top/bottom slots back
select.flatten_maxbrightness still steps inside a capture, or is over-corrected
render.group_level_tolthe renderer reports dropping a lot of frames from their groups
panels.size_fracpanels too large or too small for the output frame
panels.max_panelsyou want fewer things labelled at once
centres.r_search_*_rthe one global radius acquisition looked in the wrong range
caption.creditname the site and the gear - blank by default, drawn along the bottom
caption.show_phaseyou do not want the phase named across the top
caption.height_fraccaptions too small or too large for the output size
phases.bead_window_sthe beads caption starts or ends at the wrong moment
gif.seconds, gif.widththe animated preview is too long, too short, or too big a file

6. When it goes wrong

"no captures found" — the folder has no .ser and no images the loader recognises. Check you pointed at the folder containing the captures.

"no frame in this data has a measurable solar disc" — every frame sampled was blank or fully eclipsed. If your data is totality only, set geometry.radius_plane_px by hand; there is no full disc to measure.

A wrong disc radius throws off every box size at once. The survey measures it from the largest round bright region that does not touch the frame edge, on the least-eclipsed frame it samples. It is reported per capture — if the number looks wrong, override it in the config rather than fighting the detector.

"mixed capture kinds" — some captures are CFA and some already demosaiced. The pipeline needs one geometry throughout; run them separately.

Renders slow to a crawl — check the survey's memory line. If workers_by_memory is well under your core count you are memory-bound, and lowering render.drizzle to 1 costs a little detail and quarters the footprint.

Timestamps look wrong on image sequences — only some formats carry a capture time (EXIF DateTimeOriginal, FITS DATE-OBS, XISF Observation:Time:Start). Without one the file modification time is used, which orders and paces frames correctly but is not a real clock and will not survive some kinds of copying. The segmenter says so rather than pretending otherwise.

"no blown-out transition found; treating all as filtered" — totality is bracketed physically, by the frames the filter coming off blows out at both contacts, not by a brightness threshold. If you changed exposure before pulling the filter there may be no blown frame to find, and nothing will be treated as totality. Check segments.txt; the kind column is where to look.

Salt-and-pepper over the whole rendered frame — almost always 8-bit input. See the note on bit depth in section 2.


Platforms

Verified on both, from a clean clone through pip install . to an mp4:

Windows 11Python 3.12 and 3.14
Ubuntu 24.04 LTSPython 3.12

Nothing in the pipeline is Windows-specific. The one place the OS shows through is ecl.affinity, which pins render workers to physical cores through a Win32 call and is skipped anywhere else — --affinity is simply a no-op on Linux and the render is otherwise identical.

The synthetic fixture is byte-identical across the two (same seed, same md5), and segmentation agrees to five decimal places, so a disagreement between platforms is a real finding rather than expected drift.

One thing worth knowing if you are porting or debugging. The render pool's start method differs by platform, and it decides whether a worker inherits the parent's tuned settings or re-imports the module and gets its defaults: Windows and macOS spawn, Linux fork up to Python 3.13, and forkserver from 3.14 on. Only fork inherits. The passes therefore hand their tuned state to workers explicitly rather than relying on inheritance — see tl_render.TUNED and docs/STATE.md, which records what went wrong when they did not.


Layout

ecl/            the pipeline; every pass is `python -m ecl.<something>`
ecl/vendor/     numerics copied from lunation, unmodified — see its docstring
tools/          make_synthetic.py     a fixture, so an install can be checked
                make_alignment_demo.py rebuilds the comparison above
                measure_drizzle.py     what drizzle recovers, on the features
                measure_sun_hold.py    how still the render holds the Sun
tests/          pytest; `python -m pytest`
docs/NOTES.md   the 2024-04-08 shoot: the data, and what it turned out to need
docs/STATE.md   the running engineering log

Licence

GPL-3.0-or-later. See LICENSE. ecl/vendor/ is copied from the author's own lunation project and carries the same terms.

Contributors

dmead

64 commits

Languages

Python

100.0%