Skip to content

Latest commit

 

History

History
220 lines (161 loc) · 10.8 KB

File metadata and controls

220 lines (161 loc) · 10.8 KB

Stacking Pipeline

← README · Architecture · Calibration


Overview

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.


Flowchart

                    ┌─────────────────────────────┐
                    │  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            │
                    └─────────────────────────────┘

Stage-by-Stage Reference

1 · Background Subtraction — RemoveBackground()

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.


2 · Star Detection — DetectStars()

  1. Call RemoveBackground().
  2. Normalize the result to [0, 255].
  3. Compute the 99.99th percentile pixel value and threshold at it. This retains only the top 0.01% brightest pixels — star cores.
  4. 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.


3 · Candidate Identification — IdentifyCandidates()

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.
  • Circularity4π·area / perimeter².
  • Eccentricity — from a fitted ellipse on the component contour.

4 · Normalization & Ranking — NormalizeCandidateFeatures(), RankCandidates()

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.


5 · k-NN Graph — BuildNeighbours()

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.


6 · Triangle Descriptors — BuildTriangleDesc()

For every star i and each pair of its k=30 neighbours (j, k):

  1. Compute squared side lengths side1, side2, side3.
  2. Sort ascending → a ≤ b ≤ c.
  3. 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.


7 · Triangle Matching — MatchTriangles()

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.


8 · Correspondence Voting — ExtractCorrespondences()

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.


9 · Transform Estimation — EstimateTransform()

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 M if fewer are available or RANSAC fails.

10 · Frame Warping — warpAffine()

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


11 · Mean Stacking + Stretch — MeanStack()

  1. Divide the float32 accumulator by the count of successfully aligned frames.
  2. Flatten all channel values, sort, extract 0.1th and 99.9th percentile as lo / hi.
  3. Stretch: stretched = (meanF − lo) × (255 / (hi − lo)). Clamp negatives to zero.
  4. Convert to CV_8U → write test/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.


Skip Conditions

A frame is excluded from the accumulator (but still logged) when:

  1. No triangle matches — star fields are too dissimilar (clouds, wrong frame, bad exposure).
  2. Transform estimation failed — fewer than 3 correspondences passed voting, or RANSAC found no consensus.
  3. Off-canvas warp — the computed transform maps all image content outside the reference bounds (degenerate solution).