mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
Merge pull request #1617 from ruvnet/feat/adr-323-pose-physics
feat: add physics-constrained pose refinement
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
# ADR-323: Native Rust physics-constrained pose refinement
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-08-15
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView perception and edge runtime maintainers
|
||||
- **Tags**: pose, physics, rust, uncertainty, provenance, abstention, edge
|
||||
- **Numbering note**: ADR-323 is the next free number in the authoring checkout. Re-run the ADR index/collision check immediately before merge and rename if needed.
|
||||
- **Extends**: ADR-020, ADR-027, ADR-079, ADR-101, ADR-135, ADR-145, ADR-150, ADR-273, ADR-279, ADR-282, ADR-295, ADR-296, ADR-297, ADR-298, ADR-302, ADR-303, ADR-304, ADR-305, ADR-306
|
||||
- **Supersedes**: None
|
||||
|
||||
## Executive decision
|
||||
|
||||
RuView will add a clean-room native Rust boundary between RF pose inference and
|
||||
semantic publication. It will preserve the immutable RF observation, publish a
|
||||
physics assessment, optionally produce a bounded corrected candidate, and
|
||||
abstain when required evidence is absent. It must never increase observational
|
||||
confidence merely because a pose is physically plausible.
|
||||
|
||||
Three independently gated layers are adopted:
|
||||
|
||||
1. A deterministic kinematic auditor and bounded covariance-weighted projector
|
||||
using Rust and `nalgebra`.
|
||||
2. An optional articulated-body dynamics auditor using `rapier3d`.
|
||||
3. A later optional supervised residual model using Burn.
|
||||
|
||||
The first production milestone is deterministic audit. It is not a GRIP port,
|
||||
not PPO, and not evidence that the current pose observer is production-ready.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-101's committed Cog emits 17 COCO keypoints as normalized 2D coordinates.
|
||||
Its model has no per-joint uncertainty head and publishes a constant confidence.
|
||||
The sensing server also contains renderer-oriented EMA and bone clamping. These
|
||||
surfaces cannot establish metric 3D physics and can make weak evidence look
|
||||
more convincing.
|
||||
|
||||
Pose output can violate bone length, floor, velocity, acceleration, and temporal
|
||||
continuity constraints. Downstream consumers also cannot reliably distinguish
|
||||
observed coordinates from derived correction. The rejected premise is:
|
||||
"physically plausible means more likely correct." Plausibility is only a prior;
|
||||
many incorrect poses are plausible.
|
||||
|
||||
GRIP is architectural inspiration for an observer/controller split, but it
|
||||
observes four wearable IMUs and pressure insoles and drives a simulator. RuView
|
||||
observes RF, so GRIP weights are not input-compatible. External code, weights,
|
||||
simulators, and datasets require independent license review and never enter the
|
||||
runtime dependency graph by implication.
|
||||
|
||||
## Outcome and actors
|
||||
|
||||
For every accepted person track/timestamp, the engine returns exactly one
|
||||
`PoseRefinementV1`, including off, timeout, rejection, and abstention paths:
|
||||
|
||||
- immutable `PoseObservationV2` content hash;
|
||||
- constraint residuals and quality disposition;
|
||||
- an optional bounded candidate and an explicit `selected` bit;
|
||||
- a typed reason when correction is unavailable;
|
||||
- model, calibration, configuration, and optional learned-artifact provenance.
|
||||
|
||||
The RF observer owns observations and calibrated uncertainty; tracking owns
|
||||
identity stability; physics owns assessment/correction only; the sensing server
|
||||
owns deadlines, modes, publication, and rollback; the evidence engine owns
|
||||
release evaluation; clients choose raw/both/refined without silent fallback.
|
||||
|
||||
## Input and coordinate contract
|
||||
|
||||
Metric correction requires a monotonic nanosecond timestamp, session-scoped
|
||||
track ID, sequence and sensor epoch, 17 ordered COCO joints in metric X/Y/Z,
|
||||
per-joint positive-semidefinite covariance calibrated on held-out data, a
|
||||
versioned right-handed Z-up room frame, a normalized upward floor plane, model
|
||||
and calibration hashes, ADR-302 trust state, and authenticated/replay-protected
|
||||
source provenance.
|
||||
|
||||
`Image2d` observations may be audited for image-plane ratios and continuity but
|
||||
must never enter 3D projection/dynamics or be called physically corrected.
|
||||
Unknown trust, missing calibration, missing uncertainty, stale/non-monotonic
|
||||
input, non-finite values, invalid covariance, excessive tracks, and room-bound
|
||||
violations fail to raw output with a typed reason.
|
||||
|
||||
## Public contracts
|
||||
|
||||
`wifi-densepose-core` owns `PoseObservationV2` and `PoseRefinementV1`; no
|
||||
duplicate server/Cog contract is permitted. Public output remains COCO17. The
|
||||
engine derives pelvis and thorax virtually and never labels them observed.
|
||||
|
||||
The raw content hash is deterministic and excludes its own hash field. The
|
||||
idempotency key is `(sensor_epoch, sequence, track_id, raw_hash, config_hash)`.
|
||||
An exact duplicate returns the cached result; same sequence with different
|
||||
content is a replay rejection.
|
||||
|
||||
Contact is `hypothesis` unless a measured sensor and its provenance say
|
||||
otherwise. Raw, derived, hypothesis, and unknown labels must survive every
|
||||
projection.
|
||||
|
||||
## Confidence invariant
|
||||
|
||||
For upstream calibrated confidence `c_obs`, normalized residual `r`, and
|
||||
normalized intervention `i`:
|
||||
|
||||
```text
|
||||
c_physics = exp(-(beta_r * r + beta_i * i))
|
||||
c_effective = min(c_obs, c_obs * c_physics)
|
||||
0 <= c_effective <= c_obs <= 1
|
||||
```
|
||||
|
||||
Only a separately witnessed multimodal fusion contract may increase fused
|
||||
confidence.
|
||||
|
||||
## Deterministic projector
|
||||
|
||||
The default `kinematic` feature has no Rapier, Burn, ONNX, libtorch, Python,
|
||||
CUDA, or network dependency. Per bounded iteration it:
|
||||
|
||||
1. projects observed parent/child distances toward anonymous track-scoped
|
||||
bone-length posteriors;
|
||||
2. applies broad joint/trunk validity checks without an upright prior;
|
||||
3. bounds temporal motion and resets derivatives after gaps;
|
||||
4. resolves floor penetration only, allowing seated, kneeling, prone, child-
|
||||
scale, mobility-aid, and genuine-fall poses;
|
||||
5. recomputes residuals and stops below epsilon.
|
||||
|
||||
Initial operator-owned caps are four iterations (hard maximum eight), 0.20 m
|
||||
single-joint correction, 0.10 m root correction, 250 ms derivative gap, 500 ms
|
||||
track reset, ten known joints, a 100 m metric room bound, a separate 16,384
|
||||
image-coordinate audit bound, and a 5 ms one-track Pi 5 p95 gate. Keeping image
|
||||
and metric bounds separate prevents legitimate pixel observations from
|
||||
weakening the physical room bound. A candidate over either correction cap is
|
||||
discarded in full.
|
||||
|
||||
Bone posteriors are initialized only from high-confidence frames, anonymous,
|
||||
memory-only, track-scoped, and deleted on expiry. Persistent personalization is
|
||||
outside this ADR and requires consent/retention/deletion governance.
|
||||
|
||||
## Optional dynamics and learned layers
|
||||
|
||||
`dynamics` adds a process-owned Rapier humanoid and begins audit-only. Network
|
||||
input may never provide Rapier snapshots, bodies, constraints, solver limits,
|
||||
or arbitrary geometry. Dynamics approval is independent of kinematic approval.
|
||||
|
||||
`learned` uses first-party Burn 0.21 core/NN components without `burn-tch`
|
||||
because this workspace already has a different native libtorch link.
|
||||
`learned-cpu` adds the ndarray backend. The implemented two-layer GRU uses a
|
||||
20-frame history and width 128 to predict bounded residuals, uncertainty,
|
||||
foot-contact hypotheses, and abstention. Verified model records can be loaded
|
||||
from bytes and executed natively; no trained artifact is shipped or approved.
|
||||
The resolved Burn/CubeCL graph declares Rust 1.92, while the workspace file
|
||||
pins Rust 1.89 and the authoring host provides Rust 1.91.1.
|
||||
`--ignore-rust-version` is diagnostic evidence only: learned activation remains
|
||||
blocked until an approved Rust 1.92 release-toolchain change builds it without
|
||||
that override. Residuals are hard-clipped to deterministic caps and cannot
|
||||
bypass validation or confidence monotonicity. PPO is deferred until measured
|
||||
evidence identifies a failure supervised residual learning cannot address.
|
||||
|
||||
## Feature boundary
|
||||
|
||||
```text
|
||||
default = kinematic
|
||||
dynamics = rapier3d
|
||||
learned = burn-core + burn-nn
|
||||
learned-cpu = learned + burn-ndarray
|
||||
learned-train = learned + burn-train
|
||||
learned-wgpu = learned-train + burn-wgpu
|
||||
learned-cuda = learned-train + burn-cuda
|
||||
deterministic = rapier3d?/enhanced-determinism
|
||||
```
|
||||
|
||||
The lockfile is release authority. The learned feature currently requires the
|
||||
toolchain supported by Burn/CubeCL's resolved graph; this does not change the
|
||||
default edge build.
|
||||
|
||||
## Runtime modes and API
|
||||
|
||||
Rollout is `OFF -> AUDIT -> SHADOW_CORRECT -> OPT_IN_CORRECT -> DEFAULT_CORRECT`.
|
||||
Evidence permits forward transitions; any regression returns immediately to
|
||||
audit/off. Correct selection additionally requires authenticated sensor
|
||||
identity and replay protection from ADR-305. High model confidence cannot
|
||||
override missing source authentication.
|
||||
|
||||
Existing pose fields stay unchanged and raw remains the migration default:
|
||||
|
||||
```text
|
||||
GET /api/v1/pose/current?view=raw
|
||||
GET /api/v1/pose/current?view=both
|
||||
GET /api/v1/pose/current?view=refined
|
||||
```
|
||||
|
||||
Refined-only returns HTTP 409 with `pose_refined_unavailable` when no selected
|
||||
candidate exists. It never silently returns raw labeled refined.
|
||||
|
||||
## Security, privacy, and availability
|
||||
|
||||
All frames, model output, geometry, and pre-verification artifacts are
|
||||
untrusted. Calibration/config/model artifacts become trusted only after signed,
|
||||
hash-addressed verification and atomic activation. Runtime inference performs
|
||||
no model retrieval or other network access.
|
||||
|
||||
Fixed arrays/caps, bounded iterations, a maximum track count, room geometry
|
||||
limits, deadlines, and track expiry constrain denial of service. Timeout drops
|
||||
partial refinement, never raw publication. Backpressure retains the newest raw
|
||||
frame per track, drops intermediate refinement work, resets derivatives after
|
||||
250 ms, and never extrapolates beyond 500 ms.
|
||||
|
||||
Metrics contain only allowlisted aggregate scalars: mode/disposition/reason,
|
||||
stage latency, iterations, maximum correction, residuals, confidence delta,
|
||||
track resets, invalid input, timeout, and raw/refined divergence. They exclude
|
||||
joint arrays, body dimensions, room coordinates, CSI, and persistent person
|
||||
identifiers. Bone/gait state is memory-only and excluded from logs.
|
||||
|
||||
Refined output is not a sole medical, emergency, industrial-safety, or
|
||||
autonomous-control source. A real fall is valid state and must never be made
|
||||
upright to stabilize a simulator.
|
||||
|
||||
## Threat model summary
|
||||
|
||||
| Threat | Primary control | Residual risk |
|
||||
|---|---|---|
|
||||
| Spoofed/replayed sensor | ADR-305 identity, MAC, sequence and replay window; correction gate | Compromised legitimate sensor |
|
||||
| Altered model/floor/config | Signed hashes, authenticated configuration, atomic activation | Authorized unsafe configuration |
|
||||
| Poisoned data/splits | Immutable manifests, strict split validator, witnessed benchmarks | Subtle label poisoning |
|
||||
| Operator repudiation | Append-only witnessed transition with actor/old/new hash/reason | Compromised signer |
|
||||
| Biometric/log leakage | Track-local retention and fixed metric allowlist | Aggregate inference |
|
||||
| Track/geometry CPU flood | Authentication, cardinality/geometry/allocation/deadline caps | Valid dense-scene overload |
|
||||
| Remote mode escalation | Capability-scoped local control plane, deny by default | Compromised operator capability |
|
||||
| Derived output relabeled observed | Required schema/provenance and signed event envelope | Malicious downstream stripping |
|
||||
|
||||
The implementation review records commit, lockfile hash, Rust toolchain,
|
||||
scanner versions, and advisory-feed timestamp.
|
||||
|
||||
## Evidence protocol
|
||||
|
||||
Evidence levels are L0 deterministic synthetic, L1 public measured replay, L2
|
||||
controlled RuView RF plus optical truth, L3 subject/room/hardware/session-
|
||||
disjoint RuView, L4 privacy-safe shadow fleet aggregates, and L5 independent
|
||||
vertical validation outside this ADR.
|
||||
|
||||
No sequence, contiguous take, subject, room, or calibration session may cross
|
||||
train/test for the generalization gate. Preprocessing, body priors, and
|
||||
uncertainty calibration fit training data only. Reports include raw observer,
|
||||
renderer smoothing, audit, deterministic correction, dynamics audit, and
|
||||
learned residual on identical observations, plus empty-room, prone/fall,
|
||||
missing-joint, and OOD subsets.
|
||||
|
||||
Primary metrics are 3D MPJPE, declared-threshold PCK, per-joint error, foot
|
||||
slide, floor penetration, jerk, uncertainty calibration, abstention coverage,
|
||||
and selective risk. Learned runs use at least five fixed seeds and report mean,
|
||||
median, standard deviation, and 95% bootstrap intervals. All frames count;
|
||||
selective metrics report risk and coverage.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
- **G0 contract**: real metric 3D/covariance output, round-trip raw hash,
|
||||
versioned frame/floor, 2D compatibility, non-stub observer, ADR-298 artifact
|
||||
sanity, and the ADR-079 PCK@20 >=35% gate or adopted successor. The current
|
||||
committed Cog does not pass G0, so correction remains unavailable.
|
||||
- **G1 deterministic audit**: property/fuzz tests, deterministic hashes per
|
||||
platform class, 24-hour accelerated replay without panic/growth, Pi 5 p95
|
||||
<=5 ms, and universal confidence monotonicity.
|
||||
- **G2 shadow correction**: strict-disjoint measured median MPJPE improvement
|
||||
>=10% with positive 95% CI lower bound; foot slide >=30% and jerk >=25%
|
||||
better; no joint median >5 mm worse; fall/prone sensitivity change <=2 pp;
|
||||
>=95% corrections below 0.10 m; every correction above 0.20 m abstains.
|
||||
- **G3 opt-in**: >=30 subjects, 10 rooms, 3 hardware configurations, and 3
|
||||
independent sessions/room; UNKNOWN never selected; confidence monotonic;
|
||||
live disable; REST/WebSocket/MQTT/Home Assistant/replay compatibility.
|
||||
- **G4 default visualization only**: 30 shadow days under 0.1% timeout/internal
|
||||
error, no open severity 1/2 incidents, and gates still valid for current
|
||||
model/calibration.
|
||||
|
||||
Dynamics and learned engines each repeat G2-G4; approval is not inherited.
|
||||
|
||||
## Testing and completion evidence
|
||||
|
||||
Unit/property/fuzz/integration/security coverage maps to requirements R1-R13:
|
||||
raw hash, confidence, modes, malformed/stale/frame/covariance input, caps and
|
||||
deadlines, provenance, dependency graph, pose diversity/fall preservation,
|
||||
strict splits, fail-to-raw faults, no network capability, and authenticated
|
||||
source/replay selection.
|
||||
|
||||
Release commands include focused core/physics tests, default/dynamics/learned
|
||||
feature checks, format/clippy, benches, `cargo deny`, `cargo audit`, strict split
|
||||
verification, and golden replay verification. Completion also requires JSON
|
||||
schemas, measured Pi 5/x86 rows, strict manifest hashes, raw/refined metrics,
|
||||
SBOM/license report, rollback drill, and residual-risk owners. Missing measured
|
||||
or operational evidence leaves status Proposed and runtime in audit.
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback is an authenticated mode transition to audit/off, not a binary
|
||||
downgrade. Stop selection immediately, keep raw publication and disposition
|
||||
records, discard track state, and retain only aggregate incident metrics plus
|
||||
signed configuration history. Failed artifact activation leaves the previous
|
||||
engine atomically active. Additive schemas remain; refined-only callers receive
|
||||
the typed unavailable response.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Explicit anti-hallucination and provenance boundary after RF inference.
|
||||
- Reusable native Rust consistency primitive with measurable abstention.
|
||||
- Python/CUDA remain absent from the production default.
|
||||
- Cross-modal teacher data remains possible without wearable runtime inputs.
|
||||
|
||||
### Negative
|
||||
|
||||
- Full value requires a real metric 3D observer and calibrated uncertainty.
|
||||
- Stateful tracks add latency/memory; optional backends add supply-chain surface.
|
||||
- A constrained but wrong pose can look more credible.
|
||||
- Strict data collection costs more than the software implementation.
|
||||
|
||||
### Neutral
|
||||
|
||||
- This ADR does not improve RF observability or current weight evidence.
|
||||
- Existing 2D consumers continue to function.
|
||||
|
||||
## Implementation phases
|
||||
|
||||
P0 contracts/schemas; P1 deterministic audit; P2 bounded shadow correction; P3
|
||||
server/Cog publication and evidence ledger; P4 Rapier audit; P5 Burn residual
|
||||
training/inference. Code may land ahead of evidence, but runtime authority
|
||||
advances only through the gates above.
|
||||
|
||||
## Implementation status at proposal
|
||||
|
||||
- P0-P3 are implemented on this branch: canonical contracts, strict schemas,
|
||||
deterministic audit/projection, authenticated correction receipts,
|
||||
idempotency, bounded track state, latest-frame backpressure, additive HTTP
|
||||
and WebSocket publication, live legacy-2D audit, privacy-safe metrics, golden
|
||||
replay, and strict-split checks.
|
||||
- P4 is implemented as an optional persistent per-track Rapier dynamics auditor
|
||||
and remains audit-only pending independent G2-G4 evidence.
|
||||
- P5 inference architecture, artifact verification, serialization, and native
|
||||
CPU execution are implemented. Training data, a signed trained artifact, and
|
||||
G2-G4 accuracy/calibration evidence do not exist, so the layer has no runtime
|
||||
selection authority. Its resolved Rust 1.92 requirement is also an explicit
|
||||
activation blocker on the current Rust 1.91.1 release host.
|
||||
- The live Cog honestly emits `Image2d`, degraded trust, and uncalibrated
|
||||
uncertainty. It can be audited but cannot be selected for 3D correction.
|
||||
G0 therefore remains open until an independently released metric-3D observer
|
||||
with calibrated covariance is integrated.
|
||||
- Local x86 latency and synthetic contract checks are recorded in the append-
|
||||
only evidence ledger. Pi 5 measurements, 24-hour replay, 100-million-case
|
||||
fuzzing, held-out RF/optical accuracy, fleet shadowing, and vertical safety
|
||||
validation remain release evidence gates rather than software claims.
|
||||
|
||||
## References
|
||||
|
||||
- [GRIP project](https://ryosukehori.github.io/grip-project/)
|
||||
- [GRIP paper (arXiv:2603.16233)](https://arxiv.org/abs/2603.16233)
|
||||
- [Rapier documentation](https://docs.rs/rapier3d/)
|
||||
- [Burn documentation](https://docs.rs/burn/0.21.0/burn/)
|
||||
- [ADR-020](./ADR-020-rust-ruvector-ai-model-migration.md)
|
||||
- [ADR-079](./ADR-079-camera-ground-truth-training.md)
|
||||
- [ADR-101](./ADR-101-pose-estimation-cog.md)
|
||||
- [ADR-150](./ADR-150-rf-foundation-encoder.md)
|
||||
- [ADR-273](./ADR-273-unified-rf-spatial-world-model.md)
|
||||
- [ADR-279](./ADR-279-native-rf-frame-contract.md)
|
||||
- [ADR-298](./ADR-298-model-release-sanity-gates.md)
|
||||
- [ADR-302](./ADR-302-out-of-distribution-detection.md)
|
||||
- [ADR-303](./ADR-303-ground-truth-synchronization.md)
|
||||
- [ADR-304](./ADR-304-evidence-engine.md)
|
||||
- [ADR-305](./ADR-305-authenticated-sensor-identity.md)
|
||||
- [ADR-306](./ADR-306-canonical-spatial-ontology.md)
|
||||
@@ -179,6 +179,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-319](ADR-319-witness-chain.md) | Witness chain — staged, signed epistemic envelope | Accepted (phase 1) |
|
||||
| [ADR-320](ADR-320-sensor-hal.md) | RuView sensor HAL — abstract all sensing hardware to one Observation type | Proposed (phase 2) |
|
||||
| [ADR-321](ADR-321-decision-policy-action-authorization.md) | Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, evidence | Accepted (phase 1) |
|
||||
| [ADR-323](ADR-323-native-rust-physics-constrained-pose-refinement.md) | Native Rust physics-constrained pose refinement | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
||||
71
docs/benchmarks/physics-pose-refinement.md
Normal file
71
docs/benchmarks/physics-pose-refinement.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Physics pose refinement evidence ledger
|
||||
|
||||
ADR-323 performance and accuracy targets are gates, not measured claims. Append
|
||||
rows; never replace prior measurements. Every row must identify the repository
|
||||
commit, lockfile hash, Rust toolchain, target, engine/features, configuration
|
||||
hash, corpus/split hash, command, sample count, and evidence label.
|
||||
|
||||
## Runtime measurements
|
||||
|
||||
| Date | Commit | Lock SHA-256 | Target/toolchain | Engine/config | Tracks | Samples | p50 | p95 | p99/max | RSS delta | Evidence | Reproducer |
|
||||
|---|---|---|---|---|---:|---:|---:|---:|---:|---:|---|---|
|
||||
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow, config `ef3cf581f75124c1d45a8d6bedcef32e4d1bacb39ee0dfcd4e520171fda2d8cf` | 1 | 20,000 | 0.0080 ms | 0.0097 ms | 0.0195/0.5465 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | `cargo run --release -p wifi-densepose-physics --example latency_probe -- 20000` |
|
||||
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | deterministic kinematic shadow after final local optimization, same config | 1 | 20,000 | 0.0075 ms | 0.0084 ms | 0.0117/0.1579 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
|
||||
| 2026-08-15 | `de27336` + uncommitted ADR-323 changes | `552737eab9092b59ea9dd2b2caf68389f0b0966679f0fbb33ff2b1b3d42e2668` | Windows x86_64, Intel Core Ultra 9 285H, rustc 1.91.1 | final deterministic kinematic shadow, config `a44dc696234f31eda54cd4b436bc2d2c69b9638565b729ac9f07435cedfd0dcc` | 1 | 20,000 | 0.0071 ms | 0.0084 ms | 0.0147/1.5994 ms | not measured | **MEASURED**, local host only; not Pi 5 evidence | same release probe command |
|
||||
|
||||
The probe measures a warm, one-track `PhysicsEngine::process` call. It excludes
|
||||
transport, publication, resident-memory delta, dynamics, and learned inference.
|
||||
It is not evidence for the Pi 5 gate.
|
||||
|
||||
Criterion separately measured `kinematic_one_track` at
|
||||
`[11.911, 12.757, 14.069] us` across 100 samples (approximately 369,000 timed
|
||||
iterations). That benchmark includes observation construction and canonical
|
||||
hashing in the timed routine and uses fresh engine state; it is **MEASURED** on
|
||||
the same local host and is not a percentile or Pi 5 claim.
|
||||
|
||||
## Accuracy measurements
|
||||
|
||||
| Date | Commit | Corpus/split | Variant | Coverage | MPJPE | PCK threshold/result | Foot slide | Jerk | Fall/prone delta | Evidence |
|
||||
|---|---|---|---|---:|---:|---|---:|---:|---:|---|
|
||||
|
||||
No measured accuracy evidence has been recorded. The deterministic tests are
|
||||
L0/SYNTHETIC contract evidence only and cannot satisfy G2.
|
||||
|
||||
## Validation and supply-chain record
|
||||
|
||||
- The default dependency graph is checked to exclude Burn, Rapier, Tch, and
|
||||
ONNX Runtime. Dynamics and learned backends remain opt-in.
|
||||
- Burn CPU serialization/inference tests pass on the authoring host only with
|
||||
Cargo's `--ignore-rust-version`; the resolved CubeCL graph requires Rust 1.92.
|
||||
The workspace file pins Rust 1.89 and the host provides Rust 1.91.1. This is
|
||||
diagnostic, not release approval.
|
||||
- `cargo audit 0.22.1` used RustSec database commit
|
||||
`69f93cf294852cfa9b53751f4ca86de3283dd290` (feed timestamp 2026-08-12).
|
||||
ADR-323 updates remove resolved advisories in `event-listener`, `rkyv`, and
|
||||
`wasmtime`. The workspace still has five advisories in pre-existing
|
||||
`quick-xml` and `rsa` dependency paths; the default physics graph contains
|
||||
none of them. The optional Burn training graph includes yanked `spin 0.9.8`.
|
||||
- `cargo-deny` is not installed on the authoring host, so the required license
|
||||
and policy gate is not claimed complete.
|
||||
- Strict Clippy passes with warnings denied for core/physics default and
|
||||
dynamics builds, the diagnostic learned-CPU build, and the Cog itself with
|
||||
dependency linting excluded. Focused core, physics, dynamics, learned, Cog,
|
||||
sensing-server adapter/live-audit/HTTP, schema, golden, strict-split,
|
||||
feature-boundary, and fuzz-build checks pass.
|
||||
- The repository-wide rustfmt gate is already red across unrelated crates. The
|
||||
sensing-server library has existing warning debt, and unscoped Cog Clippy is
|
||||
blocked by existing `wifi-densepose-ruvector` warnings. The prescribed
|
||||
`cargo test --workspace --no-default-features` did not reach a terminal result
|
||||
in either a 904-second cold or 604-second warm serial run on this Windows
|
||||
host. None of these broader gates is represented as green.
|
||||
- The standalone fuzz lock SHA-256 is
|
||||
`d386c4edb130bb6b2d1a4ef77334c78e25e0695e90a9d97c01284876acb8c2c6`.
|
||||
|
||||
## Required commands
|
||||
|
||||
```text
|
||||
cargo bench -p wifi-densepose-physics
|
||||
node scripts/pose-physics/verify-feature-boundary.mjs
|
||||
bash scripts/verify-pose-physics-splits.sh <manifest.json>
|
||||
bash scripts/replay-pose-physics-golden.sh <golden-results.jsonl>
|
||||
```
|
||||
38
docs/schemas/pose-observation-v2.schema.json
Normal file
38
docs/schemas/pose-observation-v2.schema.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://ruview.net/schemas/pose-observation-v2.schema.json",
|
||||
"title": "PoseObservationV2",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "timestamp_ns", "sensor_epoch", "sequence", "track_id", "frame", "calibration_id", "floor_plane", "model", "source", "trust_state", "dimensionality", "uncertainty_calibrated", "joints", "observer_confidence", "canonical_hash"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 2 },
|
||||
"timestamp_ns": { "type": "integer", "minimum": 0 },
|
||||
"sensor_epoch": { "type": "integer", "minimum": 0 },
|
||||
"sequence": { "type": "integer", "minimum": 0 },
|
||||
"track_id": { "$ref": "#/$defs/string_id" },
|
||||
"frame": { "$ref": "#/$defs/frame" },
|
||||
"calibration_id": { "$ref": "#/$defs/string_id" },
|
||||
"floor_plane": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/floor" }] },
|
||||
"model": { "$ref": "#/$defs/model" },
|
||||
"source": { "$ref": "#/$defs/source" },
|
||||
"trust_state": { "enum": ["KNOWN", "DEGRADED", "UNKNOWN"] },
|
||||
"dimensionality": { "enum": ["image2d", "metric3d"] },
|
||||
"uncertainty_calibrated": { "type": "boolean" },
|
||||
"joints": { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/joint" } },
|
||||
"observer_confidence": { "$ref": "#/$defs/probability" },
|
||||
"canonical_hash": { "$ref": "#/$defs/hash" }
|
||||
},
|
||||
"$defs": {
|
||||
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
|
||||
"string_id": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
|
||||
"frame": { "type": "object", "additionalProperties": false, "required": ["name", "version", "metric", "right_handed", "z_up"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "version": { "type": "integer", "minimum": 1 }, "metric": { "type": "boolean" }, "right_handed": { "type": "boolean" }, "z_up": { "type": "boolean" } } },
|
||||
"floor": { "type": "object", "additionalProperties": false, "required": ["normal", "offset_m"], "properties": { "normal": { "$ref": "#/$defs/vec3" }, "offset_m": { "type": "number" } } },
|
||||
"model": { "type": "object", "additionalProperties": false, "required": ["id", "artifact_hash"], "properties": { "id": { "type": "string", "minLength": 1, "maxLength": 128 }, "artifact_hash": { "$ref": "#/$defs/hash" } } },
|
||||
"source": { "type": "object", "additionalProperties": false, "required": ["sensor_id", "authenticated", "replay_protected"], "properties": { "sensor_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "authenticated": { "type": "boolean" }, "replay_protected": { "type": "boolean" } } },
|
||||
"covariance": { "type": "object", "additionalProperties": false, "required": ["xx", "xy", "xz", "yy", "yz", "zz"], "properties": { "xx": { "type": "number", "minimum": 0 }, "xy": { "type": "number" }, "xz": { "type": "number" }, "yy": { "type": "number", "minimum": 0 }, "yz": { "type": "number" }, "zz": { "type": "number", "minimum": 0 } } },
|
||||
"joint": { "type": "object", "additionalProperties": false, "required": ["kind", "position_m", "covariance_m2", "confidence", "visibility"], "properties": { "kind": { "enum": ["nose", "left_eye", "right_eye", "left_ear", "right_ear", "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", "left_wrist", "right_wrist", "left_hip", "right_hip", "left_knee", "right_knee", "left_ankle", "right_ankle"] }, "position_m": { "$ref": "#/$defs/vec3" }, "covariance_m2": { "$ref": "#/$defs/covariance" }, "confidence": { "$ref": "#/$defs/probability" }, "visibility": { "enum": ["visible", "occluded", "unknown"] } } }
|
||||
}
|
||||
}
|
||||
36
docs/schemas/pose-refinement-v1.schema.json
Normal file
36
docs/schemas/pose-refinement-v1.schema.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://ruview.net/schemas/pose-refinement-v1.schema.json",
|
||||
"title": "PoseRefinementV1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "raw_observation_hash", "mode", "disposition", "selected", "refined_joints_m", "physics_confidence", "effective_confidence", "intervention", "residuals", "refined_residuals", "contact_hypotheses", "dynamics", "provenance", "reason", "canonical_hash"],
|
||||
"properties": {
|
||||
"schema_version": { "const": 1 },
|
||||
"raw_observation_hash": { "$ref": "#/$defs/hash" },
|
||||
"mode": { "enum": ["off", "audit", "shadow_correct", "opt_in_correct", "default_correct"] },
|
||||
"disposition": { "enum": ["bypassed", "audited2d", "audited", "shadowed", "corrected", "abstained", "rejected"] },
|
||||
"selected": { "type": "boolean" },
|
||||
"refined_joints_m": { "oneOf": [{ "type": "null" }, { "type": "array", "minItems": 17, "maxItems": 17, "items": { "$ref": "#/$defs/vec3" } }] },
|
||||
"physics_confidence": { "$ref": "#/$defs/probability" },
|
||||
"effective_confidence": { "$ref": "#/$defs/probability" },
|
||||
"intervention": { "$ref": "#/$defs/intervention" },
|
||||
"residuals": { "$ref": "#/$defs/residuals" },
|
||||
"refined_residuals": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/residuals" }] },
|
||||
"contact_hypotheses": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/contact" } },
|
||||
"dynamics": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/dynamics" }] },
|
||||
"provenance": { "$ref": "#/$defs/provenance" },
|
||||
"reason": { "enum": [null, "mode_off", "unsupported_schema", "hash_mismatch", "invalid_number", "invalid_covariance", "stale_input", "coordinate_frame_mismatch", "calibration_unavailable", "uncertainty_uncalibrated", "ood_unknown", "source_unauthenticated", "replay_protection_unavailable", "correction_not_authorized", "replay_rejected", "non_monotonic_input", "too_few_known_joints", "correction_too_large", "deadline_exceeded", "track_capacity", "internal_error"] },
|
||||
"canonical_hash": { "$ref": "#/$defs/hash" }
|
||||
},
|
||||
"$defs": {
|
||||
"probability": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"hash": { "type": "array", "minItems": 32, "maxItems": 32, "items": { "type": "integer", "minimum": 0, "maximum": 255 } },
|
||||
"vec3": { "type": "array", "minItems": 3, "maxItems": 3, "items": { "type": "number" } },
|
||||
"intervention": { "type": "object", "additionalProperties": false, "required": ["max_joint_correction_m", "root_correction_m", "corrected_joint_count", "solver_iterations", "elapsed_us"], "properties": { "max_joint_correction_m": { "type": "number", "minimum": 0 }, "root_correction_m": { "type": "number", "minimum": 0 }, "corrected_joint_count": { "type": "integer", "minimum": 0, "maximum": 17 }, "solver_iterations": { "type": "integer", "minimum": 0, "maximum": 8 }, "elapsed_us": { "type": "integer", "minimum": 0 } } },
|
||||
"residuals": { "type": "object", "additionalProperties": false, "required": ["bone_m", "joint_limit_rad", "velocity_mps", "acceleration_mps2", "temporal_jerk", "floor_penetration_m", "contact_m", "collision_m", "normalized_total"], "properties": { "bone_m": { "type": "number", "minimum": 0 }, "joint_limit_rad": { "type": "number", "minimum": 0 }, "velocity_mps": { "type": "number", "minimum": 0 }, "acceleration_mps2": { "type": "number", "minimum": 0 }, "temporal_jerk": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "contact_m": { "type": "number", "minimum": 0 }, "collision_m": { "type": "number", "minimum": 0 }, "normalized_total": { "type": "number", "minimum": 0 } } },
|
||||
"contact": { "type": "object", "additionalProperties": false, "required": ["state", "probability"], "properties": { "state": { "enum": ["hypothesis", "measured", "unknown"] }, "probability": { "$ref": "#/$defs/probability" } } },
|
||||
"dynamics": { "type": "object", "additionalProperties": false, "required": ["stable", "segment_count", "joint_count", "contact_count", "substeps", "tracking_error_m", "joint_anchor_error_m", "floor_penetration_m", "control_effort"], "properties": { "stable": { "type": "boolean" }, "segment_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "joint_count": { "type": "integer", "minimum": 0, "maximum": 255 }, "contact_count": { "type": "integer", "minimum": 0, "maximum": 65535 }, "substeps": { "type": "integer", "minimum": 1, "maximum": 8 }, "tracking_error_m": { "type": "number", "minimum": 0 }, "joint_anchor_error_m": { "type": "number", "minimum": 0 }, "floor_penetration_m": { "type": "number", "minimum": 0 }, "control_effort": { "type": "number", "minimum": 0 } } },
|
||||
"provenance": { "type": "object", "additionalProperties": false, "required": ["engine", "engine_version", "config_hash", "rf_model_hash", "calibration_id", "learned_artifact_hash"], "properties": { "engine": { "type": "string", "minLength": 1, "maxLength": 128 }, "engine_version": { "type": "string", "minLength": 1, "maxLength": 64 }, "config_hash": { "$ref": "#/$defs/hash" }, "rf_model_hash": { "$ref": "#/$defs/hash" }, "calibration_id": { "type": "string", "minLength": 1, "maxLength": 128 }, "learned_artifact_hash": { "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/hash" }] } } }
|
||||
}
|
||||
}
|
||||
1
scripts/pose-physics/testdata/golden-results.jsonl
vendored
Normal file
1
scripts/pose-physics/testdata/golden-results.jsonl
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"ankle_penetration_m":0.04,"evidence":"SYNTHETIC/L0","expected_raw_hash":"98caf45c249d584d52ede84e2d76bd8c4bf225d426a4ef1651a5ddb178258b88","expected_result_hash":"8ef2ded432c938055a5ebc86eadff811216884093853f5c740c7aff7708b9bef","sequence":1}
|
||||
21
scripts/pose-physics/testdata/leaky-split-manifest.json
vendored
Normal file
21
scripts/pose-physics/testdata/leaky-split-manifest.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"records": [
|
||||
{
|
||||
"split": "train",
|
||||
"sequence": "sequence-1",
|
||||
"take": "take-1",
|
||||
"subject": "subject-1",
|
||||
"room": "room-1",
|
||||
"calibration_session": "calibration-1"
|
||||
},
|
||||
{
|
||||
"split": "test",
|
||||
"sequence": "sequence-2",
|
||||
"take": "take-2",
|
||||
"subject": "subject-1",
|
||||
"room": "room-2",
|
||||
"calibration_session": "calibration-2"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
scripts/pose-physics/testdata/strict-split-manifest.json
vendored
Normal file
8
scripts/pose-physics/testdata/strict-split-manifest.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"evidence": "SYNTHETIC/L0",
|
||||
"records": [
|
||||
{ "split": "train", "sequence": "syn-train-seq", "take": "syn-train-take", "subject": "syn-train-subject", "room": "syn-train-room", "calibration_session": "syn-train-cal" },
|
||||
{ "split": "validation", "sequence": "syn-validation-seq", "take": "syn-validation-take", "subject": "syn-validation-subject", "room": "syn-validation-room", "calibration_session": "syn-validation-cal" },
|
||||
{ "split": "test", "sequence": "syn-test-seq", "take": "syn-test-take", "subject": "syn-test-subject", "room": "syn-test-room", "calibration_session": "syn-test-cal" }
|
||||
]
|
||||
}
|
||||
25
scripts/pose-physics/verify-feature-boundary.mjs
Normal file
25
scripts/pose-physics/verify-feature-boundary.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const result = spawnSync(
|
||||
"cargo",
|
||||
["tree", "-p", "wifi-densepose-physics", "-e", "normal", "--prefix", "none"],
|
||||
{ cwd: path.join(root, "v2"), encoding: "utf8" },
|
||||
);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
if (result.stdout) process.stderr.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const forbidden = /^(?:burn(?:-|\s|$)|rapier3d(?:\s|$)|tch(?:\s|$)|ort(?:\s|$))/m;
|
||||
if (forbidden.test(result.stdout)) {
|
||||
process.stderr.write(result.stdout);
|
||||
throw new Error("default physics dependency graph contains an optional heavy backend");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ verdict: "PASS", profile: "default-kinematic" }) + "\n");
|
||||
16
scripts/pose-physics/verify-golden.mjs
Normal file
16
scripts/pose-physics/verify-golden.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const input = process.argv[2];
|
||||
if (!input) throw new Error("usage: replay-pose-physics-golden.sh <golden-results.jsonl>");
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const result = spawnSync(
|
||||
"cargo",
|
||||
["run", "--quiet", "-p", "wifi-densepose-physics", "--features", "deterministic", "--example", "verify_golden", "--", path.resolve(input)],
|
||||
{ cwd: path.join(root, "v2"), encoding: "utf8" },
|
||||
);
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
27
scripts/pose-physics/verify-harness-tests.mjs
Normal file
27
scripts/pose-physics/verify-harness-tests.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const run = (script, fixture) =>
|
||||
spawnSync(process.execPath, [path.join(directory, script), path.join(directory, "testdata", fixture)], {
|
||||
cwd: path.resolve(directory, "../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const strict = run("verify-splits.mjs", "strict-split-manifest.json");
|
||||
if (strict.status !== 0 || !strict.stdout.includes('"verdict":"PASS"')) {
|
||||
throw new Error(`strict split fixture failed: ${strict.stderr || strict.stdout}`);
|
||||
}
|
||||
|
||||
const leaky = run("verify-splits.mjs", "leaky-split-manifest.json");
|
||||
if (leaky.status === 0 || !leaky.stderr.includes("leakage:")) {
|
||||
throw new Error("leaky split fixture was not rejected with a typed leakage error");
|
||||
}
|
||||
|
||||
const golden = run("verify-golden.mjs", "golden-results.jsonl");
|
||||
if (golden.status !== 0 || !golden.stdout.includes('"verdict":"PASS"')) {
|
||||
throw new Error(`golden replay fixture failed: ${golden.stderr || golden.stdout}`);
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ verdict: "PASS", checks: 3 }) + "\n");
|
||||
23
scripts/pose-physics/verify-splits.mjs
Normal file
23
scripts/pose-physics/verify-splits.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const path = process.argv[2];
|
||||
if (!path) throw new Error("usage: verify-pose-physics-splits.sh <manifest.json>");
|
||||
const bytes = fs.readFileSync(path);
|
||||
const manifest = JSON.parse(bytes);
|
||||
const dimensions = ["sequence", "take", "subject", "room", "calibration_session"];
|
||||
if (!Array.isArray(manifest.records) || manifest.records.length === 0) throw new Error("manifest.records must be non-empty");
|
||||
const ownership = new Map();
|
||||
for (const record of manifest.records) {
|
||||
if (!record.split || !["train", "validation", "test"].includes(record.split)) throw new Error("invalid split");
|
||||
for (const dimension of dimensions) {
|
||||
const value = record[dimension];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`missing ${dimension}`);
|
||||
const key = `${dimension}:${value}`;
|
||||
const prior = ownership.get(key);
|
||||
if (prior && prior !== record.split) throw new Error(`leakage: ${key} crosses ${prior}/${record.split}`);
|
||||
ownership.set(key, record.split);
|
||||
}
|
||||
}
|
||||
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
console.log(JSON.stringify({ verdict: "PASS", records: manifest.records.length, sha256: digest }));
|
||||
3
scripts/replay-pose-physics-golden.sh
Normal file
3
scripts/replay-pose-physics-golden.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
node "$(dirname "$0")/pose-physics/verify-golden.mjs" "$@"
|
||||
3
scripts/verify-pose-physics-splits.sh
Normal file
3
scripts/verify-pose-physics-splits.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
node "$(dirname "$0")/pose-physics/verify-splits.mjs" "$@"
|
||||
2472
v2/Cargo.lock
generated
2472
v2/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/wifi-densepose-core",
|
||||
"crates/wifi-densepose-physics", # ADR-323 bounded pose assessment/correction
|
||||
"crates/wifi-densepose-signal",
|
||||
"crates/wifi-densepose-nn",
|
||||
# wifi-densepose-api / -db / -config: removed in #578.
|
||||
@@ -155,6 +156,16 @@ serde_yaml = "0.9"
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
nalgebra = "0.34"
|
||||
smallvec = "1.15"
|
||||
bitflags = "2.9"
|
||||
rapier3d = { version = "0.33", default-features = false, features = ["std", "dim3", "f32"] }
|
||||
burn-core = { version = "0.21", default-features = false, features = ["std"] }
|
||||
burn-nn = { version = "0.21", default-features = false }
|
||||
burn-ndarray = { version = "0.21", default-features = false, features = ["std"] }
|
||||
burn-train = { version = "0.21", default-features = false }
|
||||
burn-wgpu = { version = "0.21", default-features = false, features = ["std"] }
|
||||
burn-cuda = { version = "0.21", default-features = false, features = ["std"] }
|
||||
|
||||
# Signal processing
|
||||
ndarray = { version = "0.17", features = ["serde"] }
|
||||
@@ -251,6 +262,7 @@ ruvector-gnn = { version = "2.2.0", default-features = false }
|
||||
|
||||
# Internal crates
|
||||
wifi-densepose-core = { version = "0.3.0", path = "crates/wifi-densepose-core" }
|
||||
wifi-densepose-physics = { version = "0.1.0", path = "crates/wifi-densepose-physics" }
|
||||
wifi-densepose-signal = { version = "0.3.0", path = "crates/wifi-densepose-signal" }
|
||||
wifi-densepose-nn = { version = "0.3.0", path = "crates/wifi-densepose-nn" }
|
||||
wifi-densepose-api = { version = "0.3.0", path = "crates/wifi-densepose-api" }
|
||||
|
||||
@@ -36,6 +36,7 @@ safetensors = "0.4"
|
||||
# wifi-densepose-train re-exports the model types we need; depend by path
|
||||
# inside the workspace.
|
||||
wifi-densepose-train = { version = "0.3.1", path = "../wifi-densepose-train", default-features = false }
|
||||
wifi-densepose-core = { workspace = true, features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -20,6 +20,11 @@ use candle_core::{DType, Device, Tensor};
|
||||
use candle_nn::{Conv1d, Conv1dConfig, Linear, Module, VarBuilder};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, JointObservation, JointVisibility, ModelRef, PoseDimensionality,
|
||||
PoseObservationV2, PoseTrustState, Probability, SourceProvenance, SpatialFrameRef,
|
||||
SymmetricCovariance3, TrackId,
|
||||
};
|
||||
|
||||
/// 56 subcarriers × 20 frames per CSI window — matches the format
|
||||
/// produced by `scripts/align-ground-truth.js` after #641.
|
||||
@@ -50,10 +55,87 @@ pub struct PoseOutput {
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// Process-owned provenance required to wrap the current two-dimensional
|
||||
/// observer output without claiming metric depth or calibrated uncertainty.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageObservationContext {
|
||||
pub timestamp_ns: u64,
|
||||
pub sensor_epoch: u64,
|
||||
pub sequence: u64,
|
||||
pub model_id: String,
|
||||
pub model_artifact_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl PoseOutput {
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.keypoints.iter().all(|v| v.is_finite()) && self.confidence.is_finite()
|
||||
}
|
||||
|
||||
/// Add the canonical ADR-323 contract while honestly retaining the
|
||||
/// current observer's image-space and uncertainty limitations.
|
||||
#[must_use]
|
||||
pub fn to_image_observation(
|
||||
&self,
|
||||
context: ImageObservationContext,
|
||||
) -> Option<PoseObservationV2> {
|
||||
if self.keypoints.len() != OUTPUT_KEYPOINTS * 2 || !self.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let confidence = Probability::new(self.confidence.clamp(0.0, 1.0)).ok()?;
|
||||
let joints = core::array::from_fn(|index| JointObservation {
|
||||
kind: Coco17Joint::ALL[index],
|
||||
position_m: [self.keypoints[index * 2], self.keypoints[index * 2 + 1], 0.0],
|
||||
// Zero only means no covariance estimate is supplied. The explicit
|
||||
// `uncertainty_calibrated=false` gate prevents correction.
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.0,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.0,
|
||||
yz: 0.0,
|
||||
zz: 0.0,
|
||||
},
|
||||
confidence,
|
||||
visibility: if confidence == Probability::ZERO {
|
||||
JointVisibility::Unknown
|
||||
} else {
|
||||
JointVisibility::Visible
|
||||
},
|
||||
});
|
||||
let mut observation = PoseObservationV2 {
|
||||
schema_version: wifi_densepose_core::POSE_OBSERVATION_SCHEMA_VERSION,
|
||||
timestamp_ns: context.timestamp_ns,
|
||||
sensor_epoch: context.sensor_epoch,
|
||||
sequence: context.sequence,
|
||||
track_id: TrackId("local:1".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "normalized-image".into(),
|
||||
version: 1,
|
||||
metric: false,
|
||||
right_handed: false,
|
||||
z_up: false,
|
||||
},
|
||||
calibration_id: CalibrationId("image:uncalibrated".into()),
|
||||
floor_plane: None,
|
||||
model: ModelRef {
|
||||
id: context.model_id,
|
||||
artifact_hash: context.model_artifact_hash,
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "sensing-server:loopback".into(),
|
||||
authenticated: false,
|
||||
replay_protected: false,
|
||||
},
|
||||
trust_state: PoseTrustState::Degraded,
|
||||
dimensionality: PoseDimensionality::Image2d,
|
||||
uncertainty_calibrated: false,
|
||||
joints,
|
||||
observer_confidence: confidence,
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
observation.seal();
|
||||
Some(observation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-room LoRA calibration adapter (ADR-150 §3.5–3.6). Low-rank deltas on the pose
|
||||
@@ -185,6 +267,7 @@ impl PoseNet {
|
||||
pub struct InferenceEngine {
|
||||
inner: Option<Arc<LoadedModel>>,
|
||||
device: Device,
|
||||
artifact_hash: [u8; 32],
|
||||
}
|
||||
|
||||
struct LoadedModel {
|
||||
@@ -227,6 +310,7 @@ impl InferenceEngine {
|
||||
adapter_path: Option<&Path>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let device = pick_device();
|
||||
let artifact_hash = combined_artifact_hash(weights_path, adapter_path)?;
|
||||
let inner = match weights_path {
|
||||
Some(p) if p.exists() => {
|
||||
// SAFETY: `from_mmaped_safetensors` mmaps the file for the
|
||||
@@ -246,7 +330,11 @@ impl InferenceEngine {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(Self { inner, device })
|
||||
Ok(Self {
|
||||
inner,
|
||||
device,
|
||||
artifact_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a per-room calibration adapter is currently attached.
|
||||
@@ -266,6 +354,13 @@ impl InferenceEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 identity of the base weights and optional adapter, or zero for
|
||||
/// the explicit no-model stub.
|
||||
#[must_use]
|
||||
pub const fn artifact_hash(&self) -> [u8; 32] {
|
||||
self.artifact_hash
|
||||
}
|
||||
|
||||
pub fn infer(&self, window: &CsiWindow) -> Result<PoseOutput, Box<dyn std::error::Error>> {
|
||||
if window.data.len() != INPUT_SUBCARRIERS * INPUT_TIMESTEPS {
|
||||
return Err(format!(
|
||||
@@ -336,6 +431,24 @@ fn pick_device() -> Device {
|
||||
Device::Cpu
|
||||
}
|
||||
|
||||
fn combined_artifact_hash(
|
||||
weights_path: Option<&Path>,
|
||||
adapter_path: Option<&Path>,
|
||||
) -> Result<[u8; 32], Box<dyn std::error::Error>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let Some(weights_path) = weights_path.filter(|path| path.exists()) else {
|
||||
return Ok([0; 32]);
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"ruview.pose-observer-artifacts-v1\0");
|
||||
hasher.update(std::fs::read(weights_path)?);
|
||||
if let Some(adapter_path) = adapter_path.filter(|path| path.exists()) {
|
||||
hasher.update(b"adapter\0");
|
||||
hasher.update(std::fs::read(adapter_path)?);
|
||||
}
|
||||
Ok(hasher.finalize().into())
|
||||
}
|
||||
|
||||
fn default_weights_path() -> Option<std::path::PathBuf> {
|
||||
// Search in the order an installed Cog would see it.
|
||||
let candidates = [
|
||||
|
||||
@@ -125,7 +125,10 @@ fn cmd_run(
|
||||
);
|
||||
}
|
||||
|
||||
let engine = InferenceEngine::with_adapter(adapter.as_deref())?;
|
||||
let engine = InferenceEngine::with_weights_and_adapter(
|
||||
Some(cfg.model_path.as_path()),
|
||||
adapter.as_deref(),
|
||||
)?;
|
||||
if engine.is_calibrated() {
|
||||
tracing::info!("per-room calibration adapter loaded");
|
||||
}
|
||||
|
||||
@@ -42,7 +42,12 @@ impl<'a> Event<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pose_frame(tick: u64, n_persons: usize, persons: Value) -> Self {
|
||||
pub fn pose_frame(
|
||||
tick: u64,
|
||||
n_persons: usize,
|
||||
persons: Value,
|
||||
observation: &wifi_densepose_core::PoseObservationV2,
|
||||
) -> Self {
|
||||
Self {
|
||||
ts: now_secs(),
|
||||
level: "info",
|
||||
@@ -51,6 +56,7 @@ impl<'a> Event<'a> {
|
||||
"tick": tick,
|
||||
"n_persons": n_persons,
|
||||
"persons": persons,
|
||||
"observation": observation,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! runs a CSI window through the engine, emits `pose.frame` events.
|
||||
|
||||
use crate::config::CogConfig;
|
||||
use crate::inference::{CsiWindow, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS};
|
||||
use crate::inference::{
|
||||
CsiWindow, ImageObservationContext, InferenceEngine, INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
|
||||
};
|
||||
use crate::publisher::{emit_event, Event};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
@@ -13,6 +15,14 @@ pub async fn run_loop(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut buffer: Vec<f32> = Vec::with_capacity(INPUT_SUBCARRIERS * INPUT_TIMESTEPS);
|
||||
let mut tick: u64 = 0;
|
||||
let process_started = std::time::Instant::now();
|
||||
let sensor_epoch = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_nanos()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX);
|
||||
let model_id = format!("pose-estimation:{}", engine.backend());
|
||||
let model_artifact_hash = engine.artifact_hash();
|
||||
|
||||
loop {
|
||||
// Poll one frame from the sensing-server. On error, sleep and retry —
|
||||
@@ -34,7 +44,24 @@ pub async fn run_loop(
|
||||
"keypoints": chunk_pairs(&out.keypoints),
|
||||
"confidence": out.confidence,
|
||||
}]);
|
||||
emit_event(&Event::pose_frame(tick, 1, persons));
|
||||
let timestamp_ns = sensor_epoch.saturating_add(
|
||||
process_started
|
||||
.elapsed()
|
||||
.as_nanos()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX),
|
||||
);
|
||||
if let Some(observation) = out.to_image_observation(
|
||||
ImageObservationContext {
|
||||
timestamp_ns,
|
||||
sensor_epoch,
|
||||
sequence: tick,
|
||||
model_id: model_id.clone(),
|
||||
model_artifact_hash,
|
||||
},
|
||||
) {
|
||||
emit_event(&Event::pose_frame(tick, 1, persons, &observation));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
use cog_pose_estimation::{
|
||||
inference::{
|
||||
InferenceEngine, SyntheticInput, INPUT_SUBCARRIERS, INPUT_TIMESTEPS, OUTPUT_KEYPOINTS,
|
||||
ImageObservationContext, InferenceEngine, PoseOutput, SyntheticInput, INPUT_SUBCARRIERS,
|
||||
INPUT_TIMESTEPS, OUTPUT_KEYPOINTS,
|
||||
},
|
||||
manifest::ManifestSpec,
|
||||
};
|
||||
@@ -108,6 +109,8 @@ fn per_room_adapter_changes_inference_output() {
|
||||
|
||||
assert!(!base.is_calibrated(), "base must report uncalibrated");
|
||||
assert!(cal.is_calibrated(), "adapter engine must report calibrated");
|
||||
assert_ne!(base.artifact_hash(), [0; 32]);
|
||||
assert_ne!(base.artifact_hash(), cal.artifact_hash());
|
||||
|
||||
// Non-zero input — a zero window would zero the LoRA delta (x·A·B = 0).
|
||||
let win = cog_pose_estimation::inference::CsiWindow {
|
||||
@@ -173,6 +176,49 @@ fn manifest_roundtrips() {
|
||||
assert_eq!(back.version, "0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_observer_is_canonical_image_2d_and_never_claims_calibration() {
|
||||
let output = PoseOutput {
|
||||
keypoints: vec![0.5; OUTPUT_KEYPOINTS * 2],
|
||||
confidence: 0.185,
|
||||
};
|
||||
let observation = output
|
||||
.to_image_observation(ImageObservationContext {
|
||||
timestamp_ns: 10,
|
||||
sensor_epoch: 9,
|
||||
sequence: 1,
|
||||
model_id: "pose-estimation:test".into(),
|
||||
model_artifact_hash: [4; 32],
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
observation.dimensionality,
|
||||
wifi_densepose_core::PoseDimensionality::Image2d
|
||||
);
|
||||
assert!(!observation.frame.metric);
|
||||
assert!(!observation.uncertainty_calibrated);
|
||||
assert!(!observation.source.authenticated);
|
||||
assert!(!observation.source.replay_protected);
|
||||
assert_eq!(observation.canonical_hash, observation.compute_canonical_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_image_pose_cannot_enter_canonical_contract() {
|
||||
let output = PoseOutput {
|
||||
keypoints: vec![0.5; 10],
|
||||
confidence: 0.185,
|
||||
};
|
||||
assert!(output
|
||||
.to_image_observation(ImageObservationContext {
|
||||
timestamp_ns: 10,
|
||||
sensor_epoch: 9,
|
||||
sequence: 1,
|
||||
model_id: "pose-estimation:test".into(),
|
||||
model_artifact_hash: [4; 32],
|
||||
})
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// ADR-159 §A1 — the default-config min_confidence threshold must not silently
|
||||
/// suppress every `pose.frame`. With the old `default_min_confidence()=0.3` and
|
||||
/// the model's per-frame confidence pinned at 0.185, the runtime gate
|
||||
|
||||
@@ -47,12 +47,23 @@
|
||||
extern crate alloc;
|
||||
|
||||
pub mod error;
|
||||
pub mod pose_observation_v2;
|
||||
pub mod pose_refinement_v1;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
// Re-export commonly used types at the crate root
|
||||
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
|
||||
pub use pose_observation_v2::{
|
||||
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
|
||||
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
|
||||
SpatialFrameRef, SymmetricCovariance3, TrackId, POSE_OBSERVATION_SCHEMA_VERSION,
|
||||
};
|
||||
pub use pose_refinement_v1::{
|
||||
AbstentionReason, ConstraintResiduals, ContactHypothesis, ContactState, DynamicsResiduals,
|
||||
InterventionSummary, PhysicsMode, PhysicsProvenance, PoseRefinementV1, RefinementDisposition,
|
||||
};
|
||||
pub use traits::{CanonicalFrame, DataStore, NeuralInference, SignalProcessor};
|
||||
pub use types::{
|
||||
AntennaConfig,
|
||||
|
||||
401
v2/crates/wifi-densepose-core/src/pose_observation_v2.rs
Normal file
401
v2/crates/wifi-densepose-core/src/pose_observation_v2.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
//! Canonical metric pose observation contract for physics assessment (ADR-323).
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use alloc::string::String;
|
||||
#[cfg(feature = "std")]
|
||||
use std::string::String;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema version accepted by [`PoseObservationV2`].
|
||||
pub const POSE_OBSERVATION_SCHEMA_VERSION: u16 = 2;
|
||||
|
||||
/// A validated probability in the inclusive range `[0, 1]`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(try_from = "f32", into = "f32"))]
|
||||
pub struct Probability(f32);
|
||||
|
||||
impl Probability {
|
||||
/// Construct a finite probability.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for non-finite values or values outside `[0, 1]`.
|
||||
pub fn new(value: f32) -> Result<Self, &'static str> {
|
||||
if value.is_finite() && (0.0..=1.0).contains(&value) {
|
||||
Ok(Self(value))
|
||||
} else {
|
||||
Err("probability must be finite and in [0, 1]")
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the underlying value.
|
||||
#[must_use]
|
||||
pub const fn get(self) -> f32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Zero probability.
|
||||
pub const ZERO: Self = Self(0.0);
|
||||
/// Unit probability.
|
||||
pub const ONE: Self = Self(1.0);
|
||||
}
|
||||
|
||||
impl TryFrom<f32> for Probability {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: f32) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Probability> for f32 {
|
||||
fn from(value: Probability) -> Self {
|
||||
value.get()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! string_id {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct $name(pub String);
|
||||
};
|
||||
}
|
||||
|
||||
string_id!(TrackId);
|
||||
string_id!(CalibrationId);
|
||||
|
||||
/// Versioned coordinate frame. Metric correction requires every boolean here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct SpatialFrameRef {
|
||||
/// Stable frame name.
|
||||
pub name: String,
|
||||
/// Frame definition version.
|
||||
pub version: u32,
|
||||
/// Coordinates are metres rather than image-normalized units.
|
||||
pub metric: bool,
|
||||
/// The axes form a right-handed system.
|
||||
pub right_handed: bool,
|
||||
/// Positive Z is vertical/up.
|
||||
pub z_up: bool,
|
||||
}
|
||||
|
||||
/// Signed model identity.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct ModelRef {
|
||||
/// Human-readable model/version identifier.
|
||||
pub id: String,
|
||||
/// Verified model artifact digest.
|
||||
pub artifact_hash: [u8; 32],
|
||||
}
|
||||
|
||||
/// Sensor provenance relevant to correction selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct SourceProvenance {
|
||||
/// Stable sensor identity.
|
||||
pub sensor_id: String,
|
||||
/// True only after message/source authentication succeeds.
|
||||
pub authenticated: bool,
|
||||
/// True only when sequence binding and replay-window checks are active.
|
||||
pub replay_protected: bool,
|
||||
}
|
||||
|
||||
/// RF distribution trust state from ADR-302.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
|
||||
pub enum PoseTrustState {
|
||||
/// Calibration/evidence says the sample is in distribution.
|
||||
Known,
|
||||
/// Input is usable for audit but confidence should be reduced.
|
||||
Degraded,
|
||||
/// Input is out-of-distribution or cannot be classified.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Whether the observation is image-plane or physical 3D.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum PoseDimensionality {
|
||||
/// Normalized or pixel image coordinates; audit-only.
|
||||
Image2d,
|
||||
/// Metric room-frame X/Y/Z coordinates.
|
||||
Metric3d,
|
||||
}
|
||||
|
||||
/// Normalized plane `normal dot point + offset_m = 0`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct FloorPlane {
|
||||
/// Upward-pointing unit normal.
|
||||
pub normal: [f32; 3],
|
||||
/// Plane offset in metres.
|
||||
pub offset_m: f32,
|
||||
}
|
||||
|
||||
impl FloorPlane {
|
||||
/// Whether all values are finite and the normal is approximately unit length.
|
||||
#[must_use]
|
||||
pub fn is_valid(self) -> bool {
|
||||
if !self.normal.iter().all(|v| v.is_finite()) || !self.offset_m.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let n2 = self.normal.iter().map(|v| v * v).sum::<f32>();
|
||||
(n2 - 1.0).abs() <= 1.0e-3 && self.normal[2] > 0.0
|
||||
}
|
||||
|
||||
/// Signed distance to the plane in metres.
|
||||
#[must_use]
|
||||
pub fn signed_distance(self, point: [f32; 3]) -> f32 {
|
||||
self.normal[0].mul_add(
|
||||
point[0],
|
||||
self.normal[1].mul_add(point[1], self.normal[2].mul_add(point[2], self.offset_m)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The six unique entries of a symmetric 3x3 covariance matrix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct SymmetricCovariance3 {
|
||||
pub xx: f32,
|
||||
pub xy: f32,
|
||||
pub xz: f32,
|
||||
pub yy: f32,
|
||||
pub yz: f32,
|
||||
pub zz: f32,
|
||||
}
|
||||
|
||||
impl SymmetricCovariance3 {
|
||||
/// Conservative positive-semidefinite validation using principal minors.
|
||||
#[must_use]
|
||||
#[allow(
|
||||
clippy::similar_names,
|
||||
clippy::suboptimal_flops,
|
||||
clippy::suspicious_operation_groupings
|
||||
)]
|
||||
pub fn is_positive_semidefinite(self) -> bool {
|
||||
const EPS: f32 = 1.0e-8;
|
||||
let values = [self.xx, self.xy, self.xz, self.yy, self.yz, self.zz];
|
||||
if !values.iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
if self.xx < -EPS || self.yy < -EPS || self.zz < -EPS {
|
||||
return false;
|
||||
}
|
||||
let xy_minor = self.xx * self.yy - self.xy * self.xy;
|
||||
let xz_minor = self.xx * self.zz - self.xz * self.xz;
|
||||
let yz_minor = self.yy * self.zz - self.yz * self.yz;
|
||||
let det = self.xx * (self.yy * self.zz - self.yz * self.yz)
|
||||
- self.xy * (self.xy * self.zz - self.yz * self.xz)
|
||||
+ self.xz * (self.xy * self.yz - self.yy * self.xz);
|
||||
xy_minor >= -EPS && xz_minor >= -EPS && yz_minor >= -EPS && det >= -EPS
|
||||
}
|
||||
|
||||
/// Trace, used as a bounded uncertainty weight.
|
||||
#[must_use]
|
||||
pub fn trace(self) -> f32 {
|
||||
self.xx + self.yy + self.zz
|
||||
}
|
||||
}
|
||||
|
||||
/// COCO-17 joint identity in canonical order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
#[repr(u8)]
|
||||
pub enum Coco17Joint {
|
||||
Nose,
|
||||
LeftEye,
|
||||
RightEye,
|
||||
LeftEar,
|
||||
RightEar,
|
||||
LeftShoulder,
|
||||
RightShoulder,
|
||||
LeftElbow,
|
||||
RightElbow,
|
||||
LeftWrist,
|
||||
RightWrist,
|
||||
LeftHip,
|
||||
RightHip,
|
||||
LeftKnee,
|
||||
RightKnee,
|
||||
LeftAnkle,
|
||||
RightAnkle,
|
||||
}
|
||||
|
||||
impl Coco17Joint {
|
||||
/// Canonical COCO-17 order.
|
||||
pub const ALL: [Self; 17] = [
|
||||
Self::Nose,
|
||||
Self::LeftEye,
|
||||
Self::RightEye,
|
||||
Self::LeftEar,
|
||||
Self::RightEar,
|
||||
Self::LeftShoulder,
|
||||
Self::RightShoulder,
|
||||
Self::LeftElbow,
|
||||
Self::RightElbow,
|
||||
Self::LeftWrist,
|
||||
Self::RightWrist,
|
||||
Self::LeftHip,
|
||||
Self::RightHip,
|
||||
Self::LeftKnee,
|
||||
Self::RightKnee,
|
||||
Self::LeftAnkle,
|
||||
Self::RightAnkle,
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether a joint is usable by the observer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum JointVisibility {
|
||||
Visible,
|
||||
Occluded,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One observed COCO joint and calibrated uncertainty.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct JointObservation {
|
||||
pub kind: Coco17Joint,
|
||||
pub position_m: [f32; 3],
|
||||
pub covariance_m2: SymmetricCovariance3,
|
||||
pub confidence: Probability,
|
||||
pub visibility: JointVisibility,
|
||||
}
|
||||
|
||||
/// Immutable pose observation presented to the physics boundary.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct PoseObservationV2 {
|
||||
pub schema_version: u16,
|
||||
pub timestamp_ns: u64,
|
||||
pub sensor_epoch: u64,
|
||||
pub sequence: u64,
|
||||
pub track_id: TrackId,
|
||||
pub frame: SpatialFrameRef,
|
||||
pub calibration_id: CalibrationId,
|
||||
pub floor_plane: Option<FloorPlane>,
|
||||
pub model: ModelRef,
|
||||
pub source: SourceProvenance,
|
||||
pub trust_state: PoseTrustState,
|
||||
pub dimensionality: PoseDimensionality,
|
||||
pub uncertainty_calibrated: bool,
|
||||
pub joints: [JointObservation; 17],
|
||||
pub observer_confidence: Probability,
|
||||
pub canonical_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl PoseObservationV2 {
|
||||
/// Compute a deterministic content hash excluding `canonical_hash` itself.
|
||||
#[must_use]
|
||||
pub fn compute_canonical_hash(&self) -> [u8; 32] {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(b"ruview.pose-observation-v2\0");
|
||||
h.update(&self.schema_version.to_le_bytes());
|
||||
h.update(&self.timestamp_ns.to_le_bytes());
|
||||
h.update(&self.sensor_epoch.to_le_bytes());
|
||||
h.update(&self.sequence.to_le_bytes());
|
||||
hash_str(&mut h, &self.track_id.0);
|
||||
hash_str(&mut h, &self.frame.name);
|
||||
h.update(&self.frame.version.to_le_bytes());
|
||||
h.update(&[
|
||||
self.frame.metric.into(),
|
||||
self.frame.right_handed.into(),
|
||||
self.frame.z_up.into(),
|
||||
]);
|
||||
hash_str(&mut h, &self.calibration_id.0);
|
||||
match self.floor_plane {
|
||||
Some(plane) => {
|
||||
h.update(&[1]);
|
||||
for value in plane.normal {
|
||||
h.update(&value.to_bits().to_le_bytes());
|
||||
}
|
||||
h.update(&plane.offset_m.to_bits().to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
h.update(&[0]);
|
||||
}
|
||||
}
|
||||
hash_str(&mut h, &self.model.id);
|
||||
h.update(&self.model.artifact_hash);
|
||||
hash_str(&mut h, &self.source.sensor_id);
|
||||
h.update(&[
|
||||
self.source.authenticated.into(),
|
||||
self.source.replay_protected.into(),
|
||||
]);
|
||||
h.update(&[
|
||||
self.trust_state as u8,
|
||||
self.dimensionality as u8,
|
||||
self.uncertainty_calibrated.into(),
|
||||
]);
|
||||
for joint in &self.joints {
|
||||
h.update(&[joint.kind as u8, joint.visibility as u8]);
|
||||
for value in joint.position_m {
|
||||
h.update(&value.to_bits().to_le_bytes());
|
||||
}
|
||||
for value in [
|
||||
joint.covariance_m2.xx,
|
||||
joint.covariance_m2.xy,
|
||||
joint.covariance_m2.xz,
|
||||
joint.covariance_m2.yy,
|
||||
joint.covariance_m2.yz,
|
||||
joint.covariance_m2.zz,
|
||||
] {
|
||||
h.update(&value.to_bits().to_le_bytes());
|
||||
}
|
||||
h.update(&joint.confidence.get().to_bits().to_le_bytes());
|
||||
}
|
||||
h.update(&self.observer_confidence.get().to_bits().to_le_bytes());
|
||||
*h.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// Set `canonical_hash` to the deterministic content hash.
|
||||
pub fn seal(&mut self) {
|
||||
self.canonical_hash = self.compute_canonical_hash();
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_str(hasher: &mut blake3::Hasher, value: &str) {
|
||||
let bytes = value.as_bytes();
|
||||
hasher.update(&(bytes.len() as u64).to_le_bytes());
|
||||
hasher.update(bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn probability_rejects_non_finite_and_out_of_range() {
|
||||
assert!(Probability::new(f32::NAN).is_err());
|
||||
assert!(Probability::new(-0.1).is_err());
|
||||
assert!(Probability::new(1.1).is_err());
|
||||
assert!((Probability::new(0.5).unwrap().get() - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covariance_rejects_negative_principal_minor() {
|
||||
let covariance = SymmetricCovariance3 {
|
||||
xx: 0.1,
|
||||
xy: 1.0,
|
||||
xz: 0.0,
|
||||
yy: 0.1,
|
||||
yz: 0.0,
|
||||
zz: 0.1,
|
||||
};
|
||||
assert!(!covariance.is_positive_semidefinite());
|
||||
}
|
||||
}
|
||||
317
v2/crates/wifi-densepose-core/src/pose_refinement_v1.rs
Normal file
317
v2/crates/wifi-densepose-core/src/pose_refinement_v1.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! Additive physics assessment and correction contract (ADR-323).
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use alloc::string::String;
|
||||
#[cfg(feature = "std")]
|
||||
use std::string::String;
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Probability;
|
||||
|
||||
/// Runtime rollout mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum PhysicsMode {
|
||||
Off,
|
||||
Audit,
|
||||
ShadowCorrect,
|
||||
OptInCorrect,
|
||||
DefaultCorrect,
|
||||
}
|
||||
|
||||
impl PhysicsMode {
|
||||
/// Whether a validated candidate may be selected for downstream use.
|
||||
#[must_use]
|
||||
pub const fn selects_correction(self) -> bool {
|
||||
matches!(self, Self::OptInCorrect | Self::DefaultCorrect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of processing exactly one accepted frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum RefinementDisposition {
|
||||
Bypassed,
|
||||
Audited2d,
|
||||
Audited,
|
||||
Shadowed,
|
||||
Corrected,
|
||||
Abstained,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Typed fail-to-raw reason.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum AbstentionReason {
|
||||
ModeOff,
|
||||
UnsupportedSchema,
|
||||
HashMismatch,
|
||||
InvalidNumber,
|
||||
InvalidCovariance,
|
||||
StaleInput,
|
||||
CoordinateFrameMismatch,
|
||||
CalibrationUnavailable,
|
||||
UncertaintyUncalibrated,
|
||||
OodUnknown,
|
||||
SourceUnauthenticated,
|
||||
ReplayProtectionUnavailable,
|
||||
CorrectionNotAuthorized,
|
||||
ReplayRejected,
|
||||
NonMonotonicInput,
|
||||
TooFewKnownJoints,
|
||||
CorrectionTooLarge,
|
||||
DeadlineExceeded,
|
||||
TrackCapacity,
|
||||
InternalError,
|
||||
}
|
||||
|
||||
/// Constraint residuals. Values are metric or normalized as named.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct ConstraintResiduals {
|
||||
pub bone_m: f32,
|
||||
pub joint_limit_rad: f32,
|
||||
pub velocity_mps: f32,
|
||||
pub acceleration_mps2: f32,
|
||||
pub temporal_jerk: f32,
|
||||
pub floor_penetration_m: f32,
|
||||
pub contact_m: f32,
|
||||
pub collision_m: f32,
|
||||
pub normalized_total: f32,
|
||||
}
|
||||
|
||||
/// Summary of how much the candidate differs from the observation.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct InterventionSummary {
|
||||
pub max_joint_correction_m: f32,
|
||||
pub root_correction_m: f32,
|
||||
pub corrected_joint_count: u8,
|
||||
pub solver_iterations: u8,
|
||||
pub elapsed_us: u64,
|
||||
}
|
||||
|
||||
/// Optional articulated-body audit scalars.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct DynamicsResiduals {
|
||||
pub stable: bool,
|
||||
pub segment_count: u8,
|
||||
pub joint_count: u8,
|
||||
pub contact_count: u16,
|
||||
pub substeps: u8,
|
||||
pub tracking_error_m: f32,
|
||||
pub joint_anchor_error_m: f32,
|
||||
pub floor_penetration_m: f32,
|
||||
pub control_effort: f32,
|
||||
}
|
||||
|
||||
/// Contact state is never promoted to measured without sensor provenance.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
|
||||
pub enum ContactState {
|
||||
Hypothesis,
|
||||
Measured,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One foot-contact assessment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct ContactHypothesis {
|
||||
pub state: ContactState,
|
||||
pub probability: Probability,
|
||||
}
|
||||
|
||||
impl Default for ContactHypothesis {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: ContactState::Unknown,
|
||||
probability: Probability::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version and artifact lineage for an assessment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct PhysicsProvenance {
|
||||
pub engine: String,
|
||||
pub engine_version: String,
|
||||
pub config_hash: [u8; 32],
|
||||
pub rf_model_hash: [u8; 32],
|
||||
pub calibration_id: String,
|
||||
pub learned_artifact_hash: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Physics result returned for every accepted input frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct PoseRefinementV1 {
|
||||
pub schema_version: u16,
|
||||
pub raw_observation_hash: [u8; 32],
|
||||
pub mode: PhysicsMode,
|
||||
pub disposition: RefinementDisposition,
|
||||
pub selected: bool,
|
||||
pub refined_joints_m: Option<[[f32; 3]; 17]>,
|
||||
pub physics_confidence: Probability,
|
||||
pub effective_confidence: Probability,
|
||||
pub intervention: InterventionSummary,
|
||||
/// Residuals of the immutable observer output.
|
||||
pub residuals: ConstraintResiduals,
|
||||
/// Residuals of the bounded candidate, when a candidate was computed.
|
||||
pub refined_residuals: Option<ConstraintResiduals>,
|
||||
/// Present only when the separately gated Rapier auditor ran.
|
||||
pub dynamics: Option<DynamicsResiduals>,
|
||||
pub contact_hypotheses: [ContactHypothesis; 2],
|
||||
pub provenance: PhysicsProvenance,
|
||||
pub reason: Option<AbstentionReason>,
|
||||
/// Canonical BLAKE3 hash of every preceding result field.
|
||||
pub canonical_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl PoseRefinementV1 {
|
||||
/// Compute a deterministic content hash excluding `canonical_hash` itself.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn compute_canonical_hash(&self) -> [u8; 32] {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(b"ruview.pose-refinement-v1\0");
|
||||
h.update(&self.schema_version.to_le_bytes());
|
||||
h.update(&self.raw_observation_hash);
|
||||
h.update(&[
|
||||
self.mode as u8,
|
||||
self.disposition as u8,
|
||||
self.selected.into(),
|
||||
]);
|
||||
match self.refined_joints_m {
|
||||
Some(joints) => {
|
||||
h.update(&[1]);
|
||||
for joint in joints {
|
||||
hash_f32s(&mut h, &joint);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
h.update(&[0]);
|
||||
}
|
||||
}
|
||||
hash_f32s(
|
||||
&mut h,
|
||||
&[
|
||||
self.physics_confidence.get(),
|
||||
self.effective_confidence.get(),
|
||||
],
|
||||
);
|
||||
hash_f32s(
|
||||
&mut h,
|
||||
&[
|
||||
self.intervention.max_joint_correction_m,
|
||||
self.intervention.root_correction_m,
|
||||
],
|
||||
);
|
||||
h.update(&[
|
||||
self.intervention.corrected_joint_count,
|
||||
self.intervention.solver_iterations,
|
||||
]);
|
||||
h.update(&self.intervention.elapsed_us.to_le_bytes());
|
||||
hash_residuals(&mut h, self.residuals);
|
||||
match self.refined_residuals {
|
||||
Some(residuals) => {
|
||||
h.update(&[1]);
|
||||
hash_residuals(&mut h, residuals);
|
||||
}
|
||||
None => {
|
||||
h.update(&[0]);
|
||||
}
|
||||
}
|
||||
match self.dynamics {
|
||||
Some(dynamics) => {
|
||||
h.update(&[1]);
|
||||
h.update(&[
|
||||
dynamics.stable.into(),
|
||||
dynamics.segment_count,
|
||||
dynamics.joint_count,
|
||||
dynamics.substeps,
|
||||
]);
|
||||
h.update(&dynamics.contact_count.to_le_bytes());
|
||||
hash_f32s(
|
||||
&mut h,
|
||||
&[
|
||||
dynamics.tracking_error_m,
|
||||
dynamics.joint_anchor_error_m,
|
||||
dynamics.floor_penetration_m,
|
||||
dynamics.control_effort,
|
||||
],
|
||||
);
|
||||
}
|
||||
None => {
|
||||
h.update(&[0]);
|
||||
}
|
||||
}
|
||||
for contact in self.contact_hypotheses {
|
||||
h.update(&[contact.state as u8]);
|
||||
hash_f32s(&mut h, &[contact.probability.get()]);
|
||||
}
|
||||
hash_str(&mut h, &self.provenance.engine);
|
||||
hash_str(&mut h, &self.provenance.engine_version);
|
||||
h.update(&self.provenance.config_hash);
|
||||
h.update(&self.provenance.rf_model_hash);
|
||||
hash_str(&mut h, &self.provenance.calibration_id);
|
||||
match self.provenance.learned_artifact_hash {
|
||||
Some(hash) => {
|
||||
h.update(&[1]);
|
||||
h.update(&hash);
|
||||
}
|
||||
None => {
|
||||
h.update(&[0]);
|
||||
}
|
||||
}
|
||||
match self.reason {
|
||||
Some(reason) => h.update(&[1, reason as u8]),
|
||||
None => h.update(&[0]),
|
||||
};
|
||||
*h.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// Seal the result after all fields have been populated.
|
||||
pub fn seal(&mut self) {
|
||||
self.canonical_hash = self.compute_canonical_hash();
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_residuals(hasher: &mut blake3::Hasher, residuals: ConstraintResiduals) {
|
||||
hash_f32s(
|
||||
hasher,
|
||||
&[
|
||||
residuals.bone_m,
|
||||
residuals.joint_limit_rad,
|
||||
residuals.velocity_mps,
|
||||
residuals.acceleration_mps2,
|
||||
residuals.temporal_jerk,
|
||||
residuals.floor_penetration_m,
|
||||
residuals.contact_m,
|
||||
residuals.collision_m,
|
||||
residuals.normalized_total,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn hash_f32s(hasher: &mut blake3::Hasher, values: &[f32]) {
|
||||
for value in values {
|
||||
hasher.update(&value.to_bits().to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_str(hasher: &mut blake3::Hasher, value: &str) {
|
||||
hasher.update(&(value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
}
|
||||
58
v2/crates/wifi-densepose-physics/Cargo.toml
Normal file
58
v2/crates/wifi-densepose-physics/Cargo.toml
Normal file
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "wifi-densepose-physics"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Bounded, provenance-preserving pose physics assessment for RuView"
|
||||
|
||||
[features]
|
||||
default = ["kinematic"]
|
||||
kinematic = []
|
||||
dynamics = ["dep:rapier3d"]
|
||||
learned = ["dep:burn-core", "dep:burn-nn"]
|
||||
learned-cpu = ["learned", "dep:burn-ndarray"]
|
||||
learned-train = ["learned", "dep:burn-train"]
|
||||
learned-wgpu = ["learned-train", "dep:burn-wgpu"]
|
||||
learned-cuda = ["learned-train", "dep:burn-cuda"]
|
||||
deterministic = ["rapier3d?/enhanced-determinism"]
|
||||
|
||||
[dependencies]
|
||||
wifi-densepose-core = { workspace = true, features = ["serde"] }
|
||||
nalgebra.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
smallvec.workspace = true
|
||||
bitflags.workspace = true
|
||||
tracing.workspace = true
|
||||
blake3 = "1.5"
|
||||
rapier3d = { workspace = true, optional = true }
|
||||
burn-core = { workspace = true, optional = true }
|
||||
burn-nn = { workspace = true, optional = true }
|
||||
burn-ndarray = { workspace = true, optional = true }
|
||||
burn-train = { workspace = true, optional = true }
|
||||
burn-wgpu = { workspace = true, optional = true }
|
||||
burn-cuda = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
proptest.workspace = true
|
||||
criterion.workspace = true
|
||||
|
||||
[[bench]]
|
||||
name = "frame_latency"
|
||||
harness = false
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
missing_errors_doc = "allow"
|
||||
module_name_repetitions = "allow"
|
||||
cast_precision_loss = "allow"
|
||||
cast_possible_truncation = "allow"
|
||||
cast_sign_loss = "allow"
|
||||
84
v2/crates/wifi-densepose-physics/benches/frame_latency.rs
Normal file
84
v2/crates/wifi-densepose-physics/benches/frame_latency.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
|
||||
PhysicsMode, PoseDimensionality, PoseObservationV2, PoseTrustState, Probability,
|
||||
SourceProvenance, SpatialFrameRef, SymmetricCovariance3, TrackId,
|
||||
};
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
fn observation(sequence: u64) -> PoseObservationV2 {
|
||||
let joints = Coco17Joint::ALL.map(|kind| JointObservation {
|
||||
kind,
|
||||
position_m: [f32::from(kind as u8) * 0.01, 0.0, 1.0],
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.001,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.001,
|
||||
yz: 0.0,
|
||||
zz: 0.001,
|
||||
},
|
||||
confidence: Probability::new(0.8).unwrap(),
|
||||
visibility: JointVisibility::Visible,
|
||||
});
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
|
||||
sensor_epoch: 1,
|
||||
sequence,
|
||||
track_id: TrackId("bench".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "room".into(),
|
||||
version: 1,
|
||||
metric: true,
|
||||
right_handed: true,
|
||||
z_up: true,
|
||||
},
|
||||
calibration_id: CalibrationId("cal".into()),
|
||||
floor_plane: Some(FloorPlane {
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
offset_m: 0.0,
|
||||
}),
|
||||
model: ModelRef {
|
||||
id: "bench".into(),
|
||||
artifact_hash: [1; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "bench".into(),
|
||||
authenticated: true,
|
||||
replay_protected: true,
|
||||
},
|
||||
trust_state: PoseTrustState::Known,
|
||||
dimensionality: PoseDimensionality::Metric3d,
|
||||
uncertainty_calibrated: true,
|
||||
joints,
|
||||
observer_confidence: Probability::new(0.8).unwrap(),
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
raw
|
||||
}
|
||||
|
||||
fn bench_frame(c: &mut Criterion) {
|
||||
c.bench_function("kinematic_one_track", |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap()
|
||||
},
|
||||
|mut engine| {
|
||||
let raw = observation(1);
|
||||
black_box(engine.process(&raw, raw.timestamp_ns));
|
||||
},
|
||||
criterion::BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_frame);
|
||||
criterion_main!(benches);
|
||||
130
v2/crates/wifi-densepose-physics/examples/latency_probe.rs
Normal file
130
v2/crates/wifi-densepose-physics/examples/latency_probe.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Reproducible release-mode latency probe. Results apply only to this host.
|
||||
|
||||
use std::{fmt::Write as _, hint::black_box, time::Instant};
|
||||
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
|
||||
PhysicsMode, PoseDimensionality, PoseObservationV2, PoseTrustState, Probability,
|
||||
SourceProvenance, SpatialFrameRef, SymmetricCovariance3, TrackId,
|
||||
};
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let samples = std::env::args()
|
||||
.nth(1)
|
||||
.map_or(Ok(20_000usize), |value| value.parse())?;
|
||||
if samples < 100 {
|
||||
return Err("sample count must be at least 100".into());
|
||||
}
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})?;
|
||||
let config_hash = encode_hex(engine.config_hash());
|
||||
for sequence in 1..=1_000 {
|
||||
let raw = observation(sequence);
|
||||
black_box(engine.process(&raw, raw.timestamp_ns));
|
||||
}
|
||||
let mut nanoseconds = Vec::with_capacity(samples);
|
||||
for offset in 0..samples {
|
||||
let sequence = 1_001 + offset as u64;
|
||||
let raw = observation(sequence);
|
||||
let started = Instant::now();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
nanoseconds.push(started.elapsed().as_nanos() as u64);
|
||||
assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
|
||||
black_box(result);
|
||||
}
|
||||
nanoseconds.sort_unstable();
|
||||
let percentile =
|
||||
|numerator: usize| nanoseconds[((samples - 1) * numerator) / 100] as f64 / 1_000_000.0;
|
||||
println!(
|
||||
"{{\"evidence\":\"MEASURED\",\"scope\":\"local-host-only\",\"engine\":\"kinematic-pbd\",\"mode\":\"shadow_correct\",\"config_hash\":\"{config_hash}\",\"samples\":{samples},\"p50_ms\":{:.6},\"p95_ms\":{:.6},\"p99_ms\":{:.6},\"max_ms\":{:.6}}}",
|
||||
percentile(50),
|
||||
percentile(95),
|
||||
percentile(99),
|
||||
nanoseconds[samples - 1] as f64 / 1_000_000.0,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: [u8; 32]) -> String {
|
||||
let mut encoded = String::with_capacity(64);
|
||||
for byte in bytes {
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn observation(sequence: u64) -> PoseObservationV2 {
|
||||
let positions = [
|
||||
[0.0, 0.0, 1.70],
|
||||
[-0.03, 0.0, 1.73],
|
||||
[0.03, 0.0, 1.73],
|
||||
[-0.08, 0.0, 1.71],
|
||||
[0.08, 0.0, 1.71],
|
||||
[-0.20, 0.0, 1.45],
|
||||
[0.20, 0.0, 1.45],
|
||||
[-0.35, 0.0, 1.15],
|
||||
[0.35, 0.0, 1.15],
|
||||
[-0.45, 0.0, 0.90],
|
||||
[0.45, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.90],
|
||||
[0.14, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.48],
|
||||
[0.14, 0.0, 0.48],
|
||||
[-0.14, 0.0, 0.04],
|
||||
[0.14, 0.0, 0.04],
|
||||
];
|
||||
let mut joints = core::array::from_fn(|index| JointObservation {
|
||||
kind: Coco17Joint::ALL[index],
|
||||
position_m: positions[index],
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.001,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.001,
|
||||
yz: 0.0,
|
||||
zz: 0.001,
|
||||
},
|
||||
confidence: Probability::new(0.8).expect("fixture confidence is valid"),
|
||||
visibility: JointVisibility::Visible,
|
||||
});
|
||||
joints[15].position_m[2] -= (sequence % 7) as f32 * 0.001;
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
|
||||
sensor_epoch: 1,
|
||||
sequence,
|
||||
track_id: TrackId("latency:1".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "room:latency".into(),
|
||||
version: 1,
|
||||
metric: true,
|
||||
right_handed: true,
|
||||
z_up: true,
|
||||
},
|
||||
calibration_id: CalibrationId("cal:latency".into()),
|
||||
floor_plane: Some(FloorPlane {
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
offset_m: 0.0,
|
||||
}),
|
||||
model: ModelRef {
|
||||
id: "pose:latency".into(),
|
||||
artifact_hash: [1; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "sensor:latency".into(),
|
||||
authenticated: false,
|
||||
replay_protected: false,
|
||||
},
|
||||
trust_state: PoseTrustState::Known,
|
||||
dimensionality: PoseDimensionality::Metric3d,
|
||||
uncertainty_calibrated: true,
|
||||
joints,
|
||||
observer_confidence: Probability::new(0.78).expect("fixture confidence is valid"),
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
raw
|
||||
}
|
||||
154
v2/crates/wifi-densepose-physics/examples/verify_golden.rs
Normal file
154
v2/crates/wifi-densepose-physics/examples/verify_golden.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Deterministic L0 replay verifier for ADR-323 golden hashes.
|
||||
|
||||
use std::{fmt::Write as _, path::Path};
|
||||
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
|
||||
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
|
||||
SpatialFrameRef, SymmetricCovariance3, TrackId,
|
||||
};
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut arguments = std::env::args().skip(1);
|
||||
let path = arguments
|
||||
.next()
|
||||
.ok_or("usage: verify_golden <golden.jsonl> [--emit|--contracts]")?;
|
||||
let mode = arguments.next();
|
||||
let emit = mode.as_deref() == Some("--emit");
|
||||
let contracts = mode.as_deref() == Some("--contracts");
|
||||
if mode.is_some() && !emit && !contracts {
|
||||
return Err("mode must be --emit or --contracts".into());
|
||||
}
|
||||
let contents = std::fs::read_to_string(Path::new(&path))?;
|
||||
let mut count = 0usize;
|
||||
for (line_index, line) in contents
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.enumerate()
|
||||
{
|
||||
let mut value: serde_json::Value = serde_json::from_str(line)?;
|
||||
let sequence = value["sequence"].as_u64().ok_or("sequence must be u64")?;
|
||||
let penetration = value["ankle_penetration_m"]
|
||||
.as_f64()
|
||||
.ok_or("ankle_penetration_m must be numeric")? as f32;
|
||||
if value["evidence"] != "SYNTHETIC/L0" {
|
||||
return Err(format!("line {}: evidence must be SYNTHETIC/L0", line_index + 1).into());
|
||||
}
|
||||
let mut raw = observation(sequence);
|
||||
raw.joints[15].position_m[2] -= penetration;
|
||||
raw.seal();
|
||||
let result = PhysicsEngine::new(PhysicsConfig::default())?.process(&raw, raw.timestamp_ns);
|
||||
let raw_hash = encode_hex(raw.canonical_hash);
|
||||
let result_hash = encode_hex(result.canonical_hash);
|
||||
if emit {
|
||||
value["expected_raw_hash"] = raw_hash.clone().into();
|
||||
value["expected_result_hash"] = result_hash.clone().into();
|
||||
println!("{}", serde_json::to_string(&value)?);
|
||||
} else {
|
||||
if value["expected_raw_hash"] != raw_hash
|
||||
|| value["expected_result_hash"] != result_hash
|
||||
{
|
||||
return Err(format!(
|
||||
"line {}: hash mismatch (raw={raw_hash}, result={result_hash})",
|
||||
line_index + 1
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if contracts {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({ "raw": raw, "result": result }))?
|
||||
);
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
return Err("golden replay is empty".into());
|
||||
}
|
||||
if !emit && !contracts {
|
||||
println!("{{\"verdict\":\"PASS\",\"records\":{count},\"engine\":\"kinematic-pbd\",\"evidence\":\"SYNTHETIC/L0\"}}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observation(sequence: u64) -> PoseObservationV2 {
|
||||
let positions = [
|
||||
[0.0, 0.0, 1.70],
|
||||
[-0.03, 0.0, 1.73],
|
||||
[0.03, 0.0, 1.73],
|
||||
[-0.08, 0.0, 1.71],
|
||||
[0.08, 0.0, 1.71],
|
||||
[-0.20, 0.0, 1.45],
|
||||
[0.20, 0.0, 1.45],
|
||||
[-0.35, 0.0, 1.15],
|
||||
[0.35, 0.0, 1.15],
|
||||
[-0.45, 0.0, 0.90],
|
||||
[0.45, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.90],
|
||||
[0.14, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.48],
|
||||
[0.14, 0.0, 0.48],
|
||||
[-0.14, 0.0, 0.04],
|
||||
[0.14, 0.0, 0.04],
|
||||
];
|
||||
let joints = core::array::from_fn(|index| JointObservation {
|
||||
kind: Coco17Joint::ALL[index],
|
||||
position_m: positions[index],
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.001,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.001,
|
||||
yz: 0.0,
|
||||
zz: 0.001,
|
||||
},
|
||||
confidence: Probability::new(0.8).expect("fixture confidence is valid"),
|
||||
visibility: JointVisibility::Visible,
|
||||
});
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
|
||||
sensor_epoch: 7,
|
||||
sequence,
|
||||
track_id: TrackId("golden:1".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "room:golden".into(),
|
||||
version: 1,
|
||||
metric: true,
|
||||
right_handed: true,
|
||||
z_up: true,
|
||||
},
|
||||
calibration_id: CalibrationId("cal:golden".into()),
|
||||
floor_plane: Some(FloorPlane {
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
offset_m: 0.0,
|
||||
}),
|
||||
model: ModelRef {
|
||||
id: "pose:golden".into(),
|
||||
artifact_hash: [3; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "sensor:golden".into(),
|
||||
authenticated: false,
|
||||
replay_protected: false,
|
||||
},
|
||||
trust_state: PoseTrustState::Known,
|
||||
dimensionality: PoseDimensionality::Metric3d,
|
||||
uncertainty_calibrated: true,
|
||||
joints,
|
||||
observer_confidence: Probability::new(0.78).expect("fixture confidence is valid"),
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
raw
|
||||
}
|
||||
|
||||
fn encode_hex(bytes: [u8; 32]) -> String {
|
||||
let mut encoded = String::with_capacity(64);
|
||||
for byte in bytes {
|
||||
write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
|
||||
}
|
||||
encoded
|
||||
}
|
||||
1
v2/crates/wifi-densepose-physics/fuzz/.gitignore
vendored
Normal file
1
v2/crates/wifi-densepose-physics/fuzz/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target/
|
||||
884
v2/crates/wifi-densepose-physics/fuzz/Cargo.lock
generated
Normal file
884
v2/crates/wifi-densepose-physics/fuzz/Cargo.lock
generated
Normal file
@@ -0,0 +1,884 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.17.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.20.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.29.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.30.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra"
|
||||
version = "0.34.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"glam 0.14.0",
|
||||
"glam 0.15.2",
|
||||
"glam 0.16.0",
|
||||
"glam 0.17.3",
|
||||
"glam 0.18.0",
|
||||
"glam 0.19.0",
|
||||
"glam 0.20.5",
|
||||
"glam 0.21.3",
|
||||
"glam 0.22.0",
|
||||
"glam 0.23.0",
|
||||
"glam 0.24.2",
|
||||
"glam 0.25.0",
|
||||
"glam 0.27.0",
|
||||
"glam 0.28.0",
|
||||
"glam 0.29.3",
|
||||
"glam 0.30.10",
|
||||
"glam 0.31.1",
|
||||
"glam 0.32.1",
|
||||
"matrixmultiply",
|
||||
"nalgebra-macros",
|
||||
"num-complex",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"simba",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nalgebra-macros"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simba"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-core"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chrono",
|
||||
"ndarray",
|
||||
"num-complex",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-physics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"blake3",
|
||||
"nalgebra",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"wifi-densepose-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-physics-fuzz"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"libfuzzer-sys",
|
||||
"serde_json",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-physics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
24
v2/crates/wifi-densepose-physics/fuzz/Cargo.toml
Normal file
24
v2/crates/wifi-densepose-physics/fuzz/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "wifi-densepose-physics-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
serde_json = "1"
|
||||
wifi-densepose-core = { path = "../../wifi-densepose-core", features = ["serde"] }
|
||||
wifi-densepose-physics = { path = ".." }
|
||||
|
||||
[workspace]
|
||||
members = ["."]
|
||||
|
||||
[[bin]]
|
||||
name = "pose_frame"
|
||||
path = "fuzz_targets/pose_frame.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,15 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
fuzz_target!(|bytes: &[u8]| {
|
||||
if bytes.len() > 64 * 1024 {
|
||||
return;
|
||||
}
|
||||
if let Ok(raw) = serde_json::from_slice::<wifi_densepose_core::PoseObservationV2>(bytes) {
|
||||
if let Ok(mut engine) = PhysicsEngine::new(PhysicsConfig::default()) {
|
||||
let _ = engine.process(&raw, raw.timestamp_ns);
|
||||
}
|
||||
}
|
||||
});
|
||||
164
v2/crates/wifi-densepose-physics/src/config.rs
Normal file
164
v2/crates/wifi-densepose-physics/src/config.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! Bounded runtime configuration.
|
||||
|
||||
use wifi_densepose_core::PhysicsMode;
|
||||
|
||||
/// Runtime limits. Untrusted frames cannot modify these values.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PhysicsConfig {
|
||||
pub mode: PhysicsMode,
|
||||
pub max_tracks: usize,
|
||||
pub deadline_ms: u64,
|
||||
pub max_iterations: u8,
|
||||
pub max_joint_correction_m: f32,
|
||||
pub max_root_correction_m: f32,
|
||||
pub max_frame_gap_ms: u64,
|
||||
pub track_reset_gap_ms: u64,
|
||||
pub stale_after_ms: u64,
|
||||
pub minimum_joint_confidence: f32,
|
||||
pub minimum_known_joints: usize,
|
||||
pub calibration_joint_confidence: f32,
|
||||
pub solver_epsilon_m: f32,
|
||||
pub max_velocity_mps: f32,
|
||||
pub max_acceleration_mps2: f32,
|
||||
pub room_bound_m: f32,
|
||||
pub image_coordinate_bound: f32,
|
||||
pub beta_residual: f32,
|
||||
pub beta_intervention: f32,
|
||||
pub dynamics_audit: bool,
|
||||
pub dynamics_substeps: u8,
|
||||
pub dynamics_dt_seconds: f32,
|
||||
}
|
||||
|
||||
impl Default for PhysicsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: PhysicsMode::Audit,
|
||||
max_tracks: 4,
|
||||
deadline_ms: 5,
|
||||
max_iterations: 4,
|
||||
max_joint_correction_m: 0.20,
|
||||
max_root_correction_m: 0.10,
|
||||
max_frame_gap_ms: 250,
|
||||
track_reset_gap_ms: 500,
|
||||
stale_after_ms: 500,
|
||||
minimum_joint_confidence: 0.10,
|
||||
minimum_known_joints: 10,
|
||||
calibration_joint_confidence: 0.70,
|
||||
solver_epsilon_m: 0.001,
|
||||
max_velocity_mps: 8.0,
|
||||
max_acceleration_mps2: 40.0,
|
||||
room_bound_m: 100.0,
|
||||
image_coordinate_bound: 16_384.0,
|
||||
beta_residual: 1.0,
|
||||
beta_intervention: 2.0,
|
||||
dynamics_audit: false,
|
||||
dynamics_substeps: 2,
|
||||
dynamics_dt_seconds: 1.0 / 30.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PhysicsConfig {
|
||||
/// Validate operator-controlled limits before activation.
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.max_tracks == 0 || self.max_tracks > 256 {
|
||||
return Err("max_tracks must be in 1..=256");
|
||||
}
|
||||
if self.max_iterations == 0 || self.max_iterations > 8 {
|
||||
return Err("max_iterations must be in 1..=8");
|
||||
}
|
||||
if self.deadline_ms == 0 || self.deadline_ms > 1000 {
|
||||
return Err("deadline_ms must be in 1..=1000");
|
||||
}
|
||||
let finite_positive = [
|
||||
self.max_joint_correction_m,
|
||||
self.max_root_correction_m,
|
||||
self.solver_epsilon_m,
|
||||
self.max_velocity_mps,
|
||||
self.max_acceleration_mps2,
|
||||
self.room_bound_m,
|
||||
self.image_coordinate_bound,
|
||||
self.beta_residual,
|
||||
self.beta_intervention,
|
||||
self.dynamics_dt_seconds,
|
||||
];
|
||||
if finite_positive.iter().any(|v| !v.is_finite() || *v <= 0.0) {
|
||||
return Err("numeric limits must be finite and positive");
|
||||
}
|
||||
if self.image_coordinate_bound > 1_000_000.0 {
|
||||
return Err("image coordinate bound is excessive");
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.minimum_joint_confidence)
|
||||
|| !(0.0..=1.0).contains(&self.calibration_joint_confidence)
|
||||
{
|
||||
return Err("confidence limits must be in [0, 1]");
|
||||
}
|
||||
if !(1..=17).contains(&self.minimum_known_joints) {
|
||||
return Err("minimum_known_joints must be in 1..=17");
|
||||
}
|
||||
if self.max_frame_gap_ms == 0
|
||||
|| self.track_reset_gap_ms < self.max_frame_gap_ms
|
||||
|| self.stale_after_ms < self.max_frame_gap_ms
|
||||
{
|
||||
return Err("frame/reset/stale gaps must be ordered and non-zero");
|
||||
}
|
||||
if self.max_root_correction_m > self.max_joint_correction_m
|
||||
|| self.solver_epsilon_m >= self.max_joint_correction_m
|
||||
{
|
||||
return Err("correction limits are inconsistent");
|
||||
}
|
||||
if self.calibration_joint_confidence < self.minimum_joint_confidence {
|
||||
return Err("calibration confidence must meet the joint-use threshold");
|
||||
}
|
||||
if self.dynamics_audit && !cfg!(feature = "dynamics") {
|
||||
return Err("dynamics_audit requires the dynamics Cargo feature");
|
||||
}
|
||||
if !(1..=8).contains(&self.dynamics_substeps)
|
||||
|| !self.dynamics_dt_seconds.is_finite()
|
||||
|| !(1.0 / 240.0..=0.05).contains(&self.dynamics_dt_seconds)
|
||||
{
|
||||
return Err("dynamics limits are invalid");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stable hash used in idempotency and provenance.
|
||||
#[must_use]
|
||||
pub fn canonical_hash(&self) -> [u8; 32] {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(b"ruview.pose-physics-config-v1\0");
|
||||
h.update(&[
|
||||
self.mode as u8,
|
||||
self.max_iterations,
|
||||
self.dynamics_audit.into(),
|
||||
self.dynamics_substeps,
|
||||
]);
|
||||
for value in [
|
||||
self.max_tracks as u64,
|
||||
self.deadline_ms,
|
||||
self.max_frame_gap_ms,
|
||||
self.track_reset_gap_ms,
|
||||
self.stale_after_ms,
|
||||
self.minimum_known_joints as u64,
|
||||
] {
|
||||
h.update(&value.to_le_bytes());
|
||||
}
|
||||
for value in [
|
||||
self.max_joint_correction_m,
|
||||
self.max_root_correction_m,
|
||||
self.minimum_joint_confidence,
|
||||
self.calibration_joint_confidence,
|
||||
self.solver_epsilon_m,
|
||||
self.max_velocity_mps,
|
||||
self.max_acceleration_mps2,
|
||||
self.room_bound_m,
|
||||
self.image_coordinate_bound,
|
||||
self.beta_residual,
|
||||
self.beta_intervention,
|
||||
self.dynamics_dt_seconds,
|
||||
] {
|
||||
h.update(&value.to_bits().to_le_bytes());
|
||||
}
|
||||
*h.finalize().as_bytes()
|
||||
}
|
||||
}
|
||||
26
v2/crates/wifi-densepose-physics/src/constraints/bone.rs
Normal file
26
v2/crates/wifi-densepose-physics/src/constraints/bone.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! Bone-length consistency.
|
||||
|
||||
use crate::skeleton::{
|
||||
distance, virtual_joints, SkeletonPosterior, CONSTRAINED_EDGE_COUNT, OBSERVED_EDGES,
|
||||
VIRTUAL_NECK_EDGE, VIRTUAL_TRUNK_EDGE,
|
||||
};
|
||||
|
||||
/// Mean absolute edge-length residual.
|
||||
#[must_use]
|
||||
pub fn bone_residual(joints: &[[f32; 3]; 17], posterior: &SkeletonPosterior) -> f32 {
|
||||
let observed_total = OBSERVED_EDGES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (a, b))| {
|
||||
let current = distance(joints[*a], joints[*b]);
|
||||
(current - posterior.target(index, current)).abs()
|
||||
})
|
||||
.sum::<f32>();
|
||||
let (pelvis, thorax) = virtual_joints(joints);
|
||||
let trunk = distance(pelvis, thorax);
|
||||
let neck = distance(thorax, joints[0]);
|
||||
let total = observed_total
|
||||
+ (trunk - posterior.target(VIRTUAL_TRUNK_EDGE, trunk)).abs()
|
||||
+ (neck - posterior.target(VIRTUAL_NECK_EDGE, neck)).abs();
|
||||
total / CONSTRAINED_EDGE_COUNT as f32
|
||||
}
|
||||
32
v2/crates/wifi-densepose-physics/src/constraints/contact.rs
Normal file
32
v2/crates/wifi-densepose-physics/src/constraints/contact.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Foot-contact hypotheses derived from floor proximity and tangential motion.
|
||||
|
||||
use nalgebra::Vector3;
|
||||
use wifi_densepose_core::FloorPlane;
|
||||
|
||||
use super::TemporalHistory;
|
||||
|
||||
/// Maximum tangential foot displacement while an ankle is close enough to the floor
|
||||
/// to be a contact hypothesis. This is never labeled as measured contact.
|
||||
#[must_use]
|
||||
pub fn contact_slide_residual(
|
||||
joints: &[[f32; 3]; 17],
|
||||
floor: FloorPlane,
|
||||
history: &TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
) -> f32 {
|
||||
let Some((previous, previous_ns)) = &history.previous else {
|
||||
return 0.0;
|
||||
};
|
||||
if timestamp_ns <= *previous_ns {
|
||||
return 0.0;
|
||||
}
|
||||
let normal = Vector3::from(floor.normal);
|
||||
[15usize, 16usize]
|
||||
.into_iter()
|
||||
.filter(|&index| floor.signed_distance(joints[index]).abs() <= 0.05)
|
||||
.map(|index| {
|
||||
let displacement = Vector3::from(joints[index]) - Vector3::from(previous[index]);
|
||||
(displacement - normal * displacement.dot(&normal)).norm()
|
||||
})
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
25
v2/crates/wifi-densepose-physics/src/constraints/floor.rs
Normal file
25
v2/crates/wifi-densepose-physics/src/constraints/floor.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Calibrated floor-plane constraint. It prevents penetration only.
|
||||
|
||||
use wifi_densepose_core::FloorPlane;
|
||||
|
||||
/// Maximum penetration among observed joints.
|
||||
#[must_use]
|
||||
pub fn floor_penetration(joints: &[[f32; 3]; 17], floor: FloorPlane) -> f32 {
|
||||
joints
|
||||
.iter()
|
||||
.map(|point| (-floor.signed_distance(*point)).max(0.0))
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Move penetrating joints to the plane. No upright or contact prior is used.
|
||||
pub fn project_floor(joints: &mut [[f32; 3]; 17], floor: FloorPlane, _weights: &[f32; 17]) {
|
||||
for joint in joints.iter_mut() {
|
||||
let penetration = (-floor.signed_distance(*joint)).max(0.0);
|
||||
if penetration <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (coordinate, normal) in joint.iter_mut().zip(floor.normal) {
|
||||
*coordinate += normal * penetration;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Broad hinge-angle constraints that reject only anatomically impossible poses.
|
||||
|
||||
use nalgebra::Vector3;
|
||||
|
||||
/// `(parent, hinge, distal, minimum angle, maximum angle)`.
|
||||
const HINGES: [(usize, usize, usize, f32, f32); 4] = [
|
||||
(5, 7, 9, 0.087_266_46, 3.124_139_3),
|
||||
(6, 8, 10, 0.087_266_46, 3.124_139_3),
|
||||
(11, 13, 15, 0.087_266_46, 3.124_139_3),
|
||||
(12, 14, 16, 0.087_266_46, 3.124_139_3),
|
||||
];
|
||||
|
||||
/// Maximum angular violation in radians.
|
||||
#[must_use]
|
||||
pub fn joint_limit_residual(joints: &[[f32; 3]; 17]) -> f32 {
|
||||
HINGES
|
||||
.iter()
|
||||
.map(|&(parent, hinge, distal, minimum, maximum)| {
|
||||
let angle = angle_at(joints[parent], joints[hinge], joints[distal]);
|
||||
if angle < minimum {
|
||||
minimum - angle
|
||||
} else if angle > maximum {
|
||||
angle - maximum
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Move only the distal point toward a legal hinge angle. The movement weight
|
||||
/// makes uncertain joints converge faster while the outer correction cap
|
||||
/// remains authoritative.
|
||||
pub fn project_joint_limits(joints: &mut [[f32; 3]; 17], weights: &[f32; 17]) {
|
||||
for &(parent, hinge, distal, minimum, maximum) in &HINGES {
|
||||
let parent_direction = Vector3::from(joints[parent]) - Vector3::from(joints[hinge]);
|
||||
let distal_vector = Vector3::from(joints[distal]) - Vector3::from(joints[hinge]);
|
||||
let parent_length = parent_direction.norm();
|
||||
let distal_length = distal_vector.norm();
|
||||
if parent_length <= 1.0e-6 || distal_length <= 1.0e-6 {
|
||||
continue;
|
||||
}
|
||||
let parent_unit = parent_direction / parent_length;
|
||||
let distal_unit = distal_vector / distal_length;
|
||||
let angle = parent_unit.dot(&distal_unit).clamp(-1.0, 1.0).acos();
|
||||
let target = angle.clamp(minimum, maximum);
|
||||
if (target - angle).abs() <= f32::EPSILON {
|
||||
continue;
|
||||
}
|
||||
let mut perpendicular = distal_unit - parent_unit * parent_unit.dot(&distal_unit);
|
||||
if perpendicular.norm_squared() <= 1.0e-8 {
|
||||
let basis = if parent_unit.x.abs() < 0.8 {
|
||||
Vector3::x()
|
||||
} else {
|
||||
Vector3::y()
|
||||
};
|
||||
perpendicular = parent_unit.cross(&basis);
|
||||
}
|
||||
let perpendicular = perpendicular.normalize();
|
||||
let target_direction = parent_unit * target.cos() + perpendicular * target.sin();
|
||||
let target_point = Vector3::from(joints[hinge]) + target_direction * distal_length;
|
||||
let alpha = weights[distal].clamp(0.05, 1.0);
|
||||
joints[distal] =
|
||||
(Vector3::from(joints[distal]) * (1.0 - alpha) + target_point * alpha).into();
|
||||
}
|
||||
}
|
||||
|
||||
fn angle_at(parent: [f32; 3], hinge: [f32; 3], distal: [f32; 3]) -> f32 {
|
||||
let a = Vector3::from(parent) - Vector3::from(hinge);
|
||||
let b = Vector3::from(distal) - Vector3::from(hinge);
|
||||
let denominator = a.norm() * b.norm();
|
||||
if denominator <= 1.0e-8 {
|
||||
return 0.0;
|
||||
}
|
||||
(a.dot(&b) / denominator).clamp(-1.0, 1.0).acos()
|
||||
}
|
||||
58
v2/crates/wifi-densepose-physics/src/constraints/mod.rs
Normal file
58
v2/crates/wifi-densepose-physics/src/constraints/mod.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! Deterministic kinematic residuals.
|
||||
|
||||
mod bone;
|
||||
mod contact;
|
||||
mod floor;
|
||||
mod joint_limit;
|
||||
mod temporal;
|
||||
|
||||
pub use bone::bone_residual;
|
||||
pub use contact::contact_slide_residual;
|
||||
pub use floor::{floor_penetration, project_floor};
|
||||
pub use joint_limit::{joint_limit_residual, project_joint_limits};
|
||||
pub use temporal::{temporal_residuals, TemporalHistory};
|
||||
|
||||
use crate::skeleton::{distance, virtual_joints};
|
||||
use wifi_densepose_core::ConstraintResiduals;
|
||||
|
||||
/// Evaluate residuals without mutating the candidate.
|
||||
#[must_use]
|
||||
pub fn audit(
|
||||
joints: &[[f32; 3]; 17],
|
||||
posterior: &crate::skeleton::SkeletonPosterior,
|
||||
floor: wifi_densepose_core::FloorPlane,
|
||||
history: &TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
) -> ConstraintResiduals {
|
||||
let bone_m = bone_residual(joints, posterior);
|
||||
let floor_penetration_m = floor_penetration(joints, floor);
|
||||
let (velocity_mps, acceleration_mps2, temporal_jerk) =
|
||||
temporal_residuals(joints, history, timestamp_ns);
|
||||
let contact_m = contact_slide_residual(joints, floor, history, timestamp_ns);
|
||||
let (pelvis, thorax) = virtual_joints(joints);
|
||||
let trunk = distance(pelvis, thorax);
|
||||
let trunk_violation = if (0.05..=1.2).contains(&trunk) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let joint_limit_rad = joint_limit_residual(joints).max(trunk_violation);
|
||||
let normalized_total = (bone_m / 0.05
|
||||
+ floor_penetration_m / 0.05
|
||||
+ velocity_mps / 8.0
|
||||
+ acceleration_mps2 / 40.0
|
||||
+ joint_limit_rad
|
||||
+ contact_m / 0.25)
|
||||
/ 6.0;
|
||||
ConstraintResiduals {
|
||||
bone_m,
|
||||
joint_limit_rad,
|
||||
velocity_mps,
|
||||
acceleration_mps2,
|
||||
temporal_jerk,
|
||||
floor_penetration_m,
|
||||
contact_m,
|
||||
collision_m: 0.0,
|
||||
normalized_total,
|
||||
}
|
||||
}
|
||||
65
v2/crates/wifi-densepose-physics/src/constraints/temporal.rs
Normal file
65
v2/crates/wifi-densepose-physics/src/constraints/temporal.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Bounded temporal state and residuals.
|
||||
|
||||
use nalgebra::Vector3;
|
||||
|
||||
use crate::skeleton::distance;
|
||||
|
||||
/// At most two previous frames, held only for a local track.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TemporalHistory {
|
||||
pub previous: Option<([[f32; 3]; 17], u64)>,
|
||||
pub previous_previous: Option<([[f32; 3]; 17], u64)>,
|
||||
}
|
||||
|
||||
impl TemporalHistory {
|
||||
/// Commit a validated observation/candidate.
|
||||
pub fn push(&mut self, joints: [[f32; 3]; 17], timestamp_ns: u64) {
|
||||
self.previous_previous = self.previous.take();
|
||||
self.previous = Some((joints, timestamp_ns));
|
||||
}
|
||||
|
||||
/// Drop derivatives after a discontinuity.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum velocity, acceleration, and normalized jerk.
|
||||
#[must_use]
|
||||
pub fn temporal_residuals(
|
||||
joints: &[[f32; 3]; 17],
|
||||
history: &TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
) -> (f32, f32, f32) {
|
||||
let Some((previous, previous_ns)) = &history.previous else {
|
||||
return (0.0, 0.0, 0.0);
|
||||
};
|
||||
let dt = seconds(timestamp_ns.saturating_sub(*previous_ns));
|
||||
if dt <= 0.0 {
|
||||
return (f32::INFINITY, f32::INFINITY, f32::INFINITY);
|
||||
}
|
||||
let velocity = joints
|
||||
.iter()
|
||||
.zip(previous)
|
||||
.map(|(a, b)| distance(*a, *b) / dt)
|
||||
.fold(0.0, f32::max);
|
||||
let Some((older, older_ns)) = &history.previous_previous else {
|
||||
return (velocity, 0.0, 0.0);
|
||||
};
|
||||
let previous_dt = seconds(previous_ns.saturating_sub(*older_ns));
|
||||
if previous_dt <= 0.0 {
|
||||
return (velocity, f32::INFINITY, f32::INFINITY);
|
||||
}
|
||||
let mut acceleration = 0.0f32;
|
||||
for index in 0..17 {
|
||||
let v_now = (Vector3::from(joints[index]) - Vector3::from(previous[index])) / dt;
|
||||
let v_previous =
|
||||
(Vector3::from(previous[index]) - Vector3::from(older[index])) / previous_dt;
|
||||
acceleration = acceleration.max((v_now - v_previous).norm() / dt);
|
||||
}
|
||||
(velocity, acceleration, acceleration * dt)
|
||||
}
|
||||
|
||||
fn seconds(nanoseconds: u64) -> f32 {
|
||||
nanoseconds as f32 / 1_000_000_000.0
|
||||
}
|
||||
236
v2/crates/wifi-densepose-physics/src/dynamics/mod.rs
Normal file
236
v2/crates/wifi-densepose-physics/src/dynamics/mod.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
//! Optional bounded Rapier articulated-dynamics audit.
|
||||
//!
|
||||
//! All bodies, constraints, and snapshots are process-owned. The auditor
|
||||
//! scores a candidate but never mutates or selects the public pose.
|
||||
|
||||
use rapier3d::prelude::{
|
||||
ColliderBuilder, PhysicsWorld, Pose, RigidBodyBuilder, RigidBodyHandle, Rotation,
|
||||
SphericalJointBuilder, Vector,
|
||||
};
|
||||
use wifi_densepose_core::FloorPlane;
|
||||
|
||||
use crate::skeleton::OBSERVED_EDGES;
|
||||
|
||||
const SEGMENT_RADIUS_M: f32 = 0.035;
|
||||
const MAX_SUBSTEPS: u8 = 8;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Segment {
|
||||
handle: RigidBodyHandle,
|
||||
target_position: Pose,
|
||||
endpoints: (usize, usize),
|
||||
midpoint: Vector,
|
||||
rotation: Rotation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct JointAnchor {
|
||||
first: usize,
|
||||
second: usize,
|
||||
local_first: Vector,
|
||||
local_second: Vector,
|
||||
}
|
||||
|
||||
/// Scalar dynamics assessment safe for metrics and provenance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct DynamicsAssessment {
|
||||
pub stable: bool,
|
||||
pub segment_count: u8,
|
||||
pub joint_count: u8,
|
||||
pub contact_count: u16,
|
||||
pub substeps: u8,
|
||||
pub max_tracking_error_m: f32,
|
||||
pub max_joint_anchor_error_m: f32,
|
||||
pub max_floor_penetration_m: f32,
|
||||
pub max_control_effort: f32,
|
||||
}
|
||||
|
||||
/// Process-owned humanoid assembled from capsule segments and spherical joints.
|
||||
pub struct DynamicsAuditor {
|
||||
world: PhysicsWorld,
|
||||
segments: Vec<Segment>,
|
||||
anchors: Vec<JointAnchor>,
|
||||
floor: FloorPlane,
|
||||
}
|
||||
|
||||
impl DynamicsAuditor {
|
||||
/// Build a bounded articulated model from one candidate pose.
|
||||
#[must_use]
|
||||
pub fn new(joints: &[[f32; 3]; 17], floor: FloorPlane) -> Self {
|
||||
let mut world = PhysicsWorld::new();
|
||||
world.gravity = Vector::new(0.0, 0.0, -9.81);
|
||||
let floor_normal = Vector::from_array(floor.normal);
|
||||
let floor_point = floor_normal * -floor.offset_m;
|
||||
let floor_rotation = Rotation::from_rotation_arc(Vector::Z, floor_normal);
|
||||
world.insert(
|
||||
RigidBodyBuilder::fixed().pose(Pose::from_parts(floor_point, floor_rotation)),
|
||||
ColliderBuilder::cuboid(10.0, 10.0, 0.02).friction(0.8),
|
||||
);
|
||||
|
||||
let mut segments = Vec::with_capacity(OBSERVED_EDGES.len());
|
||||
for endpoints in OBSERVED_EDGES {
|
||||
let a = Vector::from_array(joints[endpoints.0]);
|
||||
let b = Vector::from_array(joints[endpoints.1]);
|
||||
let delta = b - a;
|
||||
let length = delta.length().max(SEGMENT_RADIUS_M * 2.0);
|
||||
let midpoint = (a + b) * 0.5;
|
||||
let direction = delta.try_normalize().unwrap_or(Vector::Y);
|
||||
let rotation = Rotation::from_rotation_arc(Vector::Y, direction);
|
||||
let position = Pose::from_parts(midpoint, rotation);
|
||||
let (handle, _) = world.insert(
|
||||
RigidBodyBuilder::dynamic()
|
||||
.pose(position)
|
||||
.linear_damping(0.8)
|
||||
.angular_damping(0.8)
|
||||
.can_sleep(false),
|
||||
ColliderBuilder::capsule_y(
|
||||
(length * 0.5 - SEGMENT_RADIUS_M).max(0.001),
|
||||
SEGMENT_RADIUS_M,
|
||||
)
|
||||
.density(450.0)
|
||||
.friction(0.7),
|
||||
);
|
||||
segments.push(Segment {
|
||||
handle,
|
||||
target_position: position,
|
||||
endpoints,
|
||||
midpoint,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
|
||||
let mut anchors = Vec::new();
|
||||
for first in 0..segments.len() {
|
||||
for second in (first + 1)..segments.len() {
|
||||
let Some(shared) =
|
||||
shared_endpoint(segments[first].endpoints, segments[second].endpoints)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let point = Vector::from_array(joints[shared]);
|
||||
let local_first =
|
||||
segments[first].rotation.inverse() * (point - segments[first].midpoint);
|
||||
let local_second =
|
||||
segments[second].rotation.inverse() * (point - segments[second].midpoint);
|
||||
world.insert_impulse_joint(
|
||||
segments[first].handle,
|
||||
segments[second].handle,
|
||||
SphericalJointBuilder::new()
|
||||
.local_anchor1(local_first)
|
||||
.local_anchor2(local_second)
|
||||
.contacts_enabled(false),
|
||||
);
|
||||
anchors.push(JointAnchor {
|
||||
first,
|
||||
second,
|
||||
local_first,
|
||||
local_second,
|
||||
});
|
||||
}
|
||||
}
|
||||
Self {
|
||||
world,
|
||||
segments,
|
||||
anchors,
|
||||
floor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run bounded PD-controlled audit steps and return scalar residuals.
|
||||
#[must_use]
|
||||
pub fn audit(
|
||||
&mut self,
|
||||
joints: &[[f32; 3]; 17],
|
||||
dt_seconds: f32,
|
||||
requested_substeps: u8,
|
||||
) -> DynamicsAssessment {
|
||||
let substeps = requested_substeps.clamp(1, MAX_SUBSTEPS);
|
||||
self.world.integration_parameters.dt =
|
||||
dt_seconds.clamp(1.0 / 240.0, 0.05) / f32::from(substeps);
|
||||
let mut max_control_effort = 0.0f32;
|
||||
for segment in &mut self.segments {
|
||||
let a = Vector::from_array(joints[segment.endpoints.0]);
|
||||
let b = Vector::from_array(joints[segment.endpoints.1]);
|
||||
let midpoint = (a + b) * 0.5;
|
||||
let direction = (b - a).try_normalize().unwrap_or(Vector::Y);
|
||||
let rotation = Rotation::from_rotation_arc(Vector::Y, direction);
|
||||
segment.target_position = Pose::from_parts(midpoint, rotation);
|
||||
}
|
||||
for _ in 0..substeps {
|
||||
for segment in &self.segments {
|
||||
let Some(body) = self.world.bodies.get_mut(segment.handle) else {
|
||||
continue;
|
||||
};
|
||||
let position_error = segment.target_position.translation - body.translation();
|
||||
let rotation_error =
|
||||
(segment.target_position.rotation * body.rotation().inverse()).to_scaled_axis();
|
||||
let force = position_error * 120.0 - body.linvel() * 18.0;
|
||||
let torque = rotation_error * 35.0 - body.angvel() * 8.0;
|
||||
max_control_effort = max_control_effort.max(force.length()).max(torque.length());
|
||||
body.add_force(force, true);
|
||||
body.add_torque(torque, true);
|
||||
}
|
||||
self.world.step();
|
||||
}
|
||||
|
||||
let max_tracking_error_m = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|segment| {
|
||||
self.world
|
||||
.bodies
|
||||
.get(segment.handle)
|
||||
.map(|body| (segment, body))
|
||||
})
|
||||
.map(|(segment, body)| {
|
||||
(segment.target_position.translation - body.translation()).length()
|
||||
})
|
||||
.fold(0.0, f32::max);
|
||||
let max_joint_anchor_error_m = self
|
||||
.anchors
|
||||
.iter()
|
||||
.filter_map(|anchor| {
|
||||
let first = self.world.bodies.get(self.segments[anchor.first].handle)?;
|
||||
let second = self.world.bodies.get(self.segments[anchor.second].handle)?;
|
||||
let point_first = first.position() * anchor.local_first;
|
||||
let point_second = second.position() * anchor.local_second;
|
||||
Some((point_first - point_second).length())
|
||||
})
|
||||
.fold(0.0, f32::max);
|
||||
let max_floor_penetration_m = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|segment| self.world.bodies.get(segment.handle))
|
||||
.map(|body| (-self.floor.signed_distance(body.translation().to_array())).max(0.0))
|
||||
.fold(0.0, f32::max);
|
||||
let contact_count = self
|
||||
.world
|
||||
.narrow_phase
|
||||
.contact_pairs()
|
||||
.filter(|pair| pair.has_any_active_contact())
|
||||
.count()
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX);
|
||||
let stable = max_tracking_error_m.is_finite()
|
||||
&& max_joint_anchor_error_m.is_finite()
|
||||
&& max_floor_penetration_m.is_finite()
|
||||
&& max_control_effort.is_finite();
|
||||
DynamicsAssessment {
|
||||
stable,
|
||||
segment_count: self.segments.len().try_into().unwrap_or(u8::MAX),
|
||||
joint_count: self.anchors.len().try_into().unwrap_or(u8::MAX),
|
||||
contact_count,
|
||||
substeps,
|
||||
max_tracking_error_m,
|
||||
max_joint_anchor_error_m,
|
||||
max_floor_penetration_m,
|
||||
max_control_effort,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shared_endpoint(first: (usize, usize), second: (usize, usize)) -> Option<usize> {
|
||||
[first.0, first.1]
|
||||
.into_iter()
|
||||
.find(|endpoint| *endpoint == second.0 || *endpoint == second.1)
|
||||
}
|
||||
779
v2/crates/wifi-densepose-physics/src/engine.rs
Normal file
779
v2/crates/wifi-densepose-physics/src/engine.rs
Normal file
@@ -0,0 +1,779 @@
|
||||
//! Stateful, bounded top-level control flow.
|
||||
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
use wifi_densepose_core::{
|
||||
AbstentionReason, ConstraintResiduals, ContactHypothesis, ContactState, PhysicsMode,
|
||||
PhysicsProvenance, PoseDimensionality, PoseObservationV2, PoseRefinementV1, PoseTrustState,
|
||||
Probability, RefinementDisposition, POSE_OBSERVATION_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::PhysicsConfig,
|
||||
constraints::{self, TemporalHistory},
|
||||
error::PhysicsError,
|
||||
projector,
|
||||
skeleton::SkeletonPosterior,
|
||||
uncertainty::movement_weight,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct TrackState {
|
||||
last_sequence: Option<u64>,
|
||||
last_timestamp_ns: Option<u64>,
|
||||
last_hash: Option<[u8; 32]>,
|
||||
last_config_hash: Option<[u8; 32]>,
|
||||
cached: Option<PoseRefinementV1>,
|
||||
skeleton: SkeletonPosterior,
|
||||
temporal: TemporalHistory,
|
||||
last_calibration_id: Option<String>,
|
||||
#[cfg(feature = "dynamics")]
|
||||
dynamics: Option<crate::dynamics::DynamicsAuditor>,
|
||||
}
|
||||
|
||||
/// Evidence receipt produced only after the serving boundary verifies the
|
||||
/// signed configuration, release gate, sensor authentication, and replay
|
||||
/// policy. The physics crate binds it to one exact configuration hash.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CorrectionAuthorization {
|
||||
config_hash: [u8; 32],
|
||||
evidence_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl CorrectionAuthorization {
|
||||
/// Construct a receipt from already verified, hash-addressed evidence.
|
||||
pub fn from_verified_evidence(
|
||||
config_hash: [u8; 32],
|
||||
evidence_hash: [u8; 32],
|
||||
) -> Result<Self, PhysicsError> {
|
||||
if config_hash == [0; 32] || evidence_hash == [0; 32] {
|
||||
return Err(PhysicsError::InvalidCorrectionAuthorization(
|
||||
"configuration and evidence hashes must be non-zero",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
config_hash,
|
||||
evidence_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame receipt returned by the ADR-305 verifier after signature,
|
||||
/// freshness, sequence, replay-window, calibration, and trust-state checks.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedFrameContext {
|
||||
raw_hash: [u8; 32],
|
||||
sensor_epoch: u64,
|
||||
sequence: u64,
|
||||
sensor_id: String,
|
||||
calibration_id: String,
|
||||
receipt_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl VerifiedFrameContext {
|
||||
/// Bind process-owned verification evidence to one canonical frame.
|
||||
pub fn from_verified_envelope(
|
||||
raw_hash: [u8; 32],
|
||||
sensor_epoch: u64,
|
||||
sequence: u64,
|
||||
sensor_id: String,
|
||||
calibration_id: String,
|
||||
receipt_hash: [u8; 32],
|
||||
) -> Result<Self, PhysicsError> {
|
||||
if raw_hash == [0; 32]
|
||||
|| receipt_hash == [0; 32]
|
||||
|| sensor_id.is_empty()
|
||||
|| calibration_id.is_empty()
|
||||
{
|
||||
return Err(PhysicsError::InvalidCorrectionAuthorization(
|
||||
"verified frame receipt is incomplete",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
raw_hash,
|
||||
sensor_epoch,
|
||||
sequence,
|
||||
sensor_id,
|
||||
calibration_id,
|
||||
receipt_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn matches(&self, raw: &PoseObservationV2) -> bool {
|
||||
self.raw_hash == raw.canonical_hash
|
||||
&& self.sensor_epoch == raw.sensor_epoch
|
||||
&& self.sequence == raw.sequence
|
||||
&& self.sensor_id == raw.source.sensor_id
|
||||
&& self.calibration_id == raw.calibration_id.0
|
||||
&& self.receipt_hash != [0; 32]
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory physics engine. Track state is anonymous and discarded on expiry.
|
||||
pub struct PhysicsEngine {
|
||||
config: PhysicsConfig,
|
||||
config_hash: [u8; 32],
|
||||
tracks: HashMap<(wifi_densepose_core::TrackId, u64), TrackState>,
|
||||
correction_authorization: Option<CorrectionAuthorization>,
|
||||
}
|
||||
|
||||
impl PhysicsEngine {
|
||||
/// Activate a validated trusted configuration.
|
||||
pub fn new(config: PhysicsConfig) -> Result<Self, PhysicsError> {
|
||||
config
|
||||
.validate()
|
||||
.map_err(PhysicsError::InvalidConfiguration)?;
|
||||
let config_hash = config.canonical_hash();
|
||||
Ok(Self {
|
||||
config,
|
||||
config_hash,
|
||||
tracks: HashMap::new(),
|
||||
correction_authorization: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Current immutable configuration.
|
||||
#[must_use]
|
||||
pub const fn config(&self) -> &PhysicsConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Hash of the exact active configuration.
|
||||
#[must_use]
|
||||
pub const fn config_hash(&self) -> [u8; 32] {
|
||||
self.config_hash
|
||||
}
|
||||
|
||||
/// Hash a prospective mode without activating it, so a signed evidence
|
||||
/// receipt can be bound before the atomic transition.
|
||||
#[must_use]
|
||||
pub fn config_hash_for_mode(&self, mode: PhysicsMode) -> [u8; 32] {
|
||||
let mut prospective = self.config.clone();
|
||||
prospective.mode = mode;
|
||||
prospective.canonical_hash()
|
||||
}
|
||||
|
||||
/// Activate a correction receipt after the serving boundary has verified it.
|
||||
pub fn authorize_correction(
|
||||
&mut self,
|
||||
authorization: CorrectionAuthorization,
|
||||
) -> Result<(), PhysicsError> {
|
||||
self.correction_authorization = Some(authorization);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticated control planes may change rollout mode without restart.
|
||||
pub fn set_mode(&mut self, mode: PhysicsMode) -> Result<(), PhysicsError> {
|
||||
let mut prospective = self.config.clone();
|
||||
prospective.mode = mode;
|
||||
let prospective_hash = prospective.canonical_hash();
|
||||
if mode.selects_correction()
|
||||
&& self
|
||||
.correction_authorization
|
||||
.is_none_or(|receipt| receipt.config_hash != prospective_hash)
|
||||
{
|
||||
return Err(PhysicsError::InvalidCorrectionAuthorization(
|
||||
"correction mode requires evidence bound to the target configuration",
|
||||
));
|
||||
}
|
||||
self.config.mode = mode;
|
||||
self.config_hash = prospective_hash;
|
||||
if matches!(mode, PhysicsMode::Off) {
|
||||
self.tracks.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process one frame and always return a typed assessment.
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn process(&mut self, raw: &PoseObservationV2, now_ns: u64) -> PoseRefinementV1 {
|
||||
self.process_with_context(raw, now_ns, None)
|
||||
}
|
||||
|
||||
/// Process a frame with process-owned source verification evidence.
|
||||
#[must_use]
|
||||
pub fn process_verified(
|
||||
&mut self,
|
||||
raw: &PoseObservationV2,
|
||||
now_ns: u64,
|
||||
verification: &VerifiedFrameContext,
|
||||
) -> PoseRefinementV1 {
|
||||
self.process_with_context(raw, now_ns, Some(verification))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn process_with_context(
|
||||
&mut self,
|
||||
raw: &PoseObservationV2,
|
||||
now_ns: u64,
|
||||
verification: Option<&VerifiedFrameContext>,
|
||||
) -> PoseRefinementV1 {
|
||||
let started = Instant::now();
|
||||
if let Some(reason) = self.validate_contract(raw, now_ns) {
|
||||
return self.raw_result(raw, RefinementDisposition::Rejected, Some(reason));
|
||||
}
|
||||
if self.config.mode == PhysicsMode::Off {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Bypassed,
|
||||
Some(AbstentionReason::ModeOff),
|
||||
);
|
||||
}
|
||||
if raw.trust_state == PoseTrustState::Unknown {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::OodUnknown),
|
||||
);
|
||||
}
|
||||
if raw.dimensionality == PoseDimensionality::Image2d {
|
||||
return self.audit_2d(raw, started);
|
||||
}
|
||||
if self.config.mode.selects_correction()
|
||||
&& self
|
||||
.correction_authorization
|
||||
.is_none_or(|receipt| receipt.config_hash != self.config_hash)
|
||||
{
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::CorrectionNotAuthorized),
|
||||
);
|
||||
}
|
||||
if self.config.mode.selects_correction()
|
||||
&& verification.is_none_or(|receipt| !receipt.matches(raw))
|
||||
{
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::SourceUnauthenticated),
|
||||
);
|
||||
}
|
||||
if !raw.uncertainty_calibrated {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::UncertaintyUncalibrated),
|
||||
);
|
||||
}
|
||||
let Some(floor) = raw.floor_plane else {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::CalibrationUnavailable),
|
||||
);
|
||||
};
|
||||
if !floor.is_valid() {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Rejected,
|
||||
Some(AbstentionReason::CoordinateFrameMismatch),
|
||||
);
|
||||
}
|
||||
|
||||
self.expire_tracks(now_ns);
|
||||
let key = (raw.track_id.clone(), raw.sensor_epoch);
|
||||
if !self.tracks.contains_key(&key) && self.tracks.len() >= self.config.max_tracks {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::TrackCapacity),
|
||||
);
|
||||
}
|
||||
let mut state = self.tracks.remove(&key).unwrap_or_default();
|
||||
if state.last_sequence == Some(raw.sequence) {
|
||||
if state.last_hash == Some(raw.canonical_hash)
|
||||
&& state.last_config_hash == Some(self.config_hash)
|
||||
{
|
||||
if let Some(cached) = state.cached.clone() {
|
||||
self.tracks.insert(key, state);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
self.tracks.insert(key, state);
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::ReplayRejected),
|
||||
);
|
||||
}
|
||||
if state
|
||||
.last_sequence
|
||||
.is_some_and(|sequence| raw.sequence < sequence)
|
||||
|| state
|
||||
.last_timestamp_ns
|
||||
.is_some_and(|timestamp| raw.timestamp_ns <= timestamp)
|
||||
{
|
||||
state.temporal.reset();
|
||||
self.tracks.insert(key, state);
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::NonMonotonicInput),
|
||||
);
|
||||
}
|
||||
if state.last_timestamp_ns.is_some_and(|timestamp| {
|
||||
raw.timestamp_ns.saturating_sub(timestamp) > self.config.track_reset_gap_ms * 1_000_000
|
||||
}) {
|
||||
state = TrackState::default();
|
||||
} else if state.last_timestamp_ns.is_some_and(|timestamp| {
|
||||
raw.timestamp_ns.saturating_sub(timestamp) > self.config.max_frame_gap_ms * 1_000_000
|
||||
}) {
|
||||
state.temporal.reset();
|
||||
}
|
||||
if state
|
||||
.last_calibration_id
|
||||
.as_ref()
|
||||
.is_some_and(|calibration| calibration != &raw.calibration_id.0)
|
||||
{
|
||||
state = TrackState::default();
|
||||
}
|
||||
|
||||
let known = raw
|
||||
.joints
|
||||
.iter()
|
||||
.filter(|joint| {
|
||||
joint.visibility != wifi_densepose_core::JointVisibility::Unknown
|
||||
&& joint.confidence.get() >= self.config.minimum_joint_confidence
|
||||
})
|
||||
.count();
|
||||
if known < self.config.minimum_known_joints {
|
||||
self.tracks.insert(key, state);
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::TooFewKnownJoints),
|
||||
);
|
||||
}
|
||||
|
||||
let observed = raw.joints.map(|joint| joint.position_m);
|
||||
let confidence = raw.joints.map(|joint| joint.confidence.get());
|
||||
let weights = raw.joints.map(|joint| movement_weight(joint.covariance_m2));
|
||||
let audit = constraints::audit(
|
||||
&observed,
|
||||
&state.skeleton,
|
||||
floor,
|
||||
&state.temporal,
|
||||
raw.timestamp_ns,
|
||||
);
|
||||
state.skeleton.observe(
|
||||
&observed,
|
||||
&confidence,
|
||||
self.config.calibration_joint_confidence,
|
||||
);
|
||||
|
||||
if self.config.mode == PhysicsMode::Audit {
|
||||
let result = self.build_result(
|
||||
raw,
|
||||
RefinementDisposition::Audited,
|
||||
false,
|
||||
None,
|
||||
wifi_densepose_core::InterventionSummary {
|
||||
elapsed_us: elapsed_us(started),
|
||||
..wifi_densepose_core::InterventionSummary::default()
|
||||
},
|
||||
audit,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.temporal.push(observed, raw.timestamp_ns);
|
||||
state.last_sequence = Some(raw.sequence);
|
||||
state.last_timestamp_ns = Some(raw.timestamp_ns);
|
||||
state.last_hash = Some(raw.canonical_hash);
|
||||
state.last_config_hash = Some(self.config_hash);
|
||||
state.last_calibration_id = Some(raw.calibration_id.0.clone());
|
||||
state.cached = Some(result.clone());
|
||||
self.tracks.insert(key, state);
|
||||
return result;
|
||||
}
|
||||
|
||||
let projection = projector::project(
|
||||
&observed,
|
||||
&weights,
|
||||
&state.skeleton,
|
||||
&state.temporal,
|
||||
raw.timestamp_ns,
|
||||
floor,
|
||||
&self.config,
|
||||
started,
|
||||
);
|
||||
let result = match projection {
|
||||
Err(reason) => self.raw_result(raw, RefinementDisposition::Abstained, Some(reason)),
|
||||
Ok(candidate) => {
|
||||
#[allow(unused_mut)]
|
||||
let mut refined_residuals = constraints::audit(
|
||||
&candidate.joints,
|
||||
&state.skeleton,
|
||||
floor,
|
||||
&state.temporal,
|
||||
raw.timestamp_ns,
|
||||
);
|
||||
#[cfg(feature = "dynamics")]
|
||||
let dynamics = if self.config.dynamics_audit {
|
||||
let auditor = state.dynamics.get_or_insert_with(|| {
|
||||
crate::dynamics::DynamicsAuditor::new(&candidate.joints, floor)
|
||||
});
|
||||
let assessment = auditor.audit(
|
||||
&candidate.joints,
|
||||
self.config.dynamics_dt_seconds,
|
||||
self.config.dynamics_substeps,
|
||||
);
|
||||
refined_residuals.collision_m = assessment.max_floor_penetration_m;
|
||||
refined_residuals.normalized_total += (assessment.max_tracking_error_m / 0.05
|
||||
+ assessment.max_joint_anchor_error_m / 0.02
|
||||
+ assessment.max_floor_penetration_m / 0.05)
|
||||
/ 3.0;
|
||||
Some(wifi_densepose_core::DynamicsResiduals {
|
||||
stable: assessment.stable,
|
||||
segment_count: assessment.segment_count,
|
||||
joint_count: assessment.joint_count,
|
||||
contact_count: assessment.contact_count,
|
||||
substeps: assessment.substeps,
|
||||
tracking_error_m: assessment.max_tracking_error_m,
|
||||
joint_anchor_error_m: assessment.max_joint_anchor_error_m,
|
||||
floor_penetration_m: assessment.max_floor_penetration_m,
|
||||
control_effort: assessment.max_control_effort,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(feature = "dynamics"))]
|
||||
let dynamics: Option<wifi_densepose_core::DynamicsResiduals> = None;
|
||||
if started.elapsed() >= std::time::Duration::from_millis(self.config.deadline_ms) {
|
||||
self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::DeadlineExceeded),
|
||||
)
|
||||
} else {
|
||||
let (disposition, selected, refined) = match self.config.mode {
|
||||
PhysicsMode::Audit => unreachable!("audit returned before projection"),
|
||||
PhysicsMode::ShadowCorrect => (
|
||||
RefinementDisposition::Shadowed,
|
||||
false,
|
||||
Some(candidate.joints),
|
||||
),
|
||||
PhysicsMode::OptInCorrect | PhysicsMode::DefaultCorrect => (
|
||||
RefinementDisposition::Corrected,
|
||||
true,
|
||||
Some(candidate.joints),
|
||||
),
|
||||
PhysicsMode::Off => unreachable!("off returned before projection"),
|
||||
};
|
||||
let mut result = self.build_result(
|
||||
raw,
|
||||
disposition,
|
||||
selected,
|
||||
refined,
|
||||
candidate.intervention,
|
||||
audit,
|
||||
Some(refined_residuals),
|
||||
None,
|
||||
);
|
||||
result.dynamics = dynamics;
|
||||
if result.dynamics.is_some() {
|
||||
result.provenance.engine = "kinematic-pbd+rapier-audit".into();
|
||||
result.seal();
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let committed_joints = if result.selected {
|
||||
result.refined_joints_m.unwrap_or(observed)
|
||||
} else {
|
||||
observed
|
||||
};
|
||||
state.temporal.push(committed_joints, raw.timestamp_ns);
|
||||
state.last_sequence = Some(raw.sequence);
|
||||
state.last_timestamp_ns = Some(raw.timestamp_ns);
|
||||
state.last_hash = Some(raw.canonical_hash);
|
||||
state.last_config_hash = Some(self.config_hash);
|
||||
state.last_calibration_id = Some(raw.calibration_id.0.clone());
|
||||
state.cached = Some(result.clone());
|
||||
self.tracks.insert(key, state);
|
||||
debug_assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
|
||||
debug_assert_eq!(result.raw_observation_hash, raw.canonical_hash);
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_contract(&self, raw: &PoseObservationV2, now_ns: u64) -> Option<AbstentionReason> {
|
||||
if raw.schema_version != POSE_OBSERVATION_SCHEMA_VERSION {
|
||||
return Some(AbstentionReason::UnsupportedSchema);
|
||||
}
|
||||
if raw.compute_canonical_hash() != raw.canonical_hash {
|
||||
return Some(AbstentionReason::HashMismatch);
|
||||
}
|
||||
if raw.track_id.0.is_empty()
|
||||
|| raw.track_id.0.len() > 128
|
||||
|| raw.frame.name.is_empty()
|
||||
|| raw.frame.name.len() > 128
|
||||
|| raw.calibration_id.0.is_empty()
|
||||
|| raw.calibration_id.0.len() > 128
|
||||
|| raw.model.id.is_empty()
|
||||
|| raw.model.id.len() > 128
|
||||
|| raw.source.sensor_id.is_empty()
|
||||
|| raw.source.sensor_id.len() > 128
|
||||
{
|
||||
return Some(AbstentionReason::InvalidNumber);
|
||||
}
|
||||
if now_ns.saturating_sub(raw.timestamp_ns) > self.config.stale_after_ms * 1_000_000
|
||||
|| raw.timestamp_ns > now_ns.saturating_add(50_000_000)
|
||||
{
|
||||
return Some(AbstentionReason::StaleInput);
|
||||
}
|
||||
let coordinate_bound = match raw.dimensionality {
|
||||
PoseDimensionality::Metric3d => self.config.room_bound_m,
|
||||
PoseDimensionality::Image2d => self.config.image_coordinate_bound,
|
||||
};
|
||||
for (index, joint) in raw.joints.iter().enumerate() {
|
||||
if joint.kind != wifi_densepose_core::Coco17Joint::ALL[index]
|
||||
|| !joint.position_m.iter().all(|v| v.is_finite())
|
||||
|| joint.position_m.iter().any(|v| v.abs() > coordinate_bound)
|
||||
{
|
||||
return Some(AbstentionReason::InvalidNumber);
|
||||
}
|
||||
if !joint.covariance_m2.is_positive_semidefinite() {
|
||||
return Some(AbstentionReason::InvalidCovariance);
|
||||
}
|
||||
}
|
||||
match raw.dimensionality {
|
||||
PoseDimensionality::Metric3d
|
||||
if !(raw.frame.metric && raw.frame.right_handed && raw.frame.z_up) =>
|
||||
{
|
||||
Some(AbstentionReason::CoordinateFrameMismatch)
|
||||
}
|
||||
PoseDimensionality::Image2d if raw.frame.metric => {
|
||||
Some(AbstentionReason::CoordinateFrameMismatch)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_2d(&mut self, raw: &PoseObservationV2, started: Instant) -> PoseRefinementV1 {
|
||||
self.expire_tracks(raw.timestamp_ns);
|
||||
let key = (raw.track_id.clone(), raw.sensor_epoch);
|
||||
if !self.tracks.contains_key(&key) && self.tracks.len() >= self.config.max_tracks {
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::TrackCapacity),
|
||||
);
|
||||
}
|
||||
let mut state = self.tracks.remove(&key).unwrap_or_default();
|
||||
if state.last_sequence == Some(raw.sequence) {
|
||||
if state.last_hash == Some(raw.canonical_hash)
|
||||
&& state.last_config_hash == Some(self.config_hash)
|
||||
{
|
||||
if let Some(cached) = state.cached.clone() {
|
||||
self.tracks.insert(key, state);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
self.tracks.insert(key, state);
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::ReplayRejected),
|
||||
);
|
||||
}
|
||||
if state
|
||||
.last_sequence
|
||||
.is_some_and(|sequence| raw.sequence < sequence)
|
||||
|| state
|
||||
.last_timestamp_ns
|
||||
.is_some_and(|timestamp| raw.timestamp_ns <= timestamp)
|
||||
{
|
||||
state.temporal.reset();
|
||||
self.tracks.insert(key, state);
|
||||
return self.raw_result(
|
||||
raw,
|
||||
RefinementDisposition::Abstained,
|
||||
Some(AbstentionReason::NonMonotonicInput),
|
||||
);
|
||||
}
|
||||
if state.last_timestamp_ns.is_some_and(|timestamp| {
|
||||
raw.timestamp_ns.saturating_sub(timestamp) > self.config.track_reset_gap_ms * 1_000_000
|
||||
}) {
|
||||
state = TrackState::default();
|
||||
} else if state.last_timestamp_ns.is_some_and(|timestamp| {
|
||||
raw.timestamp_ns.saturating_sub(timestamp) > self.config.max_frame_gap_ms * 1_000_000
|
||||
}) {
|
||||
state.temporal.reset();
|
||||
}
|
||||
if state
|
||||
.last_calibration_id
|
||||
.as_ref()
|
||||
.is_some_and(|calibration| calibration != &raw.calibration_id.0)
|
||||
{
|
||||
state = TrackState::default();
|
||||
}
|
||||
let joints = raw.joints.map(|joint| joint.position_m);
|
||||
let residuals = image_residuals(&joints, &state.temporal, raw.timestamp_ns);
|
||||
let intervention = wifi_densepose_core::InterventionSummary {
|
||||
elapsed_us: elapsed_us(started),
|
||||
..wifi_densepose_core::InterventionSummary::default()
|
||||
};
|
||||
let result = self.build_result(
|
||||
raw,
|
||||
RefinementDisposition::Audited2d,
|
||||
false,
|
||||
None,
|
||||
intervention,
|
||||
residuals,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
state.temporal.push(joints, raw.timestamp_ns);
|
||||
state.last_sequence = Some(raw.sequence);
|
||||
state.last_timestamp_ns = Some(raw.timestamp_ns);
|
||||
state.last_hash = Some(raw.canonical_hash);
|
||||
state.last_config_hash = Some(self.config_hash);
|
||||
state.last_calibration_id = Some(raw.calibration_id.0.clone());
|
||||
state.cached = Some(result.clone());
|
||||
self.tracks.insert(key, state);
|
||||
result
|
||||
}
|
||||
|
||||
fn raw_result(
|
||||
&self,
|
||||
raw: &PoseObservationV2,
|
||||
disposition: RefinementDisposition,
|
||||
reason: Option<AbstentionReason>,
|
||||
) -> PoseRefinementV1 {
|
||||
self.build_result(
|
||||
raw,
|
||||
disposition,
|
||||
false,
|
||||
None,
|
||||
wifi_densepose_core::InterventionSummary::default(),
|
||||
ConstraintResiduals::default(),
|
||||
None,
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_result(
|
||||
&self,
|
||||
raw: &PoseObservationV2,
|
||||
disposition: RefinementDisposition,
|
||||
selected: bool,
|
||||
refined: Option<[[f32; 3]; 17]>,
|
||||
intervention: wifi_densepose_core::InterventionSummary,
|
||||
residuals: ConstraintResiduals,
|
||||
refined_residuals: Option<ConstraintResiduals>,
|
||||
reason: Option<AbstentionReason>,
|
||||
) -> PoseRefinementV1 {
|
||||
let penalty = self.config.beta_residual.mul_add(
|
||||
residuals.normalized_total,
|
||||
self.config.beta_intervention * intervention.max_joint_correction_m
|
||||
/ self.config.max_joint_correction_m,
|
||||
);
|
||||
let physics_value = if reason == Some(AbstentionReason::ModeOff) {
|
||||
1.0
|
||||
} else if reason.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
(-penalty.max(0.0)).exp().clamp(0.0, 1.0)
|
||||
};
|
||||
let physics_confidence = Probability::new(physics_value).unwrap_or(Probability::ZERO);
|
||||
let effective_value = raw
|
||||
.observer_confidence
|
||||
.get()
|
||||
.min(raw.observer_confidence.get() * physics_value);
|
||||
let effective_confidence = Probability::new(effective_value).unwrap_or(Probability::ZERO);
|
||||
let contact_hypotheses = if raw.dimensionality == PoseDimensionality::Metric3d {
|
||||
[15usize, 16usize].map(|index| {
|
||||
let probability = raw.floor_plane.map_or(0.0, |floor| {
|
||||
(1.0 - floor.signed_distance(raw.joints[index].position_m).abs() / 0.05)
|
||||
.clamp(0.0, 1.0)
|
||||
});
|
||||
ContactHypothesis {
|
||||
state: ContactState::Hypothesis,
|
||||
probability: Probability::new(probability).unwrap_or(Probability::ZERO),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
[ContactHypothesis::default(); 2]
|
||||
};
|
||||
let mut result = PoseRefinementV1 {
|
||||
schema_version: 1,
|
||||
raw_observation_hash: raw.canonical_hash,
|
||||
mode: self.config.mode,
|
||||
disposition,
|
||||
selected,
|
||||
refined_joints_m: refined,
|
||||
physics_confidence,
|
||||
effective_confidence,
|
||||
intervention,
|
||||
residuals,
|
||||
refined_residuals,
|
||||
dynamics: None,
|
||||
contact_hypotheses,
|
||||
provenance: PhysicsProvenance {
|
||||
engine: "kinematic-pbd".into(),
|
||||
engine_version: env!("CARGO_PKG_VERSION").into(),
|
||||
config_hash: self.config_hash,
|
||||
rf_model_hash: raw.model.artifact_hash,
|
||||
calibration_id: raw.calibration_id.0.clone(),
|
||||
learned_artifact_hash: None,
|
||||
},
|
||||
reason,
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
result.seal();
|
||||
result
|
||||
}
|
||||
|
||||
fn expire_tracks(&mut self, now_ns: u64) {
|
||||
let expiry = self.config.track_reset_gap_ms * 1_000_000;
|
||||
self.tracks.retain(|_, state| {
|
||||
state
|
||||
.last_timestamp_ns
|
||||
.is_none_or(|timestamp| now_ns.saturating_sub(timestamp) <= expiry)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn image_residuals(
|
||||
joints: &[[f32; 3]; 17],
|
||||
temporal: &crate::constraints::TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
) -> ConstraintResiduals {
|
||||
let lengths = crate::skeleton::OBSERVED_EDGES
|
||||
.map(|(a, b)| crate::skeleton::distance(joints[a], joints[b]));
|
||||
let mean = lengths.iter().sum::<f32>() / lengths.len() as f32;
|
||||
let ratio_variance = if mean > 1.0e-6 {
|
||||
lengths
|
||||
.iter()
|
||||
.map(|length| ((length - mean) / mean).powi(2))
|
||||
.sum::<f32>()
|
||||
/ lengths.len() as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (velocity, acceleration, jerk) =
|
||||
crate::constraints::temporal_residuals(joints, temporal, timestamp_ns);
|
||||
ConstraintResiduals {
|
||||
normalized_total: ratio_variance.sqrt().min(1.0),
|
||||
velocity_mps: velocity,
|
||||
acceleration_mps2: acceleration,
|
||||
temporal_jerk: jerk,
|
||||
..ConstraintResiduals::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_us(started: Instant) -> u64 {
|
||||
#[cfg(feature = "deterministic")]
|
||||
{
|
||||
let _ = started;
|
||||
0
|
||||
}
|
||||
#[cfg(not(feature = "deterministic"))]
|
||||
{
|
||||
started.elapsed().as_micros().try_into().unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
12
v2/crates/wifi-densepose-physics/src/error.rs
Normal file
12
v2/crates/wifi-densepose-physics/src/error.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Typed internal errors. Public frame failures are mapped to abstention reasons.
|
||||
|
||||
/// Construction/configuration errors that cannot be represented as a frame result.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PhysicsError {
|
||||
/// Invalid trusted configuration.
|
||||
#[error("invalid physics configuration: {0}")]
|
||||
InvalidConfiguration(&'static str),
|
||||
/// A correction authorization is empty or bound to another configuration.
|
||||
#[error("invalid correction authorization: {0}")]
|
||||
InvalidCorrectionAuthorization(&'static str),
|
||||
}
|
||||
163
v2/crates/wifi-densepose-physics/src/learned/artifact.rs
Normal file
163
v2/crates/wifi-densepose-physics/src/learned/artifact.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Signed learned-artifact identity, compatibility, and bounded output.
|
||||
|
||||
use super::{
|
||||
features::FEATURE_WIDTH,
|
||||
model::{HIDDEN_WIDTH, HISTORY_FRAMES},
|
||||
};
|
||||
|
||||
pub const LEARNED_ARTIFACT_SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LearnedArtifactManifest {
|
||||
pub schema_version: u16,
|
||||
pub history_frames: usize,
|
||||
pub feature_width: usize,
|
||||
pub hidden_width: usize,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
impl LearnedArtifactManifest {
|
||||
#[must_use]
|
||||
pub fn adr323(model_id: String) -> Self {
|
||||
Self {
|
||||
schema_version: LEARNED_ARTIFACT_SCHEMA_VERSION,
|
||||
history_frames: HISTORY_FRAMES,
|
||||
feature_width: FEATURE_WIDTH,
|
||||
hidden_width: HIDDEN_WIDTH,
|
||||
model_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_compatible(&self) -> bool {
|
||||
self.schema_version == LEARNED_ARTIFACT_SCHEMA_VERSION
|
||||
&& self.history_frames == HISTORY_FRAMES
|
||||
&& self.feature_width == FEATURE_WIDTH
|
||||
&& self.hidden_width == HIDDEN_WIDTH
|
||||
&& !self.model_id.is_empty()
|
||||
&& self.model_id.len() <= 128
|
||||
}
|
||||
}
|
||||
|
||||
/// Receipt from the existing signed RVF/Cog activation boundary.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignatureVerification {
|
||||
pub key_id: String,
|
||||
pub envelope_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl SignatureVerification {
|
||||
#[must_use]
|
||||
pub fn accepted(key_id: String, envelope_hash: [u8; 32]) -> Option<Self> {
|
||||
(!key_id.is_empty() && key_id.len() <= 128 && envelope_hash != [0; 32]).then_some(Self {
|
||||
key_id,
|
||||
envelope_hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum LearnedArtifactError {
|
||||
#[error("artifact content hash mismatch")]
|
||||
HashMismatch,
|
||||
#[error("artifact manifest is incompatible")]
|
||||
IncompatibleManifest,
|
||||
#[error("artifact signature receipt is invalid")]
|
||||
InvalidSignatureReceipt,
|
||||
#[error("artifact exceeds the configured byte limit")]
|
||||
ArtifactTooLarge,
|
||||
}
|
||||
|
||||
/// Verified immutable model bytes and activation provenance.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LearnedArtifact {
|
||||
pub content_hash: [u8; 32],
|
||||
pub manifest: LearnedArtifactManifest,
|
||||
pub signature: SignatureVerification,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LearnedArtifact {
|
||||
pub fn verified(
|
||||
bytes: Vec<u8>,
|
||||
expected: [u8; 32],
|
||||
manifest: LearnedArtifactManifest,
|
||||
signature: SignatureVerification,
|
||||
maximum_bytes: usize,
|
||||
) -> Result<Self, LearnedArtifactError> {
|
||||
if bytes.len() > maximum_bytes {
|
||||
return Err(LearnedArtifactError::ArtifactTooLarge);
|
||||
}
|
||||
if !manifest.is_compatible() {
|
||||
return Err(LearnedArtifactError::IncompatibleManifest);
|
||||
}
|
||||
if signature.key_id.is_empty() || signature.envelope_hash == [0; 32] {
|
||||
return Err(LearnedArtifactError::InvalidSignatureReceipt);
|
||||
}
|
||||
let actual = *blake3::hash(&bytes).as_bytes();
|
||||
if actual != expected {
|
||||
return Err(LearnedArtifactError::HashMismatch);
|
||||
}
|
||||
Ok(Self {
|
||||
content_hash: actual,
|
||||
manifest,
|
||||
signature,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
/// Learned heads after hard output validation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LearnedResidual {
|
||||
pub joints_m: [[f32; 3]; 17],
|
||||
pub log_variance: [[f32; 3]; 17],
|
||||
pub contact_probability: [f32; 2],
|
||||
pub abstention_probability: f32,
|
||||
}
|
||||
|
||||
impl LearnedResidual {
|
||||
/// Reject non-finite output, then clamp residuals and probabilistic heads.
|
||||
#[must_use]
|
||||
pub fn bounded(mut self, cap_m: f32) -> Option<Self> {
|
||||
if !cap_m.is_finite()
|
||||
|| cap_m <= 0.0
|
||||
|| !self
|
||||
.joints_m
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|value| value.is_finite())
|
||||
|| !self
|
||||
.log_variance
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|value| value.is_finite())
|
||||
|| !self
|
||||
.contact_probability
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
|| !self.abstention_probability.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for joint in &mut self.joints_m {
|
||||
for coordinate in joint {
|
||||
*coordinate = coordinate.clamp(-cap_m, cap_m);
|
||||
}
|
||||
}
|
||||
for joint in &mut self.log_variance {
|
||||
for coordinate in joint {
|
||||
*coordinate = coordinate.clamp(-20.0, 10.0);
|
||||
}
|
||||
}
|
||||
for probability in &mut self.contact_probability {
|
||||
*probability = probability.clamp(0.0, 1.0);
|
||||
}
|
||||
self.abstention_probability = self.abstention_probability.clamp(0.0, 1.0);
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
145
v2/crates/wifi-densepose-physics/src/learned/features.rs
Normal file
145
v2/crates/wifi-densepose-physics/src/learned/features.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! Fixed-width, bounded learned-residual feature encoding.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use wifi_densepose_core::{ConstraintResiduals, PoseObservationV2};
|
||||
|
||||
use crate::constraints::TemporalHistory;
|
||||
|
||||
pub const RF_EMBEDDING_WIDTH: usize = 128;
|
||||
pub const BASE_FEATURE_WIDTH: usize = 319;
|
||||
pub const FEATURE_WIDTH: usize = BASE_FEATURE_WIDTH + RF_EMBEDDING_WIDTH;
|
||||
|
||||
/// Exactly one encoded model timestep.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PoseFeatureFrame {
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
impl PoseFeatureFrame {
|
||||
/// Encode observer, uncertainty, deterministic residual, temporal, floor,
|
||||
/// OOD, and optional RF embedding features without allocating unbounded data.
|
||||
#[must_use]
|
||||
pub fn encode(
|
||||
raw: &PoseObservationV2,
|
||||
residuals: ConstraintResiduals,
|
||||
history: &TemporalHistory,
|
||||
rf_embedding: Option<&[f32; RF_EMBEDDING_WIDTH]>,
|
||||
) -> Self {
|
||||
let mut values = Vec::with_capacity(FEATURE_WIDTH);
|
||||
for joint in &raw.joints {
|
||||
values.extend(joint.position_m);
|
||||
}
|
||||
for joint in &raw.joints {
|
||||
values.extend([
|
||||
joint.covariance_m2.xx,
|
||||
joint.covariance_m2.xy,
|
||||
joint.covariance_m2.xz,
|
||||
joint.covariance_m2.yy,
|
||||
joint.covariance_m2.yz,
|
||||
joint.covariance_m2.zz,
|
||||
]);
|
||||
}
|
||||
values.extend(raw.joints.iter().map(|joint| joint.confidence.get()));
|
||||
values.extend(
|
||||
raw.joints
|
||||
.iter()
|
||||
.map(|joint| f32::from(joint.visibility as u8)),
|
||||
);
|
||||
values.extend([
|
||||
residuals.bone_m,
|
||||
residuals.joint_limit_rad,
|
||||
residuals.velocity_mps,
|
||||
residuals.acceleration_mps2,
|
||||
residuals.temporal_jerk,
|
||||
residuals.floor_penetration_m,
|
||||
residuals.contact_m,
|
||||
residuals.collision_m,
|
||||
residuals.normalized_total,
|
||||
]);
|
||||
let current = raw.joints.map(|joint| joint.position_m);
|
||||
let velocity = history
|
||||
.previous
|
||||
.as_ref()
|
||||
.map_or([[0.0; 3]; 17], |(previous, ns)| {
|
||||
derivative(¤t, previous, raw.timestamp_ns.saturating_sub(*ns))
|
||||
});
|
||||
for joint in velocity {
|
||||
values.extend(joint);
|
||||
}
|
||||
let acceleration = match (&history.previous, &history.previous_previous) {
|
||||
(Some((previous, previous_ns)), Some((older, older_ns))) => {
|
||||
let previous_velocity =
|
||||
derivative(previous, older, previous_ns.saturating_sub(*older_ns));
|
||||
derivative(
|
||||
&velocity,
|
||||
&previous_velocity,
|
||||
raw.timestamp_ns.saturating_sub(*previous_ns),
|
||||
)
|
||||
}
|
||||
_ => [[0.0; 3]; 17],
|
||||
};
|
||||
for joint in acceleration {
|
||||
values.extend(joint);
|
||||
}
|
||||
values.extend(raw.joints.iter().map(|joint| {
|
||||
raw.floor_plane
|
||||
.map_or(0.0, |floor| floor.signed_distance(joint.position_m))
|
||||
}));
|
||||
values.extend(match raw.trust_state {
|
||||
wifi_densepose_core::PoseTrustState::Known => [1.0, 0.0, 0.0],
|
||||
wifi_densepose_core::PoseTrustState::Degraded => [0.0, 1.0, 0.0],
|
||||
wifi_densepose_core::PoseTrustState::Unknown => [0.0, 0.0, 1.0],
|
||||
});
|
||||
values.push(raw.observer_confidence.get());
|
||||
values.extend(rf_embedding.copied().unwrap_or([0.0; RF_EMBEDDING_WIDTH]));
|
||||
debug_assert_eq!(values.len(), FEATURE_WIDTH);
|
||||
Self { values }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn values(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded twenty-frame history used by both trainer and inference.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FeatureHistory {
|
||||
frames: VecDeque<PoseFeatureFrame>,
|
||||
}
|
||||
|
||||
impl FeatureHistory {
|
||||
pub fn push(&mut self, frame: PoseFeatureFrame) {
|
||||
if self.frames.len() == super::model::HISTORY_FRAMES {
|
||||
self.frames.pop_front();
|
||||
}
|
||||
self.frames.push_back(frame);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.frames.len() == super::model::HISTORY_FRAMES
|
||||
}
|
||||
|
||||
/// Flatten only a complete history; partial windows must abstain.
|
||||
#[must_use]
|
||||
pub fn flattened(&self) -> Option<Vec<f32>> {
|
||||
self.is_ready().then(|| {
|
||||
self.frames
|
||||
.iter()
|
||||
.flat_map(|frame| frame.values.iter().copied())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn derivative(current: &[[f32; 3]; 17], previous: &[[f32; 3]; 17], dt_ns: u64) -> [[f32; 3]; 17] {
|
||||
let dt = dt_ns as f32 / 1_000_000_000.0;
|
||||
if dt <= 0.0 {
|
||||
return [[0.0; 3]; 17];
|
||||
}
|
||||
core::array::from_fn(|joint| {
|
||||
core::array::from_fn(|axis| (current[joint][axis] - previous[joint][axis]) / dt)
|
||||
})
|
||||
}
|
||||
23
v2/crates/wifi-densepose-physics/src/learned/mod.rs
Normal file
23
v2/crates/wifi-densepose-physics/src/learned/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Optional Burn residual artifact boundary.
|
||||
//!
|
||||
//! Model training and activation remain evidence-gated. This module enforces
|
||||
//! signed/hash-addressed artifact identity and the deterministic correction cap.
|
||||
|
||||
mod artifact;
|
||||
pub mod features;
|
||||
pub mod model;
|
||||
#[cfg(feature = "learned-cpu")]
|
||||
pub mod runtime;
|
||||
pub mod training;
|
||||
|
||||
pub use artifact::{
|
||||
LearnedArtifact, LearnedArtifactError, LearnedArtifactManifest, LearnedResidual,
|
||||
SignatureVerification,
|
||||
};
|
||||
|
||||
/// Re-export the selected framework so downstream trainer crates use the exact
|
||||
/// workspace-pinned Burn version rather than introducing a second runtime.
|
||||
pub use burn_core as framework;
|
||||
#[cfg(feature = "learned-cpu")]
|
||||
pub use burn_ndarray as cpu_backend;
|
||||
pub use burn_nn as neural_network;
|
||||
93
v2/crates/wifi-densepose-physics/src/learned/model.rs
Normal file
93
v2/crates/wifi-densepose-physics/src/learned/model.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Burn GRU residual architecture. Backend selection remains a Cargo feature.
|
||||
|
||||
use burn::{
|
||||
module::Module,
|
||||
record::{FullPrecisionSettings, NamedMpkBytesRecorder, Recorder, RecorderError},
|
||||
tensor::{backend::Backend, Tensor},
|
||||
};
|
||||
use burn_core as burn;
|
||||
use burn_nn::{Gru, GruConfig, Linear, LinearConfig, Sigmoid, Tanh};
|
||||
|
||||
use super::features::FEATURE_WIDTH;
|
||||
|
||||
pub const HISTORY_FRAMES: usize = 20;
|
||||
pub const HIDDEN_WIDTH: usize = 128;
|
||||
pub const JOINT_RESIDUAL_WIDTH: usize = 17 * 3;
|
||||
|
||||
/// Multi-head outputs for bounded residuals, log variance, contact, and abstention.
|
||||
pub struct ResidualModelOutput<B: Backend> {
|
||||
pub joint_residual: Tensor<B, 2>,
|
||||
pub residual_log_variance: Tensor<B, 2>,
|
||||
pub contact_probability: Tensor<B, 2>,
|
||||
pub abstention_probability: Tensor<B, 2>,
|
||||
}
|
||||
|
||||
/// Two-layer unidirectional GRU followed by independent auditable heads.
|
||||
#[derive(Module, Debug)]
|
||||
pub struct ResidualGru<B: Backend> {
|
||||
gru1: Gru<B>,
|
||||
gru2: Gru<B>,
|
||||
residual_head: Linear<B>,
|
||||
variance_head: Linear<B>,
|
||||
contact_head: Linear<B>,
|
||||
abstention_head: Linear<B>,
|
||||
tanh: Tanh,
|
||||
sigmoid: Sigmoid,
|
||||
}
|
||||
|
||||
impl<B: Backend> ResidualGru<B> {
|
||||
/// Initialize the ADR-323 architecture on an explicit backend device.
|
||||
#[must_use]
|
||||
pub fn init(device: &B::Device) -> Self {
|
||||
Self {
|
||||
gru1: GruConfig::new(FEATURE_WIDTH, HIDDEN_WIDTH, true)
|
||||
.with_clip(Some(10.0))
|
||||
.init(device),
|
||||
gru2: GruConfig::new(HIDDEN_WIDTH, HIDDEN_WIDTH, true)
|
||||
.with_clip(Some(10.0))
|
||||
.init(device),
|
||||
residual_head: LinearConfig::new(HIDDEN_WIDTH, JOINT_RESIDUAL_WIDTH).init(device),
|
||||
variance_head: LinearConfig::new(HIDDEN_WIDTH, JOINT_RESIDUAL_WIDTH).init(device),
|
||||
contact_head: LinearConfig::new(HIDDEN_WIDTH, 2).init(device),
|
||||
abstention_head: LinearConfig::new(HIDDEN_WIDTH, 1).init(device),
|
||||
tanh: Tanh::new(),
|
||||
sigmoid: Sigmoid::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize only bytes that already passed manifest, hash, and
|
||||
/// signature-receipt verification at the artifact boundary.
|
||||
pub fn from_verified_artifact(
|
||||
artifact: &super::LearnedArtifact,
|
||||
device: &B::Device,
|
||||
) -> Result<Self, RecorderError> {
|
||||
let recorder = NamedMpkBytesRecorder::<FullPrecisionSettings>::default();
|
||||
let record = recorder.load(artifact.bytes().to_vec(), device)?;
|
||||
Ok(Self::init(device).load_record(record))
|
||||
}
|
||||
|
||||
/// Serialize a trainer-produced record in the exact activation format.
|
||||
pub fn into_artifact_bytes(self) -> Result<Vec<u8>, RecorderError> {
|
||||
NamedMpkBytesRecorder::<FullPrecisionSettings>::default().record(self.into_record(), ())
|
||||
}
|
||||
|
||||
/// Forward `[batch, 20, FEATURE_WIDTH]` to four bounded output heads.
|
||||
#[must_use]
|
||||
pub fn forward(&self, input: Tensor<B, 3>) -> ResidualModelOutput<B> {
|
||||
let hidden = self.gru2.forward(self.gru1.forward(input, None), None);
|
||||
let [batch, sequence, width] = hidden.shape().dims();
|
||||
debug_assert_eq!(sequence, HISTORY_FRAMES);
|
||||
debug_assert_eq!(width, HIDDEN_WIDTH);
|
||||
let last = hidden
|
||||
.slice([0..batch, (sequence - 1)..sequence, 0..width])
|
||||
.squeeze_dim::<2>(1);
|
||||
ResidualModelOutput {
|
||||
joint_residual: self.tanh.forward(self.residual_head.forward(last.clone())),
|
||||
residual_log_variance: self.variance_head.forward(last.clone()),
|
||||
contact_probability: self
|
||||
.sigmoid
|
||||
.forward(self.contact_head.forward(last.clone())),
|
||||
abstention_probability: self.sigmoid.forward(self.abstention_head.forward(last)),
|
||||
}
|
||||
}
|
||||
}
|
||||
88
v2/crates/wifi-densepose-physics/src/learned/runtime.rs
Normal file
88
v2/crates/wifi-densepose-physics/src/learned/runtime.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! CPU inference activation for verified Burn artifacts.
|
||||
|
||||
use burn_core::tensor::{Tensor, TensorData};
|
||||
use burn_ndarray::{NdArray, NdArrayDevice};
|
||||
|
||||
use super::{
|
||||
features::FEATURE_WIDTH,
|
||||
model::{ResidualGru, HISTORY_FRAMES},
|
||||
LearnedArtifact, LearnedResidual,
|
||||
};
|
||||
|
||||
type CpuBackend = NdArray<f32>;
|
||||
|
||||
/// Native CPU residual inference. Construction requires a verified artifact.
|
||||
pub struct CpuResidualRuntime {
|
||||
model: ResidualGru<CpuBackend>,
|
||||
device: NdArrayDevice,
|
||||
artifact_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl CpuResidualRuntime {
|
||||
/// Atomically deserialize a verified Burn record before activation.
|
||||
pub fn activate(artifact: &LearnedArtifact) -> Result<Self, burn_core::record::RecorderError> {
|
||||
let device = NdArrayDevice::default();
|
||||
let model = ResidualGru::from_verified_artifact(artifact, &device)?;
|
||||
Ok(Self {
|
||||
model,
|
||||
device,
|
||||
artifact_hash: artifact.content_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Active immutable artifact identity.
|
||||
#[must_use]
|
||||
pub const fn artifact_hash(&self) -> [u8; 32] {
|
||||
self.artifact_hash
|
||||
}
|
||||
|
||||
/// Infer one complete history. Partial, non-finite, or malformed windows
|
||||
/// abstain before entering Burn.
|
||||
#[must_use]
|
||||
pub fn predict(
|
||||
&self,
|
||||
flattened_history: &[f32],
|
||||
correction_cap_m: f32,
|
||||
) -> Option<LearnedResidual> {
|
||||
if flattened_history.len() != HISTORY_FRAMES * FEATURE_WIDTH
|
||||
|| !flattened_history.iter().all(|value| value.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let input = Tensor::<CpuBackend, 3>::from_data(
|
||||
TensorData::new(
|
||||
flattened_history.to_vec(),
|
||||
[1, HISTORY_FRAMES, FEATURE_WIDTH],
|
||||
),
|
||||
&self.device,
|
||||
);
|
||||
let output = self.model.forward(input);
|
||||
let residual = output.joint_residual.into_data().to_vec::<f32>().ok()?;
|
||||
let log_variance = output
|
||||
.residual_log_variance
|
||||
.into_data()
|
||||
.to_vec::<f32>()
|
||||
.ok()?;
|
||||
let contact = output
|
||||
.contact_probability
|
||||
.into_data()
|
||||
.to_vec::<f32>()
|
||||
.ok()?;
|
||||
let abstention = output
|
||||
.abstention_probability
|
||||
.into_data()
|
||||
.to_vec::<f32>()
|
||||
.ok()?;
|
||||
let candidate = LearnedResidual {
|
||||
joints_m: core::array::from_fn(|joint| {
|
||||
core::array::from_fn(|axis| residual[joint * 3 + axis] * correction_cap_m)
|
||||
}),
|
||||
log_variance: core::array::from_fn(|joint| {
|
||||
core::array::from_fn(|axis| log_variance[joint * 3 + axis])
|
||||
}),
|
||||
contact_probability: [contact[0], contact[1]],
|
||||
abstention_probability: abstention[0],
|
||||
};
|
||||
candidate.bounded(correction_cap_m)
|
||||
}
|
||||
}
|
||||
38
v2/crates/wifi-densepose-physics/src/learned/training.rs
Normal file
38
v2/crates/wifi-densepose-physics/src/learned/training.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! Auditable supervised residual objective shared by trainers and reports.
|
||||
|
||||
/// Unweighted components reported for every training/evaluation batch.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct LossComponents {
|
||||
pub uncertainty_weighted_mpjpe: f32,
|
||||
pub bone: f32,
|
||||
pub temporal_jerk: f32,
|
||||
pub contact: f32,
|
||||
pub uncertainty_calibration: f32,
|
||||
pub intervention: f32,
|
||||
}
|
||||
|
||||
impl LossComponents {
|
||||
/// ADR-323 supervised objective. Non-finite components are rejected.
|
||||
#[must_use]
|
||||
pub fn total(self) -> Option<f32> {
|
||||
let values = [
|
||||
self.uncertainty_weighted_mpjpe,
|
||||
self.bone,
|
||||
self.temporal_jerk,
|
||||
self.contact,
|
||||
self.uncertainty_calibration,
|
||||
self.intervention,
|
||||
];
|
||||
values
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && *value >= 0.0)
|
||||
.then_some({
|
||||
self.uncertainty_weighted_mpjpe
|
||||
+ 0.20 * self.bone
|
||||
+ 0.10 * self.temporal_jerk
|
||||
+ 0.10 * self.contact
|
||||
+ 0.10 * self.uncertainty_calibration
|
||||
+ 0.05 * self.intervention
|
||||
})
|
||||
}
|
||||
}
|
||||
25
v2/crates/wifi-densepose-physics/src/lib.rs
Normal file
25
v2/crates/wifi-densepose-physics/src/lib.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Native Rust physics-constrained pose assessment (ADR-323).
|
||||
//!
|
||||
//! The default feature contains only deterministic kinematic code. Optional
|
||||
//! dynamics and learned backends cannot override hard validation, correction,
|
||||
//! provenance, or confidence invariants.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![allow(missing_docs)]
|
||||
|
||||
pub mod config;
|
||||
pub mod constraints;
|
||||
#[cfg(feature = "dynamics")]
|
||||
pub mod dynamics;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
#[cfg(feature = "learned")]
|
||||
pub mod learned;
|
||||
pub mod metrics;
|
||||
pub mod projector;
|
||||
pub mod skeleton;
|
||||
pub mod uncertainty;
|
||||
|
||||
pub use config::PhysicsConfig;
|
||||
pub use engine::{CorrectionAuthorization, PhysicsEngine, VerifiedFrameContext};
|
||||
pub use error::PhysicsError;
|
||||
154
v2/crates/wifi-densepose-physics/src/metrics.rs
Normal file
154
v2/crates/wifi-densepose-physics/src/metrics.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Privacy-safe metric labels and bounded in-process aggregation.
|
||||
|
||||
use std::{collections::BTreeMap, fmt::Write as _, sync::Mutex};
|
||||
|
||||
use wifi_densepose_core::{PoseRefinementV1, RefinementDisposition};
|
||||
|
||||
/// Fixed allowlisted scalars. It deliberately contains no joint, room, or ID data.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MetricSnapshot {
|
||||
pub latency_seconds: f64,
|
||||
pub solver_iterations: u8,
|
||||
pub max_correction_meters: f32,
|
||||
pub bone_residual: f32,
|
||||
pub floor_penetration_meters: f32,
|
||||
pub temporal_jerk: f32,
|
||||
pub confidence_delta: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aggregate {
|
||||
frames: BTreeMap<(String, String, String), u64>,
|
||||
total_latency_seconds: f64,
|
||||
observations: u64,
|
||||
deadline_exceeded: u64,
|
||||
invalid_inputs: u64,
|
||||
track_resets: BTreeMap<String, u64>,
|
||||
dropped: u64,
|
||||
latest: Option<MetricSnapshot>,
|
||||
}
|
||||
|
||||
/// Thread-safe, bounded metrics registry with no person, joint, or room labels.
|
||||
#[derive(Default)]
|
||||
pub struct PhysicsMetrics {
|
||||
aggregate: Mutex<Aggregate>,
|
||||
}
|
||||
|
||||
impl PhysicsMetrics {
|
||||
/// Record one returned result. The label cardinality is bounded by enums.
|
||||
pub fn observe(&self, result: &PoseRefinementV1, observer_confidence: f32) {
|
||||
let snapshot = MetricSnapshot::from_result(result, observer_confidence);
|
||||
let mut aggregate = self
|
||||
.aggregate
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let key = (
|
||||
enum_label(result.mode),
|
||||
enum_label(result.disposition),
|
||||
result.reason.map_or_else(|| "none".into(), enum_label),
|
||||
);
|
||||
*aggregate.frames.entry(key).or_default() += 1;
|
||||
aggregate.total_latency_seconds += snapshot.latency_seconds;
|
||||
aggregate.observations += 1;
|
||||
if result.reason == Some(wifi_densepose_core::AbstentionReason::DeadlineExceeded) {
|
||||
aggregate.deadline_exceeded += 1;
|
||||
}
|
||||
if result.disposition == RefinementDisposition::Rejected {
|
||||
aggregate.invalid_inputs += 1;
|
||||
}
|
||||
aggregate.latest = Some(snapshot);
|
||||
}
|
||||
|
||||
/// Record a bounded internal track reset reason.
|
||||
pub fn track_reset(&self, reason: &'static str) {
|
||||
let mut aggregate = self
|
||||
.aggregate
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*aggregate.track_resets.entry(reason.into()).or_default() += 1;
|
||||
}
|
||||
|
||||
/// Record discarded intermediate refinement work under backpressure.
|
||||
pub fn dropped_intermediate(&self) {
|
||||
let mut aggregate = self
|
||||
.aggregate
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
aggregate.dropped += 1;
|
||||
}
|
||||
|
||||
/// Encode the fixed metric allowlist in Prometheus text format.
|
||||
#[must_use]
|
||||
pub fn encode_prometheus(&self) -> String {
|
||||
let aggregate = self
|
||||
.aggregate
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut output = String::with_capacity(2048);
|
||||
for ((mode, disposition, reason), count) in &aggregate.frames {
|
||||
writeln!(
|
||||
output,
|
||||
"ruview_pose_physics_frames_total{{mode=\"{mode}\",disposition=\"{disposition}\",reason=\"{reason}\"}} {count}"
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
let average_latency = if aggregate.observations == 0 {
|
||||
0.0
|
||||
} else {
|
||||
aggregate.total_latency_seconds / aggregate.observations as f64
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"ruview_pose_physics_latency_seconds{{stage=\"total\",target=\"local\"}} {average_latency}"
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
if let Some(latest) = aggregate.latest {
|
||||
write!(
|
||||
output,
|
||||
"ruview_pose_physics_solver_iterations{{engine=\"kinematic-pbd\"}} {}\nruview_pose_physics_max_correction_meters {}\nruview_pose_physics_bone_residual {}\nruview_pose_physics_floor_penetration_meters {}\nruview_pose_physics_temporal_jerk {}\nruview_pose_physics_confidence_delta {}\nruview_pose_physics_raw_refined_divergence_meters {}\n",
|
||||
latest.solver_iterations,
|
||||
latest.max_correction_meters,
|
||||
latest.bone_residual,
|
||||
latest.floor_penetration_meters,
|
||||
latest.temporal_jerk,
|
||||
latest.confidence_delta,
|
||||
latest.max_correction_meters,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
for (reason, count) in &aggregate.track_resets {
|
||||
writeln!(
|
||||
output,
|
||||
"ruview_pose_physics_track_resets_total{{reason=\"{reason}\"}} {count}"
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
}
|
||||
write!(
|
||||
output,
|
||||
"ruview_pose_physics_invalid_input_total{{reason=\"contract\"}} {}\nruview_pose_physics_deadline_exceeded_total{{stage=\"total\"}} {}\nruview_pose_physics_dropped_intermediate_total {}\n",
|
||||
aggregate.invalid_inputs, aggregate.deadline_exceeded, aggregate.dropped,
|
||||
)
|
||||
.expect("writing to String cannot fail");
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_label(value: impl core::fmt::Debug) -> String {
|
||||
format!("{value:?}").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
impl MetricSnapshot {
|
||||
/// Extract only approved aggregate scalars.
|
||||
#[must_use]
|
||||
pub fn from_result(result: &PoseRefinementV1, observer_confidence: f32) -> Self {
|
||||
Self {
|
||||
latency_seconds: result.intervention.elapsed_us as f64 / 1_000_000.0,
|
||||
solver_iterations: result.intervention.solver_iterations,
|
||||
max_correction_meters: result.intervention.max_joint_correction_m,
|
||||
bone_residual: result.residuals.bone_m,
|
||||
floor_penetration_meters: result.residuals.floor_penetration_m,
|
||||
temporal_jerk: result.residuals.temporal_jerk,
|
||||
confidence_delta: result.effective_confidence.get() - observer_confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
5
v2/crates/wifi-densepose-physics/src/projector/mod.rs
Normal file
5
v2/crates/wifi-densepose-physics/src/projector/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Bounded position-based projection.
|
||||
|
||||
mod pbd;
|
||||
|
||||
pub use pbd::{project, Projection};
|
||||
213
v2/crates/wifi-densepose-physics/src/projector/pbd.rs
Normal file
213
v2/crates/wifi-densepose-physics/src/projector/pbd.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Covariance-weighted deterministic position-based dynamics projector.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nalgebra::Vector3;
|
||||
use wifi_densepose_core::{FloorPlane, InterventionSummary};
|
||||
|
||||
use crate::{
|
||||
config::PhysicsConfig,
|
||||
constraints::{bone_residual, project_floor, project_joint_limits, TemporalHistory},
|
||||
skeleton::{
|
||||
distance, virtual_joints, SkeletonPosterior, OBSERVED_EDGES, VIRTUAL_NECK_EDGE,
|
||||
VIRTUAL_TRUNK_EDGE,
|
||||
},
|
||||
};
|
||||
|
||||
/// Successful bounded candidate.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Projection {
|
||||
pub joints: [[f32; 3]; 17],
|
||||
pub intervention: InterventionSummary,
|
||||
}
|
||||
|
||||
/// Project distances, temporal motion, and floor penetration within a deadline.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn project(
|
||||
observed: &[[f32; 3]; 17],
|
||||
weights: &[f32; 17],
|
||||
posterior: &SkeletonPosterior,
|
||||
history: &TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
floor: FloorPlane,
|
||||
config: &PhysicsConfig,
|
||||
started: Instant,
|
||||
) -> Result<Projection, wifi_densepose_core::AbstentionReason> {
|
||||
let mut candidate = *observed;
|
||||
let deadline = Duration::from_millis(config.deadline_ms);
|
||||
let mut iterations = 0u8;
|
||||
let mut previous_residual = f32::INFINITY;
|
||||
|
||||
for iteration in 0..config.max_iterations {
|
||||
if started.elapsed() >= deadline {
|
||||
return Err(wifi_densepose_core::AbstentionReason::DeadlineExceeded);
|
||||
}
|
||||
for (edge, (parent, child)) in OBSERVED_EDGES.iter().copied().enumerate() {
|
||||
let delta = Vector3::from(candidate[child]) - Vector3::from(candidate[parent]);
|
||||
let length = delta.norm();
|
||||
if length <= 1.0e-6 {
|
||||
continue;
|
||||
}
|
||||
let error = length - posterior.target(edge, length);
|
||||
let direction = delta / length;
|
||||
let parent_weight = weights[parent];
|
||||
let child_weight = weights[child];
|
||||
let total_weight = (parent_weight + child_weight).max(1.0e-6);
|
||||
let parent_move = direction * error * parent_weight / total_weight;
|
||||
let child_move = direction * error * child_weight / total_weight;
|
||||
candidate[parent] = (Vector3::from(candidate[parent]) + parent_move).into();
|
||||
candidate[child] = (Vector3::from(candidate[child]) - child_move).into();
|
||||
}
|
||||
project_virtual_edges(&mut candidate, weights, posterior);
|
||||
project_joint_limits(&mut candidate, weights);
|
||||
project_temporal(&mut candidate, history, timestamp_ns, config);
|
||||
project_floor(&mut candidate, floor, weights);
|
||||
iterations = iteration + 1;
|
||||
let residual = bone_residual(&candidate, posterior);
|
||||
if (previous_residual - residual).abs() < config.solver_epsilon_m {
|
||||
break;
|
||||
}
|
||||
previous_residual = residual;
|
||||
}
|
||||
|
||||
let mut max_correction = 0.0f32;
|
||||
let mut corrected = 0u8;
|
||||
for (before, after) in observed.iter().zip(candidate.iter()) {
|
||||
let correction = distance(*before, *after);
|
||||
max_correction = max_correction.max(correction);
|
||||
corrected += u8::from(correction > config.solver_epsilon_m);
|
||||
}
|
||||
let root_before = midpoint(observed[11], observed[12]);
|
||||
let root_after = midpoint(candidate[11], candidate[12]);
|
||||
let root_correction = distance(root_before, root_after);
|
||||
if max_correction > config.max_joint_correction_m
|
||||
|| root_correction > config.max_root_correction_m
|
||||
{
|
||||
return Err(wifi_densepose_core::AbstentionReason::CorrectionTooLarge);
|
||||
}
|
||||
Ok(Projection {
|
||||
joints: candidate,
|
||||
intervention: InterventionSummary {
|
||||
max_joint_correction_m: max_correction,
|
||||
root_correction_m: root_correction,
|
||||
corrected_joint_count: corrected,
|
||||
solver_iterations: iterations,
|
||||
elapsed_us: elapsed_us(started),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn project_virtual_edges(
|
||||
joints: &mut [[f32; 3]; 17],
|
||||
weights: &[f32; 17],
|
||||
posterior: &SkeletonPosterior,
|
||||
) {
|
||||
let (pelvis, thorax) = virtual_joints(joints);
|
||||
project_group_edge(
|
||||
joints,
|
||||
weights,
|
||||
&[11, 12],
|
||||
&[5, 6],
|
||||
pelvis,
|
||||
thorax,
|
||||
posterior.target(VIRTUAL_TRUNK_EDGE, distance(pelvis, thorax)),
|
||||
);
|
||||
let (_, thorax) = virtual_joints(joints);
|
||||
let nose = joints[0];
|
||||
project_group_edge(
|
||||
joints,
|
||||
weights,
|
||||
&[5, 6],
|
||||
&[0],
|
||||
thorax,
|
||||
nose,
|
||||
posterior.target(VIRTUAL_NECK_EDGE, distance(thorax, nose)),
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn project_group_edge(
|
||||
joints: &mut [[f32; 3]; 17],
|
||||
weights: &[f32; 17],
|
||||
parent_indices: &[usize],
|
||||
child_indices: &[usize],
|
||||
parent_point: [f32; 3],
|
||||
child_point: [f32; 3],
|
||||
target: f32,
|
||||
) {
|
||||
let delta = Vector3::from(child_point) - Vector3::from(parent_point);
|
||||
let length = delta.norm();
|
||||
if length <= 1.0e-6 {
|
||||
return;
|
||||
}
|
||||
let error = length - target;
|
||||
let direction = delta / length;
|
||||
let parent_weight =
|
||||
parent_indices.iter().map(|&i| weights[i]).sum::<f32>() / parent_indices.len() as f32;
|
||||
let child_weight =
|
||||
child_indices.iter().map(|&i| weights[i]).sum::<f32>() / child_indices.len() as f32;
|
||||
let total = (parent_weight + child_weight).max(1.0e-6);
|
||||
let parent_move = direction * error * parent_weight / total;
|
||||
let child_move = direction * error * child_weight / total;
|
||||
for &index in parent_indices {
|
||||
joints[index] = (Vector3::from(joints[index]) + parent_move).into();
|
||||
}
|
||||
for &index in child_indices {
|
||||
joints[index] = (Vector3::from(joints[index]) - child_move).into();
|
||||
}
|
||||
}
|
||||
|
||||
fn project_temporal(
|
||||
candidate: &mut [[f32; 3]; 17],
|
||||
history: &TemporalHistory,
|
||||
timestamp_ns: u64,
|
||||
config: &PhysicsConfig,
|
||||
) {
|
||||
let Some((previous, previous_ns)) = &history.previous else {
|
||||
return;
|
||||
};
|
||||
let dt = timestamp_ns.saturating_sub(*previous_ns) as f32 / 1_000_000_000.0;
|
||||
if dt <= 0.0 || dt * 1000.0 > config.max_frame_gap_ms as f32 {
|
||||
return;
|
||||
}
|
||||
for (index, (joint, prior)) in candidate.iter_mut().zip(previous).enumerate() {
|
||||
let mut velocity = (Vector3::from(*joint) - Vector3::from(*prior)) / dt;
|
||||
let speed = velocity.norm();
|
||||
if speed > config.max_velocity_mps {
|
||||
velocity *= config.max_velocity_mps / speed;
|
||||
}
|
||||
if let Some((older, older_ns)) = &history.previous_previous {
|
||||
let previous_dt = previous_ns.saturating_sub(*older_ns) as f32 / 1_000_000_000.0;
|
||||
if previous_dt > 0.0 {
|
||||
let previous_velocity =
|
||||
(Vector3::from(previous[index]) - Vector3::from(older[index])) / previous_dt;
|
||||
let delta_velocity = velocity - previous_velocity;
|
||||
let maximum_delta = config.max_acceleration_mps2 * dt;
|
||||
if delta_velocity.norm() > maximum_delta {
|
||||
velocity = previous_velocity + delta_velocity.normalize() * maximum_delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
*joint = (Vector3::from(*prior) + velocity * dt).into();
|
||||
}
|
||||
}
|
||||
|
||||
fn midpoint(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
(a[0] + b[0]) * 0.5,
|
||||
(a[1] + b[1]) * 0.5,
|
||||
(a[2] + b[2]) * 0.5,
|
||||
]
|
||||
}
|
||||
|
||||
fn elapsed_us(started: Instant) -> u64 {
|
||||
#[cfg(feature = "deterministic")]
|
||||
{
|
||||
let _ = started;
|
||||
0
|
||||
}
|
||||
#[cfg(not(feature = "deterministic"))]
|
||||
{
|
||||
started.elapsed().as_micros().try_into().unwrap_or(u64::MAX)
|
||||
}
|
||||
}
|
||||
138
v2/crates/wifi-densepose-physics/src/skeleton.rs
Normal file
138
v2/crates/wifi-densepose-physics/src/skeleton.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! COCO-17 skeleton topology and anonymous track-scoped bone posteriors.
|
||||
|
||||
use nalgebra::Vector3;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Observed parent-child constraints. Virtual pelvis/thorax constraints are
|
||||
/// evaluated separately so virtual joints can never be mislabeled observed.
|
||||
pub const OBSERVED_EDGES: [(usize, usize); 14] = [
|
||||
(11, 12),
|
||||
(5, 6),
|
||||
(11, 13),
|
||||
(13, 15),
|
||||
(12, 14),
|
||||
(14, 16),
|
||||
(5, 7),
|
||||
(7, 9),
|
||||
(6, 8),
|
||||
(8, 10),
|
||||
(0, 1),
|
||||
(1, 3),
|
||||
(0, 2),
|
||||
(2, 4),
|
||||
];
|
||||
|
||||
/// Fourteen observed edges plus pelvis↔thorax and thorax↔nose virtual edges.
|
||||
pub const CONSTRAINED_EDGE_COUNT: usize = OBSERVED_EDGES.len() + 2;
|
||||
/// Posterior index for the derived pelvis↔thorax edge.
|
||||
pub const VIRTUAL_TRUNK_EDGE: usize = OBSERVED_EDGES.len();
|
||||
/// Posterior index for the derived thorax↔nose edge.
|
||||
pub const VIRTUAL_NECK_EDGE: usize = OBSERVED_EDGES.len() + 1;
|
||||
|
||||
/// Anonymous, memory-only per-track length estimate.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkeletonPosterior {
|
||||
samples_m: [SmallVec<[f32; 9]>; CONSTRAINED_EDGE_COUNT],
|
||||
}
|
||||
|
||||
impl Default for SkeletonPosterior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
samples_m: core::array::from_fn(|_| SmallVec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SkeletonPosterior {
|
||||
/// Update only edges whose endpoints pass the high-confidence gate.
|
||||
pub fn observe(&mut self, joints: &[[f32; 3]; 17], confidence: &[f32; 17], threshold: f32) {
|
||||
for (index, (a, b)) in OBSERVED_EDGES.iter().copied().enumerate() {
|
||||
if confidence[a] < threshold || confidence[b] < threshold {
|
||||
continue;
|
||||
}
|
||||
let length = distance(joints[a], joints[b]);
|
||||
if !length.is_finite() || !(0.01..=2.5).contains(&length) {
|
||||
continue;
|
||||
}
|
||||
self.observe_sample(index, length);
|
||||
}
|
||||
let (pelvis, thorax) = virtual_joints(joints);
|
||||
let trunk_confidence = confidence[11]
|
||||
.min(confidence[12])
|
||||
.min(confidence[5])
|
||||
.min(confidence[6]);
|
||||
if trunk_confidence >= threshold {
|
||||
self.observe_sample(VIRTUAL_TRUNK_EDGE, distance(pelvis, thorax));
|
||||
}
|
||||
let neck_confidence = confidence[5].min(confidence[6]).min(confidence[0]);
|
||||
if neck_confidence >= threshold {
|
||||
self.observe_sample(VIRTUAL_NECK_EDGE, distance(thorax, joints[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the posterior target, or the current length before calibration.
|
||||
#[must_use]
|
||||
pub fn target(&self, edge: usize, current: f32) -> f32 {
|
||||
let samples = &self.samples_m[edge];
|
||||
if samples.len() < 3 {
|
||||
current
|
||||
} else {
|
||||
median(samples)
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_sample(&mut self, edge: usize, length: f32) {
|
||||
if !length.is_finite() || !(0.01..=2.5).contains(&length) {
|
||||
return;
|
||||
}
|
||||
let samples = &mut self.samples_m[edge];
|
||||
if samples.len() >= 3 {
|
||||
let center = median(samples);
|
||||
let mut deviations = SmallVec::<[f32; 9]>::new();
|
||||
deviations.extend(samples.iter().map(|sample| (sample - center).abs()));
|
||||
let mad = median(&deviations);
|
||||
let tolerance = (center * 0.15).max(mad * 4.0).max(0.02);
|
||||
if (length - center).abs() > tolerance {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if samples.len() == 9 {
|
||||
samples.remove(0);
|
||||
}
|
||||
samples.push(length);
|
||||
}
|
||||
}
|
||||
|
||||
fn median(samples: &[f32]) -> f32 {
|
||||
let mut sorted = SmallVec::<[f32; 9]>::from_slice(samples);
|
||||
sorted.sort_unstable_by(f32::total_cmp);
|
||||
let middle = sorted.len() / 2;
|
||||
if sorted.len() % 2 == 0 {
|
||||
(sorted[middle - 1] + sorted[middle]) * 0.5
|
||||
} else {
|
||||
sorted[middle]
|
||||
}
|
||||
}
|
||||
|
||||
/// Euclidean distance between points.
|
||||
#[must_use]
|
||||
pub fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
(Vector3::from(b) - Vector3::from(a)).norm()
|
||||
}
|
||||
|
||||
/// Derived pelvis and thorax, never exposed as observations.
|
||||
#[must_use]
|
||||
pub fn virtual_joints(joints: &[[f32; 3]; 17]) -> ([f32; 3], [f32; 3]) {
|
||||
(
|
||||
midpoint(joints[11], joints[12]),
|
||||
midpoint(joints[5], joints[6]),
|
||||
)
|
||||
}
|
||||
|
||||
fn midpoint(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
(a[0] + b[0]) * 0.5,
|
||||
(a[1] + b[1]) * 0.5,
|
||||
(a[2] + b[2]) * 0.5,
|
||||
]
|
||||
}
|
||||
9
v2/crates/wifi-densepose-physics/src/uncertainty.rs
Normal file
9
v2/crates/wifi-densepose-physics/src/uncertainty.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Covariance-derived movement weights.
|
||||
|
||||
use wifi_densepose_core::SymmetricCovariance3;
|
||||
|
||||
/// Larger uncertainty permits more motion, bounded away from zero and one.
|
||||
#[must_use]
|
||||
pub fn movement_weight(covariance: SymmetricCovariance3) -> f32 {
|
||||
(covariance.trace() / 0.03).clamp(0.05, 0.95)
|
||||
}
|
||||
77
v2/crates/wifi-densepose-physics/tests/common/mod.rs
Normal file
77
v2/crates/wifi-densepose-physics/tests/common/mod.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, FloorPlane, JointObservation, JointVisibility, ModelRef,
|
||||
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
|
||||
SpatialFrameRef, SymmetricCovariance3, TrackId,
|
||||
};
|
||||
|
||||
pub fn observation(sequence: u64) -> PoseObservationV2 {
|
||||
let positions = [
|
||||
[0.0, 0.0, 1.70],
|
||||
[-0.03, 0.0, 1.73],
|
||||
[0.03, 0.0, 1.73],
|
||||
[-0.08, 0.0, 1.71],
|
||||
[0.08, 0.0, 1.71],
|
||||
[-0.20, 0.0, 1.45],
|
||||
[0.20, 0.0, 1.45],
|
||||
[-0.35, 0.0, 1.15],
|
||||
[0.35, 0.0, 1.15],
|
||||
[-0.45, 0.0, 0.90],
|
||||
[0.45, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.90],
|
||||
[0.14, 0.0, 0.90],
|
||||
[-0.14, 0.0, 0.48],
|
||||
[0.14, 0.0, 0.48],
|
||||
[-0.14, 0.0, 0.04],
|
||||
[0.14, 0.0, 0.04],
|
||||
];
|
||||
let joints = core::array::from_fn(|index| JointObservation {
|
||||
kind: Coco17Joint::ALL[index],
|
||||
position_m: positions[index],
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.001,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.001,
|
||||
yz: 0.0,
|
||||
zz: 0.001,
|
||||
},
|
||||
confidence: Probability::new(0.8).unwrap(),
|
||||
visibility: JointVisibility::Visible,
|
||||
});
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns: 1_000_000_000 + sequence * 33_000_000,
|
||||
sensor_epoch: 7,
|
||||
sequence,
|
||||
track_id: TrackId("local:7".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "room:lab".into(),
|
||||
version: 1,
|
||||
metric: true,
|
||||
right_handed: true,
|
||||
z_up: true,
|
||||
},
|
||||
calibration_id: CalibrationId("cal:1".into()),
|
||||
floor_plane: Some(FloorPlane {
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
offset_m: 0.0,
|
||||
}),
|
||||
model: ModelRef {
|
||||
id: "pose:test".into(),
|
||||
artifact_hash: [3; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "sensor:1".into(),
|
||||
authenticated: true,
|
||||
replay_protected: true,
|
||||
},
|
||||
trust_state: PoseTrustState::Known,
|
||||
dimensionality: PoseDimensionality::Metric3d,
|
||||
uncertainty_calibrated: true,
|
||||
joints,
|
||||
observer_confidence: Probability::new(0.78).unwrap(),
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
raw
|
||||
}
|
||||
169
v2/crates/wifi-densepose-physics/tests/contracts.rs
Normal file
169
v2/crates/wifi-densepose-physics/tests/contracts.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
#![allow(missing_docs)]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::{AbstentionReason, PhysicsMode, RefinementDisposition};
|
||||
use wifi_densepose_physics::{
|
||||
CorrectionAuthorization, PhysicsConfig, PhysicsEngine, VerifiedFrameContext,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn raw_hash_is_preserved_and_confidence_never_increases() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.raw_observation_hash, raw.canonical_hash);
|
||||
assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
|
||||
assert!(!result.selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_off_is_identity_and_clears_selection() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::Off,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.disposition, RefinementDisposition::Bypassed);
|
||||
assert_eq!(result.reason, Some(AbstentionReason::ModeOff));
|
||||
assert!(result.refined_joints_m.is_none());
|
||||
assert_eq!(result.effective_confidence, raw.observer_confidence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_source_cannot_select_correction() {
|
||||
let mut raw = observation(1);
|
||||
raw.source.authenticated = false;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::OptInCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
engine
|
||||
.authorize_correction(
|
||||
CorrectionAuthorization::from_verified_evidence(engine.config_hash(), [7; 32]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.reason, Some(AbstentionReason::SourceUnauthenticated));
|
||||
assert!(!result.selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_mode_requires_evidence_authorization() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::OptInCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(
|
||||
result.reason,
|
||||
Some(AbstentionReason::CorrectionNotAuthorized)
|
||||
);
|
||||
assert!(!result.selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_frame_and_release_receipts_allow_bounded_selection() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::OptInCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
engine
|
||||
.authorize_correction(
|
||||
CorrectionAuthorization::from_verified_evidence(engine.config_hash(), [7; 32]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let receipt = VerifiedFrameContext::from_verified_envelope(
|
||||
raw.canonical_hash,
|
||||
raw.sensor_epoch,
|
||||
raw.sequence,
|
||||
raw.source.sensor_id.clone(),
|
||||
raw.calibration_id.0.clone(),
|
||||
[8; 32],
|
||||
)
|
||||
.unwrap();
|
||||
let result = engine.process_verified(&raw, raw.timestamp_ns, &receipt);
|
||||
assert!(result.selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_reports_raw_residuals_without_running_projector() {
|
||||
let mut raw = observation(1);
|
||||
raw.joints[15].position_m[2] = -0.04;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.disposition, RefinementDisposition::Audited);
|
||||
assert!(result.residuals.floor_penetration_m >= 0.039);
|
||||
assert_eq!(result.intervention.solver_iterations, 0);
|
||||
assert!(result.refined_residuals.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_hash_is_canonical_and_detects_changes() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.compute_canonical_hash(), result.canonical_hash);
|
||||
let mut changed = result.clone();
|
||||
changed.effective_confidence = wifi_densepose_core::Probability::ZERO;
|
||||
assert_ne!(changed.compute_canonical_hash(), result.canonical_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correction_mode_transition_is_atomic_and_evidence_bound() {
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
assert!(engine.set_mode(PhysicsMode::DefaultCorrect).is_err());
|
||||
assert_eq!(engine.config().mode, PhysicsMode::Audit);
|
||||
let target_hash = engine.config_hash_for_mode(PhysicsMode::OptInCorrect);
|
||||
engine
|
||||
.authorize_correction(
|
||||
CorrectionAuthorization::from_verified_evidence(target_hash, [9; 32]).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
engine.set_mode(PhysicsMode::OptInCorrect).unwrap();
|
||||
assert_eq!(engine.config().mode, PhysicsMode::OptInCorrect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_is_idempotent_but_different_content_is_replay_rejected() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let first = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(first, engine.process(&raw, raw.timestamp_ns));
|
||||
let mut tampered = raw.clone();
|
||||
tampered.joints[0].position_m[0] += 0.01;
|
||||
tampered.seal();
|
||||
assert_eq!(
|
||||
engine.process(&tampered, tampered.timestamp_ns).reason,
|
||||
Some(AbstentionReason::ReplayRejected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_change_discards_track_temporal_state() {
|
||||
let first = observation(1);
|
||||
let mut second = observation(2);
|
||||
for joint in &mut second.joints {
|
||||
joint.position_m[0] += 0.20;
|
||||
}
|
||||
second.calibration_id.0 = "cal:2".into();
|
||||
second.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let _ = engine.process(&first, first.timestamp_ns);
|
||||
let result = engine.process(&second, second.timestamp_ns);
|
||||
assert!(result.residuals.velocity_mps.abs() <= f32::EPSILON);
|
||||
assert!(result.residuals.acceleration_mps2.abs() <= f32::EPSILON);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#![allow(missing_docs)]
|
||||
#![cfg(feature = "deterministic")]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::PhysicsMode;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn identical_replay_has_identical_canonical_json() {
|
||||
let raw = observation(1);
|
||||
let config = PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
};
|
||||
let a = PhysicsEngine::new(config.clone())
|
||||
.unwrap()
|
||||
.process(&raw, raw.timestamp_ns);
|
||||
let b = PhysicsEngine::new(config)
|
||||
.unwrap()
|
||||
.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&a).unwrap(),
|
||||
serde_json::to_vec(&b).unwrap()
|
||||
);
|
||||
}
|
||||
56
v2/crates/wifi-densepose-physics/tests/dynamics.rs
Normal file
56
v2/crates/wifi-densepose-physics/tests/dynamics.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
#![allow(missing_docs)]
|
||||
#![cfg(feature = "dynamics")]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::PhysicsMode;
|
||||
use wifi_densepose_physics::dynamics::DynamicsAuditor;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn articulated_audit_is_bounded_and_finite() {
|
||||
let raw = observation(1);
|
||||
let joints = raw.joints.map(|joint| joint.position_m);
|
||||
let mut auditor = DynamicsAuditor::new(&joints, raw.floor_plane.unwrap());
|
||||
let assessment = auditor.audit(&joints, 1.0 / 30.0, 2);
|
||||
assert!(assessment.stable);
|
||||
assert_eq!(assessment.segment_count, 14);
|
||||
assert!(assessment.joint_count >= 10);
|
||||
assert_eq!(assessment.substeps, 2);
|
||||
assert!(assessment.max_tracking_error_m.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prone_pose_remains_a_valid_dynamics_input() {
|
||||
let raw = observation(1);
|
||||
let mut joints = raw.joints.map(|joint| joint.position_m);
|
||||
for joint in &mut joints {
|
||||
joint[2] = 0.05;
|
||||
}
|
||||
let mut auditor = DynamicsAuditor::new(&joints, raw.floor_plane.unwrap());
|
||||
let assessment = auditor.audit(&joints, 1.0 / 30.0, 1);
|
||||
assert!(assessment.stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamics_assessment_is_attached_but_never_selects_shadow_output() {
|
||||
let raw = observation(1);
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
deadline_ms: 50,
|
||||
dynamics_audit: true,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert!(result.dynamics.is_some());
|
||||
assert!(!result.selected);
|
||||
assert_eq!(result.provenance.engine, "kinematic-pbd+rapier-audit");
|
||||
|
||||
let mut next = observation(2);
|
||||
next.joints[9].position_m[0] += 0.01;
|
||||
next.seal();
|
||||
let next_result = engine.process(&next, next.timestamp_ns);
|
||||
assert!(next_result.dynamics.is_some());
|
||||
assert!(!next_result.selected);
|
||||
}
|
||||
109
v2/crates/wifi-densepose-physics/tests/floor_and_fall.rs
Normal file
109
v2/crates/wifi-densepose-physics/tests/floor_and_fall.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
#![allow(missing_docs)]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::{PhysicsMode, PoseDimensionality, RefinementDisposition};
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn image_2d_is_audited_but_never_corrected() {
|
||||
let mut raw = observation(1);
|
||||
raw.dimensionality = PoseDimensionality::Image2d;
|
||||
raw.frame.metric = false;
|
||||
raw.floor_plane = None;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::OptInCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
assert_eq!(result.disposition, RefinementDisposition::Audited2d);
|
||||
assert!(!result.selected && result.refined_joints_m.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_pixels_use_a_separate_bounded_coordinate_domain() {
|
||||
let mut raw = observation(1);
|
||||
raw.dimensionality = PoseDimensionality::Image2d;
|
||||
raw.frame.metric = false;
|
||||
raw.frame.right_handed = false;
|
||||
raw.frame.z_up = false;
|
||||
raw.floor_plane = None;
|
||||
raw.joints[0].position_m = [640.0, 480.0, 0.0];
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
assert_eq!(
|
||||
engine.process(&raw, raw.timestamp_ns).disposition,
|
||||
RefinementDisposition::Audited2d
|
||||
);
|
||||
|
||||
raw.sequence += 1;
|
||||
raw.timestamp_ns += 33_000_000;
|
||||
raw.joints[0].position_m[0] = 20_000.0;
|
||||
raw.seal();
|
||||
assert_eq!(
|
||||
engine.process(&raw, raw.timestamp_ns).reason,
|
||||
Some(wifi_densepose_core::AbstentionReason::InvalidNumber)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prone_pose_is_not_forced_upright() {
|
||||
let mut raw = observation(1);
|
||||
for joint in &mut raw.joints {
|
||||
joint.position_m[2] = 0.05;
|
||||
}
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
let refined = result.refined_joints_m.unwrap();
|
||||
assert!(refined.iter().all(|joint| joint[2] < 0.20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_2d_audit_tracks_temporal_motion() {
|
||||
let mut first = observation(1);
|
||||
first.dimensionality = PoseDimensionality::Image2d;
|
||||
first.frame.metric = false;
|
||||
first.floor_plane = None;
|
||||
first.seal();
|
||||
let mut second = first.clone();
|
||||
second.sequence = 2;
|
||||
second.timestamp_ns += 33_000_000;
|
||||
second.joints[9].position_m[0] += 0.5;
|
||||
second.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let _ = engine.process(&first, first.timestamp_ns);
|
||||
let result = engine.process(&second, second.timestamp_ns);
|
||||
assert!(result.residuals.velocity_mps > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_2d_rejects_non_monotonic_sequence_and_time() {
|
||||
let mut first = observation(2);
|
||||
first.dimensionality = PoseDimensionality::Image2d;
|
||||
first.frame.metric = false;
|
||||
first.frame.right_handed = false;
|
||||
first.frame.z_up = false;
|
||||
first.floor_plane = None;
|
||||
first.uncertainty_calibrated = false;
|
||||
first.seal();
|
||||
let mut older = first.clone();
|
||||
older.sequence = 1;
|
||||
older.timestamp_ns -= 1;
|
||||
older.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
assert_eq!(
|
||||
engine.process(&first, first.timestamp_ns).disposition,
|
||||
RefinementDisposition::Audited2d
|
||||
);
|
||||
assert_eq!(
|
||||
engine.process(&older, older.timestamp_ns).reason,
|
||||
Some(wifi_densepose_core::AbstentionReason::NonMonotonicInput)
|
||||
);
|
||||
}
|
||||
86
v2/crates/wifi-densepose-physics/tests/kinematics.rs
Normal file
86
v2/crates/wifi-densepose-physics/tests/kinematics.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
#![allow(missing_docs)]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::PhysicsMode;
|
||||
use wifi_densepose_physics::skeleton::SkeletonPosterior;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn projector_bounds_vector_acceleration() {
|
||||
let config = PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
max_velocity_mps: 4.0,
|
||||
max_acceleration_mps2: 5.0,
|
||||
..PhysicsConfig::default()
|
||||
};
|
||||
let mut engine = PhysicsEngine::new(config).unwrap();
|
||||
let first = observation(1);
|
||||
let _ = engine.process(&first, first.timestamp_ns);
|
||||
|
||||
let mut second = observation(2);
|
||||
for joint in &mut second.joints {
|
||||
joint.position_m[0] += 0.02;
|
||||
}
|
||||
second.seal();
|
||||
let _ = engine.process(&second, second.timestamp_ns);
|
||||
|
||||
let mut third = observation(3);
|
||||
for joint in &mut third.joints {
|
||||
joint.position_m[0] -= 0.02;
|
||||
}
|
||||
third.seal();
|
||||
let result = engine.process(&third, third.timestamp_ns);
|
||||
let refined = result.refined_residuals.expect("shadow candidate");
|
||||
assert!(refined.acceleration_mps2 <= 5.01, "{refined:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projector_reduces_impossible_elbow_angle() {
|
||||
let mut raw = observation(1);
|
||||
raw.joints[9].position_m = [-0.25, 0.0, 1.30];
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
let refined = result.refined_residuals.expect("shadow candidate");
|
||||
assert!(refined.joint_limit_rad < result.residuals.joint_limit_rad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_outlier_does_not_redefine_calibrated_bone_length() {
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig {
|
||||
mode: PhysicsMode::ShadowCorrect,
|
||||
..PhysicsConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
for sequence in 1..=6 {
|
||||
let raw = observation(sequence);
|
||||
let _ = engine.process(&raw, raw.timestamp_ns);
|
||||
}
|
||||
let mut outlier = observation(7);
|
||||
outlier.joints[9].position_m[0] -= 0.14;
|
||||
outlier.seal();
|
||||
let result = engine.process(&outlier, outlier.timestamp_ns);
|
||||
assert!(result.residuals.bone_m > 0.001, "{:?}", result.residuals);
|
||||
assert!(result.refined_residuals.expect("shadow candidate").bone_m < result.residuals.bone_m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bone_posterior_rejects_single_calibration_outlier() {
|
||||
let baseline = observation(1);
|
||||
let joints = baseline.joints.map(|joint| joint.position_m);
|
||||
let confidence = [0.9; 17];
|
||||
let mut posterior = SkeletonPosterior::default();
|
||||
for _ in 0..6 {
|
||||
posterior.observe(&joints, &confidence, 0.7);
|
||||
}
|
||||
let target = posterior.target(7, 0.0);
|
||||
let mut outlier = joints;
|
||||
outlier[9][0] -= 0.5;
|
||||
posterior.observe(&outlier, &confidence, 0.7);
|
||||
assert!((posterior.target(7, 0.0) - target).abs() < 0.005);
|
||||
}
|
||||
186
v2/crates/wifi-densepose-physics/tests/learned.rs
Normal file
186
v2/crates/wifi-densepose-physics/tests/learned.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
#![allow(missing_docs)]
|
||||
#![cfg(feature = "learned")]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::ConstraintResiduals;
|
||||
use wifi_densepose_physics::{
|
||||
constraints::TemporalHistory,
|
||||
learned::{
|
||||
features::{FeatureHistory, PoseFeatureFrame, FEATURE_WIDTH},
|
||||
training::LossComponents,
|
||||
LearnedArtifact, LearnedArtifactError, LearnedArtifactManifest, LearnedResidual,
|
||||
SignatureVerification,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn feature_encoder_is_fixed_width_finite_and_requires_complete_history() {
|
||||
let raw = observation(1);
|
||||
let frame = PoseFeatureFrame::encode(
|
||||
&raw,
|
||||
ConstraintResiduals::default(),
|
||||
&TemporalHistory::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(frame.values().len(), FEATURE_WIDTH);
|
||||
assert!(frame.values().iter().all(|value| value.is_finite()));
|
||||
|
||||
let mut history = FeatureHistory::default();
|
||||
for _ in 0..19 {
|
||||
history.push(frame.clone());
|
||||
}
|
||||
assert!(!history.is_ready());
|
||||
assert!(history.flattened().is_none());
|
||||
history.push(frame);
|
||||
assert_eq!(history.flattened().unwrap().len(), 20 * FEATURE_WIDTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_objective_has_declared_weights_and_rejects_invalid_components() {
|
||||
let components = LossComponents {
|
||||
uncertainty_weighted_mpjpe: 1.0,
|
||||
bone: 1.0,
|
||||
temporal_jerk: 1.0,
|
||||
contact: 1.0,
|
||||
uncertainty_calibration: 1.0,
|
||||
intervention: 1.0,
|
||||
};
|
||||
assert!((components.total().unwrap() - 1.55).abs() < 1.0e-6);
|
||||
assert!(LossComponents {
|
||||
bone: f32::NAN,
|
||||
..components
|
||||
}
|
||||
.total()
|
||||
.is_none());
|
||||
assert!(LossComponents {
|
||||
intervention: -1.0,
|
||||
..components
|
||||
}
|
||||
.total()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_artifact_requires_hash_manifest_size_and_signature_receipts() {
|
||||
let bytes = b"signed-burn-record".to_vec();
|
||||
let hash = *blake3::hash(&bytes).as_bytes();
|
||||
let manifest = LearnedArtifactManifest::adr323("pose-residual-v1".into());
|
||||
let signature = SignatureVerification::accepted("release-key".into(), [7; 32]).unwrap();
|
||||
let artifact = LearnedArtifact::verified(
|
||||
bytes.clone(),
|
||||
hash,
|
||||
manifest.clone(),
|
||||
signature.clone(),
|
||||
bytes.len(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(artifact.bytes(), bytes);
|
||||
assert_eq!(artifact.content_hash, hash);
|
||||
|
||||
assert_eq!(
|
||||
LearnedArtifact::verified(
|
||||
bytes.clone(),
|
||||
[9; 32],
|
||||
manifest.clone(),
|
||||
signature.clone(),
|
||||
64
|
||||
)
|
||||
.unwrap_err(),
|
||||
LearnedArtifactError::HashMismatch
|
||||
);
|
||||
assert_eq!(
|
||||
LearnedArtifact::verified(bytes.clone(), hash, manifest.clone(), signature.clone(), 1)
|
||||
.unwrap_err(),
|
||||
LearnedArtifactError::ArtifactTooLarge
|
||||
);
|
||||
let mut incompatible = manifest;
|
||||
incompatible.feature_width += 1;
|
||||
assert_eq!(
|
||||
LearnedArtifact::verified(bytes, hash, incompatible, signature, 64).unwrap_err(),
|
||||
LearnedArtifactError::IncompatibleManifest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn learned_heads_are_finite_and_hard_bounded() {
|
||||
let residual = LearnedResidual {
|
||||
joints_m: [[[0.50, -0.50, 0.01]; 17][0]; 17],
|
||||
log_variance: [[[-40.0, 40.0, 0.0]; 17][0]; 17],
|
||||
contact_probability: [-1.0, 2.0],
|
||||
abstention_probability: 1.5,
|
||||
}
|
||||
.bounded(0.20)
|
||||
.unwrap();
|
||||
assert!(residual
|
||||
.joints_m
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|coordinate| coordinate.abs() <= 0.20));
|
||||
assert!(residual
|
||||
.contact_probability
|
||||
.iter()
|
||||
.zip([0.0, 1.0])
|
||||
.all(|(actual, expected)| (actual - expected).abs() <= f32::EPSILON));
|
||||
assert!((residual.abstention_probability - 1.0).abs() <= f32::EPSILON);
|
||||
assert!(residual.log_variance[0]
|
||||
.iter()
|
||||
.zip([-20.0, 10.0, 0.0])
|
||||
.all(|(actual, expected)| (actual - expected).abs() <= f32::EPSILON));
|
||||
|
||||
let mut invalid = residual;
|
||||
invalid.joints_m[0][0] = f32::NAN;
|
||||
assert!(invalid.bounded(0.20).is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "learned-cpu")]
|
||||
#[test]
|
||||
fn burn_gru_executes_and_roundtrips_verified_record_on_cpu() {
|
||||
use burn_core::tensor::Tensor;
|
||||
use burn_ndarray::{NdArray, NdArrayDevice};
|
||||
use wifi_densepose_physics::learned::model::{
|
||||
ResidualGru, HIDDEN_WIDTH, HISTORY_FRAMES, JOINT_RESIDUAL_WIDTH,
|
||||
};
|
||||
|
||||
type Backend = NdArray<f32>;
|
||||
let device = NdArrayDevice::default();
|
||||
let model = ResidualGru::<Backend>::init(&device);
|
||||
assert!(burn_core::module::Module::num_params(&model) > HIDDEN_WIDTH);
|
||||
let bytes = model.into_artifact_bytes().unwrap();
|
||||
let hash = *blake3::hash(&bytes).as_bytes();
|
||||
let artifact = LearnedArtifact::verified(
|
||||
bytes.clone(),
|
||||
hash,
|
||||
LearnedArtifactManifest::adr323("cpu-roundtrip".into()),
|
||||
SignatureVerification::accepted("test-release-key".into(), [5; 32]).unwrap(),
|
||||
bytes.len(),
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = ResidualGru::<Backend>::from_verified_artifact(&artifact, &device).unwrap();
|
||||
let input = Tensor::<Backend, 3>::zeros([1, HISTORY_FRAMES, FEATURE_WIDTH], &device);
|
||||
let output = loaded.forward(input);
|
||||
assert_eq!(
|
||||
output.joint_residual.shape().dims(),
|
||||
[1, JOINT_RESIDUAL_WIDTH]
|
||||
);
|
||||
assert_eq!(
|
||||
output.residual_log_variance.shape().dims(),
|
||||
[1, JOINT_RESIDUAL_WIDTH]
|
||||
);
|
||||
assert_eq!(output.contact_probability.shape().dims(), [1, 2]);
|
||||
assert_eq!(output.abstention_probability.shape().dims(), [1, 1]);
|
||||
|
||||
let runtime =
|
||||
wifi_densepose_physics::learned::runtime::CpuResidualRuntime::activate(&artifact).unwrap();
|
||||
assert_eq!(runtime.artifact_hash(), hash);
|
||||
let prediction = runtime
|
||||
.predict(&vec![0.0; 20 * FEATURE_WIDTH], 0.20)
|
||||
.unwrap();
|
||||
assert!(prediction
|
||||
.joints_m
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|value| value.is_finite() && value.abs() <= 0.20));
|
||||
assert!(runtime.predict(&[0.0; 3], 0.20).is_none());
|
||||
}
|
||||
30
v2/crates/wifi-densepose-physics/tests/malformed_input.rs
Normal file
30
v2/crates/wifi-densepose-physics/tests/malformed_input.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
#![allow(missing_docs)]
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use wifi_densepose_core::AbstentionReason;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn rejects_non_psd_covariance() {
|
||||
let mut raw = observation(1);
|
||||
raw.joints[0].covariance_m2.xy = 1.0;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
assert_eq!(
|
||||
engine.process(&raw, raw.timestamp_ns).reason,
|
||||
Some(AbstentionReason::InvalidCovariance)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nan() {
|
||||
let mut raw = observation(1);
|
||||
raw.joints[0].position_m[0] = f32::NAN;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
assert_eq!(
|
||||
engine.process(&raw, raw.timestamp_ns).reason,
|
||||
Some(AbstentionReason::InvalidNumber)
|
||||
);
|
||||
}
|
||||
96
v2/crates/wifi-densepose-physics/tests/properties.rs
Normal file
96
v2/crates/wifi-densepose-physics/tests/properties.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
#![allow(missing_docs)]
|
||||
mod common;
|
||||
|
||||
use proptest::prelude::*;
|
||||
use wifi_densepose_core::PhysicsMode;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn finite_input_never_increases_confidence(dx in -0.05f32..0.05, dz in -0.05f32..0.05) {
|
||||
let mut raw = common::observation(1);
|
||||
raw.joints[15].position_m[0] += dx;
|
||||
raw.joints[15].position_m[2] += dz;
|
||||
raw.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig { mode: PhysicsMode::ShadowCorrect, ..PhysicsConfig::default() }).unwrap();
|
||||
let result = engine.process(&raw, raw.timestamp_ns);
|
||||
prop_assert!(result.effective_confidence.get() <= raw.observer_confidence.get());
|
||||
if let Some(joints) = result.refined_joints_m {
|
||||
prop_assert!(joints.iter().flatten().all(|value| value.is_finite()));
|
||||
prop_assert!(result.intervention.max_joint_correction_m <= engine.config().max_joint_correction_m);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translating_pose_and_floor_preserves_audit_residuals() {
|
||||
let base = common::observation(1);
|
||||
let mut translated = base.clone();
|
||||
let delta = [3.0, -2.0, 0.75];
|
||||
for joint in &mut translated.joints {
|
||||
for (coordinate, offset) in joint.position_m.iter_mut().zip(delta) {
|
||||
*coordinate += offset;
|
||||
}
|
||||
}
|
||||
let floor = translated.floor_plane.as_mut().unwrap();
|
||||
floor.offset_m -= floor
|
||||
.normal
|
||||
.iter()
|
||||
.zip(delta)
|
||||
.map(|(a, b)| a * b)
|
||||
.sum::<f32>();
|
||||
translated.track_id.0 = "local:translated".into();
|
||||
translated.seal();
|
||||
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let first = engine.process(&base, base.timestamp_ns);
|
||||
let second = engine.process(&translated, translated.timestamp_ns);
|
||||
assert!((first.residuals.bone_m - second.residuals.bone_m).abs() < 1.0e-5);
|
||||
assert!(
|
||||
(first.residuals.floor_penetration_m - second.residuals.floor_penetration_m).abs() < 1.0e-5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_symmetric_input_preserves_scalar_assessment() {
|
||||
let raw = common::observation(1);
|
||||
let mut mirrored = raw.clone();
|
||||
for joint in &mut mirrored.joints {
|
||||
joint.position_m[0] = -joint.position_m[0];
|
||||
}
|
||||
mirrored.track_id.0 = "local:mirror".into();
|
||||
mirrored.seal();
|
||||
let mut engine = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let left = engine.process(&raw, raw.timestamp_ns);
|
||||
let right = engine.process(&mirrored, mirrored.timestamp_ns);
|
||||
assert!((left.residuals.normalized_total - right.residuals.normalized_total).abs() < 1.0e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_track_order_does_not_change_results() {
|
||||
let a = common::observation(1);
|
||||
let mut b = a.clone();
|
||||
b.track_id.0 = "local:8".into();
|
||||
b.joints[9].position_m[0] += 0.03;
|
||||
b.seal();
|
||||
|
||||
let mut forward = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let forward_a = forward.process(&a, a.timestamp_ns);
|
||||
let forward_b = forward.process(&b, b.timestamp_ns);
|
||||
let mut reverse = PhysicsEngine::new(PhysicsConfig::default()).unwrap();
|
||||
let reverse_b = reverse.process(&b, b.timestamp_ns);
|
||||
let reverse_a = reverse.process(&a, a.timestamp_ns);
|
||||
assert_semantically_equal_ignoring_wall_clock(forward_a, reverse_a);
|
||||
assert_semantically_equal_ignoring_wall_clock(forward_b, reverse_b);
|
||||
}
|
||||
|
||||
fn assert_semantically_equal_ignoring_wall_clock(
|
||||
mut left: wifi_densepose_core::PoseRefinementV1,
|
||||
mut right: wifi_densepose_core::PoseRefinementV1,
|
||||
) {
|
||||
left.intervention.elapsed_us = 0;
|
||||
right.intervention.elapsed_us = 0;
|
||||
left.seal();
|
||||
right.seal();
|
||||
assert_eq!(left, right);
|
||||
}
|
||||
38
v2/crates/wifi-densepose-physics/tests/schemas.rs
Normal file
38
v2/crates/wifi-densepose-physics/tests/schemas.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
#![allow(missing_docs)]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::observation;
|
||||
use std::path::PathBuf;
|
||||
use wifi_densepose_physics::{PhysicsConfig, PhysicsEngine};
|
||||
|
||||
#[test]
|
||||
fn checked_in_schemas_parse_and_cover_serialized_contract_fields() {
|
||||
let raw = observation(1);
|
||||
let result = PhysicsEngine::new(PhysicsConfig::default())
|
||||
.unwrap()
|
||||
.process(&raw, raw.timestamp_ns);
|
||||
assert_schema_required_fields("pose-observation-v2.schema.json", &raw);
|
||||
assert_schema_required_fields("pose-refinement-v1.schema.json", &result);
|
||||
}
|
||||
|
||||
fn assert_schema_required_fields(name: &str, value: &impl serde::Serialize) {
|
||||
let schema_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../docs/schemas")
|
||||
.join(name);
|
||||
let schema: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(schema_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
schema["$schema"],
|
||||
"https://json-schema.org/draft/2020-12/schema"
|
||||
);
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let serialized = serde_json::to_value(value).unwrap();
|
||||
for required in schema["required"].as_array().unwrap() {
|
||||
let field = required.as_str().unwrap();
|
||||
assert!(
|
||||
serialized.get(field).is_some(),
|
||||
"schema requires missing field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ clap = { workspace = true }
|
||||
# from `lib.rs` so the Python `[aether]` wheel can bind it without this server's
|
||||
# Axum/tokio/worldgraph/ruvector tree.
|
||||
wifi-densepose-aether = { version = "0.3.0", path = "../wifi-densepose-aether" }
|
||||
wifi-densepose-core = { workspace = true, features = ["serde"] }
|
||||
wifi-densepose-physics = { workspace = true }
|
||||
|
||||
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
|
||||
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
|
||||
|
||||
@@ -23,6 +23,8 @@ pub mod matter;
|
||||
pub mod model_format;
|
||||
pub mod mqtt;
|
||||
pub mod path_safety;
|
||||
/// ADR-323: fail-closed pose physics integration and raw/refined view selection.
|
||||
pub mod pose_physics;
|
||||
/// ADR-295: canonical source-provenance state machine (synthetic can never
|
||||
/// present as live).
|
||||
pub mod provenance;
|
||||
|
||||
@@ -22,6 +22,7 @@ mod qualcomm_csi;
|
||||
mod realtek_radar;
|
||||
mod path_safety;
|
||||
pub mod pose;
|
||||
pub mod pose_physics;
|
||||
mod rvf_container;
|
||||
// ADR-186 (TRAIN-RECONNECT): the in-server training pipeline was written but
|
||||
// never declared as a module, so it was orphaned / uncompiled. Declaring it
|
||||
@@ -1382,6 +1383,80 @@ struct AppStateInner {
|
||||
/// Held behind its own `Arc<RwLock<_>>` so the additive field router can
|
||||
/// take it as state without re-locking `AppStateInner`.
|
||||
field_surface: rufield_surface::FieldState,
|
||||
/// Canonical ADR-323 engine and latest additive publication. Existing
|
||||
/// image-space renderer poses are never silently inserted here.
|
||||
pose_physics: pose_physics::PosePhysicsRuntime,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr323_pose_physics_http_tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/api/v1/pose/current", get(pose_current))
|
||||
.route("/api/v1/pose/physics/metrics", get(pose_physics_metrics))
|
||||
.with_state(Arc::new(RwLock::new(AppStateInner::minimal())))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_view_remains_backward_compatible() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pose/current?view=raw")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), 65_536)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(body.get("persons").is_some());
|
||||
assert!(body.get("physics").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refined_view_is_typed_conflict_when_no_selected_pose_exists() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pose/current?view=refined")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body = axum::body::to_bytes(response.into_body(), 65_536)
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(body["code"], "pose_refined_unavailable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn physics_metrics_use_prometheus_content_type() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/pose/physics/metrics")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[axum::http::header::CONTENT_TYPE],
|
||||
"text/plain; version=0.0.4"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If no ESP32 frame arrives within this duration, source reverts to offline.
|
||||
@@ -1551,6 +1626,10 @@ impl AppStateInner {
|
||||
dedup_factor: 3.0,
|
||||
data_dir: std::path::PathBuf::from("data"),
|
||||
field_surface: Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())),
|
||||
pose_physics: pose_physics::PosePhysicsRuntime::new(
|
||||
wifi_densepose_physics::PhysicsConfig::default(),
|
||||
)
|
||||
.expect("default pose physics configuration is valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3001,6 +3080,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
assess_legacy_image_pose(&mut s, &update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -3159,6 +3239,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
assess_legacy_image_pose(&mut s, &update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -3572,9 +3653,18 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
// Determine pose estimation mode for the UI indicator.
|
||||
// "model_inference" — a trained RVF model is loaded.
|
||||
// "signal_derived" — keypoints estimated from raw CSI features.
|
||||
let model_loaded = {
|
||||
let (model_loaded, physics_assessment) = {
|
||||
let s = state.read().await;
|
||||
s.model_loaded
|
||||
let physics = s.pose_physics.latest().and_then(|(raw, result)| {
|
||||
(raw.sequence == sensing.tick).then(|| {
|
||||
serde_json::json!({
|
||||
"schema": "pose-refinement-v1",
|
||||
"raw_observation_hash": raw.canonical_hash,
|
||||
"assessment": result,
|
||||
})
|
||||
})
|
||||
});
|
||||
(s.model_loaded, physics)
|
||||
};
|
||||
let pose_source = if model_loaded {
|
||||
"model_inference"
|
||||
@@ -3627,6 +3717,7 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
|
||||
"type": "pose_data",
|
||||
"zone_id": "zone_1",
|
||||
"timestamp": sensing.timestamp,
|
||||
"physics": physics_assessment,
|
||||
"payload": {
|
||||
"pose": {
|
||||
"persons": persons,
|
||||
@@ -4630,6 +4721,110 @@ fn attach_field_positions(update: &mut SensingUpdate) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed legacy renderer coordinates into the canonical audit boundary without
|
||||
/// promoting them to metric 3D or calibrated evidence.
|
||||
fn assess_legacy_image_pose(state: &mut AppStateInner, update: &SensingUpdate) {
|
||||
use wifi_densepose_core::{
|
||||
CalibrationId, Coco17Joint, JointObservation, JointVisibility, ModelRef,
|
||||
PoseDimensionality, PoseObservationV2, PoseTrustState, Probability, SourceProvenance,
|
||||
SpatialFrameRef, SymmetricCovariance3, TrackId,
|
||||
};
|
||||
|
||||
if !update.timestamp.is_finite() || update.timestamp < 0.0 {
|
||||
return;
|
||||
}
|
||||
let timestamp_ns = (update.timestamp * 1_000_000_000.0) as u64;
|
||||
let Some(persons) = update.persons.as_ref() else {
|
||||
return;
|
||||
};
|
||||
for person in persons {
|
||||
if person.keypoints.len() != Coco17Joint::ALL.len()
|
||||
|| !person.confidence.is_finite()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(joints) = person
|
||||
.keypoints
|
||||
.iter()
|
||||
.zip(Coco17Joint::ALL)
|
||||
.map(|(keypoint, kind)| {
|
||||
let position = [keypoint.x as f32, keypoint.y as f32, 0.0];
|
||||
if !position.iter().all(|coordinate| coordinate.is_finite())
|
||||
|| !keypoint.confidence.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let confidence =
|
||||
Probability::new((keypoint.confidence as f32).clamp(0.0, 1.0)).ok()?;
|
||||
Some(JointObservation {
|
||||
kind,
|
||||
position_m: position,
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.0,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.0,
|
||||
yz: 0.0,
|
||||
zz: 0.0,
|
||||
},
|
||||
confidence,
|
||||
visibility: if confidence.get() > 0.0 {
|
||||
JointVisibility::Visible
|
||||
} else {
|
||||
JointVisibility::Unknown
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.and_then(|joints| joints.try_into().ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let observer_confidence =
|
||||
match Probability::new((person.confidence as f32).clamp(0.0, 1.0)) {
|
||||
Ok(confidence) => confidence,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns,
|
||||
sensor_epoch: 1,
|
||||
sequence: update.tick,
|
||||
track_id: TrackId(format!("local:{}", person.id)),
|
||||
frame: SpatialFrameRef {
|
||||
name: "legacy-renderer-image".into(),
|
||||
version: 1,
|
||||
metric: false,
|
||||
right_handed: false,
|
||||
z_up: false,
|
||||
},
|
||||
calibration_id: CalibrationId("uncalibrated-image".into()),
|
||||
floor_plane: None,
|
||||
model: ModelRef {
|
||||
id: if state.model_loaded {
|
||||
"sensing-server-model-artifact-unknown".into()
|
||||
} else {
|
||||
"sensing-server-signal-derived".into()
|
||||
},
|
||||
artifact_hash: [0; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "sensing-server-local".into(),
|
||||
authenticated: false,
|
||||
replay_protected: false,
|
||||
},
|
||||
trust_state: PoseTrustState::Degraded,
|
||||
dimensionality: PoseDimensionality::Image2d,
|
||||
uncertainty_calibrated: false,
|
||||
joints,
|
||||
observer_confidence,
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
let _ = state.pose_physics.process(&raw, timestamp_ns);
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
|
||||
let cls = &update.classification;
|
||||
if !cls.presence {
|
||||
@@ -4845,7 +5040,16 @@ async fn api_info(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn pose_current(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PoseCurrentQuery {
|
||||
#[serde(default)]
|
||||
view: pose_physics::PoseView,
|
||||
}
|
||||
|
||||
async fn pose_current(
|
||||
State(state): State<SharedState>,
|
||||
Query(query): Query<PoseCurrentQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let s = state.read().await;
|
||||
let persons = match &s.latest_update {
|
||||
Some(update) => update
|
||||
@@ -4854,12 +5058,69 @@ async fn pose_current(State(state): State<SharedState>) -> Json<serde_json::Valu
|
||||
.unwrap_or_else(|| derive_pose_from_sensing(update)),
|
||||
None => vec![],
|
||||
};
|
||||
Json(serde_json::json!({
|
||||
let legacy = serde_json::json!({
|
||||
"timestamp": chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
|
||||
"persons": persons,
|
||||
"total_persons": persons.len(),
|
||||
"source": s.effective_source(),
|
||||
}))
|
||||
});
|
||||
match query.view {
|
||||
pose_physics::PoseView::Raw => (StatusCode::OK, Json(legacy)),
|
||||
pose_physics::PoseView::Both => {
|
||||
if let Some((raw, physics)) = s.pose_physics.latest() {
|
||||
let mut body = legacy;
|
||||
body["raw"] = serde_json::to_value(raw).unwrap_or(serde_json::Value::Null);
|
||||
body["physics"] =
|
||||
serde_json::to_value(physics).unwrap_or(serde_json::Value::Null);
|
||||
(StatusCode::OK, Json(body))
|
||||
} else {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"code": "pose_physics_unavailable",
|
||||
"detail": "no canonical pose observation has been assessed"
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
pose_physics::PoseView::Refined => {
|
||||
let Some((raw, physics)) = s.pose_physics.latest() else {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::json!({
|
||||
"code": "pose_refined_unavailable",
|
||||
"detail": "no canonical pose observation has been assessed"
|
||||
})),
|
||||
);
|
||||
};
|
||||
match pose_physics::PosePhysicsRuntime::select(query.view, raw, physics) {
|
||||
Ok(pose_physics::SelectedPose::Refined(joints)) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"raw_observation_hash": raw.canonical_hash,
|
||||
"joints_m": joints,
|
||||
"physics": physics,
|
||||
})),
|
||||
),
|
||||
Ok(_) => unreachable!("refined view returns only refined data"),
|
||||
Err(error) => (
|
||||
StatusCode::CONFLICT,
|
||||
Json(serde_json::to_value(error).unwrap_or_else(|_| {
|
||||
serde_json::json!({"code": "pose_refined_unavailable"})
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pose_physics_metrics(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
let metrics = state.read().await.pose_physics.metrics_text();
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")],
|
||||
metrics,
|
||||
)
|
||||
}
|
||||
|
||||
async fn pose_stats(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
@@ -6256,6 +6517,7 @@ async fn udp_receiver_task(
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
assess_legacy_image_pose(&mut s, &update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -6708,6 +6970,7 @@ async fn udp_receiver_task(
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
assess_legacy_image_pose(&mut s, &update);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&update) {
|
||||
let _ = s.tx.send(json);
|
||||
@@ -6968,6 +7231,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
||||
}
|
||||
// #1050: attach real signal_field-peak positions to each person.
|
||||
attach_field_positions(&mut update);
|
||||
assess_legacy_image_pose(&mut s, &update);
|
||||
|
||||
if update.classification.presence {
|
||||
s.total_detections += 1;
|
||||
@@ -8313,6 +8577,10 @@ async fn main() {
|
||||
dedup_factor: runtime_config.dedup_factor,
|
||||
data_dir: data_dir.clone(),
|
||||
field_surface: field_surface.clone(),
|
||||
pose_physics: pose_physics::PosePhysicsRuntime::new(
|
||||
wifi_densepose_physics::PhysicsConfig::default(),
|
||||
)
|
||||
.expect("default pose physics configuration is valid"),
|
||||
}));
|
||||
|
||||
// Start background tasks from the resolved plan (issue #1004).
|
||||
@@ -8531,6 +8799,7 @@ async fn main() {
|
||||
.route("/api/v1/model/sona/activate", post(sona_activate))
|
||||
// Pose endpoints (WiFi-derived)
|
||||
.route("/api/v1/pose/current", get(pose_current))
|
||||
.route("/api/v1/pose/physics/metrics", get(pose_physics_metrics))
|
||||
.route("/api/v1/pose/stats", get(pose_stats))
|
||||
.route("/api/v1/pose/zones/summary", get(pose_zones_summary))
|
||||
.route("/api/v1/pose/activities", get(pose_activities))
|
||||
@@ -9566,6 +9835,29 @@ mod observatory_persons_field_position_tests {
|
||||
assert!((pj["motion_score"].as_f64().unwrap() - 63.3).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_live_pose_is_audited_as_uncalibrated_image_data() {
|
||||
let mut update = base_update(field_with_peak(10, 10), true, 20.0);
|
||||
update.persons = Some(derive_pose_from_sensing(&update));
|
||||
let mut state = AppStateInner::minimal();
|
||||
|
||||
assess_legacy_image_pose(&mut state, &update);
|
||||
|
||||
let (raw, result) = state
|
||||
.pose_physics
|
||||
.latest()
|
||||
.expect("legacy image pose should reach canonical audit");
|
||||
assert_eq!(raw.dimensionality, wifi_densepose_core::PoseDimensionality::Image2d);
|
||||
assert!(!raw.uncertainty_calibrated);
|
||||
assert!(!raw.source.authenticated);
|
||||
assert_eq!(
|
||||
result.disposition,
|
||||
wifi_densepose_core::RefinementDisposition::Audited2d
|
||||
);
|
||||
assert!(!result.selected);
|
||||
assert!(result.refined_joints_m.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pose_is_real_when_posture_present_and_absent_otherwise() {
|
||||
// No aggregate posture estimate → pose is None (never fabricated).
|
||||
|
||||
336
v2/crates/wifi-densepose-sensing-server/src/pose_physics.rs
Normal file
336
v2/crates/wifi-densepose-sensing-server/src/pose_physics.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
//! Fail-closed sensing-server integration for ADR-323.
|
||||
//!
|
||||
//! This adapter deliberately accepts only the canonical contract. Existing
|
||||
//! renderer poses are image/pixel-space and must not be silently promoted to
|
||||
//! metric 3D observations.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wifi_densepose_core::{
|
||||
PhysicsMode, PoseObservationV2, PoseRefinementV1, RefinementDisposition,
|
||||
};
|
||||
use wifi_densepose_physics::{
|
||||
metrics::PhysicsMetrics, CorrectionAuthorization, PhysicsConfig, PhysicsEngine, PhysicsError,
|
||||
VerifiedFrameContext,
|
||||
};
|
||||
|
||||
/// Operator-requested API representation.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PoseView {
|
||||
/// Immutable observer output only (migration default).
|
||||
#[default]
|
||||
Raw,
|
||||
/// Observer output plus additive physics assessment.
|
||||
Both,
|
||||
/// A selected corrected pose; unavailable is a typed conflict.
|
||||
Refined,
|
||||
}
|
||||
|
||||
/// Borrowed publication selected without relabeling raw data.
|
||||
#[derive(Debug)]
|
||||
pub enum SelectedPose<'a> {
|
||||
Raw(&'a PoseObservationV2),
|
||||
Both {
|
||||
raw: &'a PoseObservationV2,
|
||||
physics: &'a PoseRefinementV1,
|
||||
},
|
||||
Refined(&'a [[f32; 3]; 17]),
|
||||
}
|
||||
|
||||
/// Typed body for HTTP 409 when `view=refined` is unavailable.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct RefinedUnavailable {
|
||||
pub code: &'static str,
|
||||
pub disposition: RefinementDisposition,
|
||||
}
|
||||
|
||||
/// Result of scheduling refinement work while raw publication continues.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EnqueueOutcome {
|
||||
/// First pending frame for this track.
|
||||
Queued,
|
||||
/// A pending frame was discarded and the newest frame was retained.
|
||||
ReplacedIntermediate,
|
||||
/// A new track could not be scheduled within the configured cardinality cap.
|
||||
RejectedCapacity,
|
||||
}
|
||||
|
||||
/// Process-local engine wrapper. Control-plane authentication is enforced by
|
||||
/// the caller before [`Self::set_mode`] is invoked.
|
||||
pub struct PosePhysicsRuntime {
|
||||
engine: PhysicsEngine,
|
||||
metrics: PhysicsMetrics,
|
||||
latest: Option<(PoseObservationV2, PoseRefinementV1)>,
|
||||
pending: BTreeMap<(u64, String), PoseObservationV2>,
|
||||
max_pending_tracks: usize,
|
||||
}
|
||||
|
||||
impl PosePhysicsRuntime {
|
||||
/// Create the engine with validated, operator-owned limits.
|
||||
pub fn new(config: PhysicsConfig) -> Result<Self, PhysicsError> {
|
||||
let max_pending_tracks = config.max_tracks;
|
||||
Ok(Self {
|
||||
engine: PhysicsEngine::new(config)?,
|
||||
metrics: PhysicsMetrics::default(),
|
||||
latest: None,
|
||||
pending: BTreeMap::new(),
|
||||
max_pending_tracks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retain only the newest pending frame per track. This queue covers
|
||||
/// refinement work only; callers must publish every immutable raw frame
|
||||
/// independently before scheduling it here.
|
||||
pub fn enqueue_latest(&mut self, raw: PoseObservationV2) -> EnqueueOutcome {
|
||||
let key = (raw.sensor_epoch, raw.track_id.0.clone());
|
||||
if let Some(current) = self.pending.get_mut(&key) {
|
||||
if (raw.timestamp_ns, raw.sequence) > (current.timestamp_ns, current.sequence) {
|
||||
*current = raw;
|
||||
}
|
||||
self.metrics.dropped_intermediate();
|
||||
return EnqueueOutcome::ReplacedIntermediate;
|
||||
}
|
||||
if self.pending.len() >= self.max_pending_tracks {
|
||||
return EnqueueOutcome::RejectedCapacity;
|
||||
}
|
||||
self.pending.insert(key, raw);
|
||||
EnqueueOutcome::Queued
|
||||
}
|
||||
|
||||
/// Drain the bounded queue in deterministic track order.
|
||||
#[must_use]
|
||||
pub fn process_pending(&mut self, now_ns: u64) -> Vec<PoseRefinementV1> {
|
||||
let pending = core::mem::take(&mut self.pending);
|
||||
pending
|
||||
.into_values()
|
||||
.map(|raw| self.process(&raw, now_ns))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Assess exactly one canonical observation.
|
||||
#[must_use]
|
||||
pub fn process(&mut self, raw: &PoseObservationV2, now_ns: u64) -> PoseRefinementV1 {
|
||||
let result = self.engine.process(raw, now_ns);
|
||||
self.record(raw, &result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Process a frame whose signed envelope and replay window were verified by
|
||||
/// the serving data plane. Corrected selection is impossible without this receipt.
|
||||
#[must_use]
|
||||
pub fn process_verified(
|
||||
&mut self,
|
||||
raw: &PoseObservationV2,
|
||||
now_ns: u64,
|
||||
verification: &VerifiedFrameContext,
|
||||
) -> PoseRefinementV1 {
|
||||
let result = self.engine.process_verified(raw, now_ns, verification);
|
||||
self.record(raw, &result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Activate release-gate evidence previously verified by the signed local
|
||||
/// control plane.
|
||||
pub fn authorize_correction(
|
||||
&mut self,
|
||||
authorization: CorrectionAuthorization,
|
||||
) -> Result<(), PhysicsError> {
|
||||
self.engine.authorize_correction(authorization)
|
||||
}
|
||||
|
||||
/// Active configuration hash used to bind signed evidence receipts.
|
||||
#[must_use]
|
||||
pub const fn config_hash(&self) -> [u8; 32] {
|
||||
self.engine.config_hash()
|
||||
}
|
||||
|
||||
/// Apply a previously authenticated and witnessed mode transition.
|
||||
pub fn set_mode(&mut self, mode: PhysicsMode) -> Result<(), PhysicsError> {
|
||||
self.engine.set_mode(mode)?;
|
||||
if mode == PhysicsMode::Off {
|
||||
self.latest = None;
|
||||
self.pending.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Most recent immutable raw/result pair, if a canonical frame was processed.
|
||||
#[must_use]
|
||||
pub const fn latest(&self) -> Option<&(PoseObservationV2, PoseRefinementV1)> {
|
||||
self.latest.as_ref()
|
||||
}
|
||||
|
||||
/// Privacy-safe Prometheus exposition with fixed-cardinality labels only.
|
||||
#[must_use]
|
||||
pub fn metrics_text(&self) -> String {
|
||||
self.metrics.encode_prometheus()
|
||||
}
|
||||
|
||||
fn record(&mut self, raw: &PoseObservationV2, result: &PoseRefinementV1) {
|
||||
self.metrics.observe(result, raw.observer_confidence.get());
|
||||
self.latest = Some((raw.clone(), result.clone()));
|
||||
}
|
||||
|
||||
/// Select an additive API view. Refined never silently falls back to raw.
|
||||
pub fn select<'a>(
|
||||
view: PoseView,
|
||||
raw: &'a PoseObservationV2,
|
||||
physics: &'a PoseRefinementV1,
|
||||
) -> Result<SelectedPose<'a>, RefinedUnavailable> {
|
||||
match view {
|
||||
PoseView::Raw => Ok(SelectedPose::Raw(raw)),
|
||||
PoseView::Both => Ok(SelectedPose::Both { raw, physics }),
|
||||
PoseView::Refined => physics
|
||||
.refined_joints_m
|
||||
.as_ref()
|
||||
.filter(|_| physics.selected)
|
||||
.map(SelectedPose::Refined)
|
||||
.ok_or(RefinedUnavailable {
|
||||
code: "pose_refined_unavailable",
|
||||
disposition: physics.disposition,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_core::{
|
||||
AbstentionReason, CalibrationId, Coco17Joint, ConstraintResiduals, ContactHypothesis,
|
||||
InterventionSummary, JointObservation, JointVisibility, ModelRef, PhysicsProvenance,
|
||||
PoseDimensionality, PoseTrustState, Probability, SourceProvenance, SpatialFrameRef,
|
||||
SymmetricCovariance3, TrackId,
|
||||
};
|
||||
|
||||
fn pair() -> (PoseObservationV2, PoseRefinementV1) {
|
||||
let joints = Coco17Joint::ALL.map(|kind| JointObservation {
|
||||
kind,
|
||||
position_m: [0.0; 3],
|
||||
covariance_m2: SymmetricCovariance3 {
|
||||
xx: 0.0,
|
||||
xy: 0.0,
|
||||
xz: 0.0,
|
||||
yy: 0.0,
|
||||
yz: 0.0,
|
||||
zz: 0.0,
|
||||
},
|
||||
confidence: Probability::ZERO,
|
||||
visibility: JointVisibility::Unknown,
|
||||
});
|
||||
let mut raw = PoseObservationV2 {
|
||||
schema_version: 2,
|
||||
timestamp_ns: 1,
|
||||
sensor_epoch: 1,
|
||||
sequence: 1,
|
||||
track_id: TrackId("local:1".into()),
|
||||
frame: SpatialFrameRef {
|
||||
name: "image".into(),
|
||||
version: 1,
|
||||
metric: false,
|
||||
right_handed: false,
|
||||
z_up: false,
|
||||
},
|
||||
calibration_id: CalibrationId("none".into()),
|
||||
floor_plane: None,
|
||||
model: ModelRef {
|
||||
id: "test".into(),
|
||||
artifact_hash: [0; 32],
|
||||
},
|
||||
source: SourceProvenance {
|
||||
sensor_id: "test".into(),
|
||||
authenticated: false,
|
||||
replay_protected: false,
|
||||
},
|
||||
trust_state: PoseTrustState::Degraded,
|
||||
dimensionality: PoseDimensionality::Image2d,
|
||||
uncertainty_calibrated: false,
|
||||
joints,
|
||||
observer_confidence: Probability::ZERO,
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
raw.seal();
|
||||
let physics = PoseRefinementV1 {
|
||||
schema_version: 1,
|
||||
raw_observation_hash: raw.canonical_hash,
|
||||
mode: PhysicsMode::Audit,
|
||||
disposition: RefinementDisposition::Audited2d,
|
||||
selected: false,
|
||||
refined_joints_m: None,
|
||||
physics_confidence: Probability::ONE,
|
||||
effective_confidence: Probability::ZERO,
|
||||
intervention: InterventionSummary::default(),
|
||||
residuals: ConstraintResiduals::default(),
|
||||
refined_residuals: None,
|
||||
dynamics: None,
|
||||
contact_hypotheses: [ContactHypothesis::default(); 2],
|
||||
provenance: PhysicsProvenance {
|
||||
engine: "test".into(),
|
||||
engine_version: "1".into(),
|
||||
config_hash: [0; 32],
|
||||
rf_model_hash: [0; 32],
|
||||
calibration_id: "none".into(),
|
||||
learned_artifact_hash: None,
|
||||
},
|
||||
reason: Some(AbstentionReason::UncertaintyUncalibrated),
|
||||
canonical_hash: [0; 32],
|
||||
};
|
||||
(raw, physics)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refined_view_never_silently_falls_back() {
|
||||
let (raw, physics) = pair();
|
||||
let error = PosePhysicsRuntime::select(PoseView::Refined, &raw, &physics).unwrap_err();
|
||||
assert_eq!(error.code, "pose_refined_unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_publishes_latest_pair_and_privacy_safe_metrics() {
|
||||
let (raw, _) = pair();
|
||||
let mut runtime = PosePhysicsRuntime::new(PhysicsConfig::default()).unwrap();
|
||||
let result = runtime.process(&raw, raw.timestamp_ns);
|
||||
let (published_raw, published_result) = runtime.latest().unwrap();
|
||||
assert_eq!(published_raw.canonical_hash, raw.canonical_hash);
|
||||
assert_eq!(published_result.canonical_hash, result.canonical_hash);
|
||||
let metrics = runtime.metrics_text();
|
||||
assert!(metrics.contains("ruview_pose_physics_frames_total"));
|
||||
assert!(metrics.contains("ruview_pose_physics_confidence_delta"));
|
||||
assert!(!metrics.contains(&raw.track_id.0));
|
||||
assert!(!metrics.contains(&raw.source.sensor_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backpressure_retains_newest_frame_per_track_with_a_hard_cap() {
|
||||
let (raw, _) = pair();
|
||||
let mut config = PhysicsConfig::default();
|
||||
config.max_tracks = 1;
|
||||
let mut runtime = PosePhysicsRuntime::new(config).unwrap();
|
||||
|
||||
assert_eq!(runtime.enqueue_latest(raw.clone()), EnqueueOutcome::Queued);
|
||||
let mut newer = raw.clone();
|
||||
newer.sequence = 2;
|
||||
newer.timestamp_ns = 2;
|
||||
newer.seal();
|
||||
assert_eq!(
|
||||
runtime.enqueue_latest(newer.clone()),
|
||||
EnqueueOutcome::ReplacedIntermediate
|
||||
);
|
||||
|
||||
let mut second_track = raw;
|
||||
second_track.track_id = TrackId("local:2".into());
|
||||
second_track.seal();
|
||||
assert_eq!(
|
||||
runtime.enqueue_latest(second_track),
|
||||
EnqueueOutcome::RejectedCapacity
|
||||
);
|
||||
|
||||
let results = runtime.process_pending(newer.timestamp_ns);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].raw_observation_hash, newer.canonical_hash);
|
||||
assert!(runtime
|
||||
.metrics_text()
|
||||
.contains("ruview_pose_physics_dropped_intermediate_total 1"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user