← README · Architecture · Calibration
The pipeline takes a set of raw light frames, aligns every frame to the first (reference) using star-pattern matching, and produces a mean-stacked output with a percentile-based histogram stretch.
Entry point: Stack() in stacker.cpp.
┌─────────────────────────────┐
│ Input: light frame paths │
│ + CalibrationFrames │
└────────────┬────────────────┘
│
┌────────────▼────────────────┐
│ LoadFrames() │
│ Fail fast if any path bad │
└────────────┬────────────────┘
│
┌─────────────────▼─────────────────┐
│ Frame 0 — Reference │
│ ┌─────────────────────────────┐ │
│ │ CalibrateFrame() │ │
│ │ subtract masterDark │ │
│ │ divide by masterFlat │ │
│ └──────────────┬──────────────┘ │
│ ┌──────────────▼──────────────┐ │
│ │ ProcessFrame() │ │
│ │ DetectStars() │ │
│ │ IdentifyCandidates() │ │
│ │ Normalize + Rank │ │
│ │ BuildNeighbours() │ │
│ │ BuildTriangleDesc() │ │
│ └──────────────┬──────────────┘ │
│ sum = refF │ │
│ count = 1 │ │
└─────────────────┼─────────────────┘
│
┌─────────────────▼─────────────────┐
│ For each target frame i = 1…N │
│ │
│ CalibrateFrame(frame_i) │
│ │ │
│ ProcessFrame(calibrated) │
│ → FrameResult { candidates, │
│ triangles } │
│ │ │
│ MatchTriangles(ref, tgt) │
│ ┌──── empty? ────┐ │
│ │ skip frame │ │
│ └────────────────┘ │
│ │ │
│ ExtractCorrespondences() │
│ vote-deduplicate pairs │
│ │ │
│ EstimateTransform() │
│ RANSAC similarity transform │
│ ┌──── failed? ───┐ │
│ │ skip frame │ │
│ └────────────────┘ │
│ │ │
│ warpAffine(calibrated, M) │
│ ┌── off-canvas? ─┐ │
│ │ skip frame │ │
│ └────────────────┘ │
│ │ │
│ sum += aligned; count++ │
└─────────────────┬─────────────────┘
│
┌────────────▼────────────────┐
│ MeanStack(sum, count) │
│ ├ divide by count │
│ ├ percentile stretch │
│ │ lo = 0.1th percentile │
│ │ hi = 99.9th percentile │
│ └ convert → CV_8U │
└────────────┬────────────────┘
│
┌────────────▼────────────────┐
│ test/stacked.png │
└─────────────────────────────┘
output = gray − GaussianBlur(gray, kernel=101×101, σ=50)
A very large blur models the smooth sky background (light-pollution gradient, vignetting). Subtracting it leaves only high-frequency star signal. Kernel size is fixed at 101×101; adaptive sizing by image resolution is a known TODO.
- Call
RemoveBackground(). - Normalize the result to [0, 255].
- Compute the 99.99th percentile pixel value and threshold at it. This retains only the top 0.01% brightest pixels — star cores.
- Apply a 3×3 elliptical morphological opening to remove single-pixel sensor noise.
Why percentile threshold instead of a fixed value? A fixed threshold is brittle across exposure times, ISO, and sky conditions. A percentile adapts to the actual brightness distribution of the image.
Connected-component analysis (cv::connectedComponentsWithStats) on the binary mask. For each component:
| Check | Rule | Reason |
|---|---|---|
| Area | ≥ 5 px | Reject single-pixel noise |
| Eccentricity | ≤ 0.8 | Reject elongated streaks (satellite trails, etc.) |
| Circularity | ≥ 0.4 | Reject non-star blobs |
Per candidate, the following are computed:
- Centroid — intensity-weighted (flux-weighted), not geometric centre.
- Brightness — sum of all pixel values in bounding box.
- Contrast — peak pixel minus mean of surrounding background ring.
- Circularity —
4π·area / perimeter². - Eccentricity — from a fitted ellipse on the component contour.
Each feature is min-max normalized to [0, 1] across all candidates in the frame. A weighted composite score is then computed:
score = 3.25·normalizedArea
+ 2.25·normalizedBrightness
+ 3.25·normalizedContrast
+ 0.25·circularity
+ 0.25·(1 − eccentricity)
Stars are sorted descending by score; only the top-ranked stars participate in k-NN / triangle matching.
For each star, compute squared Euclidean distances to every other star. Sort ascending, keep the k=30 closest. Squared distances are used throughout — ratios compare correctly without sqrt, saving one transcendental function call per pair.
Current implementation is brute-force O(n²). A KD-tree is noted as a future optimisation.
For every star i and each pair of its k=30 neighbours (j, k):
- Compute squared side lengths
side1, side2, side3. - Sort ascending →
a ≤ b ≤ c. - Compute shape ratios:
r1 = a/c,r2 = b/c.
The pair (r1, r2) is invariant to rotation, translation, and uniform scale. Degenerate triangles (any side < 1e-6) are skipped.
For every target triangle × every reference triangle, compare |r1_tgt − r1_ref| and |r2_tgt − r2_ref|. Both must be < 0.002 (0.2% tolerance). All passing pairs are returned, sorted by total error ascending.
Complexity: O(|ref| · |tgt|). At k=30, each candidate produces O(n · k²/2) triangles, giving fast invariant shape matching across star triplets.
Each TriangleMatch encodes three star-pair correspondences (one per vertex). These are accumulated in a vote map keyed by (ref_idx, tgt_idx). Only pairs with votes ≥ 2 are kept. Pairs are sorted descending by vote count — the most-agreed-on correspondences are fed to RANSAC first.
cv::estimateAffinePartial2D with RANSAC:
- 4 DOF: translation (tx, ty) + rotation (θ) + uniform scale (s). No shear.
- Reprojection threshold: 3 px.
- Requires ≥ 3 input pairs; returns empty
Mif fewer are available or RANSAC fails.
The calibrated float32 frame is warped using transform M to the reference coordinate space. A sanity check rejects warps that map all content off-canvas (mean pixel < 0.5 in all channels).
- Divide the float32 accumulator by the count of successfully aligned frames.
- Flatten all channel values, sort, extract 0.1th and 99.9th percentile as
lo/hi. - Stretch:
stretched = (meanF − lo) × (255 / (hi − lo)). Clamp negatives to zero. - Convert to
CV_8U→ writetest/stacked.png.
Why the stretch? A raw stacked image looks identical to a single frame — all the stacked signal lives in a very narrow low-value band of [0, 255]. The percentile stretch maps faint nebulosity into the full 8-bit range while clipping hot pixels.
A frame is excluded from the accumulator (but still logged) when:
- No triangle matches — star fields are too dissimilar (clouds, wrong frame, bad exposure).
- Transform estimation failed — fewer than 3 correspondences passed voting, or RANSAC found no consensus.
- Off-canvas warp — the computed transform maps all image content outside the reference bounds (degenerate solution).