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:
2026-09-12 10:09:41 +03:00
parent c50a55cf79
commit de88dd5799
2 changed files with 502 additions and 0 deletions
+501
View File
@@ -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));
}
}
+1
View File
@@ -2,6 +2,7 @@
//! HyperHDR's flatbuffers server. //! HyperHDR's flatbuffers server.
pub mod config; pub mod config;
pub mod convert;
pub mod hyperhdr; pub mod hyperhdr;
pub mod pacing; pub mod pacing;
pub mod slot; pub mod slot;