diff --git a/examples/meta_quest/record.py b/examples/meta_quest/record.py index c8e8091..c089bb9 100644 --- a/examples/meta_quest/record.py +++ b/examples/meta_quest/record.py @@ -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 diff --git a/src/syncfield/adapters/meta_quest.py b/src/syncfield/adapters/meta_quest.py index 176fec7..f34f6da 100644 --- a/src/syncfield/adapters/meta_quest.py +++ b/src/syncfield/adapters/meta_quest.py @@ -137,6 +137,12 @@ 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, @@ -144,6 +150,8 @@ def __init__( 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, @@ -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 @@ -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. @@ -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. @@ -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.""" diff --git a/src/syncfield/adapters/meta_quest_camera/preview.py b/src/syncfield/adapters/meta_quest_camera/preview.py index c51ca63..4fd4a7f 100644 --- a/src/syncfield/adapters/meta_quest_camera/preview.py +++ b/src/syncfield/adapters/meta_quest_camera/preview.py @@ -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) @@ -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. diff --git a/src/syncfield/adapters/meta_quest_camera/stream.py b/src/syncfield/adapters/meta_quest_camera/stream.py index 8173d60..65b60a0 100644 --- a/src/syncfield/adapters/meta_quest_camera/stream.py +++ b/src/syncfield/adapters/meta_quest_camera/stream.py @@ -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 diff --git a/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx b/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx index b815b2d..f7ee21b 100644 --- a/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx +++ b/src/syncfield/viewer/frontend/src/components/quest3-pose-panel.tsx @@ -1,3 +1,4 @@ +/// /** * Quest3PosePanel — 3-D hand skeleton + head rig renderer for the * MetaQuestHandStream data. @@ -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); @@ -323,7 +327,7 @@ export function Quest3PosePanel({ pose: poseData }: Quest3PosePanelProps) { return (
- + @@ -334,7 +338,7 @@ export function Quest3PosePanel({ pose: poseData }: Quest3PosePanelProps) { - +
);