diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-19 04:02:26 +0300 |
| commit | 18d4c6cf9fabc18282b29d103c8d30024f66e49b (patch) | |
| tree | 7f9adfa0dcae5653f378c14b32a238c6771c1602 /crates/render-parity/src/main.rs | |
| parent | 0e19660eb5122c8c52d5e909927884ad5c50b813 (diff) | |
| download | fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.tar.xz fparkan-18d4c6cf9fabc18282b29d103c8d30024f66e49b.zip | |
feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
Diffstat (limited to 'crates/render-parity/src/main.rs')
| -rw-r--r-- | crates/render-parity/src/main.rs | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/crates/render-parity/src/main.rs b/crates/render-parity/src/main.rs new file mode 100644 index 0000000..22795bc --- /dev/null +++ b/crates/render-parity/src/main.rs @@ -0,0 +1,405 @@ +use image::RgbaImage; +use render_parity::{ + build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest, +}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const DEFAULT_MANIFEST: &str = "parity/cases.toml"; +const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current"; +const DEFAULT_WIDTH: u32 = 1280; +const DEFAULT_HEIGHT: u32 = 720; +const DEFAULT_LOD: usize = 0; +const DEFAULT_GROUP: usize = 0; +const DEFAULT_ANGLE: f32 = 0.0; +const DEFAULT_DIFF_THRESHOLD: u8 = 8; +const DEFAULT_MAX_MEAN_ABS: f32 = 2.0; +const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01; + +struct Args { + manifest: PathBuf, + output_dir: PathBuf, + demo_bin: Option<PathBuf>, + keep_going: bool, +} + +#[derive(Debug, Clone)] +struct EffectiveCase { + id: String, + archive: PathBuf, + model: Option<String>, + reference: PathBuf, + width: u32, + height: u32, + lod: usize, + group: usize, + angle: f32, + diff_threshold: u8, + max_mean_abs: f32, + max_changed_ratio: f32, +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + if let Err(err) = run(args) { + eprintln!("{err}"); + std::process::exit(1); + } +} + +fn parse_args() -> Result<Args, String> { + let mut manifest = PathBuf::from(DEFAULT_MANIFEST); + let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR); + let mut demo_bin = None; + let mut keep_going = false; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--manifest" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --manifest"))?; + manifest = PathBuf::from(value); + } + "--output-dir" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --output-dir"))?; + output_dir = PathBuf::from(value); + } + "--demo-bin" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --demo-bin"))?; + demo_bin = Some(PathBuf::from(value)); + } + "--keep-going" => { + keep_going = true; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + Ok(Args { + manifest, + output_dir, + demo_bin, + keep_going, + }) +} + +fn print_help() { + eprintln!( + "render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]" + ); + eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})"); + eprintln!(" --output-dir where current renders and diff images are written"); + eprintln!(" --demo-bin prebuilt parkan-render-demo binary path"); + eprintln!(" --keep-going continue all cases even after failures"); +} + +fn run(args: Args) -> Result<(), String> { + let workspace = workspace_root()?; + let manifest_path = resolve_path(&workspace, &args.manifest); + let output_dir = resolve_path(&workspace, &args.output_dir); + let demo_bin = args + .demo_bin + .as_ref() + .map(|path| resolve_path(&workspace, path)); + + let manifest_raw = fs::read_to_string(&manifest_path) + .map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?; + let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| { + format!( + "failed to parse manifest {}: {err}", + manifest_path.display() + ) + })?; + + if manifest.cases.is_empty() { + println!( + "render-parity: no cases in {} (nothing to validate)", + manifest_path.display() + ); + return Ok(()); + } + + fs::create_dir_all(&output_dir).map_err(|err| { + format!( + "failed to create output directory {}: {err}", + output_dir.display() + ) + })?; + + let manifest_dir = manifest_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| workspace.clone()); + + let mut failed_cases = 0usize; + for case in &manifest.cases { + let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?; + let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id))); + let diff_file = output_dir + .join("diff") + .join(format!("{}.png", sanitize_case_id(&effective.id))); + + let run_res = run_single_case( + &workspace, // ensure `cargo run` executes from workspace root + demo_bin.as_deref(), + &effective, + &case_file, + &diff_file, + ); + + match run_res { + Ok(()) => {} + Err(err) => { + failed_cases = failed_cases.saturating_add(1); + eprintln!("[FAIL] {}: {}", effective.id, err); + if !args.keep_going { + break; + } + } + } + } + + if failed_cases > 0 { + return Err(format!( + "render-parity failed: {} case(s) did not match reference frames", + failed_cases + )); + } + + println!("render-parity: all cases passed"); + Ok(()) +} + +fn run_single_case( + workspace: &Path, + demo_bin: Option<&Path>, + case: &EffectiveCase, + case_file: &Path, + diff_file: &Path, +) -> Result<(), String> { + run_render_capture(workspace, demo_bin, case, case_file)?; + + let reference = load_rgba(&case.reference)?; + let actual = load_rgba(case_file)?; + let metrics = compare_images(&reference, &actual, case.diff_threshold)?; + let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio); + + if violations.is_empty() { + println!( + "[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})", + case.id, + metrics.mean_abs, + metrics.changed_ratio * 100.0, + metrics.max_abs, + metrics.width, + metrics.height + ); + return Ok(()); + } + + if let Some(parent) = diff_file.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create diff output directory {}: {err}", + parent.display() + ) + })?; + } + let diff = build_diff_image(&reference, &actual)?; + diff.save(diff_file) + .map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?; + + let mut details = String::new(); + for item in violations { + if !details.is_empty() { + details.push_str("; "); + } + details.push_str(&item); + } + Err(format!( + "{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}", + details, + diff_file.display(), + metrics.mean_abs, + metrics.changed_ratio * 100.0, + metrics.changed_pixels, + metrics.max_abs + )) +} + +fn run_render_capture( + workspace: &Path, + demo_bin: Option<&Path>, + case: &EffectiveCase, + out_path: &Path, +) -> Result<(), String> { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create capture directory {}: {err}", + parent.display() + ) + })?; + } + + let mut cmd = if let Some(bin) = demo_bin { + Command::new(bin) + } else { + let mut command = Command::new("cargo"); + command.args(["run", "-p", "render-demo", "--features", "demo", "--"]); + command + }; + + cmd.current_dir(workspace) + .arg("--archive") + .arg(&case.archive) + .arg("--lod") + .arg(case.lod.to_string()) + .arg("--group") + .arg(case.group.to_string()) + .arg("--width") + .arg(case.width.to_string()) + .arg("--height") + .arg(case.height.to_string()) + .arg("--angle") + .arg(case.angle.to_string()) + .arg("--capture") + .arg(out_path); + + if let Some(model) = case.model.as_deref() { + cmd.arg("--model").arg(model); + } + + let output = cmd.output().map_err(|err| { + let mode = if demo_bin.is_some() { + "parkan-render-demo" + } else { + "cargo run -p render-demo" + }; + format!("failed to execute {} for case {}: {err}", mode, case.id) + })?; + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout, + stderr + )); + } + + Ok(()) +} + +fn load_rgba(path: &Path) -> Result<RgbaImage, String> { + image::open(path) + .map_err(|err| format!("failed to load image {}: {err}", path.display())) + .map(|img| img.to_rgba8()) +} + +fn make_effective_case( + meta: &ManifestMeta, + case: &CaseSpec, + manifest_dir: &Path, +) -> Result<EffectiveCase, String> { + let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH); + let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT); + if width == 0 || height == 0 { + return Err(format!( + "case '{}' has invalid dimensions {}x{}", + case.id, width, height + )); + } + + let archive = resolve_path(manifest_dir, Path::new(&case.archive)); + let reference = resolve_path(manifest_dir, Path::new(&case.reference)); + if !archive.is_file() { + return Err(format!( + "case '{}' archive not found: {}", + case.id, + archive.display() + )); + } + if !reference.is_file() { + return Err(format!( + "case '{}' reference frame not found: {}", + case.id, + reference.display() + )); + } + + Ok(EffectiveCase { + id: case.id.clone(), + archive, + model: case.model.clone(), + reference, + width, + height, + lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD), + group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP), + angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE), + diff_threshold: case + .diff_threshold + .or(meta.diff_threshold) + .unwrap_or(DEFAULT_DIFF_THRESHOLD), + max_mean_abs: case + .max_mean_abs + .or(meta.max_mean_abs) + .unwrap_or(DEFAULT_MAX_MEAN_ABS), + max_changed_ratio: case + .max_changed_ratio + .or(meta.max_changed_ratio) + .unwrap_or(DEFAULT_MAX_CHANGED_RATIO), + }) +} + +fn sanitize_case_id(id: &str) -> String { + id.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +fn workspace_root() -> Result<PathBuf, String> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize() + .map_err(|err| format!("failed to resolve workspace root: {err}"))?; + Ok(root) +} + +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} |
