use core::f64::consts::PI;
use crate::catalog::anet::AnetIndex;
use crate::detection::get_background;
use crate::detection::stars::find_stars_with_background;
use crate::error::{ArcsecError, Result};
use crate::math::coords::equatorial_standard;
use crate::math::lsq::solve_plate_constants;
use crate::types::StarList;
use crate::wcs::output::derive_wcs;
#[derive(Debug, Clone)]
pub struct BlindSolveParams {
pub quad_tolerance: f64,
pub hfd_min: f64,
pub max_stars: usize,
pub binning: usize,
pub fov_deg: f64,
}
#[derive(Debug, Clone)]
struct ImageEntry {
code: [f64; 4],
stars: [(f64, f64); 4],
n_stars: usize,
d_ab_px: f64,
}
fn code_for_point(
adx: f64,
ady: f64,
abx: f64,
aby: f64,
scale: f64,
parity_flip: bool,
) -> (f64, f64) {
let cos_theta = (aby + abx) / scale;
let sin_theta = (aby - abx) / scale;
if parity_flip {
(
adx * cos_theta + ady * sin_theta,
-adx * sin_theta + ady * cos_theta,
)
} else {
(
-adx * sin_theta + ady * cos_theta,
adx * cos_theta + ady * sin_theta,
)
}
}
fn make_triangle(
a: (f64, f64),
b: (f64, f64),
c: (f64, f64),
parity_flip: bool,
) -> Option<ImageEntry> {
let (xa, ya) = a;
let (xb, yb) = b;
let abx = xb - xa;
let aby = yb - ya;
let scale = abx * abx + aby * aby;
if scale < 1.0 {
return None;
}
let d_ab_px = scale.sqrt();
let (mut cx, mut cy) = code_for_point(c.0 - xa, c.1 - ya, abx, aby, scale, parity_flip);
let (mut pa, mut pb) = (a, b);
if cx > 0.5 {
core::mem::swap(&mut pa, &mut pb);
cx = 1.0 - cx;
cy = 1.0 - cy;
}
Some(ImageEntry {
code: [cx, cy, 0.0, 0.0],
stars: [pa, pb, c, (0.0, 0.0)],
n_stars: 3,
d_ab_px,
})
}
fn make_quad4(
a: (f64, f64),
b: (f64, f64),
c: (f64, f64),
d: (f64, f64),
parity_flip: bool,
) -> Option<ImageEntry> {
let (xa, ya) = a;
let (xb, yb) = b;
let abx = xb - xa;
let aby = yb - ya;
let scale = abx * abx + aby * aby;
if scale < 1.0 {
return None;
}
let d_ab_px = scale.sqrt();
let (mut cx, mut cy) = code_for_point(c.0 - xa, c.1 - ya, abx, aby, scale, parity_flip);
let (mut dx, mut dy) = code_for_point(d.0 - xa, d.1 - ya, abx, aby, scale, parity_flip);
let (mut pa, mut pb, mut pc, mut pd) = (a, b, c, d);
if cx + dx > 1.0 {
core::mem::swap(&mut pa, &mut pb);
cx = 1.0 - cx;
cy = 1.0 - cy;
dx = 1.0 - dx;
dy = 1.0 - dy;
}
#[allow(clippy::float_cmp)] let swap_cd = dx < cx || (dx == cx && dy < cy);
if swap_cd {
core::mem::swap(&mut pc, &mut pd);
core::mem::swap(&mut cx, &mut dx);
core::mem::swap(&mut cy, &mut dy);
}
Some(ImageEntry {
code: [cx, cy, dx, dy],
stars: [pa, pb, pc, pd],
n_stars: 4,
d_ab_px,
})
}
#[inline]
fn dist_sq(p: (f64, f64), q: (f64, f64)) -> f64 {
let dx = p.0 - q.0;
let dy = p.1 - q.1;
dx * dx + dy * dy
}
fn build_triangles(stars: &StarList, n: usize, parity_flip: bool) -> Vec<ImageEntry> {
let n = stars.len().min(n);
let mut out = Vec::new();
if n < 3 {
return out;
}
for i in 0..n {
for j in (i + 1)..n {
for k in (j + 1)..n {
let a = (stars.0[i].x, stars.0[i].y);
let b = (stars.0[j].x, stars.0[j].y);
let c = (stars.0[k].x, stars.0[k].y);
if let Some(e) = make_triangle(a, b, c, parity_flip) {
out.push(e);
}
if let Some(e) = make_triangle(a, c, b, parity_flip) {
out.push(e);
}
if let Some(e) = make_triangle(b, c, a, parity_flip) {
out.push(e);
}
}
}
}
out
}
fn build_quads4(stars: &StarList, n: usize, parity_flip: bool) -> Vec<ImageEntry> {
const PAIR_INDICES: [(usize, usize); 6] = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)];
let n = stars.len().min(n);
let mut out = Vec::new();
if n < 4 {
return out;
}
let pts: Vec<(f64, f64)> = stars.0[..n].iter().map(|s| (s.x, s.y)).collect();
for i in 0..n {
for j in (i + 1)..n {
for k in (j + 1)..n {
for l in (k + 1)..n {
let group = [pts[i], pts[j], pts[k], pts[l]];
let (ai, bi) = PAIR_INDICES
.iter()
.copied()
.max_by(|&(a1, b1), &(a2, b2)| {
dist_sq(group[a1], group[b1]).total_cmp(&dist_sq(group[a2], group[b2]))
})
.unwrap();
let mut rest = (0..4).filter(|&x| x != ai && x != bi);
let (Some(ci), Some(di)) = (rest.next(), rest.next()) else {
unreachable!("four stars minus a pair leaves two");
};
if let Some(e) =
make_quad4(group[ai], group[bi], group[ci], group[di], parity_flip)
{
out.push(e);
}
}
}
}
}
out
}
fn build_image_entries(
stars: &StarList,
n: usize,
parity_flip: bool,
dim_quads: usize,
) -> Vec<ImageEntry> {
match dim_quads {
3 => build_triangles(stars, n, parity_flip),
4 => build_quads4(stars, n, parity_flip),
_ => vec![],
}
}
struct HypEntry {
est_ra: f64,
est_dec: f64,
ref_ra: f64,
ref_dec: f64,
plate: crate::types::PlateConstants,
quad_cat_ra: [f64; 4],
quad_cat_dec: [f64; 4],
n_quad: usize,
}
fn hyp_from_entry(
img_entry: &ImageEntry,
idx_entry: &crate::catalog::anet::AnetIndexEntry,
img_w: usize,
img_h: usize,
) -> Option<HypEntry> {
let ref_ra = idx_entry.center_ra;
let ref_dec = idx_entry.center_dec;
let n = img_entry.n_stars.min(idx_entry.n_stars);
let mut img_px = [(0.0f64, 0.0f64); 4];
let mut cat_xy = [(0.0f64, 0.0f64); 4];
for i in 0..n {
img_px[i] = img_entry.stars[i];
cat_xy[i] = equatorial_standard(
ref_ra,
ref_dec,
idx_entry.star_ra[i],
idx_entry.star_dec[i],
1.0,
);
}
let plate = solve_plate_constants(&img_px[..n], &cat_xy[..n]).ok()?;
let wcs = derive_wcs(ref_ra, ref_dec, &plate, img_w, img_h);
let mut quad_cat_ra = [0.0f64; 4];
let mut quad_cat_dec = [0.0f64; 4];
quad_cat_ra[..n].copy_from_slice(&idx_entry.star_ra[..n]);
quad_cat_dec[..n].copy_from_slice(&idx_entry.star_dec[..n]);
Some(HypEntry {
est_ra: wcs.ra0,
est_dec: wcs.dec0,
ref_ra,
ref_dec,
plate,
quad_cat_ra,
quad_cat_dec,
n_quad: n,
})
}
fn sky_to_px(
h: &HypEntry,
star_ra: f64,
star_dec: f64,
det: f64,
img_w: usize,
img_h: usize,
) -> Option<(f64, f64)> {
let (xs, ys) = equatorial_standard(h.ref_ra, h.ref_dec, star_ra, star_dec, 1.0);
let xc = xs - h.plate.c;
let yc = ys - h.plate.f;
let px = (h.plate.e * xc - h.plate.b * yc) / det;
let py = (h.plate.a * yc - h.plate.d * xc) / det;
if px >= 0.0 && px < img_w as f64 && py >= 0.0 && py < img_h as f64 {
Some((px, py))
} else {
None
}
}
fn verify_score(
h: &HypEntry,
stars_by_dec: &[crate::catalog::anet::AnetStar],
det_stars: &[(f64, f64)],
fov_rad: f64,
match_px_sq: f64,
img_w: usize,
img_h: usize,
) -> usize {
let det = h.plate.a * h.plate.e - h.plate.b * h.plate.d;
if det.abs() < 1e-15 {
return 0;
}
let half_fov = fov_rad * 1.1;
let dec_lo = h.est_dec - half_fov;
let dec_hi = h.est_dec + half_fov;
let lo = stars_by_dec.partition_point(|s| s.dec < dec_lo);
let hi = stars_by_dec.partition_point(|s| s.dec <= dec_hi);
let stars_in_band = &stars_by_dec[lo..hi];
let min_cos_sep = half_fov.cos();
let (sin_e, cos_e) = h.est_dec.sin_cos();
let mut score = 0usize;
'star: for star in stars_in_band {
let cos_sep = sin_e * star.dec.sin() + cos_e * star.dec.cos() * (star.ra - h.est_ra).cos();
if cos_sep < min_cos_sep {
continue;
}
for i in 0..h.n_quad {
let dra = (star.ra - h.quad_cat_ra[i]).abs();
let ddec = (star.dec - h.quad_cat_dec[i]).abs();
if dra < 1e-9 && ddec < 1e-9 {
continue 'star;
}
}
let Some((px, py)) = sky_to_px(h, star.ra, star.dec, det, img_w, img_h) else {
continue;
};
let nearest_sq = det_stars.iter().fold(f64::INFINITY, |acc, &(dx, dy)| {
let d = (dx - px) * (dx - px) + (dy - py) * (dy - py);
acc.min(d)
});
if nearest_sq <= match_px_sq {
score += 1;
}
}
score
}
const N_ENTRY_STARS: usize = 30;
const VOTE_STEP: f64 = 0.1 * PI / 180.0; const MATCH_PX: f64 = 5.0;
const MIN_VERIFY_SCORE: usize = 18;
const EARLY_STOP_SCORE: usize = 20;
fn run_blind_pass(
img: &crate::types::ImageBuffer,
index: &AnetIndex,
stars: &StarList,
parity_flip: bool,
fov_rad: f64,
d_px: core::ops::RangeInclusive<f64>,
tol: f64,
) -> (f64, f64, usize) {
let n_entry_stars = stars.len().min(N_ENTRY_STARS);
let all_entries = build_image_entries(stars, n_entry_stars, parity_flip, index.dim_quads);
let img_entries: Vec<ImageEntry> = if *d_px.start() <= 0.0 && *d_px.end() == f64::INFINITY {
all_entries
} else {
all_entries
.into_iter()
.filter(|e| d_px.contains(&e.d_ab_px))
.collect()
};
let parity_label = if parity_flip { "flipped" } else { "normal" };
let n_total = if index.dim_quads == 4 {
let n = n_entry_stars;
n * (n - 1) * (n - 2) * (n - 3) / 24 } else {
n_entry_stars * (n_entry_stars - 1) * (n_entry_stars - 2) / 6 * 3 };
log::info!(
"Blind ({}): {}/{} entry-axis pairs in scale range [{:.0},{:.0}] px.",
parity_label,
img_entries.len(),
n_total,
d_px.start(),
d_px.end(),
);
if img_entries.len() < 3 {
return (0.0, 0.0, 0);
}
let mut vote_map: std::collections::HashMap<(i32, i32), Vec<HypEntry>> =
std::collections::HashMap::new();
let mut n_matches = 0usize;
let mut n_hyp = 0usize;
let mut hits_scratch: Vec<usize> = Vec::with_capacity(32);
for ie in &img_entries {
index.find_code_matches_into(&ie.code, tol, &mut hits_scratch);
n_matches += hits_scratch.len();
for &idx in &hits_scratch {
let idx_entry = &index.entries[idx];
if let Some(h) = hyp_from_entry(ie, idx_entry, img.width, img.height) {
let ra_bin = (h.est_ra / VOTE_STEP) as i32;
let dec_bin = ((h.est_dec + PI / 2.0) / VOTE_STEP) as i32;
vote_map.entry((ra_bin, dec_bin)).or_default().push(h);
n_hyp += 1;
}
}
}
log::info!(
"Blind ({}): {} entries, {} code matches → {} WCS hypotheses, {} vote cells.",
parity_label,
img_entries.len(),
n_matches,
n_hyp,
vote_map.len(),
);
if n_hyp == 0 {
return (0.0, 0.0, 0);
}
let mut stars_by_dec = index.stars.clone();
stars_by_dec.sort_unstable_by(|a, b| a.dec.total_cmp(&b.dec));
let det_stars: Vec<(f64, f64)> = stars.0.iter().map(|s| (s.x, s.y)).collect();
let mut vote_cells: Vec<_> = vote_map.iter().collect();
vote_cells.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
let mut best_score = 0usize;
let mut best_ra = 0.0f64;
let mut best_dec = 0.0f64;
'outer: for (_, hyps) in &vote_cells {
let h = &hyps[0];
let sc = verify_score(
h,
&stars_by_dec,
&det_stars,
fov_rad,
MATCH_PX * MATCH_PX,
img.width,
img.height,
);
log::debug!(
"Blind verify ({}): RA={:.2}° Dec={:.2}° votes={} score={}",
parity_label,
h.est_ra.to_degrees(),
h.est_dec.to_degrees(),
hyps.len(),
sc,
);
if sc > best_score {
best_score = sc;
best_ra = h.est_ra;
best_dec = h.est_dec;
if best_score >= EARLY_STOP_SCORE {
break 'outer;
}
}
}
log::info!(
"Blind ({}): best verified score = {} (threshold {}, cells={}).",
parity_label,
best_score,
MIN_VERIFY_SCORE,
vote_cells.len(),
);
(best_ra, best_dec, best_score)
}
pub fn blind_solve(
img: &crate::types::ImageBuffer,
index: &AnetIndex,
params: &BlindSolveParams,
) -> Result<(f64, f64, usize)> {
let bg = get_background(img, params.max_stars);
let (stars, stars_raw) = find_stars_with_background(
img,
&bg,
params.hfd_min,
params.max_stars,
img.width,
img.height,
);
let mut stars = stars;
if stars_raw > params.max_stars {
stars.0.truncate((params.max_stars / 2).max(50));
}
let n = stars.len();
if n < 5 {
return Err(ArcsecError::InsufficientStars {
found: n,
required: 5,
});
}
log::info!("Blind: {n} stars detected.");
let (min_d_px, max_d_px) = if params.fov_deg > 0.0 {
let fov = params.fov_deg.to_radians();
let lo = index.scale_lo * img.height as f64 / fov * 0.8;
let hi = index.scale_hi * img.height as f64 / fov * 1.2;
(lo, hi)
} else {
(0.0, f64::INFINITY)
};
let verify_fov = if params.fov_deg > 0.0 {
params.fov_deg.to_radians()
} else {
index.scale_hi * 2.0
};
let tol = params.quad_tolerance;
let mut best_ra = 0.0f64;
let mut best_dec = 0.0f64;
let mut best_score = 0usize;
for parity_flip in [false, true] {
let (ra, dec, score) = run_blind_pass(
img,
index,
&stars,
parity_flip,
verify_fov,
min_d_px..=max_d_px,
tol,
);
if score > best_score {
best_score = score;
best_ra = ra;
best_dec = dec;
}
if best_score >= EARLY_STOP_SCORE {
break;
}
}
if best_score >= MIN_VERIFY_SCORE {
log::info!(
"Blind: estimated centre RA={:.3}°, Dec={:.3}° (score={best_score})",
best_ra.to_degrees(),
best_dec.to_degrees(),
);
Ok((best_ra, best_dec, best_score))
} else {
Err(ArcsecError::InsufficientQuads {
found: best_score,
required: MIN_VERIFY_SCORE,
})
}
}
#[cfg(test)]
mod canonical_form {
use super::*;
fn rng() -> impl FnMut() -> f64 {
let mut seed = 0x2545F4914F6CDD1Du64;
move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
}
}
#[test]
fn image_quads_lie_in_the_index_code_region() {
let mut next = rng();
let (mut total, mut outside) = (0usize, 0usize);
for _ in 0..4000 {
let p: Vec<(f64, f64)> = (0..4).map(|_| (next() * 1000.0, next() * 1000.0)).collect();
for flip in [false, true] {
if let Some(e) = make_quad4(p[0], p[1], p[2], p[3], flip) {
total += 1;
let (cx, dx) = (e.code[0], e.code[2]);
if cx + dx > 1.0 + 1e-9 {
outside += 1;
}
assert!(cx <= dx + 1e-9, "second invariant broken: cx={cx} dx={dx}");
}
}
}
assert!(
total > 1000,
"probe built too few quads to be meaningful: {total}"
);
assert_eq!(
outside, 0,
"{outside} of {total} image quads outside cx + dx <= 1"
);
}
#[test]
fn triangles_keep_the_half_plane_rule() {
let mut next = rng();
let mut total = 0usize;
for _ in 0..4000 {
let p: Vec<(f64, f64)> = (0..3).map(|_| (next() * 1000.0, next() * 1000.0)).collect();
if let Some(e) = make_triangle(p[0], p[1], p[2], false) {
total += 1;
assert!(e.code[0] <= 0.5 + 1e-9, "cx={} above 0.5", e.code[0]);
}
}
assert!(total > 1000, "probe built too few triangles: {total}");
}
}