#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![warn(clippy::correctness)]
#![deny(missing_docs)]
pub use image::RgbaImage;
pub use nexrad_model::data::Product;
use nexrad_model::data::{
CFPMomentValue, CartesianField, DataMoment, GateStatus, MomentValue, Radial, SweepField,
VerticalField,
};
use nexrad_model::geo::{GeoExtent, RadarCoordinateSystem};
use result::{Error, Result};
mod color;
pub use crate::color::*;
mod render_result;
pub use render_result::{PointQuery, RenderMetadata, RenderResult};
pub mod result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Interpolation {
#[default]
Nearest,
Bilinear,
}
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub(crate) size: (usize, usize),
pub(crate) background: Option<[u8; 4]>,
pub(crate) extent: Option<GeoExtent>,
pub(crate) coord_system: Option<RadarCoordinateSystem>,
pub(crate) interpolation: Interpolation,
}
impl RenderOptions {
pub fn new(width: usize, height: usize) -> Self {
Self {
size: (width, height),
background: Some([0, 0, 0, 255]),
extent: None,
coord_system: None,
interpolation: Interpolation::Nearest,
}
}
pub fn transparent(mut self) -> Self {
self.background = None;
self
}
pub fn with_background(mut self, rgba: [u8; 4]) -> Self {
self.background = Some(rgba);
self
}
pub fn with_extent(mut self, extent: GeoExtent) -> Self {
self.extent = Some(extent);
self
}
pub fn with_coord_system(mut self, coord_system: RadarCoordinateSystem) -> Self {
self.coord_system = Some(coord_system);
self
}
pub fn native_for(field: &SweepField) -> Self {
let size = field.gate_count() * 2;
Self::new(size, size)
}
pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self {
self.interpolation = interpolation;
self
}
pub fn bilinear(mut self) -> Self {
self.interpolation = Interpolation::Bilinear;
self
}
pub fn size(&self) -> (usize, usize) {
self.size
}
pub fn background(&self) -> Option<[u8; 4]> {
self.background
}
pub fn extent(&self) -> Option<&GeoExtent> {
self.extent.as_ref()
}
pub fn coord_system(&self) -> Option<&RadarCoordinateSystem> {
self.coord_system.as_ref()
}
pub fn interpolation(&self) -> Interpolation {
self.interpolation
}
}
pub fn render_radials(
radials: &[Radial],
product: Product,
scale: &DiscreteColorScale,
options: &RenderOptions,
) -> Result<RgbaImage> {
let (width, height) = options.size;
let mut buffer = vec![0u8; width * height * 4];
if let Some(bg) = options.background {
for pixel in buffer.chunks_exact_mut(4) {
pixel.copy_from_slice(&bg);
}
}
if radials.is_empty() {
return Err(Error::NoRadials);
}
let (min_val, max_val) = product.value_range();
let lut = ColorLookupTable::from_scale(scale, min_val, max_val, 256);
let first_radial = &radials[0];
let gate_params = get_gate_params(product, first_radial).ok_or(Error::ProductNotFound)?;
let first_gate_km = gate_params.first_gate_km;
let gate_interval_km = gate_params.gate_interval_km;
let gate_count = gate_params.gate_count;
let radar_range_km = first_gate_km + gate_count as f64 * gate_interval_km;
let mut radial_data: Vec<(f32, Vec<Option<f32>>)> = Vec::with_capacity(radials.len());
for radial in radials {
let azimuth = radial.azimuth_angle_degrees();
if let Some(values) = get_radial_float_values(product, radial) {
radial_data.push((azimuth, values));
}
}
radial_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let sorted_azimuths: Vec<f32> = radial_data.iter().map(|(az, _)| *az).collect();
let azimuth_spacing = first_radial.azimuth_spacing_degrees();
let max_azimuth_gap = azimuth_spacing * 1.5;
let center_x = width as f64 / 2.0;
let center_y = height as f64 / 2.0;
let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
for y in 0..height {
let dy = y as f64 - center_y;
for x in 0..width {
let dx = x as f64 - center_x;
let distance_pixels = (dx * dx + dy * dy).sqrt();
let distance_km = distance_pixels / scale_factor;
if distance_km < first_gate_km || distance_km >= radar_range_km {
continue;
}
let azimuth_rad = dx.atan2(-dy);
let azimuth_deg = (azimuth_rad.to_degrees() + 360.0) % 360.0;
let (radial_idx, angular_distance) =
find_closest_radial(&sorted_azimuths, azimuth_deg as f32);
if angular_distance > max_azimuth_gap {
continue;
}
let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
if gate_index >= gate_count {
continue;
}
let (_, ref values) = radial_data[radial_idx];
if let Some(Some(value)) = values.get(gate_index) {
let color = lut.color(*value);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
}
}
}
RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)
}
pub fn render_radials_default(
radials: &[Radial],
product: Product,
options: &RenderOptions,
) -> Result<RgbaImage> {
let scale = default_scale(product);
render_radials(radials, product, &scale, options)
}
pub fn render_sweep(
field: &SweepField,
color_scale: &ColorScale,
options: &RenderOptions,
) -> Result<RenderResult> {
let (width, height) = options.size;
let mut buffer = vec![0u8; width * height * 4];
if let Some(bg) = options.background {
for pixel in buffer.chunks_exact_mut(4) {
pixel.copy_from_slice(&bg);
}
}
if field.azimuth_count() == 0 {
return Err(Error::NoRadials);
}
let first_gate_km = field.first_gate_range_km();
let gate_interval_km = field.gate_interval_km();
let gate_count = field.gate_count();
let radar_range_km = field.max_range_km();
let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
let azimuth_spacing = field.azimuth_spacing_degrees();
let max_azimuth_gap = azimuth_spacing * 1.5;
let center_x = width as f64 / 2.0;
let center_y = height as f64 / 2.0;
let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
let use_bilinear = options.interpolation == Interpolation::Bilinear;
for y in 0..height {
let dy = y as f64 - center_y;
for x in 0..width {
let dx = x as f64 - center_x;
let distance_pixels = (dx * dx + dy * dy).sqrt();
let distance_km = distance_pixels / scale_factor;
if distance_km < first_gate_km || distance_km >= radar_range_km {
continue;
}
let azimuth_rad = dx.atan2(-dy);
let azimuth_deg = ((azimuth_rad.to_degrees() + 360.0) % 360.0) as f32;
if use_bilinear {
if let Some(val) =
sample_sweep_bilinear(field, azimuth_deg, distance_km, max_azimuth_gap)
{
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
continue;
}
}
let (radial_idx, angular_distance) = find_closest_radial(field.azimuths(), azimuth_deg);
if angular_distance > max_azimuth_gap {
continue;
}
let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
if gate_index >= gate_count {
continue;
}
let (val, status) = field.get(radial_idx, gate_index);
if status == GateStatus::Valid {
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
}
}
}
let image =
RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
let geo_extent = options.extent.or_else(|| {
options
.coord_system
.as_ref()
.map(|cs| cs.sweep_extent(radar_range_km))
});
let metadata = RenderMetadata {
width: width as u32,
height: height as u32,
center_pixel: (center_x, center_y),
pixels_per_km: scale_factor,
max_range_km: radar_range_km,
elevation_degrees: Some(field.elevation_degrees()),
geo_extent,
coord_system: options.coord_system.clone(),
};
Ok(RenderResult::new(image, metadata))
}
pub fn render_cartesian(
field: &CartesianField,
color_scale: &ColorScale,
options: &RenderOptions,
) -> Result<RenderResult> {
let (width, height) = options.size;
let mut buffer = vec![0u8; width * height * 4];
if let Some(bg) = options.background {
for pixel in buffer.chunks_exact_mut(4) {
pixel.copy_from_slice(&bg);
}
}
let field_width = field.width();
let field_height = field.height();
if field_width == 0 || field_height == 0 {
return Err(Error::NoRadials);
}
let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
let use_bilinear = options.interpolation == Interpolation::Bilinear;
let max_row = field_height - 1;
let max_col = field_width - 1;
for y in 0..height {
let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
let row_f = row_f.max(0.0);
for x in 0..width {
let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
let col_f = col_f.max(0.0);
if use_bilinear {
if let Some(val) =
sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
{
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
continue;
}
}
let field_row = (row_f + 0.5) as usize;
let field_row = field_row.min(max_row);
let field_col = (col_f + 0.5) as usize;
let field_col = field_col.min(max_col);
let (val, status) = field.get(field_row, field_col);
if status == GateStatus::Valid {
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
}
}
}
let image =
RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
let metadata = RenderMetadata {
width: width as u32,
height: height as u32,
center_pixel: (width as f64 / 2.0, height as f64 / 2.0),
pixels_per_km: 0.0, max_range_km: 0.0,
elevation_degrees: None,
geo_extent: Some(*field.extent()),
coord_system: options.coord_system.clone(),
};
Ok(RenderResult::new(image, metadata))
}
pub fn render_vertical(
field: &VerticalField,
color_scale: &ColorScale,
options: &RenderOptions,
) -> Result<RenderResult> {
let (width, height) = options.size;
let mut buffer = vec![0u8; width * height * 4];
if let Some(bg) = options.background {
for pixel in buffer.chunks_exact_mut(4) {
pixel.copy_from_slice(&bg);
}
}
let field_width = field.width();
let field_height = field.height();
if field_width == 0 || field_height == 0 {
return Err(Error::NoRadials);
}
let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
let use_bilinear = options.interpolation == Interpolation::Bilinear;
let max_row = field_height - 1;
let max_col = field_width - 1;
for y in 0..height {
let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
let row_f = row_f.max(0.0);
for x in 0..width {
let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
let col_f = col_f.max(0.0);
if use_bilinear {
if let Some(val) =
sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
{
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
continue;
}
}
let field_row = (row_f + 0.5) as usize;
let field_row = field_row.min(max_row);
let field_col = (col_f + 0.5) as usize;
let field_col = field_col.min(max_col);
let (val, status) = field.get(field_row, field_col);
if status == GateStatus::Valid {
let color = lut.color(val);
let pixel_index = (y * width + x) * 4;
buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
}
}
}
let image =
RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
let (d_min, d_max) = field.distance_range_km();
let max_range = d_max - d_min;
let metadata = RenderMetadata {
width: width as u32,
height: height as u32,
center_pixel: (0.0, height as f64 / 2.0), pixels_per_km: width as f64 / max_range,
max_range_km: max_range,
elevation_degrees: None,
geo_extent: None,
coord_system: options.coord_system.clone(),
};
Ok(RenderResult::new(image, metadata))
}
pub fn default_scale(product: Product) -> DiscreteColorScale {
match product {
Product::Reflectivity => nws_reflectivity_scale(),
Product::Velocity => velocity_scale(),
Product::SpectrumWidth => spectrum_width_scale(),
Product::DifferentialReflectivity => differential_reflectivity_scale(),
Product::DifferentialPhase => differential_phase_scale(),
Product::CorrelationCoefficient => correlation_coefficient_scale(),
Product::ClutterFilterPower => clutter_filter_power_scale(),
}
}
pub fn default_color_scale(product: Product) -> ColorScale {
default_scale(product).into()
}
#[inline]
fn find_closest_radial(sorted_azimuths: &[f32], azimuth: f32) -> (usize, f32) {
let len = sorted_azimuths.len();
if len == 0 {
return (0, f32::MAX);
}
let pos = sorted_azimuths
.binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or_else(|i| i);
if pos == 0 {
let dist_to_first = (sorted_azimuths[0] - azimuth).abs();
let dist_to_last = 360.0 - sorted_azimuths[len - 1] + azimuth;
if dist_to_last < dist_to_first {
return (len - 1, dist_to_last);
}
return (0, dist_to_first);
}
if pos >= len {
let dist_to_last = (azimuth - sorted_azimuths[len - 1]).abs();
let dist_to_first = 360.0 - azimuth + sorted_azimuths[0];
if dist_to_first < dist_to_last {
return (0, dist_to_first);
}
return (len - 1, dist_to_last);
}
let dist_to_prev = (azimuth - sorted_azimuths[pos - 1]).abs();
let dist_to_curr = (sorted_azimuths[pos] - azimuth).abs();
if dist_to_prev <= dist_to_curr {
(pos - 1, dist_to_prev)
} else {
(pos, dist_to_curr)
}
}
#[inline]
fn find_bracketing_radials(
sorted_azimuths: &[f32],
azimuth: f32,
max_gap: f32,
) -> Option<(usize, usize, f32)> {
let len = sorted_azimuths.len();
if len < 2 {
return None;
}
let pos = sorted_azimuths
.binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or_else(|i| i);
let (lo_idx, hi_idx) = if pos == 0 || pos >= len {
(len - 1, 0)
} else {
(pos - 1, pos)
};
let lo_az = sorted_azimuths[lo_idx];
let hi_az = sorted_azimuths[hi_idx];
let span = if hi_az >= lo_az {
hi_az - lo_az
} else {
360.0 - lo_az + hi_az
};
if span > max_gap || span == 0.0 {
return None;
}
let offset = if azimuth >= lo_az {
azimuth - lo_az
} else {
360.0 - lo_az + azimuth
};
let frac = (offset / span).clamp(0.0, 1.0);
Some((lo_idx, hi_idx, frac))
}
#[inline]
fn sample_sweep_bilinear(
field: &SweepField,
azimuth_deg: f32,
distance_km: f64,
max_azimuth_gap: f32,
) -> Option<f32> {
let gate_count = field.gate_count();
let first_gate_km = field.first_gate_range_km();
let gate_interval_km = field.gate_interval_km();
let (az_lo, az_hi, az_frac) =
find_bracketing_radials(field.azimuths(), azimuth_deg, max_azimuth_gap)?;
let gate_f = (distance_km - first_gate_km) / gate_interval_km;
let g_lo = gate_f as usize;
let g_hi = g_lo + 1;
if g_hi >= gate_count {
return None;
}
let g_frac = (gate_f - g_lo as f64) as f32;
let (v00, s00) = field.get(az_lo, g_lo);
let (v01, s01) = field.get(az_lo, g_hi);
let (v10, s10) = field.get(az_hi, g_lo);
let (v11, s11) = field.get(az_hi, g_hi);
if s00 != GateStatus::Valid
|| s01 != GateStatus::Valid
|| s10 != GateStatus::Valid
|| s11 != GateStatus::Valid
{
return None;
}
let v_lo = v00 + (v01 - v00) * g_frac;
let v_hi = v10 + (v11 - v10) * g_frac;
Some(v_lo + (v_hi - v_lo) * az_frac)
}
#[inline]
fn sample_grid_bilinear(
row_f: f64,
col_f: f64,
max_row: usize,
max_col: usize,
get_fn: impl Fn(usize, usize) -> (f32, GateStatus),
) -> Option<f32> {
let r0 = row_f as usize;
let c0 = col_f as usize;
let r1 = (r0 + 1).min(max_row);
let c1 = (c0 + 1).min(max_col);
let rf = (row_f - r0 as f64) as f32;
let cf = (col_f - c0 as f64) as f32;
let (v00, s00) = get_fn(r0, c0);
let (v01, s01) = get_fn(r0, c1);
let (v10, s10) = get_fn(r1, c0);
let (v11, s11) = get_fn(r1, c1);
if s00 != GateStatus::Valid
|| s01 != GateStatus::Valid
|| s10 != GateStatus::Valid
|| s11 != GateStatus::Valid
{
return None;
}
let v_top = v00 + (v01 - v00) * cf;
let v_bot = v10 + (v11 - v10) * cf;
Some(v_top + (v_bot - v_top) * rf)
}
struct GateParams {
first_gate_km: f64,
gate_interval_km: f64,
gate_count: usize,
}
fn get_gate_params(product: Product, radial: &Radial) -> Option<GateParams> {
fn extract(m: &impl DataMoment) -> GateParams {
GateParams {
first_gate_km: m.first_gate_range_km(),
gate_interval_km: m.gate_interval_km(),
gate_count: m.gate_count() as usize,
}
}
if let Some(moment) = product.moment_data(radial) {
return Some(extract(moment));
}
if let Some(cfp) = product.cfp_moment_data(radial) {
return Some(extract(cfp));
}
None
}
fn get_radial_float_values(product: Product, radial: &Radial) -> Option<Vec<Option<f32>>> {
if let Some(moment) = product.moment_data(radial) {
return Some(
moment
.iter()
.map(|v| match v {
MomentValue::Value(f) => Some(f),
_ => None,
})
.collect(),
);
}
if let Some(cfp) = product.cfp_moment_data(radial) {
return Some(
cfp.iter()
.map(|v| match v {
CFPMomentValue::Value(f) => Some(f),
CFPMomentValue::Status(_) => None,
})
.collect(),
);
}
None
}