diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..95f68be --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,501 @@ +//! BGRx → RGB24 conversion with optional downscaling (box filter for integer factors, +//! area averaging otherwise). + +/// Fixed-point unit of the area-average weights. +const WEIGHT_BITS: u32 = 16; +const WEIGHT_ONE: u32 = 1 << WEIGHT_BITS; + +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum ConvertError { + #[error("empty frame {width}x{height}")] + Empty { width: usize, height: usize }, + #[error("stride {stride} is smaller than width*4 ({width}*4)")] + StrideTooSmall { stride: usize, width: usize }, + #[error("{width}x{height} frame with stride {stride} at offset {offset} does not fit into {mapped} mapped bytes")] + OutOfBounds { + width: usize, + height: usize, + stride: usize, + offset: usize, + mapped: usize, + }, + #[error("chunk size {chunk_size} is smaller than stride*height ({needed})")] + ChunkTooSmall { chunk_size: usize, needed: usize }, +} + +/// A bounds-checked view of a packed BGRx frame. +#[derive(Debug, Clone, Copy)] +pub struct BgrxFrame<'a> { + /// Starts at the first pixel of row 0. + data: &'a [u8], + width: usize, + height: usize, + stride: usize, +} + +impl<'a> BgrxFrame<'a> { + /// Validates the frame geometry against the mapped memory and the chunk size. + pub fn new( + mapped: &'a [u8], + offset: usize, + chunk_size: usize, + stride: usize, + width: usize, + height: usize, + ) -> Result { + if width == 0 || height == 0 { + return Err(ConvertError::Empty { width, height }); + } + let row_bytes = width + .checked_mul(4) + .ok_or(ConvertError::StrideTooSmall { stride, width })?; + if stride < row_bytes { + return Err(ConvertError::StrideTooSmall { stride, width }); + } + let out_of_bounds = ConvertError::OutOfBounds { + width, + height, + stride, + offset, + mapped: mapped.len(), + }; + let end = stride + .checked_mul(height - 1) + .and_then(|v| v.checked_add(row_bytes)) + .and_then(|v| v.checked_add(offset)) + .ok_or_else(|| out_of_bounds.clone())?; + if end > mapped.len() { + return Err(out_of_bounds); + } + match stride.checked_mul(height) { + Some(needed) if chunk_size >= needed => {} + needed => { + return Err(ConvertError::ChunkTooSmall { + chunk_size, + needed: needed.unwrap_or(usize::MAX), + }) + } + } + Ok(Self { + data: &mapped[offset..end], + width, + height, + stride, + }) + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn height(&self) -> usize { + self.height + } + + fn row(&self, y: usize) -> &'a [u8] { + let start = y * self.stride; + &self.data[start..start + self.width * 4] + } +} + +/// Largest size that fits into `max_w`x`max_h` with the aspect ratio of `src_w`x`src_h`. +/// Never upscales. +pub fn fit_within(src_w: u32, src_h: u32, max_w: u32, max_h: u32) -> (u32, u32) { + if src_w <= max_w && src_h <= max_h { + return (src_w, src_h); + } + let scale = f64::min( + f64::from(max_w) / f64::from(src_w), + f64::from(max_h) / f64::from(src_h), + ); + let w = (f64::from(src_w) * scale) + .round() + .clamp(1.0, f64::from(max_w)) as u32; + let h = (f64::from(src_h) * scale) + .round() + .clamp(1.0, f64::from(max_h)) as u32; + (w, h) +} + +/// Source taps of one axis for area averaging: for destination index `i`, +/// `taps[starts[i]..starts[i + 1]]` holds `(source index, weight)` with weights summing +/// to `WEIGHT_ONE`. +#[derive(Debug, Default)] +struct AxisTaps { + starts: Vec, + taps: Vec<(usize, u32)>, +} + +impl AxisTaps { + fn build(src: usize, dst: usize) -> Self { + let scale = src as f64 / dst as f64; + let mut starts = Vec::with_capacity(dst + 1); + let mut taps = Vec::new(); + for i in 0..dst { + starts.push(taps.len()); + let begin = i as f64 * scale; + let end = (i + 1) as f64 * scale; + let first = begin.floor() as usize; + let last = (end.ceil() as usize).min(src); + let from = taps.len(); + for j in first..last { + let overlap = end.min(j as f64 + 1.0) - begin.max(j as f64); + let weight = (overlap / scale * f64::from(WEIGHT_ONE)).round() as u32; + if weight > 0 { + taps.push((j, weight)); + } + } + // Make the weights sum to exactly WEIGHT_ONE so flat areas stay flat. + let sum: u32 = taps[from..].iter().map(|t| t.1).sum(); + if let Some(max) = taps[from..].iter_mut().max_by_key(|t| t.1) { + max.1 = (max.1 + WEIGHT_ONE).saturating_sub(sum); + } else { + taps.push((first.min(src - 1), WEIGHT_ONE)); + } + } + starts.push(taps.len()); + Self { starts, taps } + } + + fn get(&self, i: usize) -> &[(usize, u32)] { + &self.taps[self.starts[i]..self.starts[i + 1]] + } +} + +/// Reusable converter; keeps its scratch buffers and filter taps between frames. +#[derive(Debug, Default)] +pub struct Converter { + geometry: (usize, usize, usize, usize), + x_taps: AxisTaps, + y_taps: AxisTaps, + row: Vec, + acc: Vec, +} + +impl Converter { + pub fn new() -> Self { + Self::default() + } + + /// Converts `src` to RGB24 of `dst_w`x`dst_h` into `out` (resized as needed). + pub fn convert(&mut self, src: &BgrxFrame<'_>, dst_w: usize, dst_h: usize, out: &mut Vec) { + assert!(dst_w > 0 && dst_h > 0, "destination must not be empty"); + out.resize(dst_w * dst_h * 3, 0); + let (sw, sh) = (src.width, src.height); + if (sw, sh) == (dst_w, dst_h) { + copy_rgb(src, out); + } else if sw % dst_w == 0 && sh % dst_h == 0 { + self.box_filter(src, sw / dst_w, sh / dst_h, dst_w, out); + } else { + self.area_average(src, dst_w, dst_h, out); + } + } + + fn box_filter( + &mut self, + src: &BgrxFrame<'_>, + fx: usize, + fy: usize, + dst_w: usize, + out: &mut [u8], + ) { + let n = (fx * fy) as u32; + let sums = &mut self.row; + sums.clear(); + sums.resize(dst_w * 3, 0); + for (dy, out_row) in out.chunks_exact_mut(dst_w * 3).enumerate() { + sums.fill(0); + for sy in dy * fy..(dy + 1) * fy { + let row = src.row(sy); + for (block, sum) in row.chunks_exact(fx * 4).zip(sums.chunks_exact_mut(3)) { + for px in block.chunks_exact(4) { + sum[0] += u32::from(px[2]); + sum[1] += u32::from(px[1]); + sum[2] += u32::from(px[0]); + } + } + } + for (o, s) in out_row.iter_mut().zip(sums.iter()) { + *o = ((s + n / 2) / n) as u8; + } + } + } + + fn area_average(&mut self, src: &BgrxFrame<'_>, dst_w: usize, dst_h: usize, out: &mut [u8]) { + let geometry = (src.width, src.height, dst_w, dst_h); + if self.geometry != geometry { + self.x_taps = AxisTaps::build(src.width, dst_w); + self.y_taps = AxisTaps::build(src.height, dst_h); + self.geometry = geometry; + } + self.row.clear(); + self.row.resize(dst_w * 3, 0); + self.acc.clear(); + self.acc.resize(dst_w * 3, 0); + + let round = 1u64 << (2 * WEIGHT_BITS - 1); + for (dy, out_row) in out.chunks_exact_mut(dst_w * 3).enumerate() { + self.acc.fill(0); + for &(sy, wy) in self.y_taps.get(dy) { + let src_row = src.row(sy); + // Horizontal pass: values in [0, 255 * WEIGHT_ONE]. + for (dx, rgb) in self.row.chunks_exact_mut(3).enumerate() { + let (mut r, mut g, mut b) = (0u32, 0u32, 0u32); + for &(sx, wx) in self.x_taps.get(dx) { + let px = &src_row[sx * 4..sx * 4 + 4]; + r += wx * u32::from(px[2]); + g += wx * u32::from(px[1]); + b += wx * u32::from(px[0]); + } + rgb[0] = r; + rgb[1] = g; + rgb[2] = b; + } + let wy = u64::from(wy); + for (a, v) in self.acc.iter_mut().zip(self.row.iter()) { + *a += wy * u64::from(*v); + } + } + for (o, a) in out_row.iter_mut().zip(self.acc.iter()) { + *o = ((a + round) >> (2 * WEIGHT_BITS)) as u8; + } + } + } +} + +fn copy_rgb(src: &BgrxFrame<'_>, out: &mut [u8]) { + for (y, out_row) in out.chunks_exact_mut(src.width * 3).enumerate() { + for (px, rgb) in src.row(y).chunks_exact(4).zip(out_row.chunks_exact_mut(3)) { + rgb[0] = px[2]; + rgb[1] = px[1]; + rgb[2] = px[0]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a BGRx buffer with padding and offset filled with garbage. + fn make_buffer( + width: usize, + height: usize, + stride: usize, + offset: usize, + pixel: impl Fn(usize, usize) -> [u8; 3], + ) -> Vec { + let mut buf = vec![0xEE; offset + stride * height]; + for y in 0..height { + for x in 0..width { + let [r, g, b] = pixel(x, y); + let i = offset + y * stride + x * 4; + buf[i..i + 4].copy_from_slice(&[b, g, r, 0xFF]); + } + } + buf + } + + fn convert( + buf: &[u8], + offset: usize, + stride: usize, + w: usize, + h: usize, + dw: usize, + dh: usize, + ) -> Vec { + let frame = BgrxFrame::new(buf, offset, buf.len() - offset, stride, w, h).unwrap(); + let mut out = Vec::new(); + Converter::new().convert(&frame, dw, dh, &mut out); + out + } + + /// Reference area average in floating point. + fn reference( + w: usize, + h: usize, + dw: usize, + dh: usize, + pixel: impl Fn(usize, usize) -> [u8; 3], + ) -> Vec { + let (sx, sy) = (w as f64 / dw as f64, h as f64 / dh as f64); + let overlap = + |a0: f64, a1: f64, j: usize| (a1.min(j as f64 + 1.0) - a0.max(j as f64)).max(0.0); + let mut out = Vec::new(); + for dy in 0..dh { + for dx in 0..dw { + let (x0, x1, y0, y1) = ( + dx as f64 * sx, + (dx + 1) as f64 * sx, + dy as f64 * sy, + (dy + 1) as f64 * sy, + ); + let mut acc = [0f64; 3]; + let xs = x0.floor() as usize..(x1.ceil() as usize).min(w); + for y in y0.floor() as usize..(y1.ceil() as usize).min(h) { + for x in xs.clone() { + let wgt = overlap(x0, x1, x) * overlap(y0, y1, y); + let p = pixel(x, y); + for c in 0..3 { + acc[c] += wgt * f64::from(p[c]); + } + } + } + for a in acc { + out.push((a / (sx * sy)).round() as u8); + } + } + } + out + } + + fn pattern(x: usize, y: usize) -> [u8; 3] { + [ + (x * 37 + y * 11) as u8, + (x * 5 + y * 71) as u8, + ((x ^ y) * 29) as u8, + ] + } + + #[test] + fn identity_with_padding_and_offset() { + let (w, h, stride, offset) = (3, 2, 16, 5); + let buf = make_buffer(w, h, stride, offset, pattern); + let out = convert(&buf, offset, stride, w, h, w, h); + let expected: Vec = (0..h) + .flat_map(|y| (0..w).flat_map(move |x| pattern(x, y))) + .collect(); + assert_eq!(out, expected); + } + + #[test] + fn identity_odd_size() { + let (w, h) = (7, 5); + let buf = make_buffer(w, h, w * 4, 0, pattern); + let out = convert(&buf, 0, w * 4, w, h, w, h); + let expected: Vec = (0..h) + .flat_map(|y| (0..w).flat_map(move |x| pattern(x, y))) + .collect(); + assert_eq!(out, expected); + } + + #[test] + fn box_filter_factor_2() { + let values = [ + [0u8, 10, 20, 30], + [40, 50, 60, 70], + [100, 100, 0, 0], + [100, 101, 0, 0], + ]; + let buf = make_buffer(4, 4, 16, 0, |x, y| [values[y][x], 255 - values[y][x], 7]); + let out = convert(&buf, 0, 16, 4, 4, 2, 2); + // (0+10+40+50)/4 = 25, (20+30+60+70)/4 = 45, (100+100+100+101)/4 = 100.25, 0 + let r: Vec = out.chunks(3).map(|p| p[0]).collect(); + assert_eq!(r, [25, 45, 100, 0]); + let g: Vec = out.chunks(3).map(|p| p[1]).collect(); + assert_eq!(g, [230, 210, 155, 255]); + assert!(out.chunks(3).all(|p| p[2] == 7)); + } + + #[test] + fn box_filter_factor_3_with_padding() { + let (w, h, stride, offset) = (6, 3, 28, 8); + let buf = make_buffer(w, h, stride, offset, |x, y| [(x + y * 6) as u8 * 10, 0, 0]); + let out = convert(&buf, offset, stride, w, h, 2, 1); + // Left block: x 0..3, all rows: mean of {0,10,20,60,70,80,120,130,140} = 70 + // Right block: +30 = 100 + assert_eq!(out, [70, 0, 0, 100, 0, 0]); + } + + #[test] + fn area_average_non_integer_known_values() { + let values = [0u8, 90, 180]; + let buf = make_buffer(3, 1, 12, 0, |x, _| [values[x], values[x], values[x]]); + let out = convert(&buf, 0, 12, 3, 1, 2, 1); + assert_eq!(out, [30, 30, 30, 150, 150, 150]); + } + + #[test] + fn area_average_matches_reference() { + for &(w, h, dw, dh) in &[ + (5, 3, 2, 2), + (322, 182, 320, 181), + (17, 9, 4, 3), + (10, 10, 3, 7), + ] { + let buf = make_buffer(w, h, w * 4, 0, pattern); + let out = convert(&buf, 0, w * 4, w, h, dw, dh); + let expected = reference(w, h, dw, dh, pattern); + assert_eq!(out.len(), expected.len()); + for (i, (a, b)) in out.iter().zip(expected.iter()).enumerate() { + assert!( + a.abs_diff(*b) <= 1, + "{w}x{h}->{dw}x{dh} byte {i}: {a} vs {b}" + ); + } + } + } + + #[test] + fn flat_color_stays_flat() { + let buf = make_buffer(641, 359, 641 * 4, 0, |_, _| [200, 17, 255]); + let out = convert(&buf, 0, 641 * 4, 641, 359, 320, 180); + assert!(out.chunks(3).all(|p| p == [200, 17, 255])); + } + + #[test] + fn converter_reuse_across_geometries() { + let mut conv = Converter::new(); + let mut out = Vec::new(); + for &(w, h, dw, dh) in &[(9, 9, 4, 4), (8, 8, 4, 4), (9, 9, 4, 4), (5, 5, 5, 5)] { + let buf = make_buffer(w, h, w * 4, 0, pattern); + let frame = BgrxFrame::new(&buf, 0, buf.len(), w * 4, w, h).unwrap(); + conv.convert(&frame, dw, dh, &mut out); + let expected = reference(w, h, dw, dh, pattern); + assert!(out + .iter() + .zip(expected.iter()) + .all(|(a, b)| a.abs_diff(*b) <= 1)); + } + } + + #[test] + fn bounds_violations_are_errors() { + let buf = vec![0u8; 64]; + assert!(matches!( + BgrxFrame::new(&buf, 0, 64, 16, 0, 4), + Err(ConvertError::Empty { .. }) + )); + assert!(matches!( + BgrxFrame::new(&buf, 0, 64, 15, 4, 4), + Err(ConvertError::StrideTooSmall { .. }) + )); + assert!(matches!( + BgrxFrame::new(&buf, 1, 64, 16, 4, 4), + Err(ConvertError::OutOfBounds { .. }) + )); + assert!(matches!( + BgrxFrame::new(&buf, 0, 63, 16, 4, 4), + Err(ConvertError::ChunkTooSmall { .. }) + )); + assert!(BgrxFrame::new(&buf, 0, 64, 16, 4, 4).is_ok()); + // Last row without padding still fits. + assert!(BgrxFrame::new(&buf[..60], 0, 64, 16, 3, 4).is_ok()); + // Overflowing geometry must not panic. + assert!(BgrxFrame::new(&buf, usize::MAX, 64, 16, 4, 4).is_err()); + assert!(BgrxFrame::new(&buf, 0, usize::MAX, usize::MAX, 4, usize::MAX).is_err()); + assert!(BgrxFrame::new(&buf, 0, 64, 16, usize::MAX, 1).is_err()); + } + + #[test] + fn fit_within_keeps_aspect() { + assert_eq!(fit_within(3840, 2160, 320, 180), (320, 180)); + assert_eq!(fit_within(640, 360, 320, 180), (320, 180)); + assert_eq!(fit_within(320, 134, 320, 180), (320, 134)); + assert_eq!(fit_within(322, 182, 321, 181), (320, 181)); + assert_eq!(fit_within(1080, 1920, 320, 180), (101, 180)); + assert_eq!(fit_within(100, 100, 320, 180), (100, 100)); + assert_eq!(fit_within(10000, 1, 320, 180), (320, 1)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8f52000..d126966 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ //! HyperHDR's flatbuffers server. pub mod config; +pub mod convert; pub mod hyperhdr; pub mod pacing; pub mod slot;