Skip to main content

astroimage/
annotate.rs

1/// Star annotation overlay: draws detected-star ellipses onto converted images.
2
3use crate::analysis::AnalysisResult;
4use crate::types::ProcessedImage;
5
6/// Color scheme for star annotations.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub enum ColorScheme {
9    /// All annotations use a single color (green).
10    Uniform,
11    /// Color by eccentricity: green (round) → yellow → red (elongated).
12    Eccentricity,
13    /// Color by FWHM relative to median: green (tight) → yellow → red (bloated).
14    Fwhm,
15}
16
17/// Configuration for annotation rendering.
18pub struct AnnotationConfig {
19    /// Color scheme for annotations.
20    pub color_scheme: ColorScheme,
21    /// Draw a direction tick along the elongation axis.
22    pub show_direction_tick: bool,
23    /// Minimum ellipse semi-axis radius in output pixels.
24    pub min_radius: f32,
25    /// Maximum ellipse semi-axis radius in output pixels.
26    pub max_radius: f32,
27    /// Line thickness: 1 = single pixel, 2 = 3px cross kernel, 3 = 5px diamond.
28    pub line_width: u8,
29    /// Eccentricity threshold: below this is green (good).
30    pub ecc_good: f32,
31    /// Eccentricity threshold: between `ecc_good` and this is yellow (warning).
32    /// At or above this is red (problem).
33    pub ecc_warn: f32,
34    /// FWHM ratio threshold: below this is green (good). Ratio = star FWHM / median FWHM.
35    pub fwhm_good: f32,
36    /// FWHM ratio threshold: between `fwhm_good` and this is yellow (warning).
37    /// At or above this is red (problem).
38    pub fwhm_warn: f32,
39}
40
41impl Default for AnnotationConfig {
42    fn default() -> Self {
43        AnnotationConfig {
44            color_scheme: ColorScheme::Eccentricity,
45            show_direction_tick: true,
46            min_radius: 6.0,
47            max_radius: 60.0,
48            line_width: 2,
49            ecc_good: 0.5,
50            ecc_warn: 0.6,
51            fwhm_good: 1.3,
52            fwhm_warn: 2.0,
53        }
54    }
55}
56
57/// Pre-computed annotation for one star, in output image coordinates.
58pub struct StarAnnotation {
59    /// Centroid X in output image coordinates.
60    pub x: f32,
61    /// Centroid Y in output image coordinates.
62    pub y: f32,
63    /// Semi-major axis in output pixels.
64    pub semi_major: f32,
65    /// Semi-minor axis in output pixels.
66    pub semi_minor: f32,
67    /// Rotation angle (radians), counter-clockwise from +X axis.
68    pub theta: f32,
69    /// Original eccentricity value.
70    pub eccentricity: f32,
71    /// Original geometric mean FWHM (analysis pixels).
72    pub fwhm: f32,
73    /// RGB color based on the chosen scheme.
74    pub color: [u8; 3],
75}
76
77// ── Tier 1: Raw geometry ──
78
79/// Compute annotation geometry for all detected stars, transformed to output coordinates.
80pub fn compute_annotations(
81    result: &AnalysisResult,
82    output_width: usize,
83    output_height: usize,
84    flip_vertical: bool,
85    config: &AnnotationConfig,
86) -> Vec<StarAnnotation> {
87    if result.stars.is_empty() || result.width == 0 || result.height == 0 {
88        return Vec::new();
89    }
90
91    let scale_x = output_width as f32 / result.width as f32;
92    let scale_y = output_height as f32 / result.height as f32;
93
94    result
95        .stars
96        .iter()
97        .map(|star| {
98            let x_out = star.x * scale_x;
99            let y_out = if flip_vertical {
100                output_height as f32 - 1.0 - star.y * scale_y
101            } else {
102                star.y * scale_y
103            };
104
105            // Semi-axes: scale FWHM to output, multiply by 2.5 for visibility, then clamp.
106            // metrics.rs guarantees fwhm_x >= fwhm_y with theta along major axis.
107            let raw_a = star.fwhm_x * scale_x;
108            let raw_b = star.fwhm_y * scale_y;
109            let semi_major = (raw_a * 2.5).clamp(config.min_radius, config.max_radius);
110            let semi_minor = (raw_b * 2.5).clamp(config.min_radius, config.max_radius);
111
112            let color = star_color(config, star.eccentricity, star.fwhm, result.median_fwhm);
113
114            // Y-flip reflects through the horizontal axis, negating the angle.
115            let theta = if flip_vertical { -star.theta } else { star.theta };
116
117            StarAnnotation {
118                x: x_out,
119                y: y_out,
120                semi_major,
121                semi_minor,
122                theta,
123                eccentricity: star.eccentricity,
124                fwhm: star.fwhm,
125                color,
126            }
127        })
128        .collect()
129}
130
131// ── Tier 2: RGBA overlay layer ──
132
133/// Rasterize annotations into a transparent RGBA buffer (same dimensions as output image).
134pub fn create_annotation_layer(
135    result: &AnalysisResult,
136    output_width: usize,
137    output_height: usize,
138    flip_vertical: bool,
139    config: &AnnotationConfig,
140) -> Vec<u8> {
141    let mut layer = vec![0u8; output_width * output_height * 4];
142    let annotations = compute_annotations(result, output_width, output_height, flip_vertical, config);
143    let lw = config.line_width;
144
145    for ann in &annotations {
146        draw_ellipse_rgba(&mut layer, output_width, output_height, ann, lw);
147        if config.show_direction_tick && ann.eccentricity > 0.15 {
148            draw_direction_tick_rgba(&mut layer, output_width, output_height, ann, lw);
149        }
150    }
151
152    layer
153}
154
155// ── Tier 3: Burn into ProcessedImage ──
156
157/// Draw star annotations directly onto a ProcessedImage (RGB or RGBA).
158pub fn annotate_image(
159    image: &mut ProcessedImage,
160    result: &AnalysisResult,
161    config: &AnnotationConfig,
162) {
163    let annotations = compute_annotations(
164        result,
165        image.width,
166        image.height,
167        image.flip_vertical,
168        config,
169    );
170    let bpp = image.channels as usize;
171    let lw = config.line_width;
172
173    for ann in &annotations {
174        draw_ellipse_rgb(&mut image.data, image.width, image.height, bpp, ann, lw);
175        if config.show_direction_tick && ann.eccentricity > 0.15 {
176            draw_direction_tick_rgb(&mut image.data, image.width, image.height, bpp, ann, lw);
177        }
178    }
179}
180
181// ── Drawing primitives (private) ──
182
183/// Bounds-checked single pixel write on an RGB/RGBA buffer.
184#[inline]
185fn set_pixel_one(buf: &mut [u8], width: usize, height: usize, bpp: usize, x: i32, y: i32, color: [u8; 3]) {
186    if x >= 0 && y >= 0 && (x as usize) < width && (y as usize) < height {
187        let idx = (y as usize * width + x as usize) * bpp;
188        buf[idx] = color[0];
189        buf[idx + 1] = color[1];
190        buf[idx + 2] = color[2];
191    }
192}
193
194/// Bounds-checked single pixel write on an RGBA buffer (sets alpha to 255).
195#[inline]
196fn set_pixel_one_rgba(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: [u8; 3]) {
197    if x >= 0 && y >= 0 && (x as usize) < width && (y as usize) < height {
198        let idx = (y as usize * width + x as usize) * 4;
199        buf[idx] = color[0];
200        buf[idx + 1] = color[1];
201        buf[idx + 2] = color[2];
202        buf[idx + 3] = 255;
203    }
204}
205
206/// Thick pixel write: draws a kernel around (x,y).
207/// lw=1: single pixel, lw=2: 3px cross (+), lw>=3: 5px diamond.
208#[inline]
209fn set_pixel(buf: &mut [u8], width: usize, height: usize, bpp: usize, x: i32, y: i32, color: [u8; 3], lw: u8) {
210    set_pixel_one(buf, width, height, bpp, x, y, color);
211    if lw >= 2 {
212        set_pixel_one(buf, width, height, bpp, x - 1, y, color);
213        set_pixel_one(buf, width, height, bpp, x + 1, y, color);
214        set_pixel_one(buf, width, height, bpp, x, y - 1, color);
215        set_pixel_one(buf, width, height, bpp, x, y + 1, color);
216    }
217    if lw >= 3 {
218        set_pixel_one(buf, width, height, bpp, x - 2, y, color);
219        set_pixel_one(buf, width, height, bpp, x + 2, y, color);
220        set_pixel_one(buf, width, height, bpp, x, y - 2, color);
221        set_pixel_one(buf, width, height, bpp, x, y + 2, color);
222    }
223}
224
225/// Thick pixel write on RGBA buffer.
226#[inline]
227fn set_pixel_rgba(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: [u8; 3], lw: u8) {
228    set_pixel_one_rgba(buf, width, height, x, y, color);
229    if lw >= 2 {
230        set_pixel_one_rgba(buf, width, height, x - 1, y, color);
231        set_pixel_one_rgba(buf, width, height, x + 1, y, color);
232        set_pixel_one_rgba(buf, width, height, x, y - 1, color);
233        set_pixel_one_rgba(buf, width, height, x, y + 1, color);
234    }
235    if lw >= 3 {
236        set_pixel_one_rgba(buf, width, height, x - 2, y, color);
237        set_pixel_one_rgba(buf, width, height, x + 2, y, color);
238        set_pixel_one_rgba(buf, width, height, x, y - 2, color);
239        set_pixel_one_rgba(buf, width, height, x, y + 2, color);
240    }
241}
242
243/// Bresenham line drawing on an RGB/RGBA buffer with thickness.
244fn draw_line(buf: &mut [u8], width: usize, height: usize, bpp: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 3], lw: u8) {
245    let dx = (x1 - x0).abs();
246    let dy = -(y1 - y0).abs();
247    let sx = if x0 < x1 { 1 } else { -1 };
248    let sy = if y0 < y1 { 1 } else { -1 };
249    let mut err = dx + dy;
250    let mut x = x0;
251    let mut y = y0;
252
253    loop {
254        set_pixel(buf, width, height, bpp, x, y, color, lw);
255        if x == x1 && y == y1 {
256            break;
257        }
258        let e2 = 2 * err;
259        if e2 >= dy {
260            if x == x1 { break; }
261            err += dy;
262            x += sx;
263        }
264        if e2 <= dx {
265            if y == y1 { break; }
266            err += dx;
267            y += sy;
268        }
269    }
270}
271
272/// Bresenham line drawing on an RGBA buffer with thickness.
273fn draw_line_rgba(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 3], lw: u8) {
274    let dx = (x1 - x0).abs();
275    let dy = -(y1 - y0).abs();
276    let sx = if x0 < x1 { 1 } else { -1 };
277    let sy = if y0 < y1 { 1 } else { -1 };
278    let mut err = dx + dy;
279    let mut x = x0;
280    let mut y = y0;
281
282    loop {
283        set_pixel_rgba(buf, width, height, x, y, color, lw);
284        if x == x1 && y == y1 {
285            break;
286        }
287        let e2 = 2 * err;
288        if e2 >= dy {
289            if x == x1 { break; }
290            err += dy;
291            x += sx;
292        }
293        if e2 <= dx {
294            if y == y1 { break; }
295            err += dx;
296            y += sy;
297        }
298    }
299}
300
301/// Draw a rotated ellipse by sampling parametric points and connecting with lines.
302fn draw_ellipse_rgb(buf: &mut [u8], width: usize, height: usize, bpp: usize, ann: &StarAnnotation, lw: u8) {
303    let steps = 64;
304    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
305    let mut prev_x = 0i32;
306    let mut prev_y = 0i32;
307
308    for i in 0..=steps {
309        let t = (i as f32) * std::f32::consts::TAU / (steps as f32);
310        let ex = ann.semi_major * t.cos();
311        let ey = ann.semi_minor * t.sin();
312        let rx = ex * ct - ey * st + ann.x;
313        let ry = ex * st + ey * ct + ann.y;
314        let px = rx.round() as i32;
315        let py = ry.round() as i32;
316
317        if i > 0 {
318            draw_line(buf, width, height, bpp, prev_x, prev_y, px, py, ann.color, lw);
319        }
320        prev_x = px;
321        prev_y = py;
322    }
323}
324
325/// Draw a rotated ellipse on an RGBA layer buffer.
326fn draw_ellipse_rgba(buf: &mut [u8], width: usize, height: usize, ann: &StarAnnotation, lw: u8) {
327    let steps = 64;
328    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
329    let mut prev_x = 0i32;
330    let mut prev_y = 0i32;
331
332    for i in 0..=steps {
333        let t = (i as f32) * std::f32::consts::TAU / (steps as f32);
334        let ex = ann.semi_major * t.cos();
335        let ey = ann.semi_minor * t.sin();
336        let rx = ex * ct - ey * st + ann.x;
337        let ry = ex * st + ey * ct + ann.y;
338        let px = rx.round() as i32;
339        let py = ry.round() as i32;
340
341        if i > 0 {
342            draw_line_rgba(buf, width, height, prev_x, prev_y, px, py, ann.color, lw);
343        }
344        prev_x = px;
345        prev_y = py;
346    }
347}
348
349/// Draw a direction tick extending from the ellipse edge along theta.
350fn draw_direction_tick_rgb(buf: &mut [u8], width: usize, height: usize, bpp: usize, ann: &StarAnnotation, lw: u8) {
351    let tick_len = ann.semi_major * ann.eccentricity * 1.2;
352    if tick_len < 2.0 {
353        return;
354    }
355    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
356
357    // Start at the ellipse edge along major axis, extend outward
358    let start_x = ann.x + ann.semi_major * ct;
359    let start_y = ann.y + ann.semi_major * st;
360    let end_x = start_x + tick_len * ct;
361    let end_y = start_y + tick_len * st;
362
363    draw_line(buf, width, height, bpp,
364        start_x.round() as i32, start_y.round() as i32,
365        end_x.round() as i32, end_y.round() as i32,
366        ann.color, lw);
367
368    // Opposite side
369    let start_x2 = ann.x - ann.semi_major * ct;
370    let start_y2 = ann.y - ann.semi_major * st;
371    let end_x2 = start_x2 - tick_len * ct;
372    let end_y2 = start_y2 - tick_len * st;
373
374    draw_line(buf, width, height, bpp,
375        start_x2.round() as i32, start_y2.round() as i32,
376        end_x2.round() as i32, end_y2.round() as i32,
377        ann.color, lw);
378}
379
380/// Draw a direction tick on an RGBA layer buffer.
381fn draw_direction_tick_rgba(buf: &mut [u8], width: usize, height: usize, ann: &StarAnnotation, lw: u8) {
382    let tick_len = ann.semi_major * ann.eccentricity * 1.2;
383    if tick_len < 2.0 {
384        return;
385    }
386    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
387
388    let start_x = ann.x + ann.semi_major * ct;
389    let start_y = ann.y + ann.semi_major * st;
390    let end_x = start_x + tick_len * ct;
391    let end_y = start_y + tick_len * st;
392
393    draw_line_rgba(buf, width, height,
394        start_x.round() as i32, start_y.round() as i32,
395        end_x.round() as i32, end_y.round() as i32,
396        ann.color, lw);
397
398    let start_x2 = ann.x - ann.semi_major * ct;
399    let start_y2 = ann.y - ann.semi_major * st;
400    let end_x2 = start_x2 - tick_len * ct;
401    let end_y2 = start_y2 - tick_len * st;
402
403    draw_line_rgba(buf, width, height,
404        start_x2.round() as i32, start_y2.round() as i32,
405        end_x2.round() as i32, end_y2.round() as i32,
406        ann.color, lw);
407}
408
409/// Choose annotation color based on the color scheme and star metrics.
410fn star_color(config: &AnnotationConfig, eccentricity: f32, fwhm: f32, median_fwhm: f32) -> [u8; 3] {
411    match config.color_scheme {
412        ColorScheme::Uniform => [0, 255, 0],
413        ColorScheme::Eccentricity => {
414            if eccentricity <= config.ecc_good {
415                [0, 255, 0]       // Green: round, good
416            } else if eccentricity <= config.ecc_warn {
417                [255, 255, 0]     // Yellow: slightly elongated
418            } else {
419                [255, 64, 64]     // Red: problem
420            }
421        }
422        ColorScheme::Fwhm => {
423            if median_fwhm <= 0.0 {
424                return [0, 255, 0];
425            }
426            let ratio = fwhm / median_fwhm;
427            if ratio < config.fwhm_good {
428                [0, 255, 0]       // Green: tight
429            } else if ratio < config.fwhm_warn {
430                [255, 255, 0]     // Yellow: somewhat bloated
431            } else {
432                [255, 64, 64]     // Red: very bloated
433            }
434        }
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::analysis::{AnalysisResult, StarMetrics};
442
443    fn dummy_result(stars: Vec<StarMetrics>) -> AnalysisResult {
444        AnalysisResult {
445            width: 100,
446            height: 100,
447            source_channels: 1,
448            background: 0.0,
449            noise: 0.0,
450            detection_threshold: 0.0,
451            stars_detected: stars.len(),
452            median_fwhm: 5.0,
453            median_eccentricity: 0.2,
454            median_snr: 50.0,
455            median_hfr: 3.0,
456            snr_weight: 100.0,
457            psf_signal: 50.0,
458            frame_snr: 0.0,
459            trail_r_squared: 0.0,
460            possibly_trailed: false,
461            measured_fwhm_kernel: 3.0,
462            median_beta: None,
463            stars,
464        }
465    }
466
467    fn make_star(x: f32, y: f32, fwhm: f32, ecc: f32) -> StarMetrics {
468        StarMetrics {
469            x, y,
470            peak: 1000.0,
471            flux: 5000.0,
472            fwhm_x: fwhm,
473            fwhm_y: fwhm * (1.0 - ecc * ecc).sqrt(),
474            fwhm,
475            eccentricity: ecc,
476            snr: 50.0,
477            hfr: fwhm * 0.6,
478            theta: 0.0,
479            beta: None,
480            fit_method: crate::analysis::FitMethod::Gaussian,
481            fit_residual: 0.0,
482        }
483    }
484
485    #[test]
486    fn test_compute_annotations_empty() {
487        let result = dummy_result(vec![]);
488        let anns = compute_annotations(&result, 100, 100, false, &AnnotationConfig::default());
489        assert!(anns.is_empty());
490    }
491
492    #[test]
493    fn test_compute_annotations_coordinate_transform() {
494        let star = make_star(50.0, 25.0, 5.0, 0.1);
495        let result = dummy_result(vec![star]);
496
497        // Same size, no flip
498        let anns = compute_annotations(&result, 100, 100, false, &AnnotationConfig::default());
499        assert_eq!(anns.len(), 1);
500        assert!((anns[0].x - 50.0).abs() < 0.1);
501        assert!((anns[0].y - 25.0).abs() < 0.1);
502
503        // Same size, flipped
504        let anns = compute_annotations(&result, 100, 100, true, &AnnotationConfig::default());
505        assert!((anns[0].y - 74.0).abs() < 0.1); // 99 - 25 = 74
506
507        // Half size (e.g. debayer)
508        let anns = compute_annotations(&result, 50, 50, false, &AnnotationConfig::default());
509        assert!((anns[0].x - 25.0).abs() < 0.1);
510        assert!((anns[0].y - 12.5).abs() < 0.1);
511    }
512
513    #[test]
514    fn test_eccentricity_colors() {
515        let config = AnnotationConfig::default();
516        assert_eq!(star_color(&config, 0.3, 5.0, 5.0), [0, 255, 0]);       // below 0.5 → green
517        assert_eq!(star_color(&config, 0.55, 5.0, 5.0), [255, 255, 0]);    // 0.51..0.6 → yellow
518        assert_eq!(star_color(&config, 0.7, 5.0, 5.0), [255, 64, 64]);     // above 0.6 → red
519    }
520
521    #[test]
522    fn test_annotate_image_smoke() {
523        let star = make_star(50.0, 50.0, 5.0, 0.2);
524        let result = dummy_result(vec![star]);
525
526        let mut image = ProcessedImage {
527            data: vec![0u8; 100 * 100 * 3],
528            width: 100,
529            height: 100,
530            is_color: false,
531            channels: 3,
532            flip_vertical: false,
533        };
534
535        annotate_image(&mut image, &result, &AnnotationConfig::default());
536
537        // At least some pixels should have been drawn (non-zero)
538        let nonzero = image.data.iter().filter(|&&b| b > 0).count();
539        assert!(nonzero > 0, "Expected some drawn pixels");
540    }
541
542    #[test]
543    fn test_create_annotation_layer_smoke() {
544        let star = make_star(50.0, 50.0, 5.0, 0.2);
545        let result = dummy_result(vec![star]);
546
547        let layer = create_annotation_layer(&result, 100, 100, false, &AnnotationConfig::default());
548        assert_eq!(layer.len(), 100 * 100 * 4);
549
550        // Check that some alpha values are 255 (drawn pixels)
551        let drawn = layer.chunks_exact(4).filter(|px| px[3] == 255).count();
552        assert!(drawn > 0, "Expected some drawn pixels in layer");
553    }
554
555    #[test]
556    fn test_flip_vertical_negates_theta() {
557        let theta = std::f32::consts::FRAC_PI_6; // 30°
558        let star = StarMetrics {
559            x: 50.0,
560            y: 25.0,
561            peak: 1000.0,
562            flux: 5000.0,
563            fwhm_x: 8.0,
564            fwhm_y: 4.0,
565            fwhm: 5.66,
566            eccentricity: 0.87,
567            snr: 50.0,
568            hfr: 3.0,
569            theta,
570            beta: None,
571            fit_method: crate::analysis::FitMethod::Gaussian,
572            fit_residual: 0.0,
573        };
574        let result = dummy_result(vec![star]);
575        let config = AnnotationConfig::default();
576
577        let anns_no_flip = compute_annotations(&result, 100, 100, false, &config);
578        let anns_flipped = compute_annotations(&result, 100, 100, true, &config);
579
580        assert!((anns_no_flip[0].theta - theta).abs() < 1e-6,
581            "without flip, theta should be unchanged");
582        assert!((anns_flipped[0].theta - (-theta)).abs() < 1e-6,
583            "with flip, theta should be negated: got {} expected {}",
584            anns_flipped[0].theta, -theta);
585    }
586
587    #[test]
588    fn test_bresenham_diagonal() {
589        let mut buf = vec![0u8; 10 * 10 * 3];
590        draw_line(&mut buf, 10, 10, 3, 0, 0, 9, 9, [255, 0, 0], 1);
591        // Check that the diagonal has some red pixels
592        let red_count = buf.chunks_exact(3).filter(|px| px[0] == 255).count();
593        assert!(red_count >= 10, "Expected at least 10 red pixels on diagonal");
594    }
595}