Skip to main content

arcsec_core/quads/
seeded.rs

1//! Catalogue-seeded quad search: a fallback matcher that does not rank image stars.
2//!
3//! The quad matcher pairs image and catalogue quads built from each side's own
4//! brightest stars, so it needs the two brightness rankings to agree: when the
5//! image's brightest stars are not the catalogue's (saturated discs, nebulosity, a
6//! cluster core the catalogue resolves and the image does not, a passband far from
7//! Gaia's), the two quad sets share too few neighbourhoods to match. This search
8//! uses brightness on the catalogue side only, where it can be trusted.
9//!
10//! Quads are built from the catalogue's brightest stars, each with three of its four
11//! nearest bright neighbours. On the image side every detected star takes part,
12//! whatever its brightness: a table of all image star pairs sorted by length, and a
13//! position hash. For each catalogue quad, the image pairs as long as the quad's
14//! widest pair (at the expected pixel scale) are looked up by binary search; each
15//! pair, in both orders and both parities, fixes a similarity transform, under
16//! which the quad's other two stars must land on detected stars. A transform that
17//! passes is scored by how many of the catalogue's brightest stars it puts on a
18//! detection, and one that scores enough is handed to the caller's verification.
19//!
20//! The idea and its constants follow seiza's rank-robust fallback
21//! (<https://github.com/theatrus/seiza>, `docs/design/rank-robust-matching.md`,
22//! Apache-2.0); the code is arcsec's own.
23
24use crate::types::PlateConstants;
25
26/// Image star pairs shorter than this (pixels) are not indexed: too short to fix a
27/// rotation.
28const MIN_PAIR_PX: f64 = 8.0;
29
30/// A transform's census must find at least this many times the hits expected by
31/// chance (catalogue stars in the frame times the chance of a detection within the
32/// census radius of a random point). On a 2.2° TESS crop, 470 detections on
33/// 384 × 384 pixels, a random point has a detection within 3.75 px 14% of the time,
34/// and wrong transforms passed a fixed floor of 10 by the thousand.
35pub const CENSUS_SIGNIFICANCE: f64 = 2.0;
36
37/// A uniform grid over image positions, for "is there a star within `tol` of this
38/// point" lookups. Compressed-row layout: one allocation for the whole grid.
39struct PosGrid {
40    min_x: f64,
41    min_y: f64,
42    inv_cell: f64,
43    nx: usize,
44    ny: usize,
45    /// `start[c]..start[c + 1]` indexes `items` for cell `c`.
46    start: Vec<u32>,
47    items: Vec<u32>,
48}
49
50impl PosGrid {
51    fn new(pos: &[(f64, f64)], cell: f64) -> Self {
52        let (mut min_x, mut min_y) = (f64::INFINITY, f64::INFINITY);
53        let (mut max_x, mut max_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
54        for &(x, y) in pos {
55            min_x = min_x.min(x);
56            max_x = max_x.max(x);
57            min_y = min_y.min(y);
58            max_y = max_y.max(y);
59        }
60        if pos.is_empty() {
61            (min_x, min_y, max_x, max_y) = (0.0, 0.0, 0.0, 0.0);
62        }
63        let cell = cell.max(0.5);
64        let inv_cell = 1.0 / cell;
65        let nx = ((max_x - min_x) * inv_cell) as usize + 1;
66        let ny = ((max_y - min_y) * inv_cell) as usize + 1;
67        let cell_of = |x: f64, y: f64| {
68            let gx = (((x - min_x) * inv_cell) as usize).min(nx - 1);
69            let gy = (((y - min_y) * inv_cell) as usize).min(ny - 1);
70            gy * nx + gx
71        };
72        let mut start = vec![0u32; nx * ny + 1];
73        for &(x, y) in pos {
74            start[cell_of(x, y) + 1] += 1;
75        }
76        for c in 0..nx * ny {
77            start[c + 1] += start[c];
78        }
79        let mut fill = start.clone();
80        let mut items = vec![0u32; pos.len()];
81        for (i, &(x, y)) in pos.iter().enumerate() {
82            let c = cell_of(x, y);
83            items[fill[c] as usize] = i as u32;
84            fill[c] += 1;
85        }
86        Self {
87            min_x,
88            min_y,
89            inv_cell,
90            nx,
91            ny,
92            start,
93            items,
94        }
95    }
96
97    /// The nearest star within `tol` of `(x, y)`.
98    fn nearest(&self, pos: &[(f64, f64)], x: f64, y: f64, tol: f64) -> Option<usize> {
99        let gx0 = ((x - tol - self.min_x) * self.inv_cell).floor();
100        let gy0 = ((y - tol - self.min_y) * self.inv_cell).floor();
101        let gx1 = ((x + tol - self.min_x) * self.inv_cell).floor();
102        let gy1 = ((y + tol - self.min_y) * self.inv_cell).floor();
103        if gx1 < 0.0 || gy1 < 0.0 || gx0 >= self.nx as f64 || gy0 >= self.ny as f64 {
104            return None;
105        }
106        let (gx0, gy0) = (gx0.max(0.0) as usize, gy0.max(0.0) as usize);
107        let gx1 = (gx1 as usize).min(self.nx - 1);
108        let gy1 = (gy1 as usize).min(self.ny - 1);
109        let mut best = None;
110        let mut best_d2 = tol * tol;
111        for gy in gy0..=gy1 {
112            let row = gy * self.nx;
113            for c in row + gx0..=row + gx1 {
114                for &i in &self.items[self.start[c] as usize..self.start[c + 1] as usize] {
115                    let (px, py) = pos[i as usize];
116                    let d2 = (px - x) * (px - x) + (py - y) * (py - y);
117                    if d2 <= best_d2 {
118                        best_d2 = d2;
119                        best = Some(i as usize);
120                    }
121                }
122            }
123        }
124        best
125    }
126}
127
128/// Cells of at least `probe_tol`, few enough (about a million) that the bit map
129/// stays in cache: probes land all over the frame, and on a 4300-pixel frame a
130/// map of 2.5-pixel cells (3 million bits) made every probe a cache miss.
131const NEAR_MAX_CELLS_PER_SIDE: f64 = 1024.0;
132
133/// One bit per cell, set for every cell within one cell of a star; the cell is at
134/// least `probe_tol` wide, so a point whose cell is clear has no star within
135/// `probe_tol`.
136struct NearMap {
137    min_x: f32,
138    min_y: f32,
139    inv_cell: f32,
140    nx: usize,
141    ny: usize,
142    bits: Vec<u64>,
143}
144
145impl NearMap {
146    fn new(pos: &[(f64, f64)], grid: &PosGrid, probe_tol: f64) -> Self {
147        let (w, h) = (
148            grid.nx as f64 / grid.inv_cell,
149            grid.ny as f64 / grid.inv_cell,
150        );
151        // A little over `probe_tol`, so that rounding the f32 probes cannot carry
152        // a point within `probe_tol` of a star two cells from it.
153        let cell = (1.01 * probe_tol)
154            .max(w.max(h) / NEAR_MAX_CELLS_PER_SIDE)
155            .max(0.5);
156        let inv_cell = 1.0 / cell;
157        let nx = (w * inv_cell) as usize + 1;
158        let ny = (h * inv_cell) as usize + 1;
159        let mut bits = vec![0u64; (nx * ny).div_ceil(64)];
160        for &(x, y) in pos {
161            let gx = ((x - grid.min_x) * inv_cell) as usize;
162            let gy = ((y - grid.min_y) * inv_cell) as usize;
163            for cy in gy.saturating_sub(1)..=(gy + 1).min(ny - 1) {
164                for cx in gx.saturating_sub(1)..=(gx + 1).min(nx - 1) {
165                    let c = cy * nx + cx;
166                    bits[c / 64] |= 1 << (c % 64);
167                }
168            }
169        }
170        Self {
171            min_x: grid.min_x as f32,
172            min_y: grid.min_y as f32,
173            inv_cell: inv_cell as f32,
174            nx,
175            ny,
176            bits,
177        }
178    }
179
180    /// False if no star can be within `probe_tol` of `(x, y)`.
181    #[inline]
182    fn maybe(&self, x: f32, y: f32) -> bool {
183        let fx = (x - self.min_x) * self.inv_cell;
184        let fy = (y - self.min_y) * self.inv_cell;
185        if !(fx >= 0.0 && fy >= 0.0) {
186            // Within one cell outside the map can still be within tol of a star.
187            return fx > -1.0 && fy > -1.0;
188        }
189        let (cx, cy) = (fx as usize, fy as usize);
190        if cx >= self.nx || cy >= self.ny {
191            return cx <= self.nx && cy <= self.ny;
192        }
193        let c = cy * self.nx + cx;
194        self.bits[c / 64] & (1 << (c % 64)) != 0
195    }
196}
197
198/// The image side of the search: every star's position, a position hash, and the
199/// star pairs sorted by length. Built once per image.
200pub struct ImageIndex {
201    pos: Vec<(f64, f64)>,
202    grid: PosGrid,
203    /// A point whose cell here is clear has no star within `probe_tol`.
204    near: NearMap,
205    /// The pairs' lengths, sorted, for the binary search ...
206    pair_len: Vec<f32>,
207    /// ... each pair's first star and the vector to its second, for the probes ...
208    pair_vec: Vec<[f32; 4]>,
209    /// ... and the two stars.
210    pair_idx: Vec<(u32, u32)>,
211    probe_tol: f64,
212}
213
214impl ImageIndex {
215    /// Index `pos` (pixels) for probes within `probe_tol` pixels, and the pairs
216    /// among them no longer than `max_pair_px`.
217    #[must_use]
218    pub fn new(pos: Vec<(f64, f64)>, max_pair_px: f64, probe_tol: f64) -> Self {
219        let grid = PosGrid::new(&pos, probe_tol);
220        let near = NearMap::new(&pos, &grid, probe_tol);
221        // Sorted by x so only pairs within max_pair_px in x are examined.
222        let mut order: Vec<u32> = (0..pos.len() as u32).collect();
223        order.sort_unstable_by(|&a, &b| pos[a as usize].0.total_cmp(&pos[b as usize].0));
224        let max2 = max_pair_px * max_pair_px;
225        let min2 = MIN_PAIR_PX * MIN_PAIR_PX;
226        let mut pairs = Vec::new();
227        for (k, &a) in order.iter().enumerate() {
228            let (ax, ay) = pos[a as usize];
229            for &b in &order[k + 1..] {
230                let (bx, by) = pos[b as usize];
231                if bx - ax > max_pair_px {
232                    break;
233                }
234                let d2 = (bx - ax) * (bx - ax) + (by - ay) * (by - ay);
235                if d2 >= min2 && d2 <= max2 {
236                    pairs.push((d2.sqrt() as f32, a, b));
237                }
238            }
239        }
240        pairs.sort_unstable_by(|p, q| p.0.total_cmp(&q.0));
241        let pair_len = pairs.iter().map(|p| p.0).collect();
242        let pair_vec = pairs
243            .iter()
244            .map(|&(_, a, b)| {
245                let ((ax, ay), (bx, by)) = (pos[a as usize], pos[b as usize]);
246                [ax as f32, ay as f32, (bx - ax) as f32, (by - ay) as f32]
247            })
248            .collect();
249        let pair_idx = pairs.iter().map(|&(_, a, b)| (a, b)).collect();
250        Self {
251            pos,
252            grid,
253            near,
254            pair_len,
255            pair_vec,
256            pair_idx,
257            probe_tol,
258        }
259    }
260
261    /// Number of indexed stars.
262    #[must_use]
263    pub fn len(&self) -> usize {
264        self.pos.len()
265    }
266
267    /// Whether no star is indexed.
268    #[must_use]
269    pub fn is_empty(&self) -> bool {
270        self.pos.is_empty()
271    }
272
273    /// Number of indexed pairs.
274    #[must_use]
275    pub fn n_pairs(&self) -> usize {
276        self.pair_len.len()
277    }
278
279    fn hit(&self, x: f64, y: f64, tol: f64) -> Option<usize> {
280        self.grid.nearest(&self.pos, x, y, tol)
281    }
282}
283
284/// What the search is looking for.
285#[derive(Debug, Clone)]
286pub struct SeedParams {
287    /// Expected pixel scale, catalogue units (arcsec) per pixel.
288    pub scale: f64,
289    /// Fractional tolerance on the scale.
290    pub scale_tol: f64,
291    /// Image width and height, pixels.
292    pub width: f64,
293    /// Image height, pixels.
294    pub height: f64,
295    /// Catalogue stars that seed quads (the brightest).
296    pub seed_stars: usize,
297    /// Most catalogue quads examined.
298    pub max_quads: usize,
299    /// Catalogue stars (the brightest) a transform is scored on.
300    pub census_stars: usize,
301    /// Fewest of them a transform must put on a detection, when at least
302    /// `1.5 × min_census` of them land in the frame; fewer in frame lower it to
303    /// two thirds of those, but never below 4. In a dense frame it is raised to
304    /// [`CENSUS_SIGNIFICANCE`] times the hits expected by chance.
305    pub min_census: usize,
306    /// Work charged to the budget for each candidate handed to the verification.
307    pub verify_cost: u64,
308}
309
310/// A candidate found by [`search`]: a plate (pixel → catalogue coordinates) and the
311/// star pairs its census found.
312#[derive(Debug, Clone)]
313pub struct Candidate {
314    /// Plate from the similarity transform, refitted to the census pairs.
315    pub plate: PlateConstants,
316    /// Image positions of the census pairs.
317    pub img: Vec<(f64, f64)>,
318    /// Their catalogue positions.
319    pub cat: Vec<(f64, f64)>,
320}
321
322/// A similarity transform from catalogue coordinates to pixels:
323/// `z_pix = s · w + t`, with `w = z_cat` or its mirror image `conj(z_cat)`.
324#[derive(Debug, Clone, Copy)]
325struct Similarity {
326    sr: f64,
327    si: f64,
328    tr: f64,
329    ti: f64,
330    mirrored: bool,
331}
332
333impl Similarity {
334    /// The transform taking catalogue `p1`, `p2` to pixels `a`, `b`. (The search
335    /// computes the same inline, with the per-quad terms hoisted.)
336    #[cfg(test)]
337    fn from_pair(
338        p1: (f64, f64),
339        p2: (f64, f64),
340        a: (f64, f64),
341        b: (f64, f64),
342        mirrored: bool,
343    ) -> Option<Self> {
344        let flip = |p: (f64, f64)| if mirrored { (p.0, -p.1) } else { p };
345        let (w1, w2) = (flip(p1), flip(p2));
346        let (dwr, dwi) = (w2.0 - w1.0, w2.1 - w1.1);
347        let den = dwr * dwr + dwi * dwi;
348        if den <= 0.0 {
349            return None;
350        }
351        let (dzr, dzi) = (b.0 - a.0, b.1 - a.1);
352        // s = dz / dw
353        let sr = (dzr * dwr + dzi * dwi) / den;
354        let si = (dzi * dwr - dzr * dwi) / den;
355        let tr = a.0 - (sr * w1.0 - si * w1.1);
356        let ti = a.1 - (sr * w1.1 + si * w1.0);
357        Some(Self {
358            sr,
359            si,
360            tr,
361            ti,
362            mirrored,
363        })
364    }
365
366    #[inline]
367    fn apply(&self, p: (f64, f64)) -> (f64, f64) {
368        let wy = if self.mirrored { -p.1 } else { p.1 };
369        (
370            self.sr * p.0 - self.si * wy + self.tr,
371            self.sr * wy + self.si * p.0 + self.ti,
372        )
373    }
374
375    /// The inverse, as plate constants (pixel → catalogue).
376    fn plate(&self) -> Option<PlateConstants> {
377        // pixel = M · (x, wy) + t with M = [[sr, -si], [si, sr]]; cat y = ±wy.
378        let det = self.sr * self.sr + self.si * self.si;
379        if det <= 0.0 {
380            return None;
381        }
382        let (ir, ii) = (self.sr / det, -self.si / det); // 1/s
383        // w = (z - t) / s
384        let (a, b, c) = (ir, -ii, -(ir * self.tr - ii * self.ti));
385        let (d, e, f) = (ii, ir, -(ir * self.ti + ii * self.tr));
386        Some(if self.mirrored {
387            PlateConstants {
388                a,
389                b,
390                c,
391                d: -d,
392                e: -e,
393                f: -f,
394            }
395        } else {
396            PlateConstants { a, b, c, d, e, f }
397        })
398    }
399}
400
401/// Catalogue quads: each of the brightest `seed_stars` with three of its four nearest
402/// neighbours among them, as indexes into `cat`, the widest pair first.
403fn seed_quads(cat: &[(f64, f64)], seed_stars: usize, max_quads: usize) -> Vec<[usize; 4]> {
404    let n = cat.len().min(seed_stars);
405    let mut quads = Vec::new();
406    for a in 0..n {
407        let mut near: Vec<(f64, usize)> = (0..n)
408            .filter(|&b| b != a)
409            .map(|b| {
410                let d = (cat[b].0 - cat[a].0).hypot(cat[b].1 - cat[a].1);
411                (d, b)
412            })
413            .collect();
414        near.sort_unstable_by(|p, q| p.0.total_cmp(&q.0));
415        near.truncate(4);
416        if near.len() < 3 {
417            continue;
418        }
419        for skip in 0..near.len() {
420            let mut q = [a; 4];
421            let mut k = 1;
422            for (m, &(_, b)) in near.iter().enumerate() {
423                if m != skip && k < 4 {
424                    q[k] = b;
425                    k += 1;
426                }
427            }
428            if k < 4 {
429                continue;
430            }
431            // Widest pair first: it fixes the transform best.
432            let mut widest = (0, 1, -1.0);
433            for i in 0..4 {
434                for j in i + 1..4 {
435                    let d = (cat[q[i]].0 - cat[q[j]].0).hypot(cat[q[i]].1 - cat[q[j]].1);
436                    if d > widest.2 {
437                        widest = (i, j, d);
438                    }
439                }
440            }
441            let rest: Vec<usize> = (0..4).filter(|&k| k != widest.0 && k != widest.1).collect();
442            quads.push([q[widest.0], q[widest.1], q[rest[0]], q[rest[1]]]);
443            if quads.len() >= max_quads {
444                break;
445            }
446        }
447        if quads.len() >= max_quads {
448            break;
449        }
450    }
451    quads
452}
453
454/// The longest pair, in pixels, any seed quad can need: for sizing the image's
455/// pair table.
456#[must_use]
457pub fn max_backbone_px(cat: &[(f64, f64)], p: &SeedParams) -> f64 {
458    let quads = seed_quads(cat, p.seed_stars, p.max_quads);
459    let longest = quads
460        .iter()
461        .map(|q| (cat[q[0]].0 - cat[q[1]].0).hypot(cat[q[0]].1 - cat[q[1]].1))
462        .fold(0.0, f64::max);
463    longest / (p.scale * (1.0 - p.scale_tol)) + 1.0
464}
465
466/// Search for a transform that puts the catalogue's bright stars on detections.
467///
468/// `cat` holds catalogue positions in standard coordinates (arcsec), brightest first.
469/// Every transform whose census passes is passed to `accept` (the caller's
470/// verification); the first it accepts is returned. Each probe — one transform tried
471/// on a quad's third star — and each census star scored costs one unit of `budget`;
472/// the search stops when it runs out.
473pub fn search(
474    index: &ImageIndex,
475    cat: &[(f64, f64)],
476    p: &SeedParams,
477    budget: &mut u64,
478    mut accept: impl FnMut(&Candidate) -> bool,
479) -> Option<Candidate> {
480    if index.len() < 4 || cat.len() < 4 {
481        return None;
482    }
483    let tol = index.probe_tol;
484    let census: Vec<(f64, f64)> = cat.iter().take(p.census_stars).copied().collect();
485    let margin = 1.5 * tol;
486    let density = index.len() as f64 / (p.width * p.height).max(1.0);
487    let p_chance = 1.0 - (-density * core::f64::consts::PI * margin * margin).exp();
488    let in_frame = |q: (f64, f64)| {
489        q.0 >= -margin && q.1 >= -margin && q.0 < p.width + margin && q.1 < p.height + margin
490    };
491    // Transforms already scored: the same image stars reached from another quad.
492    let mut seen: std::collections::HashSet<(i64, i64, i64, bool)> = Default::default();
493
494    for quad in seed_quads(cat, p.seed_stars, p.max_quads) {
495        let (p1, p2, p3, p4) = (cat[quad[0]], cat[quad[1]], cat[quad[2]], cat[quad[3]]);
496        let backbone = (p2.0 - p1.0).hypot(p2.1 - p1.1);
497        let lo = (backbone / (p.scale * (1.0 + p.scale_tol)) - tol) as f32;
498        let hi = (backbone / (p.scale * (1.0 - p.scale_tol)) + tol) as f32;
499        let from = index.pair_len.partition_point(|&l| l < lo);
500        let to = index.pair_len.partition_point(|&l| l <= hi);
501        for mirrored in [false, true] {
502            // Per quad and parity: with w = z_cat (or its mirror image), the
503            // transform taking w1, w2 to pixels a, b has s = (b - a) / (w2 - w1),
504            // and puts w3 at a + s (w3 - w1).
505            let flip = |q: (f64, f64)| if mirrored { (q.0, -q.1) } else { q };
506            let (w1, w2, w3, w4) = (flip(p1), flip(p2), flip(p3), flip(p4));
507            let (dwr, dwi) = (w2.0 - w1.0, w2.1 - w1.1);
508            let den = dwr * dwr + dwi * dwi;
509            if den <= 0.0 {
510                continue;
511            }
512            let (ir, ii) = (dwr / den, -dwi / den); // 1 / (w2 - w1)
513            let (d3, d4) = ((w3.0 - w1.0, w3.1 - w1.1), (w4.0 - w1.0, w4.1 - w1.1));
514            // The same in f32 for the first test, which rejects almost every probe.
515            let (ir32, ii32) = (ir as f32, ii as f32);
516            let (d3x, d3y, d4x, d4y) = (d3.0 as f32, d3.1 as f32, d4.0 as f32, d4.1 as f32);
517            for (k, &[ax, ay, dx, dy]) in index.pair_vec[from..to].iter().enumerate() {
518                // From a to b, s = d / (w2 - w1) puts w3 at a + s (w3 - w1); from b
519                // to a, s is negated and w3 lands at b - s (w3 - w1).
520                let (sr, si) = (dx * ir32 - dy * ii32, dx * ii32 + dy * ir32);
521                let (e3x, e3y) = (sr * d3x - si * d3y, sr * d3y + si * d3x);
522                let (e4x, e4y) = (sr * d4x - si * d4y, sr * d4y + si * d4x);
523                let (bx, by) = (ax + dx, ay + dy);
524                for (forward, zx, zy, sign) in [(true, ax, ay, 1.0f32), (false, bx, by, -1.0)] {
525                    if *budget == 0 {
526                        return None;
527                    }
528                    *budget -= 1;
529                    if !(index.near.maybe(zx + sign * e3x, zy + sign * e3y)
530                        && index.near.maybe(zx + sign * e4x, zy + sign * e4y))
531                    {
532                        continue;
533                    }
534                    // Exactly, in f64, from the stars themselves.
535                    let (i, j) = index.pair_idx[from + k];
536                    let (a, b) = (index.pos[i as usize], index.pos[j as usize]);
537                    let (za, zb) = if forward { (a, b) } else { (b, a) };
538                    let (dzr, dzi) = (zb.0 - za.0, zb.1 - za.1);
539                    let (sr, si) = (dzr * ir - dzi * ii, dzr * ii + dzi * ir);
540                    let q3 = (za.0 + sr * d3.0 - si * d3.1, za.1 + sr * d3.1 + si * d3.0);
541                    let q4 = (za.0 + sr * d4.0 - si * d4.1, za.1 + sr * d4.1 + si * d4.0);
542                    if index.hit(q3.0, q3.1, tol).is_none() || index.hit(q4.0, q4.1, tol).is_none()
543                    {
544                        continue;
545                    }
546                    let t = Similarity {
547                        sr,
548                        si,
549                        tr: za.0 - (sr * w1.0 - si * w1.1),
550                        ti: za.1 - (sr * w1.1 + si * w1.0),
551                        mirrored,
552                    };
553                    let key = (
554                        (t.tr / 2.0).round() as i64,
555                        (t.ti / 2.0).round() as i64,
556                        (t.si.atan2(t.sr) * 200.0).round() as i64,
557                        mirrored,
558                    );
559                    if !seen.insert(key) {
560                        continue;
561                    }
562                    // Census of the brightest catalogue stars in the frame.
563                    *budget = budget.saturating_sub(census.len() as u64);
564                    let mut n_in = 0usize;
565                    let mut img = Vec::new();
566                    let mut catp = Vec::new();
567                    let mut used = std::collections::HashSet::new();
568                    for &c in &census {
569                        let q = t.apply(c);
570                        if !in_frame(q) {
571                            continue;
572                        }
573                        n_in += 1;
574                        if let Some(m) = index.hit(q.0, q.1, margin)
575                            && used.insert(m)
576                        {
577                            img.push(index.pos[m]);
578                            catp.push(c);
579                        }
580                    }
581                    // The quad's own four stars are hits by construction.
582                    let need = (p.min_census.min((n_in * 2 / 3).max(4)) as f64)
583                        .max(CENSUS_SIGNIFICANCE * p_chance * n_in.saturating_sub(4) as f64 + 4.0);
584                    if (img.len() as f64) < need {
585                        continue;
586                    }
587                    let plate = crate::math::lsq::fit_affine(&img, &catp)
588                        .ok()
589                        .or_else(|| t.plate());
590                    let Some(plate) = plate else { continue };
591                    let cand = Candidate {
592                        plate,
593                        img,
594                        cat: catp,
595                    };
596                    *budget = budget.saturating_sub(p.verify_cost);
597                    if accept(&cand) {
598                        return Some(cand);
599                    }
600                }
601            }
602        }
603    }
604    None
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    fn lcg(seed: &mut u64) -> f64 {
612        *seed = seed
613            .wrapping_mul(6_364_136_223_846_793_005)
614            .wrapping_add(1_442_695_040_888_963_407);
615        (*seed >> 11) as f64 / (1u64 << 53) as f64
616    }
617
618    #[test]
619    fn similarity_maps_the_pair_and_inverts_to_a_plate() {
620        for mirrored in [false, true] {
621            let t = Similarity::from_pair(
622                (10.0, 20.0),
623                (110.0, -5.0),
624                (300.0, 400.0),
625                (350.0, 480.0),
626                mirrored,
627            )
628            .unwrap();
629            let a = t.apply((10.0, 20.0));
630            let b = t.apply((110.0, -5.0));
631            assert!((a.0 - 300.0).abs() < 1e-9 && (a.1 - 400.0).abs() < 1e-9);
632            assert!((b.0 - 350.0).abs() < 1e-9 && (b.1 - 480.0).abs() < 1e-9);
633            let pl = t.plate().unwrap();
634            let q = (37.0, -12.0);
635            let z = t.apply(q);
636            let back = (
637                pl.a * z.0 + pl.b * z.1 + pl.c,
638                pl.d * z.0 + pl.e * z.1 + pl.f,
639            );
640            assert!((back.0 - q.0).abs() < 1e-9 && (back.1 - q.1).abs() < 1e-9);
641            // A mirrored transform has a negative determinant.
642            let det = pl.a * pl.e - pl.b * pl.d;
643            assert_eq!(det < 0.0, mirrored);
644        }
645    }
646
647    /// A field whose image list is shuffled in brightness and mostly unrelated to
648    /// the catalogue still yields the true transform.
649    #[test]
650    fn finds_the_transform_whatever_the_image_ranking() {
651        let mut seed = 7u64;
652        let (w, h) = (1000.0, 800.0);
653        let scale = 2.0; // arcsec per pixel
654        let rot = 0.6f64;
655        let (c, s) = (rot.cos(), rot.sin());
656        // Catalogue: 200 stars over the field, brightest first.
657        let cat: Vec<(f64, f64)> = (0..200)
658            .map(|_| {
659                (
660                    (lcg(&mut seed) - 0.5) * w * scale,
661                    (lcg(&mut seed) - 0.5) * h * scale,
662                )
663            })
664            .collect();
665        let to_pix = |p: (f64, f64)| {
666            let (x, y) = (p.0 / scale, p.1 / scale);
667            (c * x - s * y + w / 2.0, s * x + c * y + h / 2.0)
668        };
669        // Image: a third of the catalogue (every third star), with 1000 unrelated
670        // detections, in no particular order.
671        let mut pos: Vec<(f64, f64)> = cat.iter().step_by(3).map(|&p| to_pix(p)).collect();
672        for _ in 0..1000 {
673            pos.push((lcg(&mut seed) * w, lcg(&mut seed) * h));
674        }
675        for i in (1..pos.len()).rev() {
676            let j = (lcg(&mut seed) * (i + 1) as f64) as usize;
677            pos.swap(i, j);
678        }
679        let p = SeedParams {
680            scale,
681            scale_tol: 0.05,
682            width: w,
683            height: h,
684            seed_stars: 100,
685            max_quads: 400,
686            census_stars: 100,
687            min_census: 10,
688            verify_cost: 0,
689        };
690        let index = ImageIndex::new(pos, max_backbone_px(&cat, &p), 2.5);
691        let mut budget = 50_000_000u64;
692        // Stand-in for the solver's verification: a third of the 100 brightest
693        // catalogue stars are in the image, and the true transform finds them.
694        let found = search(&index, &cat, &p, &mut budget, |c| c.img.len() >= 25).expect("found");
695        let pl = &found.plate;
696        // Check the plate against the truth at the frame corners.
697        for &(x, y) in &[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] {
698            let got = (pl.a * x + pl.b * y + pl.c, pl.d * x + pl.e * y + pl.f);
699            let (dx, dy) = (x - w / 2.0, y - h / 2.0);
700            let want = ((c * dx + s * dy) * scale, (-s * dx + c * dy) * scale);
701            assert!(
702                (got.0 - want.0).hypot(got.1 - want.1) < 2.0 * scale,
703                "corner ({x},{y}) {got:?} vs {want:?}"
704            );
705        }
706    }
707
708    #[test]
709    fn the_budget_bounds_a_search_that_cannot_succeed() {
710        let mut seed = 11u64;
711        let cat: Vec<(f64, f64)> = (0..200)
712            .map(|_| (lcg(&mut seed) * 2000.0, lcg(&mut seed) * 2000.0))
713            .collect();
714        let pos: Vec<(f64, f64)> = (0..800)
715            .map(|_| (lcg(&mut seed) * 1000.0, lcg(&mut seed) * 1000.0))
716            .collect();
717        let p = SeedParams {
718            scale: 2.0,
719            scale_tol: 0.1,
720            width: 1000.0,
721            height: 1000.0,
722            seed_stars: 100,
723            max_quads: 400,
724            census_stars: 100,
725            min_census: 10,
726            verify_cost: 0,
727        };
728        let index = ImageIndex::new(pos, max_backbone_px(&cat, &p), 2.5);
729        let mut budget = 1_000u64;
730        assert!(search(&index, &cat, &p, &mut budget, |_| false).is_none());
731        assert_eq!(budget, 0, "a refused search runs until the budget is spent");
732    }
733}