filmcraft/crates/media/tests/exr_oracle.rs

192 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! OpenEXR stills and sequences against ffmpeg (an external test oracle): ffmpeg writes EXR files in
//! every layout its encoder has (RGB, RGBA, luminance; half and float; no, RLE, ZIPS and ZIP
//! compression) and our decode must give exactly the floats ffmpeg's decoder gives.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use filmcraft_frame::PixelData;
use filmcraft_media::sequence::{ImageSequenceSource, Numbered, sequence_frames};
use filmcraft_media::{FrameRequest, MediaKind, MediaSource};
use filmcraft_time::{FrameRate, Tick};
const W: usize = 95; // odd, so nothing lines up by accident
const H: usize = 63;
fn dir() -> PathBuf {
filmcraft_testkit::fixtures::fixtures_dir("media/exr")
}
/// The filter making a 95×63 picture, translucent when `alpha`.
fn filter(alpha: bool) -> String {
let a = if alpha { ",format=gbrapf32le,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='(X*2+Y)/250'" } else { "" };
format!("scale={W}:{H}{a}")
}
/// One EXR still written by ffmpeg (cached across runs).
fn fixture(ff: &Path, pix_fmt: &str, format: &str, compression: &str) -> Option<PathBuf> {
let out = dir().join(format!("{pix_fmt}_{format}_{compression}.exr"));
let vf = filter(pix_fmt.starts_with("gbrap"));
filmcraft_testkit::fixtures::generate(&out, |tmp| {
std::process::Command::new(ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt])
.args(["-compression", compression, "-format", format])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A half float's value, computed arithmetically (independent of the decoder's bit manipulation).
fn f16_value(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let (e, m) = (i32::from((h >> 10) & 0x1F), f32::from(h & 0x3FF));
sign * if e == 0 { m * 2f32.powi(-24) } else { (1.0 + m / 1024.0) * 2f32.powi(e - 15) }
}
/// ffmpeg's decode of `file` as planar floats in `pix_fmt` (32-bit, or the 16-bit variant ffmpeg
/// decodes half files to natively when `half`: its conversion to 32 bits is not exact), as
/// interleaved RGBA.
fn ffmpeg_rgba(ff: &Path, file: &Path, pix_fmt: &str, half: bool) -> Vec<f32> {
let fmt = if half { pix_fmt.replace("f32", "f16") } else { pix_fmt.to_string() };
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(file).args(["-f", "rawvideo", "-pix_fmt", &fmt, "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg decode of {}", file.display());
let v: Vec<f32> = if half {
out.stdout.as_chunks::<2>().0.iter().map(|b| f16_value(u16::from_le_bytes(*b))).collect()
} else {
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
};
let n = W * H;
let plane = |k: usize| &v[k * n..(k + 1) * n];
(0..n)
.flat_map(|i| match pix_fmt {
"grayf32le" => [plane(0)[i], plane(0)[i], plane(0)[i], 1.0],
"gbrpf32le" => [plane(2)[i], plane(0)[i], plane(1)[i], 1.0],
_ => [plane(2)[i], plane(0)[i], plane(1)[i], plane(3)[i]],
})
.collect()
}
fn floats(f: &filmcraft_frame::VideoFrame) -> Vec<f32> {
match &f.data {
PixelData::RgbaF32(p) => p.to_vec(),
_ => panic!("EXR frames are float"),
}
}
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max)
}
#[test]
fn exr_stills_match_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
for pix_fmt in ["gbrpf32le", "gbrapf32le", "grayf32le"] {
for format in ["float", "half"] {
for compression in ["none", "rle", "zip1", "zip16"] {
let name = format!("{pix_fmt} {format} {compression}");
let Some(f) = fixture(&ff, pix_fmt, format, compression) else {
eprintln!("SKIPPED: ffmpeg cannot write {name}");
continue;
};
let src = filmcraft_media::open_bytes("still.exr", std::fs::read(&f).unwrap().into(), &[]).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(src.info().kind, MediaKind::Still, "{name}");
let v = src.info().video.clone().unwrap();
assert_eq!((v.width as usize, v.height as usize), (W, H), "{name}");
assert_eq!(v.has_alpha, pix_fmt == "gbrapf32le", "{name}");
let ours = floats(&src.video_frame(FrameRequest::full(Tick(0))).unwrap());
let d = max_diff(&ours, &ffmpeg_rgba(&ff, &f, pix_fmt, format == "half"));
assert_eq!(d, 0.0, "{name}: differs by {d}");
eprintln!("{name}: max diff {d}");
}
}
}
}
#[test]
fn exr_sequence_plays_linear_float_frames() {
let ff = filmcraft_testkit::require_ffmpeg!();
let d = dir().join("seq_v1");
let last = d.join("img_0003.exr");
if !last.exists() {
let tmp = filmcraft_testkit::fixtures::temp_path(&d);
std::fs::create_dir_all(&tmp).unwrap();
let ok = std::process::Command::new(&ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=96x64:rate=25", "-frames:v", "3", "-vf", &filter(true)])
.args(["-pix_fmt", "gbrapf32le", "-compression", "zip16", "-format", "half", "-f", "image2"])
.arg(tmp.join("img_%04d.exr"))
.status()
.is_ok_and(|s| s.success());
if !ok || std::fs::rename(&tmp, &d).is_err() {
let _ = std::fs::remove_dir_all(&tmp);
}
if !last.exists() {
eprintln!("SKIPPED: ffmpeg cannot write an EXR sequence");
return;
}
}
let first = d.join("img_0001.exr").to_string_lossy().to_string();
let names: Vec<String> = std::fs::read_dir(&d).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
let frames = sequence_frames(&Numbered::parse(&first).unwrap(), &names);
let src = ImageSequenceSource::new("img_0001", frames, FrameRate::FPS_25, Arc::new(|p: &str| std::fs::read(p))).unwrap();
assert_eq!(src.info().kind, MediaKind::ImageSequence);
assert_eq!(src.frame_count(), 3);
let v = src.info().video.clone().unwrap();
assert_eq!((v.codec.as_str(), v.pixel_format.as_str(), v.has_alpha), ("EXR sequence", "RGBA 16-bit float", true));
assert_eq!(v.color.transfer, filmcraft_color::Transfer::Linear);
for i in [2, 0, 1] {
let ours = floats(&src.video_frame(FrameRequest::full(FrameRate::FPS_25.tick_of(i))).unwrap());
let want = ffmpeg_rgba(&ff, &d.join(format!("img_{:04}.exr", i + 1)), "gbrapf32le", true);
assert!(max_diff(&ours, &want) == 0.0, "frame {i}");
}
}
/// PXR24 (lossy 24-bit floats; ffmpeg's encoder cannot write it): OpenImageIO writes the files and
/// re-writes them uncompressed (its OpenEXR reader undoes the compression), and our decode of the
/// PXR24 file must give exactly the floats of that uncompressed copy.
#[test]
fn exr_pxr24_matches_openexr() {
let tool = |args: &[&str]| std::process::Command::new("oiiotool").args(args).status().is_ok_and(|s| s.success());
if !tool(&["--version"]) {
eprintln!("SKIPPED: oiiotool is not installed");
return;
}
for (name, depth, channels, size) in [("f4", "float", 4, "95x63"), ("h4", "half", 4, "40x35"), ("f3", "float", 3, "17x16"), ("h3", "half", 3, "16x17")] {
let packed = dir().join(format!("pxr24_{name}.exr"));
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let ok = |p: &Path| {
filmcraft_testkit::fixtures::generate(p, |tmp| {
let t = tmp.to_string_lossy().to_string();
if p == packed.as_path() {
tool(&[
"--pattern",
"fill:topleft=1,0,0,0.5:topright=0,1,0,0.5:bottomleft=0,0,1,1:bottomright=1,1,1,1",
size,
&channels.to_string(),
"--noise:type=gaussian:mean=0:stddev=0.3",
"-d",
depth,
"--compression",
"pxr24",
"-o",
&t,
])
} else {
tool(&[packed.to_string_lossy().as_ref(), "-d", depth, "--compression", "none", "-o", &t])
}
})
};
let (Some(packed), Some(_)) = (ok(&packed), ok(&plain)) else {
eprintln!("SKIPPED: oiiotool cannot write {name}");
continue;
};
let plain = dir().join(format!("pxr24_{name}_plain.exr"));
let decode = |p: &Path| {
floats(&filmcraft_media::open_bytes("still.exr", std::fs::read(p).unwrap().into(), &[]).unwrap().video_frame(FrameRequest::full(Tick(0))).unwrap())
};
let (ours, want) = (decode(&packed), decode(&plain));
assert_eq!(max_diff(&ours, &want), 0.0, "{name}");
}
}