1#![forbid(unsafe_code)]
46#![deny(clippy::unwrap_used)]
47#![deny(clippy::expect_used)]
48#![warn(clippy::correctness)]
49#![deny(missing_docs)]
50
51pub use image::RgbaImage;
52pub use nexrad_model::data::Product;
53use nexrad_model::data::{
54 CFPMomentValue, CartesianField, DataMoment, GateStatus, MomentValue, Radial, SweepField,
55 VerticalField,
56};
57use nexrad_model::geo::{GeoExtent, RadarCoordinateSystem};
58use result::{Error, Result};
59
60mod color;
61pub use crate::color::*;
62
63mod render_result;
64pub use render_result::{PointQuery, RenderMetadata, RenderResult};
65
66pub mod result;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum Interpolation {
74 #[default]
79 Nearest,
80 Bilinear,
90}
91
92#[derive(Debug, Clone)]
112pub struct RenderOptions {
113 pub(crate) size: (usize, usize),
114 pub(crate) background: Option<[u8; 4]>,
115 pub(crate) extent: Option<GeoExtent>,
116 pub(crate) coord_system: Option<RadarCoordinateSystem>,
117 pub(crate) interpolation: Interpolation,
118}
119
120impl RenderOptions {
121 pub fn new(width: usize, height: usize) -> Self {
123 Self {
124 size: (width, height),
125 background: Some([0, 0, 0, 255]),
126 extent: None,
127 coord_system: None,
128 interpolation: Interpolation::Nearest,
129 }
130 }
131
132 pub fn transparent(mut self) -> Self {
137 self.background = None;
138 self
139 }
140
141 pub fn with_background(mut self, rgba: [u8; 4]) -> Self {
143 self.background = Some(rgba);
144 self
145 }
146
147 pub fn with_extent(mut self, extent: GeoExtent) -> Self {
152 self.extent = Some(extent);
153 self
154 }
155
156 pub fn with_coord_system(mut self, coord_system: RadarCoordinateSystem) -> Self {
161 self.coord_system = Some(coord_system);
162 self
163 }
164
165 pub fn native_for(field: &SweepField) -> Self {
171 let size = field.gate_count() * 2;
172 Self::new(size, size)
173 }
174
175 pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self {
180 self.interpolation = interpolation;
181 self
182 }
183
184 pub fn bilinear(mut self) -> Self {
188 self.interpolation = Interpolation::Bilinear;
189 self
190 }
191
192 pub fn size(&self) -> (usize, usize) {
194 self.size
195 }
196
197 pub fn background(&self) -> Option<[u8; 4]> {
199 self.background
200 }
201
202 pub fn extent(&self) -> Option<&GeoExtent> {
207 self.extent.as_ref()
208 }
209
210 pub fn coord_system(&self) -> Option<&RadarCoordinateSystem> {
215 self.coord_system.as_ref()
216 }
217
218 pub fn interpolation(&self) -> Interpolation {
223 self.interpolation
224 }
225}
226
227pub fn render_radials(
264 radials: &[Radial],
265 product: Product,
266 scale: &DiscreteColorScale,
267 options: &RenderOptions,
268) -> Result<RgbaImage> {
269 let (width, height) = options.size;
270 let mut buffer = vec![0u8; width * height * 4];
271
272 if let Some(bg) = options.background {
274 for pixel in buffer.chunks_exact_mut(4) {
275 pixel.copy_from_slice(&bg);
276 }
277 }
278
279 if radials.is_empty() {
280 return Err(Error::NoRadials);
281 }
282
283 let (min_val, max_val) = product.value_range();
285 let lut = ColorLookupTable::from_scale(scale, min_val, max_val, 256);
286
287 let first_radial = &radials[0];
289 let gate_params = get_gate_params(product, first_radial).ok_or(Error::ProductNotFound)?;
290 let first_gate_km = gate_params.first_gate_km;
291 let gate_interval_km = gate_params.gate_interval_km;
292 let gate_count = gate_params.gate_count;
293 let radar_range_km = first_gate_km + gate_count as f64 * gate_interval_km;
294
295 let mut radial_data: Vec<(f32, Vec<Option<f32>>)> = Vec::with_capacity(radials.len());
297 for radial in radials {
298 let azimuth = radial.azimuth_angle_degrees();
299 if let Some(values) = get_radial_float_values(product, radial) {
300 radial_data.push((azimuth, values));
301 }
302 }
303
304 radial_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
306
307 let sorted_azimuths: Vec<f32> = radial_data.iter().map(|(az, _)| *az).collect();
309
310 let azimuth_spacing = first_radial.azimuth_spacing_degrees();
313 let max_azimuth_gap = azimuth_spacing * 1.5;
314
315 let center_x = width as f64 / 2.0;
316 let center_y = height as f64 / 2.0;
317 let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
318
319 for y in 0..height {
321 let dy = y as f64 - center_y;
322
323 for x in 0..width {
324 let dx = x as f64 - center_x;
325
326 let distance_pixels = (dx * dx + dy * dy).sqrt();
328 let distance_km = distance_pixels / scale_factor;
329
330 if distance_km < first_gate_km || distance_km >= radar_range_km {
332 continue;
333 }
334
335 let azimuth_rad = dx.atan2(-dy);
337 let azimuth_deg = (azimuth_rad.to_degrees() + 360.0) % 360.0;
338
339 let (radial_idx, angular_distance) =
341 find_closest_radial(&sorted_azimuths, azimuth_deg as f32);
342
343 if angular_distance > max_azimuth_gap {
345 continue;
346 }
347
348 let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
350 if gate_index >= gate_count {
351 continue;
352 }
353
354 let (_, ref values) = radial_data[radial_idx];
356 if let Some(Some(value)) = values.get(gate_index) {
357 let color = lut.color(*value);
358 let pixel_index = (y * width + x) * 4;
359 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
360 }
361 }
362 }
363
364 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)
366}
367
368pub fn render_radials_default(
400 radials: &[Radial],
401 product: Product,
402 options: &RenderOptions,
403) -> Result<RgbaImage> {
404 let scale = default_scale(product);
405 render_radials(radials, product, &scale, options)
406}
407
408pub fn render_sweep(
436 field: &SweepField,
437 color_scale: &ColorScale,
438 options: &RenderOptions,
439) -> Result<RenderResult> {
440 let (width, height) = options.size;
441 let mut buffer = vec![0u8; width * height * 4];
442
443 if let Some(bg) = options.background {
445 for pixel in buffer.chunks_exact_mut(4) {
446 pixel.copy_from_slice(&bg);
447 }
448 }
449
450 if field.azimuth_count() == 0 {
451 return Err(Error::NoRadials);
452 }
453
454 let first_gate_km = field.first_gate_range_km();
455 let gate_interval_km = field.gate_interval_km();
456 let gate_count = field.gate_count();
457 let radar_range_km = field.max_range_km();
458
459 let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
461 let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
462
463 let azimuth_spacing = field.azimuth_spacing_degrees();
465 let max_azimuth_gap = azimuth_spacing * 1.5;
466
467 let center_x = width as f64 / 2.0;
468 let center_y = height as f64 / 2.0;
469 let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
470
471 let use_bilinear = options.interpolation == Interpolation::Bilinear;
473
474 for y in 0..height {
475 let dy = y as f64 - center_y;
476
477 for x in 0..width {
478 let dx = x as f64 - center_x;
479
480 let distance_pixels = (dx * dx + dy * dy).sqrt();
481 let distance_km = distance_pixels / scale_factor;
482
483 if distance_km < first_gate_km || distance_km >= radar_range_km {
484 continue;
485 }
486
487 let azimuth_rad = dx.atan2(-dy);
488 let azimuth_deg = ((azimuth_rad.to_degrees() + 360.0) % 360.0) as f32;
489
490 if use_bilinear {
492 if let Some(val) =
493 sample_sweep_bilinear(field, azimuth_deg, distance_km, max_azimuth_gap)
494 {
495 let color = lut.color(val);
496 let pixel_index = (y * width + x) * 4;
497 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
498 continue;
499 }
500 }
501
502 let (radial_idx, angular_distance) = find_closest_radial(field.azimuths(), azimuth_deg);
504
505 if angular_distance > max_azimuth_gap {
506 continue;
507 }
508
509 let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
510 if gate_index >= gate_count {
511 continue;
512 }
513
514 let (val, status) = field.get(radial_idx, gate_index);
515 if status == GateStatus::Valid {
516 let color = lut.color(val);
517 let pixel_index = (y * width + x) * 4;
518 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
519 }
520 }
521 }
522
523 let image =
524 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
525
526 let geo_extent = options.extent.or_else(|| {
527 options
528 .coord_system
529 .as_ref()
530 .map(|cs| cs.sweep_extent(radar_range_km))
531 });
532
533 let metadata = RenderMetadata {
534 width: width as u32,
535 height: height as u32,
536 center_pixel: (center_x, center_y),
537 pixels_per_km: scale_factor,
538 max_range_km: radar_range_km,
539 elevation_degrees: Some(field.elevation_degrees()),
540 geo_extent,
541 coord_system: options.coord_system.clone(),
542 };
543
544 Ok(RenderResult::new(image, metadata))
545}
546
547pub fn render_cartesian(
558 field: &CartesianField,
559 color_scale: &ColorScale,
560 options: &RenderOptions,
561) -> Result<RenderResult> {
562 let (width, height) = options.size;
563 let mut buffer = vec![0u8; width * height * 4];
564
565 if let Some(bg) = options.background {
566 for pixel in buffer.chunks_exact_mut(4) {
567 pixel.copy_from_slice(&bg);
568 }
569 }
570
571 let field_width = field.width();
572 let field_height = field.height();
573
574 if field_width == 0 || field_height == 0 {
575 return Err(Error::NoRadials);
576 }
577
578 let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
580 let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
581
582 let use_bilinear = options.interpolation == Interpolation::Bilinear;
584 let max_row = field_height - 1;
585 let max_col = field_width - 1;
586
587 for y in 0..height {
588 let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
589 let row_f = row_f.max(0.0);
590
591 for x in 0..width {
592 let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
593 let col_f = col_f.max(0.0);
594
595 if use_bilinear {
597 if let Some(val) =
598 sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
599 {
600 let color = lut.color(val);
601 let pixel_index = (y * width + x) * 4;
602 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
603 continue;
604 }
605 }
606
607 let field_row = (row_f + 0.5) as usize;
609 let field_row = field_row.min(max_row);
610 let field_col = (col_f + 0.5) as usize;
611 let field_col = field_col.min(max_col);
612
613 let (val, status) = field.get(field_row, field_col);
614 if status == GateStatus::Valid {
615 let color = lut.color(val);
616 let pixel_index = (y * width + x) * 4;
617 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
618 }
619 }
620 }
621
622 let image =
623 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
624
625 let metadata = RenderMetadata {
626 width: width as u32,
627 height: height as u32,
628 center_pixel: (width as f64 / 2.0, height as f64 / 2.0),
629 pixels_per_km: 0.0, max_range_km: 0.0,
631 elevation_degrees: None,
632 geo_extent: Some(*field.extent()),
633 coord_system: options.coord_system.clone(),
634 };
635
636 Ok(RenderResult::new(image, metadata))
637}
638
639pub fn render_vertical(
650 field: &VerticalField,
651 color_scale: &ColorScale,
652 options: &RenderOptions,
653) -> Result<RenderResult> {
654 let (width, height) = options.size;
655 let mut buffer = vec![0u8; width * height * 4];
656
657 if let Some(bg) = options.background {
658 for pixel in buffer.chunks_exact_mut(4) {
659 pixel.copy_from_slice(&bg);
660 }
661 }
662
663 let field_width = field.width();
664 let field_height = field.height();
665
666 if field_width == 0 || field_height == 0 {
667 return Err(Error::NoRadials);
668 }
669
670 let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
672 let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
673
674 let use_bilinear = options.interpolation == Interpolation::Bilinear;
676 let max_row = field_height - 1;
677 let max_col = field_width - 1;
678
679 for y in 0..height {
680 let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
681 let row_f = row_f.max(0.0);
682
683 for x in 0..width {
684 let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
685 let col_f = col_f.max(0.0);
686
687 if use_bilinear {
689 if let Some(val) =
690 sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
691 {
692 let color = lut.color(val);
693 let pixel_index = (y * width + x) * 4;
694 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
695 continue;
696 }
697 }
698
699 let field_row = (row_f + 0.5) as usize;
701 let field_row = field_row.min(max_row);
702 let field_col = (col_f + 0.5) as usize;
703 let field_col = field_col.min(max_col);
704
705 let (val, status) = field.get(field_row, field_col);
706 if status == GateStatus::Valid {
707 let color = lut.color(val);
708 let pixel_index = (y * width + x) * 4;
709 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
710 }
711 }
712 }
713
714 let image =
715 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
716
717 let (d_min, d_max) = field.distance_range_km();
718 let max_range = d_max - d_min;
719
720 let metadata = RenderMetadata {
721 width: width as u32,
722 height: height as u32,
723 center_pixel: (0.0, height as f64 / 2.0), pixels_per_km: width as f64 / max_range,
725 max_range_km: max_range,
726 elevation_degrees: None,
727 geo_extent: None,
728 coord_system: options.coord_system.clone(),
729 };
730
731 Ok(RenderResult::new(image, metadata))
732}
733
734pub fn default_scale(product: Product) -> DiscreteColorScale {
749 match product {
750 Product::Reflectivity => nws_reflectivity_scale(),
751 Product::Velocity => velocity_scale(),
752 Product::SpectrumWidth => spectrum_width_scale(),
753 Product::DifferentialReflectivity => differential_reflectivity_scale(),
754 Product::DifferentialPhase => differential_phase_scale(),
755 Product::CorrelationCoefficient => correlation_coefficient_scale(),
756 Product::ClutterFilterPower => clutter_filter_power_scale(),
757 }
758}
759
760pub fn default_color_scale(product: Product) -> ColorScale {
765 default_scale(product).into()
766}
767
768#[inline]
773fn find_closest_radial(sorted_azimuths: &[f32], azimuth: f32) -> (usize, f32) {
774 let len = sorted_azimuths.len();
775 if len == 0 {
776 return (0, f32::MAX);
777 }
778
779 let pos = sorted_azimuths
781 .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
782 .unwrap_or_else(|i| i);
783
784 if pos == 0 {
785 let dist_to_first = (sorted_azimuths[0] - azimuth).abs();
787 let dist_to_last = 360.0 - sorted_azimuths[len - 1] + azimuth;
788 if dist_to_last < dist_to_first {
789 return (len - 1, dist_to_last);
790 }
791 return (0, dist_to_first);
792 }
793
794 if pos >= len {
795 let dist_to_last = (azimuth - sorted_azimuths[len - 1]).abs();
797 let dist_to_first = 360.0 - azimuth + sorted_azimuths[0];
798 if dist_to_first < dist_to_last {
799 return (0, dist_to_first);
800 }
801 return (len - 1, dist_to_last);
802 }
803
804 let dist_to_prev = (azimuth - sorted_azimuths[pos - 1]).abs();
806 let dist_to_curr = (sorted_azimuths[pos] - azimuth).abs();
807
808 if dist_to_prev <= dist_to_curr {
809 (pos - 1, dist_to_prev)
810 } else {
811 (pos, dist_to_curr)
812 }
813}
814
815#[inline]
825fn find_bracketing_radials(
826 sorted_azimuths: &[f32],
827 azimuth: f32,
828 max_gap: f32,
829) -> Option<(usize, usize, f32)> {
830 let len = sorted_azimuths.len();
831 if len < 2 {
832 return None;
833 }
834
835 let pos = sorted_azimuths
836 .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
837 .unwrap_or_else(|i| i);
838
839 let (lo_idx, hi_idx) = if pos == 0 || pos >= len {
842 (len - 1, 0)
844 } else {
845 (pos - 1, pos)
846 };
847
848 let lo_az = sorted_azimuths[lo_idx];
849 let hi_az = sorted_azimuths[hi_idx];
850
851 let span = if hi_az >= lo_az {
853 hi_az - lo_az
854 } else {
855 360.0 - lo_az + hi_az
856 };
857
858 if span > max_gap || span == 0.0 {
859 return None;
860 }
861
862 let offset = if azimuth >= lo_az {
864 azimuth - lo_az
865 } else {
866 360.0 - lo_az + azimuth
867 };
868
869 let frac = (offset / span).clamp(0.0, 1.0);
870 Some((lo_idx, hi_idx, frac))
871}
872
873#[inline]
879fn sample_sweep_bilinear(
880 field: &SweepField,
881 azimuth_deg: f32,
882 distance_km: f64,
883 max_azimuth_gap: f32,
884) -> Option<f32> {
885 let gate_count = field.gate_count();
886 let first_gate_km = field.first_gate_range_km();
887 let gate_interval_km = field.gate_interval_km();
888
889 let (az_lo, az_hi, az_frac) =
891 find_bracketing_radials(field.azimuths(), azimuth_deg, max_azimuth_gap)?;
892
893 let gate_f = (distance_km - first_gate_km) / gate_interval_km;
895 let g_lo = gate_f as usize;
896 let g_hi = g_lo + 1;
897
898 if g_hi >= gate_count {
899 return None;
900 }
901
902 let g_frac = (gate_f - g_lo as f64) as f32;
903
904 let (v00, s00) = field.get(az_lo, g_lo);
906 let (v01, s01) = field.get(az_lo, g_hi);
907 let (v10, s10) = field.get(az_hi, g_lo);
908 let (v11, s11) = field.get(az_hi, g_hi);
909
910 if s00 != GateStatus::Valid
911 || s01 != GateStatus::Valid
912 || s10 != GateStatus::Valid
913 || s11 != GateStatus::Valid
914 {
915 return None;
916 }
917
918 let v_lo = v00 + (v01 - v00) * g_frac;
920 let v_hi = v10 + (v11 - v10) * g_frac;
921 Some(v_lo + (v_hi - v_lo) * az_frac)
922}
923
924#[inline]
929fn sample_grid_bilinear(
930 row_f: f64,
931 col_f: f64,
932 max_row: usize,
933 max_col: usize,
934 get_fn: impl Fn(usize, usize) -> (f32, GateStatus),
935) -> Option<f32> {
936 let r0 = row_f as usize;
937 let c0 = col_f as usize;
938 let r1 = (r0 + 1).min(max_row);
939 let c1 = (c0 + 1).min(max_col);
940
941 let rf = (row_f - r0 as f64) as f32;
942 let cf = (col_f - c0 as f64) as f32;
943
944 let (v00, s00) = get_fn(r0, c0);
945 let (v01, s01) = get_fn(r0, c1);
946 let (v10, s10) = get_fn(r1, c0);
947 let (v11, s11) = get_fn(r1, c1);
948
949 if s00 != GateStatus::Valid
950 || s01 != GateStatus::Valid
951 || s10 != GateStatus::Valid
952 || s11 != GateStatus::Valid
953 {
954 return None;
955 }
956
957 let v_top = v00 + (v01 - v00) * cf;
958 let v_bot = v10 + (v11 - v10) * cf;
959 Some(v_top + (v_bot - v_top) * rf)
960}
961
962struct GateParams {
964 first_gate_km: f64,
965 gate_interval_km: f64,
966 gate_count: usize,
967}
968
969fn get_gate_params(product: Product, radial: &Radial) -> Option<GateParams> {
971 fn extract(m: &impl DataMoment) -> GateParams {
972 GateParams {
973 first_gate_km: m.first_gate_range_km(),
974 gate_interval_km: m.gate_interval_km(),
975 gate_count: m.gate_count() as usize,
976 }
977 }
978 if let Some(moment) = product.moment_data(radial) {
979 return Some(extract(moment));
980 }
981 if let Some(cfp) = product.cfp_moment_data(radial) {
982 return Some(extract(cfp));
983 }
984 None
985}
986
987fn get_radial_float_values(product: Product, radial: &Radial) -> Option<Vec<Option<f32>>> {
991 if let Some(moment) = product.moment_data(radial) {
992 return Some(
993 moment
994 .iter()
995 .map(|v| match v {
996 MomentValue::Value(f) => Some(f),
997 _ => None,
998 })
999 .collect(),
1000 );
1001 }
1002
1003 if let Some(cfp) = product.cfp_moment_data(radial) {
1004 return Some(
1005 cfp.iter()
1006 .map(|v| match v {
1007 CFPMomentValue::Value(f) => Some(f),
1008 CFPMomentValue::Status(_) => None,
1009 })
1010 .collect(),
1011 );
1012 }
1013
1014 None
1015}