Monocular Hover

Training a small CNN to recover fingertip hover position from a single oblique low-res camera.

Vireo-Interfaces-Ltd/raspicam-hover-inference

The offhand device is a VR peripheral touch surface that handles precise selection and UI. That surface has to track the fingertip hovering over it at 1 cm or better (in addition to touch sensing), to provide visual feedback to the user through VR goggles.

Concept sketch: camera module on an oblique mount looking across a capacitive tracking surface
First concept: a camera in the corner of the device, mounted oblique at ~30–45°, watching the finger over the tracking surface.

Problem

Capacitive hover itself is a difficult problem. The pad’s sensing stack must be tuned and shielded for touch to maximize resolution, and even at maximum sensitivity a close hover took ~3 s to register (due to weak coupling between the fingertip and sensor array). An alternative is optical sensing. Hand-tracking literature is egocentric, not device-centric with the finger foreshortened and self-occluding.

The goal was a kB-scale TinyML model on an MCU with a low-res (~100×100) camera. Validation stand-in was a Raspberry Pi 4 and Camera Module V2.1, with the model compressed and profiled for size, latency, and power.

Picking an inference scheme

Three candidate formulations:

  1. End-to-end — CNN maps the raw frame directly to normalized surface coordinates (x,y)(x, y).
  2. Ray-plane — CNN finds the fingertip pixel (u,v)(u, v); calibrated intrinsics + extrinsics cast a ray and intersect it with the surface plane.
  3. LUT — CNN finds (u,v)(u, v); a per-session calibration lookup table maps it to (x,y)(x, y).

Ray-plane is physics-based and survives lighting changes, but is sensitive to calibration-drift. The LUT absorbs lens and perspective distortion for free, but is session-specific. Both share irreducible depth ambiguity.

End-to-end has the simplest runtime and the whole hand is in frame, so the network can lean on context the tip-only schemes throw away.

The rig

Raspberry Pi 4, Trill Square in a printed enclosure, and Pi Camera V2.1 on a cardboard oblique mount
Pi 4, the 7×7 cm Trill Square in a printed enclosure, and the camera on its (very rigid) oblique mount.
  • Camera: raspicam_node publishing compressed frames over ROS, with a custom roslaunch file to run at 192×96 to profile to a 32-bit MCU training resolution.
  • Ground truth: a Trill Square capacitive sensor, read by an Arduino over I2C and streamed as x y touch events.

Labels without a mocap system

The capacitive pad reports touches, but the model must learn hover. There is no sensor in the loop that measures hover position directly.

The workaround is interpolation. Two consecutive taps define a motion segment (touch down at P0P_0, t0t_0, touch up at P1P_1, t1t_1). Every camera frame timestamped inside the segment gets a label interpolated between the endpoints, so sweeping a finger from tap to tap labels dozens of hover frames with two ground-truth contacts.

flowchart LR
    subgraph sensor["Sensor"]
        direction TD
        TS[Trill Square] -- I2C --> AR[Arduino]
    end

    subgraph ser["Serial reader (thread)"]
        direction TD
        RD[read serial lines] --> PS[parse touch state] --> QE[queue touch events]
    end

    subgraph lab["Labeler"]
        direction TD
        RQ[read off queue] --> CB[ROS callback] --> LF[label frames]
    end

    subgraph wr["CSV / frame write"]
        direction TD
        WI[write images] --> LG["log timestamp + (x, y)"]
    end

    sensor -- "(flag, x, y)" --> ser
    ser --> lab
    lab --> wr

Interpolating finger position between the endpoints requires assuming a speed profile. I cross-referenced candidate profiles (linear, cosine, logistic sigmoid) against top-view video of the same sweeps; sigmoid tracked the real motion best, which matches intuition: a finger accelerates out of the first tap and decelerates into the second.

usigmoid(u)=11+ek(u0.5),k=12u_{\text{sigmoid}}(u) = \frac{1}{1 + e^{-k (u - 0.5)}}, \quad k = 12

Trill Square annotated with interpolated position values along a swept path
Validating the speed profile: interpolated positions cross-referenced against a top-view recording of the sweep.

The capture script seemed simple on paper but was rewritten several times to maintain accuracy and capture frequency against a starved main loop:

  • Serial reads blocked the ROS callback and dropped touch events → moved to a dedicated thread feeding a queue.
  • The GUI preview froze the program at high frame rates → --headless flag.
  • Sequential JPEG compression missed frames at 60 fps → ring buffer, batched processing, rate-limited polling.
  • Writing to the SMB mount cost ~50 ms per frame → write to the local SD card, sync to the Mac after the session.
  • Even the sigmoid computation got evicted: record raw timestamps during capture, interpolate in a separate pass.

Model

A MobileNetV2 backbone (last 30 layers fine-tuned) with a GAP → Dense(256) → Dropout → Dense(2, sigmoid) head regressing normalized (x,y)(x, y) under MSE, with brightness/contrast/flip augmentation.

Training curve: train and validation MAE over 25 epochs
Validation MAE settles around 0.06 normalized, roughly 4 mm on the 7 cm surface.

Validation MAE lands around 0.06 in normalized coordinates, about 4 mm on the 7 cm pad, from a 128×64 crop of a 192×96 stream. For a UI where the surface maps to a handful of hover targets, that is comfortably inside the budget.

The trained model converts to TFLite and runs on the Pi against the real camera stream, drawing the predicted position as a dot on a top-view canvas next to the camera feed.

The camera's oblique view of a hand over the surface during capture

The concept validates: hover position is recoverable from a single device-centric oblique view, end to end, with no calibration step.

Python · TensorFlow / Keras · MobileNetV2 · TFLite · OpenCV · ROS · raspicam_node · Raspberry Pi 4 · Pi Camera V2.1 · Trill Square · Arduino · NumPy

Other projects