211 lines
7.3 KiB
Rust
211 lines
7.3 KiB
Rust
//! Converts WorldGraph PersonTrack ENU positions into an [`OccupancyGrid3D`]
|
||
//! tensor suitable for submission to the OccWorld inference server (ADR-147).
|
||
//!
|
||
//! ## Voxel encoding
|
||
//! | Class index | Meaning |
|
||
//! |-------------|---------|
|
||
//! | 17 | Free space (default) |
|
||
//! | 10 | Person occupancy |
|
||
//!
|
||
//! The grid footprint is defined by axis-aligned [`SceneBounds`] in the local
|
||
//! ENU coordinate frame. The *z* / *up* dimension is always 16 voxels; the
|
||
//! floor voxel column for a given person is derived from their `up_m` value
|
||
//! clamped to `[0, depth-1]`.
|
||
|
||
use crate::OccupancyGrid3D;
|
||
|
||
/// Class index written into voxels that contain a detected person.
|
||
pub const CLASS_PERSON: u8 = 10;
|
||
/// Class index written into voxels that are free (unoccupied).
|
||
pub const CLASS_FREE: u8 = 17;
|
||
|
||
/// Number of voxels along the east/x axis (fixed at 200).
|
||
pub const GRID_WIDTH: usize = 200;
|
||
/// Number of voxels along the north/y axis (fixed at 200).
|
||
pub const GRID_HEIGHT: usize = 200;
|
||
/// Number of voxels along the up/z axis (fixed at 16).
|
||
pub const GRID_DEPTH: usize = 16;
|
||
|
||
/// Maximum height (metres) mapped onto the depth axis. Points above this
|
||
/// value are clamped to the topmost voxel.
|
||
const MAX_HEIGHT_M: f32 = 3.2; // 3.2 m / 16 voxels = 0.2 m per z-voxel
|
||
|
||
/// A single person position expressed in local ENU metres.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PersonPosition {
|
||
/// Stable track identifier (mirrors `WorldNode::PersonTrack::track_id`).
|
||
pub track_id: u64,
|
||
/// East offset from installation origin, in metres.
|
||
pub east_m: f64,
|
||
/// North offset from installation origin, in metres.
|
||
pub north_m: f64,
|
||
/// Up offset (height above floor), in metres.
|
||
pub up_m: f64,
|
||
}
|
||
|
||
/// Axis-aligned bounding box of the scene in the ENU plane.
|
||
///
|
||
/// Maps the *east* axis to the voxel *x* dimension and the *north* axis to
|
||
/// the voxel *y* dimension.
|
||
#[derive(Debug, Clone)]
|
||
pub struct SceneBounds {
|
||
/// Western (minimum east) edge of the scene, in metres.
|
||
pub min_e: f64,
|
||
/// Southern (minimum north) edge of the scene, in metres.
|
||
pub min_n: f64,
|
||
/// Eastern (maximum east) edge of the scene, in metres.
|
||
pub max_e: f64,
|
||
/// Northern (maximum north) edge of the scene, in metres.
|
||
pub max_n: f64,
|
||
}
|
||
|
||
impl SceneBounds {
|
||
/// Returns `(east_extent_m, north_extent_m)`. If either dimension
|
||
/// is zero or negative a default of `1.0` is used to avoid division by
|
||
/// zero.
|
||
fn extents(&self) -> (f64, f64) {
|
||
let e = (self.max_e - self.min_e).max(1.0);
|
||
let n = (self.max_n - self.min_n).max(1.0);
|
||
(e, n)
|
||
}
|
||
|
||
/// Maps a continuous ENU coordinate to `(vx, vy)` grid indices.
|
||
/// Out-of-bounds positions are clamped to the grid extent.
|
||
pub fn to_voxel_xy(&self, east_m: f64, north_m: f64) -> (usize, usize) {
|
||
let (e_ext, n_ext) = self.extents();
|
||
let fx = (east_m - self.min_e) / e_ext; // [0, 1]
|
||
let fy = (north_m - self.min_n) / n_ext; // [0, 1]
|
||
let vx = (fx * GRID_WIDTH as f64)
|
||
.floor()
|
||
.clamp(0.0, (GRID_WIDTH - 1) as f64) as usize;
|
||
let vy = (fy * GRID_HEIGHT as f64)
|
||
.floor()
|
||
.clamp(0.0, (GRID_HEIGHT - 1) as f64) as usize;
|
||
(vx, vy)
|
||
}
|
||
|
||
/// Maps a height value (metres) to a voxel *z* index in `[0, depth-1]`.
|
||
pub fn to_voxel_z(up_m: f64) -> usize {
|
||
let fz = (up_m as f32).clamp(0.0, MAX_HEIGHT_M) / MAX_HEIGHT_M;
|
||
let vz = (fz * GRID_DEPTH as f32)
|
||
.floor()
|
||
.clamp(0.0, (GRID_DEPTH - 1) as f32) as usize;
|
||
vz
|
||
}
|
||
}
|
||
|
||
/// Converts a list of person positions from the WorldGraph into a flat
|
||
/// [`OccupancyGrid3D`] tensor.
|
||
///
|
||
/// The voxel buffer is laid out as `[x, y, z]` with stride order
|
||
/// `voxels[z * height * width + y * width + x]` (row-major, depth last).
|
||
///
|
||
/// # Arguments
|
||
/// * `persons` – Slice of person ENU positions (may be empty).
|
||
/// * `bounds` – Axis-aligned scene footprint used to define the grid.
|
||
/// * `resolution_m` – Informational only; the grid is always 200×200×16 —
|
||
/// this value is echoed back in the IPC request for the Python server.
|
||
///
|
||
/// # Returns
|
||
/// An [`OccupancyGrid3D`] with `width = 200`, `height = 200`, `depth = 16`.
|
||
pub fn worldgraph_to_occupancy(
|
||
persons: &[PersonPosition],
|
||
bounds: &SceneBounds,
|
||
_resolution_m: f32,
|
||
) -> OccupancyGrid3D {
|
||
let total = GRID_WIDTH * GRID_HEIGHT * GRID_DEPTH;
|
||
let mut voxels = vec![CLASS_FREE; total];
|
||
|
||
for p in persons {
|
||
let (vx, vy) = bounds.to_voxel_xy(p.east_m, p.north_m);
|
||
let vz = SceneBounds::to_voxel_z(p.up_m);
|
||
|
||
let idx = vz * GRID_HEIGHT * GRID_WIDTH + vy * GRID_WIDTH + vx;
|
||
// `idx` is always in-bounds given the clamping above.
|
||
voxels[idx] = CLASS_PERSON;
|
||
}
|
||
|
||
OccupancyGrid3D {
|
||
width: GRID_WIDTH as u32,
|
||
height: GRID_HEIGHT as u32,
|
||
depth: GRID_DEPTH as u32,
|
||
voxels,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn default_bounds() -> SceneBounds {
|
||
SceneBounds {
|
||
min_e: -10.0,
|
||
min_n: -10.0,
|
||
max_e: 10.0,
|
||
max_n: 10.0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn empty_persons_all_free() {
|
||
let g = worldgraph_to_occupancy(&[], &default_bounds(), 0.1);
|
||
assert!(g.voxels.iter().all(|&v| v == CLASS_FREE));
|
||
assert_eq!(g.voxels.len(), GRID_WIDTH * GRID_HEIGHT * GRID_DEPTH);
|
||
}
|
||
|
||
#[test]
|
||
fn person_at_origin_maps_to_centre_voxel() {
|
||
let bounds = default_bounds(); // ±10 m; centre = (100, 100) in 200×200
|
||
let persons = vec![PersonPosition {
|
||
track_id: 1,
|
||
east_m: 0.0,
|
||
north_m: 0.0,
|
||
up_m: 0.0,
|
||
}];
|
||
let g = worldgraph_to_occupancy(&persons, &bounds, 0.1);
|
||
|
||
// At ENU (0,0,0): vx=100, vy=100, vz=0
|
||
let expected_idx = 0 * GRID_HEIGHT * GRID_WIDTH + 100 * GRID_WIDTH + 100;
|
||
assert_eq!(g.voxels[expected_idx], CLASS_PERSON);
|
||
// All other voxels must still be free
|
||
let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count();
|
||
assert_eq!(person_count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_bounds_position_is_clamped() {
|
||
let bounds = default_bounds();
|
||
let persons = vec![PersonPosition {
|
||
track_id: 2,
|
||
east_m: 99.0, // well outside max_e=10
|
||
north_m: 99.0,
|
||
up_m: 100.0,
|
||
}];
|
||
let g = worldgraph_to_occupancy(&persons, &bounds, 0.1);
|
||
// Should not panic; exactly one person voxel set
|
||
let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count();
|
||
assert_eq!(person_count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_persons_independent_voxels() {
|
||
let bounds = default_bounds();
|
||
let persons = vec![
|
||
PersonPosition { track_id: 1, east_m: -5.0, north_m: -5.0, up_m: 0.5 },
|
||
PersonPosition { track_id: 2, east_m: 5.0, north_m: 5.0, up_m: 1.5 },
|
||
];
|
||
let g = worldgraph_to_occupancy(&persons, &bounds, 0.1);
|
||
let person_count = g.voxels.iter().filter(|&&v| v == CLASS_PERSON).count();
|
||
assert_eq!(person_count, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn grid_dimensions_correct() {
|
||
let g = worldgraph_to_occupancy(&[], &default_bounds(), 0.4);
|
||
assert_eq!(g.width, 200);
|
||
assert_eq!(g.height, 200);
|
||
assert_eq!(g.depth, 16);
|
||
assert_eq!(g.voxels.len(), 200 * 200 * 16);
|
||
}
|
||
}
|