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
1 change: 1 addition & 0 deletions examples/meta_quest/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
session.add(MetaQuestHandStream(
"quest_tracking",
mode="hand", # or "controller" to map Touch Plus pose into wrist slots
quest_host=QUEST_IP, # push our IP via Quest HTTP — no broadcast needed
))

# Stereo passthrough camera. We pull MP4 + timestamps over HTTP on
Expand Down
78 changes: 68 additions & 10 deletions src/syncfield/adapters/meta_quest.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,21 @@ class MetaQuestHandStream(StreamBase):
_discovery_kind = "sensor"
_discovery_adapter_type = "meta_quest"

# Quest companion app HTTP control port (same CameraHttpServer that
# serves /status, /preview, /recording/*). We piggy-back on it to
# push the Mac's IP directly to the tracker, skipping UDP broadcast
# discovery entirely (which some APs silently drop).
DEFAULT_QUEST_HTTP_PORT = 14045

def __init__(
self,
id: str,
*,
host: str = "0.0.0.0",
port: int = DEFAULT_PORT,
mode: str = "hand",
quest_host: Optional[str] = None,
quest_http_port: int = 14045,
) -> None:
super().__init__(
id=id,
Expand All @@ -158,6 +166,8 @@ def __init__(
self._host = host
self._port = port
self._mode = mode.lower() if mode in ("hand", "controller") else "hand"
self._quest_host = quest_host
self._quest_http_port = quest_http_port

self._socket: Optional[socket.socket] = None
self._receive_thread: Optional[threading.Thread] = None
Expand Down Expand Up @@ -212,6 +222,20 @@ def connect(self) -> None:
self.id, self._host, self._port, self._mode,
)

# Direct-push of our IP to the Quest companion app — bypasses
# UDP broadcast discovery, which some APs silently drop. Only
# runs when the caller passed quest_host (i.e. they know where
# the headset is); otherwise we fall back to the responder path.
if self._quest_host:
try:
self._push_target_to_quest()
except Exception as exc:
logger.warning(
"[%s] Could not push target IP to Quest at %s:%d: %s "
"(falling back to broadcast discovery)",
self.id, self._quest_host, self._quest_http_port, exc,
)

# Spin up the discovery responder so the Quest companion app can
# auto-resolve our IP. Without this the user has to type the
# Mac's IP into the Quest HUD every time the network changes.
Expand Down Expand Up @@ -262,6 +286,34 @@ def disconnect(self) -> None:
# Discovery responder (UDP :14044)
# ------------------------------------------------------------------

def _push_target_to_quest(self) -> None:
"""POST our local IP to the Quest's CameraHttpServer.

The Quest's UDPTrackingSender will then unicast tracking packets
directly to us — no UDP broadcast required. This is the reliable
path when the Wi-Fi AP drops broadcasts (client isolation,
IPv6-only, etc.).

Uses urllib so we don't pull httpx into the base sensor adapter
just for one request — the camera adapter has its own httpx
dependency, but MetaQuestHandStream stays import-light.
"""
import urllib.request

local_ip = self._resolve_local_ip_for(self._quest_host)
payload = json.dumps({"ip": local_ip, "port": self._port}).encode("utf-8")
url = f"http://{self._quest_host}:{self._quest_http_port}/tracker/target"
req = urllib.request.Request(
url, data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=2.0) as resp:
body = resp.read().decode("utf-8", errors="replace")
logger.info(
"[%s] Pushed target %s:%d to Quest %s — %s",
self.id, local_ip, self._port, self._quest_host, body.strip(),
)

def _start_discovery_responder(self) -> None:
"""Respond to Quest companion-app discovery broadcasts.

Expand Down Expand Up @@ -489,20 +541,26 @@ def _process_packet(self, data: bytes) -> None:
# Build channels from packet
channels = self._parse_channels(packet)

# Always emit so the viewer's live panel shows tracking data the
# moment the headset is connected — without this the user has to
# press Record before they can confirm the sensor is even alive.
# Recording state still gates first/last_at (those describe the
# recorded segment, not the live preview) and the FinalizationReport
# frame count.
frame_number = self._frame_count
self._frame_count += 1
if self._recording:
if self._first_at is None:
self._first_at = capture_ns
self._last_at = capture_ns
frame_number = self._frame_count
self._frame_count += 1
self._emit_sample(SampleEvent(
stream_id=self.id,
frame_number=frame_number,
capture_ns=capture_ns,
channels=channels,
uncertainty_ns=self.UNCERTAINTY_NS,
clock_domain=self.CLOCK_DOMAIN,
))
self._emit_sample(SampleEvent(
stream_id=self.id,
frame_number=frame_number,
capture_ns=capture_ns,
channels=channels,
uncertainty_ns=self.UNCERTAINTY_NS,
clock_domain=self.CLOCK_DOMAIN,
))

def _update_connection_state_on_packet(self, at_ns: int) -> None:
"""Emit HEARTBEAT on first packet, RECONNECT after a drop."""
Expand Down
34 changes: 31 additions & 3 deletions src/syncfield/adapters/meta_quest_camera/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,40 @@ def stop(self) -> None:

# ------------------------------------------------------------------

# Exponential backoff for repeat failures. Quest's HTTP server
# disappearing for a minute (app restart, idle screen-off) would
# otherwise spam ~60 WARN lines at 1 s cadence.
_RECONNECT_INITIAL_S = 1.0
_RECONNECT_MAX_S = 8.0

def _run(self) -> None:
delay_s = self._RECONNECT_INITIAL_S
consecutive_failures = 0
while not self._stop_event.is_set():
try:
self._consume_once()
# _consume_once returned without raising — either the
# stream ended cleanly or the stop_event fired. Reset
# the backoff so the next genuine failure logs loudly.
delay_s = self._RECONNECT_INITIAL_S
consecutive_failures = 0
except Exception as exc: # pragma: no cover - exercised by reconnect test
logger.warning("MJPEG consumer error: %s", exc)
if self._on_health is not None:
consecutive_failures += 1
# First failure per reconnect cycle is a real warning;
# subsequent retries against the same outage get demoted
# to INFO so debug sessions aren't buried under spam.
if consecutive_failures == 1:
logger.warning("MJPEG consumer error: %s", exc)
else:
logger.info(
"MJPEG consumer error (retry %d, next in %.1fs): %s",
consecutive_failures, delay_s, exc,
)
if self._on_health is not None and consecutive_failures == 1:
self._on_health("drop", f"mjpeg error: {exc}")
if self._stop_event.wait(1.0):
if self._stop_event.wait(delay_s):
return
delay_s = min(delay_s * 2.0, self._RECONNECT_MAX_S)

def _consume_once(self) -> None:
client = httpx.Client(transport=self._transport, timeout=None)
Expand Down Expand Up @@ -218,6 +242,10 @@ def _decode_jpeg(data: bytes):
from PIL import Image

img = Image.open(io.BytesIO(data)).convert("RGB")
# Quest's passthrough camera frames arrive Y-flipped (OpenGL
# texture-origin convention): without this transpose the viewer
# shows the scene upside-down. Flip before the numpy handoff.
img = img.transpose(Image.FLIP_TOP_BOTTOM)
# The viewer server re-encodes frames assuming BGR (SyncField's
# house convention across OakCameraStream and UVCWebcamStream),
# so flip the last axis here.
Expand Down
30 changes: 19 additions & 11 deletions src/syncfield/adapters/meta_quest_camera/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,18 +267,26 @@ def latest_frame_right(self):

@property
def latest_frame(self):
"""Viewer-compat: return the left eye so the standard video panel
has something to render. The viewer's ``StreamSnapshot`` polls
``stream.latest_frame`` for any adapter declaring
``kind="video"``; without this proxy the camera card would sit
black even while the MJPEG preview is actively streaming.

The left eye is authoritative for now — phase 1 records the
same primary-camera view on both slots (see spec §9 Q1). Once
per-eye acquisition lands we can switch this to a side-by-side
composite or expose a user-selectable eye.
"""Viewer-compat: return a side-by-side ``[left | right]`` composite
so the single video panel shows both eyes at once. The viewer's
``StreamSnapshot`` polls ``stream.latest_frame`` for any adapter
declaring ``kind="video"``; syncfield's panel model is 1 panel =
1 stream_id, so until we split into two adapters we surface the
stereo pair as a horizontally-concatenated frame.

Falls back to whichever eye is available if the other is still
connecting or has dropped — users should see *something* rather
than a black card whenever at least one preview is alive.
"""
left = self.latest_frame_left
right = self.latest_frame_right
if left is not None and right is not None:
import numpy as np
if left.shape == right.shape:
return np.hstack((left, right))
# Shapes can diverge for a frame or two during startup while
# the two previews race to produce their first decoded image.
# Fall through to the single-eye path instead of raising.
if left is not None:
return left
return self.latest_frame_right
return right
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="@react-three/fiber" />
/**
* Quest3PosePanel — 3-D hand skeleton + head rig renderer for the
* MetaQuestHandStream data.
Expand Down Expand Up @@ -274,8 +275,11 @@ function SceneContent({ pose }: { pose: ParsedPoseData }) {
);
// Scale down aggressively so content stays inside the 0.84-m workspace box.
const fitScale = Math.min(0.8, Math.max(0.2, 0.18 / maxRadius));
const headAxisLength = Math.max(0.03, 0.06 * fitScale);
const markerScale = Math.max(0.6, Math.min(1.2, fitScale * 1.5));
// Bumped from 0.06 → 0.10 (head axis arms) and 1.5 → 2.4 (joint marker
// multiplier) so head + hand skeletons are legible at the closer
// default camera distance — previously they shrank to a tiny dot.
const headAxisLength = Math.max(0.05, 0.10 * fitScale);
const markerScale = Math.max(1.0, Math.min(2.0, fitScale * 2.4));

const transformed = (p: Vec3): Vec3 => vectorScale(vectorSubtract(p, pose.sceneCenter), fitScale);
const leftJoints = pose.leftJoints.map(transformed);
Expand Down Expand Up @@ -323,7 +327,7 @@ export function Quest3PosePanel({ pose: poseData }: Quest3PosePanelProps) {

return (
<div className="aspect-video" style={{ background: COLORS.bg }}>
<Canvas camera={{ position: [0.5, 0.35, 0.5], fov: 50 }}>
<Canvas camera={{ position: [0.17, 0.15, 0.20], fov: 55 }}>
<color attach="background" args={[COLORS.bg]} />
<ambientLight intensity={1.0} />
<directionalLight position={[3, 4, 2]} intensity={1.0} color="#fffdf8" />
Expand All @@ -334,7 +338,7 @@ export function Quest3PosePanel({ pose: poseData }: Quest3PosePanelProps) {
<Workspace />
<SceneContent pose={parsed} />

<OrbitControls enablePan={false} target={[0, 0.1, 0]} minDistance={0.2} maxDistance={2} />
<OrbitControls enablePan={false} target={[0, 0.1, 0]} minDistance={0.15} maxDistance={1.2} />
</Canvas>
</div>
);
Expand Down
Loading