Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 92 additions & 5 deletions examples/egocap/backend/egocap_web/viser_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@

_COLORS = {"left": (60, 140, 255), "right": (255, 140, 60)}

# Joint and bone size in the glove drawing. Larger than a bare point so the skeleton reads at arm's length on a
# projector, small enough that the finger joints stay distinct.
_JOINT_POINT_SIZE = 0.009
_BONE_LINE_WIDTH = 3.0
# How much a fingertip is lightened toward white (0 keeps the side colour, 1 is white).
_TIP_LIGHTEN = 0.65
# A finger's base joint is the side colour darkened to this fraction, so the shade runs knuckle to fingertip.
_BASE_DARKEN = 0.55


def _joint_colors(side: str) -> np.ndarray:
"""A (26, 3) uint8 colour per OpenXR hand joint for one side.

The side keeps its identity (blue left, orange right) while each finger shades from a dark knuckle to a pale
tip, which makes the finger direction and curl readable without a legend. The palm and wrist sit mid-shade."""
base = np.array(_COLORS[side], dtype=np.float64)
dark, light = base * _BASE_DARKEN, base + (255.0 - base) * _TIP_LIGHTEN
colors = np.tile(dark + (light - dark) * 0.35, (26, 1)) # PALM and WRIST
for chain in _CHAINS:
for rank, joint in enumerate(chain):
colors[joint] = dark + (light - dark) * (rank / (len(chain) - 1))
return np.clip(colors, 0, 255).astype(np.uint8)


_JOINT_COLORS = {side: _joint_colors(side) for side in _COLORS}

# The floor of the 3D view: a dark slab under a blue-grey grid. Tracking space is ``session_floor`` (y = 0 is the
# floor), so the slab's top face and the grid sit at the origin plane and the person is drawn standing on them.
# The slab is what gives the floor a surface to read against; lines alone float in the empty canvas.
_FLOOR_SIZE_M = 6.0
_FLOOR_THICKNESS_M = 0.01
_FLOOR_SURFACE_COLOR = (15, 22, 34)
_FLOOR_CELL_M = 0.25
_FLOOR_CELL_COLOR = (44, 58, 82)
_FLOOR_SECTION_COLOR = (86, 108, 142)
_ORIGIN_AXES_LENGTH_M = 0.25


# The tracking fields this view reads, with the dtype and shape the ic_tracking_v1 layout must declare for each.
_HAND_FIELDS = (
("present", "u1", ()),
Expand Down Expand Up @@ -203,10 +241,15 @@ class NodePose:

@dataclass(frozen=True)
class Glove:
"""A glove skeleton: the reported valid joint points and the bones between valid joints, in tracking space."""
"""A glove skeleton: the reported valid joint points and the bones between valid joints, in tracking space.

The colour arrays are presentation only (a shade per joint, and per bone end so a bone blends from one joint's
shade to the next); ``None`` draws the whole glove in one flat colour."""

points: np.ndarray # (N, 3)
segments: np.ndarray # (M, 2, 3)
point_colors: np.ndarray | None = None # (N, 3) uint8
segment_colors: np.ndarray | None = None # (M, 2, 3) uint8


@dataclass(frozen=True)
Expand Down Expand Up @@ -237,8 +280,11 @@ def _glove(values: dict[str, np.ndarray], side: str) -> Glove | None:
valid = values[f"{hand}.joint_valid"].astype(bool) & np.isfinite(positions).all(axis=1)
if not valid.any():
return None
segments = np.array([(positions[a], positions[b]) for a, b in HAND_BONES if valid[a] and valid[b]])
return Glove(positions[valid], segments.reshape(-1, 2, 3))
bones = [(a, b) for a, b in HAND_BONES if valid[a] and valid[b]]
segments = np.array([(positions[a], positions[b]) for a, b in bones])
shades = _JOINT_COLORS[side]
segment_colors = np.array([(shades[a], shades[b]) for a, b in bones], dtype=np.uint8)
return Glove(positions[valid], segments.reshape(-1, 2, 3), shades[valid], segment_colors.reshape(-1, 2, 3))


def build_frame(values: dict[str, np.ndarray]) -> SceneFrame:
Expand Down Expand Up @@ -443,6 +489,7 @@ def _build_nodes(self, server: Any, assets: dict[str, bytes | None]) -> None:
self._assets.setdefault(self._profile, assets)
scene = server.scene
scene.set_up_direction("+y")
self._add_environment(scene)
scene.add_frame("/tracking", show_axes=False)
head = scene.add_frame("/tracking/head", show_axes=assets["head"] is None, axes_length=0.1, visible=False)
self._handles["head"] = head
Expand All @@ -456,6 +503,32 @@ def _build_nodes(self, server: Any, assets: dict[str, bytes | None]) -> None:
)
self._add_models(scene, assets)

def _add_environment(self, scene: Any) -> None:
"""The static surroundings: a dark floor slab, a grid on it at y = 0 and the tracking-space origin axes.

None of it is tracked data, so it is never hidden with the tracked nodes. The floor and axes give the eye a
reference for height and distance, which the bare models on a flat canvas lack."""
# The slab sits just below y = 0 so its top face is the floor plane and never z-fights the grid on it.
scene.add_box(
"/floor_surface",
color=_FLOOR_SURFACE_COLOR,
dimensions=(_FLOOR_SIZE_M, _FLOOR_THICKNESS_M, _FLOOR_SIZE_M),
position=(0.0, -_FLOOR_THICKNESS_M / 2 - 0.002, 0.0),
)
scene.add_grid(
"/floor",
width=_FLOOR_SIZE_M,
height=_FLOOR_SIZE_M,
plane="xz",
cell_size=_FLOOR_CELL_M,
cell_thickness=0.6,
cell_color=_FLOOR_CELL_COLOR,
section_size=1.0,
section_thickness=1.2,
section_color=_FLOOR_SECTION_COLOR,
)
scene.add_frame("/origin", show_axes=True, axes_length=_ORIGIN_AXES_LENGTH_M, axes_radius=0.005)

def _add_models(self, scene: Any, assets: dict[str, bytes | None]) -> None:
"""Attach one profile's model meshes under the tracked frames (a missing file leaves the frame's axes)."""
if assets["head"] is not None:
Expand Down Expand Up @@ -591,9 +664,23 @@ def _set_geometry(self, side: str, glove: Glove | None) -> None:
if glove is None or not len(glove.points):
return
scene, base, color = self._server.scene, f"/tracking/{side}_glove", _COLORS[side]
handles = [scene.add_point_cloud(f"{base}/joints", glove.points, colors=color, point_size=0.006)]
joint_colors = color if glove.point_colors is None else glove.point_colors
bone_colors = color if glove.segment_colors is None else glove.segment_colors
handles = [
scene.add_point_cloud(
f"{base}/joints",
glove.points,
colors=joint_colors,
point_size=_JOINT_POINT_SIZE,
point_shape="circle",
)
]
if len(glove.segments):
handles.append(scene.add_line_segments(f"{base}/bones", glove.segments, colors=color, line_width=2.0))
handles.append(
scene.add_line_segments(
f"{base}/bones", glove.segments, colors=bone_colors, line_width=_BONE_LINE_WIDTH
)
)
self._geometry[side] = handles

def _place(self, key: str, pose: NodePose | None) -> None:
Expand Down
38 changes: 38 additions & 0 deletions examples/egocap/frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ when it isn't. Snapshots arrive about once a second, so a per-frame jitter figur
cannot be derived here and is deliberately not shown; measure jitter on the backend
or with a direct `/stream{id}` client instead.

## Operator layout

The page is built for an operator mid-take: the cameras fill it, and everything else is either a glance or one click away.

- **Status bar (top).** A large recording indicator (`READY`, `REC` with the live take timer, `STOPPING`, `SAVING`, `SET UP`,
`NOT READY`, `RECOVERY`, `NO FEED`), the task in large type, the saved count, the last take's outcome (kept / rejected /
discarded, duration, issue count on hover), and one chip per tracking device and camera. Chips with a problem are listed
first and say why; a healthy chip is just its name. The bar is read-only and derived from the backend's recording snapshot
and capture status (`OperatorBar.tsx`, `deviceHealth.ts`).
- `READY` is claimed only when the backend would admit a take **and** this browser has an operator name and the control token;
until then it says `SET UP` and what is missing. A command in flight shows at once (`STOPPING` while a take is written),
because the backend's own phase only changes when the command finishes. A stale feed shows `NO FEED`, never a retained
`REC`.
- **Camera stage (middle).** It takes every pixel the bar above and the dock below leave. The head camera (the one named
`head*`, otherwise the first) takes the left two-thirds and the others stack on the right; each tile carries its name and
measured / advertised Hz and latency on the picture. A live take puts a red frame around the stage.
- **Control dock (bottom).** Record / Stop-Save / Discard are large; an unset operator or control-token field leads the dock,
and once set shrinks to a quiet line. The dock hides the recording panel's own header, status line and last-result line,
since the bar above shows them.
- **Side panel (right).** Always showing: the 3D tracking view on top and the raw tracking tables and session details beneath
it (Details scrolls inside itself, so neither is pushed off the screen). **Drag the divider** between it and the main column
to choose its width -- pulling left widens the panel and narrows the cameras, status bar and controls, and the other way
round. The divider also works from the keyboard (focus it, then Left / Right arrows, Shift for bigger steps, Home / End for
the limits) and double-click resets it. The width is clamped between 400 px and 1000 px and never leaves the main column
under 560 px; it is remembered in this browser (`localStorage`, silently the default if storage is blocked). The status
bar rearranges itself by the main column's width (chips drop to their own row when it is narrow). On a window under 900 px
there is no room for two columns, so the panel goes below the page and the divider is hidden.
- The 3D view's iframe is never narrower than 640 px: Viser switches to a layout that hides its scene at 576 px and below, and
crashes its own page when resized across that breakpoint.

## Status presentation

`derivePresentation` (`frontend/src/App.tsx`) is the single source every
Expand Down Expand Up @@ -157,6 +187,14 @@ EGOCAP_VISER_ENABLED=0 uv run uvicorn egocap_web.app:app --app-dir backend --por
provision that directory with the application. The view's `diagnostic` always says which device's models it is
showing (`models: Pico 4 Ultra`), so a stand-in is never mistaken for the real hardware.
- The hands are drawn from the reported tracking-space joints as they arrive; controller models follow the grip pose.
Each glove keeps its side colour (blue left, orange right) and shades every finger from a dark knuckle to a pale
tip, so finger direction and curl read at a glance. Tracking space is `session_floor`, so the view draws a floor slab
and grid at y = 0 (1 m sections, 0.25 m cells) and small axes at the tracking origin.
- The **Normal / Large / Full screen** buttons above the view change its size. Normal is inline in the column, Large
is a centred pop-up over a dimmed page, and Full screen fills the window. Close with the Close / Exit full screen
button, by clicking outside the pop-up, or with Escape (Escape typed inside the iframe never reaches the page, so use
the button after clicking into the view). Changing size never reloads the iframe, so the orbit camera is kept, and
the size is not remembered across page loads.
- Viser draws its own control strip down the right edge of its page and 0.2.x cannot hide it, so the page makes the
iframe wider and crops that strip (see `.pose-viser-viewport` in `src/styles.css`). The cropped controls can still be
reached by keyboard from inside the iframe. Reset view reloads the iframe, which returns the camera to the default
Expand Down
Loading
Loading