Skip to content

feat(stereo-camera): vendor-neutral UVC side-by-side stereo source (Windows MF; V4L2 stub) - #1846

Merged
dfattal merged 3 commits into
mainfrom
feat/stereo-camera-uvc-source
Oct 9, 2026
Merged

dfattal merged 3 commits into
mainfrom
feat/stereo-camera-uvc-source

Conversation

@dfattal

@dfattal dfattal commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

What

ADR-043 Amendment 4: a vendor-neutral, service-owned stereo camera source for plain UVC side-by-side stereo webcams. Today every stereo camera comes from a display plug-in. With this, any display plus any SBS UVC stereo webcam works through XR_DXR_stereo_camera, with no vendor plug-in, the same consent and privacy model, and the same rectified output.

  • auxiliary/util/u_stereo_uvc.{h,c} (pure, tested on every OS):
    • Opt-in config. Nothing is claimed without the per-user stereo-cameras.json (%LOCALAPPDATA%\DisplayXR\, $XDG_CONFIG_HOME/displayxr/). DXR_STEREO_CAMERA_UVC_CONFIG overrides the path and DXR_STEREO_CAMERA_UVC=0 turns the source off.
    • Each entry matches by VID:PID and/or a friendly-name substring, and sets the layout (sbs-full | sbs-half), eye order, mode, eye size, nominal baseline and HFOV, plus an optional OpenCV calibration file (YAML or JSON).
    • Device assignment: one device per entry, and a name-only entry that matches several devices claims none.
    • A device that a plug-in reports as its platform_device_hint is never claimed. The Leia plug-in reports its tracker as "vvvv:pppp", and this form is covered by a test.
    • The SBS split resamples each half to a square-pixel eye (integer box reduction, then bilinear; NV12 or YUY2 in, NV12 out). Half-SBS uses an exact 2-row average. The default eye size is capped at 1280 px wide.
    • The nominal pinhole model, the calibration-file parser, and a fake backend.
    • Built-in allowlist: none. I could not cite a public SBS spec for any device.
  • auxiliary/os/os_uvc_capture*: the Media Foundation backend.
    • MFEnumDeviceSources only lists devices; nothing is activated.
    • The mode list creates the device source but never starts it.
    • Capture uses an async IMFSourceReader with advanced video processing, so the system MJPEG decoder MFT does the decoding. The newest sample wins.
    • V4L2 is a stub with the plan written down. The backend is its own library (aux_os_uvc) and is linked only into the service and displayxr-cli, never the runtime DLL.
  • Camera manager:
    • Each camera carries a source tag, and a single src_* dispatch picks the source. Consent, CAMERA_CONSUMER rules, the foreground rule, the in-use indicator, linger, rings, the rectifier and the refinement are one code path for both sources.
    • Plug-in cameras are enumerated first.
    • An uncalibrated UVC pair is reported without CALIBRATED, and its RAW calibration is refused. RECTIFIED is a pass-through (no remap, no zoom, nominal numbers) until the online vertical refinement applies a correction. After that it uses the normal corrected LUT path and the calibration generation moves.
    • A calibration file makes the camera CALIBRATED, and the R2 rectifier runs as usual.
    • Adds a create_ex() test seam.
    • Fixes a latent bug: cam->index now always equals the camera's slot. Before, it was the plug-in index, which went wrong after a skipped malformed camera.
  • CLI:
    • camera list shows (source: plugin|uvc), and "source" in --json. The JSON now escapes backslashes in the hint.
    • New camera uvc-devices [--modes] runs locally with no service. It shows the config and the devices present, and which entry claims which device (or that an entry is ambiguous). --modes lists modes for claimed devices only.
  • Wire: xrt_stereo_camera_properties.reserved becomes source (same size). The OpenXR struct and the spec version are unchanged.

Docs

  • ADR-043 Amendment 4.
  • Spec: new §9a, plus the rule changes in §2–§5, §8, §11 and §13. RECTIFIED now requires a camera the service can rectify, not CALIBRATED | NATIVELY_RECTIFIED.
  • Roadmap: row U1.
  • Env-var census: two new vars.
  • index.json summary updated; gen_extensions_index.py --check passes.

Tests (Windows, local)

  • tests_stereo_uvc: 12 cases, 402 assertions. Covers config parsing and errors, VID:PID parsing, matching, the assignment and ambiguity rules, plug-in exclusion, eye-size math, the nominal model, calibration files, the split (lr/rl, full/half, NV12/YUY2), and the fake source end to end (disparity 25.60 px, dy +2.40 px recovered). Closed loop: dy +2.398 → +0.004 px.
  • tests_stereo_camera_manager: the real manager, driven through its IPC handlers on the fake backend with an in-memory consent store. No MF, no device, no service, so it runs on CI. Checks enumerate and calibration semantics, and a RECTIFIED stream through consent and the read-only ring with refinement APPLIED, generation 0 → 1, delivered dy +2.40 → +0.004 px. A RAW stream stays RAW.
  • Full ctest: 91/91. build_windows.bat build is warning-clean apart from the known C4090 in oxr_session.c.
  • MinGW cross-compile was not run because it isn't installed on this box. macOS and Linux compile coverage comes from PR CI.

Not done / follow-ups

  • Hardware test with a real SBS webcam (owner run; recipe in the session report).
  • V4L2 backend.
  • MJPEG decode at 4K60 in software is heavy. A D3D11 device manager for hardware MJPEG decode, or choosing a smaller mode, is the next lever.
  • GPU rectifier (already a TODO in R2).

🤖 Generated with Claude Code

@dfattal

dfattal commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Hardware check (SR laptop + SpatialLabs Eyes 0733:011a, 2026-10-08): no config → claims nothing, tracker 04f2:b70c 'never touched'. With config (sbs-half, lr, 3840x2160@60): CLAIMED, camera list shows it as source: uvc 1280x720/eye; probe delivers a real stereo pair (2560x720, eye order correct: +35 px disparity at ~1.5 m). Findings: (1) 11.3 Hz delivered, ~93 ms latency — software MJPEG decode of 4K60 is the bottleneck; needs the D3D11/DXVA hardware decoder path before this is usable for calls (the camera only offers 3840x2160). (2) --rectified gate FAIL by a margin (+1.18 px vs 0.5) after 8 s at 11 Hz; refinement converging (+1.77 → +0.09 px window). Functional, but HW decode should land before merge.

dfattal and others added 3 commits October 9, 2026 12:23
…indows MF; V4L2 stub)

ADR-043 Amendment 4. Every stereo camera so far came from a display plug-in.
This adds the SERVICE's own source for plain UVC side-by-side stereo webcams,
so any display + any SBS UVC stereo webcam works with no vendor plug-in.

- util/u_stereo_uvc: opt-in config (per-user stereo-cameras.json, or
  DXR_STEREO_CAMERA_UVC_CONFIG; DXR_STEREO_CAMERA_UVC=0 = off): VID:PID and/or
  name match, sbs-full|sbs-half, eye order, mode, eye size, nominal baseline +
  HFOV, optional OpenCV calibration (YAML/JSON). Device assignment (a name-only
  entry matching several devices claims none; a plug-in camera's
  platform_device_hint is never claimed), the SBS split (box + bilinear, NV12/
  YUY2 in, NV12 out), the nominal pinhole model, and a fake backend.
- os/os_uvc_capture: Media Foundation backend (MFEnumDeviceSources, async
  IMFSourceReader, MJPEG decoded by the reader's decoder MFT); V4L2 stub + plan.
  Own library (aux_os_uvc) linked only into the service and displayxr-cli.
- Camera manager: per-camera source tag + src_* dispatch, so consent,
  CAMERA_CONSUMER rules, foreground rule, indicator, linger, rings and the
  rectifier are one path. Uncalibrated UVC pair: no CALIBRATED, RAW calibration
  refused, RECTIFIED = pass-through with the nominal numbers until the online
  vertical refinement folds a correction in. create_ex() test seam.
- CLI: `camera list` shows the source (plugin|uvc); `camera uvc-devices
  [--modes]` (local) shows config + devices + which entry claims what.
- xrt_stereo_camera_properties.reserved -> source (same wire size).
- Tests: tests_stereo_uvc (config, matching, assignment, geometry, calibration
  files, split, fake source, closed loop) and tests_stereo_camera_manager (the
  real manager through its IPC handlers on the fake backend: delivered row
  offset +2.40 -> +0.004 px).
- Docs: ADR-043 Amendment 4, spec section 9a + rule changes, roadmap U1,
  env-var census, index.json summary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…PU decode + scale)

The first hardware run (SpatialLabs Eyes, 3840x2160@60 half-SBS MJPEG)
delivered 11.3 Hz / ~93 ms. Root causes and fixes:

- Hardware path (Media Foundation): pick the adapter that has a hardware
  MJPEG decoder MFT (MFTEnumEx HARDWARE, matched to DXGI adapters by vendor;
  most dedicated memory wins, DXR_STEREO_CAMERA_UVC_ADAPTER picks), create a
  D3D11 device (VIDEO_SUPPORT, multithread-protected), hand the reader an
  IMFDXGIDeviceManager + MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, and request
  NV12 AT THE OUTPUT SIZE so the GPU decodes and downscales (3840x2160 ->
  2560x720). Readback: CopySubresourceRegion to a staging NV12 texture, an
  event query polled (a blocking Map would hold the device lock MF's decoder
  needs), one Map, copy out, Unmap, sample returned at once. The decoder MF
  actually inserted is inspected; a software decoder behind the device
  manager reopens on the software path. Software fallback with one WARN;
  DXR_STEREO_CAMERA_UVC_DECODER=auto|hardware|software. Software also asks
  MF's video processor for the output size.
- The CPU split was the real 11 Hz limiter: ~110 ms for a 4K frame. Rewritten
  (row-major accumulate, reciprocal multiply, single-row fast paths, memcpy
  when the frame is pre-scaled): ~25 ms for 4K, 0.34 ms for a pre-scaled frame.
- Reader callback re-issued ReadSample under its (recursive) lock; the reader
  can decode inline and call back re-entrantly, starving read(). Now issued
  outside the lock on an AddRef'd reader.
- read() returned a spurious instant TIMEOUT when the auto-reset event was
  left signalled; it now waits against a deadline.
- Backend open() takes the output-size hint; diagnostics: per-open decoder
  line, close summary (frames, mean readback), delivered/superseded on stop.
- CLI: `camera uvc-devices --decoder` (decoders + adapters + policy, no
  camera), `camera uvc-decode-bench <file>` (a media file through the
  identical path, paced to its frame rate, no camera). Dev laptop, 4K60
  half-SBS MJPEG file: hardware NVIDIA 54-58 Hz / Intel 60 Hz read rate,
  software with MF scaling 59.9 Hz.
- Docs: spec section 9a decode-path table + numbers, ADR consequence, census.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…k ring

Real SpatialLabs Eyes run of e5f8e2e: (1) the GPU scale 3840x2160 ->
2560x720 came out PILLARBOXED (the video processor preserves display aspect:
a centred ~1280 px picture between black bars, each eye black + half the
picture); (2) 36 Hz / 29 ms, readback 22-25 ms per frame (a synchronous copy +
Map waits behind the decode).

- Output type now declares an anamorphic pixel aspect so its display aspect
  equals the source's: PAR_out = PAR_src * src_w*out_h / (src_h*out_w) (1:2
  for 3840x2160 -> 2560x720) — a plain per-axis resize, each half its own eye.
  Applies to the GPU and the software video processor alike.
- Readback pipelined over a ring of 3 staging NV12 textures: each read queues
  a copy + event query and delivers the NEWEST copy already finished (this
  frame when the GPU is quick), polling the older one only when two are in
  flight; a read with no new sample flushes the waiting frame (no frame is
  lost at a pause / end of file). The first read primes (TIMEOUT).
- `camera uvc-decode-bench` checks the picture: per eye, mean luma of the
  outer 5 % columns vs the centre; exits 4 on dark side bars. It treats a
  TIMEOUT as "not yet" (2 s without a frame = end).
- tests_uvc_capture_mf (Windows): writes a bright 16:9 YUY2 AVI and opens it
  through the real backend asking for 32:9, on the hardware AND software
  policies, asserting the requested size and no dark side bars. Fails on the
  old 1:1 PAR, passes now. SKIPs without Media Foundation.
- Bench (dev laptop, bright 3840x2160@60 half-SBS MJPEG file, out 2560x720,
  no bars on any path): Intel 60.0 Hz (readback 0.7 ms), software + MF scale
  59.7 Hz, NVIDIA 43-53 Hz (readback 5.6-6.8 ms) — a file lets the async
  NVIDIA MFT decode far ahead of the paced requests (250-300 of 360 frames
  superseded), so the copies queue behind a backlog a 60 Hz camera does not
  create.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dfattal
dfattal force-pushed the feat/stereo-camera-uvc-source branch from 13dcbe5 to 3cbe14a Compare October 9, 2026 19:23
@dfattal
dfattal marked this pull request as ready for review October 9, 2026 19:40
@dfattal
dfattal requested a review from a team as a code owner October 9, 2026 19:40
@dfattal
dfattal merged commit e0ee49d into main Oct 9, 2026
40 checks passed
@dfattal
dfattal deleted the feat/stereo-camera-uvc-source branch October 9, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant