feat(convert): add BGRx to RGB24 conversion with downscaling
Box filter for integer scale factors, fixed-point area averaging otherwise. The source view is bounds-checked so a short or misdescribed PipeWire buffer is reported instead of read out of range.
This commit is contained in:
+501
@@ -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<Self, ConvertError> {
|
||||
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<usize>,
|
||||
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<u32>,
|
||||
acc: Vec<u64>,
|
||||
}
|
||||
|
||||
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<u8>) {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> = (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<u8> = (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<u8> = out.chunks(3).map(|p| p[0]).collect();
|
||||
assert_eq!(r, [25, 45, 100, 0]);
|
||||
let g: Vec<u8> = 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));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! HyperHDR's flatbuffers server.
|
||||
|
||||
pub mod config;
|
||||
pub mod convert;
|
||||
pub mod hyperhdr;
|
||||
pub mod pacing;
|
||||
pub mod slot;
|
||||
|
||||
Reference in New Issue
Block a user