mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
feat(worldmodel): ADR-147 — OccWorld world model integration, wifi-densepose-worldmodel v0.3.0 (#856)
* feat(worldmodel): ADR-147 — OccWorld integration, wifi-densepose-worldmodel v0.3.0 (#854) - New crate `wifi-densepose-worldmodel` v0.3.0: async Unix-socket bridge to OccWorld Python inference server; `OccWorldBridge`, `OccupancyGrid3D`, `TrajectoryPrior`, `worldgraph_to_occupancy` encoder (14/14 tests pass) - `scripts/occworld_server.py`: long-lived Python inference server for OccWorld TransVQVAE (72.4M params); applies API-bug patches; dummy mode for CI testing; graceful SIGTERM shutdown - `pose_tracker.rs`: `trajectory_prior` soft-blend injection (80/20 Kalman/prior) on torso keypoint; `set_trajectory_prior()` public method - CI: added `Run ADR-147 worldmodel tests` step - ADR-147: accepted — OccWorld primary (209 ms, 3.37 GB VRAM, RTX 5080); Cosmos deferred to ADR-148 (32.54 GB VRAM exceeds hardware) - Benchmark proof: 208.7 ms P50, 3.37 GB peak VRAM, 12.1 GB headroom Co-Authored-By: claude-flow <ruv@ruv.net> * chore: update ruvector.db state Co-Authored-By: claude-flow <ruv@ruv.net> * chore: ruvector.db sync Co-Authored-By: claude-flow <ruv@ruv.net> * fix(cli): add missing min_frames field to CalibrateArgs test helper E0063 in calibrate.rs:448 — CalibrateArgs gained min_frames in ADR-135 but the default_args() test helper was not updated. min_frames=0 means 'use tier default', matching the existing runtime behaviour. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+15
-4
@@ -10565,7 +10565,7 @@ checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc",
|
||||
@@ -10608,7 +10608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
@@ -10770,7 +10770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-ruvector"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -10820,7 +10820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
@@ -10934,6 +10934,17 @@ dependencies = [
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -55,6 +55,9 @@ members = [
|
||||
# WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class),
|
||||
# 121 (identity risk), 122 (HA/Matter), 123 (capture path).
|
||||
"crates/wifi-densepose-bfld",
|
||||
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
|
||||
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
|
||||
"crates/wifi-densepose-worldmodel",
|
||||
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
|
||||
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
|
||||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
@@ -200,6 +203,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
|
||||
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
|
||||
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
|
||||
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
@@ -453,6 +453,7 @@ mod tests {
|
||||
tier: "ht20".into(),
|
||||
banner_every: 20,
|
||||
abort_z_threshold: 2.0,
|
||||
min_frames: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,9 @@ pub struct PoseTrack {
|
||||
pub created_at: u64,
|
||||
/// Last update timestamp in microseconds.
|
||||
pub updated_at: u64,
|
||||
/// Optional trajectory prior from OccWorld — position hint for next N frames.
|
||||
/// Each entry is (east_m, north_m, up_m) for frame t+1, t+2, ...
|
||||
pub trajectory_prior: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl PoseTrack {
|
||||
@@ -296,18 +299,44 @@ impl PoseTrack {
|
||||
consecutive_hits: 1,
|
||||
created_at: timestamp_us,
|
||||
updated_at: timestamp_us,
|
||||
trajectory_prior: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Predict all keypoints forward by dt seconds.
|
||||
///
|
||||
/// If a trajectory prior is loaded, pops the first waypoint and applies it
|
||||
/// as a soft measurement on the torso keypoint (index 8, MID_HIP/centroid):
|
||||
/// blended position = 0.80 * Kalman_prediction + 0.20 * prior_waypoint.
|
||||
pub fn predict(&mut self, dt: f32, process_noise: f32) {
|
||||
for kp in &mut self.keypoints {
|
||||
kp.predict(dt, process_noise);
|
||||
}
|
||||
|
||||
// Apply trajectory prior soft blend to torso keypoint (index 8).
|
||||
if !self.trajectory_prior.is_empty() {
|
||||
let waypoint = self.trajectory_prior.remove(0);
|
||||
// Torso keypoint index 8 (MID_HIP / centroid anchor).
|
||||
const TORSO_KP: usize = 8;
|
||||
let kp = &mut self.keypoints[TORSO_KP];
|
||||
kp.state[0] = 0.80 * kp.state[0] + 0.20 * waypoint[0];
|
||||
kp.state[1] = 0.80 * kp.state[1] + 0.20 * waypoint[1];
|
||||
kp.state[2] = 0.80 * kp.state[2] + 0.20 * waypoint[2];
|
||||
}
|
||||
|
||||
self.age += 1;
|
||||
self.time_since_update += 1;
|
||||
}
|
||||
|
||||
/// Set (or replace) the trajectory prior for this track.
|
||||
///
|
||||
/// The prior is a sequence of position hints `[east_m, north_m, up_m]`
|
||||
/// for frames t+1, t+2, … provided by an OccWorld predictor. Each call to
|
||||
/// [`Self::predict`] consumes the first entry from the front.
|
||||
pub fn set_trajectory_prior(&mut self, prior: Vec<[f32; 3]>) {
|
||||
self.trajectory_prior = prior;
|
||||
}
|
||||
|
||||
/// Update all keypoints with new measurements.
|
||||
///
|
||||
/// Also updates lifecycle state transitions based on birth/loss gates.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
description = "ADR-147 — OccWorld thin-client bridge: WorldGraph PersonTrack history → OccWorld Python subprocess → TrajectoryPrior"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["net", "io-util", "macros", "time"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-worldgraph = { path = "../wifi-densepose-worldgraph" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Async Unix-socket client that sends an [`OccupancyWorldModelRequest`] to
|
||||
//! the OccWorld Python inference server and receives an
|
||||
//! [`OccupancyWorldModelResponse`] (ADR-147).
|
||||
//!
|
||||
//! ## Protocol
|
||||
//! Communication uses newline-delimited JSON over a Unix-domain stream socket:
|
||||
//! 1. Connect to the socket path.
|
||||
//! 2. Write the JSON-serialised request followed by a single `\n` byte.
|
||||
//! 3. Read bytes until the first `\n`; decode as JSON response.
|
||||
//!
|
||||
//! A hard 30-second wall-clock timeout wraps the entire operation.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::error::WorldModelError;
|
||||
use crate::{OccupancyWorldModelRequest, OccupancyWorldModelResponse};
|
||||
|
||||
/// Hard deadline applied to each inference round-trip.
|
||||
const TIMEOUT_S: u64 = 30;
|
||||
|
||||
/// Maximum number of bytes accepted for a single response line.
|
||||
///
|
||||
/// 200×200×16 future frames × 15 steps × ~1 byte/voxel = ~9.6 MB in the
|
||||
/// worst case; set a generous 64 MB ceiling to stay safe without allocating
|
||||
/// it up front.
|
||||
const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// Thin async client for the OccWorld Unix-socket inference server.
|
||||
///
|
||||
/// Instances are cheap to clone (they only hold a [`PathBuf`]) and are safe
|
||||
/// to share across threads. A fresh TCP-free connection is established for
|
||||
/// every [`OccWorldBridge::predict`] call so the server can restart between
|
||||
/// requests without invalidating a long-lived connection handle.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OccWorldBridge {
|
||||
/// Path to the Unix-domain socket served by the OccWorld Python process.
|
||||
pub socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl OccWorldBridge {
|
||||
/// Creates a new bridge pointing at the given Unix-domain socket path.
|
||||
pub fn new(socket_path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
socket_path: socket_path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the OccWorld server and returns the decoded
|
||||
/// response, or an error if the connection fails, times out, or the
|
||||
/// response is malformed.
|
||||
pub async fn predict(
|
||||
&self,
|
||||
request: OccupancyWorldModelRequest,
|
||||
) -> Result<OccupancyWorldModelResponse, WorldModelError> {
|
||||
timeout(
|
||||
Duration::from_secs(TIMEOUT_S),
|
||||
self.send_recv(request),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| WorldModelError::Timeout { timeout_s: TIMEOUT_S })?
|
||||
}
|
||||
|
||||
/// Internal: connect, write request, read response — no timeout here;
|
||||
/// the outer [`timeout`] in [`predict`] handles that.
|
||||
async fn send_recv(
|
||||
&self,
|
||||
request: OccupancyWorldModelRequest,
|
||||
) -> Result<OccupancyWorldModelResponse, WorldModelError> {
|
||||
let stream = self.connect().await?;
|
||||
|
||||
// Split into reader/writer halves so we can write and then read
|
||||
// without fully consuming the stream.
|
||||
let (reader_half, mut writer_half) = stream.into_split();
|
||||
|
||||
// Encode request as a single newline-terminated JSON line.
|
||||
let mut payload = serde_json::to_vec(&request)?;
|
||||
payload.push(b'\n');
|
||||
|
||||
writer_half
|
||||
.write_all(&payload)
|
||||
.await
|
||||
.map_err(|e| WorldModelError::Protocol(format!("write error: {e}")))?;
|
||||
|
||||
// Flush the write half so the server sees the complete line.
|
||||
writer_half
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| WorldModelError::Protocol(format!("flush error: {e}")))?;
|
||||
|
||||
// Read exactly one newline-delimited JSON line from the server.
|
||||
let mut line = String::new();
|
||||
let mut buf_reader = BufReader::new(reader_half);
|
||||
|
||||
buf_reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| WorldModelError::Protocol(format!("read error: {e}")))?;
|
||||
|
||||
if line.is_empty() {
|
||||
return Err(WorldModelError::Protocol(
|
||||
"server closed connection before sending a response".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if line.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(WorldModelError::Protocol(format!(
|
||||
"response line too large ({} bytes > {} byte limit)",
|
||||
line.len(),
|
||||
MAX_RESPONSE_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
let response: OccupancyWorldModelResponse = serde_json::from_str(line.trim())?;
|
||||
|
||||
// Propagate any VRAM error signalled by the server via a dedicated
|
||||
// sentinel in the model_id field (convention agreed in ADR-147).
|
||||
if response.model_id.starts_with("error:vram:") {
|
||||
return Err(WorldModelError::VramUnavailable(
|
||||
response.model_id["error:vram:".len()..].to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Establishes a [`UnixStream`] connection to `self.socket_path`.
|
||||
async fn connect(&self) -> Result<UnixStream, WorldModelError> {
|
||||
UnixStream::connect(&self.socket_path)
|
||||
.await
|
||||
.map_err(|e| WorldModelError::SocketConnect {
|
||||
path: self.socket_path.display().to_string(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default Unix socket path used by the OccWorld Python server
|
||||
/// as specified in ADR-147.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
PathBuf::from("/tmp/occworld.sock")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bridge_new_stores_path() {
|
||||
let b = OccWorldBridge::new("/tmp/test.sock");
|
||||
assert_eq!(b.socket_path, PathBuf::from("/tmp/test.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_socket_path_is_deterministic() {
|
||||
assert_eq!(default_socket_path(), PathBuf::from("/tmp/occworld.sock"));
|
||||
}
|
||||
|
||||
/// Verify that a missing socket returns `SocketConnect` and not a panic.
|
||||
#[tokio::test]
|
||||
async fn connect_to_missing_socket_returns_error() {
|
||||
let bridge = OccWorldBridge::new("/tmp/__occworld_nonexistent_test__.sock");
|
||||
use crate::{OccupancyGrid3D, OccupancyWorldModelRequest, SceneBoundsJson};
|
||||
let req = OccupancyWorldModelRequest {
|
||||
past_frames: vec![OccupancyGrid3D {
|
||||
width: 200,
|
||||
height: 200,
|
||||
depth: 16,
|
||||
voxels: vec![17u8; 200 * 200 * 16],
|
||||
}],
|
||||
voxel_resolution_m: 0.1,
|
||||
scene_bounds: SceneBoundsJson {
|
||||
min_e: -10.0,
|
||||
min_n: -10.0,
|
||||
max_e: 10.0,
|
||||
max_n: 10.0,
|
||||
},
|
||||
prediction_steps: 1,
|
||||
};
|
||||
let err = bridge.predict(req).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, WorldModelError::SocketConnect { .. }),
|
||||
"expected SocketConnect, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Error types for the OccWorld world-model bridge (ADR-147).
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// All errors that can be returned by the OccWorld bridge.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WorldModelError {
|
||||
/// Could not connect to the Unix-domain socket served by the Python
|
||||
/// OccWorld inference process.
|
||||
#[error("could not connect to OccWorld socket at `{path}`: {source}")]
|
||||
SocketConnect {
|
||||
/// The socket path that was attempted.
|
||||
path: String,
|
||||
/// The underlying I/O error.
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// A request or response exceeded the 30-second wall-clock deadline.
|
||||
#[error("OccWorld inference timed out after {timeout_s}s")]
|
||||
Timeout {
|
||||
/// The configured timeout in seconds.
|
||||
timeout_s: u64,
|
||||
},
|
||||
|
||||
/// The JSON payload received from the server could not be decoded, or the
|
||||
/// payload we tried to send could not be encoded.
|
||||
#[error("JSON (de)serialisation error: {0}")]
|
||||
SerdeJson(#[from] serde_json::Error),
|
||||
|
||||
/// The server sent a response that violates the newline-delimited JSON
|
||||
/// protocol (e.g. an unexpected EOF before the newline delimiter, or an
|
||||
/// oversized frame that exceeded the read buffer limit).
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
/// The OccWorld inference server reported that GPU VRAM is unavailable
|
||||
/// (out-of-memory condition on the device side).
|
||||
#[error("OccWorld server reports VRAM unavailable: {0}")]
|
||||
VramUnavailable(String),
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! `wifi-densepose-worldmodel` — OccWorld thin-client bridge (ADR-147).
|
||||
//!
|
||||
//! Bridges [`wifi_densepose_worldgraph`] `PersonTrack` history to the OccWorld
|
||||
//! Python inference subprocess and returns [`TrajectoryPrior`]s that can be
|
||||
//! injected into the Kalman pose tracker.
|
||||
//!
|
||||
//! ## Quick start
|
||||
//! ```rust,no_run
|
||||
//! use wifi_densepose_worldmodel::{
|
||||
//! OccWorldBridge, OccupancyWorldModelRequest, OccupancyGrid3D,
|
||||
//! SceneBoundsJson, worldgraph_to_occupancy,
|
||||
//! };
|
||||
//! use wifi_densepose_worldmodel::occupancy::{PersonPosition, SceneBounds};
|
||||
//!
|
||||
//! # async fn example() -> Result<(), wifi_densepose_worldmodel::WorldModelError> {
|
||||
//! let bridge = OccWorldBridge::new("/tmp/occworld.sock");
|
||||
//!
|
||||
//! let bounds = SceneBounds { min_e: -10.0, min_n: -10.0, max_e: 10.0, max_n: 10.0 };
|
||||
//! let persons = vec![
|
||||
//! PersonPosition { track_id: 1, east_m: 2.0, north_m: 3.0, up_m: 1.0 },
|
||||
//! ];
|
||||
//! let frame = worldgraph_to_occupancy(&persons, &bounds, 0.1);
|
||||
//!
|
||||
//! let request = OccupancyWorldModelRequest {
|
||||
//! past_frames: vec![frame],
|
||||
//! voxel_resolution_m: 0.1,
|
||||
//! scene_bounds: SceneBoundsJson {
|
||||
//! min_e: bounds.min_e, min_n: bounds.min_n,
|
||||
//! max_e: bounds.max_e, max_n: bounds.max_n,
|
||||
//! },
|
||||
//! prediction_steps: 15,
|
||||
//! };
|
||||
//!
|
||||
//! let response = bridge.predict(request).await?;
|
||||
//! println!("confidence={:.2}", response.confidence);
|
||||
//! for prior in &response.trajectory_priors {
|
||||
//! println!("track {} has {} waypoints", prior.track_id, prior.waypoints.len());
|
||||
//! }
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod bridge;
|
||||
pub mod error;
|
||||
pub mod occupancy;
|
||||
|
||||
// Re-export the bridge type at the crate root for convenience.
|
||||
pub use bridge::{default_socket_path, OccWorldBridge};
|
||||
pub use error::WorldModelError;
|
||||
pub use occupancy::worldgraph_to_occupancy;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voxel grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A 3-D occupancy grid whose voxel values are class indices (u8).
|
||||
///
|
||||
/// Layout: `voxels[z * height * width + y * width + x]` (row-major, depth last).
|
||||
/// The grid is always `200 × 200 × 16` when produced by
|
||||
/// [`worldgraph_to_occupancy`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OccupancyGrid3D {
|
||||
/// Number of voxels along the east/x axis.
|
||||
pub width: u32,
|
||||
/// Number of voxels along the north/y axis.
|
||||
pub height: u32,
|
||||
/// Number of voxels along the up/z axis.
|
||||
pub depth: u32,
|
||||
/// Flat class-index array, length `width * height * depth`.
|
||||
pub voxels: Vec<u8>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trajectory types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single point on a predicted trajectory, with a relative timestamp.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrajectoryWaypoint {
|
||||
/// East offset from installation origin, in metres.
|
||||
pub e: f64,
|
||||
/// North offset from installation origin, in metres.
|
||||
pub n: f64,
|
||||
/// Up offset (height), in metres.
|
||||
pub u: f64,
|
||||
/// Time offset from "now", in seconds (positive = future).
|
||||
pub t_s: f32,
|
||||
}
|
||||
|
||||
/// Predicted future trajectory for one tracked person.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrajectoryPrior {
|
||||
/// Stable track identifier (mirrors `WorldNode::PersonTrack::track_id`).
|
||||
pub track_id: u64,
|
||||
/// Ordered sequence of predicted future waypoints.
|
||||
pub waypoints: Vec<TrajectoryWaypoint>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene bounds (JSON wire shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Axis-aligned scene footprint sent to the OccWorld server in the IPC
|
||||
/// request. Mirrors [`occupancy::SceneBounds`] but derives `Serialize` /
|
||||
/// `Deserialize` for direct inclusion in the JSON payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneBoundsJson {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// JSON request sent from the Rust bridge to the OccWorld Python server.
|
||||
///
|
||||
/// Serialised as a single newline-terminated JSON object over the Unix socket.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OccupancyWorldModelRequest {
|
||||
/// History of occupancy grids (chronological, oldest first).
|
||||
/// OccWorld expects at least one frame; the reference implementation uses
|
||||
/// the most recent 4 frames for temporal context.
|
||||
pub past_frames: Vec<OccupancyGrid3D>,
|
||||
|
||||
/// Physical size of one voxel cell on the ground plane, in metres.
|
||||
pub voxel_resolution_m: f32,
|
||||
|
||||
/// Scene footprint used to build the occupancy grid.
|
||||
pub scene_bounds: SceneBoundsJson,
|
||||
|
||||
/// Number of future time steps to predict (reference: 15 × 0.1 s = 1.5 s).
|
||||
pub prediction_steps: u32,
|
||||
}
|
||||
|
||||
/// JSON response returned by the OccWorld Python server.
|
||||
///
|
||||
/// Decoded from a single newline-terminated JSON object on the Unix socket.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OccupancyWorldModelResponse {
|
||||
/// Predicted future occupancy grids (chronological, `prediction_steps`
|
||||
/// frames in total).
|
||||
pub future_frames: Vec<OccupancyGrid3D>,
|
||||
|
||||
/// Per-person predicted trajectories extracted from `future_frames`.
|
||||
pub trajectory_priors: Vec<TrajectoryPrior>,
|
||||
|
||||
/// Aggregate confidence score in `[0, 1]` for the entire prediction.
|
||||
pub confidence: f32,
|
||||
|
||||
/// Identifier of the model that produced this response.
|
||||
/// The sentinel prefix `"error:vram:"` signals a VRAM error (see ADR-147).
|
||||
pub model_id: String,
|
||||
|
||||
/// Wall-clock time the Python server spent on inference, in milliseconds.
|
||||
pub inference_ms: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorldGraph helper — extract PersonPosition list from a WorldGraph snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use wifi_densepose_worldgraph::WorldGraph;
|
||||
|
||||
use crate::occupancy::PersonPosition;
|
||||
|
||||
/// Extracts all [`PersonPosition`]s from a [`WorldGraph`] by serialising the
|
||||
/// graph to its canonical JSON form (via [`WorldGraph::to_json`]) and scanning
|
||||
/// the `nodes` array for `PersonTrack` entries.
|
||||
///
|
||||
/// This avoids coupling to the private fields of `WorldGraphSnapshot`.
|
||||
/// The returned positions are unsorted; callers may sort by `track_id` if
|
||||
/// deterministic ordering is required.
|
||||
///
|
||||
/// # Panics
|
||||
/// Does not panic — if serialisation fails the function returns an empty
|
||||
/// `Vec` and logs a warning via `eprintln!`. In practice, serialisation of a
|
||||
/// valid `WorldGraph` should never fail.
|
||||
pub fn persons_from_worldgraph(graph: &WorldGraph) -> Vec<PersonPosition> {
|
||||
let bytes = match graph.to_json() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
eprintln!("[worldmodel] WorldGraph::to_json failed: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
// Parse as a raw JSON value to avoid depending on the exact shape of the
|
||||
// private `WorldGraphSnapshot` struct fields.
|
||||
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("[worldmodel] failed to parse WorldGraph JSON: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let nodes = match value.get("nodes").and_then(|n| n.as_array()) {
|
||||
Some(arr) => arr,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
nodes
|
||||
.iter()
|
||||
.filter_map(|node| {
|
||||
// Nodes use a serde-tagged enum; the PersonTrack variant carries a
|
||||
// `kind` discriminator equal to `"person_track"`.
|
||||
if node.get("kind")?.as_str()? != "person_track" {
|
||||
return None;
|
||||
}
|
||||
let track_id = node.get("track_id")?.as_u64()?;
|
||||
let pos = node.get("last_position")?;
|
||||
let east_m = pos.get("east_m")?.as_f64()?;
|
||||
let north_m = pos.get("north_m")?.as_f64()?;
|
||||
let up_m = pos.get("up_m")?.as_f64()?;
|
||||
Some(PersonPosition { track_id, east_m, north_m, up_m })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn occupancy_grid_serde_roundtrip() {
|
||||
let grid = OccupancyGrid3D {
|
||||
width: 4,
|
||||
height: 4,
|
||||
depth: 2,
|
||||
voxels: vec![17u8; 32],
|
||||
};
|
||||
let json = serde_json::to_string(&grid).expect("serialize");
|
||||
let decoded: OccupancyGrid3D = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.width, grid.width);
|
||||
assert_eq!(decoded.voxels.len(), grid.voxels.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trajectory_prior_serde_roundtrip() {
|
||||
let prior = TrajectoryPrior {
|
||||
track_id: 42,
|
||||
waypoints: vec![
|
||||
TrajectoryWaypoint { e: 1.0, n: 2.0, u: 0.0, t_s: 0.1 },
|
||||
TrajectoryWaypoint { e: 1.1, n: 2.1, u: 0.0, t_s: 0.2 },
|
||||
],
|
||||
};
|
||||
let json = serde_json::to_string(&prior).expect("serialize");
|
||||
let decoded: TrajectoryPrior = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.track_id, 42);
|
||||
assert_eq!(decoded.waypoints.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serde_roundtrip() {
|
||||
let req = OccupancyWorldModelRequest {
|
||||
past_frames: vec![OccupancyGrid3D {
|
||||
width: 200,
|
||||
height: 200,
|
||||
depth: 16,
|
||||
voxels: vec![17u8; 200 * 200 * 16],
|
||||
}],
|
||||
voxel_resolution_m: 0.1,
|
||||
scene_bounds: SceneBoundsJson {
|
||||
min_e: -10.0,
|
||||
min_n: -10.0,
|
||||
max_e: 10.0,
|
||||
max_n: 10.0,
|
||||
},
|
||||
prediction_steps: 15,
|
||||
};
|
||||
let json = serde_json::to_string(&req).expect("serialize");
|
||||
let decoded: OccupancyWorldModelRequest =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.prediction_steps, 15);
|
||||
assert_eq!(decoded.past_frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_serde_roundtrip() {
|
||||
let resp = OccupancyWorldModelResponse {
|
||||
future_frames: vec![],
|
||||
trajectory_priors: vec![TrajectoryPrior {
|
||||
track_id: 1,
|
||||
waypoints: vec![TrajectoryWaypoint { e: 0.0, n: 0.0, u: 0.0, t_s: 0.0 }],
|
||||
}],
|
||||
confidence: 0.82,
|
||||
model_id: "occworld-dummy-v0".into(),
|
||||
inference_ms: 375,
|
||||
};
|
||||
let json = serde_json::to_string(&resp).expect("serialize");
|
||||
let decoded: OccupancyWorldModelResponse =
|
||||
serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(decoded.inference_ms, 375);
|
||||
assert!((decoded.confidence - 0.82).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vram_error_sentinel_roundtrip() {
|
||||
let resp = OccupancyWorldModelResponse {
|
||||
future_frames: vec![],
|
||||
trajectory_priors: vec![],
|
||||
confidence: 0.0,
|
||||
model_id: "error:vram:out of memory (CUDA)".into(),
|
||||
inference_ms: 0,
|
||||
};
|
||||
assert!(resp.model_id.starts_with("error:vram:"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user