A pan/tilt laser turret that uses a fixed ESP32-CAM to find a tennis ball and keep a laser dot on it. A rangefinder rides on top of the laser, so once the dot is on the ball it can also tell you how far away the ball is.
Everything runs on the ESP32-CAM itself. No laptop is needed.
fixed camera turret
┌──────────────┐ ┌──────────────────┐
│ ESP32-CAM │ sees ball + │ rangefinder │
│ (OV2640) │ red dot ───► │ laser │
└──────────────┘ │ servo 2 (tilt) │
│ servo 1 (pan) │
└──────────────────┘
Every frame (160×120, about 15–20 fps):
Together these let the turret jump straight to the ball and then fine-tune until the dot sits on it.
The camera stays still and the turret sits a little to the side of it. Because of that, the pixel-to-angle map is only exact at the distance where you calibrated. The feedback loop corrects for the rest.
| Part | Notes |
|---|---|
| AI-Thinker ESP32-CAM | with the OV2640 camera and PSRAM (standard) |
| ESP32-CAM-MB USB board or a USB-serial adapter | for flashing |
| 2× SG90 servos | servo 1 = pan (left/right), servo 2 = tilt (up/down) |
| Laser module | low power only: Class 2, < 1 mW (e.g. KY-008) |
| Rangefinder | HC-SR04 (ultrasonic) or TF-Luna / TFmini (time-of-flight). See below |
| NPN transistor (2N2222) + 1 kΩ resistor | to switch the laser |
| 5 V supply, 2 A or more | for the servos. Do not power servos from the ESP32 |
| 470 µF capacitor | across the servo supply, to stop resets |
| 1 kΩ + 2 kΩ resistors | voltage divider for the HC-SR04 echo pin |
ESP32-CAM Connects to
───────── ───────────────────────────────────────────
GPIO 12 ─────────── Servo 1 (pan) signal (orange)
GPIO 2 ─────────── Servo 2 (tilt) signal (orange)
GPIO 13 ──[1kΩ]──── 2N2222 base
2N2222 emitter → GND
2N2222 collector → laser − (laser + → 5V)
GPIO 14 ─────────── HC-SR04 TRIG | TF-Luna RX (pin 3)
GPIO 15 ─────────── HC-SR04 ECHO * | TF-Luna TX (pin 2)
5V ─────────── rangefinder VCC
GND ─────────── common ground (ESP32, servo supply, laser, rangefinder)
Servo red/brown → external 5V supply + / − (grounds tied to the ESP32 GND!)
* HC-SR04 ECHO is 5V. Use a divider: ECHO ──[1kΩ]──┬── GPIO 15
└─[2kΩ]── GND
Why these pins? With the camera running, the ESP32-CAM only has GPIO 2, 4, 12, 13, 14 and 15 free, and those are only free because the SD card isn’t used.
All pins are set in firmware/laser_turret/config.h.
You can use either of two modules. Pick yours in config.h:
#define RANGEFINDER_TYPE RANGE_HCSR04 // or RANGE_TFLUNA, or RANGE_NONE
| Module | How it measures | Range | Beam | Notes |
|---|---|---|---|---|
| HC-SR04 | ultrasonic, times the echo | 2–400 cm | wide (~15°) | cheap; needs the 5V→3.3V divider on ECHO |
| TF-Luna / TFmini | light time-of-flight (LiDAR) | 20–800 cm | narrow (~2°) | much better match for a laser: it measures roughly where the dot is |
Mount it on top of the laser, pointing the same way, so it moves with the turret.
RANGE_OFFSET_CM corrects for how far the sensor sits from the pivot.The distance shows up in the serial output and on the debug page. When locked, the debug page also shows the ball’s position:
range 142.3 cm (TF-Luna) target x 31.2 y -4.0 z 138.8 cm
firmware/laser_turret/laser_turret.ino.secrets.example.h to secrets.h and fill in
your network. If you skip this, the board makes its own WiFi network called
LaserTurret (password turret123). You can also turn the page off
completely with ENABLE_WEB_DEBUG 0.c in Serial Monitor (or press calibrate on the debug page).The turret sweeps a 3×3 grid, finds the dot at each point and fits the map. The result is saved to flash, so it survives a reboot. Re-run it if you move the camera or the turret.
Before calibration the firmware uses a rough default map. It still tracks, just
more slowly. If the dot moves the wrong way before calibration, flip
PAN_SIGN or TILT_SIGN.
| Key | Action |
|---|---|
c |
calibrate |
a |
arm / disarm (disarmed = laser always off) |
r |
reset learned bias |
x |
clear calibration |
h |
help |
Status prints 5× a second:
ball ( 96.4, 51.2) dot ( 95.8, 51.9) pan 97.1 tilt 86.3 range 142.3 cm LASER LOCKED 18fps
Open the IP address printed at boot. The page has:
Use it for tuning the colour thresholds.
Everything is in config.h.
BALL_HUE_MIN/MAX, or lower BALL_SAT_MIN and
BALL_VAL_MIN. Light matters a lot; a dim room makes the ball look dull
olive.LASER_MIN_R or LASER_MIN_DIFF. A darker room
helps.LASER_MAX_AREA, or lower LASER_SEARCH_PX.ON_TARGET_FRAC to make it push further into the ball before holding.RGB565_SWAP_BYTES 1.MAX_STEP_DEG or BIAS_GAIN, or raise
TARGET_SMOOTH toward 1 for less smoothing / toward 0 for more.PAN_MIN/MAX_DEG and TILT_MIN/MAX_DEG.PAN_*, TILT_*) stop the turret from aiming outside your
area. Set them so it can’t reach head height.demo/ runs the turret’s tracking on your laptop camera; no hardware is needed.
Open it online
and allow camera access.
r) switches back to the tennis ball.The demo runs the same detection and control code as the firmware, ported to JS
in demo/tracker.js, on a 160×120 copy of each frame. The laser is simulated,
since there isn’t one on your laptop. The simulated turret is deliberately
mis-aligned and offset from the camera, so you can watch the feedback loop pull
the dot onto the target.
To run it locally, browsers only allow camera access over https or
localhost. Opening the file directly may not work, so serve the folder
instead:
cd demo && python3 -m http.server 8000
# then open http://localhost:8000
vision based laser turret/
├── README.md
├── firmware/laser_turret/
│ ├── laser_turret.ino main loop, tracking control, calibration, serial
│ ├── config.h pins, thresholds, gains, limits
│ ├── vision.cpp/.h ball + laser detection on RGB565 frames
│ ├── calibration.cpp/.h pixel ↔ angle map, least-squares fit, flash storage
│ ├── servo_ledc.cpp/.h servo PWM that doesn't clash with the camera clock
│ ├── rangefinder.cpp/.h HC-SR04 / TF-Luna drivers
│ ├── web_debug.cpp/.h optional WiFi debug page
│ ├── turret_state.h shared state
│ └── secrets.example.h WiFi template (copy to secrets.h)
└── demo/
├── index.html
├── demo.js scene, simulated turret, input
└── tracker.js detection + control (same as firmware)
camera init failed: reseat the camera ribbon and check that PSRAM is
enabled.