Skip to main content

citra_solve/extract/
centroid.rs

1//! Centroid extraction for star detection.
2
3use crate::core::types::DetectedStar;
4use image::DynamicImage;
5use std::path::Path;
6
7/// Configuration for star extraction.
8#[derive(Debug, Clone)]
9pub struct ExtractionConfig {
10    /// Sigma above background for detection threshold
11    pub sigma_threshold: f64,
12    /// Minimum distance between detected stars (pixels)
13    pub min_separation: f64,
14    /// Maximum number of stars to return
15    pub max_stars: usize,
16    /// Radius for centroid computation (pixels)
17    pub centroid_radius: usize,
18    /// Minimum star flux (after background subtraction)
19    pub min_flux: f64,
20    /// Minimum peak contrast above local annulus background.
21    pub min_peak_contrast: f64,
22    /// Minimum RMS spot radius (pixels) to reject hot pixels.
23    pub min_rms_radius: f64,
24    /// Maximum RMS spot radius (pixels) to reject broad cloud blobs.
25    pub max_rms_radius: f64,
26    /// Maximum spot eccentricity (0=circular, 1=line-like).
27    pub max_eccentricity: f64,
28    /// Minimum normalized peak sharpness relative to local contrast.
29    pub min_peak_sharpness: f64,
30}
31
32impl Default for ExtractionConfig {
33    fn default() -> Self {
34        Self {
35            sigma_threshold: 5.5,
36            min_separation: 10.0,
37            max_stars: 40,
38            centroid_radius: 5,
39            min_flux: 110.0,
40            min_peak_contrast: 6.0,
41            min_rms_radius: 0.6,
42            max_rms_radius: 3.8,
43            max_eccentricity: 0.9,
44            min_peak_sharpness: 0.12,
45        }
46    }
47}
48
49/// Extract stars from an image file.
50pub fn extract_stars<P: AsRef<Path>>(
51    path: P,
52    config: &ExtractionConfig,
53) -> Result<Vec<DetectedStar>, String> {
54    let img = image::open(path).map_err(|e| format!("Failed to open image: {}", e))?;
55    extract_stars_from_image(&img, config)
56}
57
58/// Extract stars from a loaded image.
59pub fn extract_stars_from_image(
60    img: &DynamicImage,
61    config: &ExtractionConfig,
62) -> Result<Vec<DetectedStar>, String> {
63    let gray = img.to_luma8();
64    let (width, height) = gray.dimensions();
65
66    // Compute background statistics (median and stddev estimate)
67    let (background, noise) = estimate_background(&gray);
68
69    // Detection threshold
70    let threshold = background + config.sigma_threshold * noise;
71
72    // Find local maxima above threshold
73    let mut candidates: Vec<(u32, u32, f64, f64)> = Vec::new();
74    let margin = config.centroid_radius as u32;
75
76    for y in margin..(height - margin) {
77        for x in margin..(width - margin) {
78            let val = gray.get_pixel(x, y).0[0] as f64;
79
80            if val < threshold {
81                continue;
82            }
83
84            // Check if local maximum in 3x3 neighborhood
85            let mut is_max = true;
86            'outer: for dy in -1i32..=1 {
87                for dx in -1i32..=1 {
88                    if dx == 0 && dy == 0 {
89                        continue;
90                    }
91                    let nx = (x as i32 + dx) as u32;
92                    let ny = (y as i32 + dy) as u32;
93                    if gray.get_pixel(nx, ny).0[0] as f64 >= val {
94                        is_max = false;
95                        break 'outer;
96                    }
97                }
98            }
99
100            if is_max {
101                // Local contrast gate: reject broad cloud structures and gradients.
102                let local_bg = local_annulus_mean(
103                    &gray,
104                    x as i32,
105                    y as i32,
106                    config.centroid_radius as i32 + 2,
107                    config.centroid_radius as i32 + 6,
108                );
109                let contrast = val - local_bg;
110                if contrast < config.min_peak_contrast {
111                    continue;
112                }
113                let sharpness = local_peak_sharpness(&gray, x, y, val, local_bg);
114                if sharpness < config.min_peak_sharpness {
115                    continue;
116                }
117                candidates.push((x, y, val, contrast));
118            }
119        }
120    }
121
122    // Sort by brightness (descending)
123    candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
124
125    // Extract centroids with non-maximum suppression
126    let mut stars: Vec<DetectedStar> = Vec::new();
127    let sep_sq = config.min_separation * config.min_separation;
128
129    for (px, py, _peak, contrast) in candidates {
130        // Compute intensity-weighted centroid
131        let (cx, cy, flux, rms_radius, eccentricity) =
132            compute_centroid(&gray, px, py, config.centroid_radius, background);
133
134        if flux < config.min_flux {
135            continue;
136        }
137        if rms_radius < config.min_rms_radius || rms_radius > config.max_rms_radius {
138            continue;
139        }
140        if eccentricity > config.max_eccentricity {
141            continue;
142        }
143
144        // Check separation from existing stars using CENTROID position
145        let too_close = stars.iter().any(|s| {
146            let dx = s.x - cx;
147            let dy = s.y - cy;
148            dx * dx + dy * dy < sep_sq
149        });
150
151        if too_close {
152            continue;
153        }
154
155        // Quality-weighted brightness helps prioritize star-like centroids
156        // over broad cloud structures when selecting top stars for solving.
157        let compactness = 1.0 / (1.0 + 0.7 * rms_radius * rms_radius);
158        let roundness = (1.0 - 0.4 * eccentricity * eccentricity).max(0.2);
159        let contrast_boost = (1.0 + contrast / 20.0).min(2.5);
160        let quality_flux = flux * compactness * roundness * contrast_boost;
161
162        stars.push(DetectedStar::new(cx, cy, quality_flux));
163
164        if stars.len() >= config.max_stars {
165            break;
166        }
167    }
168
169    // Sort by flux (brightest first)
170    stars.sort_by(|a, b| b.flux.partial_cmp(&a.flux).unwrap());
171
172    Ok(stars)
173}
174
175/// Compute a normalized peak sharpness score in [~0, 1+].
176///
177/// Broad cloud structures tend to have low sharpness while true star peaks
178/// usually stand out from their immediate 4-neighborhood.
179fn local_peak_sharpness(img: &image::GrayImage, x: u32, y: u32, peak: f64, local_bg: f64) -> f64 {
180    if x == 0 || y == 0 || x + 1 >= img.width() || y + 1 >= img.height() {
181        return 0.0;
182    }
183    let n1 = img.get_pixel(x - 1, y).0[0] as f64;
184    let n2 = img.get_pixel(x + 1, y).0[0] as f64;
185    let n3 = img.get_pixel(x, y - 1).0[0] as f64;
186    let n4 = img.get_pixel(x, y + 1).0[0] as f64;
187    let neighbors_mean = (n1 + n2 + n3 + n4) * 0.25;
188    let denom = (peak - local_bg).max(1.0);
189    ((peak - neighbors_mean) / denom).max(0.0)
190}
191
192/// Compute local annulus mean around (cx, cy).
193fn local_annulus_mean(img: &image::GrayImage, cx: i32, cy: i32, r_in: i32, r_out: i32) -> f64 {
194    let mut sum = 0.0;
195    let mut count = 0usize;
196    let r_in2 = (r_in * r_in) as i64;
197    let r_out2 = (r_out * r_out) as i64;
198
199    for dy in -r_out..=r_out {
200        for dx in -r_out..=r_out {
201            let x = cx + dx;
202            let y = cy + dy;
203            if x < 0 || y < 0 || x >= img.width() as i32 || y >= img.height() as i32 {
204                continue;
205            }
206            let d2 = (dx as i64 * dx as i64) + (dy as i64 * dy as i64);
207            if d2 >= r_in2 && d2 <= r_out2 {
208                sum += img.get_pixel(x as u32, y as u32).0[0] as f64;
209                count += 1;
210            }
211        }
212    }
213
214    if count > 0 {
215        sum / count as f64
216    } else {
217        0.0
218    }
219}
220
221/// Estimate background level and noise using median and MAD.
222fn estimate_background(img: &image::GrayImage) -> (f64, f64) {
223    let (width, height) = img.dimensions();
224
225    // Sample pixels for efficiency on large images
226    let mut samples: Vec<u8> = Vec::with_capacity(10000);
227    let step = ((width * height) as usize / 10000).max(1);
228
229    for (i, pixel) in img.pixels().enumerate() {
230        if i % step == 0 {
231            samples.push(pixel.0[0]);
232        }
233    }
234
235    samples.sort_unstable();
236
237    // Median
238    let median = samples[samples.len() / 2] as f64;
239
240    // MAD (Median Absolute Deviation) for robust noise estimate
241    let mut deviations: Vec<f64> = samples.iter().map(|&v| (v as f64 - median).abs()).collect();
242    deviations.sort_by(|a, b| a.partial_cmp(b).unwrap());
243    let mad = deviations[deviations.len() / 2];
244
245    // Convert MAD to standard deviation estimate (factor of 1.4826 for normal distribution)
246    let sigma = mad * 1.4826;
247
248    (median, sigma.max(1.0))
249}
250
251/// Compute intensity-weighted centroid around a peak.
252fn compute_centroid(
253    img: &image::GrayImage,
254    px: u32,
255    py: u32,
256    radius: usize,
257    background: f64,
258) -> (f64, f64, f64, f64, f64) {
259    let r = radius as i32;
260    let mut sum_x = 0.0;
261    let mut sum_y = 0.0;
262    let mut sum_w = 0.0;
263
264    for dy in -r..=r {
265        for dx in -r..=r {
266            let x = px as i32 + dx;
267            let y = py as i32 + dy;
268
269            if x < 0 || y < 0 {
270                continue;
271            }
272
273            let x = x as u32;
274            let y = y as u32;
275
276            if x >= img.width() || y >= img.height() {
277                continue;
278            }
279
280            let val = img.get_pixel(x, y).0[0] as f64;
281            let weight = (val - background).max(0.0);
282
283            sum_x += x as f64 * weight;
284            sum_y += y as f64 * weight;
285            sum_w += weight;
286        }
287    }
288
289    if sum_w <= 0.0 {
290        return (px as f64, py as f64, 0.0, f64::INFINITY, 1.0);
291    }
292
293    let cx = sum_x / sum_w;
294    let cy = sum_y / sum_w;
295
296    // Second pass: RMS radius around centroid for compactness filtering.
297    let mut sum_r2_w = 0.0;
298    let mut sum_x2_w = 0.0;
299    let mut sum_y2_w = 0.0;
300    let mut sum_xy_w = 0.0;
301    for dy in -r..=r {
302        for dx in -r..=r {
303            let x = px as i32 + dx;
304            let y = py as i32 + dy;
305            if x < 0 || y < 0 {
306                continue;
307            }
308            let x = x as u32;
309            let y = y as u32;
310            if x >= img.width() || y >= img.height() {
311                continue;
312            }
313
314            let val = img.get_pixel(x, y).0[0] as f64;
315            let weight = (val - background).max(0.0);
316            if weight <= 0.0 {
317                continue;
318            }
319
320            let dx = x as f64 - cx;
321            let dy = y as f64 - cy;
322            sum_r2_w += (dx * dx + dy * dy) * weight;
323            sum_x2_w += dx * dx * weight;
324            sum_y2_w += dy * dy * weight;
325            sum_xy_w += dx * dy * weight;
326        }
327    }
328
329    let rms_radius = (sum_r2_w / sum_w).sqrt();
330    let mxx = sum_x2_w / sum_w;
331    let myy = sum_y2_w / sum_w;
332    let mxy = sum_xy_w / sum_w;
333    let trace = mxx + myy;
334    let det = (mxx * myy - mxy * mxy).max(0.0);
335    let disc = (0.25 * trace * trace - det).max(0.0).sqrt();
336    let l1 = (0.5 * trace + disc).max(0.0);
337    let l2 = (0.5 * trace - disc).max(0.0);
338    let eccentricity = if l1 > 1e-12 {
339        (1.0 - (l2 / l1)).max(0.0).sqrt()
340    } else {
341        0.0
342    };
343
344    (cx, cy, sum_w, rms_radius, eccentricity)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_extraction_config_default() {
353        let config = ExtractionConfig::default();
354        assert_eq!(config.sigma_threshold, 5.5);
355        assert_eq!(config.min_separation, 10.0);
356        assert_eq!(config.max_stars, 40);
357        assert_eq!(config.min_flux, 110.0);
358        assert_eq!(config.min_peak_contrast, 6.0);
359        assert_eq!(config.min_rms_radius, 0.6);
360        assert_eq!(config.max_rms_radius, 3.8);
361        assert_eq!(config.max_eccentricity, 0.9);
362        assert_eq!(config.min_peak_sharpness, 0.12);
363    }
364}