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, 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
132pub 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
156pub 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#[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#[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#[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#[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
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) {
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
273fn 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
302fn 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
326fn 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
350fn 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 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 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
381fn 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
410fn 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] } else if eccentricity <= config.ecc_warn {
418 [255, 255, 0] } else {
420 [255, 64, 64] }
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] } else if ratio < config.fwhm_warn {
431 [255, 255, 0] } else {
433 [255, 64, 64] }
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 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 let anns = compute_annotations(&result, 100, 100, true, &AnnotationConfig::default());
501 assert!((anns[0].y - 74.0).abs() < 0.1); 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]); 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]); }
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 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 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 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}