Live webcam demo — "expensive computation, all in Python"¶
Date: 2026-07-12 · Env: pixi vision (robostack-jazzy + conda-forge),
opencv 4.13.0 (C++ libs + headers + cv2), rerun-sdk 0.34.1, cppyy 3.5.0,
Python 3.12, linux-64. Machine: quiet laptop, /dev/video0 (built-in webcam,
640×480 @ ~30 fps), RTX PRO 2000 Blackwell (unused here — see the CUDA note).
The brief: a compelling live webcam demo doing genuinely expensive computation entirely in Python, with a robotics slant.
What it is. One script (cv_kit/demos/webcam_demo.py) runs a small visual
odometry front-end on the live webcam, two ways, over the identical frames,
side by side in Rerun with per-frame processing-time / achievable-FPS / CPU% plots:
- Pipeline A ("all in Python", the cppyy_kit way). camera →
cv::Mat(zero-copy alias of the capture buffer viacv_kit.numpy_to_mat) → C++cv::ORBkeypoints (cv_kit) → a hand-written per-keypoint NCC patch tracker + a 2D similarity motion estimate, all inside onecppyy.cppdefC++ kernel (features, grayscale patches and correspondence arrays never cross back into Python) → a TF transform + an image topic published viarclcpp_kit→ Rerun. - Pipeline B (naive Python baseline). The identical algorithm written the way
a roboticist prototypes it:
cv2.ORBkeypoints as Python objects, and the NCC patch tracker as a NumPy-per-keypoint Python loop.
Verdict: it lands, and the numbers are honest. On the same live frames the kit pipeline sails at 160–230 fps while the naive-Python one struggles at ~14 fps — a measured ~12–15× — and both compute the same optical flow. The gap is not magic: it is exactly the cppyy_kit thesis (COMMON_PATTERNS §6/§26 — a per-element Python loop is the trap), and it is dramatic here specifically because the expensive stage is a custom kernel with no OpenCV one-liner. Where OpenCV does provide the primitive (ORB, RANSAC), A is only ~1.1–1.2× faster — and this report says so.
How it fits together¶
flowchart LR
C["webcam (cv2.VideoCapture)\nor synthetic moving scene"]
M["cv_kit.numpy_to_mat\nZERO-COPY cv::Mat over the capture buffer"]
O["cv::ORB detect (cv_kit)\nkeypoints stay in C++"]
N["NCC patch tracker + estimateAffinePartial2D\nONE cppyy.cppdef C++ kernel (the expensive stage)"]
P["pose accumulate\n(dx,dy,dtheta)"]
R["Rerun: image + tracked features + flow arrows\n+ A-vs-B ms / fps / CPU% + trajectory"]
T["rclcpp_kit: publish /tf (world->camera)\n+ vision/webcam (sensor_msgs/Image)"]
C --> M --> O --> N --> P --> R
P --> T
Bp["Pipeline B (naive Python)\ncv2.ORB + NumPy-per-keypoint NCC loop"]
C --> Bp --> R
The two pipelines are VoTrackerCpp (A) and VoTrackerPy (B) in the demo; the C++
kernel is rclcppyy_webcam::VoTracker (a cppyy.cppdef block that also loads
calib3d for estimateAffinePartial2D, which cv_kit does not load by default).
The A-vs-B table¶
The expensive stage (the demo default): a per-keypoint NCC patch tracker. There
is no single cv2 call for "NCC-search each keypoint's patch over a window and
return the refined flow", so the naive baseline must loop in Python. Synthetic moving
scene, ORB nfeatures, NCC over the strongest ≤150 keypoints, 7×7 patch, 11×11
search, --bench-n 100, quiet machine (directional, not exact):
| Resolution | tracked kps | A (cppyy_kit → C++) | B (naive Python) | A speedup |
|---|---|---|---|---|
| 640×480 | 140 | 4.32 ms · 231 fps · 85% CPU | 66.3 ms · 15.1 fps · 99% CPU | 15.4× |
| 1280×720 | 150 | 6.14 ms · 163 fps · 95% CPU | 72.8 ms · 13.7 fps · 99% CPU | 11.9× |
Live from the actual webcam (640×480, --track-points 80): A 3.0 ms/frame
(~328 fps) vs B 39.8 ms (~25 fps) = 13×, 0 dropped frames, clean exit.
(CPU% is process CPU / wall — >100% when OpenCV multithreads ORB internally; the
NCC kernel and the Python loop are single-threaded, so at the plotted per-frame level
both hover near one core.)
The honest control — library-provided ops only (ORB match + RANSAC, no NCC
stage). When the per-frame work is only OpenCV C++ calls, cv2 is C++ too, so
the difference collapses to per-frame Python orchestration/copies (directional
micro-bench):
| Resolution / features | A (cppyy_kit) | B (cv2 Python) | A speedup |
|---|---|---|---|
| 640×480 / 1500 | 5.3 ms | 6.3 ms | 1.18× |
| 640×480 / 3000 | 7.7 ms | 8.7 ms | 1.12× |
| 1280×720 / 3000 | 10.9 ms | 11.5 ms | 1.06× |
This matches cv_kit's own REPORT note ("cv2.ORB would give similar per-frame
numbers — the win is composition"). The lesson for the stage: the cppyy_kit win
is large exactly when you write your own numerical kernel (which robotics people
constantly do — custom trackers, cost functions, robust estimators) and small when
you're just chaining library primitives. This demo shows both, on the same screen.
What the live view shows (stage-story self-assessment)¶
Left: the live camera with the tracked ORB features (green dots) and their NCC flow vectors (yellow arrows) — you move the camera and the arrows sweep with the motion. Right, top to bottom: processing time ms/frame (A green line pinned near the bottom, B orange line 10–15× higher), achievable FPS, process CPU %, and the accumulated camera trajectory (blue path) it publishes as TF. As you pan the camera the trajectory grows; the plot divergence is immediate and unambiguous.
Quality: strong. The two lines are far apart and stay apart, on live imagery, and
the narrative ("you wrote both in Python; one runs as C++") is true. Caveats worth
knowing on stage: (1) with both pipelines on every frame the loop is bottlenecked
by B (~14 fps), so the video updates at ~14 fps — press on with --no-baseline to
show A alone at full frame rate, or --track-points 60 to keep the loop snappier;
(2) the webcam here caps at 640×480 (the 1280×720 row is synthetic).
Robustness (built for a live stage)¶
- No camera? No problem.
--source auto(default) uses the webcam if it opens and otherwise falls back to the synthetic moving scene, printing why.--source syntheticforces it (CI/rehearsal). - Unplug mid-demo? A dropped/failed read never raises; after 5 consecutive failures the demo switches to the synthetic scene and logs a Rerun warning, so it keeps running.
- Frame 0 doesn't stutter.
warmup()runs both pipelines on throwaway frames first, moving the one-time first-use JIT of the C++track()wrapper (and OpenCV codegen) out of the live loop. - Clean teardown. The camera is released in a
finally;rclcppshuts down in order viacppyy_kit(noos._exit); Ctrl-C exits cleanly. Verified exit 0 across synthetic-headless, synthetic+ROS, and 6252-frame live runs. - Headless == live, minus the window.
RCLCPPYY_RERUN_SPAWN=0writes a.rrd; unset + a display spawns the native viewer (verified: the viewer opened a real X11 window and streamed). Samevision_vizconventions as the other demos.
Run-book (what to type on stage, what can go wrong)¶
# 0. one-time, in the worktree/checkout:
pixi install -e vision
# 1. THE demo — live webcam if present, else synthetic; opens a Rerun window:
ROS_DOMAIN_ID=62 pixi run -e vision demo-webcam
# 2. smoother video (A alone at full rate; the plots still show B from the table):
ROS_DOMAIN_ID=62 pixi run -e vision demo-webcam --no-baseline
# 3. no camera on the podium laptop — identical story on the synthetic scene:
pixi run -e vision demo-webcam --source synthetic
# 4. crank the drama (bigger gap, B drops harder): raise the tracked-point budget
pixi run -e vision demo-webcam --track-points 250
# 5. just the numbers (no window, no ROS) for a slide:
pixi run -e vision bench-webcam
What can go wrong, and the fix:
| Symptom | Cause | Fix |
|---|---|---|
| "no webcam … using the synthetic moving scene" | camera busy / no /dev/video* |
expected fallback; or free the camera / pick --device N |
| video feels laggy (~14 fps) | both pipelines run every frame (B is the bottleneck) | --no-baseline, or lower --track-points |
| no Rerun window opens | no display, or the viewer can't bind | it degrades to a .rrd and prints the rerun <file> line; force with RCLCPPYY_RERUN_SPAWN=1 |
/tf or image topic missing |
another ROS_DOMAIN_ID |
export ROS_DOMAIN_ID=62 (the demo's default) |
| want it without ROS entirely | — | --no-ros |
Controls: --source {auto,webcam,synthetic}, --device, --width/--height,
--duration, --nfeatures, --track-points, --patch-radius, --search-radius,
--min-score, --motion-scale, --no-ros, --no-baseline, --bench.
CUDA note (why there is no CUDA row)¶
cv_kit auto-detects a CUDA OpenCV build (cv::cuda::ORB) and pipeline A would take
it with no code change (docs: cv_kit/CUDA_OPENCV.md, Esri prebuilt validated on
this GPU at ~4.7×). Two honest reasons the head-to-head has no CUDA row:
- The single-process A-vs-B comparison is incompatible with provisioning CUDA
OpenCV. Pipeline B uses
cv2(the CPUlibopencv); pipeline A via cppyy would load the CUDAlibopencv. They share every soname, andCUDA_OPENCV.mdis explicit that loading bothlibopencv_corevariants in one process corrupts it. The CUDA path is therefore an A-only run (--no-baselinein thevision-cudaenv), not a same-process comparison. - The expensive stage is a CPU custom kernel. The NCC tracker (the thing that makes B struggle) is hand-written C++; CUDA would only accelerate the ORB detect step, a small fraction of A's ~4 ms — it wouldn't move the A-vs-B story.
So CUDA was timeboxed out as low-value-for-this-demo, with the path documented.
Tests¶
cv_kit/tests/test_webcam_demo.py (in pixi run -e vision test-vision; auto-skips
without OpenCV/cv2/rerun, so the default suite is unaffected — a clean collect-and-
skip): A/B parity (same keypoints, ≥90% bit-identical NCC flow, motion delta <0.5px),
A>2× B on the bench path, a deadline-bounded synthetic-headless live run that writes
its .rrd, and the source fallback. 13 passed, 14 skipped (the skipped ones are
the DBoW2 loop-closure suites — this demo doesn't use dbow).
Gaps / lesson candidates (for the lead — not added to COMMON_PATTERNS by me)¶
- The honest webcam headline: the cppyy win tracks "custom kernel vs library
primitive", not "C++ vs Python". For OpenCV-provided ops (ORB/match/RANSAC) A is
~1.1–1.2× (per-frame Python orchestration only); for a hand-written per-element
kernel with no
cv2/vectorized-NumPy one-liner (NCC patch track) it is ~12–15×. The demo's value is showing both on one screen so the audience sees exactly where cppyy_kit earns its keep. Reinforces §6/§26. cv2and cppyy-loaded OpenCV coexist fine in one process — for the same build. The CPUlibopencvis loaded once (same.so), so pipeline A (cppyy) and pipeline B (cv2) share it with no corruption. This is the flip side of the CUDA same-soname hazard: the hazard is mixing builds, not two loaders of one build. Worth a line for anyone wanting a cppyy-vs-cv2 comparison in one process.- A live A-vs-B demo can't also be a CUDA demo in one process (the soname conflict above). If a GPU comparison is wanted it must be A-only, or two processes.
time.process_time()deltas are a dependency-free honest per-pipeline CPU meter in a single-threaded driver: bracket each pipeline call,cpu% = 100 * Δcpu/Δwall= average cores busy (naturally >100% when OpenCV parallelizes). No psutil needed; the attribution is clean because the calls are sequential.