mirror of
https://github.com/ruvnet/RuView
synced 2026-07-15 16:13:18 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04028acd54 | |||
| 82c1b8fdf8 | |||
| 9dceb976c7 | |||
| 7b244bdc8c | |||
| 90667d0f1d | |||
| 8c1d3d772a | |||
| 83f549d308 | |||
| d59ca00baa | |||
| da81eab714 | |||
| d840ced2db | |||
| 2ddb6a7b02 | |||
| b5ce60081b | |||
| 0a282d0870 |
@@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Multi-actor PoseCode scene crate (`wifi-densepose-posecode`, ADR-266).** Adds a deterministic Rust scene model for multiple persistent RuView actors, synchronized poses, inter-person contacts, confidence and observation provenance. Includes a bounded line parser, canonical serializer, direct `PoseTrack` adapter, confidence-aware phase segmentation, provenance-sensitive range validation, 14 feature-enabled unit tests and Criterion parser/serializer benchmarks. Raw 20 Hz observations remain separate from semantic phases; observed range anomalies are warnings rather than medical safety claims. MEASURED on the implementation container: two-actor parse 3.40 µs, serialization 3.29 µs.
|
||||
|
||||
### Changed
|
||||
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
|
||||
|
||||
### Fixed
|
||||
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
|
||||
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
|
||||
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps).
|
||||
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
|
||||
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
|
||||
- **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
|
||||
|
||||
@@ -14,6 +14,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-posecode` | ADR-266 multi-actor semantic motion scenes, PoseTrack adapter, bounded parser, confidence-aware phase segmentation |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
|
||||
| `wifi-densepose-calibration` | ADR-151 per-room calibration & specialist training — `baseline → enroll → extract → train` → bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly) + multistatic fusion; pure Rust, edge-deployable |
|
||||
@@ -81,6 +82,7 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
|
||||
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
|
||||
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
|
||||
- ADR-266: Multi-actor PoseCode scenes from persistent RuView tracks (Accepted)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
|
||||
@@ -617,7 +617,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 183 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Multi-actor PoseCode](v2/crates/wifi-densepose-posecode/) | Rust scene protocol converting persistent RuView pose tracks into confidence-scored actors, synchronized motion phases, contacts and deterministic text. [ADR-266](docs/adr/ADR-266-multi-actor-posecode-scenes.md). |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# ADR-266: Multi-Actor PoseCode Scenes
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Accepted, crate implementation complete |
|
||||
| Date | 2026-07-15 |
|
||||
| Deciders | ruv |
|
||||
| Crate | `wifi-densepose-posecode` |
|
||||
| Related | ADR-029, ADR-037, ADR-082, ADR-101, ADR-170 |
|
||||
|
||||
## Context
|
||||
|
||||
RuView already maintains multiple persistent `PoseTrack` aggregates. Each track
|
||||
contains 17 COCO keypoints, a lifecycle, an AETHER identity embedding and a
|
||||
stable `TrackId`. The missing layer is a compact, agent-readable representation
|
||||
of what those tracked people do together over time.
|
||||
|
||||
PoseCode 0.1 provides a useful vocabulary for one actor, sequential movement
|
||||
phases, semantic joint actions, contacts and browser rendering. Its current
|
||||
protocol does not represent multiple actors, shared world coordinates,
|
||||
inter-person contact, observation confidence or track provenance.
|
||||
|
||||
Encoding every 20 Hz RuView frame as text would also be incorrect. It would
|
||||
produce 1,200 steps per person per minute, discard observation uncertainty and
|
||||
couple the sensing hot path to a presentation language.
|
||||
|
||||
## Decision
|
||||
|
||||
Create the leaf crate `wifi-densepose-posecode` and define a backwards-minded
|
||||
0.2 scene extension with these invariants:
|
||||
|
||||
1. A `Scene` owns named actors and synchronized phases.
|
||||
2. Each actor can carry the originating RuView `TrackId` and confidence.
|
||||
3. Every joint target and inter-person contact carries confidence.
|
||||
4. Actor positions and travel use one room-calibrated three-dimensional frame.
|
||||
5. Raw `SceneFrame` observations remain separate from compact semantic phases.
|
||||
6. `PhaseSegmenter` emits a phase on actor membership change, motion settling
|
||||
or a bounded maximum duration.
|
||||
7. Serialization is canonical and deterministic.
|
||||
8. The parser has hard document, line, actor, phase and target limits.
|
||||
9. General range-of-motion violations are errors for authored scenes but only
|
||||
warnings for observed WiFi scenes. RF pose estimates are not medical joint
|
||||
measurements and must not create false safety claims.
|
||||
|
||||
The text form is:
|
||||
|
||||
```posecode
|
||||
posecode scene "Assisted squat"
|
||||
source observed_wifi_csi
|
||||
actor patient:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
track 7
|
||||
confidence 0.82
|
||||
position 0 0 0
|
||||
actor therapist:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
track 12
|
||||
confidence 0.76
|
||||
position 1.2 0 0
|
||||
step "Lower" 1.5s flow:
|
||||
patient.knee_left: flex 95 0.8
|
||||
therapist.shoulder_left: flex 30 0.7
|
||||
contact therapist.hand_left patient.shoulder_right 0.7
|
||||
repeat 1
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
`RuViewAdapter` consumes references to existing `PoseTrack` values. It does not
|
||||
duplicate assignment or identity tracking. It calculates elbows and knees from
|
||||
three-point interior angles, estimates hip and shoulder sagittal movement in a
|
||||
calibrated coordinate frame and maps the hip midpoint to actor position.
|
||||
The adapter is exposed by the crate's `ruview` feature so the protocol, parser
|
||||
and segmenter remain usable without pulling the full signal and RuVector graph.
|
||||
|
||||
The adapter uses keypoint confidence when available. Current trackers that have
|
||||
not populated that field receive an explicit configurable fallback confidence,
|
||||
degraded by track staleness. Non-finite coordinates and terminated tracks fail
|
||||
closed.
|
||||
|
||||
`PhaseSegmenter` consumes ordered frames. High-rate frames remain available for
|
||||
live rendering and evidence storage while the resulting scene records only
|
||||
meaningful synchronized targets. This preserves both fidelity and readability.
|
||||
|
||||
## Security and Resource Bounds
|
||||
|
||||
The public parser accepts at most 1 MiB per document and 4,096 bytes per line.
|
||||
The scene validator defaults to 32 actors, 10,000 phases and 64 joint targets
|
||||
per actor per phase. All coordinates, angles and confidence values must be
|
||||
finite. References to undeclared actors are rejected. Durations, repeats and
|
||||
timeline addition are bounded.
|
||||
|
||||
No network, filesystem, model or renderer capability is present in the crate.
|
||||
It is a deterministic transformation leaf over owned data.
|
||||
|
||||
## Consequences
|
||||
|
||||
RuView can now produce privacy-preserving multi-person replays, structured fall
|
||||
and interaction evidence, exercise comparison inputs and agent-readable motion
|
||||
records without video.
|
||||
|
||||
The crate does not solve RF source separation. Its multi-person accuracy is
|
||||
bounded by the upstream pose model and `PoseTracker`. Contact inference is also
|
||||
not fabricated; contacts must be observed by a future calibrated classifier or
|
||||
authored explicitly.
|
||||
|
||||
The current COCO skeleton has no toe keypoints, so ankle rotation and precise
|
||||
foot contact cannot be inferred honestly. Those targets are omitted rather
|
||||
than guessed.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. A scene containing two actors, simultaneous targets and a cross-actor
|
||||
contact parses and serializes deterministically.
|
||||
2. Unknown actors, non-finite values, reversed timestamps and unbounded inputs
|
||||
fail closed.
|
||||
3. One RuView track converts to finite semantic targets with the same TrackId.
|
||||
4. Observed out-of-range angles produce warnings, not medical errors.
|
||||
5. Phase count is smaller than input frame count for a normal motion sequence.
|
||||
6. Focused crate tests and the complete workspace test suite pass before merge.
|
||||
|
||||
## Measured Results
|
||||
|
||||
On the implementation container with Rust 1.88, Criterion measured the canonical
|
||||
two-actor scene parser at 3.40 microseconds and serializer at 3.29 microseconds
|
||||
per operation. The protocol-only dependency surface is three runtime crates:
|
||||
`serde`, `serde_json` and `thiserror`.
|
||||
|
||||
The protocol-only configuration passes 13 unit tests, documentation tests,
|
||||
Rustfmt and Clippy with warnings denied. The `ruview` feature passes 14 unit
|
||||
tests, including native `PoseTrack` conversion, under the repository's RuVector
|
||||
AVX512-capable build configuration.
|
||||
|
||||
## References
|
||||
|
||||
1. PoseCode repository and protocol, MIT licensed:
|
||||
<https://github.com/posecode-dev/posecode>
|
||||
2. RuView multi-person pose decision: ADR-037.
|
||||
3. RuView persistent pose tracking: ADR-029 and `pose_tracker.rs`.
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
This folder contains 183 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
|
||||
|
||||
## Why ADRs?
|
||||
|
||||
@@ -123,6 +123,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
|
||||
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
|
||||
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
|
||||
| [ADR-266](ADR-266-multi-actor-posecode-scenes.md) | Multi-actor PoseCode scenes from persistent RuView tracks | Accepted |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
@@ -48,10 +48,18 @@ with `--flash_size 4MB`.
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the
|
||||
> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles
|
||||
> display support in, and the runtime panel probe false-positives on boards
|
||||
> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and
|
||||
> collapses CSI yield to 0 pps. See the header of
|
||||
> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact
|
||||
> build command.
|
||||
|
||||
### 2. Flash
|
||||
|
||||
Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB):
|
||||
@@ -250,7 +258,7 @@ Offset Size Field
|
||||
# From the repository root:
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build"
|
||||
```
|
||||
|
||||
@@ -268,7 +276,7 @@ To change Kconfig settings before building:
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm -it \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.2 bash -c \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"idf.py set-target esp32s3 && idf.py menuconfig"
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# DevKitC-1 (display-less) production overlay.
|
||||
#
|
||||
# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime
|
||||
# probe false-positives on it: with no TCA9554 and floating QSPI pins, the
|
||||
# SH8601 init sequence reports success, display_is_active() returns true, and
|
||||
# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield
|
||||
# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support
|
||||
# out makes has_display constant-false so the upgrade always applies.
|
||||
#
|
||||
# Build (from repo root, per README "Docker — the only reliable method"):
|
||||
# MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
# espressif/idf:v5.4 bash -c \
|
||||
# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build"
|
||||
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
@@ -147,4 +147,17 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiService = new ApiService();
|
||||
export const apiService = new ApiService();
|
||||
|
||||
// Storage key shared with the QuickSettings "API Access" panel.
|
||||
export const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
// Apply a previously-saved bearer token at module load — before app init
|
||||
// dispatches its first request — so a configured RUVIEW_API_TOKEN works from
|
||||
// the very first /api/v1/* call. The server only ever checks the
|
||||
// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally
|
||||
// never puts the token in a URL query string.
|
||||
try {
|
||||
const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY);
|
||||
if (storedToken) apiService.setAuthToken(storedToken);
|
||||
} catch { /* storage unavailable (private browsing etc.) */ }
|
||||
@@ -4088,6 +4088,20 @@ a:focus-visible,
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.qs-text-input {
|
||||
padding: var(--space-6) var(--space-8);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.qs-text-input:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* --- Screenshot Flash --- */
|
||||
|
||||
.screenshot-flash {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Quick Settings Panel - Centralized configuration for all UI features
|
||||
// Accessible via gear icon in header
|
||||
|
||||
import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js';
|
||||
|
||||
export class QuickSettings {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
@@ -9,6 +11,8 @@ export class QuickSettings {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
// A stored token is applied at api.service.js module load (before any
|
||||
// request fires) — this panel only saves/clears it.
|
||||
init() {
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
@@ -70,6 +74,18 @@ export class QuickSettings {
|
||||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">API Access</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
|
||||
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
|
||||
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
|
||||
</div>
|
||||
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Data</div>
|
||||
<div class="qs-row">
|
||||
@@ -112,6 +128,30 @@ export class QuickSettings {
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
const token = input.value.trim();
|
||||
if (!token) {
|
||||
status.textContent = 'Enter a token first, or use Clear to remove one.';
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
|
||||
apiService.setAuthToken(token);
|
||||
status.textContent = 'Token saved and applied. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
apiService.setAuthToken(null);
|
||||
input.value = '';
|
||||
status.textContent = 'Token cleared. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
@@ -154,6 +194,10 @@ export class QuickSettings {
|
||||
if (this.getSetting('compact')) {
|
||||
document.body.classList.add('compact-mode');
|
||||
}
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
let hasToken = false;
|
||||
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
|
||||
}
|
||||
|
||||
prefersReducedMotion() {
|
||||
|
||||
Generated
+15
-16
@@ -7533,7 +7533,6 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"wifi-densepose-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10961,7 +10960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -11092,6 +11091,17 @@ dependencies = [
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-posecode"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-rufield"
|
||||
version = "0.3.0"
|
||||
@@ -11126,7 +11136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-sensing-server"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -11161,7 +11171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
@@ -11268,7 +11278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
@@ -11277,17 +11287,6 @@ dependencies = [
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldmodel"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
||||
@@ -46,6 +46,9 @@ members = [
|
||||
# PR #491 slot heuristic with a Candle network + Stoer-Wagner fusion.
|
||||
# Motivated by #499 ghost-skeleton reports.
|
||||
"crates/cog-person-count",
|
||||
# ADR-266: Multi-actor PoseCode scene protocol. Converts persistent
|
||||
# RuvSense PoseTracks into confidence-scored, deterministic motion scenes.
|
||||
"crates/wifi-densepose-posecode",
|
||||
# ADR-116: Home Assistant + Matter Cognitum Seed cog. Wraps the
|
||||
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
||||
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
||||
@@ -215,6 +218,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
|
||||
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
|
||||
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
|
||||
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
|
||||
wifi-densepose-posecode = { version = "0.3.0", path = "crates/wifi-densepose-posecode" }
|
||||
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "wifi-densepose-engine"
|
||||
description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -226,6 +226,19 @@ impl StreamingEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the multistatic fuser's timestamp guard interval (#1049/#1057).
|
||||
/// Without this, `StreamingEngine::new` always builds
|
||||
/// `MultistaticFuser::with_config(MultistaticConfig::default())` — a
|
||||
/// hardcoded 60 ms hard guard that ignores whatever schedule/override the
|
||||
/// caller derived from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`, so
|
||||
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
|
||||
/// trust cycles even after widening the guard elsewhere.
|
||||
///
|
||||
/// Rebuilds the fuser, so call before any frames are processed.
|
||||
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
|
||||
self.fuser = MultistaticFuser::with_config(cfg);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "wifi-densepose-posecode"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Multi-actor semantic motion scenes for RuView PoseTrack streams"
|
||||
documentation = "https://docs.rs/wifi-densepose-posecode"
|
||||
keywords = ["wifi", "pose", "motion", "posecode", "multi-person"]
|
||||
categories = ["science", "computer-vision", "parser-implementations"]
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-signal = { version = "0.3.5", path = "../wifi-densepose-signal", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ruview = ["dep:wifi-densepose-signal"]
|
||||
|
||||
[[bench]]
|
||||
name = "posecode_bench"
|
||||
harness = false
|
||||
@@ -0,0 +1,37 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use wifi_densepose_posecode::{parse_posecode, to_posecode};
|
||||
|
||||
const TWO_ACTORS: &str = r#"posecode scene "crossing"
|
||||
source observed_wifi_csi
|
||||
actor person_1:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
track 1
|
||||
confidence 0.8
|
||||
position 0 0 0
|
||||
actor person_2:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
track 2
|
||||
confidence 0.8
|
||||
position 1 0 0
|
||||
step "Move" 0.05s linear:
|
||||
person_1.knee_left: flex 30 0.8
|
||||
person_1.hip_left: flex 20 0.8
|
||||
person_2.knee_right: flex 35 0.8
|
||||
person_2.hip_right: flex 25 0.8
|
||||
repeat 1
|
||||
"#;
|
||||
|
||||
fn benchmarks(c: &mut Criterion) {
|
||||
c.bench_function("parse_two_actor_scene", |b| {
|
||||
b.iter(|| parse_posecode(black_box(TWO_ACTORS)).unwrap())
|
||||
});
|
||||
let scene = parse_posecode(TWO_ACTORS).unwrap();
|
||||
c.bench_function("serialize_two_actor_scene", |b| {
|
||||
b.iter(|| to_posecode(black_box(&scene)))
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, benchmarks);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Conversion from tracked COCO-17 skeletons to semantic joint observations.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{ActorObservation, JointTarget, SceneFrame, Vec3};
|
||||
|
||||
const NUM_KEYPOINTS: usize = 17;
|
||||
const LEFT_SHOULDER: usize = 5;
|
||||
const RIGHT_SHOULDER: usize = 6;
|
||||
const LEFT_ELBOW: usize = 7;
|
||||
const RIGHT_ELBOW: usize = 8;
|
||||
const LEFT_WRIST: usize = 9;
|
||||
const RIGHT_WRIST: usize = 10;
|
||||
const LEFT_HIP: usize = 11;
|
||||
const RIGHT_HIP: usize = 12;
|
||||
const LEFT_KNEE: usize = 13;
|
||||
const RIGHT_KNEE: usize = 14;
|
||||
const LEFT_ANKLE: usize = 15;
|
||||
const RIGHT_ANKLE: usize = 16;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrackState {
|
||||
Tentative,
|
||||
Active,
|
||||
Lost,
|
||||
Terminated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct KeypointObservation {
|
||||
pub position: Vec3,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TrackObservation {
|
||||
pub id: u64,
|
||||
pub state: TrackState,
|
||||
pub keypoints: [KeypointObservation; NUM_KEYPOINTS],
|
||||
pub time_since_update: u64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ruview")]
|
||||
impl From<&wifi_densepose_signal::ruvsense::PoseTrack> for TrackObservation {
|
||||
fn from(track: &wifi_densepose_signal::ruvsense::PoseTrack) -> Self {
|
||||
use wifi_densepose_signal::ruvsense::TrackLifecycleState;
|
||||
Self {
|
||||
id: track.id.0,
|
||||
state: match track.lifecycle {
|
||||
TrackLifecycleState::Tentative => TrackState::Tentative,
|
||||
TrackLifecycleState::Active => TrackState::Active,
|
||||
TrackLifecycleState::Lost => TrackState::Lost,
|
||||
TrackLifecycleState::Terminated => TrackState::Terminated,
|
||||
},
|
||||
keypoints: std::array::from_fn(|i| {
|
||||
let point = track.keypoints[i].position();
|
||||
KeypointObservation {
|
||||
position: Vec3::new(point[0], point[1], point[2]),
|
||||
confidence: track.keypoints[i].confidence,
|
||||
}
|
||||
}),
|
||||
time_since_update: track.time_since_update,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdapterConfig {
|
||||
/// Coordinate system up vector after room calibration.
|
||||
pub world_up: Vec3,
|
||||
/// Coordinate system forward vector after room calibration.
|
||||
pub world_forward: Vec3,
|
||||
/// Ignore tracks below this aggregate confidence.
|
||||
pub min_confidence: f32,
|
||||
/// Confidence used until an inference backend populates keypoint confidence.
|
||||
pub unscored_confidence: f32,
|
||||
/// Emit lost tracks as predicted observations.
|
||||
pub include_lost: bool,
|
||||
}
|
||||
|
||||
impl Default for AdapterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
world_up: Vec3::new(0.0, 1.0, 0.0),
|
||||
world_forward: Vec3::new(0.0, 0.0, 1.0),
|
||||
min_confidence: 0.2,
|
||||
unscored_confidence: 0.5,
|
||||
include_lost: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuViewAdapter {
|
||||
config: AdapterConfig,
|
||||
}
|
||||
|
||||
impl RuViewAdapter {
|
||||
pub fn new(config: AdapterConfig) -> Result<Self> {
|
||||
if !config.world_up.is_finite() || !config.world_forward.is_finite() {
|
||||
return Err(Error::Observation("coordinate axes must be finite".into()));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&config.min_confidence)
|
||||
|| !(0.0..=1.0).contains(&config.unscored_confidence)
|
||||
{
|
||||
return Err(Error::Observation("confidence must be in [0, 1]".into()));
|
||||
}
|
||||
if norm(config.world_up) < 1e-6 || norm(config.world_forward) < 1e-6 {
|
||||
return Err(Error::Observation(
|
||||
"coordinate axes must be non-zero".into(),
|
||||
));
|
||||
}
|
||||
let alignment = dot(normalize(config.world_up), normalize(config.world_forward)).abs();
|
||||
if alignment > 0.98 {
|
||||
return Err(Error::Observation(
|
||||
"world up and forward axes must not be parallel".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
/// Convert all visible tracks into one synchronized observation frame.
|
||||
pub fn frame(&self, timestamp_ms: u64, tracks: &[TrackObservation]) -> SceneFrame {
|
||||
let actors = tracks
|
||||
.iter()
|
||||
.filter_map(|track| self.actor(track))
|
||||
.collect();
|
||||
SceneFrame {
|
||||
timestamp_ms,
|
||||
actors,
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct bridge for the RuView tracker aggregate. The feature is optional
|
||||
/// so protocol-only consumers do not pull the signal and RuVector graph.
|
||||
#[cfg(feature = "ruview")]
|
||||
pub fn frame_from_pose_tracks(
|
||||
&self,
|
||||
timestamp_ms: u64,
|
||||
tracks: &[&wifi_densepose_signal::ruvsense::PoseTrack],
|
||||
) -> SceneFrame {
|
||||
let observations: Vec<TrackObservation> =
|
||||
tracks.iter().map(|track| (*track).into()).collect();
|
||||
self.frame(timestamp_ms, &observations)
|
||||
}
|
||||
|
||||
pub fn actor(&self, track: &TrackObservation) -> Option<ActorObservation> {
|
||||
if track.state == TrackState::Terminated
|
||||
|| (!self.config.include_lost && track.state == TrackState::Lost)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let confidence = track_confidence(track, self.config.unscored_confidence);
|
||||
if confidence < self.config.min_confidence {
|
||||
return None;
|
||||
}
|
||||
|
||||
let p = |index: usize| track.keypoints[index].position;
|
||||
if track.keypoints.iter().any(|kp| !kp.position.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let left_hip = p(LEFT_HIP);
|
||||
let right_hip = p(RIGHT_HIP);
|
||||
let position = midpoint(left_hip, right_hip);
|
||||
let up = normalize(self.config.world_up);
|
||||
let forward = normalize(sub(
|
||||
self.config.world_forward,
|
||||
scale(up, dot(self.config.world_forward, up)),
|
||||
));
|
||||
|
||||
let mut joints = Vec::with_capacity(10);
|
||||
add_flexion(
|
||||
&mut joints,
|
||||
"elbow_left",
|
||||
p(LEFT_SHOULDER),
|
||||
p(LEFT_ELBOW),
|
||||
p(LEFT_WRIST),
|
||||
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW, LEFT_WRIST], confidence),
|
||||
);
|
||||
add_flexion(
|
||||
&mut joints,
|
||||
"elbow_right",
|
||||
p(RIGHT_SHOULDER),
|
||||
p(RIGHT_ELBOW),
|
||||
p(RIGHT_WRIST),
|
||||
joint_confidence(
|
||||
track,
|
||||
&[RIGHT_SHOULDER, RIGHT_ELBOW, RIGHT_WRIST],
|
||||
confidence,
|
||||
),
|
||||
);
|
||||
add_flexion(
|
||||
&mut joints,
|
||||
"knee_left",
|
||||
left_hip,
|
||||
p(LEFT_KNEE),
|
||||
p(LEFT_ANKLE),
|
||||
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE, LEFT_ANKLE], confidence),
|
||||
);
|
||||
add_flexion(
|
||||
&mut joints,
|
||||
"knee_right",
|
||||
right_hip,
|
||||
p(RIGHT_KNEE),
|
||||
p(RIGHT_ANKLE),
|
||||
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE, RIGHT_ANKLE], confidence),
|
||||
);
|
||||
|
||||
add_signed_flex(
|
||||
&mut joints,
|
||||
"hip_left",
|
||||
sub(p(LEFT_KNEE), left_hip),
|
||||
up,
|
||||
forward,
|
||||
joint_confidence(track, &[LEFT_HIP, LEFT_KNEE], confidence),
|
||||
);
|
||||
add_signed_flex(
|
||||
&mut joints,
|
||||
"hip_right",
|
||||
sub(p(RIGHT_KNEE), right_hip),
|
||||
up,
|
||||
forward,
|
||||
joint_confidence(track, &[RIGHT_HIP, RIGHT_KNEE], confidence),
|
||||
);
|
||||
add_signed_flex(
|
||||
&mut joints,
|
||||
"shoulder_left",
|
||||
sub(p(LEFT_ELBOW), p(LEFT_SHOULDER)),
|
||||
up,
|
||||
forward,
|
||||
joint_confidence(track, &[LEFT_SHOULDER, LEFT_ELBOW], confidence),
|
||||
);
|
||||
add_signed_flex(
|
||||
&mut joints,
|
||||
"shoulder_right",
|
||||
sub(p(RIGHT_ELBOW), p(RIGHT_SHOULDER)),
|
||||
up,
|
||||
forward,
|
||||
joint_confidence(track, &[RIGHT_SHOULDER, RIGHT_ELBOW], confidence),
|
||||
);
|
||||
|
||||
let shoulder_mid = midpoint(p(LEFT_SHOULDER), p(RIGHT_SHOULDER));
|
||||
let torso = sub(shoulder_mid, position);
|
||||
let hinge = angle_deg(torso, up).clamp(0.0, 180.0);
|
||||
joints.push(JointTarget {
|
||||
joint: "pelvis".into(),
|
||||
action: "hinge".into(),
|
||||
degrees: hinge,
|
||||
confidence: joint_confidence(
|
||||
track,
|
||||
&[LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP],
|
||||
confidence,
|
||||
),
|
||||
});
|
||||
|
||||
Some(ActorObservation {
|
||||
actor_id: format!("person_{}", track.id),
|
||||
track_id: track.id,
|
||||
confidence,
|
||||
position,
|
||||
joints,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn track_confidence(track: &TrackObservation, fallback: f32) -> f32 {
|
||||
let scored: Vec<f32> = track
|
||||
.keypoints
|
||||
.iter()
|
||||
.map(|kp| kp.confidence)
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.collect();
|
||||
let measured = if scored.is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
scored.iter().sum::<f32>() / scored.len() as f32
|
||||
};
|
||||
let freshness = 1.0 / (1.0 + track.time_since_update as f32 * 0.2);
|
||||
(measured * freshness).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn joint_confidence(track: &TrackObservation, indices: &[usize], fallback: f32) -> f32 {
|
||||
indices.iter().fold(1.0_f32, |confidence, &index| {
|
||||
let measured = track.keypoints[index].confidence;
|
||||
confidence.min(if measured.is_finite() && measured > 0.0 {
|
||||
measured
|
||||
} else {
|
||||
fallback
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn add_flexion(out: &mut Vec<JointTarget>, name: &str, a: Vec3, b: Vec3, c: Vec3, confidence: f32) {
|
||||
let inner = angle_deg(sub(a, b), sub(c, b));
|
||||
if inner.is_finite() {
|
||||
out.push(JointTarget {
|
||||
joint: name.into(),
|
||||
action: "flex".into(),
|
||||
degrees: (180.0 - inner).clamp(0.0, 180.0),
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn add_signed_flex(
|
||||
out: &mut Vec<JointTarget>,
|
||||
name: &str,
|
||||
limb: Vec3,
|
||||
up: Vec3,
|
||||
forward: Vec3,
|
||||
confidence: f32,
|
||||
) {
|
||||
let down = scale(up, -1.0);
|
||||
let degrees = dot(limb, forward).atan2(dot(limb, down)).to_degrees();
|
||||
if degrees.is_finite() {
|
||||
out.push(JointTarget {
|
||||
joint: name.into(),
|
||||
action: if degrees >= 0.0 { "flex" } else { "extend" }.into(),
|
||||
degrees: degrees.abs().clamp(0.0, 180.0),
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn midpoint(a: Vec3, b: Vec3) -> Vec3 {
|
||||
scale(add(a, b), 0.5)
|
||||
}
|
||||
fn add(a: Vec3, b: Vec3) -> Vec3 {
|
||||
Vec3::new(a.x + b.x, a.y + b.y, a.z + b.z)
|
||||
}
|
||||
fn sub(a: Vec3, b: Vec3) -> Vec3 {
|
||||
Vec3::new(a.x - b.x, a.y - b.y, a.z - b.z)
|
||||
}
|
||||
fn scale(v: Vec3, s: f32) -> Vec3 {
|
||||
Vec3::new(v.x * s, v.y * s, v.z * s)
|
||||
}
|
||||
fn dot(a: Vec3, b: Vec3) -> f32 {
|
||||
a.x * b.x + a.y * b.y + a.z * b.z
|
||||
}
|
||||
fn norm(v: Vec3) -> f32 {
|
||||
dot(v, v).sqrt()
|
||||
}
|
||||
fn normalize(v: Vec3) -> Vec3 {
|
||||
scale(v, 1.0 / norm(v).max(1e-9))
|
||||
}
|
||||
fn angle_deg(a: Vec3, b: Vec3) -> f32 {
|
||||
let denom = norm(a) * norm(b);
|
||||
if denom < 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
(dot(a, b) / denom).clamp(-1.0, 1.0).acos().to_degrees()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn standing_track() -> TrackObservation {
|
||||
let mut keypoints = [KeypointObservation::default(); NUM_KEYPOINTS];
|
||||
let mut set = |index, x, y, z| {
|
||||
keypoints[index] = KeypointObservation {
|
||||
position: Vec3::new(x, y, z),
|
||||
confidence: 0.9,
|
||||
};
|
||||
};
|
||||
set(LEFT_SHOULDER, -0.2, 1.5, 0.0);
|
||||
set(RIGHT_SHOULDER, 0.2, 1.5, 0.0);
|
||||
set(LEFT_ELBOW, -0.25, 1.1, 0.0);
|
||||
set(RIGHT_ELBOW, 0.25, 1.1, 0.0);
|
||||
set(LEFT_WRIST, -0.25, 0.75, 0.0);
|
||||
set(RIGHT_WRIST, 0.25, 0.75, 0.0);
|
||||
set(LEFT_HIP, -0.12, 0.9, 0.0);
|
||||
set(RIGHT_HIP, 0.12, 0.9, 0.0);
|
||||
set(LEFT_KNEE, -0.12, 0.5, 0.0);
|
||||
set(RIGHT_KNEE, 0.12, 0.5, 0.0);
|
||||
set(LEFT_ANKLE, -0.12, 0.05, 0.0);
|
||||
set(RIGHT_ANKLE, 0.12, 0.05, 0.0);
|
||||
TrackObservation {
|
||||
id: 7,
|
||||
state: TrackState::Active,
|
||||
keypoints,
|
||||
time_since_update: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_track_to_named_actor() {
|
||||
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
|
||||
let actor = adapter.actor(&standing_track()).unwrap();
|
||||
assert_eq!(actor.actor_id, "person_7");
|
||||
assert_eq!(actor.track_id, 7);
|
||||
assert!(actor.joints.len() >= 9);
|
||||
assert!(actor.joints.iter().all(|j| j.degrees.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_axes() {
|
||||
let config = AdapterConfig {
|
||||
world_up: Vec3::default(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RuViewAdapter::new(config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_parallel_coordinate_axes() {
|
||||
let config = AdapterConfig {
|
||||
world_forward: Vec3::new(0.0, 2.0, 0.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(RuViewAdapter::new(config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_stale_low_confidence_tracks() {
|
||||
let mut track = standing_track();
|
||||
track.time_since_update = 100;
|
||||
let adapter = RuViewAdapter::new(AdapterConfig::default()).unwrap();
|
||||
assert!(adapter.actor(&track).is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "ruview")]
|
||||
#[test]
|
||||
fn converts_native_ruview_pose_track() {
|
||||
use wifi_densepose_signal::ruvsense::{PoseTrack, TrackId, TrackLifecycleState};
|
||||
|
||||
let source = standing_track();
|
||||
let positions = std::array::from_fn(|i| {
|
||||
let p = source.keypoints[i].position;
|
||||
[p.x, p.y, p.z]
|
||||
});
|
||||
let mut native = PoseTrack::new(TrackId(23), &positions, 0, 128);
|
||||
native.lifecycle = TrackLifecycleState::Active;
|
||||
let observed = TrackObservation::from(&native);
|
||||
assert_eq!(observed.id, 23);
|
||||
assert_eq!(observed.state, TrackState::Active);
|
||||
assert_eq!(
|
||||
observed.keypoints[LEFT_HIP].position,
|
||||
source.keypoints[LEFT_HIP].position
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Error types for parser and adapter boundaries.
|
||||
|
||||
/// Crate result alias.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Errors are structured and line anchored where input text is involved.
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum Error {
|
||||
#[error("line {line}: {message}")]
|
||||
Parse { line: usize, message: String },
|
||||
#[error("invalid scene: {0}")]
|
||||
Validation(String),
|
||||
#[error("invalid observation: {0}")]
|
||||
Observation(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn parse(line: usize, message: impl Into<String>) -> Self {
|
||||
Self::Parse {
|
||||
line,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Multi-actor semantic motion scenes for RuView.
|
||||
//!
|
||||
//! This crate uses the PoseCode movement vocabulary and implements the
|
||||
//! RuView 0.2 multi-actor extension from ADR-266. It deliberately separates
|
||||
//! the high-rate observed pose stream from the compact semantic scene:
|
||||
//! [`adapter::RuViewAdapter`] converts persistent RuView tracks into observed
|
||||
//! frames, [`segmenter::PhaseSegmenter`] reduces those frames to synchronized
|
||||
//! phases, and [`serialize::to_posecode`] emits deterministic text.
|
||||
|
||||
pub mod adapter;
|
||||
pub mod error;
|
||||
pub mod model;
|
||||
pub mod parser;
|
||||
pub mod segmenter;
|
||||
pub mod serialize;
|
||||
pub mod validate;
|
||||
|
||||
pub use adapter::{
|
||||
AdapterConfig, KeypointObservation, RuViewAdapter, TrackObservation, TrackState,
|
||||
};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::*;
|
||||
pub use parser::parse_posecode;
|
||||
pub use segmenter::{PhaseSegmenter, SegmenterConfig};
|
||||
pub use serialize::to_posecode;
|
||||
pub use validate::{validate_scene, ValidationConfig, ValidationIssue, ValidationSeverity};
|
||||
|
||||
/// Protocol version implemented by this crate.
|
||||
pub const PROTOCOL_VERSION: &str = "0.2";
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Renderer independent scene model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Hard limits keep untrusted scene documents bounded in memory and time.
|
||||
pub const MAX_ACTORS: usize = 32;
|
||||
pub const MAX_PHASES: usize = 10_000;
|
||||
pub const MAX_TARGETS_PER_ACTOR: usize = 64;
|
||||
pub const MAX_NAME_BYTES: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec3 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
pub fn new(x: f32, y: f32, z: f32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
|
||||
pub fn distance(self, other: Self) -> f32 {
|
||||
let dx = self.x - other.x;
|
||||
let dy = self.y - other.y;
|
||||
let dz = self.z - other.z;
|
||||
(dx * dx + dy * dy + dz * dz).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EulerDeg {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
impl EulerDeg {
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SceneSource {
|
||||
Authored,
|
||||
ObservedWifiCsi,
|
||||
Imported,
|
||||
}
|
||||
|
||||
impl Default for SceneSource {
|
||||
fn default() -> Self {
|
||||
Self::Authored
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Actor {
|
||||
pub id: String,
|
||||
pub rig: String,
|
||||
pub start_pose: String,
|
||||
pub track_id: Option<u64>,
|
||||
pub confidence: f32,
|
||||
pub position: Vec3,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn humanoid(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
rig: "humanoid".into(),
|
||||
start_pose: "standing".into(),
|
||||
track_id: None,
|
||||
confidence: 1.0,
|
||||
position: Vec3::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct JointTarget {
|
||||
pub joint: String,
|
||||
pub action: String,
|
||||
pub degrees: f32,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ActorTargets {
|
||||
pub actor_id: String,
|
||||
pub joints: Vec<JointTarget>,
|
||||
pub ground_lock: Vec<String>,
|
||||
pub travel: Option<Vec3>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
impl ActorTargets {
|
||||
pub fn new(actor_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
actor_id: actor_id.into(),
|
||||
joints: Vec::new(),
|
||||
ground_lock: Vec::new(),
|
||||
travel: None,
|
||||
confidence: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EffectorRef {
|
||||
pub actor_id: String,
|
||||
pub effector: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
pub from: EffectorRef,
|
||||
pub to: EffectorRef,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Timing {
|
||||
Flow,
|
||||
Settle,
|
||||
Drive,
|
||||
Snap,
|
||||
Linear,
|
||||
EaseIn,
|
||||
EaseOut,
|
||||
EaseInOut,
|
||||
}
|
||||
|
||||
impl Timing {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Flow => "flow",
|
||||
Self::Settle => "settle",
|
||||
Self::Drive => "drive",
|
||||
Self::Snap => "snap",
|
||||
Self::Linear => "linear",
|
||||
Self::EaseIn => "ease-in",
|
||||
Self::EaseOut => "ease-out",
|
||||
Self::EaseInOut => "ease-in-out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Phase {
|
||||
pub name: String,
|
||||
pub start_ms: u64,
|
||||
pub duration_ms: u64,
|
||||
pub timing: Timing,
|
||||
pub actors: Vec<ActorTargets>,
|
||||
pub contacts: Vec<Contact>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Scene {
|
||||
pub version: String,
|
||||
pub name: String,
|
||||
pub source: SceneSource,
|
||||
pub actors: Vec<Actor>,
|
||||
pub phases: Vec<Phase>,
|
||||
pub repeat: u32,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
version: crate::PROTOCOL_VERSION.into(),
|
||||
name: name.into(),
|
||||
source: SceneSource::Authored,
|
||||
actors: Vec::new(),
|
||||
phases: Vec::new(),
|
||||
repeat: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One actor at one observed instant. Raw observations remain separate from
|
||||
/// semantic phases so 20 Hz input does not become unreadable scene text.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ActorObservation {
|
||||
pub actor_id: String,
|
||||
pub track_id: u64,
|
||||
pub confidence: f32,
|
||||
pub position: Vec3,
|
||||
pub joints: Vec<JointTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneFrame {
|
||||
pub timestamp_ms: u64,
|
||||
pub actors: Vec<ActorObservation>,
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Bounded line parser for the PoseCode 0.2 multi-actor extension.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::*;
|
||||
use crate::validate::{validate_scene, ValidationConfig, ValidationSeverity};
|
||||
|
||||
const MAX_INPUT_BYTES: usize = 1_048_576;
|
||||
const MAX_LINE_BYTES: usize = 4096;
|
||||
|
||||
pub fn parse_posecode(source: &str) -> Result<Scene> {
|
||||
if source.len() > MAX_INPUT_BYTES {
|
||||
return Err(Error::parse(0, "document exceeds 1 MiB"));
|
||||
}
|
||||
let mut scene: Option<Scene> = None;
|
||||
let mut current_actor: Option<usize> = None;
|
||||
let mut current_phase: Option<usize> = None;
|
||||
let mut elapsed_ms = 0_u64;
|
||||
|
||||
for (index, raw) in source.lines().enumerate() {
|
||||
let line_no = index + 1;
|
||||
if raw.len() > MAX_LINE_BYTES {
|
||||
return Err(Error::parse(line_no, "line exceeds 4096 bytes"));
|
||||
}
|
||||
let line = strip_comment(raw).trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if scene.is_none() {
|
||||
let rest = line
|
||||
.strip_prefix("posecode scene ")
|
||||
.ok_or_else(|| Error::parse(line_no, "expected posecode scene header"))?;
|
||||
scene = Some(Scene::new(parse_quoted(rest, line_no)?));
|
||||
continue;
|
||||
}
|
||||
let doc = scene.as_mut().expect("initialized above");
|
||||
|
||||
if let Some(value) = line.strip_prefix("source ") {
|
||||
doc.source = match value {
|
||||
"authored" => SceneSource::Authored,
|
||||
"observed_wifi_csi" => SceneSource::ObservedWifiCsi,
|
||||
"imported" => SceneSource::Imported,
|
||||
_ => return Err(Error::parse(line_no, "unknown source")),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("actor ") {
|
||||
let id = value
|
||||
.strip_suffix(':')
|
||||
.ok_or_else(|| Error::parse(line_no, "actor declaration must end with ':'"))?;
|
||||
doc.actors.push(Actor::humanoid(id.trim()));
|
||||
current_actor = Some(doc.actors.len() - 1);
|
||||
current_phase = None;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("step ") {
|
||||
let (name, tail) = take_quoted(value, line_no)?;
|
||||
let fields: Vec<&str> = tail.trim_end_matches(':').split_whitespace().collect();
|
||||
if fields.len() != 2 {
|
||||
return Err(Error::parse(line_no, "step requires duration and timing"));
|
||||
}
|
||||
let duration_ms = parse_duration(fields[0], line_no)?;
|
||||
let timing = parse_timing(fields[1], line_no)?;
|
||||
doc.phases.push(Phase {
|
||||
name,
|
||||
start_ms: elapsed_ms,
|
||||
duration_ms,
|
||||
timing,
|
||||
actors: Vec::new(),
|
||||
contacts: Vec::new(),
|
||||
});
|
||||
elapsed_ms = elapsed_ms
|
||||
.checked_add(duration_ms)
|
||||
.ok_or_else(|| Error::parse(line_no, "timeline overflow"))?;
|
||||
current_phase = Some(doc.phases.len() - 1);
|
||||
current_actor = None;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("repeat ") {
|
||||
doc.repeat = value
|
||||
.parse()
|
||||
.map_err(|_| Error::parse(line_no, "repeat must be an integer"))?;
|
||||
if doc.repeat == 0 || doc.repeat > 10_000 {
|
||||
return Err(Error::parse(line_no, "repeat must be 1 to 10000"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ai) = current_actor {
|
||||
parse_actor_field(&mut doc.actors[ai], line, line_no)?;
|
||||
continue;
|
||||
}
|
||||
if let Some(pi) = current_phase {
|
||||
parse_phase_field(&mut doc.phases[pi], line, line_no)?;
|
||||
continue;
|
||||
}
|
||||
return Err(Error::parse(line_no, "directive is outside actor or step"));
|
||||
}
|
||||
|
||||
let scene = scene.ok_or_else(|| Error::parse(0, "empty document"))?;
|
||||
if let Some(issue) = validate_scene(&scene, &ValidationConfig::default())
|
||||
.into_iter()
|
||||
.find(|i| i.severity == ValidationSeverity::Error)
|
||||
{
|
||||
return Err(Error::Validation(format!(
|
||||
"{}: {}",
|
||||
issue.path, issue.message
|
||||
)));
|
||||
}
|
||||
Ok(scene)
|
||||
}
|
||||
|
||||
fn parse_actor_field(actor: &mut Actor, line: &str, line_no: usize) -> Result<()> {
|
||||
if let Some(value) = line.strip_prefix("rig ") {
|
||||
actor.rig = value.trim().into();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("pose start = ") {
|
||||
actor.start_pose = value.trim().into();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("track ") {
|
||||
actor.track_id = Some(
|
||||
value
|
||||
.parse()
|
||||
.map_err(|_| Error::parse(line_no, "track must be an integer"))?,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("confidence ") {
|
||||
actor.confidence = parse_confidence(value, line_no)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("position ") {
|
||||
actor.position = parse_vec3(value, line_no)?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::parse(line_no, "unknown actor directive"))
|
||||
}
|
||||
|
||||
fn parse_phase_field(phase: &mut Phase, line: &str, line_no: usize) -> Result<()> {
|
||||
if let Some(value) = line.strip_prefix("contact ") {
|
||||
let fields: Vec<&str> = value.split_whitespace().collect();
|
||||
if !(fields.len() == 2 || fields.len() == 3) {
|
||||
return Err(Error::parse(
|
||||
line_no,
|
||||
"contact requires two effectors and optional confidence",
|
||||
));
|
||||
}
|
||||
phase.contacts.push(Contact {
|
||||
from: parse_effector(fields[0], line_no)?,
|
||||
to: parse_effector(fields[1], line_no)?,
|
||||
confidence: if fields.len() == 3 {
|
||||
parse_confidence(fields[2], line_no)?
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let (lhs, rhs) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| Error::parse(line_no, "phase directive requires ':'"))?;
|
||||
let (actor_id, target) = lhs
|
||||
.trim()
|
||||
.split_once('.')
|
||||
.ok_or_else(|| Error::parse(line_no, "target must be actor qualified"))?;
|
||||
let actor = phase_actor_mut(phase, actor_id);
|
||||
match target {
|
||||
"ground-lock" => {
|
||||
actor.ground_lock = rhs
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
}
|
||||
"travel" => {
|
||||
actor.travel = Some(parse_vec3_or_xz(rhs, line_no)?);
|
||||
}
|
||||
joint => {
|
||||
let fields: Vec<&str> = rhs.split_whitespace().collect();
|
||||
if !(fields.len() == 2 || fields.len() == 3) {
|
||||
return Err(Error::parse(
|
||||
line_no,
|
||||
"joint target requires action, degrees and optional confidence",
|
||||
));
|
||||
}
|
||||
actor.joints.push(JointTarget {
|
||||
joint: joint.into(),
|
||||
action: fields[0].into(),
|
||||
degrees: fields[1]
|
||||
.parse()
|
||||
.map_err(|_| Error::parse(line_no, "degrees must be numeric"))?,
|
||||
confidence: if fields.len() == 3 {
|
||||
parse_confidence(fields[2], line_no)?
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn phase_actor_mut<'a>(phase: &'a mut Phase, id: &str) -> &'a mut ActorTargets {
|
||||
if let Some(index) = phase.actors.iter().position(|a| a.actor_id == id) {
|
||||
return &mut phase.actors[index];
|
||||
}
|
||||
phase.actors.push(ActorTargets::new(id));
|
||||
phase.actors.last_mut().expect("just pushed")
|
||||
}
|
||||
|
||||
fn parse_effector(value: &str, line: usize) -> Result<EffectorRef> {
|
||||
let (actor_id, effector) = value
|
||||
.split_once('.')
|
||||
.ok_or_else(|| Error::parse(line, "effector must be actor qualified"))?;
|
||||
Ok(EffectorRef {
|
||||
actor_id: actor_id.into(),
|
||||
effector: effector.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_vec3(value: &str, line: usize) -> Result<Vec3> {
|
||||
let f: Vec<&str> = value.split_whitespace().collect();
|
||||
if f.len() != 3 {
|
||||
return Err(Error::parse(line, "position requires x y z"));
|
||||
}
|
||||
Ok(Vec3::new(
|
||||
parse_f32(f[0], line)?,
|
||||
parse_f32(f[1], line)?,
|
||||
parse_f32(f[2], line)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_vec3_or_xz(value: &str, line: usize) -> Result<Vec3> {
|
||||
let f: Vec<&str> = value.split_whitespace().collect();
|
||||
match f.len() {
|
||||
2 => Ok(Vec3::new(
|
||||
parse_f32(f[0], line)?,
|
||||
0.0,
|
||||
parse_f32(f[1], line)?,
|
||||
)),
|
||||
3 => parse_vec3(value, line),
|
||||
_ => Err(Error::parse(line, "travel requires x z or x y z")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_f32(value: &str, line: usize) -> Result<f32> {
|
||||
let v: f32 = value
|
||||
.parse()
|
||||
.map_err(|_| Error::parse(line, "expected finite number"))?;
|
||||
if !v.is_finite() {
|
||||
return Err(Error::parse(line, "expected finite number"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_confidence(value: &str, line: usize) -> Result<f32> {
|
||||
let v = parse_f32(value, line)?;
|
||||
if !(0.0..=1.0).contains(&v) {
|
||||
return Err(Error::parse(line, "confidence must be in [0, 1]"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_duration(value: &str, line: usize) -> Result<u64> {
|
||||
let seconds = value
|
||||
.strip_suffix('s')
|
||||
.ok_or_else(|| Error::parse(line, "duration must end in s"))?;
|
||||
let seconds = parse_f32(seconds, line)?;
|
||||
if seconds <= 0.0 || seconds > 300.0 {
|
||||
return Err(Error::parse(line, "duration must be in (0, 300] seconds"));
|
||||
}
|
||||
Ok((seconds * 1000.0).round() as u64)
|
||||
}
|
||||
|
||||
fn parse_timing(value: &str, line: usize) -> Result<Timing> {
|
||||
match value {
|
||||
"flow" => Ok(Timing::Flow),
|
||||
"settle" => Ok(Timing::Settle),
|
||||
"drive" => Ok(Timing::Drive),
|
||||
"snap" => Ok(Timing::Snap),
|
||||
"linear" => Ok(Timing::Linear),
|
||||
"ease-in" => Ok(Timing::EaseIn),
|
||||
"ease-out" => Ok(Timing::EaseOut),
|
||||
"ease-in-out" => Ok(Timing::EaseInOut),
|
||||
_ => Err(Error::parse(line, "unknown timing")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quoted(value: &str, line: usize) -> Result<String> {
|
||||
let (quoted, tail) = take_quoted(value, line)?;
|
||||
if !tail.trim().is_empty() {
|
||||
return Err(Error::parse(line, "unexpected text after quoted name"));
|
||||
}
|
||||
Ok(quoted)
|
||||
}
|
||||
|
||||
fn take_quoted(value: &str, line: usize) -> Result<(String, &str)> {
|
||||
let rest = value
|
||||
.strip_prefix('"')
|
||||
.ok_or_else(|| Error::parse(line, "expected quoted name"))?;
|
||||
let end = rest
|
||||
.find('"')
|
||||
.ok_or_else(|| Error::parse(line, "unterminated quoted name"))?;
|
||||
Ok((rest[..end].to_string(), &rest[end + 1..]))
|
||||
}
|
||||
|
||||
fn strip_comment(line: &str) -> &str {
|
||||
let hash = line.find('#');
|
||||
let slash = line.find("//");
|
||||
match (hash, slash) {
|
||||
(Some(a), Some(b)) => &line[..a.min(b)],
|
||||
(Some(a), None) | (None, Some(a)) => &line[..a],
|
||||
(None, None) => line,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SCENE: &str = r#"posecode scene "Assisted squat"
|
||||
source observed_wifi_csi
|
||||
actor patient:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
track 7
|
||||
confidence 0.82
|
||||
position 0 0 0
|
||||
actor therapist:
|
||||
rig humanoid
|
||||
pose start = standing
|
||||
position 1.2 0 0
|
||||
step "Lower" 1.5s flow:
|
||||
patient.knee_left: flex 95 0.8
|
||||
patient.ground-lock: feet
|
||||
therapist.shoulder_left: flex 30
|
||||
contact therapist.hand_left patient.shoulder_right 0.7
|
||||
repeat 2
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_actors_and_contacts() {
|
||||
let scene = parse_posecode(SCENE).unwrap();
|
||||
assert_eq!(scene.actors.len(), 2);
|
||||
assert_eq!(scene.phases[0].actors.len(), 2);
|
||||
assert_eq!(scene.phases[0].contacts.len(), 1);
|
||||
assert_eq!(scene.repeat, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_actor_reference() {
|
||||
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nstep \"x\" 1s flow:\n ghost.knee_left: flex 20";
|
||||
assert!(parse_posecode(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_finite_number() {
|
||||
let bad = "posecode scene \"x\"\nactor p1:\n position NaN 0 0";
|
||||
assert!(parse_posecode(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_actors() {
|
||||
let bad = "posecode scene \"x\"\nactor p1:\n rig humanoid\nactor p1:\n rig humanoid";
|
||||
assert!(parse_posecode(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_document_before_parsing() {
|
||||
let bad = "x".repeat(MAX_INPUT_BYTES + 1);
|
||||
assert!(parse_posecode(&bad).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Confidence preserving reduction of high-rate observations into phases.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SegmenterConfig {
|
||||
pub minimum_phase_ms: u64,
|
||||
pub maximum_phase_ms: u64,
|
||||
pub stable_velocity_deg_s: f32,
|
||||
}
|
||||
|
||||
impl Default for SegmenterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
minimum_phase_ms: 200,
|
||||
maximum_phase_ms: 3_000,
|
||||
stable_velocity_deg_s: 8.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PhaseSegmenter {
|
||||
config: SegmenterConfig,
|
||||
}
|
||||
|
||||
impl PhaseSegmenter {
|
||||
pub fn new(config: SegmenterConfig) -> Result<Self> {
|
||||
if config.minimum_phase_ms == 0
|
||||
|| config.maximum_phase_ms < config.minimum_phase_ms
|
||||
|| !config.stable_velocity_deg_s.is_finite()
|
||||
|| config.stable_velocity_deg_s < 0.0
|
||||
{
|
||||
return Err(Error::Observation("invalid segmenter configuration".into()));
|
||||
}
|
||||
Ok(Self { config })
|
||||
}
|
||||
|
||||
pub fn segment(&self, name: impl Into<String>, frames: &[SceneFrame]) -> Result<Scene> {
|
||||
if frames.is_empty() {
|
||||
return Err(Error::Observation("at least one frame is required".into()));
|
||||
}
|
||||
for pair in frames.windows(2) {
|
||||
if pair[1].timestamp_ms <= pair[0].timestamp_ms {
|
||||
return Err(Error::Observation(
|
||||
"timestamps must be strictly increasing".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut scene = Scene::new(name);
|
||||
scene.source = SceneSource::ObservedWifiCsi;
|
||||
scene.actors = actors_from_frames(frames);
|
||||
|
||||
if frames.len() == 1 {
|
||||
return Ok(scene);
|
||||
}
|
||||
let mut start = 0_usize;
|
||||
let mut was_stable = true;
|
||||
for i in 1..frames.len() {
|
||||
let dt = frames[i].timestamp_ms - frames[i - 1].timestamp_ms;
|
||||
let velocity = angular_velocity(&frames[i - 1], &frames[i], dt);
|
||||
let stable = velocity <= self.config.stable_velocity_deg_s;
|
||||
let elapsed = frames[i].timestamp_ms - frames[start].timestamp_ms;
|
||||
let membership_changed = actor_ids(&frames[i - 1]) != actor_ids(&frames[i]);
|
||||
let boundary = membership_changed
|
||||
|| elapsed >= self.config.maximum_phase_ms
|
||||
|| (stable && !was_stable && elapsed >= self.config.minimum_phase_ms);
|
||||
if boundary {
|
||||
scene
|
||||
.phases
|
||||
.push(make_phase(scene.phases.len(), &frames[start], &frames[i]));
|
||||
start = i;
|
||||
}
|
||||
was_stable = stable;
|
||||
}
|
||||
let last = frames.len() - 1;
|
||||
if start < last {
|
||||
scene.phases.push(make_phase(
|
||||
scene.phases.len(),
|
||||
&frames[start],
|
||||
&frames[last],
|
||||
));
|
||||
}
|
||||
Ok(scene)
|
||||
}
|
||||
}
|
||||
|
||||
fn actors_from_frames(frames: &[SceneFrame]) -> Vec<Actor> {
|
||||
let mut found = BTreeMap::<u64, Actor>::new();
|
||||
for frame in frames {
|
||||
for observed in &frame.actors {
|
||||
found
|
||||
.entry(observed.track_id)
|
||||
.and_modify(|actor| {
|
||||
actor.confidence = actor.confidence.max(observed.confidence);
|
||||
actor.position = observed.position;
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
let mut actor = Actor::humanoid(observed.actor_id.clone());
|
||||
actor.track_id = Some(observed.track_id);
|
||||
actor.confidence = observed.confidence;
|
||||
actor.position = observed.position;
|
||||
actor
|
||||
});
|
||||
}
|
||||
}
|
||||
found.into_values().collect()
|
||||
}
|
||||
|
||||
fn make_phase(index: usize, start: &SceneFrame, end: &SceneFrame) -> Phase {
|
||||
let actors = end
|
||||
.actors
|
||||
.iter()
|
||||
.map(|observed| ActorTargets {
|
||||
actor_id: observed.actor_id.clone(),
|
||||
joints: observed.joints.clone(),
|
||||
ground_lock: Vec::new(),
|
||||
travel: Some(observed.position),
|
||||
confidence: observed.confidence,
|
||||
})
|
||||
.collect();
|
||||
Phase {
|
||||
name: format!("Observed {}", index + 1),
|
||||
start_ms: start.timestamp_ms,
|
||||
duration_ms: (end.timestamp_ms - start.timestamp_ms).max(1),
|
||||
timing: Timing::Linear,
|
||||
actors,
|
||||
contacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn actor_ids(frame: &SceneFrame) -> BTreeSet<u64> {
|
||||
frame.actors.iter().map(|a| a.track_id).collect()
|
||||
}
|
||||
|
||||
fn angular_velocity(previous: &SceneFrame, current: &SceneFrame, dt_ms: u64) -> f32 {
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0_u32;
|
||||
for a in ¤t.actors {
|
||||
let Some(before) = previous.actors.iter().find(|p| p.track_id == a.track_id) else {
|
||||
return f32::INFINITY;
|
||||
};
|
||||
for joint in &a.joints {
|
||||
if let Some(old) = before
|
||||
.joints
|
||||
.iter()
|
||||
.find(|j| j.joint == joint.joint && j.action == joint.action)
|
||||
{
|
||||
sum += (joint.degrees - old.degrees).abs() * joint.confidence.min(old.confidence);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(sum / count as f32) / (dt_ms as f32 / 1000.0).max(0.001)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(ms: u64, angle: f32) -> SceneFrame {
|
||||
SceneFrame {
|
||||
timestamp_ms: ms,
|
||||
actors: vec![ActorObservation {
|
||||
actor_id: "p1".into(),
|
||||
track_id: 1,
|
||||
confidence: 0.9,
|
||||
position: Vec3::default(),
|
||||
joints: vec![JointTarget {
|
||||
joint: "knee_left".into(),
|
||||
action: "flex".into(),
|
||||
degrees: angle,
|
||||
confidence: 0.9,
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_compact_observed_scene() {
|
||||
let frames = vec![
|
||||
frame(0, 0.0),
|
||||
frame(100, 20.0),
|
||||
frame(200, 40.0),
|
||||
frame(300, 40.1),
|
||||
frame(500, 40.1),
|
||||
];
|
||||
let scene = PhaseSegmenter::new(SegmenterConfig::default())
|
||||
.unwrap()
|
||||
.segment("squat", &frames)
|
||||
.unwrap();
|
||||
assert_eq!(scene.actors.len(), 1);
|
||||
assert!(!scene.phases.is_empty());
|
||||
assert!(scene.phases.len() < frames.len());
|
||||
assert_eq!(scene.source, SceneSource::ObservedWifiCsi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reversed_time() {
|
||||
let frames = vec![frame(100, 0.0), frame(50, 1.0)];
|
||||
assert!(PhaseSegmenter::new(SegmenterConfig::default())
|
||||
.unwrap()
|
||||
.segment("bad", &frames)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Canonical deterministic PoseCode text serialization.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::model::*;
|
||||
|
||||
pub fn to_posecode(scene: &Scene) -> String {
|
||||
let mut out = String::new();
|
||||
writeln!(out, "posecode scene \"{}\"", safe_text(&scene.name)).unwrap();
|
||||
writeln!(out, "source {}", source_name(scene.source)).unwrap();
|
||||
for actor in &scene.actors {
|
||||
writeln!(out, "actor {}:", actor.id).unwrap();
|
||||
writeln!(out, " rig {}", actor.rig).unwrap();
|
||||
writeln!(out, " pose start = {}", actor.start_pose).unwrap();
|
||||
if let Some(track) = actor.track_id {
|
||||
writeln!(out, " track {track}").unwrap();
|
||||
}
|
||||
writeln!(out, " confidence {}", number(actor.confidence)).unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
" position {} {} {}",
|
||||
number(actor.position.x),
|
||||
number(actor.position.y),
|
||||
number(actor.position.z)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
for phase in &scene.phases {
|
||||
writeln!(
|
||||
out,
|
||||
"step \"{}\" {}s {}:",
|
||||
safe_text(&phase.name),
|
||||
number(phase.duration_ms as f32 / 1000.0),
|
||||
phase.timing.as_str()
|
||||
)
|
||||
.unwrap();
|
||||
for actor in &phase.actors {
|
||||
for joint in &actor.joints {
|
||||
writeln!(
|
||||
out,
|
||||
" {}.{}: {} {} {}",
|
||||
actor.actor_id,
|
||||
joint.joint,
|
||||
joint.action,
|
||||
number(joint.degrees),
|
||||
number(joint.confidence)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if !actor.ground_lock.is_empty() {
|
||||
writeln!(
|
||||
out,
|
||||
" {}.ground-lock: {}",
|
||||
actor.actor_id,
|
||||
actor.ground_lock.join(", ")
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(p) = actor.travel {
|
||||
writeln!(
|
||||
out,
|
||||
" {}.travel: {} {} {}",
|
||||
actor.actor_id,
|
||||
number(p.x),
|
||||
number(p.y),
|
||||
number(p.z)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for contact in &phase.contacts {
|
||||
writeln!(
|
||||
out,
|
||||
" contact {}.{} {}.{} {}",
|
||||
contact.from.actor_id,
|
||||
contact.from.effector,
|
||||
contact.to.actor_id,
|
||||
contact.to.effector,
|
||||
number(contact.confidence)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(out, "repeat {}", scene.repeat).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn source_name(source: SceneSource) -> &'static str {
|
||||
match source {
|
||||
SceneSource::Authored => "authored",
|
||||
SceneSource::ObservedWifiCsi => "observed_wifi_csi",
|
||||
SceneSource::Imported => "imported",
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_text(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && *c != '"')
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn number(value: f32) -> String {
|
||||
if value == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
let mut text = format!("{value:.4}");
|
||||
while text.ends_with('0') {
|
||||
text.pop();
|
||||
}
|
||||
if text.ends_with('.') {
|
||||
text.pop();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parse_posecode;
|
||||
|
||||
#[test]
|
||||
fn canonical_text_round_trips() {
|
||||
let mut scene = Scene::new("two people");
|
||||
scene.actors.push(Actor::humanoid("p1"));
|
||||
scene.actors.push(Actor::humanoid("p2"));
|
||||
let text = to_posecode(&scene);
|
||||
assert_eq!(parse_posecode(&text).unwrap(), scene);
|
||||
assert_eq!(to_posecode(&parse_posecode(&text).unwrap()), text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Deterministic scene validation with provenance-aware range policy.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::model::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidationSeverity {
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ValidationIssue {
|
||||
pub severity: ValidationSeverity,
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationConfig {
|
||||
pub max_actors: usize,
|
||||
pub max_phases: usize,
|
||||
/// Authored values outside general ROM are errors. Observed values are
|
||||
/// warnings because RF estimates are not medical measurements.
|
||||
pub authored_rom_is_error: bool,
|
||||
}
|
||||
|
||||
impl Default for ValidationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_actors: MAX_ACTORS,
|
||||
max_phases: MAX_PHASES,
|
||||
authored_rom_is_error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_scene(scene: &Scene, config: &ValidationConfig) -> Vec<ValidationIssue> {
|
||||
let mut issues = Vec::new();
|
||||
if scene.name.is_empty() || scene.name.len() > MAX_NAME_BYTES {
|
||||
error(
|
||||
&mut issues,
|
||||
"scene.name",
|
||||
"name must contain 1 to 128 bytes",
|
||||
);
|
||||
}
|
||||
if scene.actors.len() > config.max_actors {
|
||||
error(&mut issues, "scene.actors", "actor limit exceeded");
|
||||
}
|
||||
if scene.phases.len() > config.max_phases {
|
||||
error(&mut issues, "scene.phases", "phase limit exceeded");
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
for (i, actor) in scene.actors.iter().enumerate() {
|
||||
let path = format!("actors[{i}]");
|
||||
if !valid_id(&actor.id) {
|
||||
error(&mut issues, &path, "invalid actor identifier");
|
||||
}
|
||||
if !ids.insert(actor.id.as_str()) {
|
||||
error(&mut issues, &path, "duplicate actor identifier");
|
||||
}
|
||||
check_confidence(&mut issues, &format!("{path}.confidence"), actor.confidence);
|
||||
if !actor.position.is_finite() {
|
||||
error(
|
||||
&mut issues,
|
||||
&format!("{path}.position"),
|
||||
"position must be finite",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let actor_ids: HashSet<&str> = scene.actors.iter().map(|a| a.id.as_str()).collect();
|
||||
for (pi, phase) in scene.phases.iter().enumerate() {
|
||||
let path = format!("phases[{pi}]");
|
||||
if phase.duration_ms == 0 || phase.duration_ms > 300_000 {
|
||||
error(
|
||||
&mut issues,
|
||||
&format!("{path}.duration_ms"),
|
||||
"duration must be 1 to 300000 ms",
|
||||
);
|
||||
}
|
||||
let mut phase_actors = HashSet::new();
|
||||
for targets in &phase.actors {
|
||||
if !actor_ids.contains(targets.actor_id.as_str()) {
|
||||
error(&mut issues, &path, "phase references unknown actor");
|
||||
}
|
||||
if !phase_actors.insert(targets.actor_id.as_str()) {
|
||||
error(&mut issues, &path, "actor appears more than once in phase");
|
||||
}
|
||||
if targets.joints.len() > MAX_TARGETS_PER_ACTOR {
|
||||
error(&mut issues, &path, "joint target limit exceeded");
|
||||
}
|
||||
check_confidence(&mut issues, &path, targets.confidence);
|
||||
for joint in &targets.joints {
|
||||
check_confidence(&mut issues, &path, joint.confidence);
|
||||
if !joint.degrees.is_finite() {
|
||||
error(&mut issues, &path, "joint angle must be finite");
|
||||
continue;
|
||||
}
|
||||
if let Some((min, max)) = range(&joint.joint, &joint.action) {
|
||||
if joint.degrees < min || joint.degrees > max {
|
||||
let severity = if scene.source == SceneSource::Authored
|
||||
&& config.authored_rom_is_error
|
||||
{
|
||||
ValidationSeverity::Error
|
||||
} else {
|
||||
ValidationSeverity::Warning
|
||||
};
|
||||
issues.push(ValidationIssue {
|
||||
severity,
|
||||
path: path.clone(),
|
||||
message: format!(
|
||||
"{}.{} angle {} outside general range [{}, {}]",
|
||||
joint.joint, joint.action, joint.degrees, min, max
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for contact in &phase.contacts {
|
||||
if !actor_ids.contains(contact.from.actor_id.as_str())
|
||||
|| !actor_ids.contains(contact.to.actor_id.as_str())
|
||||
{
|
||||
error(&mut issues, &path, "contact references unknown actor");
|
||||
}
|
||||
check_confidence(&mut issues, &path, contact.confidence);
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(i, b)| b.is_ascii_alphanumeric() || b == b'_' || (i > 0 && b == b'-'))
|
||||
}
|
||||
|
||||
fn check_confidence(issues: &mut Vec<ValidationIssue>, path: &str, value: f32) {
|
||||
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
|
||||
error(issues, path, "confidence must be finite and in [0, 1]");
|
||||
}
|
||||
}
|
||||
|
||||
fn error(issues: &mut Vec<ValidationIssue>, path: &str, message: &str) {
|
||||
issues.push(ValidationIssue {
|
||||
severity: ValidationSeverity::Error,
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
});
|
||||
}
|
||||
|
||||
fn range(joint: &str, action: &str) -> Option<(f32, f32)> {
|
||||
match (
|
||||
joint.trim_end_matches("_left").trim_end_matches("_right"),
|
||||
action,
|
||||
) {
|
||||
("shoulder", "flex") => Some((0.0, 180.0)),
|
||||
("shoulder", "extend") => Some((0.0, 60.0)),
|
||||
("elbow", "flex") => Some((0.0, 154.0)),
|
||||
("hip", "flex") => Some((0.0, 135.0)),
|
||||
("hip", "extend") => Some((0.0, 20.0)),
|
||||
("knee", "flex") => Some((0.0, 144.0)),
|
||||
("pelvis", "hinge") => Some((0.0, 120.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn observed_rom_is_warning_not_medical_error() {
|
||||
let mut scene = Scene::new("observed");
|
||||
scene.source = SceneSource::ObservedWifiCsi;
|
||||
scene.actors.push(Actor::humanoid("p1"));
|
||||
let mut targets = ActorTargets::new("p1");
|
||||
targets.joints.push(JointTarget {
|
||||
joint: "knee_left".into(),
|
||||
action: "flex".into(),
|
||||
degrees: 170.0,
|
||||
confidence: 0.5,
|
||||
});
|
||||
scene.phases.push(Phase {
|
||||
name: "frame".into(),
|
||||
start_ms: 0,
|
||||
duration_ms: 50,
|
||||
timing: Timing::Linear,
|
||||
actors: vec![targets],
|
||||
contacts: vec![],
|
||||
});
|
||||
let issues = validate_scene(&scene, &ValidationConfig::default());
|
||||
assert_eq!(issues.len(), 1);
|
||||
assert_eq!(issues[0].severity, ValidationSeverity::Warning);
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ repository.workspace = true
|
||||
# (serde / serde_json / toml / sha2 / ed25519-dalek only — no tch / openblas /
|
||||
# ndarray / candle), so they build under `--no-default-features`.
|
||||
[dependencies]
|
||||
rufield-core = { path = "../../../vendor/rufield/crates/rufield-core" }
|
||||
rufield-provenance = { path = "../../../vendor/rufield/crates/rufield-provenance" }
|
||||
rufield-privacy = { path = "../../../vendor/rufield/crates/rufield-privacy" }
|
||||
rufield-fusion = { path = "../../../vendor/rufield/crates/rufield-fusion" }
|
||||
rufield-core = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-core" }
|
||||
rufield-provenance = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-provenance" }
|
||||
rufield-privacy = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-privacy" }
|
||||
rufield-fusion = { version = "0.1.0", path = "../../../vendor/rufield/crates/rufield-fusion" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-sensing-server"
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition.workspace = true
|
||||
description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing"
|
||||
license.workspace = true
|
||||
|
||||
@@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
||||
/// Path prefix the middleware protects when auth is enabled.
|
||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||
|
||||
/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
|
||||
/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
|
||||
/// cannot attach an `Authorization` header to the handshake request, so this
|
||||
/// path can never carry a bearer token from a stock browser client — the
|
||||
/// same reasoning that already exempts `/ws/sensing` (see module docs).
|
||||
/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
|
||||
/// surface change for existing clients.
|
||||
const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
|
||||
|
||||
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
@@ -93,7 +102,8 @@ pub async fn require_bearer(
|
||||
let Some(expected) = auth.token.clone() else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
|
||||
let path = request.uri().path();
|
||||
if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let supplied = request
|
||||
@@ -141,6 +151,7 @@ mod tests {
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/v1/info", get(|| async { "ok" }))
|
||||
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
|
||||
.route("/api/v1/stream/pose", get(|| async { "ok" }))
|
||||
.route("/ui/index.html", get(|| async { "<html/>" }))
|
||||
}
|
||||
|
||||
@@ -361,6 +372,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
|
||||
/// constructor drives directly — it cannot attach an `Authorization`
|
||||
/// header, so this path must stay reachable even with auth ON (mirrors
|
||||
/// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
|
||||
/// prefix this time).
|
||||
#[tokio::test]
|
||||
async fn enabled_exempts_pose_stream_websocket() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
|
||||
StatusCode::OK,
|
||||
"pose stream WS must stay reachable without a bearer token"
|
||||
);
|
||||
// The exemption is narrow: it must not leak to other /api/v1/* paths.
|
||||
assert_eq!(
|
||||
status(r, "GET", "/api/v1/info", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ct_eq_basics() {
|
||||
assert!(ct_eq(b"abc", b"abc"));
|
||||
|
||||
@@ -38,6 +38,7 @@ use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
||||
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig;
|
||||
use wifi_densepose_worldgraph::WorldId;
|
||||
|
||||
use super::multistatic_bridge::node_frames_from_states;
|
||||
@@ -79,8 +80,24 @@ pub struct EngineBridge {
|
||||
impl EngineBridge {
|
||||
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
||||
/// observation scope; `mode` is the starting privacy mode.
|
||||
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
||||
///
|
||||
/// `multistatic_cfg` is `None` on the pure default (60 ms/20 ms guard);
|
||||
/// callers that derive a config from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`
|
||||
/// (see `main.rs::multistatic_guard_config_from_env`) should pass it here
|
||||
/// too — otherwise the governed trust cycle silently keeps using the
|
||||
/// hardcoded default even though the sibling `multistatic_fuser` field on
|
||||
/// `AppState` picked up the override (#1049/#1057).
|
||||
pub fn new(
|
||||
mode: PrivacyMode,
|
||||
model_version: u16,
|
||||
room_area_id: &str,
|
||||
room_name: &str,
|
||||
multistatic_cfg: Option<MultistaticConfig>,
|
||||
) -> Self {
|
||||
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
||||
if let Some(cfg) = multistatic_cfg {
|
||||
engine.set_multistatic_config(cfg);
|
||||
}
|
||||
let room = engine.add_room(room_area_id, room_name);
|
||||
Self {
|
||||
engine,
|
||||
@@ -267,7 +284,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_states_produce_no_belief() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
|
||||
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
||||
assert!(out.is_none());
|
||||
// No belief published, no sensor wired.
|
||||
@@ -276,7 +293,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None);
|
||||
let states = two_node_states();
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&states, 10_000)
|
||||
@@ -299,7 +316,7 @@ mod tests {
|
||||
fn live_path_is_deterministic() {
|
||||
let states = two_node_states_fixed();
|
||||
let run = || {
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
||||
};
|
||||
let a = run();
|
||||
@@ -325,7 +342,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn nodes_registered_once_across_cycles() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
let states = two_node_states();
|
||||
bridge.process_cycle_from_states(&states, 1_000);
|
||||
bridge.process_cycle_from_states(&states, 2_000);
|
||||
@@ -336,7 +353,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn retention_bounds_world_graph_growth() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
bridge.set_semantic_retention(5);
|
||||
let states = two_node_states();
|
||||
for i in 0..20i64 {
|
||||
@@ -349,7 +366,7 @@ mod tests {
|
||||
#[test]
|
||||
fn adapter_identity_flows_into_live_witness() {
|
||||
let states = two_node_states_fixed();
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
let base = bridge
|
||||
.process_cycle_from_states(&states, 1_000)
|
||||
.unwrap()
|
||||
@@ -381,7 +398,7 @@ mod tests {
|
||||
/// the status endpoint, with a zero error count on the happy path.
|
||||
#[test]
|
||||
fn observe_cycle_records_trust_state() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
assert!(bridge.last_trust_witness().is_none());
|
||||
assert_eq!(bridge.effective_class(), None);
|
||||
|
||||
@@ -430,7 +447,7 @@ mod tests {
|
||||
m
|
||||
}
|
||||
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
let mismatched = mismatched_states();
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
||||
@@ -461,7 +478,7 @@ mod tests {
|
||||
/// mapping bfld's privacy gate applies at `Restricted`.
|
||||
#[test]
|
||||
fn restricted_class_suppresses_raw_outputs() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
||||
bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
@@ -472,7 +489,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identity_strict_mode_is_carried_into_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None);
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&two_node_states(), 7_000)
|
||||
|
||||
@@ -7514,6 +7514,11 @@ async fn main() {
|
||||
let field_surface: rufield_surface::FieldState =
|
||||
Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env()));
|
||||
|
||||
// Populated inside the `multistatic_fuser` field initializer below, then
|
||||
// threaded into `engine_bridge` so both fusion paths honor the same
|
||||
// WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057).
|
||||
let mut engine_bridge_multistatic_cfg: Option<MultistaticConfig> = None;
|
||||
|
||||
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
||||
latest_update: None,
|
||||
rssi_history: VecDeque::new(),
|
||||
@@ -7588,7 +7593,7 @@ async fn main() {
|
||||
);
|
||||
let mut fuser = MultistaticFuser::with_config(MultistaticConfig {
|
||||
min_nodes: 1, // single-node passthrough
|
||||
..cfg
|
||||
..cfg.clone()
|
||||
});
|
||||
if let Some(ref pos_str) = args.node_positions {
|
||||
let positions = field_bridge::parse_node_positions(pos_str);
|
||||
@@ -7600,6 +7605,10 @@ async fn main() {
|
||||
fuser.set_node_positions(positions);
|
||||
}
|
||||
}
|
||||
engine_bridge_multistatic_cfg = Some(MultistaticConfig {
|
||||
min_nodes: 1,
|
||||
..cfg
|
||||
});
|
||||
fuser
|
||||
},
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
@@ -7607,6 +7616,7 @@ async fn main() {
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
engine_bridge_multistatic_cfg,
|
||||
),
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "wifi-densepose-signal"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition.workspace = true
|
||||
description = "WiFi CSI signal processing for DensePose estimation"
|
||||
license.workspace = true
|
||||
|
||||
+1
-1
Submodule v2/crates/worldgraph updated: fdade42206...4441bc07b5
Vendored
+1
-1
Submodule vendor/rufield updated: 509d8ae29e...f3c149296a
Vendored
+1
-1
Submodule vendor/ruvector updated: 2b68dad01a...f3de1724fa
Reference in New Issue
Block a user