Blink Comparator

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.

1. Loading images

At the top of the left column:

Files are sorted naturally — frame_2.fits comes before frame_10.fits, not after. The last-used folder is remembered across sessions.

1.1 Load scale (1:1 / 1:2 / 1:4 / 1:8)

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.

1.2 PreLoad vs Stream

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.

1.3 Keep color on CFA frames

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.

1.4 What Blink does to each frame on load

Every frame goes through the same pipeline:

  1. Load the raw image + header.
  2. Debayer if the file is a mono mosaic (CFA pattern detected from header).
  3. Optionally downsample per load-scale setting. On the fast path (§ 1.1) steps 1–3 collapse into a single strided read that produces the downsampled frame directly.
  4. Measure background on the pre-stretch linear data (SEP) and cache the result — this is what the Background and Bg Rate metrics report.
  5. Auto-stretch to a target median of 0.25 (per channel for colour, global for mono) for display purposes.
  6. Store as uint16. Blink used to pick a storage dtype from available RAM — uint8 on small machines, float32 on large ones — and both ends were wrong. The array has already been stretched and clipped to [0, 1] by this point, so float32 was spending two extra bytes a pixel on precision no display can show, while uint8 was below what the Aggressive Stretch needs and produced visible banding on exactly the machines least able to spare the quality. See § 13.12.

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.

2. The tree view

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.

2.1 Columns

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.

2.2 Show filter

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.

3. Blinking through frames

3.1 Playback controls

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.

3.2 Keyboard shortcuts

When the file tree has focus:

4. Preview area

4.1 Main preview

Occupies the right side of the window. The current frame is shown stretched for display. Controls:

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.

4.2 Aggressive Stretch toggle

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.

4.3 Zoom Panel (loupe)

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.

5. Interactive Metrics & Culling

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.

5.1 The seven metrics

5.2 Interacting with points

5.3 Threshold lines

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.

5.4 Guide lines

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.

5.5 Filter selector

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.

6. Satellite Trail Detection

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.

6.1 Fast vs Highlight trails

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.

6.2 Advanced settings (the wrench)

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.

7. Stacking Suite integration

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).

8. Video Export

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.

9. Right-click menu

Right-clicking any leaf (file) in the tree opens a context menu with these actions:

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).

10. Other buttons

11. Persistence

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:

KeyDefaultWhat it does
blink/last_folder— Folder the file dialogs open in.
blink/load_scale1 1:1 / 1:2 / 1:4 / 1:8 (§ 1.1).
blink/streamfalse PreLoad (false) or Stream (true) (§ 1.2).
blink/stream_budget_mb1024 How much decoded imagery Stream mode keeps resident.
blink/cfa_colortrue Keep colour on CFA frames (§ 1.3).
blink/fast_readtrue Kill switch for the strided reader (§ 13.10).
blink/aggressive_stretchfalse Aggressive Stretch toggle state.
blink/auto_fitfalse Keep fitting new frames to the preview.
blink/play_fps1.0 Playback rate.
blink/tree_filterall The Show dropdown's selection.
blink/sat_detect_modefast Fast or Highlight trails (§ 6.1).
blink/sat_edge_exclusion_px128 Amp-glow edge filter width (§ 6.2).
blink/sat_detect_workers2 Concurrent satellite-detection loaders (§ 6.2).
blink/thread_safetyauto auto, safe or fast (§ 13.7).

12. Keyboard shortcuts summary

ShortcutAction
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.

13. Technical details

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.

13.1 Per-frame SEP pipeline

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:

  1. Convert the storage image to float32 in [0, 1] (collapsing colour to a mean plane). NaN/Inf are replaced with 0 / 1 / 0 before SEP sees anything.
  2. Resize to half size with cv2.INTER_AREA.
  3. Build a SEP 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.
  4. Try the detection threshold cascade:
    \{\,7\sigma \text{ (minarea 4)},\; 5\sigma \text{ (4)},\; 4\sigma \text{ (3)},\; 3.5\sigma \text{ (2)}\,\}
    taking the first threshold that returns any stars. On very clean frames the 7σ pass finds hundreds of sources; on cloudy frames the cascade drops down until something comes back. This is what lets Blink continue to produce a star-count metric even on bad frames.

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.

13.2 FWHM and eccentricity

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:

\mathrm{FWHM} \;=\; \mathrm{median}\!\left(2.3548\,\sqrt{a\,b}\right) \;\cdot\; 2 \;\cdot\; s_{\mathrm{load}}

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:

e \;=\; \sqrt{1 \;-\; (b/a)^2}

reported again as the median across sources. Perfect round stars give 0, highly elongated streaks give values approaching 1.

13.3 Weighted Score

A composite built from star count, eccentricity, and background:

W \;=\; N_{\star} \,\cdot\, (1 - e) \,\cdot\, \left(\frac{2}{\sqrt{\mathrm{bg}} + 1} \;-\; 1\right)

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.

13.4 Bg Rate (ADU per second)

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:

\mathrm{BG/s} \;=\; \frac{\mathrm{bg} \,\cdot\, (2^{B} - 1) \;-\; \mathrm{OFFSET}}{t_{\mathrm{exp}}}

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.

13.5 Robust sigma estimator for guides

The ±1σ, ±2σ, ±3σ guides on the metrics plots use the median-absolute-deviation estimator:

\hat{\sigma} \;=\; 1.4826 \,\cdot\, \mathrm{median}(|y - \tilde{y}|)

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.

13.6 Satellite detection

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.

13.7 Thread safety and numba threading layer

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:

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.

13.8 Header-aware exposure labels

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.

13.9 Push to Document

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.

13.10 The strided reader (1:4 and 1:8)

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):

Path1:41:8
Mono / RGB1.41×2.00×
CFA, red0.93×1.18×
CFA, green1.26×1.66×
CFA, blue0.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.

13.11 Stream mode internals

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.

13.12 Storage dtype

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.

Credits. Blink Comparator is SASpro's own tool. Star measurement uses SEP (Barbary 2016), the Python port of E. Bertin and S. Arnouts' Source Extractor (SExtractor), which is where the 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.