//! Port of `pipeline_360.py rig --self-test`: validates stage-4 geometry & //! schema without any video or GPU. Pure `std` so it can run offline. use std::path::Path; use crate::json; use crate::math; use crate::stages::rig::{self, Layout, LinkMode}; /// Write a tiny valid (stored/deflate) single-colour PNG so rig's pure-std /// dimension reader and copy-linker can operate on it without a codec. fn write_dummy_png(path: &Path, width: u32, height: u32) -> std::io::Result<()> { // Build an uncompressed-all-zero RGB image: each scanline prefixed with a // filter byte 0, then width RGB triples. let mut raw: Vec = Vec::with_capacity((height as usize) * (1 + 3 * width as usize)); for _ in 0..height { raw.push(0); raw.extend(std::iter::repeat(0u8).take((3 * width) as usize)); } // zlib stream: a single "stored" deflate block (BTYPE=00) with the full // uncompressed length, no compression. Valid zlib with header 0x78 0x01. let len = raw.len() as u32; let mut deflate = Vec::new(); deflate.push(0x01); // BFINAL=1, BTYPE=00 deflate.extend_from_slice(&len.to_le_bytes()); deflate.extend_from_slice(&(!len).to_le_bytes()); deflate.extend_from_slice(&raw); let mut idat = vec![0x78, 0x01]; // zlib adler32 of `raw`. let adler = adler32(&raw); idat.extend_from_slice(&deflate); idat.extend_from_slice(&adler.to_be_bytes()); let ihdr_data = make_ihdr(width, height); let mut out = Vec::new(); out.extend_from_slice(b"\x89PNG\r\n\x1a\n"); png_chunk(&mut out, b"IHDR", &ihdr_data); png_chunk(&mut out, b"IDAT", &idat); png_chunk(&mut out, b"IEND", &[]); std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")))?; std::fs::write(path, out) } fn make_ihdr(width: u32, height: u32) -> Vec { let mut d = Vec::new(); d.extend_from_slice(&width.to_be_bytes()); d.extend_from_slice(&height.to_be_bytes()); d.extend_from_slice(&[8, 2, 0, 0, 0]); // bit depth 8, colour type 2 (RGB) d } fn png_chunk(out: &mut Vec, tag: &[u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); out.extend_from_slice(tag); out.extend_from_slice(data); // CRC32 of (tag + data). let mut crc_input = Vec::new(); crc_input.extend_from_slice(tag); crc_input.extend_from_slice(data); out.extend_from_slice(&crc32(&crc_input).to_be_bytes()); } fn crc32(data: &[u8]) -> u32 { let mut table = [0u32; 256]; for (n, item) in table.iter_mut().enumerate() { let mut c = n as u32; for _ in 0..8 { c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } *item = c; } let mut c = 0xFFFF_FFFFu32; for &b in data { c = table[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8); } c ^ 0xFFFF_FFFF } fn adler32(data: &[u8]) -> u32 { let mut a = 1u32; let mut b = 0u32; for &byte in data { a = (a + byte as u32) % 65521; b = (b + a) % 65521; } (b << 16) | a } fn approx_eq_vec(a: &[f64], b: &[f64], tol: f64) -> bool { a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol) } /// Run the six check groups. Returns `(ok_count, first_error)`. pub fn run_selftest() -> Result { let n = 64u32; let mut ok = 0usize; // Group 1: every FACE_AXES rotation is valid. for face in math::FACES { let m = math::face_rotation_matrix(face); math::assert_valid_rotation(&m, &format!("FACE_AXES[{face}]"), 1e-9) .map_err(|e| format!("group 1: {e}"))?; } ok += 1; // Group 2: R<-F quaternion and 90° angle. let q_r = math::cam_from_rig_rotation("R", "F"); let sqrt2_2 = std::f64::consts::SQRT_2 / 2.0; let expected_r = [sqrt2_2, 0.0, -sqrt2_2, 0.0]; if !approx_eq_vec(&[q_r.0, q_r.1, q_r.2, q_r.3], &expected_r, 1e-12) { return Err(format!("group 2: R quaternion wrong: {q_r:?}")); } if (math::rotation_angle_deg(q_r) - 90.0).abs() >= 1e-9 { return Err("group 2: rotation angle not 90°".into()); } ok += 1; // Group 3: forward directions from each face's transpose mapping +Z. let r_mat = math::face_rotation_matrix("R"); let fwd_f = math::apply(&math::mat_transpose(&r_mat), &[0.0, 0.0, 1.0]); if !approx_eq_vec(&fwd_f, &[1.0, 0.0, 0.0], 1e-12) { return Err(format!("group 3: R forward wrong: {fwd_f:?}")); } for (face, expected) in [ ("B", [0.0, 0.0, -1.0]), ("L", [-1.0, 0.0, 0.0]), ("U", [0.0, -1.0, 0.0]), ("D", [0.0, 1.0, 0.0]), ] { let got = math::apply( &math::mat_transpose(&math::face_rotation_matrix(face)), &[0.0, 0.0, 1.0], ); if !approx_eq_vec(&got, &expected, 1e-12) { return Err(format!("group 3: face {face} forward wrong: {got:?}")); } } ok += 1; // Group 4: intrinsics. let (fx, fy, cx, cy) = math::compute_intrinsics(n, "linspace"); if !approx_eq_vec( &[fx, fy, cx, cy], &[ (n - 1) as f64 / 2.0, (n - 1) as f64 / 2.0, n as f64 / 2.0, n as f64 / 2.0, ], 0.0, ) { return Err(format!("group 4: intrinsics wrong: {fx},{fy},{cx},{cy}")); } ok += 1; // Group 5: full rig run on a built-up dataset. let tmp = std::env::temp_dir().join(format!("rig_selftest_{}", std::process::id())); let root = tmp.as_path(); let _ = std::fs::remove_dir_all(root); let flat = root.join("images"); let masks_flat = root.join("masks"); for i in 1..=2u32 { for face in math::FACES { write_dummy_png(&flat.join(format!("frame_{i:05}_{face}.png")), n, n) .map_err(|e| format!("group 5: write image: {e}"))?; } } for face in ["F", "R", "B", "L"] { write_dummy_png(&flat.join(format!("frame_00003_{face}.png")), n, n) .map_err(|e| format!("group 5: write partial image: {e}"))?; } for face in math::FACES { write_dummy_png( &masks_flat.join(format!("frame_00001_{face}.png.png")), n, n, ) .map_err(|e| format!("group 5: write mask: {e}"))?; } for face in ["F", "R"] { write_dummy_png( &masks_flat.join(format!("frame_00003_{face}.png.png")), n, n, ) .map_err(|e| format!("group 5: write partial mask: {e}"))?; } let out = root.join("rig.json"); let link_tree = root.join("nested"); let masks_link_tree = root.join("masks_nested"); let _res = rig::run_rig_stage( &flat, &out, Some(&masks_flat), None, Layout::Auto, "F", "linspace", Some(&link_tree), Some(&masks_link_tree), LinkMode::Copy, ) .map_err(|e| format!("group 5: run_rig_stage: {e}"))?; let text = std::fs::read_to_string(&out).map_err(|e| format!("group 5: read rig: {e}"))?; let config = json::parse(&text).map_err(|e| format!("group 5: parse rig json: {e}"))?; let cams = config .as_array() .and_then(|a| a.first()) .and_then(|v| v.find("cameras")) .and_then(|v| v.as_array()) .ok_or("group 5: no cameras array")?; if cams.len() != 6 { return Err(format!("group 5: expected 6 cameras, got {}", cams.len())); } let refs: Vec<&json::Value> = cams .iter() .filter(|c| matches!(c.find("ref_sensor"), Some(crate::json::Value::Bool(true)))) .collect(); if refs.len() != 1 { return Err(format!( "group 5: expected exactly 1 ref_sensor, got {}", refs.len() )); } if refs[0].find("image_prefix").and_then(|v| v.as_str()) != Some("F/") { return Err("group 5: ref prefix not F/".into()); } let mut by_prefix: std::collections::HashMap<&str, &json::Value> = std::collections::HashMap::new(); for c in cams { if let Some(p) = c.find("image_prefix").and_then(|v| v.as_str()) { by_prefix.insert(p, c); } } let got_r: Vec = by_prefix .get("R/") .and_then(|c| c.find("cam_from_rig_rotation")) .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_f64()).collect()) .ok_or("group 5: no R rotation")?; if !approx_eq_vec(&got_r, &expected_r, 1e-12) { return Err(format!("group 5: R rotation wrong: {got_r:?}")); } if !link_tree.join("F").join("frame_00001.png").is_file() { return Err("group 5: linked image missing".into()); } if !masks_link_tree .join("F") .join("frame_00001.png.png") .is_file() { return Err("group 5: linked mask missing".into()); } if masks_link_tree .join("F") .join("frame_00002.png.png") .exists() { return Err("group 5: unexpected frame_00002 mask".into()); } if !masks_link_tree .join("R") .join("frame_00003.png.png") .is_file() { return Err("group 5: partial frame_00003 mask missing".into()); } ok += 1; // Group 6: partial rig (only 4 faces). let partial_dir = root.join("partial"); for i in 1..=2u32 { for face in ["F", "R", "B", "L"] { write_dummy_png(&partial_dir.join(format!("frame_{i:05}_{face}.png")), n, n) .map_err(|e| format!("group 6: write partial: {e}"))?; } } let out2 = root.join("rig_partial.json"); let _ = rig::run_rig_stage( &partial_dir, &out2, None, None, Layout::Auto, "F", "linspace", None, None, LinkMode::Copy, ) .map_err(|e| format!("group 6: run_rig_stage: {e}"))?; let text2 = std::fs::read_to_string(&out2).map_err(|e| format!("group 6: read rig: {e}"))?; let cfg2 = json::parse(&text2).map_err(|e| format!("group 6: parse: {e}"))?; let cams2 = cfg2 .as_array() .and_then(|a| a.first()) .and_then(|v| v.find("cameras")) .and_then(|v| v.as_array()) .ok_or("group 6: no cameras")?; if cams2.len() != 4 { return Err(format!("group 6: expected 4 cameras, got {}", cams2.len())); } ok += 1; let _ = std::fs::remove_dir_all(root); Ok(ok) } #[cfg(test)] mod tests { use super::run_selftest; #[test] fn rig_selftest_passes() { let ok = run_selftest().expect("self-test must pass"); assert_eq!(ok, 6, "expected 6 check groups"); } }