Skip to main content

arcsec_core/index/
pattern.rs

1//! The geometry shared by the index builder and the index solver: the 4-star
2//! pattern descriptor, its quantised hash key, the canonical vertex order that
3//! gives a star correspondence, and the tangent-plane projection.
4//!
5//! The descriptor is the six pairwise distances of a quad, sorted, divided by the
6//! largest — the five ratios ASTAP's quads use too (`quads/`). It is invariant to
7//! translation, rotation, scale and reflection, so one index entry serves both image
8//! parities. Unlike astrometry.net's code space it does not say which star is which,
9//! so the vertices are put in a canonical order (ascending total distance to the
10//! other three), which both sides compute the same way; near-ties in that order are
11//! the one ambiguity, and [`orderings`] enumerates the alternatives on the image
12//! side. A wrong correspondence fails the affine shape check, so trying an extra
13//! ordering costs a few multiplications, never a false match.
14//!
15//! **Any change to [`BINS`], [`descriptor`], [`key`] or [`canonical`] changes which
16//! entries an index holds or how they hash, and must bump the format version in
17//! `format.rs`.**
18
19/// Quantisation bins per descriptor dimension. A bin is 1/128 = 0.0078 wide, about
20/// the hinted solver's default quad tolerance (0.007).
21pub const BINS: f64 = 128.0;
22
23/// Probe the neighbouring bin when a ratio lies within this distance of a bin edge.
24/// Measurement noise can only move a ratio across an edge it is already close to,
25/// so only those dimensions need a second probe: typically one to four keys per
26/// quad instead of 3⁵.
27pub const PROBE_EPS: f64 = 0.0025;
28
29/// Relative difference in total distance below which two vertices count as tied in
30/// the canonical order, so both orders are tried.
31const TIE_EPS: f64 = 0.015;
32
33/// Pairs of a quad's vertices, in the order the six distances are taken.
34const PAIRS: [(usize, usize); 6] = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)];
35
36/// The canonical vertex order of a quad and its longest edge.
37///
38/// Vertices are sorted by their total distance to the other three, ascending (ties
39/// by original position, so the result is deterministic). Returns the order and the
40/// sorted totals, or `None` for a degenerate quad (all points coincident).
41#[must_use]
42pub fn canonical(p: &[(f64, f64); 4]) -> Option<([usize; 4], [f64; 4], f64)> {
43    let mut totals = [0.0f64; 4];
44    let mut max_edge = 0.0f64;
45    for &(i, j) in &PAIRS {
46        let d = (p[i].0 - p[j].0).hypot(p[i].1 - p[j].1);
47        totals[i] += d;
48        totals[j] += d;
49        max_edge = max_edge.max(d);
50    }
51    if max_edge <= 0.0 || !max_edge.is_finite() {
52        return None;
53    }
54    let mut order = [0usize, 1, 2, 3];
55    order.sort_by(|&a, &b| totals[a].total_cmp(&totals[b]).then(a.cmp(&b)));
56    let sorted = order.map(|i| totals[i]);
57    Some((order, sorted, max_edge))
58}
59
60/// The canonical order plus the alternatives that a near-tie makes possible: each
61/// adjacent pair whose totals differ by less than `TIE_EPS` (1.5 %) may be swapped. At
62/// most eight orders (three independent adjacent swaps).
63#[must_use]
64pub fn orderings(order: [usize; 4], totals: [f64; 4]) -> Vec<[usize; 4]> {
65    let mut out = vec![order];
66    for k in 0..3 {
67        let tied = (totals[k + 1] - totals[k]) <= TIE_EPS * totals[k + 1];
68        if tied {
69            let n = out.len();
70            for i in 0..n {
71                let mut o = out[i];
72                o.swap(k, k + 1);
73                if !out.contains(&o) {
74                    out.push(o);
75                }
76            }
77        }
78    }
79    out
80}
81
82/// The five sorted distance ratios of a quad, each in `(0, 1]`.
83#[must_use]
84pub fn descriptor(p: &[(f64, f64); 4]) -> [f64; 5] {
85    let mut e = [0.0f64; 6];
86    for (k, &(i, j)) in PAIRS.iter().enumerate() {
87        e[k] = (p[i].0 - p[j].0).hypot(p[i].1 - p[j].1);
88    }
89    e.sort_by(f64::total_cmp);
90    let m = e[5].max(1e-300);
91    [e[0] / m, e[1] / m, e[2] / m, e[3] / m, e[4] / m]
92}
93
94#[inline]
95fn bin(v: f64) -> i64 {
96    ((v * BINS) as i64).clamp(0, BINS as i64 - 1)
97}
98
99/// The descriptor's home bucket: each ratio floored into one of [`BINS`] bins,
100/// packed eight bits per dimension, first ratio most significant.
101#[must_use]
102pub fn key(d: &[f64; 5]) -> u64 {
103    d.iter().fold(0u64, |k, &v| (k << 8) | bin(v) as u64)
104}
105
106/// Every key a measured descriptor could have had in the index: the home bucket,
107/// plus the neighbouring bin in any dimension within [`PROBE_EPS`] of an edge.
108/// Written into `out`, which is cleared first.
109pub fn probe_keys(d: &[f64; 5], out: &mut Vec<u64>) {
110    out.clear();
111    let mut opts = [[0i64; 2]; 5];
112    let mut n_opts = [1usize; 5];
113    for (dim, &v) in d.iter().enumerate() {
114        let b = bin(v);
115        opts[dim][0] = b;
116        let frac = v * BINS - b as f64;
117        if frac < PROBE_EPS * BINS && b > 0 {
118            opts[dim][1] = b - 1;
119            n_opts[dim] = 2;
120        } else if frac > 1.0 - PROBE_EPS * BINS && b < BINS as i64 - 1 {
121            opts[dim][1] = b + 1;
122            n_opts[dim] = 2;
123        }
124    }
125    let total: usize = n_opts.iter().product();
126    for combo in 0..total {
127        let mut rest = combo;
128        let mut k = 0u64;
129        for dim in 0..5 {
130            let pick = rest % n_opts[dim];
131            rest /= n_opts[dim];
132            k = (k << 8) | opts[dim][pick] as u64;
133        }
134        out.push(k);
135    }
136}
137
138// ── Sphere ↔ tangent plane ─────────────────────────────────────────────────────
139
140/// Unit vector of (`ra`, `dec`), radians.
141#[inline]
142#[must_use]
143pub fn unit(ra: f64, dec: f64) -> [f64; 3] {
144    let (sr, cr) = ra.sin_cos();
145    let (sd, cd) = dec.sin_cos();
146    [cd * cr, cd * sr, sd]
147}
148
149#[inline]
150fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
151    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
152}
153
154/// A gnomonic tangent plane: its centre and local east and north unit vectors.
155/// Standard coordinates are radians, east and north positive.
156#[derive(Debug, Clone, Copy)]
157pub struct Tangent {
158    /// Unit vector of the tangent point.
159    pub c: [f64; 3],
160    e: [f64; 3],
161    n: [f64; 3],
162}
163
164impl Tangent {
165    /// The plane touching the sphere at unit vector `c` (need not be normalised).
166    /// `None` at the poles' exact axis, where east is undefined.
167    #[must_use]
168    pub fn at(c: [f64; 3]) -> Option<Self> {
169        let norm = dot(&c, &c).sqrt();
170        if norm <= 0.0 {
171            return None;
172        }
173        let c = [c[0] / norm, c[1] / norm, c[2] / norm];
174        let h = c[0].hypot(c[1]);
175        if h < 1e-12 {
176            return None;
177        }
178        let e = [-c[1] / h, c[0] / h, 0.0];
179        let n = [-c[2] * e[1], c[2] * e[0], h];
180        Some(Self { c, e, n })
181    }
182
183    /// The plane touching (`ra`, `dec`).
184    #[must_use]
185    pub fn at_radec(ra: f64, dec: f64) -> Option<Self> {
186        Self::at(unit(ra, dec))
187    }
188
189    /// Standard coordinates of unit vector `u`; `None` behind the plane.
190    #[inline]
191    #[must_use]
192    pub fn project(&self, u: &[f64; 3]) -> Option<(f64, f64)> {
193        let depth = dot(u, &self.c);
194        if depth <= 1e-9 {
195            return None;
196        }
197        Some((dot(u, &self.e) / depth, dot(u, &self.n) / depth))
198    }
199
200    /// (RA, Dec) radians of standard coordinates (`xi`, `eta`).
201    #[must_use]
202    pub fn deproject(&self, xi: f64, eta: f64) -> (f64, f64) {
203        let v = [
204            self.c[0] + xi * self.e[0] + eta * self.n[0],
205            self.c[1] + xi * self.e[1] + eta * self.n[1],
206            self.c[2] + xi * self.e[2] + eta * self.n[2],
207        ];
208        let norm = dot(&v, &v).sqrt();
209        let ra = v[1].atan2(v[0]).rem_euclid(2.0 * core::f64::consts::PI);
210        (ra, (v[2] / norm).clamp(-1.0, 1.0).asin())
211    }
212}
213
214/// Least-squares affine map `q = A·p + t` from points `p` to points `q`, returned as
215/// `[a, b, c, d, e, f]` with `qx = a·px + b·py + c`, `qy = d·px + e·py + f`. `None`
216/// when the points are collinear or fewer than three.
217#[must_use]
218pub fn fit_affine(p: &[(f64, f64)], q: &[(f64, f64)]) -> Option<[f64; 6]> {
219    let n = p.len().min(q.len());
220    if n < 3 {
221        return None;
222    }
223    // Centre both sets for conditioning, then solve the 2×2 normal equations.
224    let (mut mx, mut my, mut nx, mut ny) = (0.0, 0.0, 0.0, 0.0);
225    for i in 0..n {
226        mx += p[i].0;
227        my += p[i].1;
228        nx += q[i].0;
229        ny += q[i].1;
230    }
231    let k = n as f64;
232    let (mx, my, nx, ny) = (mx / k, my / k, nx / k, ny / k);
233    let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
234    let (mut ux, mut uy, mut vx, mut vy) = (0.0, 0.0, 0.0, 0.0);
235    for i in 0..n {
236        let (x, y) = (p[i].0 - mx, p[i].1 - my);
237        let (u, v) = (q[i].0 - nx, q[i].1 - ny);
238        sxx += x * x;
239        sxy += x * y;
240        syy += y * y;
241        ux += u * x;
242        uy += u * y;
243        vx += v * x;
244        vy += v * y;
245    }
246    let det = sxx * syy - sxy * sxy;
247    if det.abs() <= 1e-12 * (sxx * syy).max(1e-300) {
248        return None;
249    }
250    let a = (ux * syy - uy * sxy) / det;
251    let b = (uy * sxx - ux * sxy) / det;
252    let d = (vx * syy - vy * sxy) / det;
253    let e = (vy * sxx - vx * sxy) / det;
254    Some([a, b, nx - a * mx - b * my, d, e, ny - d * mx - e * my])
255}
256
257/// How far an affine map is from a similarity (rotation + uniform scale, either
258/// parity): the larger of the column-length mismatch and the columns' cosine.
259/// Zero for a perfect similarity.
260#[must_use]
261pub fn shape_error(m: &[f64; 6]) -> f64 {
262    let c1 = m[0].hypot(m[3]);
263    let c2 = m[1].hypot(m[4]);
264    if c1 <= 0.0 || c2 <= 0.0 {
265        return f64::INFINITY;
266    }
267    let ortho = (m[0] * m[1] + m[3] * m[4]).abs() / (c1 * c2);
268    ortho.max((c1 / c2 - 1.0).abs())
269}
270
271/// Invert an affine map; `None` if singular.
272#[must_use]
273pub fn invert_affine(m: &[f64; 6]) -> Option<[f64; 6]> {
274    let det = m[0] * m[4] - m[1] * m[3];
275    if det.abs() < 1e-300 {
276        return None;
277    }
278    let (a, b, d, e) = (m[4] / det, -m[1] / det, -m[3] / det, m[0] / det);
279    Some([a, b, -(a * m[2] + b * m[5]), d, e, -(d * m[2] + e * m[5])])
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn quad() -> [(f64, f64); 4] {
287        [(0.0, 0.0), (10.0, 1.0), (3.0, 7.0), (8.0, 9.5)]
288    }
289
290    fn transform(p: &[(f64, f64); 4], s: f64, th: f64, flip: bool) -> [(f64, f64); 4] {
291        let (st, ct) = th.sin_cos();
292        p.map(|(x, y)| {
293            let x = if flip { -x } else { x };
294            (s * (ct * x - st * y) + 100.0, s * (st * x + ct * y) - 40.0)
295        })
296    }
297
298    #[test]
299    fn descriptor_and_key_are_similarity_invariant() {
300        let q = quad();
301        let k = key(&descriptor(&q));
302        for (s, th, flip) in [(2.0, 0.3, false), (0.01, 2.0, true), (37.0, -1.0, true)] {
303            let t = transform(&q, s, th, flip);
304            let d = descriptor(&t);
305            let mut keys = Vec::new();
306            probe_keys(&d, &mut keys);
307            assert!(keys.contains(&k), "{s} {th} {flip}");
308        }
309    }
310
311    #[test]
312    fn the_canonical_order_gives_the_correspondence() {
313        let q = quad();
314        let t = transform(&q, 3.0, 1.1, true);
315        let (o1, _, _) = canonical(&q).unwrap();
316        let (o2, _, _) = canonical(&t).unwrap();
317        // The same original vertex sits in each canonical slot.
318        assert_eq!(o1, o2);
319        let p: Vec<_> = o1.iter().map(|&i| q[i]).collect();
320        let r: Vec<_> = o2.iter().map(|&i| t[i]).collect();
321        let m = fit_affine(&p, &r).unwrap();
322        assert!(shape_error(&m) < 1e-9);
323    }
324
325    #[test]
326    fn near_ties_offer_both_orders() {
327        // A square: every total ties.
328        let sq = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
329        let (o, t, _) = canonical(&sq).unwrap();
330        assert!(orderings(o, t).len() >= 4);
331        let (o, t, _) = canonical(&quad()).unwrap();
332        assert!(!orderings(o, t).is_empty());
333    }
334
335    #[test]
336    fn probing_covers_a_value_just_across_an_edge() {
337        // Bin centres, far from any edge, except the one under test.
338        let mut d = [25.5 / BINS, 51.5 / BINS, 0.0, 76.5 / BINS, 115.5 / BINS];
339        d[2] = 64.0 / BINS + 0.001; // just above an edge
340        let mut keys = Vec::new();
341        probe_keys(&d, &mut keys);
342        let mut below = d;
343        below[2] = 64.0 / BINS - 0.0005;
344        assert!(keys.contains(&key(&below)));
345        assert_eq!(keys.len(), 2);
346    }
347
348    #[test]
349    fn tangent_round_trip() {
350        let t = Tangent::at_radec(1.0, 0.5).unwrap();
351        let (ra, dec) = (1.01, 0.49);
352        let (x, y) = t.project(&unit(ra, dec)).unwrap();
353        let (r2, d2) = t.deproject(x, y);
354        assert!((r2 - ra).abs() < 1e-12 && (d2 - dec).abs() < 1e-12);
355        // East is +xi, north is +eta.
356        let (x, _) = t.project(&unit(1.001, 0.5)).unwrap();
357        assert!(x > 0.0);
358        let (_, y) = t.project(&unit(1.0, 0.501)).unwrap();
359        assert!(y > 0.0);
360    }
361
362    #[test]
363    fn affine_inverse_round_trips() {
364        let m = [2.0, 0.5, 3.0, -0.4, 1.5, -7.0];
365        let i = invert_affine(&m).unwrap();
366        let (x, y) = (3.3, -1.2);
367        let (u, v) = (m[0] * x + m[1] * y + m[2], m[3] * x + m[4] * y + m[5]);
368        let (x2, y2) = (i[0] * u + i[1] * v + i[2], i[3] * u + i[4] * v + i[5]);
369        assert!((x - x2).abs() < 1e-12 && (y - y2).abs() < 1e-12);
370    }
371}