Compare commits

..

5 Commits

Author SHA1 Message Date
rUv 04028acd54 feat: add multi-actor PoseCode scene crate 2026-07-15 09:53:38 -04:00
ruv 82c1b8fdf8 chore: bump wifi-densepose-signal 0.3.5 for crates.io (#1334)
Published 0.3.4 predates HardwareNormalizer::resample_to_canonical and
MultistaticConfig::for_tdm_schedule, which the sensing-server binary
uses — its publish verify fails against the registry 0.3.4. The in-repo
version had not been bumped since those APIs landed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:31:42 -04:00
ruv 9dceb976c7 chore(publish): version rufield deps + bump worldgraph/rufield submodules (#1334)
- wifi-densepose-rufield: add version="0.1.0" to the four rufield path
  deps — rufield-core/-provenance/-privacy/-fusion are now published to
  crates.io, making this crate (and wifi-densepose-sensing-server 0.3.4)
  publishable
- v2/crates/worldgraph -> 4441bc0: wifi-densepose-worldgraph 0.3.2
  published (adds prune_semantic_states; unblocks wifi-densepose-engine
  0.3.1 publish)
- vendor/rufield -> f3c1492: breaks the fusion<->adapters circular
  dev-dependency (path-only dev-dep, stripped at publish)

Closes the crates.io publish blockers in #1334.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 13:18:43 -04:00
rUv 7b244bdc8c Merge pull request #1332 from ruvnet/integrate/pr-1311-1313
Integrate community fixes: DevKitC-1 overlay, EngineBridge guard config, pose-WS bearer auth (#1308 #1309 #1310)
2026-07-14 12:48:11 -04:00
github-actions[bot] 90667d0f1d chore: update vendor submodules to latest upstream (#1331)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 12:15:39 -04:00
23 changed files with 1860 additions and 25 deletions
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Multi-actor PoseCode scene crate (`wifi-densepose-posecode`, ADR-266).** Adds a deterministic Rust scene model for multiple persistent RuView actors, synchronized poses, inter-person contacts, confidence and observation provenance. Includes a bounded line parser, canonical serializer, direct `PoseTrack` adapter, confidence-aware phase segmentation, provenance-sensitive range validation, 14 feature-enabled unit tests and Criterion parser/serializer benchmarks. Raw 20 Hz observations remain separate from semantic phases; observed range anomalies are warnings rather than medical safety claims. MEASURED on the implementation container: two-actor parse 3.40 µs, serialization 3.29 µs.
### Changed
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
+2
View File
@@ -14,6 +14,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
| `wifi-densepose-posecode` | ADR-266 multi-actor semantic motion scenes, PoseTrack adapter, bounded parser, confidence-aware phase segmentation |
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
@@ -81,6 +82,7 @@ All 5 ruvector crates integrated in workspace:
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
- ADR-266: Multi-actor PoseCode scenes from persistent RuView tracks (Accepted)
### Supported Hardware
+2 -1
View File
@@ -617,7 +617,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Architecture Decisions](docs/adr/README.md) | 183 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Multi-actor PoseCode](v2/crates/wifi-densepose-posecode/) | Rust scene protocol converting persistent RuView pose tracks into confidence-scored actors, synchronized motion phases, contacts and deterministic text. [ADR-266](docs/adr/ADR-266-multi-actor-posecode-scenes.md). |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
@@ -0,0 +1,141 @@
# ADR-266: Multi-Actor PoseCode Scenes
| Field | Value |
|-------|-------|
| Status | Accepted, crate implementation complete |
| Date | 2026-07-15 |
| Deciders | ruv |
| Crate | `wifi-densepose-posecode` |
| Related | ADR-029, ADR-037, ADR-082, ADR-101, ADR-170 |
## Context
RuView already maintains multiple persistent `PoseTrack` aggregates. Each track
contains 17 COCO keypoints, a lifecycle, an AETHER identity embedding and a
stable `TrackId`. The missing layer is a compact, agent-readable representation
of what those tracked people do together over time.
PoseCode 0.1 provides a useful vocabulary for one actor, sequential movement
phases, semantic joint actions, contacts and browser rendering. Its current
protocol does not represent multiple actors, shared world coordinates,
inter-person contact, observation confidence or track provenance.
Encoding every 20 Hz RuView frame as text would also be incorrect. It would
produce 1,200 steps per person per minute, discard observation uncertainty and
couple the sensing hot path to a presentation language.
## Decision
Create the leaf crate `wifi-densepose-posecode` and define a backwards-minded
0.2 scene extension with these invariants:
1. A `Scene` owns named actors and synchronized phases.
2. Each actor can carry the originating RuView `TrackId` and confidence.
3. Every joint target and inter-person contact carries confidence.
4. Actor positions and travel use one room-calibrated three-dimensional frame.
5. Raw `SceneFrame` observations remain separate from compact semantic phases.
6. `PhaseSegmenter` emits a phase on actor membership change, motion settling
or a bounded maximum duration.
7. Serialization is canonical and deterministic.
8. The parser has hard document, line, actor, phase and target limits.
9. General range-of-motion violations are errors for authored scenes but only
warnings for observed WiFi scenes. RF pose estimates are not medical joint
measurements and must not create false safety claims.
The text form is:
```posecode
posecode scene "Assisted squat"
source observed_wifi_csi
actor patient:
rig humanoid
pose start = standing
track 7
confidence 0.82
position 0 0 0
actor therapist:
rig humanoid
pose start = standing
track 12
confidence 0.76
position 1.2 0 0
step "Lower" 1.5s flow:
patient.knee_left: flex 95 0.8
therapist.shoulder_left: flex 30 0.7
contact therapist.hand_left patient.shoulder_right 0.7
repeat 1
```
## Architecture
`RuViewAdapter` consumes references to existing `PoseTrack` values. It does not
duplicate assignment or identity tracking. It calculates elbows and knees from
three-point interior angles, estimates hip and shoulder sagittal movement in a
calibrated coordinate frame and maps the hip midpoint to actor position.
The adapter is exposed by the crate's `ruview` feature so the protocol, parser
and segmenter remain usable without pulling the full signal and RuVector graph.
The adapter uses keypoint confidence when available. Current trackers that have
not populated that field receive an explicit configurable fallback confidence,
degraded by track staleness. Non-finite coordinates and terminated tracks fail
closed.
`PhaseSegmenter` consumes ordered frames. High-rate frames remain available for
live rendering and evidence storage while the resulting scene records only
meaningful synchronized targets. This preserves both fidelity and readability.
## Security and Resource Bounds
The public parser accepts at most 1 MiB per document and 4,096 bytes per line.
The scene validator defaults to 32 actors, 10,000 phases and 64 joint targets
per actor per phase. All coordinates, angles and confidence values must be
finite. References to undeclared actors are rejected. Durations, repeats and
timeline addition are bounded.
No network, filesystem, model or renderer capability is present in the crate.
It is a deterministic transformation leaf over owned data.
## Consequences
RuView can now produce privacy-preserving multi-person replays, structured fall
and interaction evidence, exercise comparison inputs and agent-readable motion
records without video.
The crate does not solve RF source separation. Its multi-person accuracy is
bounded by the upstream pose model and `PoseTracker`. Contact inference is also
not fabricated; contacts must be observed by a future calibrated classifier or
authored explicitly.
The current COCO skeleton has no toe keypoints, so ankle rotation and precise
foot contact cannot be inferred honestly. Those targets are omitted rather
than guessed.
## Acceptance Criteria
1. A scene containing two actors, simultaneous targets and a cross-actor
contact parses and serializes deterministically.
2. Unknown actors, non-finite values, reversed timestamps and unbounded inputs
fail closed.
3. One RuView track converts to finite semantic targets with the same TrackId.
4. Observed out-of-range angles produce warnings, not medical errors.
5. Phase count is smaller than input frame count for a normal motion sequence.
6. Focused crate tests and the complete workspace test suite pass before merge.
## Measured Results
On the implementation container with Rust 1.88, Criterion measured the canonical
two-actor scene parser at 3.40 microseconds and serializer at 3.29 microseconds
per operation. The protocol-only dependency surface is three runtime crates:
`serde`, `serde_json` and `thiserror`.
The protocol-only configuration passes 13 unit tests, documentation tests,
Rustfmt and Clippy with warnings denied. The `ruview` feature passes 14 unit
tests, including native `PoseTrack` conversion, under the repository's RuVector
AVX512-capable build configuration.
## References
1. PoseCode repository and protocol, MIT licensed:
<https://github.com/posecode-dev/posecode>
2. RuView multi-person pose decision: ADR-037.
3. RuView persistent pose tracking: ADR-029 and `pose_tracker.rs`.
+2 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decision Records
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
This folder contains 183 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
@@ -123,6 +123,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
| [ADR-266](ADR-266-multi-actor-posecode-scenes.md) | Multi-actor PoseCode scenes from persistent RuView tracks | Accepted |
---
Generated
+13 -14
View File
@@ -7533,7 +7533,6 @@ dependencies = [
"tokio-test",
"toml 0.8.23",
"tracing",
"wifi-densepose-core",
]
[[package]]
@@ -11092,6 +11091,17 @@ dependencies = [
"tower-http",
]
[[package]]
name = "wifi-densepose-posecode"
version = "0.3.0"
dependencies = [
"criterion",
"serde",
"serde_json",
"thiserror 2.0.18",
"wifi-densepose-signal",
]
[[package]]
name = "wifi-densepose-rufield"
version = "0.3.0"
@@ -11161,7 +11171,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-signal"
version = "0.3.4"
version = "0.3.5"
dependencies = [
"chrono",
"criterion",
@@ -11268,7 +11278,7 @@ dependencies = [
[[package]]
name = "wifi-densepose-worldgraph"
version = "0.3.1"
version = "0.3.2"
dependencies = [
"petgraph",
"serde",
@@ -11277,17 +11287,6 @@ dependencies = [
"wifi-densepose-geo",
]
[[package]]
name = "wifi-densepose-worldmodel"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"wifi-densepose-worldgraph",
]
[[package]]
name = "winapi"
version = "0.3.9"
+4
View File
@@ -46,6 +46,9 @@ members = [
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
# Motivated by #499 ghost-skeleton reports.
"crates/cog-person-count",
# ADR-266: Multi-actor PoseCode scene protocol. Converts persistent
# RuvSense PoseTracks into confidence-scored, deterministic motion scenes.
"crates/wifi-densepose-posecode",
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
# ADR-115 MQTT publisher as a Seed-installable artifact with
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
@@ -215,6 +218,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-posecode = { version = "0.3.0", path = "crates/wifi-densepose-posecode" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
[profile.release]
@@ -0,0 +1,28 @@
[package]
name = "wifi-densepose-posecode"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Multi-actor semantic motion scenes for RuView PoseTrack streams"
documentation = "https://docs.rs/wifi-densepose-posecode"
keywords = ["wifi", "pose", "motion", "posecode", "multi-person"]
categories = ["science", "computer-vision", "parser-implementations"]
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
wifi-densepose-signal = { version = "0.3.5", path = "../wifi-densepose-signal", default-features = false, optional = true }
[dev-dependencies]
criterion.workspace = true
[features]
default = []
ruview = ["dep:wifi-densepose-signal"]
[[bench]]
name = "posecode_bench"
harness = false
@@ -0,0 +1,37 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use wifi_densepose_posecode::{parse_posecode, to_posecode};
const TWO_ACTORS: &str = r#"posecode scene "crossing"
source observed_wifi_csi
actor person_1:
rig humanoid
pose start = standing
track 1
confidence 0.8
position 0 0 0
actor person_2:
rig humanoid
pose start = standing
track 2
confidence 0.8
position 1 0 0
step "Move" 0.05s linear:
person_1.knee_left: flex 30 0.8
person_1.hip_left: flex 20 0.8
person_2.knee_right: flex 35 0.8
person_2.hip_right: flex 25 0.8
repeat 1
"#;
fn benchmarks(c: &mut Criterion) {
c.bench_function("parse_two_actor_scene", |b| {
b.iter(|| parse_posecode(black_box(TWO_ACTORS)).unwrap())
});
let scene = parse_posecode(TWO_ACTORS).unwrap();
c.bench_function("serialize_two_actor_scene", |b| {
b.iter(|| to_posecode(black_box(&scene)))
});
}
criterion_group!(benches, benchmarks);
criterion_main!(benches);
@@ -0,0 +1,443 @@
//! Conversion from tracked COCO-17 skeletons to semantic joint observations.
use crate::error::{Error, Result};
use crate::model::{ActorObservation, JointTarget, SceneFrame, Vec3};
const NUM_KEYPOINTS: usize = 17;
const LEFT_SHOULDER: usize = 5;
const RIGHT_SHOULDER: usize = 6;
const LEFT_ELBOW: usize = 7;
const RIGHT_ELBOW: usize = 8;
const LEFT_WRIST: usize = 9;
const RIGHT_WRIST: usize = 10;
const LEFT_HIP: usize = 11;
const RIGHT_HIP: usize = 12;
const LEFT_KNEE: usize = 13;
const RIGHT_KNEE: usize = 14;
const LEFT_ANKLE: usize = 15;
const RIGHT_ANKLE: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackState {
Tentative,
Active,
Lost,
Terminated,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct KeypointObservation {
pub position: Vec3,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackObservation {
pub id: u64,
pub state: TrackState,
pub keypoints: [KeypointObservation; NUM_KEYPOINTS],
pub time_since_update: u64,
}
#[cfg(feature = "ruview")]
impl From<&wifi_densepose_signal::ruvsense::PoseTrack> for TrackObservation {
fn from(track: &wifi_densepose_signal::ruvsense::PoseTrack) -> Self {
use wifi_densepose_signal::ruvsense::TrackLifecycleState;
Self {
id: track.id.0,
state: match track.lifecycle {
TrackLifecycleState::Tentative => TrackState::Tentative,
TrackLifecycleState::Active => TrackState::Active,
TrackLifecycleState::Lost => TrackState::Lost,
TrackLifecycleState::Terminated => TrackState::Terminated,
},
keypoints: std::array::from_fn(|i| {
let point = track.keypoints[i].position();
KeypointObservation {
position: Vec3::new(point[0], point[1], point[2]),
confidence: track.keypoints[i].confidence,
}
}),
time_since_update: track.time_since_update,
}
}
}
#[derive(Debug, Clone)]
pub struct AdapterConfig {
/// Coordinate system up vector after room calibration.
pub world_up: Vec3,
/// Coordinate system forward vector after room calibration.
pub world_forward: Vec3,
/// Ignore tracks below this aggregate confidence.
pub min_confidence: f32,
/// Confidence used until an inference backend populates keypoint confidence.
pub unscored_confidence: f32,
/// Emit lost tracks as predicted observations.
pub include_lost: bool,
}
impl Default for AdapterConfig {
fn default() -> Self {
Self {
world_up: Vec3::new(0.0, 1.0, 0.0),
world_forward: Vec3::new(0.0, 0.0, 1.0),
min_confidence: 0.2,
unscored_confidence: 0.5,
include_lost: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RuViewAdapter {
config: AdapterConfig,
}
impl RuViewAdapter {
pub fn new(config: AdapterConfig) -> Result<Self> {
if !config.world_up.is_finite() || !config.world_forward.is_finite() {
return Err(Error::Observation("coordinate axes must be finite".into()));
}
if !(0.0..=1.0).contains(&config.min_confidence)
|| !(0.0..=1.0).contains(&config.unscored_confidence)
{
return Err(Error::Observation("confidence must be in [0, 1]".into()));
}
if norm(config.world_up) < 1e-6 || norm(config.world_forward) < 1e-6 {
return Err(Error::Observation(
"coordinate axes must be non-zero".into(),
));
}
let alignment = dot(normalize(config.world_up), normalize(config.world_forward)).abs();
if alignment > 0.98 {
return Err(Error::Observation(
"world up and forward axes must not be parallel".into(),
));
}
Ok(Self { config })
}
/// Convert all visible tracks into one synchronized observation frame.
pub fn frame(&self, timestamp_ms: u64, tracks: &[TrackObservation]) -> SceneFrame {
let actors = tracks
.iter()
.filter_map(|track| self.actor(track))
.collect();
SceneFrame {
timestamp_ms,
actors,
}
}
/// Direct bridge for the RuView tracker aggregate. The feature is optional
/// so protocol-only consumers do not pull the signal and RuVector graph.
#[cfg(feature = "ruview")]
pub fn frame_from_pose_tracks(
&self,
timestamp_ms: u64,
tracks: &[&wifi_densepose_signal::ruvsense::PoseTrack],
) -> SceneFrame {
let observations: Vec<TrackObservation> =
tracks.iter().map(|track| (*track).into()).collect();
self.frame(timestamp_ms, &observations)
}
pub fn actor(&self, track: &TrackObservation) -> Option<ActorObservation> {
if track.state == TrackState::Terminated
|| (!self.config.include_lost && track.state == TrackState::Lost)
{
return None;
}
let confidence = track_confidence(track, self.config.unscored_confidence);
if confidence < self.config.min_confidence {
return None;
}
let p = |index: usize| track.keypoints[index].position;
if track.keypoints.iter().any(|kp| !kp.position.is_finite()) {
return None;
}
let left_hip = p(LEFT_HIP);
let right_hip = p(RIGHT_HIP);
let position = midpoint(left_hip, right_hip);
let up = normalize(self.config.world_up);
let forward = normalize(sub(
self.config.world_forward,
scale(up, dot(self.config.world_forward, up)),
));
let mut joints = Vec::with_capacity(10);
add_flexion(
&mut joints,
"elbow_left",
p(LEFT_SHOULDER),
p(LEFT_ELBOW),
p(LEFT_WRIST),
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW, LEFT_WRIST], confidence),
);
add_flexion(
&mut joints,
"elbow_right",
p(RIGHT_SHOULDER),
p(RIGHT_ELBOW),
p(RIGHT_WRIST),
joint_confidence(
track,
&[RIGHT_SHOULDER, RIGHT_ELBOW, RIGHT_WRIST],
confidence,
),
);
add_flexion(
&mut joints,
"knee_left",
left_hip,
p(LEFT_KNEE),
p(LEFT_ANKLE),
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE, LEFT_ANKLE], confidence),
);
add_flexion(
&mut joints,
"knee_right",
right_hip,
p(RIGHT_KNEE),
p(RIGHT_ANKLE),
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE, RIGHT_ANKLE], confidence),
);
add_signed_flex(
&mut joints,
"hip_left",
sub(p(LEFT_KNEE), left_hip),
up,
forward,
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE], confidence),
);
add_signed_flex(
&mut joints,
"hip_right",
sub(p(RIGHT_KNEE), right_hip),
up,
forward,
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE], confidence),
);
add_signed_flex(
&mut joints,
"shoulder_left",
sub(p(LEFT_ELBOW), p(LEFT_SHOULDER)),
up,
forward,
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW], confidence),
);
add_signed_flex(
&mut joints,
"shoulder_right",
sub(p(RIGHT_ELBOW), p(RIGHT_SHOULDER)),
up,
forward,
joint_confidence(track, &[RIGHT_SHOULDER, RIGHT_ELBOW], confidence),
);
let shoulder_mid = midpoint(p(LEFT_SHOULDER), p(RIGHT_SHOULDER));
let torso = sub(shoulder_mid, position);
let hinge = angle_deg(torso, up).clamp(0.0, 180.0);
joints.push(JointTarget {
joint: "pelvis".into(),
action: "hinge".into(),
degrees: hinge,
confidence: joint_confidence(
track,
&[LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP],
confidence,
),
});
Some(ActorObservation {
actor_id: format!("person_{}", track.id),
track_id: track.id,
confidence,
position,
joints,
})
}
}
fn track_confidence(track: &TrackObservation, fallback: f32) -> f32 {
let scored: Vec<f32> = track
.keypoints
.iter()
.map(|kp| kp.confidence)
.filter(|v| v.is_finite() && *v > 0.0)
.collect();
let measured = if scored.is_empty() {
fallback
} else {
scored.iter().sum::<f32>() / scored.len() as f32
};
let freshness = 1.0 / (1.0 + track.time_since_update as f32 * 0.2);
(measured * freshness).clamp(0.0, 1.0)
}
fn joint_confidence(track: &TrackObservation, indices: &[usize], fallback: f32) -> f32 {
indices.iter().fold(1.0_f32, |confidence, &index| {
let measured = track.keypoints[index].confidence;
confidence.min(if measured.is_finite() && measured > 0.0 {
measured
} else {
fallback
})
})
}
fn add_flexion(out: &mut Vec<JointTarget>, name: &str, a: Vec3, b: Vec3, c: Vec3, confidence: f32) {
let inner = angle_deg(sub(a, b), sub(c, b));
if inner.is_finite() {
out.push(JointTarget {
joint: name.into(),
action: "flex".into(),
degrees: (180.0 - inner).clamp(0.0, 180.0),
confidence,
});
}
}
fn add_signed_flex(
out: &mut Vec<JointTarget>,
name: &str,
limb: Vec3,
up: Vec3,
forward: Vec3,
confidence: f32,
) {
let down = scale(up, -1.0);
let degrees = dot(limb, forward).atan2(dot(limb, down)).to_degrees();
if degrees.is_finite() {
out.push(JointTarget {
joint: name.into(),
action: if degrees >= 0.0 { "flex" } else { "extend" }.into(),
degrees: degrees.abs().clamp(0.0, 180.0),
confidence,
});
}
}
fn midpoint(a: Vec3, b: Vec3) -> Vec3 {
scale(add(a, b), 0.5)
}
fn add(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
Vec3::new(a.x - b.x, a.y - b.y, a.z - b.z)
}
fn scale(v: Vec3, s: f32) -> Vec3 {
Vec3::new(v.x * s, v.y * s, v.z * s)
}
fn dot(a: Vec3, b: Vec3) -> f32 {
a.x * b.x + a.y * b.y + a.z * b.z
}
fn norm(v: Vec3) -> f32 {
dot(v, v).sqrt()
}
fn normalize(v: Vec3) -> Vec3 {
scale(v, 1.0 / norm(v).max(1e-9))
}
fn angle_deg(a: Vec3, b: Vec3) -> f32 {
let denom = norm(a) * norm(b);
if denom < 1e-9 {
return 0.0;
}
(dot(a, b) / denom).clamp(-1.0, 1.0).acos().to_degrees()
}
#[cfg(test)]
mod tests {
use super::*;
fn standing_track() -> TrackObservation {
let mut keypoints = [KeypointObservation::default(); NUM_KEYPOINTS];
let mut set = |index, x, y, z| {
keypoints[index] = KeypointObservation {
position: Vec3::new(x, y, z),
confidence: 0.9,
};
};
set(LEFT_SHOULDER, -0.2, 1.5, 0.0);
set(RIGHT_SHOULDER, 0.2, 1.5, 0.0);
set(LEFT_ELBOW, -0.25, 1.1, 0.0);
set(RIGHT_ELBOW, 0.25, 1.1, 0.0);
set(LEFT_WRIST, -0.25, 0.75, 0.0);
set(RIGHT_WRIST, 0.25, 0.75, 0.0);
set(LEFT_HIP, -0.12, 0.9, 0.0);
set(RIGHT_HIP, 0.12, 0.9, 0.0);
set(LEFT_KNEE, -0.12, 0.5, 0.0);
set(RIGHT_KNEE, 0.12, 0.5, 0.0);
set(LEFT_ANKLE, -0.12, 0.05, 0.0);
set(RIGHT_ANKLE, 0.12, 0.05, 0.0);
TrackObservation {
id: 7,
state: TrackState::Active,
keypoints,
time_since_update: 0,
}
}
#[test]
fn converts_track_to_named_actor() {
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
let actor = adapter.actor(&standing_track()).unwrap();
assert_eq!(actor.actor_id, "person_7");
assert_eq!(actor.track_id, 7);
assert!(actor.joints.len() >= 9);
assert!(actor.joints.iter().all(|j| j.degrees.is_finite()));
}
#[test]
fn rejects_invalid_axes() {
let config = AdapterConfig {
world_up: Vec3::default(),
..Default::default()
};
assert!(RuViewAdapter::new(config).is_err());
}
#[test]
fn rejects_parallel_coordinate_axes() {
let config = AdapterConfig {
world_forward: Vec3::new(0.0, 2.0, 0.0),
..Default::default()
};
assert!(RuViewAdapter::new(config).is_err());
}
#[test]
fn filters_stale_low_confidence_tracks() {
let mut track = standing_track();
track.time_since_update = 100;
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
assert!(adapter.actor(&track).is_none());
}
#[cfg(feature = "ruview")]
#[test]
fn converts_native_ruview_pose_track() {
use wifi_densepose_signal::ruvsense::{PoseTrack, TrackId, TrackLifecycleState};
let source = standing_track();
let positions = std::array::from_fn(|i| {
let p = source.keypoints[i].position;
[p.x, p.y, p.z]
});
let mut native = PoseTrack::new(TrackId(23), &positions, 0, 128);
native.lifecycle = TrackLifecycleState::Active;
let observed = TrackObservation::from(&native);
assert_eq!(observed.id, 23);
assert_eq!(observed.state, TrackState::Active);
assert_eq!(
observed.keypoints[LEFT_HIP].position,
source.keypoints[LEFT_HIP].position
);
}
}
@@ -0,0 +1,24 @@
//! Error types for parser and adapter boundaries.
/// Crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
/// Errors are structured and line anchored where input text is involved.
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
#[error("line {line}: {message}")]
Parse { line: usize, message: String },
#[error("invalid scene: {0}")]
Validation(String),
#[error("invalid observation: {0}")]
Observation(String),
}
impl Error {
pub(crate) fn parse(line: usize, message: impl Into<String>) -> Self {
Self::Parse {
line,
message: message.into(),
}
}
}
@@ -0,0 +1,29 @@
//! Multi-actor semantic motion scenes for RuView.
//!
//! This crate uses the PoseCode movement vocabulary and implements the
//! RuView 0.2 multi-actor extension from ADR-266. It deliberately separates
//! the high-rate observed pose stream from the compact semantic scene:
//! [`adapter::RuViewAdapter`] converts persistent RuView tracks into observed
//! frames, [`segmenter::PhaseSegmenter`] reduces those frames to synchronized
//! phases, and [`serialize::to_posecode`] emits deterministic text.
pub mod adapter;
pub mod error;
pub mod model;
pub mod parser;
pub mod segmenter;
pub mod serialize;
pub mod validate;
pub use adapter::{
AdapterConfig, KeypointObservation, RuViewAdapter, TrackObservation, TrackState,
};
pub use error::{Error, Result};
pub use model::*;
pub use parser::parse_posecode;
pub use segmenter::{PhaseSegmenter, SegmenterConfig};
pub use serialize::to_posecode;
pub use validate::{validate_scene, ValidationConfig, ValidationIssue, ValidationSeverity};
/// Protocol version implemented by this crate.
pub const PROTOCOL_VERSION: &str = "0.2";
@@ -0,0 +1,203 @@
//! Renderer independent scene model.
use serde::{Deserialize, Serialize};
/// Hard limits keep untrusted scene documents bounded in memory and time.
pub const MAX_ACTORS: usize = 32;
pub const MAX_PHASES: usize = 10_000;
pub const MAX_TARGETS_PER_ACTOR: usize = 64;
pub const MAX_NAME_BYTES: usize = 128;
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Vec3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vec3 {
pub fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
pub fn distance(self, other: Self) -> f32 {
let dx = self.x - other.x;
let dy = self.y - other.y;
let dz = self.z - other.z;
(dx * dx + dy * dy + dz * dz).sqrt()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct EulerDeg {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl EulerDeg {
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SceneSource {
Authored,
ObservedWifiCsi,
Imported,
}
impl Default for SceneSource {
fn default() -> Self {
Self::Authored
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Actor {
pub id: String,
pub rig: String,
pub start_pose: String,
pub track_id: Option<u64>,
pub confidence: f32,
pub position: Vec3,
}
impl Actor {
pub fn humanoid(id: impl Into<String>) -> Self {
Self {
id: id.into(),
rig: "humanoid".into(),
start_pose: "standing".into(),
track_id: None,
confidence: 1.0,
position: Vec3::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JointTarget {
pub joint: String,
pub action: String,
pub degrees: f32,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActorTargets {
pub actor_id: String,
pub joints: Vec<JointTarget>,
pub ground_lock: Vec<String>,
pub travel: Option<Vec3>,
pub confidence: f32,
}
impl ActorTargets {
pub fn new(actor_id: impl Into<String>) -> Self {
Self {
actor_id: actor_id.into(),
joints: Vec::new(),
ground_lock: Vec::new(),
travel: None,
confidence: 1.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EffectorRef {
pub actor_id: String,
pub effector: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Contact {
pub from: EffectorRef,
pub to: EffectorRef,
pub confidence: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Timing {
Flow,
Settle,
Drive,
Snap,
Linear,
EaseIn,
EaseOut,
EaseInOut,
}
impl Timing {
pub fn as_str(self) -> &'static str {
match self {
Self::Flow => "flow",
Self::Settle => "settle",
Self::Drive => "drive",
Self::Snap => "snap",
Self::Linear => "linear",
Self::EaseIn => "ease-in",
Self::EaseOut => "ease-out",
Self::EaseInOut => "ease-in-out",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Phase {
pub name: String,
pub start_ms: u64,
pub duration_ms: u64,
pub timing: Timing,
pub actors: Vec<ActorTargets>,
pub contacts: Vec<Contact>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scene {
pub version: String,
pub name: String,
pub source: SceneSource,
pub actors: Vec<Actor>,
pub phases: Vec<Phase>,
pub repeat: u32,
}
impl Scene {
pub fn new(name: impl Into<String>) -> Self {
Self {
version: crate::PROTOCOL_VERSION.into(),
name: name.into(),
source: SceneSource::Authored,
actors: Vec::new(),
phases: Vec::new(),
repeat: 1,
}
}
}
/// One actor at one observed instant. Raw observations remain separate from
/// semantic phases so 20 Hz input does not become unreadable scene text.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActorObservation {
pub actor_id: String,
pub track_id: u64,
pub confidence: f32,
pub position: Vec3,
pub joints: Vec<JointTarget>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SceneFrame {
pub timestamp_ms: u64,
pub actors: Vec<ActorObservation>,
}
@@ -0,0 +1,375 @@
//! Bounded line parser for the PoseCode 0.2 multi-actor extension.
use crate::error::{Error, Result};
use crate::model::*;
use crate::validate::{validate_scene, ValidationConfig, ValidationSeverity};
const MAX_INPUT_BYTES: usize = 1_048_576;
const MAX_LINE_BYTES: usize = 4096;
pub fn parse_posecode(source: &str) -> Result<Scene> {
if source.len() > MAX_INPUT_BYTES {
return Err(Error::parse(0, "document exceeds 1 MiB"));
}
let mut scene: Option<Scene> = None;
let mut current_actor: Option<usize> = None;
let mut current_phase: Option<usize> = None;
let mut elapsed_ms = 0_u64;
for (index, raw) in source.lines().enumerate() {
let line_no = index + 1;
if raw.len() > MAX_LINE_BYTES {
return Err(Error::parse(line_no, "line exceeds 4096 bytes"));
}
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
if scene.is_none() {
let rest = line
.strip_prefix("posecode scene ")
.ok_or_else(|| Error::parse(line_no, "expected posecode scene header"))?;
scene = Some(Scene::new(parse_quoted(rest, line_no)?));
continue;
}
let doc = scene.as_mut().expect("initialized above");
if let Some(value) = line.strip_prefix("source ") {
doc.source = match value {
"authored" => SceneSource::Authored,
"observed_wifi_csi" => SceneSource::ObservedWifiCsi,
"imported" => SceneSource::Imported,
_ => return Err(Error::parse(line_no, "unknown source")),
};
continue;
}
if let Some(value) = line.strip_prefix("actor ") {
let id = value
.strip_suffix(':')
.ok_or_else(|| Error::parse(line_no, "actor declaration must end with ':'"))?;
doc.actors.push(Actor::humanoid(id.trim()));
current_actor = Some(doc.actors.len() - 1);
current_phase = None;
continue;
}
if let Some(value) = line.strip_prefix("step ") {
let (name, tail) = take_quoted(value, line_no)?;
let fields: Vec<&str> = tail.trim_end_matches(':').split_whitespace().collect();
if fields.len() != 2 {
return Err(Error::parse(line_no, "step requires duration and timing"));
}
let duration_ms = parse_duration(fields[0], line_no)?;
let timing = parse_timing(fields[1], line_no)?;
doc.phases.push(Phase {
name,
start_ms: elapsed_ms,
duration_ms,
timing,
actors: Vec::new(),
contacts: Vec::new(),
});
elapsed_ms = elapsed_ms
.checked_add(duration_ms)
.ok_or_else(|| Error::parse(line_no, "timeline overflow"))?;
current_phase = Some(doc.phases.len() - 1);
current_actor = None;
continue;
}
if let Some(value) = line.strip_prefix("repeat ") {
doc.repeat = value
.parse()
.map_err(|_| Error::parse(line_no, "repeat must be an integer"))?;
if doc.repeat == 0 || doc.repeat > 10_000 {
return Err(Error::parse(line_no, "repeat must be 1 to 10000"));
}
continue;
}
if let Some(ai) = current_actor {
parse_actor_field(&mut doc.actors[ai], line, line_no)?;
continue;
}
if let Some(pi) = current_phase {
parse_phase_field(&mut doc.phases[pi], line, line_no)?;
continue;
}
return Err(Error::parse(line_no, "directive is outside actor or step"));
}
let scene = scene.ok_or_else(|| Error::parse(0, "empty document"))?;
if let Some(issue) = validate_scene(&scene, &ValidationConfig::default())
.into_iter()
.find(|i| i.severity == ValidationSeverity::Error)
{
return Err(Error::Validation(format!(
"{}: {}",
issue.path, issue.message
)));
}
Ok(scene)
}
fn parse_actor_field(actor: &mut Actor, line: &str, line_no: usize) -> Result<()> {
if let Some(value) = line.strip_prefix("rig ") {
actor.rig = value.trim().into();
return Ok(());
}
if let Some(value) = line.strip_prefix("pose start = ") {
actor.start_pose = value.trim().into();
return Ok(());
}
if let Some(value) = line.strip_prefix("track ") {
actor.track_id = Some(
value
.parse()
.map_err(|_| Error::parse(line_no, "track must be an integer"))?,
);
return Ok(());
}
if let Some(value) = line.strip_prefix("confidence ") {
actor.confidence = parse_confidence(value, line_no)?;
return Ok(());
}
if let Some(value) = line.strip_prefix("position ") {
actor.position = parse_vec3(value, line_no)?;
return Ok(());
}
Err(Error::parse(line_no, "unknown actor directive"))
}
fn parse_phase_field(phase: &mut Phase, line: &str, line_no: usize) -> Result<()> {
if let Some(value) = line.strip_prefix("contact ") {
let fields: Vec<&str> = value.split_whitespace().collect();
if !(fields.len() == 2 || fields.len() == 3) {
return Err(Error::parse(
line_no,
"contact requires two effectors and optional confidence",
));
}
phase.contacts.push(Contact {
from: parse_effector(fields[0], line_no)?,
to: parse_effector(fields[1], line_no)?,
confidence: if fields.len() == 3 {
parse_confidence(fields[2], line_no)?
} else {
1.0
},
});
return Ok(());
}
let (lhs, rhs) = line
.split_once(':')
.ok_or_else(|| Error::parse(line_no, "phase directive requires ':'"))?;
let (actor_id, target) = lhs
.trim()
.split_once('.')
.ok_or_else(|| Error::parse(line_no, "target must be actor qualified"))?;
let actor = phase_actor_mut(phase, actor_id);
match target {
"ground-lock" => {
actor.ground_lock = rhs
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"travel" => {
actor.travel = Some(parse_vec3_or_xz(rhs, line_no)?);
}
joint => {
let fields: Vec<&str> = rhs.split_whitespace().collect();
if !(fields.len() == 2 || fields.len() == 3) {
return Err(Error::parse(
line_no,
"joint target requires action, degrees and optional confidence",
));
}
actor.joints.push(JointTarget {
joint: joint.into(),
action: fields[0].into(),
degrees: fields[1]
.parse()
.map_err(|_| Error::parse(line_no, "degrees must be numeric"))?,
confidence: if fields.len() == 3 {
parse_confidence(fields[2], line_no)?
} else {
1.0
},
});
}
}
Ok(())
}
fn phase_actor_mut<'a>(phase: &'a mut Phase, id: &str) -> &'a mut ActorTargets {
if let Some(index) = phase.actors.iter().position(|a| a.actor_id == id) {
return &mut phase.actors[index];
}
phase.actors.push(ActorTargets::new(id));
phase.actors.last_mut().expect("just pushed")
}
fn parse_effector(value: &str, line: usize) -> Result<EffectorRef> {
let (actor_id, effector) = value
.split_once('.')
.ok_or_else(|| Error::parse(line, "effector must be actor qualified"))?;
Ok(EffectorRef {
actor_id: actor_id.into(),
effector: effector.into(),
})
}
fn parse_vec3(value: &str, line: usize) -> Result<Vec3> {
let f: Vec<&str> = value.split_whitespace().collect();
if f.len() != 3 {
return Err(Error::parse(line, "position requires x y z"));
}
Ok(Vec3::new(
parse_f32(f[0], line)?,
parse_f32(f[1], line)?,
parse_f32(f[2], line)?,
))
}
fn parse_vec3_or_xz(value: &str, line: usize) -> Result<Vec3> {
let f: Vec<&str> = value.split_whitespace().collect();
match f.len() {
2 => Ok(Vec3::new(
parse_f32(f[0], line)?,
0.0,
parse_f32(f[1], line)?,
)),
3 => parse_vec3(value, line),
_ => Err(Error::parse(line, "travel requires x z or x y z")),
}
}
fn parse_f32(value: &str, line: usize) -> Result<f32> {
let v: f32 = value
.parse()
.map_err(|_| Error::parse(line, "expected finite number"))?;
if !v.is_finite() {
return Err(Error::parse(line, "expected finite number"));
}
Ok(v)
}
fn parse_confidence(value: &str, line: usize) -> Result<f32> {
let v = parse_f32(value, line)?;
if !(0.0..=1.0).contains(&v) {
return Err(Error::parse(line, "confidence must be in [0, 1]"));
}
Ok(v)
}
fn parse_duration(value: &str, line: usize) -> Result<u64> {
let seconds = value
.strip_suffix('s')
.ok_or_else(|| Error::parse(line, "duration must end in s"))?;
let seconds = parse_f32(seconds, line)?;
if seconds <= 0.0 || seconds > 300.0 {
return Err(Error::parse(line, "duration must be in (0, 300] seconds"));
}
Ok((seconds * 1000.0).round() as u64)
}
fn parse_timing(value: &str, line: usize) -> Result<Timing> {
match value {
"flow" => Ok(Timing::Flow),
"settle" => Ok(Timing::Settle),
"drive" => Ok(Timing::Drive),
"snap" => Ok(Timing::Snap),
"linear" => Ok(Timing::Linear),
"ease-in" => Ok(Timing::EaseIn),
"ease-out" => Ok(Timing::EaseOut),
"ease-in-out" => Ok(Timing::EaseInOut),
_ => Err(Error::parse(line, "unknown timing")),
}
}
fn parse_quoted(value: &str, line: usize) -> Result<String> {
let (quoted, tail) = take_quoted(value, line)?;
if !tail.trim().is_empty() {
return Err(Error::parse(line, "unexpected text after quoted name"));
}
Ok(quoted)
}
fn take_quoted(value: &str, line: usize) -> Result<(String, &str)> {
let rest = value
.strip_prefix('"')
.ok_or_else(|| Error::parse(line, "expected quoted name"))?;
let end = rest
.find('"')
.ok_or_else(|| Error::parse(line, "unterminated quoted name"))?;
Ok((rest[..end].to_string(), &rest[end + 1..]))
}
fn strip_comment(line: &str) -> &str {
let hash = line.find('#');
let slash = line.find("//");
match (hash, slash) {
(Some(a), Some(b)) => &line[..a.min(b)],
(Some(a), None) | (None, Some(a)) => &line[..a],
(None, None) => line,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SCENE: &str = r#"posecode scene "Assisted squat"
source observed_wifi_csi
actor patient:
rig humanoid
pose start = standing
track 7
confidence 0.82
position 0 0 0
actor therapist:
rig humanoid
pose start = standing
position 1.2 0 0
step "Lower" 1.5s flow:
patient.knee_left: flex 95 0.8
patient.ground-lock: feet
therapist.shoulder_left: flex 30
contact therapist.hand_left patient.shoulder_right 0.7
repeat 2
"#;
#[test]
fn parses_multiple_actors_and_contacts() {
let scene = parse_posecode(SCENE).unwrap();
assert_eq!(scene.actors.len(), 2);
assert_eq!(scene.phases[0].actors.len(), 2);
assert_eq!(scene.phases[0].contacts.len(), 1);
assert_eq!(scene.repeat, 2);
}
#[test]
fn rejects_unknown_actor_reference() {
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nstep \"x\" 1s flow:\n ghost.knee_left: flex 20";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_non_finite_number() {
let bad = "posecode scene \"x\"\nactor p1:\n position NaN 0 0";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_duplicate_actors() {
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nactor p1:\n rig humanoid";
assert!(parse_posecode(bad).is_err());
}
#[test]
fn rejects_oversized_document_before_parsing() {
let bad = "x".repeat(MAX_INPUT_BYTES + 1);
assert!(parse_posecode(&bad).is_err());
}
}
@@ -0,0 +1,212 @@
//! Confidence preserving reduction of high-rate observations into phases.
use std::collections::{BTreeMap, BTreeSet};
use crate::error::{Error, Result};
use crate::model::*;
#[derive(Debug, Clone)]
pub struct SegmenterConfig {
pub minimum_phase_ms: u64,
pub maximum_phase_ms: u64,
pub stable_velocity_deg_s: f32,
}
impl Default for SegmenterConfig {
fn default() -> Self {
Self {
minimum_phase_ms: 200,
maximum_phase_ms: 3_000,
stable_velocity_deg_s: 8.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PhaseSegmenter {
config: SegmenterConfig,
}
impl PhaseSegmenter {
pub fn new(config: SegmenterConfig) -> Result<Self> {
if config.minimum_phase_ms == 0
|| config.maximum_phase_ms < config.minimum_phase_ms
|| !config.stable_velocity_deg_s.is_finite()
|| config.stable_velocity_deg_s < 0.0
{
return Err(Error::Observation("invalid segmenter configuration".into()));
}
Ok(Self { config })
}
pub fn segment(&self, name: impl Into<String>, frames: &[SceneFrame]) -> Result<Scene> {
if frames.is_empty() {
return Err(Error::Observation("at least one frame is required".into()));
}
for pair in frames.windows(2) {
if pair[1].timestamp_ms <= pair[0].timestamp_ms {
return Err(Error::Observation(
"timestamps must be strictly increasing".into(),
));
}
}
let mut scene = Scene::new(name);
scene.source = SceneSource::ObservedWifiCsi;
scene.actors = actors_from_frames(frames);
if frames.len() == 1 {
return Ok(scene);
}
let mut start = 0_usize;
let mut was_stable = true;
for i in 1..frames.len() {
let dt = frames[i].timestamp_ms - frames[i - 1].timestamp_ms;
let velocity = angular_velocity(&frames[i - 1], &frames[i], dt);
let stable = velocity <= self.config.stable_velocity_deg_s;
let elapsed = frames[i].timestamp_ms - frames[start].timestamp_ms;
let membership_changed = actor_ids(&frames[i - 1]) != actor_ids(&frames[i]);
let boundary = membership_changed
|| elapsed >= self.config.maximum_phase_ms
|| (stable && !was_stable && elapsed >= self.config.minimum_phase_ms);
if boundary {
scene
.phases
.push(make_phase(scene.phases.len(), &frames[start], &frames[i]));
start = i;
}
was_stable = stable;
}
let last = frames.len() - 1;
if start < last {
scene.phases.push(make_phase(
scene.phases.len(),
&frames[start],
&frames[last],
));
}
Ok(scene)
}
}
fn actors_from_frames(frames: &[SceneFrame]) -> Vec<Actor> {
let mut found = BTreeMap::<u64, Actor>::new();
for frame in frames {
for observed in &frame.actors {
found
.entry(observed.track_id)
.and_modify(|actor| {
actor.confidence = actor.confidence.max(observed.confidence);
actor.position = observed.position;
})
.or_insert_with(|| {
let mut actor = Actor::humanoid(observed.actor_id.clone());
actor.track_id = Some(observed.track_id);
actor.confidence = observed.confidence;
actor.position = observed.position;
actor
});
}
}
found.into_values().collect()
}
fn make_phase(index: usize, start: &SceneFrame, end: &SceneFrame) -> Phase {
let actors = end
.actors
.iter()
.map(|observed| ActorTargets {
actor_id: observed.actor_id.clone(),
joints: observed.joints.clone(),
ground_lock: Vec::new(),
travel: Some(observed.position),
confidence: observed.confidence,
})
.collect();
Phase {
name: format!("Observed {}", index + 1),
start_ms: start.timestamp_ms,
duration_ms: (end.timestamp_ms - start.timestamp_ms).max(1),
timing: Timing::Linear,
actors,
contacts: Vec::new(),
}
}
fn actor_ids(frame: &SceneFrame) -> BTreeSet<u64> {
frame.actors.iter().map(|a| a.track_id).collect()
}
fn angular_velocity(previous: &SceneFrame, current: &SceneFrame, dt_ms: u64) -> f32 {
let mut sum = 0.0;
let mut count = 0_u32;
for a in &current.actors {
let Some(before) = previous.actors.iter().find(|p| p.track_id == a.track_id) else {
return f32::INFINITY;
};
for joint in &a.joints {
if let Some(old) = before
.joints
.iter()
.find(|j| j.joint == joint.joint && j.action == joint.action)
{
sum += (joint.degrees - old.degrees).abs() * joint.confidence.min(old.confidence);
count += 1;
}
}
}
if count == 0 {
return 0.0;
}
(sum / count as f32) / (dt_ms as f32 / 1000.0).max(0.001)
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(ms: u64, angle: f32) -> SceneFrame {
SceneFrame {
timestamp_ms: ms,
actors: vec![ActorObservation {
actor_id: "p1".into(),
track_id: 1,
confidence: 0.9,
position: Vec3::default(),
joints: vec![JointTarget {
joint: "knee_left".into(),
action: "flex".into(),
degrees: angle,
confidence: 0.9,
}],
}],
}
}
#[test]
fn creates_compact_observed_scene() {
let frames = vec![
frame(0, 0.0),
frame(100, 20.0),
frame(200, 40.0),
frame(300, 40.1),
frame(500, 40.1),
];
let scene = PhaseSegmenter::new(SegmenterConfig::default())
.unwrap()
.segment("squat", &frames)
.unwrap();
assert_eq!(scene.actors.len(), 1);
assert!(!scene.phases.is_empty());
assert!(scene.phases.len() < frames.len());
assert_eq!(scene.source, SceneSource::ObservedWifiCsi);
}
#[test]
fn rejects_reversed_time() {
let frames = vec![frame(100, 0.0), frame(50, 1.0)];
assert!(PhaseSegmenter::new(SegmenterConfig::default())
.unwrap()
.segment("bad", &frames)
.is_err());
}
}
@@ -0,0 +1,131 @@
//! Canonical deterministic PoseCode text serialization.
use std::fmt::Write;
use crate::model::*;
pub fn to_posecode(scene: &Scene) -> String {
let mut out = String::new();
writeln!(out, "posecode scene \"{}\"", safe_text(&scene.name)).unwrap();
writeln!(out, "source {}", source_name(scene.source)).unwrap();
for actor in &scene.actors {
writeln!(out, "actor {}:", actor.id).unwrap();
writeln!(out, " rig {}", actor.rig).unwrap();
writeln!(out, " pose start = {}", actor.start_pose).unwrap();
if let Some(track) = actor.track_id {
writeln!(out, " track {track}").unwrap();
}
writeln!(out, " confidence {}", number(actor.confidence)).unwrap();
writeln!(
out,
" position {} {} {}",
number(actor.position.x),
number(actor.position.y),
number(actor.position.z)
)
.unwrap();
}
for phase in &scene.phases {
writeln!(
out,
"step \"{}\" {}s {}:",
safe_text(&phase.name),
number(phase.duration_ms as f32 / 1000.0),
phase.timing.as_str()
)
.unwrap();
for actor in &phase.actors {
for joint in &actor.joints {
writeln!(
out,
" {}.{}: {} {} {}",
actor.actor_id,
joint.joint,
joint.action,
number(joint.degrees),
number(joint.confidence)
)
.unwrap();
}
if !actor.ground_lock.is_empty() {
writeln!(
out,
" {}.ground-lock: {}",
actor.actor_id,
actor.ground_lock.join(", ")
)
.unwrap();
}
if let Some(p) = actor.travel {
writeln!(
out,
" {}.travel: {} {} {}",
actor.actor_id,
number(p.x),
number(p.y),
number(p.z)
)
.unwrap();
}
}
for contact in &phase.contacts {
writeln!(
out,
" contact {}.{} {}.{} {}",
contact.from.actor_id,
contact.from.effector,
contact.to.actor_id,
contact.to.effector,
number(contact.confidence)
)
.unwrap();
}
}
writeln!(out, "repeat {}", scene.repeat).unwrap();
out
}
fn source_name(source: SceneSource) -> &'static str {
match source {
SceneSource::Authored => "authored",
SceneSource::ObservedWifiCsi => "observed_wifi_csi",
SceneSource::Imported => "imported",
}
}
fn safe_text(value: &str) -> String {
value
.chars()
.filter(|c| !c.is_control() && *c != '"')
.collect()
}
fn number(value: f32) -> String {
if value == 0.0 {
return "0".into();
}
let mut text = format!("{value:.4}");
while text.ends_with('0') {
text.pop();
}
if text.ends_with('.') {
text.pop();
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_posecode;
#[test]
fn canonical_text_round_trips() {
let mut scene = Scene::new("two people");
scene.actors.push(Actor::humanoid("p1"));
scene.actors.push(Actor::humanoid("p2"));
let text = to_posecode(&scene);
assert_eq!(parse_posecode(&text).unwrap(), scene);
assert_eq!(to_posecode(&parse_posecode(&text).unwrap()), text);
}
}
@@ -0,0 +1,202 @@
//! Deterministic scene validation with provenance-aware range policy.
use std::collections::HashSet;
use crate::model::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationIssue {
pub severity: ValidationSeverity,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub max_actors: usize,
pub max_phases: usize,
/// Authored values outside general ROM are errors. Observed values are
/// warnings because RF estimates are not medical measurements.
pub authored_rom_is_error: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_actors: MAX_ACTORS,
max_phases: MAX_PHASES,
authored_rom_is_error: true,
}
}
}
pub fn validate_scene(scene: &Scene, config: &ValidationConfig) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if scene.name.is_empty() || scene.name.len() > MAX_NAME_BYTES {
error(
&mut issues,
"scene.name",
"name must contain 1 to 128 bytes",
);
}
if scene.actors.len() > config.max_actors {
error(&mut issues, "scene.actors", "actor limit exceeded");
}
if scene.phases.len() > config.max_phases {
error(&mut issues, "scene.phases", "phase limit exceeded");
}
let mut ids = HashSet::new();
for (i, actor) in scene.actors.iter().enumerate() {
let path = format!("actors[{i}]");
if !valid_id(&actor.id) {
error(&mut issues, &path, "invalid actor identifier");
}
if !ids.insert(actor.id.as_str()) {
error(&mut issues, &path, "duplicate actor identifier");
}
check_confidence(&mut issues, &format!("{path}.confidence"), actor.confidence);
if !actor.position.is_finite() {
error(
&mut issues,
&format!("{path}.position"),
"position must be finite",
);
}
}
let actor_ids: HashSet<&str> = scene.actors.iter().map(|a| a.id.as_str()).collect();
for (pi, phase) in scene.phases.iter().enumerate() {
let path = format!("phases[{pi}]");
if phase.duration_ms == 0 || phase.duration_ms > 300_000 {
error(
&mut issues,
&format!("{path}.duration_ms"),
"duration must be 1 to 300000 ms",
);
}
let mut phase_actors = HashSet::new();
for targets in &phase.actors {
if !actor_ids.contains(targets.actor_id.as_str()) {
error(&mut issues, &path, "phase references unknown actor");
}
if !phase_actors.insert(targets.actor_id.as_str()) {
error(&mut issues, &path, "actor appears more than once in phase");
}
if targets.joints.len() > MAX_TARGETS_PER_ACTOR {
error(&mut issues, &path, "joint target limit exceeded");
}
check_confidence(&mut issues, &path, targets.confidence);
for joint in &targets.joints {
check_confidence(&mut issues, &path, joint.confidence);
if !joint.degrees.is_finite() {
error(&mut issues, &path, "joint angle must be finite");
continue;
}
if let Some((min, max)) = range(&joint.joint, &joint.action) {
if joint.degrees < min || joint.degrees > max {
let severity = if scene.source == SceneSource::Authored
&& config.authored_rom_is_error
{
ValidationSeverity::Error
} else {
ValidationSeverity::Warning
};
issues.push(ValidationIssue {
severity,
path: path.clone(),
message: format!(
"{}.{} angle {} outside general range [{}, {}]",
joint.joint, joint.action, joint.degrees, min, max
),
});
}
}
}
}
for contact in &phase.contacts {
if !actor_ids.contains(contact.from.actor_id.as_str())
|| !actor_ids.contains(contact.to.actor_id.as_str())
{
error(&mut issues, &path, "contact references unknown actor");
}
check_confidence(&mut issues, &path, contact.confidence);
}
}
issues
}
fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.enumerate()
.all(|(i, b)| b.is_ascii_alphanumeric() || b == b'_' || (i > 0 && b == b'-'))
}
fn check_confidence(issues: &mut Vec<ValidationIssue>, path: &str, value: f32) {
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
error(issues, path, "confidence must be finite and in [0, 1]");
}
}
fn error(issues: &mut Vec<ValidationIssue>, path: &str, message: &str) {
issues.push(ValidationIssue {
severity: ValidationSeverity::Error,
path: path.into(),
message: message.into(),
});
}
fn range(joint: &str, action: &str) -> Option<(f32, f32)> {
match (
joint.trim_end_matches("_left").trim_end_matches("_right"),
action,
) {
("shoulder", "flex") => Some((0.0, 180.0)),
("shoulder", "extend") => Some((0.0, 60.0)),
("elbow", "flex") => Some((0.0, 154.0)),
("hip", "flex") => Some((0.0, 135.0)),
("hip", "extend") => Some((0.0, 20.0)),
("knee", "flex") => Some((0.0, 144.0)),
("pelvis", "hinge") => Some((0.0, 120.0)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observed_rom_is_warning_not_medical_error() {
let mut scene = Scene::new("observed");
scene.source = SceneSource::ObservedWifiCsi;
scene.actors.push(Actor::humanoid("p1"));
let mut targets = ActorTargets::new("p1");
targets.joints.push(JointTarget {
joint: "knee_left".into(),
action: "flex".into(),
degrees: 170.0,
confidence: 0.5,
});
scene.phases.push(Phase {
name: "frame".into(),
start_ms: 0,
duration_ms: 50,
timing: Timing::Linear,
actors: vec![targets],
contacts: vec![],
});
let issues = validate_scene(&scene, &ValidationConfig::default());
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, ValidationSeverity::Warning);
}
}
+4 -4
View File
@@ -15,10 +15,10 @@ repository.workspace = true
# (serde / serde_json / toml / sha2 / ed25519-dalek only — no tch / openblas /
# ndarray / candle), so they build under `--no-default-features`.
[dependencies]
rufield-core = { path = "../../../vendor/rufield/crates/rufield-core" }
rufield-provenance = { path = "../../../vendor/rufield/crates/rufield-provenance" }
rufield-privacy = { path = "../../../vendor/rufield/crates/rufield-privacy" }
rufield-fusion = { path = "../../../vendor/rufield/crates/rufield-fusion" }
rufield-core = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-core" }
rufield-provenance = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-provenance" }
rufield-privacy = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-privacy" }
rufield-fusion = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-fusion" }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "wifi-densepose-signal"
version = "0.3.4"
version = "0.3.5"
edition.workspace = true
description = "WiFi CSI signal processing for DensePose estimation"
license.workspace = true
+1 -1