//! nespyro against the C++ reference it is a port of.
//!
//! Needs the harness `reference/build.sh` builds, named by
//! `NESPYRO_REFERENCE`. Unset or missing, the test fails: a cross-check that
//! quietly does not run is indistinguishable from one that passed.
//!
//! Decoding is floating point, so two decoders of one stream may differ by
//! rounding. The tolerance is upstream's own, from
//! `pyrowave_device_validation.cpp`: one step of luma, two of chroma.
mod common;
use std::path::PathBuf;
use std::process::Command;
use ash::vk;
use common::{Content, Gpu, plane_values, psnr, reference_ycbcr, rgba8};
use nespyro::bitstream::Push;
use nespyro::{Chroma, DecodeConfig, Decoder, Depth, EncodeConfig, EncodedFrame, Encoder};
fn harness() -> PathBuf {
let path = std::env::var_os("NESPYRO_REFERENCE").unwrap_or_else(|| {
panic!(
"NESPYRO_REFERENCE is not set. Build the reference with \
crates/nespyro/reference/build.sh
and point it at the harness it prints."
)
});
let path = PathBuf::from(path);
assert!(
path.is_file(),
"NESPYRO_REFERENCE names {path:?}, which does not exist"
);
path
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("nespyro-reference-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn chroma_arg(c: Chroma) -> &'static str {
match c {
Chroma::Yuv420 => "420",
Chroma::Yuv444 => "444",
}
}
fn run(args: &[&str]) {
let out = Command::new(harness()).args(args).output().unwrap();
assert!(
out.status.success(),
"harness {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn write_packets(path: &PathBuf, packets: &[&[u8]]) {
let mut out = Vec::new();
for p in packets {
out.extend((p.len() as u32).to_le_bytes());
out.extend(*p);
}
std::fs::write(path, out).unwrap();
}
fn read_packets(path: &PathBuf) -> Vec> {
let data = std::fs::read(path).unwrap();
let mut at = 0;
let mut packets = Vec::new();
while at < data.len() {
let len = u32::from_le_bytes(data[at..at + 4].try_into().unwrap()) as usize;
packets.push(data[at + 4..at + 4 + len].to_vec());
at += 4 + len;
}
packets
}
/// Planes as 8-bit samples, Y then Cb then Cr.
fn to_u8(planes: &[Vec; 3]) -> Vec {
planes
.iter()
.flatten()
.map(|v| (v * 255.0).round() as u8)
.collect()
}
fn our_decode(gpu: &Gpu, w: u32, h: u32, chroma: Chroma, packets: &[&[u8]]) -> Vec {
try_our_decode(gpu, w, h, chroma, packets).unwrap()
}
fn try_our_decode(
gpu: &Gpu,
w: u32,
h: u32,
chroma: Chroma,
packets: &[&[u8]],
) -> Result, nespyro::Error> {
let mut decoder =
Decoder::new(gpu.ctx(), DecodeConfig::new(w, h, chroma, Depth::Eight)).unwrap();
for p in packets {
assert_eq!(decoder.push_packet(p)?, Push::Accepted);
}
assert!(decoder.readiness().is_complete());
let frame = decoder.decode_after(&[]).unwrap();
gpu.wait(frame.ready);
Ok(to_u8(&[
plane_values(gpu, &frame.y),
plane_values(gpu, &frame.cb),
plane_values(gpu, &frame.cr),
]))
}
fn their_decode(w: u32, h: u32, chroma: Chroma, packets: &[&[u8]]) -> Vec {
let pkts = scratch("in.pkts");
let yuv = scratch("out.yuv");
write_packets(&pkts, packets);
run(&[
"decode",
&w.to_string(),
&h.to_string(),
chroma_arg(chroma),
pkts.to_str().unwrap(),
yuv.to_str().unwrap(),
]);
std::fs::read(yuv).unwrap()
}
/// The largest difference per plane between two decodes of one stream.
fn max_difference(a: &[u8], b: &[u8], w: u32, h: u32, chroma: Chroma) -> [u8; 3] {
assert_eq!(a.len(), b.len());
let luma = (w * h) as usize;
let c = match chroma {
Chroma::Yuv420 => luma / 4,
Chroma::Yuv444 => luma,
};
let ranges = [0..luma, luma..luma + c, luma + c..luma + 2 * c];
ranges.map(|r| {
a[r.clone()]
.iter()
.zip(&b[r])
.map(|(x, y)| x.abs_diff(*y))
.max()
.unwrap()
})
}
fn within_tolerance(diff: [u8; 3]) -> bool {
diff[0] <= 1 && diff[1] <= 2 && diff[2] <= 2
}
const CASES: &[(u32, u32, Chroma)] = &[(1920, 1080, Chroma::Yuv420), (1366, 768, Chroma::Yuv444)];
const CONTENT: [Content; 3] = [Content::Gradient, Content::Edges, Content::Noise];
fn our_encode(gpu: &Gpu, w: u32, h: u32, chroma: Chroma, rgba: &[u8]) -> EncodedFrame {
let image = gpu.upload(vk::Format::R8G8B8A8_UNORM, w, h, rgba);
let mut encoder = Encoder::new(gpu.ctx(), EncodeConfig::new(w, h).with_chroma(chroma)).unwrap();
let f = encoder
.encode_after(image.image, image.format, vk::ImageLayout::GENERAL, &[])
.unwrap();
pollster::block_on(f).unwrap()
}
#[test]
#[ignore = "needs a Vulkan 1.3 GPU and the reference harness (NESPYRO_REFERENCE)"]
fn their_decoder_reads_our_stream() {
let gpu = Gpu::new();
for &(w, h, chroma) in CASES {
for content in CONTENT {
let frame = our_encode(&gpu, w, h, chroma, &rgba8(content, w, h));
let packets: Vec<&[u8]> = frame
.packets
.iter()
.map(|p| &frame.data[p.clone()])
.collect();
// Their decoder refuses a packet it cannot parse and a frame it
// considers incomplete, so getting planes back at all is half of
// the check.
let theirs = their_decode(w, h, chroma, &packets);
let ours = our_decode(&gpu, w, h, chroma, &packets);
let diff = max_difference(&ours, &theirs, w, h, chroma);
eprintln!("ours → theirs {w}x{h} {chroma:?} {content:?}: max difference {diff:?}");
assert!(
within_tolerance(diff),
"{w}x{h} {chroma:?} {content:?}: {diff:?}"
);
}
}
gpu.assert_clean();
}
#[test]
#[ignore = "needs a Vulkan 1.3 GPU and the reference harness (NESPYRO_REFERENCE)"]
fn our_decoder_reads_their_stream_and_our_encoder_keeps_up() {
let gpu = Gpu::new();
for &(w, h, chroma) in CASES {
for content in CONTENT {
let rgba = rgba8(content, w, h);
// The same conversion our encoder makes, done on the CPU and
// rounded to 8 bits, is their input.
let reference = reference_ycbcr(&rgba, w, h, chroma == Chroma::Yuv420, false);
let yuv = scratch("in.yuv");
let pkts = scratch("out.pkts");
std::fs::write(&yuv, to_u8(&reference)).unwrap();
// Both encoders get exactly these planes.
let input = to_u8(&reference);
let luma = (w * h) as usize;
let (cw, ch) = match chroma {
Chroma::Yuv420 => (w / 2, h / 2),
Chroma::Yuv444 => (w, h),
};
let c = (cw * ch) as usize;
let y_img = gpu.upload(vk::Format::R8_UNORM, w, h, &input[..luma]);
let cb_img = gpu.upload(vk::Format::R8_UNORM, cw, ch, &input[luma..luma + c]);
let cr_img = gpu.upload(vk::Format::R8_UNORM, cw, ch, &input[luma + c..]);
let mut encoder =
Encoder::new(gpu.ctx(), EncodeConfig::new(w, h).with_chroma(chroma)).unwrap();
let f = encoder
.encode_planes_after(
[y_img.image, cb_img.image, cr_img.image],
vk::ImageLayout::GENERAL,
&[],
)
.unwrap();
let ours_frame = pollster::block_on(f).unwrap();
drop(encoder);
let target = ours_frame.stats.target_bytes;
run(&[
"encode",
&w.to_string(),
&h.to_string(),
chroma_arg(chroma),
&target.to_string(),
"1200",
yuv.to_str().unwrap(),
pkts.to_str().unwrap(),
]);
let their_packets = read_packets(&pkts);
let their_refs: Vec<&[u8]> = their_packets.iter().map(|p| p.as_slice()).collect();
let their_bytes: usize = their_packets.iter().map(Vec::len).sum();
// Upstream sizes its quantizer scratch at aligned width × height
// × 2 bytes, and high-entropy content needs more. Ours asks for
// nearly the same amount on the same planes, so it says when
// theirs overflowed; its encoder then packs blocks from bytes it
// never wrote, and a block's contents stop matching its header.
let layout = nespyro::bitstream::Layout::new(w, h, chroma).unwrap();
let upstream_scratch = layout.aligned_width * layout.aligned_height * 2;
let upstream_overflowed = ours_frame.stats.scratch_bytes > upstream_scratch;
let by_them = their_decode(w, h, chroma, &their_refs);
let by_us = match try_our_decode(&gpu, w, h, chroma, &their_refs) {
Ok(planes) => planes,
Err(nespyro::Error::Bitstream(e)) if upstream_overflowed => {
// Refused, not decoded: the stream is malformed.
eprintln!(
"theirs → ours {w}x{h} {chroma:?} {content:?}: upstream's scratch overflowed \
({} of {upstream_scratch} bytes) and our decoder refused its stream: {e}",
ours_frame.stats.scratch_bytes
);
continue;
}
Err(e) => {
panic!("{w}x{h} {chroma:?} {content:?}: our decoder refused their stream: {e}")
}
};
let diff = max_difference(&by_us, &by_them, w, h, chroma);
// Our encoder beside theirs, each judged against the 8-bit input.
let ours_packets: Vec<&[u8]> = ours_frame
.packets
.iter()
.map(|p| &ours_frame.data[p.clone()])
.collect();
let our_decoded = our_decode(&gpu, w, h, chroma, &ours_packets);
let as_f32 = |v: &[u8]| v.iter().map(|&x| f32::from(x) / 255.0).collect::>();
let our_db = psnr(&as_f32(&our_decoded[..luma]), &as_f32(&input[..luma]));
let their_db = psnr(&as_f32(&by_them[..luma]), &as_f32(&input[..luma]));
let same_blocks = identical_blocks(&ours_frame.data, &their_packets);
eprintln!(
"theirs → ours {w}x{h} {chroma:?} {content:?}: max difference {diff:?}; \
encoders: ours {} bytes {our_db:.2} dB, theirs {their_bytes} bytes {their_db:.2} dB, \
{:.1}% of blocks identical",
ours_frame.data.len(),
same_blocks * 100.0
);
assert!(
within_tolerance(diff),
"{w}x{h} {chroma:?} {content:?}: {diff:?}"
);
// Slang and glslang may round floating point differently, so the
// encoders need not agree byte for byte; ours must not be
// measurably worse.
assert!(
our_db >= their_db - 0.5,
"{w}x{h} {chroma:?} {content:?}: ours {our_db:.2} dB, theirs {their_db:.2} dB"
);
}
}
gpu.assert_clean();
}
/// The fraction of their blocks that also appear, byte for byte, in ours.
/// Sequence numbers are masked, since the two encoders count separately.
fn identical_blocks(ours: &[u8], theirs: &[Vec]) -> f64 {
let blocks = |bytes: &[u8]| {
let mut out = std::collections::HashMap::new();
let mut at = 0;
while at + 8 <= bytes.len() {
let w0 = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
if w0 >> 31 != 0 {
at += 8;
continue;
}
let size = ((w0 >> 16) & 0xfff) as usize * 4;
let w1 = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap());
let mut b = bytes[at..at + size].to_vec();
b[3] &= 0x8f;
out.insert(w1 >> 8, b);
at += size;
}
out
};
let ours = blocks(ours);
let theirs: std::collections::HashMap<_, _> = theirs.iter().flat_map(|p| blocks(p)).collect();
let same = theirs
.iter()
.filter(|(k, v)| ours.get(*k) == Some(*v))
.count();
same as f64 / theirs.len().max(1) as f64
}