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.
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”.
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.
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.