ProjectsTOMÁS MARKEY

Robotics & Vision

BOATTRACK: A GIMBAL THAT KEEPS A BOAT IN FRAME

A pan-tilt camera mount that finds a boat in frame and keeps it centred.

In Progress⛵ RoboticsVision & Control

Project Overview

BoatTrack is a pan-tilt camera mount that finds a boat in frame and keeps it centred. A Mac handles the vision work, an ESP32-S3 relays commands, and two Feetech STS3215 smart servos move the camera.

Since I didn't have a real boat nearby, I tested with a photo of a sailing boat on an iPad screen, mounted on a stand.

BoatTrack pan-tilt mount holding an iPhone, clamped to a desk, with a sailboat on an iPad in the background
Fig. 1 — BoatTrack on the bench, with a sailboat on an iPad standing in for the target.

How It's Put Together

The system has three layers, and each one runs its own loop:

  1. Mac

    Camera → detect / track → P controller → pan & tilt velocity in [−1, 1]

    USB serial — "V <pan> <tilt>"
  2. ESP32

    Integrates velocity into position targets, applies soft limits, runs the watchdog

    Half-duplex servo bus @ 1 Mbps
  3. STS3215

    Internal position PID holds each target

Everything on the Mac side is Python: Ultralytics, OpenCV and NumPy. The firmware is C++ built with PlatformIO.

~30FPS
Median loop rate
~12ms
Median processing per loop
100Hz
Firmware motion loop
500ms
Watchdog timeout

Phase 1

Perception & Control on the Mac

I began without any hardware. For video I used my iPhone as a webcam through Camo Studio, which provides the RGB feed, and the servo output went to a MockServo that just printed commands. The actuator sits behind a small ServoInterface class, so later swapping in the real serial driver didn't touch anything upstream.

Detection

The default detector is YOLO11n filtered to the COCO boat class, running on the Metal GPU through MPS. I also added a YOLOWorld mode. It's an open-vocabulary model, so the target can be described in text, with prompts like “small toy boat”.

Tracking

Running YOLO on every frame was too slow, so the tracker runs detection every 15 frames and uses OpenCV's CSRT tracker in between. It also re-detects right away when CSRT loses lock.

New detections are matched to existing tracks by centroid distance, which keeps the primary target from jumping to a different boat. You can also click the preview to force a re-detect near a specific point.

One gotcha: CSRT is only included in opencv-contrib-python, not the plain wheel.

Control

The error is the centroid's offset from frame centre, normalised by frame size, so the gains work at any resolution. Frames get downscaled to 960px wide before processing, which was the biggest single improvement to loop rate.

I started with a full PID and a P → D → I tuning pass, but ended up with plain P control plus:

  • an EMA filter on the centroid to remove detection jitter
  • a small deadband so the gimbal stays still once the target is roughly centred

The ESP32 already integrates velocity into position, so the loop has an integrator built in, and the extra terms weren't helping much.

Each run writes a per-frame CSV trace with error, commands, FPS, latency, servo steps and kinematics, and I plotted those while tuning. On my longest logged session (about 23 minutes), the loop's median frame rate was around 30 FPS. Median per-loop processing time was about 12 ms.

Beyond Centring

Estimating Distance & Speed

I also wanted distance and speed estimates for the target, not just a centred image.

Monocular

This uses the pinhole model:

distance = real_width × focal_px / bbox_width

It depends on knowing the target's real width and the camera's horizontal FOV, and the estimate drifts when the target rotates or is partially hidden.

Velocity

A constant-velocity Kalman filter on the centroid gives image-plane velocity plus a short look-ahead position. Tangential speed is distance × angular velocity. Radial speed comes from how fast the distance changes.

LiDAR

I added a FrameSource abstraction so the pipeline could also take LiDAR depth, which the iPhone sends through the Record3D app. With depth available, distance is the median depth inside the bounding box instead of an estimate from box size.

It works over USB and over WiFi via WebRTC. The WiFi mode means one phone in the mount handles everything with no cable. The trade-off is that depth arrives HSV-encoded and compressed, with a range of only 0–3 m.

The BoatTrack dashboard on a laptop, showing a boxed sailboat on an iPad with a LiDAR depth view and readouts for status, distance, speed, servo steps and FPS
Fig. 2 — The live dashboard locked on target, with LiDAR depth, a 0.64 m distance reading and servo telemetry.

Phase 2

Real Servos

The firmware runs on a Seeed XIAO ESP32-S3. It reads newline-terminated ASCII commands:

CommandPurpose
VVelocity
CCentre
HHalt
KWrite the servo's PID registers
ISet a servo's ID
PPing a servo
FFree mode — torque off to position the axes by hand

The firmware also streams position telemetry back to the Mac. At 100 Hz it integrates the commanded velocity into position targets and clamps them to soft limits. I found those limits by moving each axis to its hard stops in free mode and backing off about 25 steps.

A 500 ms watchdog sets velocity to zero if the Mac stops sending, so a dropped USB link leaves the gimbal holding its position.

Problems Along the Way

  1. Servo IDs: both servos ship as ID 1. I added the I command so I could renumber them one at a time from the serial monitor instead of installing Feetech's tool.
  2. Stutter at slow speeds: with a fixed high move speed, the servo snapped to each small setpoint and then waited for the next one. Setting the move speed to match the rate the setpoint was advancing (with a small margin) made motion smooth.
  3. USB back-pressure: on the XIAO's native USB-CDC, if the Mac doesn't read the telemetry, the buffer fills up and the firmware's motion loop stalls. The Mac driver now drains incoming data every time it sends a command.
  4. Mounting orientation: the right swap and invert settings depend on how the camera is mounted, so --swap-axes, --invert-pan and --invert-tilt are CLI flags.
An early 3D-printed pan-tilt mount on a desk, resting on a pencil sketch
Fig. 3 — An early print of the mount on the desk, on top of its sketch.

Where It's At

The gimbal pointing its phone at a sailboat on an iPad as the iPad is moved across the table, with the camera's tracked view inset
Fig. 4 — The full loop on the bench: the gimbal follows as the target moves, with the camera's tracked view inset.

The full loop works end to end on the bench: camera → YOLO + CSRT → P controller → ESP32 → servos, with a live dashboard showing the feed, a LiDAR view, and distance, speed and servo data. When the target is lost, the gimbal currently just holds still.

The next things I want to add are search/recentre behaviour after a timeout, and feeding the Kalman look-ahead position into the controller to reduce tracking lag. Beyond that is testing on actual boats at real distances, where LiDAR won't reach and monocular estimation has to do the work.