use vacc_core::frame::{DecodedFrame, PixelData, PixelPlane, RgbFrame};
use vacc_core::gpu::{GpuDevice, GpuFrame};
use vacc_image::{ImageConfig, Kernel, ProcessedFrame, YuvImage, YuvLayout};
use crate::error::{UnifiedError, UnifiedResult};
#[derive(Debug)]
pub(crate) enum ApplyOutcome {
Transformed,
Skipped(String),
}
pub(crate) fn apply(frame: &mut DecodedFrame, cfg: &ImageConfig) -> UnifiedResult<ApplyOutcome> {
if cfg.is_noop() {
return Ok(ApplyOutcome::Skipped("no-op image config".into()));
}
if let Some(src) = frame.gpu.as_ref() {
match gpu_pipeline(src, cfg) {
Some(out) => {
write_back_gpu(frame, out);
return Ok(ApplyOutcome::Transformed);
}
None => readback_into_pixel_data(frame)?,
}
}
let Some(pd) = frame.pixel_data.as_ref() else {
return Ok(ApplyOutcome::Skipped("frame carries no pixel data".into()));
};
let processed = match map_source(pd) {
Ok(src) => run_pipeline(&src.image(), cfg)
.map_err(|e| UnifiedError::ImageProcessing { message: e.to_string() })?,
Err(vacc_image::ImageError::Unsupported(reason)) => return Ok(ApplyOutcome::Skipped(reason)),
Err(e) => return Err(UnifiedError::ImageProcessing { message: e.to_string() }),
};
write_back(frame, processed, cfg);
Ok(ApplyOutcome::Transformed)
}
fn gpu_pipeline(src: &GpuFrame, cfg: &ImageConfig) -> Option<GpuFrame> {
let result = match src.device {
#[cfg(feature = "npp")]
GpuDevice::Cuda { .. } => {
if !vacc_npp::Npp::is_available() {
log::warn!("cuda gpu frame but NPP unavailable; falling back to readback + host pipeline");
return None;
}
vacc_npp::process_gpu(src, cfg)
}
#[cfg(not(feature = "npp"))]
GpuDevice::Cuda { .. } => {
log::warn!("cuda gpu frame but the NPP backend is not compiled in; falling back to readback + host pipeline");
return None;
}
GpuDevice::Vulkan { .. } => vacc_vkimage::process_device(src, cfg),
};
match result {
Ok(out) => Some(out),
Err(e) => {
log::warn!("gpu image pipeline failed ({e}); falling back to readback + host pipeline");
None
}
}
}
fn readback_into_pixel_data(frame: &mut DecodedFrame) -> UnifiedResult<()> {
let src = frame.gpu.take().expect("gpu checked by caller");
let buf = match src.device {
GpuDevice::Cuda { .. } => {
#[cfg(feature = "npp")]
{
vacc_npp::readback(&src)
}
#[cfg(not(feature = "npp"))]
{
return Err(UnifiedError::ImageProcessing {
message: "cuda frame readback requires the NPP backend (vacc feature `npp`)".to_string(),
});
}
}
GpuDevice::Vulkan { .. } => vacc_vkimage::readback(&src),
}
.map_err(|e| UnifiedError::ImageProcessing { message: e.to_string() })?;
let (w, h) = (src.width as usize, src.height as usize);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
frame.pixel_data = Some(PixelData {
format: "NV12".to_string(),
y: PixelPlane { data: buf.as_ptr(), pitch: w, width: w, height: h },
u: PixelPlane {
data: unsafe { buf.as_ptr().add(w * h) },
pitch: cw * 2,
width: cw,
height: ch,
},
v: None,
buffer: buf,
});
Ok(())
}
fn write_back_gpu(frame: &mut DecodedFrame, out: GpuFrame) {
let (w, h) = (out.width, out.height);
frame.gpu = Some(out);
frame.pixel_data = None;
frame.rgb_pixels = None;
frame.width = w;
frame.height = h;
}
pub(crate) enum MappedSource<'a> {
Direct(YuvImage<'a>),
Normalized { buf: Vec<u8>, width: usize, height: usize },
}
impl MappedSource<'_> {
pub(crate) fn image(&self) -> YuvImage<'_> {
match self {
Self::Direct(img) => *img,
Self::Normalized { buf, width, height } => planar_u16_view(buf, *width, *height),
}
}
}
pub(crate) fn map_source(pd: &PixelData) -> Result<MappedSource<'_>, vacc_image::ImageError> {
use vacc_image::ImageError;
let (w, h) = (pd.y.width, pd.y.height);
if w == 0 || h == 0 {
return Err(ImageError::InvalidDimensions("zero-sized frame".into()));
}
match pd.format.as_str() {
"I420" => Ok(MappedSource::Direct(planar_view(pd, 8)?)),
"NV12" => {
if pd.v.is_some() {
return Err(ImageError::Unsupported(
"NV12 frame must not carry a separate V plane".into(),
));
}
let y = plane_slice(&pd.y);
let uv = plane_slice(&pd.u);
Ok(MappedSource::Direct(YuvImage::semi(y, pd.y.pitch, uv, pd.u.pitch, w, h, 8)))
}
"P010" | "I420_16BIT" => Ok(MappedSource::Direct(planar_view(pd, 10)?)),
"I420-10" | "Y410P16" => {
let buf = normalize_u16_to_top(pd);
Ok(MappedSource::Normalized { buf, width: w, height: h })
}
other => Err(ImageError::Unsupported(format!(
"format '{other}' is not a supported 4:2:0 Y'CbCr source"
))),
}
}
fn planar_view<'a>(pd: &'a PixelData, bps: u8) -> Result<YuvImage<'a>, vacc_image::ImageError> {
use vacc_image::ImageError;
let v = pd
.v
.as_ref()
.ok_or_else(|| ImageError::Unsupported(format!("format '{}' must carry a V plane", pd.format)))?;
Ok(YuvImage::planar(
plane_slice(&pd.y),
pd.y.pitch,
plane_slice(&pd.u),
pd.u.pitch,
plane_slice(v),
v.pitch,
pd.y.width,
pd.y.height,
bps,
))
}
fn normalize_u16_to_top(pd: &PixelData) -> Vec<u8> {
let mut out = Vec::with_capacity(
pd.y.height * pd.y.pitch + pd.u.height * pd.u.pitch
+ pd.v.as_ref().map_or(0, |v| v.height * v.pitch),
);
for p in [Some(&pd.y), Some(&pd.u), pd.v.as_ref()] {
if let Some(p) = p {
for row in 0..p.height {
let r = unsafe { std::slice::from_raw_parts(p.data.add(row * p.pitch), p.width * 2) };
for i in (0..r.len()).step_by(2) {
let v = u16::from_le_bytes([r[i], r[i + 1]]);
out.extend_from_slice(&(v << 6).to_le_bytes());
}
}
}
}
out
}
fn planar_u16_view(buf: &[u8], w: usize, h: usize) -> YuvImage<'_> {
let (cw, ch) = ((w + 1) / 2, (h + 1) / 2);
let y_len = w * h * 2;
let u_len = cw * ch * 2;
let (y, rest) = buf.split_at(y_len);
let (u, v) = rest.split_at(u_len);
YuvImage::planar(y, w * 2, u, cw * 2, v, cw * 2, w, h, 10)
}
#[cfg(feature = "npp")]
fn try_npp(img: &YuvImage, cfg: &ImageConfig) -> Option<vacc_image::ImageResult<ProcessedFrame>> {
if !vacc_npp::Npp::is_available() {
return None;
}
Some(vacc_npp::process(img, cfg))
}
#[cfg(not(feature = "npp"))]
fn try_npp(_img: &YuvImage, _cfg: &ImageConfig) -> Option<vacc_image::ImageResult<ProcessedFrame>> {
None
}
fn run_pipeline(img: &YuvImage, cfg: &ImageConfig) -> vacc_image::ImageResult<ProcessedFrame> {
if let Some(res) = try_npp(img, cfg) {
match res {
Ok(p) => return Ok(p),
Err(e) => log::warn!("npp pipeline failed ({e}); using fallback"),
}
}
if vacc_vkimage::is_available() {
match vacc_vkimage::process(img, cfg) {
Ok(p) => return Ok(p),
Err(e) => log::warn!("vulkan image pipeline failed ({e}); using software fallback"),
}
}
vacc_image::process(img, cfg, Kernel::Auto)
}
fn write_back(frame: &mut DecodedFrame, out: ProcessedFrame, img_cfg: &ImageConfig) {
match out {
ProcessedFrame::Yuv(y) => {
let (w, h) = (y.width as usize, y.height as usize);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let buffer = y.data;
frame.pixel_data = match y.layout {
YuvLayout::Planar => {
let u_off = w * h;
let v_off = u_off + cw * ch;
Some(PixelData {
format: "I420".to_string(),
y: PixelPlane { data: buffer.as_ptr(), pitch: w, width: w, height: h },
u: PixelPlane {
data: unsafe { buffer.as_ptr().add(u_off) },
pitch: cw,
width: cw,
height: ch,
},
v: Some(PixelPlane {
data: unsafe { buffer.as_ptr().add(v_off) },
pitch: cw,
width: cw,
height: ch,
}),
buffer,
})
}
YuvLayout::Semi => {
let uv_off = w * h;
Some(PixelData {
format: "NV12".to_string(),
y: PixelPlane { data: buffer.as_ptr(), pitch: w, width: w, height: h },
u: PixelPlane {
data: unsafe { buffer.as_ptr().add(uv_off) },
pitch: cw * 2,
width: cw,
height: ch,
},
v: None,
buffer,
})
}
};
frame.rgb_pixels = None;
frame.width = y.width;
frame.height = y.height;
}
ProcessedFrame::Rgb(r) => {
if img_cfg.scale.is_some() || img_cfg.affine.is_some() {
frame.pixel_data = None;
}
frame.rgb_pixels = Some(RgbFrame {
data: r.data,
width: r.width,
height: r.height,
channels: r.channels,
});
frame.width = r.width;
frame.height = r.height;
}
}
}
fn plane_slice(p: &PixelPlane) -> &[u8] {
unsafe { std::slice::from_raw_parts(p.data, p.height * p.pitch) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DecoderConfig;
use vacc_image::{Affine, Interpolation, RgbChannels, Scale, Warp};
fn i420_frame(w: usize, h: usize) -> DecodedFrame {
let cw = (w + 1) / 2;
let ch = (h + 1) / 2;
let mut buf = vec![0u8; w * h + 2 * cw * ch];
for r in 0..h {
for c in 0..w {
buf[r * w + c] = (c * 255 / w.max(1)) as u8;
}
}
let uv_off = w * h;
for i in uv_off..uv_off + 2 * cw * ch {
buf[i] = 128;
}
let mut frame = DecodedFrame::new(0, 0, w as u32, h as u32, false);
frame.pixel_data = Some(PixelData {
format: "I420".to_string(),
y: PixelPlane { data: buf.as_ptr(), pitch: w, width: w, height: h },
u: PixelPlane { data: unsafe { buf.as_ptr().add(uv_off) }, pitch: cw, width: cw, height: ch },
v: Some(PixelPlane {
data: unsafe { buf.as_ptr().add(uv_off + cw * ch) },
pitch: cw,
width: cw,
height: ch,
}),
buffer: buf,
});
frame
}
fn config(scale: Option<Scale>, rgb: Option<RgbChannels>) -> DecoderConfig {
let image = ImageConfig { scale, rgb, ..Default::default() };
DecoderConfig::new([crate::backend::Backend::Software]).with_image(image)
}
#[test]
fn scale_only_replaces_pixel_data() {
let mut frame = i420_frame(64, 32);
let cfg = config(Some(Scale::new(32, 16, Interpolation::Bilinear)), None);
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
let pd = frame.pixel_data.as_ref().unwrap();
assert_eq!(pd.format, "I420");
assert_eq!((frame.width, frame.height), (32, 16));
assert_eq!(pd.buffer.len(), 32 * 16 + 2 * 16 * 8);
assert!(pd.buffer[0] < 40);
assert!(pd.buffer[31] > 200);
}
#[test]
fn rgb_only_keeps_yuv_and_adds_rgb() {
let mut frame = i420_frame(64, 32);
let cfg = config(None, Some(RgbChannels::Rgb24));
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
let rgb = frame.rgb_pixels.as_ref().unwrap();
assert_eq!((rgb.width, rgb.height, rgb.channels), (64, 32, 3));
assert_eq!(rgb.data.len(), 64 * 32 * 3);
assert!(frame.pixel_data.is_some());
assert!(rgb.data[0] < 80);
assert!(rgb.data[(64 * 32 - 1) as usize * 3] > 150);
}
#[test]
fn scale_and_rgb_yields_only_rgb() {
let mut frame = i420_frame(64, 32);
let cfg = config(Some(Scale::new(32, 16, Interpolation::Box)), Some(RgbChannels::Rgba32));
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
assert!(frame.pixel_data.is_none());
let rgb = frame.rgb_pixels.as_ref().unwrap();
assert_eq!((rgb.width, rgb.height, rgb.channels), (32, 16, 4));
assert_eq!((frame.width, frame.height), (32, 16));
}
#[test]
fn unsupported_format_is_skipped() {
let mut frame = i420_frame(8, 8);
frame.pixel_data.as_mut().unwrap().format = "GRAY".to_string();
let cfg = config(None, Some(RgbChannels::Rgb24));
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Skipped(reason)) => assert!(reason.contains("GRAY")),
other => panic!("expected skip, got {other:?}"),
}
assert!(frame.rgb_pixels.is_none());
}
#[test]
fn bottom_justified_u16_is_normalized() {
let (w, h) = (8usize, 8usize);
let (cw, ch) = (4usize, 4usize);
let mut buf = vec![0u16 as u8; (w * h + 2 * cw * ch) * 2];
for i in 0..w * h {
buf[i * 2..i * 2 + 2].copy_from_slice(&100u16.to_le_bytes());
}
for i in w * h..w * h + 2 * cw * ch {
buf[i * 2..i * 2 + 2].copy_from_slice(&512u16.to_le_bytes());
}
let mut frame = DecodedFrame::new(0, 0, w as u32, h as u32, false);
frame.pixel_data = Some(PixelData {
format: "I420-10".to_string(),
y: PixelPlane { data: buf.as_ptr(), pitch: w * 2, width: w, height: h },
u: PixelPlane { data: unsafe { buf.as_ptr().add(w * h * 2) }, pitch: cw * 2, width: cw, height: ch },
v: Some(PixelPlane {
data: unsafe { buf.as_ptr().add((w * h + cw * ch) * 2) },
pitch: cw * 2,
width: cw,
height: ch,
}),
buffer: buf,
});
let cfg = config(Some(Scale::new(8, 8, Interpolation::Bilinear)), None);
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
let pd = frame.pixel_data.as_ref().unwrap();
assert_eq!(pd.format, "I420");
assert!((98..=102).contains(&pd.buffer[0]), "got {}", pd.buffer[0]);
}
#[test]
fn nv12_scale_preserves_semi_planar() {
let (w, h) = (16usize, 8usize);
let (cw, ch) = (8usize, 4usize);
let mut buf = vec![0u8; w * h + 2 * cw * ch];
for r in 0..h {
for c in 0..w {
buf[r * w + c] = (c * 255 / (w - 1)) as u8;
}
}
let uv_off = w * h;
for i in uv_off..uv_off + 2 * cw * ch {
buf[i] = 128;
}
let mut frame = DecodedFrame::new(0, 0, w as u32, h as u32, false);
frame.pixel_data = Some(PixelData {
format: "NV12".to_string(),
y: PixelPlane { data: buf.as_ptr(), pitch: w, width: w, height: h },
u: PixelPlane { data: unsafe { buf.as_ptr().add(uv_off) }, pitch: cw * 2, width: cw, height: ch },
v: None,
buffer: buf,
});
let cfg = config(Some(Scale::new(8, 4, Interpolation::Bilinear)), None);
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
let pd = frame.pixel_data.as_ref().unwrap();
assert_eq!(pd.format, "NV12");
assert!(pd.v.is_none());
assert_eq!((frame.width, frame.height), (8, 4));
assert_eq!(pd.buffer.len(), 8 * 4 + 2 * 4 * 2);
}
#[test]
fn warp_yields_rgb_and_drops_yuv() {
let mut frame = i420_frame(64, 32);
let image = ImageConfig {
rgb: Some(RgbChannels::Rgba32),
affine: Some(Warp::bilinear(Affine::translate(4.0, 2.0))),
..Default::default()
};
let cfg = DecoderConfig::new([crate::backend::Backend::Software]).with_image(image);
match apply(&mut frame, &cfg.image()) {
Ok(ApplyOutcome::Transformed) => {}
other => panic!("expected transform, got {other:?}"),
}
assert!(frame.pixel_data.is_none());
let rgb = frame.rgb_pixels.as_ref().unwrap();
assert_eq!((rgb.width, rgb.height, rgb.channels), (64, 32, 4));
assert!(rgb.data[0] == 0 && rgb.data[1] == 0 && rgb.data[2] == 0);
}
}