1use crate::analysis::AnalysisResult;
4use crate::types::ProcessedImage;
5
6#[derive(Clone, Copy, Debug, PartialEq)]
8pub enum ColorScheme {
9 Uniform,
11 Eccentricity,
13 Fwhm,
15}
16
17pub struct AnnotationConfig {
19 pub color_scheme: ColorScheme,
21 pub show_direction_tick: bool,
23 pub min_radius: f32,
25 pub max_radius: f32,
27 pub line_width: u8,
29 pub ecc_good: f32,
31 pub ecc_warn: f32,
34 pub fwhm_good: f32,
36 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
57pub struct StarAnnotation {
59 pub x: f32,
61 pub y: f32,
63 pub semi_major: f32,
65 pub semi_minor: f32,
67 pub theta: f32,
69 pub eccentricity: f32,
71 pub fwhm: f32,
73 pub color: [u8; 3],
75}
76
77pub 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 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 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
131pub 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
155pub 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#[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#[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#[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#[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
243fn 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
272fn 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
301fn 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
325fn 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
349fn 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 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 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
380fn 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
409fn 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] } else if eccentricity <= config.ecc_warn {
417 [255, 255, 0] } else {
419 [255, 64, 64] }
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] } else if ratio < config.fwhm_warn {
430 [255, 255, 0] } else {
432 [255, 64, 64] }
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 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 let anns = compute_annotations(&result, 100, 100, true, &AnnotationConfig::default());
505 assert!((anns[0].y - 74.0).abs() < 0.1); 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]); assert_eq!(star_color(&config, 0.55, 5.0, 5.0), [255, 255, 0]); assert_eq!(star_color(&config, 0.7, 5.0, 5.0), [255, 64, 64]); }
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 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 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; 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 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}