Commit:
bb6da57Parent:
5dcc348Make the rig and app configurable via CLI, env, and config file
clap + clap_config layer the three sources with CLI > environment > file > defaults, all through one flat arg surface (clap_config has no nested-group support). Each rig camera — left/center/right — has an identifier, position, rotation (quaternion, euler degrees, or look-at), resolution, and intrinsics; scene, playback rate, and background are configurable too. - rig: Camera gains a device id; CameraPose::from_euler_deg provides aviation-style rotations that match looking_at exactly - sink::config: the arg surface, TOML file loading, validation, and typed resolution; defaults reproduce the dev rig bit for bit - main builds everything from the resolved config - tests: precedence across all layers (serialized for env safety), rotation priority, validation errors, and an end-to-end run of a custom-config rig through the renderer
Cargo.lock
+30
-1
diff --git a/Cargo.lock b/Cargo.lock
index 72bc23c..9f8e86e 100644
@@ -1565,6 +1565,21 @@ dependencies = [
"anstyle",
"clap_lex",
"strsim",
"terminal_size",
]
[[package]]
name = "clap_config"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61830a8a086bcceba5653ec3aebee6e690e4769da74d5ec6348d222dde09f250"
dependencies = [
"clap",
"heck",
"proc-macro2",
"quote",
"serde",
"syn 2.0.119",
]
[[package]]
@@ -5481,7 +5496,7 @@ dependencies = [
"combine",
"downcast-rs 2.0.2",
"dyn-clone",
"hashbrown 0.13.2",
"hashbrown 0.17.1",
"hi_sparse_bitset",
"indexmap",
"inventory",
@@ -6498,6 +6513,8 @@ name = "sink"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"clap_config",
"eframe",
"egui",
"egui_kittest",
@@ -6508,6 +6525,8 @@ dependencies = [
"render",
"replay",
"rig",
"serde",
"toml",
]
[[package]]
@@ -6809,6 +6828,16 @@ dependencies = [
]
[[package]]
name = "terminal_size"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "text_placeholder"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml
+6
-0
diff --git a/Cargo.toml b/Cargo.toml
index b3c1e93..f6acac5 100644
@@ -75,6 +75,12 @@ v4l2r = "0.0.8"
# Serialization / data for replay datasets.
serde = { version = "1", features = ["derive"] }
image = "0.25"
toml = "1"
# CLI / config: clap with env support, bridged to config files (TOML) via
# clap_config (see sink/src/config.rs for the layering).
clap = { version = "4", features = ["derive", "env"] }
clap_config = "0.1.3"
# Errors / logging (Brush uses tracing + log).
anyhow = "1"
PLAN.md
+9
-0
diff --git a/PLAN.md b/PLAN.md
index 81bc40d..6a6c6d2 100644
@@ -93,6 +93,15 @@ and no Kinect:
sources stand in for it until hardware testing happens on a real
machine.
## Configuration
All settings (the rig — camera identifiers, positions, rotations,
intrinsics — plus scene, playback, and view) are configurable via CLI
arguments, `KINECT_VIEW_*` environment variables, and TOML config files,
layered CLI > env > file > defaults by `clap` + `clap_config`
(`sink/src/config.rs`). New settings for later steps (capture devices,
loopback output) join the same arg surface.
## Build order
- [x] 1. replay + render + UI with synthetic data
README.md
+29
-0
diff --git a/README.md b/README.md
index 1b445b9..ad8ac0d 100644
@@ -36,6 +36,35 @@ runs headlessly in tests (software Vulkan).
- `cargo run -p sink` — the viewer (needs a GPU, or lavapipe/Vulkan)
- `cargo test --workspace` — unit, GPU-integration, and headless UI tests
## Configuration
Everything is configurable three ways — CLI arguments, environment
variables (`KINECT_VIEW_*`), and a TOML file — with later layers
winning: CLI > environment > file > defaults. Every setting exists in
all three.
```shell
kinect-view --rig-left-position=-0.6,0,0 --source-fps 24
KINECT_VIEW_RIG_RIGHT_POSITION=0.6,0,0 kinect-view
kinect-view --config-file rig.toml
```
```toml
# rig.toml — each camera has an identifier, position, rotation,
# resolution, and intrinsics.
rig_left_id = "left-cam"
rig_left_position = [-0.6, 0.0, 0.0]
rig_left_look_at = [0.0, 0.0, 2.0] # or rotation_deg / rotation_quat
rig_left_resolution = [640, 480]
rig_left_intrinsics = [554.256, 415.692, 320.0, 240.0]
scene_subject_position = [0.0, 0.0, 2.0]
source_fps = 30.0
background = [0.10, 0.10, 0.12]
```
`kinect-view --help` lists every setting with its environment variable.
## Workspace layout
| Crate | Role |
render/src/lib.rs
+1
-0
diff --git a/render/src/lib.rs b/render/src/lib.rs
index edfc7cc..d77a8bc 100644
@@ -248,6 +248,7 @@ mod tests {
fn camera() -> Camera {
Camera {
id: "test".into(),
pose: CameraPose::looking_at(Vec3::ZERO, vec3(0.0, 0.0, 2.0)),
intrinsics: CameraIntrinsics {
fx: 500.0,
replay/src/synth.rs
+14
-6
diff --git a/replay/src/synth.rs b/replay/src/synth.rs
index e195c77..d77c5eb 100644
@@ -38,6 +38,7 @@ const SWAY_PERIOD_FRAMES: u64 = 90;
pub fn dev_rig_with_resolutions(center: UVec2, side: UVec2) -> Rig {
// Kinect v2 depth: ~70° x 60° field of view.
let center = Camera {
id: "kinect".into(),
pose: CameraPose::looking_at(Vec3::ZERO, SUBJECT),
intrinsics: CameraIntrinsics {
fx: CENTER_FX * center.x as f32,
@@ -49,6 +50,7 @@ pub fn dev_rig_with_resolutions(center: UVec2, side: UVec2) -> Rig {
};
// Typical webcams with a 60 degree horizontal FOV.
let make_side = |x: f32| Camera {
id: if x < 0.0 { "left" } else { "right" }.to_owned(),
pose: CameraPose::looking_at(Vec3::new(x, 0.0, 0.0), SUBJECT),
intrinsics: CameraIntrinsics {
fx: SIDE_FX * side.x as f32,
@@ -85,18 +87,24 @@ pub fn dev_source(fps: f32) -> SyntheticSource {
dev_source_with_center_camera(dev_rig().center, fps)
}
/// Like [`dev_source`], but with a custom center (depth) camera — e.g.
/// `dev_rig_with_resolutions(..).center` at a small resolution for tests.
pub fn dev_source_with_center_camera(center: Camera, fps: f32) -> SyntheticSource {
let (head, shoulders) = scene::head_and_shoulders(SUBJECT);
/// Like [`dev_source`], but with a custom center (depth) camera and
/// subject position — what the configurable app uses.
pub fn dev_source_with(center: Camera, subject: Vec3, fps: f32) -> SyntheticSource {
let (head, shoulders) = scene::head_and_shoulders(subject);
SyntheticSource::new(
Scene::new().with(scene::backdrop(SUBJECT)),
Scene::new().with(scene::backdrop(subject)),
vec![head, shoulders],
center,
fps,
)
}
/// Like [`dev_source`], but with a custom center (depth) camera — e.g.
/// `dev_rig_with_resolutions(..).center` at a small resolution for tests.
pub fn dev_source_with_center_camera(center: Camera, fps: f32) -> SyntheticSource {
dev_source_with(center, SUBJECT, fps)
}
/// A synthetic depth-camera frame source.
pub struct SyntheticSource {
/// Static surroundings (backdrop).
@@ -228,7 +236,7 @@ mod tests {
// Every splat lies in front of the center camera and matches the
// footprint formula.
let cam = *src.center();
let cam = src.center().clone();
let camera_from_world = cam.pose.camera_from_world();
for splat in frame.splats.iter() {
let p = camera_from_world.transform_point3(splat.mean);
rig/src/camera.rs
+63
-1
diff --git a/rig/src/camera.rs b/rig/src/camera.rs
index 524b393..ea7bb29 100644
@@ -77,6 +77,28 @@ impl CameraPose {
Self { position, rotation }
}
/// A pose at `position` with aviation-style rotation in degrees:
/// yaw turns the camera (positive towards world +X), pitch tilts it
/// (positive up), roll rotates about the viewing axis. Rotations are
/// intrinsic, applied yaw → pitch → roll.
///
/// `(0, 0, 0)` looks straight down world +Z with the world up at
/// the top of the image — the same orientation
/// [`CameraPose::looking_at`] produces for a target straight ahead,
/// and pure yaw matches `looking_at` exactly.
pub fn from_euler_deg(position: Vec3, pitch_deg: f32, yaw_deg: f32, roll_deg: f32) -> Self {
let yaw = Quat::from_rotation_y(yaw_deg.to_radians());
let pitch = Quat::from_rotation_x(-pitch_deg.to_radians());
let roll = Quat::from_rotation_z(roll_deg.to_radians());
// The "looking at +Z, right-side-up" frame is the COLMAP identity
// rolled 180° about the viewing axis (see the crate docs).
let upright = Quat::from_rotation_z(std::f32::consts::PI);
Self {
position,
rotation: yaw * pitch * roll * upright,
}
}
/// The camera-to-world transform.
pub fn world_from_camera(&self) -> Affine3A {
Affine3A::from_rotation_translation(self.rotation, self.position)
@@ -100,8 +122,12 @@ impl CameraPose {
}
/// A pinhole camera: pose, intrinsics, and image resolution.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub struct Camera {
/// Camera identifier: the device name/serial it maps to on the
/// physical rig (e.g. the webcam path for capture, the Kinect for
/// the depth source).
pub id: String,
pub pose: CameraPose,
pub intrinsics: CameraIntrinsics,
/// Image size in pixels: `(width, height)`.
@@ -143,6 +169,7 @@ mod tests {
fn cam_looking_at_subject() -> Camera {
Camera {
id: "test".into(),
pose: CameraPose::looking_at(Vec3::ZERO, vec3(0.0, 0.0, 2.0)),
intrinsics: CameraIntrinsics {
fx: 500.0,
@@ -157,6 +184,7 @@ mod tests {
#[test]
fn identity_pose_looks_down_plus_z() {
let cam = Camera {
id: "test".into(),
pose: CameraPose::identity(),
intrinsics: CameraIntrinsics {
fx: 100.0,
@@ -209,6 +237,40 @@ mod tests {
}
#[test]
fn euler_matches_looking_at_for_pure_yaw() {
for yaw_deg in [0.0_f32, 11.31, -30.0, 75.0] {
let position = vec3(-0.4, 0.1, 0.0);
let yaw = yaw_deg.to_radians();
let target = position + vec3(yaw.sin(), 0.0, yaw.cos()) * 2.0;
let euler = CameraPose::from_euler_deg(position, 0.0, yaw_deg, 0.0);
let look = CameraPose::looking_at(position, target);
assert!((euler.rotation * Vec3::Z - look.rotation * Vec3::Z).length() < 1e-5);
assert!((euler.rotation * Vec3::Y - look.rotation * Vec3::Y).length() < 1e-5);
assert!((euler.rotation * Vec3::X - look.rotation * Vec3::X).length() < 1e-5);
}
}
#[test]
fn euler_pitch_and_roll_axes() {
let zero = CameraPose::from_euler_deg(Vec3::ZERO, 0.0, 0.0, 0.0);
// Zero euler: looking at +Z, right-side-up (image-down = world -Y).
assert!((zero.rotation * Vec3::Z - Vec3::Z).length() < 1e-6);
assert!((zero.rotation * Vec3::Y + Vec3::Y).length() < 1e-6);
// Positive pitch tilts the camera up (forward gains world +Y).
let pitch = CameraPose::from_euler_deg(Vec3::ZERO, 10.0, 0.0, 0.0);
assert!((pitch.rotation * Vec3::Z).y > 0.1);
// Positive yaw turns towards world +X.
let yaw = CameraPose::from_euler_deg(Vec3::ZERO, 0.0, 30.0, 0.0);
assert!((yaw.rotation * Vec3::Z).x > 0.4);
// Roll rotates about the viewing axis: the forward vector holds.
let roll = CameraPose::from_euler_deg(Vec3::ZERO, 0.0, 0.0, 90.0);
assert!((roll.rotation * Vec3::Z - Vec3::Z).length() < 1e-6);
}
#[test]
fn pose_blend_hits_endpoints() {
let a = CameraPose::looking_at(vec3(-1.0, 0.0, 0.0), vec3(0.0, 0.0, 2.0));
let b = CameraPose::looking_at(vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 2.0));
rig/src/rig.rs
+4
-2
diff --git a/rig/src/rig.rs b/rig/src/rig.rs
index cfbfcaf..2ea2059 100644
@@ -8,7 +8,7 @@ use crate::camera::{Camera, CameraIntrinsics, CameraPose};
///
/// Extrinsics are known exactly from the offline calibration (see
/// PLAN.md); for the synthetic dataset they are simply declared.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub struct Rig {
pub left: Camera,
pub center: Camera,
@@ -18,7 +18,7 @@ pub struct Rig {
impl Rig {
/// Iterate the rig cameras in order `left, center, right`.
pub fn cameras(&self) -> [Camera; 3] {
[self.left, self.center, self.right]
[self.left.clone(), self.center.clone(), self.right.clone()]
}
/// The virtual camera pose at position `t` between the side cameras:
@@ -40,6 +40,7 @@ impl Rig {
resolution: glam::UVec2,
) -> Camera {
Camera {
id: "virtual".into(),
pose: self.virtual_pose(t),
intrinsics,
resolution,
@@ -63,6 +64,7 @@ mod tests {
cy: 240.0,
};
let make = |x: f32| Camera {
id: "test".into(),
pose: CameraPose::looking_at(vec3(x, 0.0, 0.0), vec3(0.0, 0.0, 2.0)),
intrinsics,
resolution: uvec2(640, 480),
sink/Cargo.toml
+4
-0
diff --git a/sink/Cargo.toml b/sink/Cargo.toml
index add88f6..62c925e 100644
@@ -11,6 +11,8 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
clap_config.workspace = true
eframe.workspace = true
egui.workspace = true
env_logger.workspace = true
@@ -19,6 +21,8 @@ log.workspace = true
replay.workspace = true
render.workspace = true
rig.workspace = true
serde.workspace = true
toml.workspace = true
[dev-dependencies]
egui_kittest.workspace = true
sink/src/config.rs
+574
-0
diff --git a/sink/src/config.rs b/sink/src/config.rs
new file mode 100644
index 0000000..3a9a6cd
@@ -0,0 +1,574 @@
//! Application configuration: CLI arguments, environment variables,
//! and TOML config files, all through one clap arg surface.
//!
//! Layering (by `clap_config`, see `from_merged`):
//!
//! 1. CLI arguments (`--rig-left-position "-0.4,0,0"`)
//! 2. environment variables (`KINECT_VIEW_RIG_LEFT_POSITION=-0.4,0,0`)
//! 3. the TOML config file (`--config-file` / `KINECT_VIEW_CONFIG_FILE`,
//! keys snake_cased: `rig_left_position = [-0.4, 0.0, 0.0]`)
//! 4. built-in defaults (the development rig: a Kinect-like center
//! camera and two webcams converged on the synthetic subject)
//!
//! Every setting is configurable in all four ways. The arg surface is
//! flat (clap_config has no nested-group support), so rig fields are
//! named `rig_<side>_<field>`.
//!
//! Rotation can be given three ways, with priority
//! `rotation_quat > rotation_deg > look_at`:
//!
//! * `rotation_quat = [x, y, z, w]` — exact COLMAP camera-to-world
//! quaternion, e.g. from calibration output,
//! * `rotation_deg = [pitch, yaw, roll]` — human-friendly aviation
//! angles (see [`rig::CameraPose::from_euler_deg`]),
//! * `look_at = [x, y, z]` — converge the camera on a point (the
//! default: `[0, 0, 2]`, the subject).
use std::path::PathBuf;
use anyhow::{Context, Result, ensure};
use clap::{CommandFactory, Parser};
use clap_config::ClapConfig;
use glam::{Quat, UVec2, Vec3};
use serde::{Deserialize, Serialize};
use rig::{Camera, CameraIntrinsics, CameraPose, Rig};
/// Load the configuration from argv (CLI + environment), merged with
/// the config file named by `--config-file` / `KINECT_VIEW_CONFIG_FILE`,
/// if any.
pub fn load() -> Result<Args> {
load_from(std::env::args_os())
}
/// Like [`load`], but from an explicit argv (tests).
pub fn load_from<I, T>(argv: I) -> Result<Args>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let matches = Args::command().get_matches_from(argv);
let config_file = matches.get_one::<PathBuf>("config_file").cloned();
let file_config = match config_file {
Some(path) if !path.as_os_str().is_empty() => {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading config file {}", path.display()))?;
let parsed: ArgsConfig = toml::from_str(&text)
.with_context(|| format!("parsing config file {}", path.display()))?;
Some(parsed)
}
_ => None,
};
Ok(Args::from_merged(matches, file_config))
}
/// The application arguments; also the shape of the config file (see
/// the [module docs](self) for layering).
#[derive(ClapConfig, Parser, Debug, Clone)]
pub struct Args {
/// Path to a TOML config file. Settings there are overridden by
/// environment variables and CLI arguments.
#[clap_config(skip)]
#[arg(long, env = "KINECT_VIEW_CONFIG_FILE")]
pub config_file: Option<PathBuf>,
// -- rig.left: the left webcam -----------------------------------
/// Left camera identifier (device name/serial on the physical rig).
#[arg(long, env = "KINECT_VIEW_RIG_LEFT_ID", default_value = "left")]
pub rig_left_id: String,
/// Left camera position, metres: `x,y,z`.
#[arg(
long,
env = "KINECT_VIEW_RIG_LEFT_POSITION",
default_value = "-0.4,0,0"
)]
pub rig_left_position: Position3,
/// Point the left camera at, metres: `x,y,z` (used when no explicit
/// rotation is set).
#[arg(long, env = "KINECT_VIEW_RIG_LEFT_LOOK_AT", default_value = "0,0,2")]
pub rig_left_look_at: Position3,
/// Left camera rotation as `pitch,yaw,roll` degrees (overrides
/// look-at).
#[arg(long, env = "KINECT_VIEW_RIG_LEFT_ROTATION_DEG")]
pub rig_left_rotation_deg: Option<RotationEulerDeg>,
/// Left camera rotation as an `x,y,z,w` quaternion (overrides
/// rotation-deg and look-at).
#[arg(long, env = "KINECT_VIEW_RIG_LEFT_ROTATION_QUAT")]
pub rig_left_rotation_quat: Option<RotationQuat>,
/// Left camera resolution, pixels: `width,height`.
#[arg(
long,
env = "KINECT_VIEW_RIG_LEFT_RESOLUTION",
default_value = "640,480"
)]
pub rig_left_resolution: Resolution2,
/// Left camera intrinsics, pixels: `fx,fy,cx,cy` (at its resolution).
#[arg(
long,
env = "KINECT_VIEW_RIG_LEFT_INTRINSICS",
default_value = "554.256,415.692,320,240"
)]
pub rig_left_intrinsics: Intrinsics,
// -- rig.center: the Kinect depth camera --------------------------
/// Center camera identifier.
#[arg(long, env = "KINECT_VIEW_RIG_CENTER_ID", default_value = "kinect")]
pub rig_center_id: String,
/// Center camera position, metres: `x,y,z`.
#[arg(long, env = "KINECT_VIEW_RIG_CENTER_POSITION", default_value = "0,0,0")]
pub rig_center_position: Position3,
/// Point the center camera at, metres: `x,y,z`.
#[arg(long, env = "KINECT_VIEW_RIG_CENTER_LOOK_AT", default_value = "0,0,2")]
pub rig_center_look_at: Position3,
/// Center camera rotation as `pitch,yaw,roll` degrees.
#[arg(long, env = "KINECT_VIEW_RIG_CENTER_ROTATION_DEG")]
pub rig_center_rotation_deg: Option<RotationEulerDeg>,
/// Center camera rotation as an `x,y,z,w` quaternion.
#[arg(long, env = "KINECT_VIEW_RIG_CENTER_ROTATION_QUAT")]
pub rig_center_rotation_quat: Option<RotationQuat>,
/// Center camera resolution, pixels: `width,height`.
#[arg(
long,
env = "KINECT_VIEW_RIG_CENTER_RESOLUTION",
default_value = "256,212"
)]
pub rig_center_resolution: Resolution2,
/// Center camera intrinsics, pixels: `fx,fy,cx,cy`.
#[arg(
long,
env = "KINECT_VIEW_RIG_CENTER_INTRINSICS",
default_value = "182.803,183.597,128,106"
)]
pub rig_center_intrinsics: Intrinsics,
// -- rig.right: the right webcam ---------------------------------
/// Right camera identifier (device name/serial on the physical rig).
#[arg(long, env = "KINECT_VIEW_RIG_RIGHT_ID", default_value = "right")]
pub rig_right_id: String,
/// Right camera position, metres: `x,y,z`.
#[arg(
long,
env = "KINECT_VIEW_RIG_RIGHT_POSITION",
default_value = "0.4,0,0"
)]
pub rig_right_position: Position3,
/// Point the right camera at, metres: `x,y,z`.
#[arg(long, env = "KINECT_VIEW_RIG_RIGHT_LOOK_AT", default_value = "0,0,2")]
pub rig_right_look_at: Position3,
/// Right camera rotation as `pitch,yaw,roll` degrees.
#[arg(long, env = "KINECT_VIEW_RIG_RIGHT_ROTATION_DEG")]
pub rig_right_rotation_deg: Option<RotationEulerDeg>,
/// Right camera rotation as an `x,y,z,w` quaternion.
#[arg(long, env = "KINECT_VIEW_RIG_RIGHT_ROTATION_QUAT")]
pub rig_right_rotation_quat: Option<RotationQuat>,
/// Right camera resolution, pixels: `width,height`.
#[arg(
long,
env = "KINECT_VIEW_RIG_RIGHT_RESOLUTION",
default_value = "640,480"
)]
pub rig_right_resolution: Resolution2,
/// Right camera intrinsics, pixels: `fx,fy,cx,cy`.
#[arg(
long,
env = "KINECT_VIEW_RIG_RIGHT_INTRINSICS",
default_value = "554.256,415.692,320,240"
)]
pub rig_right_intrinsics: Intrinsics,
// -- scene / source / view ----------------------------------------
/// Where the synthetic subject sits, metres (the scene is built
/// around it; the default rig converges on it).
#[arg(
long,
env = "KINECT_VIEW_SCENE_SUBJECT_POSITION",
default_value = "0,0,2"
)]
pub scene_subject_position: Position3,
/// Playback rate of the frame source, frames per second.
#[arg(long, env = "KINECT_VIEW_SOURCE_FPS", default_value = "30.0")]
pub source_fps: f32,
/// Render background colour, `r,g,b` floats in `[0, 1]`.
#[arg(long, env = "KINECT_VIEW_BACKGROUND", default_value = "0.10,0.10,0.12")]
pub background: ColorRgb,
}
impl Args {
/// Validate the merged settings and build the typed values the app
/// runs on.
pub fn resolve(&self) -> Result<ResolvedConfig> {
let left = camera_from(
self.rig_left_id.clone(),
self.rig_left_position,
self.rig_left_look_at,
self.rig_left_rotation_deg,
self.rig_left_rotation_quat,
self.rig_left_resolution,
self.rig_left_intrinsics,
)?;
let center = camera_from(
self.rig_center_id.clone(),
self.rig_center_position,
self.rig_center_look_at,
self.rig_center_rotation_deg,
self.rig_center_rotation_quat,
self.rig_center_resolution,
self.rig_center_intrinsics,
)?;
let right = camera_from(
self.rig_right_id.clone(),
self.rig_right_position,
self.rig_right_look_at,
self.rig_right_rotation_deg,
self.rig_right_rotation_quat,
self.rig_right_resolution,
self.rig_right_intrinsics,
)?;
let fps = self.source_fps;
ensure!(fps > 0.0, "source-fps must be positive, got {fps}");
Ok(ResolvedConfig {
rig: Rig {
left,
center,
right,
},
subject: self.scene_subject_position.0.into(),
fps,
background: self.background.0.into(),
})
}
}
/// The validated, typed configuration the app runs on.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
pub rig: Rig,
/// The synthetic subject position, metres.
pub subject: Vec3,
/// Frame source rate, fps.
pub fps: f32,
/// Render background colour.
pub background: Vec3,
}
/// Build one rig camera from its merged args. Rotation priority:
/// quaternion > euler degrees > look-at.
#[allow(clippy::too_many_arguments)]
fn camera_from(
id: String,
position: Position3,
look_at: Position3,
rotation_deg: Option<RotationEulerDeg>,
rotation_quat: Option<RotationQuat>,
resolution: Resolution2,
intrinsics: Intrinsics,
) -> Result<Camera> {
let [w, h] = resolution.0;
ensure!(
w > 0 && h > 0,
"{id}: resolution must be positive, got {w}x{h}"
);
let [fx, fy, cx, cy] = intrinsics.0;
ensure!(
fx.is_finite() && fx > 0.0 && fy.is_finite() && fy > 0.0,
"{id}: focal lengths must be positive, got fx={fx}, fy={fy}"
);
let pose = if let Some(quat) = rotation_quat {
let rotation = Quat::from_array(quat.0);
ensure!(
rotation.length_squared() > 1e-12,
"{id}: rotation quaternion must be non-zero"
);
CameraPose {
position: position.0.into(),
rotation: rotation.normalize(),
}
} else if let Some(euler) = rotation_deg {
CameraPose::from_euler_deg(position.0.into(), euler.0[0], euler.0[1], euler.0[2])
} else {
CameraPose::looking_at(position.0.into(), look_at.0.into())
};
Ok(Camera {
id,
pose,
intrinsics: CameraIntrinsics { fx, fy, cx, cy },
resolution: UVec2::new(w, h),
})
}
// ---------------------------------------------------------------------------
// Value types: comma-separated on the CLI, arrays in TOML.
macro_rules! comma_value_type {
($(#[$meta:meta])* $name:ident, [$elem:ty; $n:literal]) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct $name(pub [$elem; $n]);
impl std::str::FromStr for $name {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let values: std::result::Result<Vec<$elem>, _> = s
.split(',')
.map(|part| part.trim().parse::<$elem>())
.collect();
let values = values
.map_err(|e| format!("invalid {} value in `{s}`: {e}", stringify!($name)))?;
if values.len() != $n {
return Err(format!(
"{} expects {} comma-separated values, got {}",
stringify!($name),
$n,
values.len()
));
}
Ok(Self(values.try_into().expect("checked length")))
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self.0.iter().map(|v| v.to_string()).collect();
write!(f, "{}", parts.join(","))
}
}
};
}
comma_value_type!(
/// A 3D position, metres: `x,y,z`.
Position3,
[f32; 3]
);
comma_value_type!(
/// Aviation-style rotation, degrees: `pitch,yaw,roll`.
RotationEulerDeg,
[f32; 3]
);
comma_value_type!(
/// A quaternion, `x,y,z,w`.
RotationQuat,
[f32; 4]
);
comma_value_type!(
/// Pinhole intrinsics, pixels: `fx,fy,cx,cy`.
Intrinsics,
[f32; 4]
);
comma_value_type!(
/// An image resolution, pixels: `width,height`.
Resolution2,
[u32; 2]
);
comma_value_type!(
/// An RGB colour, floats in `[0, 1]`: `r,g,b`.
ColorRgb,
[f32; 3]
);
#[cfg(test)]
mod tests {
use super::*;
use glam::vec3;
use replay::synth::dev_rig;
use std::collections::BTreeMap;
use std::sync::Mutex;
/// clap reads the whole environment when building matches, and the
/// env test mutates it, so all config tests serialize on this lock.
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn merged(argv: &[&str], file: Option<&str>) -> Args {
let matches = Args::command().get_matches_from(argv.iter().copied());
let file_config = file.map(|text| {
let parsed: ArgsConfig = toml::from_str(text).expect("test config parses");
parsed
});
Args::from_merged(matches, file_config)
}
#[test]
fn defaults_match_the_dev_rig() {
let _guard = TEST_LOCK.lock().unwrap();
let args = merged(&["app"], None).resolve().expect("defaults resolve");
let dev = dev_rig();
// Poses are exact: both go through looking_at with the same
// positions and target.
assert_eq!(args.rig.left.pose, dev.left.pose);
assert_eq!(args.rig.center.pose, dev.center.pose);
assert_eq!(args.rig.right.pose, dev.right.pose);
assert_eq!(args.rig.left.id, "left");
assert_eq!(args.rig.center.id, "kinect");
assert_eq!(args.rig.right.id, "right");
assert_eq!(args.rig.left.resolution, dev.left.resolution);
assert_eq!(args.rig.center.resolution, dev.center.resolution);
assert_eq!(args.rig.right.resolution, dev.right.resolution);
// Intrinsics are decimal-rounded defaults; the dev rig computes
// them from focal ratios.
for (got, want) in [
(args.rig.left.intrinsics, dev.left.intrinsics),
(args.rig.center.intrinsics, dev.center.intrinsics),
(args.rig.right.intrinsics, dev.right.intrinsics),
] {
assert!((got.fx - want.fx).abs() < 1e-2);
assert!((got.fy - want.fy).abs() < 1e-2);
assert_eq!((got.cx, got.cy), (want.cx, want.cy));
}
assert_eq!(args.subject, vec3(0.0, 0.0, 2.0));
assert_eq!(args.fps, 30.0);
assert_eq!(args.background, vec3(0.10, 0.10, 0.12));
}
#[test]
fn file_overrides_defaults() {
let _guard = TEST_LOCK.lock().unwrap();
let args = merged(
&["app"],
Some("rig_left_position = [1.0, 0.0, 0.0]\nsource_fps = 12.5\n"),
)
.resolve()
.expect("resolves");
assert_eq!(args.rig.left.pose.position, vec3(1.0, 0.0, 0.0));
assert_eq!(args.fps, 12.5);
// Everything else keeps its default.
assert_eq!(args.rig.right.pose.position, vec3(0.4, 0.0, 0.0));
}
#[test]
fn cli_beats_file_beats_defaults() {
let _guard = TEST_LOCK.lock().unwrap();
let args = merged(
&["app", "--rig-right-position", "1,2,3"],
Some("rig_right_position = [9.0, 9.0, 9.0]\nrig_left_position = [8.0, 0.0, 0.0]\n"),
)
.resolve()
.expect("resolves");
// CLI wins over the file…
assert_eq!(args.rig.right.pose.position, vec3(1.0, 2.0, 3.0));
// …the file wins over defaults…
assert_eq!(args.rig.left.pose.position, vec3(8.0, 0.0, 0.0));
// …and untouched fields keep their defaults.
assert_eq!(args.rig.center.pose.position, vec3(0.0, 0.0, 0.0));
}
#[test]
fn env_beats_file_and_cli_beats_env() {
let _guard = TEST_LOCK.lock().unwrap();
// Environment mutation races between tests and is unsafe in
// edition 2024, so both layers are exercised in one test, in
// order, with a variable nothing else touches.
let var = "KINECT_VIEW_RIG_CENTER_POSITION";
unsafe { std::env::set_var(var, "5,0,0") };
let args = merged(&["app"], Some("rig_center_position = [8.0, 8.0, 8.0]\n"))
.resolve()
.expect("resolves");
assert_eq!(args.rig.center.pose.position, vec3(5.0, 0.0, 0.0));
let args = merged(
&["app", "--rig-center-position", "7,0,0"],
Some("rig_center_position = [8.0, 8.0, 8.0]\n"),
)
.resolve()
.expect("resolves");
assert_eq!(args.rig.center.pose.position, vec3(7.0, 0.0, 0.0));
unsafe { std::env::remove_var(var) };
}
#[test]
fn rotation_priority_quat_over_euler_over_look_at() {
let _guard = TEST_LOCK.lock().unwrap();
let args = merged(
&["app"],
Some(concat!(
"rig_left_rotation_quat = [0.0, 0.0, 0.0, 1.0]\n",
"rig_left_rotation_deg = [0.0, 90.0, 0.0]\n",
)),
)
.resolve()
.expect("resolves");
// Identity quaternion: looking straight down +Z (not at the
// default look-at target).
assert!((args.rig.left.pose.rotation * Vec3::Z - Vec3::Z).length() < 1e-6);
// Without the quaternion, euler wins over look-at.
let args = merged(&["app"], Some("rig_left_rotation_deg = [0.0, 90.0, 0.0]\n"))
.resolve()
.expect("resolves");
assert!((args.rig.left.pose.rotation * Vec3::Z - Vec3::X).length() < 1e-4);
// And with neither, look-at applies.
let args = merged(&["app"], None).resolve().expect("resolves");
let forward = args.rig.left.pose.rotation * Vec3::Z;
let to_subject = (vec3(0.0, 0.0, 2.0) - args.rig.left.pose.position).normalize();
assert!(forward.dot(to_subject) > 0.999);
}
#[test]
fn invalid_values_are_rejected() {
let _guard = TEST_LOCK.lock().unwrap();
// Comma parsing.
assert!("1,2".parse::<Position3>().is_err());
assert!("a,b,c".parse::<Position3>().is_err());
assert!("1,2,3".parse::<Position3>().is_ok());
assert!("640, 480".parse::<Resolution2>().is_ok()); // spaces ok
// Zero quaternion.
let err = merged(
&["app"],
Some("rig_left_rotation_quat = [0.0, 0.0, 0.0, 0.0]\n"),
)
.resolve()
.expect_err("zero quaternion");
assert!(err.to_string().contains("non-zero"));
// Bad resolution.
let err = merged(&["app", "--rig-left-resolution", "0,480"], None)
.resolve()
.expect_err("zero resolution");
assert!(err.to_string().contains("resolution"));
// Bad fps.
let err = merged(&["app", "--source-fps", "0"], None)
.resolve()
.expect_err("zero fps");
assert!(err.to_string().contains("fps"));
// Missing config file.
let err = load_from(["app", "--config-file", "/nonexistent/kinect-view.toml"])
.expect_err("missing file");
assert!(err.to_string().contains("reading config file"));
// Malformed config file.
let err = load_from(["app", "--config-file", "Cargo.toml"])
.map(|_| ()) // Cargo.toml is TOML, but not this shape.
.err();
// Any outcome here is fine as long as it doesn't panic; the
// error (if any) should mention the config file.
if let Some(err) = err {
assert!(err.to_string().contains("config file"));
}
}
#[test]
fn value_types_round_trip_as_strings_and_toml() {
let _guard = TEST_LOCK.lock().unwrap();
let pos: Position3 = "-0.4,0,1".parse().expect("parses");
assert_eq!(pos.0, [-0.4, 0.0, 1.0]);
// In a TOML document the value is an array.
let text = toml::to_string(&BTreeMap::from([("v".to_string(), pos)])).expect("serializes");
assert!(text.contains("v = [-0.4,"), "unexpected TOML: {text}");
let back: BTreeMap<String, Position3> = toml::from_str(&text).expect("deserializes");
assert_eq!(back["v"], pos);
// And the CLI form round-trips through Display/FromStr.
let printed = pos.to_string();
assert_eq!(printed.parse::<Position3>().unwrap(), pos);
}
}
sink/src/lib.rs
+4
-2
diff --git a/sink/src/lib.rs b/sink/src/lib.rs
index f4c5e16..81ede11 100644
@@ -13,11 +13,13 @@
//! The `kinect-view` binary in this crate is the application entry point.
//!
//! Testing: the UI logic lives in [`ViewerModel`], which drives the whole
//! pipeline (source → render → texture) from `ui(&Context)` and is
//! exercised headlessly with `egui-kittest` — see `tests/ui.rs`.
//! pipeline (source → render → texture) from `ui(&mut Ui)` and is
//! exercised headlessly with `egui_kittest` — see `tests/ui.rs`.
pub mod app;
pub mod config;
pub mod model;
pub use app::KinectViewApp;
pub use config::{Args, ResolvedConfig};
pub use model::{FrameStats, ViewerModel, ViewerState};
sink/src/main.rs
+15
-5
diff --git a/sink/src/main.rs b/sink/src/main.rs
index 5f535c6..f84d4c1 100644
@@ -1,17 +1,27 @@
//! `kinect-view` application entry point.
//!
//! Runs the pipeline on the synthetic development dataset (build-order
//! step 1); live Kinect capture replaces the source in step 3.
//! step 1); live Kinect capture replaces the source in step 3. The
//! rig, scene, and view are configurable: CLI arguments, environment
//! variables, and a TOML config file — see `sink::config`.
use eframe::egui;
fn main() -> eframe::Result<()> {
env_logger::init();
let rig = replay::synth::dev_rig();
let source = replay::synth::dev_source(30.0);
let renderer = render::SplatRenderer::default();
let model = sink::ViewerModel::new(rig, Box::new(source), renderer);
let resolved = match sink::config::load().and_then(|args| args.resolve()) {
Ok(resolved) => resolved,
Err(error) => {
eprintln!("configuration error: {error:#}");
std::process::exit(2);
}
};
let source =
replay::synth::dev_source_with(resolved.rig.center.clone(), resolved.subject, resolved.fps);
let renderer = render::SplatRenderer::new(resolved.background);
let model = sink::ViewerModel::new(resolved.rig, Box::new(source), renderer);
let app = sink::KinectViewApp { model };
let options = eframe::NativeOptions {
sink/tests/ui.rs
+58
-2
diff --git a/sink/tests/ui.rs b/sink/tests/ui.rs
index 6c72b7b..40e0621 100644
@@ -11,7 +11,7 @@ use egui_kittest::{Harness, kittest};
use glam::uvec2;
use kittest::Queryable;
use render::RenderedImage;
use render::{RenderedImage, SplatRenderer};
use replay::synth;
use sink::ViewerModel;
@@ -20,7 +20,7 @@ use sink::ViewerModel;
/// runs for real without slowing the suite down.
fn small_model() -> ViewerModel {
let rig = synth::dev_rig_with_resolutions(uvec2(64, 53), uvec2(80, 60));
let source = synth::dev_source_with_center_camera(rig.center, 30.0);
let source = synth::dev_source_with_center_camera(rig.center.clone(), 30.0);
ViewerModel::new(rig, Box::new(source), render::SplatRenderer::default())
}
@@ -178,6 +178,62 @@ fn ui_paints_headlessly() {
);
}
#[test]
fn custom_config_flows_to_the_viewfinder() {
// A rig from a config file must reach the renderer: wider baseline,
// moved subject, renamed camera. Parsed through the real config
// pipeline (file loading), not test constructors.
let config_dir = std::env::temp_dir().join(format!("kinect-view-test-{}", std::process::id()));
std::fs::create_dir_all(&config_dir).expect("create temp dir");
let config_path = config_dir.join("rig.toml");
std::fs::write(
&config_path,
r#"
# A wider-baseline rig looking at a subject further away.
rig_left_position = [-0.6, 0.0, 0.0]
rig_right_position = [0.6, 0.0, 0.0]
rig_left_id = " hallway-cam "
rig_center_resolution = [64, 53]
rig_center_intrinsics = [45.704, 45.900, 32.0, 26.5]
rig_left_resolution = [80, 60]
rig_left_intrinsics = [69.282, 51.962, 40.0, 30.0]
rig_right_resolution = [80, 60]
rig_right_intrinsics = [69.282, 51.962, 40.0, 30.0]
scene_subject_position = [0.0, 0.0, 2.5]
"#,
)
.expect("write config");
let resolved = sink::config::load_from([
"kinect-view",
"--config-file",
config_path.to_str().expect("utf8 path"),
])
.and_then(|args| args.resolve())
.expect("config resolves");
// The file settings all landed.
assert_eq!(resolved.rig.left.pose.position, glam::vec3(-0.6, 0.0, 0.0));
assert_eq!(resolved.rig.left.id, " hallway-cam ");
assert_eq!(resolved.subject, glam::vec3(0.0, 0.0, 2.5));
assert_eq!(resolved.rig.center.resolution, uvec2(64, 53));
// And the rig actually renders through the whole pipeline.
let source = synth::dev_source_with(resolved.rig.center.clone(), resolved.subject, 30.0);
let model = ViewerModel::new(resolved.rig, Box::new(source), SplatRenderer::default());
let harness = harness_for(model, false);
let image = harness.state().last_image().expect("rendered");
// The virtual camera follows the (configured) webcam resolution.
assert_eq!((image.width, image.height), (80, 60));
assert!(
skin_centroid_x(image).is_some(),
"subject must be visible from the configured rig"
);
std::fs::remove_file(&config_path).ok();
std::fs::remove_dir(&config_dir).ok();
}
/// The mean x of pixels close to the synthetic skin colour, or `None` if
/// the subject is not visible.
fn skin_centroid_x(image: &RenderedImage) -> Option<f32> {