Files
LocalAI/docs/content/features/motion.md
T
Richard Palethorpe 3de91d48d9 feat(motion): add GEM-X backend and duplex streaming API
Add the GEM-X native backend with live pose and root displacement output, bounded duplex WebSocket streaming, session authorization, metrics and usage accounting. Share the embedded bidirectional RPC channel adapter across streaming backends.

Include backend build and gallery integration, API documentation, schemas and tests.

Assisted-by: Codex:gpt-6
Signed-off-by: Richard Palethorpe <io@richiejp.com>
(cherry picked from commit 016dc3d71a)
2026-10-13 08:27:24 +00:00

363 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
+++
title = "Motion Capture"
url = "/features/motion/"
weight = 21
+++
LocalAI's `gemxcpp` backend converts timestamped RGB frames into human poses
using [gem-x.cpp](https://github.com/localai-org/gem-x.cpp). It runs resident
GEM-X, ViTPose and YOLOX models on CPU or Vulkan. Live inference does not need
SAM3D Body. This initial integration is API-only: no Motion UI, offline clip
export, body mesh or SONIC controller is included.
## Install and configure
Import `https://huggingface.co/LocalAI-io/GEM-X-GGUF` through the model importer.
It selects `gemxcpp` and downloads three checksummed, revision-pinned assets.
These are custom F32 GGUF architectures, not language models. Weight licenses
are described in the source model card. Allow memory for all three models and
execution buffers, beyond their roughly 4 GB combined download size.
Equivalent configuration with existing local files:
```yaml
name: gem-x
backend: gemxcpp
known_usecases: [motion]
parameters:
model: gem-x/gem-x-contact-f32.gguf
threads: 4
options:
- vitpose:gem-x/vitpose-f32.gguf
- yolox:gem-x/yolox-f32.gguf
- window:30
- detector_interval:1
- selection:continuity
- precision:strict
- max_gap_us:2000000
```
Additional options: `device:cpu` or `device:vulkan`, and `device_index:0`.
Without `device`, the package selects CPU or Vulkan according to its build.
Darwin uses CPU; Metal inference is not supported. Explicit unavailable devices
fail rather than silently falling back. Threads are capped at eight.
`window` accepts 2–120 observations; `detector_interval` accepts 1–30 processed
frames. Larger detector intervals reuse the previous crop and may miss movement
or person loss until the next detection. `selection:continuity` conservatively
associates one unique box; loss/ambiguity clears history. `selection:parity`
uses upstream's largest detection/full-image fallback and makes no identity
continuity guarantee. Neither mode is person re-identification.
`precision:strict` is the default. The launcher fixes GGML Vulkan precision
flags before initialization. `precision:backend_default` opts out of strict
validation, without promising a faster path. The same F32 model files are used.
## Sessions
All endpoints use standard LocalAI authentication, the **Motion Capture** feature
permission and model access controls. Sessions belong to their creating user.
When authentication is disabled, sessions share the unauthenticated principal.
Legacy admin keys have the existing shared legacy-admin identity.
```sh
curl http://localhost:8080/api/motion/sessions \
-H 'Content-Type: application/json' \
-d '{"model":"gem-x","profile":"smpl24","time_origin":"camera session start"}'
```
The response is HTTP 201 with `id`, `model`, `profile`, `time_origin` and counters.
`profile` defaults to `soma77`; `time_origin` is required (1–128 characters).
A session owns its model's native temporal state. Only one session per model
is admitted, with up to 16 sessions per frontend and one duplex connection per
session. Additional model configurations consume independent model memory.
| Endpoint | Purpose |
| --- | --- |
| `POST /api/motion/sessions` | Create and load a session |
| `GET /api/motion/sessions/{id}` | Read frame/pose/drop counters |
| `DELETE /api/motion/sessions/{id}` | Cancel and release a session |
| `GET /api/motion/sessions/{id}/poses` | Exchange frames and poses over a duplex WebSocket |
| `POST /api/motion/sessions/{id}/tickets` | Issue a single-use browser WebSocket ticket |
Sessions expire after two minutes without frame activity. Inference also
has a two-minute timeout; initial model loading has a five-minute stream wait
limit, subject to the loader's own cancellation behavior. Native inference is
synchronous and cannot be interrupted mid-call. Closing stops new work and
discards late results; native cleanup completes after the current call returns.
Session state lives in one frontend process. Use sticky routing for all session
requests in multi-frontend deployments. Backend streaming uses the existing
model loader and keeps its selected gRPC connection for the session. There is
no session migration or recovery after frontend/backend restart.
## Duplex frame transport (v2)
The browser negotiates `localai.motion.v2` on the existing ticket-authenticated
`/poses` socket. It sends uncompressed binary protobuf `Input.frame` and receives
binary `Output` messages. HTTP remains for discovery, session creation, tickets
and deletion. There is no HTTP frame fallback. The protobuf package remains v1;
the v2 name versions the duplex transport and flow protocol.
Definition precedes a JSON flow hello. Each flow message has `type: "flow"`,
`version: 2`, a consecutive integer `id`, `release` containing decimal sequence
strings, `window_frames`, `max_pending_bytes`, `max_frame_bytes`,
`processing_ewma_ms`, `recommended_fps`, `queued_frames`, `queued_bytes`,
`oldest_age_ms`, `pressure` and cumulative `dropped_frames`. A completed frame
also has `completed_sequence` and `completed_kind` (`pose` or `event`). Its binary
output is written before its release; discarded inputs have release only.
While connected, every completed or dropped capture is retired once, and pose identity retains source time.
The initial window is two frames, with zero recommended FPS meaning camera-paced
subject to credits. After a service sample the window is eight. Service EWMA uses
alpha 0.2 and a 1ms floor; normal offered rate is 1.05/service, pressure rate is
0.9/service. Clients should gate capture before canvas/RGB allocation on credits,
outstanding bytes, pacing and prospective WebSocket upload backlog. A recommended
upload backlog limit is two frame sizes or 2MiB, whichever is smaller. Prefer fresh
camera callbacks over a fixed capture timer or a local RGB FIFO. Camera/upload
speed can limit the achieved rate. These are recommendations for external clients;
LocalAI does not include a browser capture client or enforce client-side pacing.
LocalAI uses one reader, one inference worker and one writer. Pending input is
bounded to eight frames/8MiB with a 2MiB encoded frame limit. At six frames or
6MiB, thinning retains half the pending frames nearest evenly spaced source-time
targets across oldest/newest (earlier ties; one survivor means newest). Active
inference is never thinned. Inputs older than 250ms since server admission expire
before enqueue/dispatch. Pressure clears once pending depth is at most two.
The 8MiB pending limit is not a total process-memory claim: encoded/decoded
active and reader payloads add up to approximately another 6MiB, excluding runtime,
protobuf and transport overhead. The writer queue holds at most eight messages;
writes time out at five seconds. External clients should also detect stalled
feedback; a five-second timeout is recommended.
A connection exclusively owns ingress: a second connection receives HTTP 409.
Only `localai.motion.v2` is accepted; missing or read-only subprotocols receive HTTP 400. The socket accepts
frames only; reset/camera/model changes require a new session. Disconnect cancels
transport, discards pending input and joins workers before releasing ownership;
this does not preempt an executing native GPU kernel. Only processed frames enter
existing `motion_frames_v1` accounting; dropped frames are not billed. Session
summaries expose upload accepted/dropped counts; ingress metrics distinguish
accepted, thinned and expired outcomes. Upload drop counts exclude pending/active
inputs disposed by disconnect; disconnect clears all browser credits. No per-frame content or credentials are logged.
## Frame schema and authentication
The authoritative schema is `pkg/motion/proto/motion.proto` in the LocalAI
repository, package `localai.motion.v1`. Generate client bindings from that file;
clients do not need LocalAI's internal backend protocol. Numeric arrays are
packed float32, joint-major then component. JavaScript clients must retain
64-bit sequence/timestamp fields as BigInt or their Protobuf library's integer
representation, not lossy JavaScript numbers for arbitrarily large values.
1. Create the session through HTTP JSON.
2. Connect to `/api/motion/sessions/{id}/poses` over `ws://` or `wss://`,
requesting subprotocol `localai.motion.v2`. Native clients can use Bearer
authentication; browsers use their authenticated same-origin session cookie.
Successfully validated `Authorization`, `x-api-key` or `xi-api-key` header
credentials bypass the WebSocket origin check. Invalid or unvalidated headers
do not; cookie-only authentication still follows the origin policy. Standard
browser WebSocket APIs cannot set these headers, so browser clients using
cookies need an explicit CORS policy: set `LOCALAI_CORS=true`
and `LOCALAI_CORS_ALLOW_ORIGINS=https://consumer.example,http://localhost:3000`.
This explicit origin policy also permits cross-site session creation, tickets
and deletion through CSRF protection. Authentication and permissions
still apply. Allowed origins use the HTTP CORS matching rules, including explicitly
configured wildcards. Without an explicit policy or validated header credentials,
sockets remain same-origin even though HTTP uses a permissive default. Native clients without an Origin
header are supported. Origin approval does not bypass authentication or session
ownership; browser cookie availability still depends on cookie and browser
policies. Credentials are not URL parameters.
3. Read the first binary message as `Output.definition`. It declares joint order,
parents, rest transforms, coordinate conventions, profile and available channels.
The definition is sent once per session connection. A disconnect ends the session;
create a new session before reconnecting.
4. Send serialized `Input.frame` messages as binary WebSocket messages, following
the flow credits described above. A frame contains tightly packed RGB8
bytes, width/height, sequence, source microseconds and optional subject box/ID.
Subject boxes use inclusive source pixel XYXY coordinates, bounded by
`[0, width-1]` and `[0, height-1]`. Frames must be at least 8 pixels per side, at most 32766 per side and at most
16 million pixels. RGB byte length must equal width × height × 3. The stricter
2MiB encoded-message limit also applies to every upload.
5. Read `Output.pose` or `Output.event`, followed by the JSON completion release.
The first usable observation produces `warmup`; at least two are needed for a pose.
Invalid frames, replayed sequences and non-increasing timestamps close the
connection without submitting that input to the backend. Source timestamps are
nonnegative microseconds relative to the declared origin and must strictly
increase, as must sequence numbers. Gaps in sequence numbers are allowed.
Create a new session before seeking backward, explicitly resetting or changing
cameras. Long source gaps, changed dimensions or a changed caller-selected
subject reset native context; poses/events carry the new epoch and reset flag.
Intrinsics follow upstream's image-centre/max-dimension approximation;
calibrated cameras are not supported by this ABI.
The model uses accepted-frame indices, not time-aware irregular sampling. Source
timestamps are preserved, not replaced by receive times or an invented FPS.
Consumers own resampling and clock alignment. Local processing durations do not
measure capture-to-consumer latency.
Pose and lifecycle outputs are delivered in order through the bounded writer
queue. A stalled reader eventually causes a write timeout and session closure;
outputs are not replaced by newer poses. WebSocket uses TCP, so already-buffered
bytes cannot be retracted. Reconnecting requires a new session; there is no
history replay.
## Browser WebSocket tickets
Browsers cannot set `Authorization` on a native `WebSocket` constructor. After
creating a motion session, exchange your normal credentials for a ticket:
```js
const response = await fetch(`${apiBase}/api/motion/sessions/${sessionId}/tickets`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ origin: window.location.origin }),
});
if (!response.ok) throw new Error(`Ticket request failed: ${response.status}`);
const { ticket, expires_at } = await response.json();
const wsBase = apiBase.replace(/^http/, "ws");
const socket = new WebSocket(`${wsBase}/api/motion/sessions/${sessionId}/poses`, [
"localai.motion.v2",
`localai.ticket.${ticket}`,
]);
socket.binaryType = "arraybuffer";
```
Cookie-authenticated clients can use `credentials: "include"` instead of the
Authorization header, subject to browser cookie policy. The ticket endpoint uses
normal authentication, Motion feature permission, model access and session
ownership checks. Its JSON response is HTTP 201 with `ticket` and `expires_at`,
and `Cache-Control: no-store`. When auth is disabled, tickets retain the existing
anonymous access policy; they do not create a user or grant additional access.
Tickets expire after **30 seconds**, are **single use**, and are bound to the
exact browser origin and session's pose WebSocket path. If the HTTP request has
an Origin header, it must match the requested origin. A reconnect requires a new
session and ticket. Redemption consumes the ticket before authentication/upgrade; even a
failed matching upgrade requires a fresh ticket. The original credentials are
revalidated, so revoked keys or sessions cannot redeem outstanding tickets.
Feature, model and ownership permissions are checked again. Successfully redeemed
tickets authorize the bound origin without requiring browser cookie delivery.
The server selects only `localai.motion.v2`; it never echoes the ticket protocol.
Tickets belong in the protocol list, not in the URL or query string. LocalAI
strips them before upgrade and redacts the protocol header from API traces.
Configure external proxies and request loggers to redact `Sec-WebSocket-Protocol`
as well. Ticket responses must not be cached or logged.
There are at most 32 outstanding tickets per user (shared for anonymous/legacy
identities) and 4096 per frontend; issuance returns 429 at capacity. Closing a
session revokes its outstanding tickets. The in-memory store is shared by ticket
issuance and upgrade on one frontend; both requests need sticky routing. Restart
invalidates all outstanding tickets. Ticket expiry limits the handshake window,
not the lifetime of an established connection. Ticket requests do not add frame
usage. This first integration supports motion; other WebSocket endpoints do not
yet issue tickets.
## Output profiles
**soma77:** 77 joints, metres, right-handed native SOMA Y-up. Positions have pelvis
translation removed only; root rotation is retained. Local rotations are XYZW;
local translations vary with the emitted frame's identity/scale. Root axis-angle
is in radians. Root translation is zero: this is not a continuous world trajectory.
**smpl24:** upstream-validated SOMA-to-SMPL mapping; 24 joint positions with pelvis
translation and anchor rotation removed. The separate anchor is a unit WXYZ
quaternion in the upstream Z-up/base-rotation convention. It reconstructs the
mapped, gravity-aligned points from root-local positions. No SMPL joint rotations,
mesh parameters, physical-base alignment or robot wrist commands are fabricated.
Definitions distinguish neutral reference transforms from per-frame shape.
Quaternions use upstream's canonical nonnegative-w policy, not temporal sign
smoothing; consumers interpolating rotations should handle equivalent signs.
Events include `warmup`, `lost`, `ambiguous` and `reset`. Frame flags identify
reset (1), reused crop (2), full-image fallback (4), detector execution (8) and
caller box (16). Track epochs mark acquisitions; automatic parity mode uses
track epoch zero without asserting identity continuity. No 3D confidence is
advertised. Consumers must detect stale streams and choose their own hold,
interpolation or stop behavior.
An external SONIC consumer can request SMPL-24, buffer timed poses and construct
its policy-specific reference windows. LocalAI does not execute that policy,
align references to physical robot state, or run physics.
## Observability
With metrics enabled, the existing `/metrics` exporter includes:
- `localai_motion_sessions`: active sessions.
- `localai_motion_frames`: processed frame outcomes.
- `localai_motion_uploads`: accepted, thinned and expired input frames.
- `localai_motion_resets`: explicit or native context resets.
- `localai_motion_frame_duration_seconds`: backend frame round-trip duration.
- `localai_motion_stage_duration_seconds`: detector, ViTPose, GEM and skeleton
construction stage durations from the native pipeline.
Prometheus may append standard counter/unit suffixes. Labels use model/backend,
finite outcome names and fixed stage names, never session IDs or person IDs.
Frame/pose/drop totals are also available through session status.
Frame submissions also use the shared usage accounting pipeline, like animation
requests. Each successfully processed frame records `input_units: 1`; a pose
records `output_units: 1`, while warmup, lost and ambiguous outcomes record zero
output units. Records retain `accounting_rule: "motion_frames_v1"` in their usage
metadata. The existing usage dashboard, storage and billing metrics expose these
units through their legacy prompt/completion/total token fields; they represent
frames, not text tokens. Configure any model pricing in those units.
Accounting happens once per successfully processed WebSocket frame, before
socket delivery. Rejected or dropped inputs and failed inference do not record
usage. Session creation/status/deletion, tickets and WebSocket upgrades do not
record usage either. Shared statistics can be disabled through the existing
statistics configuration.
With backend tracing enabled, a `motion` trace spans the session and records its
profile, duration, termination reason and counts. Model-load failures use the
existing model-load traces. Logs record lifecycle changes. Images, pose arrays,
credentials and private model paths are not included in motion session traces.
### Webcam pose overlays
GEM-X pose messages optionally include `image_positions` (Protobuf field 13):
packed source-image pixel XY pairs in `Definition.joint_names` order (48 values for
SMPL24, 154 for SOMA77). The definition advertises the channel and convention
`source-pixel xy, joint_names order, top-left origin, unmirrored`. Coordinates
reference the exact submitted RGB Frame identified by the pose sequence/timestamp;
use that frame's width/height, then scale/letterbox with the displayed image. They
are neither normalized crop coordinates nor confidence scores. No image mirroring
is applied. Pixels can fall outside the source image; clients should clip drawing.
The backend projects GEM-X's emitted camera-space skeleton plus its camera
translation using the native live API's actual intrinsics: focal length
`max(width,height)` and image-centre principal point. SMPL uses the mapping from
that native definition, not a hard-coded substitute skeleton. If a joint has
nonpositive depth or projection is nonfinite the optional channel is empty; root
local control data remains separate. Older servers may omit the channel. Do not
invent an image-aligned overlay from root-local positions, anchor or bounding box.
### Optional live root displacement
The GEM-X SMPL stream advertises `root_displacement` only when its native library
supports channel 14. It contains three metres-per-interval components in the
**end pose's anchor-local basis**, not velocity or absolute world position. Rotate
by the end pose's WXYZ anchor for gravity-aligned Z-up displacement. The closed
interval is `[displacement_start_time_us, source_time_us]`, between consecutive
frames actually processed by GEM-X (including its warmup observation).
This uses the last two decoded world translations inside one rolling window;
it does not subtract absolute translations from separate windows. The current
frame's unobserved next-interval prediction is not emitted. `root_translation`
remains zero for compatibility. Older native libraries omit the new channel.
Consumers must establish a baseline after an epoch/track change, reset, missing
interval or reconnect; they must not add a delta across those boundaries. A
dropped input changes the model's accepted-frame spacing: these estimates are
not time-normalized or contact-refined, and integration can drift. Robot/world
placement is a separate consumer-owned transform and need not reset on tracking
loss. Model-backed checks cover interval boundaries and coordinate-basis
consistency; they do not establish trajectory accuracy against ground truth.