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            // Use the larger of (fwhm_x, fwhm_y) as major and smaller as minor,
107            // preserving the axis ratio so elongation is clearly visible.
108            let (raw_a, raw_b) = if star.fwhm_x >= star.fwhm_y {
109                (star.fwhm_x * scale_x, star.fwhm_y * scale_y)
110            } else {
111                (star.fwhm_y * scale_y, star.fwhm_x * scale_x)
112            };
113            let semi_major = (raw_a * 2.5).clamp(config.min_radius, config.max_radius);
114            let semi_minor = (raw_b * 2.5).clamp(config.min_radius, config.max_radius);
115
116            let color = star_color(config, star.eccentricity, star.fwhm, result.median_fwhm);
117
118            StarAnnotation {
119                x: x_out,
120                y: y_out,
121                semi_major,
122                semi_minor,
123                theta: star.theta,
124                eccentricity: star.eccentricity,
125                fwhm: star.fwhm,
126                color,
127            }
128        })
129        .collect()
130}
131
132// ── Tier 2: RGBA overlay layer ──
133
134/// Rasterize annotations into a transparent RGBA buffer (same dimensions as output image).
135pub fn create_annotation_layer(
136    result: &AnalysisResult,
137    output_width: usize,
138    output_height: usize,
139    flip_vertical: bool,
140    config: &AnnotationConfig,
141) -> Vec<u8> {
142    let mut layer = vec![0u8; output_width * output_height * 4];
143    let annotations = compute_annotations(result, output_width, output_height, flip_vertical, config);
144    let lw = config.line_width;
145
146    for ann in &annotations {
147        draw_ellipse_rgba(&mut layer, output_width, output_height, ann, lw);
148        if config.show_direction_tick && ann.eccentricity > 0.15 {
149            draw_direction_tick_rgba(&mut layer, output_width, output_height, ann, lw);
150        }
151    }
152
153    layer
154}
155
156// ── Tier 3: Burn into ProcessedImage ──
157
158/// Draw star annotations directly onto a ProcessedImage (RGB or RGBA).
159pub fn annotate_image(
160    image: &mut ProcessedImage,
161    result: &AnalysisResult,
162    config: &AnnotationConfig,
163) {
164    let annotations = compute_annotations(
165        result,
166        image.width,
167        image.height,
168        image.flip_vertical,
169        config,
170    );
171    let bpp = image.channels as usize;
172    let lw = config.line_width;
173
174    for ann in &annotations {
175        draw_ellipse_rgb(&mut image.data, image.width, image.height, bpp, ann, lw);
176        if config.show_direction_tick && ann.eccentricity > 0.15 {
177            draw_direction_tick_rgb(&mut image.data, image.width, image.height, bpp, ann, lw);
178        }
179    }
180}
181
182// ── Drawing primitives (private) ──
183
184/// Bounds-checked single pixel write on an RGB/RGBA buffer.
185#[inline]
186fn set_pixel_one(buf: &mut [u8], width: usize, height: usize, bpp: usize, x: i32, y: i32, color: [u8; 3]) {
187    if x >= 0 && y >= 0 && (x as usize) < width && (y as usize) < height {
188        let idx = (y as usize * width + x as usize) * bpp;
189        buf[idx] = color[0];
190        buf[idx + 1] = color[1];
191        buf[idx + 2] = color[2];
192    }
193}
194
195/// Bounds-checked single pixel write on an RGBA buffer (sets alpha to 255).
196#[inline]
197fn set_pixel_one_rgba(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: [u8; 3]) {
198    if x >= 0 && y >= 0 && (x as usize) < width && (y as usize) < height {
199        let idx = (y as usize * width + x as usize) * 4;
200        buf[idx] = color[0];
201        buf[idx + 1] = color[1];
202        buf[idx + 2] = color[2];
203        buf[idx + 3] = 255;
204    }
205}
206
207/// Thick pixel write: draws a kernel around (x,y).
208/// lw=1: single pixel, lw=2: 3px cross (+), lw>=3: 5px diamond.
209#[inline]
210fn set_pixel(buf: &mut [u8], width: usize, height: usize, bpp: usize, x: i32, y: i32, color: [u8; 3], lw: u8) {
211    set_pixel_one(buf, width, height, bpp, x, y, color);
212    if lw >= 2 {
213        set_pixel_one(buf, width, height, bpp, x - 1, y, color);
214        set_pixel_one(buf, width, height, bpp, x + 1, y, color);
215        set_pixel_one(buf, width, height, bpp, x, y - 1, color);
216        set_pixel_one(buf, width, height, bpp, x, y + 1, color);
217    }
218    if lw >= 3 {
219        set_pixel_one(buf, width, height, bpp, x - 2, y, color);
220        set_pixel_one(buf, width, height, bpp, x + 2, y, color);
221        set_pixel_one(buf, width, height, bpp, x, y - 2, color);
222        set_pixel_one(buf, width, height, bpp, x, y + 2, color);
223    }
224}
225
226/// Thick pixel write on RGBA buffer.
227#[inline]
228fn set_pixel_rgba(buf: &mut [u8], width: usize, height: usize, x: i32, y: i32, color: [u8; 3], lw: u8) {
229    set_pixel_one_rgba(buf, width, height, x, y, color);
230    if lw >= 2 {
231        set_pixel_one_rgba(buf, width, height, x - 1, y, color);
232        set_pixel_one_rgba(buf, width, height, x + 1, y, color);
233        set_pixel_one_rgba(buf, width, height, x, y - 1, color);
234        set_pixel_one_rgba(buf, width, height, x, y + 1, color);
235    }
236    if lw >= 3 {
237        set_pixel_one_rgba(buf, width, height, x - 2, y, color);
238        set_pixel_one_rgba(buf, width, height, x + 2, y, color);
239        set_pixel_one_rgba(buf, width, height, x, y - 2, color);
240        set_pixel_one_rgba(buf, width, height, x, y + 2, color);
241    }
242}
243
244/// Bresenham line drawing on an RGB/RGBA buffer with thickness.
245fn draw_line(buf: &mut [u8], width: usize, height: usize, bpp: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 3], lw: u8) {
246    let dx = (x1 - x0).abs();
247    let dy = -(y1 - y0).abs();
248    let sx = if x0 < x1 { 1 } else { -1 };
249    let sy = if y0 < y1 { 1 } else { -1 };
250    let mut err = dx + dy;
251    let mut x = x0;
252    let mut y = y0;
253
254    loop {
255        set_pixel(buf, width, height, bpp, x, y, color, lw);
256        if x == x1 && y == y1 {
257            break;
258        }
259        let e2 = 2 * err;
260        if e2 >= dy {
261            if x == x1 { break; }
262            err += dy;
263            x += sx;
264        }
265        if e2 <= dx {
266            if y == y1 { break; }
267            err += dx;
268            y += sy;
269        }
270    }
271}
272
273/// Bresenham line drawing on an RGBA buffer with thickness.
274fn draw_line_rgba(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: [u8; 3], lw: u8) {
275    let dx = (x1 - x0).abs();
276    let dy = -(y1 - y0).abs();
277    let sx = if x0 < x1 { 1 } else { -1 };
278    let sy = if y0 < y1 { 1 } else { -1 };
279    let mut err = dx + dy;
280    let mut x = x0;
281    let mut y = y0;
282
283    loop {
284        set_pixel_rgba(buf, width, height, x, y, color, lw);
285        if x == x1 && y == y1 {
286            break;
287        }
288        let e2 = 2 * err;
289        if e2 >= dy {
290            if x == x1 { break; }
291            err += dy;
292            x += sx;
293        }
294        if e2 <= dx {
295            if y == y1 { break; }
296            err += dx;
297            y += sy;
298        }
299    }
300}
301
302/// Draw a rotated ellipse by sampling parametric points and connecting with lines.
303fn draw_ellipse_rgb(buf: &mut [u8], width: usize, height: usize, bpp: usize, ann: &StarAnnotation, lw: u8) {
304    let steps = 64;
305    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
306    let mut prev_x = 0i32;
307    let mut prev_y = 0i32;
308
309    for i in 0..=steps {
310        let t = (i as f32) * std::f32::consts::TAU / (steps as f32);
311        let ex = ann.semi_major * t.cos();
312        let ey = ann.semi_minor * t.sin();
313        let rx = ex * ct - ey * st + ann.x;
314        let ry = ex * st + ey * ct + ann.y;
315        let px = rx.round() as i32;
316        let py = ry.round() as i32;
317
318        if i > 0 {
319            draw_line(buf, width, height, bpp, prev_x, prev_y, px, py, ann.color, lw);
320        }
321        prev_x = px;
322        prev_y = py;
323    }
324}
325
326/// Draw a rotated ellipse on an RGBA layer buffer.
327fn draw_ellipse_rgba(buf: &mut [u8], width: usize, height: usize, ann: &StarAnnotation, lw: u8) {
328    let steps = 64;
329    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
330    let mut prev_x = 0i32;
331    let mut prev_y = 0i32;
332
333    for i in 0..=steps {
334        let t = (i as f32) * std::f32::consts::TAU / (steps as f32);
335        let ex = ann.semi_major * t.cos();
336        let ey = ann.semi_minor * t.sin();
337        let rx = ex * ct - ey * st + ann.x;
338        let ry = ex * st + ey * ct + ann.y;
339        let px = rx.round() as i32;
340        let py = ry.round() as i32;
341
342        if i > 0 {
343            draw_line_rgba(buf, width, height, prev_x, prev_y, px, py, ann.color, lw);
344        }
345        prev_x = px;
346        prev_y = py;
347    }
348}
349
350/// Draw a direction tick extending from the ellipse edge along theta.
351fn draw_direction_tick_rgb(buf: &mut [u8], width: usize, height: usize, bpp: usize, ann: &StarAnnotation, lw: u8) {
352    let tick_len = ann.semi_major * ann.eccentricity * 1.2;
353    if tick_len < 2.0 {
354        return;
355    }
356    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
357
358    // Start at the ellipse edge along major axis, extend outward
359    let start_x = ann.x + ann.semi_major * ct;
360    let start_y = ann.y + ann.semi_major * st;
361    let end_x = start_x + tick_len * ct;
362    let end_y = start_y + tick_len * st;
363
364    draw_line(buf, width, height, bpp,
365        start_x.round() as i32, start_y.round() as i32,
366        end_x.round() as i32, end_y.round() as i32,
367        ann.color, lw);
368
369    // Opposite side
370    let start_x2 = ann.x - ann.semi_major * ct;
371    let start_y2 = ann.y - ann.semi_major * st;
372    let end_x2 = start_x2 - tick_len * ct;
373    let end_y2 = start_y2 - tick_len * st;
374
375    draw_line(buf, width, height, bpp,
376        start_x2.round() as i32, start_y2.round() as i32,
377        end_x2.round() as i32, end_y2.round() as i32,
378        ann.color, lw);
379}
380
381/// Draw a direction tick on an RGBA layer buffer.
382fn draw_direction_tick_rgba(buf: &mut [u8], width: usize, height: usize, ann: &StarAnnotation, lw: u8) {
383    let tick_len = ann.semi_major * ann.eccentricity * 1.2;
384    if tick_len < 2.0 {
385        return;
386    }
387    let (ct, st) = (ann.theta.cos(), ann.theta.sin());
388
389    let start_x = ann.x + ann.semi_major * ct;
390    let start_y = ann.y + ann.semi_major * st;
391    let end_x = start_x + tick_len * ct;
392    let end_y = start_y + tick_len * st;
393
394    draw_line_rgba(buf, width, height,
395        start_x.round() as i32, start_y.round() as i32,
396        end_x.round() as i32, end_y.round() as i32,
397        ann.color, lw);
398
399    let start_x2 = ann.x - ann.semi_major * ct;
400    let start_y2 = ann.y - ann.semi_major * st;
401    let end_x2 = start_x2 - tick_len * ct;
402    let end_y2 = start_y2 - tick_len * st;
403
404    draw_line_rgba(buf, width, height,
405        start_x2.round() as i32, start_y2.round() as i32,
406        end_x2.round() as i32, end_y2.round() as i32,
407        ann.color, lw);
408}
409
410/// Choose annotation color based on the color scheme and star metrics.
411fn star_color(config: &AnnotationConfig, eccentricity: f32, fwhm: f32, median_fwhm: f32) -> [u8; 3] {
412    match config.color_scheme {
413        ColorScheme::Uniform => [0, 255, 0],
414        ColorScheme::Eccentricity => {
415            if eccentricity <= config.ecc_good {
416                [0, 255, 0]       // Green: round, good
417            } else if eccentricity <= config.ecc_warn {
418                [255, 255, 0]     // Yellow: slightly elongated
419            } else {
420                [255, 64, 64]     // Red: problem
421            }
422        }
423        ColorScheme::Fwhm => {
424            if median_fwhm <= 0.0 {
425                return [0, 255, 0];
426            }
427            let ratio = fwhm / median_fwhm;
428            if ratio < config.fwhm_good {
429                [0, 255, 0]       // Green: tight
430            } else if ratio < config.fwhm_warn {
431                [255, 255, 0]     // Yellow: somewhat bloated
432            } else {
433                [255, 64, 64]     // Red: very bloated
434            }
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::analysis::{AnalysisResult, StarMetrics};
443
444    fn dummy_result(stars: Vec<StarMetrics>) -> AnalysisResult {
445        AnalysisResult {
446            width: 100,
447            height: 100,
448            source_channels: 1,
449            background: 0.0,
450            noise: 0.0,
451            detection_threshold: 0.0,
452            stars_detected: stars.len(),
453            median_fwhm: 5.0,
454            median_eccentricity: 0.2,
455            median_snr: 50.0,
456            median_hfr: 3.0,
457            snr_db: 20.0,
458            snr_weight: 100.0,
459            psf_signal: 50.0,
460            trail_r_squared: 0.0,
461            possibly_trailed: false,
462            stars,
463        }
464    }
465
466    fn make_star(x: f32, y: f32, fwhm: f32, ecc: f32) -> StarMetrics {
467        StarMetrics {
468            x, y,
469            peak: 1000.0,
470            flux: 5000.0,
471            fwhm_x: fwhm,
472            fwhm_y: fwhm * (1.0 - ecc * ecc).sqrt(),
473            fwhm,
474            eccentricity: ecc,
475            snr: 50.0,
476            hfr: fwhm * 0.6,
477            theta: 0.0,
478        }
479    }
480
481    #[test]
482    fn test_compute_annotations_empty() {
483        let result = dummy_result(vec![]);
484        let anns = compute_annotations(&result, 100, 100, false, &AnnotationConfig::default());
485        assert!(anns.is_empty());
486    }
487
488    #[test]
489    fn test_compute_annotations_coordinate_transform() {
490        let star = make_star(50.0, 25.0, 5.0, 0.1);
491        let result = dummy_result(vec![star]);
492
493        // Same size, no flip
494        let anns = compute_annotations(&result, 100, 100, false, &AnnotationConfig::default());
495        assert_eq!(anns.len(), 1);
496        assert!((anns[0].x - 50.0).abs() < 0.1);
497        assert!((anns[0].y - 25.0).abs() < 0.1);
498
499        // Same size, flipped
500        let anns = compute_annotations(&result, 100, 100, true, &AnnotationConfig::default());
501        assert!((anns[0].y - 74.0).abs() < 0.1); // 99 - 25 = 74
502
503        // Half size (e.g. debayer)
504        let anns = compute_annotations(&result, 50, 50, false, &AnnotationConfig::default());
505        assert!((anns[0].x - 25.0).abs() < 0.1);
506        assert!((anns[0].y - 12.5).abs() < 0.1);
507    }
508
509    #[test]
510    fn test_eccentricity_colors() {
511        let config = AnnotationConfig::default();
512        assert_eq!(star_color(&config, 0.3, 5.0, 5.0), [0, 255, 0]);       // below 0.5 → green
513        assert_eq!(star_color(&config, 0.55, 5.0, 5.0), [255, 255, 0]);    // 0.51..0.6 → yellow
514        assert_eq!(star_color(&config, 0.7, 5.0, 5.0), [255, 64, 64]);     // above 0.6 → red
515    }
516
517    #[test]
518    fn test_annotate_image_smoke() {
519        let star = make_star(50.0, 50.0, 5.0, 0.2);
520        let result = dummy_result(vec![star]);
521
522        let mut image = ProcessedImage {
523            data: vec![0u8; 100 * 100 * 3],
524            width: 100,
525            height: 100,
526            is_color: false,
527            channels: 3,
528            flip_vertical: false,
529        };
530
531        annotate_image(&mut image, &result, &AnnotationConfig::default());
532
533        // At least some pixels should have been drawn (non-zero)
534        let nonzero = image.data.iter().filter(|&&b| b > 0).count();
535        assert!(nonzero > 0, "Expected some drawn pixels");
536    }
537
538    #[test]
539    fn test_create_annotation_layer_smoke() {
540        let star = make_star(50.0, 50.0, 5.0, 0.2);
541        let result = dummy_result(vec![star]);
542
543        let layer = create_annotation_layer(&result, 100, 100, false, &AnnotationConfig::default());
544        assert_eq!(layer.len(), 100 * 100 * 4);
545
546        // Check that some alpha values are 255 (drawn pixels)
547        let drawn = layer.chunks_exact(4).filter(|px| px[3] == 255).count();
548        assert!(drawn > 0, "Expected some drawn pixels in layer");
549    }
550
551    #[test]
552    fn test_bresenham_diagonal() {
553        let mut buf = vec![0u8; 10 * 10 * 3];
554        draw_line(&mut buf, 10, 10, 3, 0, 0, 9, 9, [255, 0, 0], 1);
555        // Check that the diagonal has some red pixels
556        let red_count = buf.chunks_exact(3).filter(|px| px[0] == 255).count();
557        assert!(red_count >= 10, "Expected at least 10 red pixels on diagonal");
558    }
559}