1use crate::core::types::DetectedStar;
4use image::DynamicImage;
5use std::path::Path;
6
7#[derive(Debug, Clone)]
9pub struct ExtractionConfig {
10 pub sigma_threshold: f64,
12 pub min_separation: f64,
14 pub max_stars: usize,
16 pub centroid_radius: usize,
18 pub min_flux: f64,
20 pub min_peak_contrast: f64,
22 pub min_rms_radius: f64,
24 pub max_rms_radius: f64,
26 pub max_eccentricity: f64,
28 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
49pub 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
58pub 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 let (background, noise) = estimate_background(&gray);
68
69 let threshold = background + config.sigma_threshold * noise;
71
72 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 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 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 candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
124
125 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 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 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 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 stars.sort_by(|a, b| b.flux.partial_cmp(&a.flux).unwrap());
171
172 Ok(stars)
173}
174
175fn 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
192fn 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
221fn estimate_background(img: &image::GrayImage) -> (f64, f64) {
223 let (width, height) = img.dimensions();
224
225 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 let median = samples[samples.len() / 2] as f64;
239
240 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 let sigma = mad * 1.4826;
247
248 (median, sigma.max(1.0))
249}
250
251fn 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 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}