A quality-assessment and culling station for a night's worth of sub-exposures. Load a folder, blink through the frames to spot clouds, trails, bad guiding, and focus drift, measure every frame's FWHM / eccentricity / background / star count / sensor temperature / background rate, drag threshold lines to auto-flag the losers, then hand the survivors to the Stacking Suite.
Blink is designed around the astrophotographer's actual Monday-morning workflow: you shot a hundred lights last night, roughly half are usable, and you need to decide which half quickly. Visual blinking catches things the metrics miss (satellites that dodged the detector, a single frame with reflection ghosts); the interactive metrics catch things the eye misses (gradual focus drift, a quietly-rising sensor temperature). Both views share the same list and the same flag state.
At the top of the left column:
.fit.gz,
.fits.gz, .fz), XISF, and raw camera
formats (CR2, CR3, NEF, ARW, DNG, RAF, ORF, RW2, PEF).Files are sorted naturally — frame_2.fits
comes before frame_10.fits, not after. The last-used
folder is remembered across sessions.
Choose how much to downsample on load. 1:1 keeps full resolution and is what you want for publishable work; 1:2, 1:4, and 1:8 progressively cut both dimensions in half, which cuts RAM use and makes blink / zoom responsive on very large sensors or long sessions. The setting persists across sessions. Star-size metrics still report in full- resolution pixel units — FWHM at 1:4 and FWHM at 1:1 are directly comparable.
At 1:4 and 1:8, plain FITS and XISF files take a faster route: instead of reading the whole frame and shrinking it, Blink reads only the rows the downsampled frame needs, straight off disk. On a 6000×4000 uint16 FITS that is roughly 2× faster at 1:4 and 7× faster at 1:8; the gain grows with frame size. 1:1 and 1:2 use the classic full read, because at 1:2 the strided read would have to touch every row anyway and would come out slower. Anything the fast path cannot reproduce exactly falls back automatically — see § 13.10 for the full list and the trade-offs it makes.
The Frames: row picks how Blink gets pixels into memory.
Stream mode is not just lazy loading. Recently viewed frames stay in a memory-capped cache, and the next few frames in your direction of travel are fetched in the background, so playback and arrow-keying stay smooth after the first pass. Interactive Metrics streams too: it loads, measures and releases each frame in turn rather than needing them all resident at once, so you can measure a 500-sub folder on a laptop. See § 13.11 for the internals.
The two directions behave differently on purpose. Switching to Stream releases the resident pixels immediately, so you get the memory back at once and frames reload as you visit them. Switching back to PreLoad takes effect on the next load, so that flipping a radio button never silently decodes hundreds of frames.
Which should I use? PreLoad if the whole session fits in RAM and you are going to blink back and forth repeatedly. Stream for a big folder, a quick triage pass, a machine with limited memory, or whenever you just want the list on screen now.
Only relevant to one-shot-colour (CFA / Bayer) data at 1:4 and 1:8.
Counter-intuitively, the unchecked option is the slower of the two: building a grey frame of the same size needs twice as many rows off disk as building the mosaic does. Its real argument is that it touches no numba code whatsoever, so it is worth trying if you ever hit a threading problem during loads (§ 13.7). Mono frames are unaffected either way, and the setting takes effect on the next load, like the resolution buttons above it.
Every frame goes through the same pipeline:
In Stream mode only the header part of this runs at open time; the rest happens the first time you select the frame.
Failed loads are automatically retried twice with a short delay before being reported as failures — useful on NAS-backed folders where one or two files occasionally take a moment to become readable.
Loaded frames are grouped as Object → Filter → Exposure
→ files, inferred from each frame's FITS header
(OBJECT, FILTER, EXPTIME). Headers
missing a key fall back to “Unknown” so no file disappears.
This hierarchy makes multi-filter LRGB sessions readable at a glance.
In Stream mode the grouping comes from a header-only probe, so it is built before any pixels are read. FITS and XISF give up their headers cheaply and group correctly from the start. Formats that keep no cheap header — raw camera files, and PNG/TIFF/JPEG that SASpro did not write — land under “Object: Unknown” until you open them, at which point the real header replaces the blank one.
The tree has nine columns:
Column widths and order are preserved across sessions. Drag column dividers to resize; drag column headers to reorder. Click a column header to sort by it — metric columns sort numerically, filenames sort naturally, and the Object / Filter / Exposure group rows keep their own order regardless of which column you sorted on. Sorting is purely visual; it never reorders the underlying session.
The Show dropdown above the tree filters what's visible:
Group headers (Object / Filter / Exposure) auto-hide when they'd be empty, so the view stays tidy. The choice is remembered across sessions.
The row of transport buttons below the Metrics / Sat buttons:
The Speed slider and spin box set playback rate from 0.1 to 10 FPS, synchronised to each other.
In Stream mode the first pass through a folder is limited by how fast frames decode, because each one is being read as you reach it. Blink prefetches ahead in whichever direction you are travelling, so the second pass — and any back-and-forth blinking within the cached window — runs at the full requested rate.
When the file tree has focus:
Occupies the right side of the window. The current frame is shown stretched for display. Controls:
Ctrl + wheel — cursor-anchored zoom (0.05×
to 3×). Also responds to trackpad pinch gestures on supported
platforms.Pan and zoom are preserved across frame switches, so blinking shows you the same patch of sky on consecutive frames — which is the whole point. Clicking Fit to Preview also turns on fit-on-switch, so new frames keep arriving fit-sized; zooming manually turns that back off. The preference persists across sessions.
The amber Aggressive Stretch button applies an extra asinh boost on top of the already-stretched display, revealing faint signal and making subtle defects (dim streaks, gradient asymmetry) obvious at the cost of noise and star bloat. It's purely a display setting — nothing is written back to the frame data. Also noticeably slower to render large frames; turn off when you don't need it.
The right-hand pane shows a magnified crop of the current frame. Controls:
The yellow rectangle marks the region the loupe is showing, measured in real image pixels. That means it gets smaller as you zoom the main preview out — correctly so, because the loupe really is showing you a smaller fraction of what is on screen. When the image is zoomed out far enough to sit letterboxed inside the preview pane, the rectangle stays glued to the image rather than to the pane.
Drag the vertical splitter between the main preview and the loupe to adjust their relative widths.
Click Interactive Metrics & Culling to open a second window with seven live scatter plots — one per metric — across every loaded frame.
In Stream mode the measurement pass loads, measures and releases each frame in turn, with a progress dialog you can cancel. The numbers are what persist, along with each frame's header, bit depth and background — so once a sweep has finished, the tree's metric columns are fully populated even though almost no pixels are resident.
CCD-TEMP, DET-TEMP, and friends). Higher
= cooling slipped, dark current rising.Each plot has a red horizontal line you can drag. Dropping a frame on the wrong side of the line flags it automatically:
Thresholds are per-filter and persist for the session: your threshold for Luminance stays set when you switch to Ha. A frame is flagged if any group's threshold triggers it, which keeps the per-filter tuning from unflagging a frame someone else wanted gone.
The Show median and ±3σ guides checkbox draws dashed horizontal guides on every plot:
σ here is the MAD-based robust estimator (§ 13.5), so individual outliers don't skew it the way a plain standard deviation would.
The dropdown above the plots restricts the display to one FILTER value, so you can threshold Ha and OIII independently. “All” shows every frame. The status bar at the bottom reports “Flagged Items X/Y (Z%)” for whatever subset is on screen.
The Detect Satellite Trails button runs the Cosmic Clarity ResNet detection model (a two-stage classifier) over every loaded image, flagging frames that likely contain a trail. See § 13.6 for the algorithm. Results populate the Sat column: ⚠ (orange) for detected, ✓ (green) for clean, blank for a frame that could not be checked.
The Mode dropdown next to the button picks how much work to do:
Both modes share exactly the same scan, so they can never disagree about which frames or tiles hold a trail. Highlight mode only adds the painting step on top.
This is a pre-pass, not a verdict — it can occasionally false-positive on big diffraction spikes or on bright edge stars. Use it to narrow down what to look at, not to automatically delete.
Once detection has run, right-click → Flag All Satellite Detections promotes every ⚠ to a flag in one shot.
Workflow suggestion. Detect sats first, then run Metrics, then blink through what's left. Each stage removes an obvious class of problem, leaving the subtle visual-only issues for the blinking pass.
Three buttons under the metrics / sat row push the current session to the Stacking Suite:
These buttons hand over file paths, not pixels, so they behave identically in PreLoad and Stream mode. They are also in the right-click menu (§ 9).
The Export Video… button creates an MP4 or AVI of the current session, suitable for timelapses or satellite-trail walkthroughs:
Blink tries multiple codec combinations in order (mp4v, avc1, H264, MJPG, XVID) and falls back to the imageio-ffmpeg backend if none of them opens. The best available is used automatically; if nothing works, Blink says so and suggests installing FFmpeg.
Export works in Stream mode as well — it pulls each frame as it reaches it — so a long export is disk-bound rather than memory-bound.
Right-clicking any leaf (file) in the tree opens a context menu with these actions:
Bad_) to every flagged frame's filename, so you can
cull later via a filename filter in your stacker of choice without
losing anything.After deleting rows, Blink selects the next surviving frame and keeps your place in the list rather than jumping back to the top.
Delete is permanent. “Delete Selected
Items” and “Delete All Flagged Images” both call
os.remove() — there is no undo and no trash. If
you're not sure, use the Move variants (which are reversible by moving
the files back) or Rename Flagged (which just renames).
Blink saves nearly everything about its state across sessions. Window
geometry (main and Metrics windows), tree column widths and order
(tree_header_state_v2), the main / preview and zoom-panel
splitter positions, and Zoom Panel visibility are all remembered.
The named settings keys are:
| Key | Default | What it does |
|---|---|---|
blink/last_folder | — | Folder the file dialogs open in. |
blink/load_scale | 1 | 1:1 / 1:2 / 1:4 / 1:8 (§ 1.1). |
blink/stream | false | PreLoad (false) or Stream (true) (§ 1.2). |
blink/stream_budget_mb | 1024 | How much decoded imagery Stream mode keeps resident. |
blink/cfa_color | true | Keep colour on CFA frames (§ 1.3). |
blink/fast_read | true | Kill switch for the strided reader (§ 13.10). |
blink/aggressive_stretch | false | Aggressive Stretch toggle state. |
blink/auto_fit | false | Keep fitting new frames to the preview. |
blink/play_fps | 1.0 | Playback rate. |
blink/tree_filter | all | The Show dropdown's selection. |
blink/sat_detect_mode | fast | Fast or Highlight trails (§ 6.1). |
blink/sat_edge_exclusion_px | 128 | Amp-glow edge filter width (§ 6.2). |
blink/sat_detect_workers | 2 | Concurrent satellite-detection loaders (§ 6.2). |
blink/thread_safety | auto | auto, safe or fast
(§ 13.7). |
| Shortcut | Action |
|---|---|
| Space | Toggle play / pause (when tree has focus). |
| F | Flag or unflag the current frame, then advance. |
| ↑ / ↓ | Previous / next frame. |
Ctrl + wheel |
Zoom the main preview around the cursor. |
| Left-drag on preview | Pan. |
| Right-click on preview | Pin the Zoom Panel to this spot. |
Blink runs the metric computations in parallel
threads using ThreadPoolExecutor, with
SEP (Source Extractor Python) doing the per-frame source extraction and
numpy / OpenCV handling the pixel-level work. The threading
model — and specifically its interaction with numba's resolved
threading layer — is unusually subtle and is covered in
§ 13.7.
For each frame, metric computation runs on a 2×-downsampled copy of the storage image. The downsample is for speed and RAM — SEP's background modelling is the slow part, and a 2× downsample is enough to keep sub-pixel precision in the result. The pipeline:
cv2.INTER_AREA.Background model and extract the global
RMS. If RMS is non-finite (uniform background, pure sky), fall
back to None and let SEP decide its own.In Stream mode step 1 is preceded by a just-in-time decode of the frame, performed on the measuring worker's own thread. The decode deliberately bypasses the frame cache: routing it through the cache's two-thread pool would funnel every measuring worker through the same bottleneck, and would evict the frames you are actually looking at in favour of frames measured and dropped a moment later.
From each SEP source's second-moment axes a and
b (semi-major and semi-minor), the sigma of an equivalent
circular Gaussian is σ = sqrt(a·b), and
FWHM ≈ 2.3548 · σ. Blink reports the median across
all detected sources, scaled back to full-resolution pixel units:
where the factor of 2 compensates for the 2× downsample and
sload is the load-scale factor (1 at 1:1, 2 at
1:2, etc.), so FWHM values from any load scale compare directly.
Eccentricity from the same axes:
reported again as the median across sources. Perfect round stars give 0, highly elongated streaks give values approaching 1.
A composite built from star count, eccentricity, and background:
interpreting as: more stars is better, round stars are better, dark background is better. The three terms multiply, so a bad score on any axis drags the composite down — a 500-star frame with e = 0.6 scores worse than a 300-star frame at e = 0.1. Background enters as a saturating curve so a very dark frame doesn't dominate the metric. For most sessions, thresholding on Weighted Score alone catches ~80% of what the other three catch individually.
Converts the cached pre-stretch background from its stored normalised value back to raw ADU, subtracts the sensor OFFSET pedestal from the header, and divides by exposure time:
where B is the file's bit depth. OFFSET falls back to
0 when not in the header (common on SGP captures with default
templates, and some ASCOM drivers) — which means within-session
relative ordering is preserved for culling, only the absolute
ADU/s value is inflated by the missing pedestal / exposure. Blink
exploits this: BG/s is used for ranking, not for calibration, so a
missing OFFSET doesn't break the metric.
The ±1σ, ±2σ, ±3σ guides on the metrics plots use the median-absolute-deviation estimator:
where &ytilde; is the median. The constant 1.4826 is
1 / Φ⁻¹(0.75), which makes
σ̂ an unbiased estimator of the standard
deviation for Gaussian data. The point of using MAD rather than the
usual std is immunity to outliers: one or two really bad frames in a
session don't push the σ lines out so far that they become
useless.
A two-stage classifier runs on 256×256 interior tiles of the raw (linear, un-stretched) frame, on a fixed non-overlapping grid. The grid has to be fixed, because of what happens next.
After every frame has been scanned, Blink builds a fixed-pattern map per rig configuration: tiles that keep passing in the same sensor position across many frames are not satellites — they are diffraction spikes, amp glow, or a hot column. Those tiles are vetoed, and a frame whose only evidence was a fixed-pattern tile is cleared. This is why the detection grid cannot overlap: on a fixed grid a stationary artefact lands in the same tile every frame and the map catches it, while on an overlapping grid it lands in a different subset each time and the common region shrinks to nothing.
Frames that could not be scanned at all are left unknown (blank Sat cell) rather than being recorded as clean. If the GPU adapter is lost mid-run, the run aborts, GPU acceleration is disabled for the rest of the session, and Blink says so plainly rather than silently finishing on partial results.
Blink's parallel loader is where a notorious cross-platform hazard
lives. SASpro's debayer path is accelerated with numba; numba's
resolved threading layer varies by platform and build. On boxes
whose numba fell back to the non-threadsafe workqueue
layer (seen on some StellarMate / Linux and macOS builds), running the
debayer kernel from several threads at once aborts the process.
Blink works around this by:
workqueue → serialize, anything threadsafe
(OpenMP / TBB) → concurrent.blink/thread_safety with values auto
(default), safe, or fast for people who
want to force a mode.Worker counts scale with the mode: safe mode caps at
min(cpus − 1, 8); fast mode uses
min(cpus − reserved, 60) with reserved =
25% of cores (capped at 4). The SEP metric path uses its own smaller
pool (4 to 12 workers depending on frame size) because SEP's internal
C allocator is itself contended, and in Stream mode that pool is capped
at 4, because there every in-flight worker is also holding a
full-resolution frame it just decoded.
Stream mode applies the same numba guard before its cache workers start, and its prefetch pool is only two threads wide — so it is a far gentler load on this machinery than the PreLoad fan-out. Turning off Keep color on CFA frames (§ 1.3) removes the debayer from the Stream path entirely.
Exposure values on the tree — the “300s” part —
are canonicalised so that 300, 300.0,
"300 s", and "300 seconds"
all end up in the same bucket. Temperature values are similarly
tolerant of units (“-10 C”, “−10 C”,
“-10 °C” are all recognised) and EXIF-style
shutter fractions (“1/125”) are parsed as floats. The
temperature keyword list prefers actual sensor temps (CCD-TEMP,
DET-TEMP, SENSOR-TEMP and friends) over cooler setpoints (SET-TEMP,
SETPOINT), matching the Stacking Suite's own precedence.
Right-click → Open in Document Window creates a new SASpro
document from the current frame, preserving the original FITS header,
bit depth, is-mono flag, and metadata dict. Only the numpy array is
new — everything else that load_image originally
returned rides along, so plate-solving, WCS, and any other
header-aware tool in SASpro will see the correct metadata.
Both FITS and XISF store pixels the same way once you know the byte
offset: uncompressed, row-major, one contiguous plane per channel. So
at 1:4 and 1:8 Blink skips load_image entirely and reads
only the rows the downsampled frame needs, through a plain per-thread
file handle. It is deliberately not memory-mapped — SASpro's
FITS loader moved to memmap=False precisely because
faulting mmap pages from a parallel loader corrupts the heap, and a
seek/read handle shares no state at all.
Each output pixel is the mean of a 2 × step box
centred in its step × step cell. The rows are what
cost I/O; the columns are already in the buffer and therefore free.
That transfers 2/step of the pixel block, and it is why
1:2 is excluded: at step 2 the box needs every row, so it would move
the whole file in thousands of small reads and lose to one big read
plus cv2.INTER_AREA.
CFA frames get a different box. Naively striding a Bayer
mosaic is a trap — any even stride samples the same
position of every quad, so data[::4, ::4] on an RGGB frame
returns a pure red plane. Instead Blink reads two rows out of every
2×step, both quad-aligned, and averages each CFA
channel separately. That yields one intact quad per cell, so the result
is still a genuine mosaic at the target size and the ordinary debayer
turns it into colour — and it transfers only
1/step, half what the grey path needs.
What it costs, measured against the full-read-then-INTER_AREA path (ratios above 1 are noisier than before):
| Path | 1:4 | 1:8 |
|---|---|---|
| Mono / RGB | 1.41× | 2.00× |
| CFA, red | 0.93× | 1.18× |
| CFA, green | 1.26× | 1.66× |
| CFA, blue | 0.92× | 1.16× |
Red and blue come out at or near parity because the colour path
spends its whole sample budget on real CFA samples rather than
interpolated ones. Green is the weak channel: the sensor gives it twice
the density, and the old full-cell average exploited that. Reading all
step rows would match the old noise exactly and save no I/O
whatsoever, which is the entire point of the exercise.
The reader declines anything it cannot reproduce exactly as
load_image would, and the classic path picks it up
silently. It declines: gzipped and tile-compressed FITS; compressed,
inline or big-endian XISF; the FITS sample formats load_image
min-max normalises (BITPIX 32 and 64, plain signed 16-bit), because a
min/max taken over a subsample is not the frame's min/max and the same
file would then normalise differently at 1:1 and 1:4; 3-D data that is
not planar RGB; frames too small for the seeks to pay; and BOTTOM-UP
CFA frames in colour mode, because flipping a mosaic vertically swaps
row parity and therefore changes the effective Bayer pattern.
blink/fast_read disables it outright.
Side effect worth knowing. Finding the header
cheaply for Stream mode turned up a long-standing cost in
get_valid_header: it located the image HDU by testing
hdu.data is not None, which with memmap=False
materialises the entire pixel block. Since load_image calls
it and then reopens the file to read the pixels again, every FITS
load in SASpro was reading the data twice. The test is now header-based
(NAXIS plus non-zero NAXISn), which is 39.6 ms →
0.6 ms on a 48 MB frame and speeds up FITS loading everywhere,
not just in Blink.
Opening a folder in Stream mode runs a header-only probe per file — about 0.9 ms for a FITS frame, roughly 375× cheaper than decoding it — and builds the tree from that. Entries exist with no pixels attached.
Decoded frames live in an LRU cache under a byte budget
(blink/stream_budget_mb, 1 GB by default), evicted
oldest-first. A single frame larger than the entire budget is still
kept, because evicting it would mean never being able to display it.
Two background worker threads run the prefetch, three frames ahead in
whichever direction you are travelling; an interactive click cancels any
queued-but-unstarted prefetch so it never waits behind speculative work.
Concurrent requests for the same frame collapse onto one decode.
The cache is keyed by file path, not row index. That matters more than it sounds: deleting a row shifts every index after it, and an index-keyed cache would then be able to serve one frame's pixels under another frame's row. With path keying a stale entry can only ever waste a little memory — never show you the wrong picture, which is the one failure a blink comparator must not have.
Metadata and pixels have different lifetimes. When a frame is decoded, its header, bit depth, mono flag and background are folded back onto the entry permanently; only the pixel array is subject to eviction. So a frame that has been viewed or measured once keeps its tree grouping and its metric columns forever, at a cost of a few hundred bytes.
Blink stores display data as uint16 unconditionally. The frame has
already been stretched and clipped to [0, 1] by the time it is
stored, and the only thing that re-expands it is the Aggressive Stretch,
whose steepest slope is
k / arcsinh(k) = 2.07× at
k = 3.7 × 1.25. Working through what each width
survives that boost:
uint16 is the only width that is neither lossy nor wasteful. A 6000×4000 frame drops from 96 MB to 48 MB, so a 500-frame session that used to want 48 GB now wants 24 GB — and the real memory levers remain the load-scale buttons (4× / 16× / 64×) and Stream mode.
a / b second-moment axes and the
background model come from. Satellite trail detection uses
Cosmic Clarity's ResNet dual-classifier model (see the Cosmic
Clarity suite documentation for model provenance). Image loading and
debayering use SASpro's existing engines
(setiastro.saspro.legacy.image_manager for loads,
setiastro.saspro.debayer for the canonical debayer path
shared with the Stacking Suite). XISF parsing uses the
xisf Python module (Sergio Díaz), and the XISF format
specification is PixInsight's. The MAD-based robust-sigma guide line
uses the classical 1.4826 Gaussian-consistency factor
(Rousseeuw & Croux 1993). Video export uses OpenCV's
VideoWriter with codec fallbacks and imageio-ffmpeg as a
portable last resort.