Status: completed. University project (WiSe 2024/25), kept as a reference implementation.
Related: self-written encoder/PID library for the Arduino Leonardo — Zumo328P-Library
Camera-based line-following and path-mapping for the Zumo robot using ROS 2
A complete robotics stack built on ROS 2 (Python / ament_python) that enables a Zumo Shield robot to autonomously follow a black line on a white surface using a USB webcam and OpenCV, while simultaneously mapping the traversed path via wheel-encoder dead-reckoning.
Developed as part of the Master's course Autonomous Intelligent Systems (Mechatronics & Robotics, Frankfurt UAS, WiSe 2024/2025).
| Feature | Description |
|---|---|
| Line detection | Grayscale threshold → contour analysis across 5 image sections; Kalman-filtered centroid shown in the debug overlay |
| PID motor control | Discrete-time PID with anti-windup, derivative low-pass filter; configurable via ROS parameters |
| Encoder odometry | Dead-reckoning from quadrature encoder ticks; publishes Pose and OccupancyGrid |
| TF2 broadcasting | map → base_link transform published for RViz visualisation |
| Qt GUI | PyQt5 dashboard: live video, encoder values, motor speeds, pose, log |
| Custom Arduino library | Ported Zumo encoder library for the Arduino Leonardo (ATmega32U4) with interrupt-driven counting |
┌────────────────┐ ┌───────────────────┐
│ │◄─ camera_topic ─────┤ camera_node │
│ │◄─ line_coordinates ─┤ │
│ │ └─────────┬─────────┘
│ │ ▼ line_coordinates
│ │ ┌───────────────────┐
│ ├─ robot_command ────►│ motors_node │
│ │◄─ motor_speeds ─────┤ │
│ │ └─────────┬─────────┘
│ interface_node │ ▼ serial (speeds)
│ (Qt GUI) │ ┌───────────────────┐
│ │ │ Arduino │
│ │ └─────────┬─────────┘
│ │ ▼ serial (encoder packets)
│ │ ┌───────────────────┐
│ │◄─ encoder_data ─────┤ encoder_node │
│ │ └─────────┬─────────┘
│ │ ▼ encoder_data
│ │ ┌───────────────────┐
│ ├─ robot_command ────►│ path_mapping_node │
│ │◄─ robot_position ───┤ │
└────────────────┘ └──┬──────────────┬─┘
robot_position │ │ path_map,
▼ ▼ visualization_marker
┌──────────┐ ┌───────┐
│ tf2_node ├─TF─►│ RViz2 │
└──────────┘ └───────┘
All nodes also publish status messages on log_messages, which the GUI shows in its log window.
| Component | Details |
|---|---|
| Zumo Shield for Arduino v1.2 | Base platform with DC motors |
| Arduino Leonardo (ATmega32U4) | Motor control and encoder reading |
| Magnetic encoder pair | Pololu #3081 — 12 CPR, 2.7–18 V (external, mounted manually) |
| USB webcam | Any Linux-compatible UVC camera |
| Linux PC | Runs ROS 2 nodes |
| Track | Black line on white background, ≥ 2 cm width |
- ROS 2 Humble or Jazzy (Ubuntu 22.04 / 24.04)
- Python 3.10+
- Arduino IDE ≥ 2.0
sudo apt update
sudo apt install python3-pip python3-opencv python3-pyqt5 python3-serial \
ros-$ROS_DISTRO-cv-bridge \
ros-$ROS_DISTRO-tf2-ros \
ros-$ROS_DISTRO-rviz2
sudo apt remove brltty # conflicts with /dev/ttyUSB0 on Ubuntu 22.04mkdir -p ~/ros2_ws/src && cd ~/ros2_ws/src
git clone https://github.com/valnity98/ZumoRobot-ROS2.git
rosdep install --from-paths . --ignore-src -r -y # optional: install the dependencies declared in package.xml
cd ~/ros2_ws
colcon build --packages-select zumo_robot
source install/setup.bash- Clone Zumo328P-Library and open
Zumo328P-Library/example/ZumoRos2/ZumoRos2.inoin the Arduino IDE. - Install library dependencies via Library Manager: Zumo32U4 (used by the sketch on the ATmega32U4) and FastGPIO. ZumoShield is only needed when building for an ATmega328P.
- Copy its
library/folder into your Arduinolibraries/folder. - Flash to the Arduino Leonardo connected to the Zumo Shield.
ros2 launch zumo_robot launch_zumo.pyOptional arguments:
| Argument | Default | Description |
|---|---|---|
serial_port |
/dev/ttyUSB0 |
Arduino serial port |
target_position |
320 |
Image column for line centre (pixels) |
Example with custom port:
ros2 launch zumo_robot launch_zumo.py serial_port:=/dev/ttyACM0An Arduino Leonardo (ATmega32U4) shows up as /dev/ttyACM0, a USB-serial adapter as /dev/ttyUSB0 (the default). Check which one your system created (e.g. with ls /dev/ttyACM* /dev/ttyUSB*) and set serial_port accordingly.
The robot only drives after it has received the start command: press Start in the GUI, or send it from a terminal:
ros2 topic pub --once /robot_command std_msgs/msg/Int8 "{data: 1}"0 stops the robot, 2 resets the encoders and the odometry (see the robot_command topic below).
ros2 run zumo_robot camera_node
ros2 run zumo_robot encoder_node --ros-args -p serial_port:=/dev/ttyUSB0
ros2 run zumo_robot motors_node --ros-args -p target_position:=320
ros2 run zumo_robot path_mapping_node
ros2 run zumo_robot interface_nodeThe odometry constants at the top of zumo_robot/zumo_robot/path_node.py are not measured for every robot. Check CPR and WHEEL_RADIUS on yours and rebuild after changing them:
CPR(encoder counts per wheel revolution, currently75.81 × 12): Pololu's 12 CPR applies when both edges of both encoder channels are counted, but the Arduino library only counts the edges of channel A. Send reset ({data: 2}), turn one wheel by hand exactly one revolution, send stop once ({data: 0}, the Arduino only reports encoder values in reply to motor packets) and read the tick count withros2 topic echo /encoder_data. That count is the value forCPR.WHEEL_RADIUS(currently0.019m): withCPRcalibrated, reset, drive a straight 1 m and compare the distance reported inrobot_position(metres from the start) with 1 m. AdjustWHEEL_RADIUSuntil they match.
| Topic | Type | Direction | Description |
|---|---|---|---|
line_coordinates |
Float32MultiArray |
camera → motors | Centroid [cx, cy] (pixels) |
camera_topic |
sensor_msgs/Image |
camera → GUI | Annotated BGR frame |
encoder_data |
Int32MultiArray |
encoder → path/GUI | [left_ticks, right_ticks] |
motor_speeds |
Int16MultiArray |
motors → GUI | [left_speed, right_speed] |
robot_position |
geometry_msgs/Pose |
path → tf2/GUI | Dead-reckoning pose |
path_map |
nav_msgs/OccupancyGrid |
path → RViz | Visited-cell map |
visualization_marker |
visualization_msgs/Marker |
path → RViz | Path LINE_STRIP |
robot_command |
Int8 |
GUI → motors, path | 0=stop, 1=start, 2=reset (Arduino encoders and odometry) |
log_messages |
std_msgs/String |
all → GUI | Status log |
| Parameter | Type | Default | Description |
|---|---|---|---|
serial_port |
string | /dev/ttyUSB0 |
Arduino port |
target_position |
int | 320 |
Line-centre column |
kp |
float | 0.35 |
PID proportional gain |
ki |
float | 0.10 |
PID integral gain |
kd |
float | 0.10 |
PID derivative gain |
max_speed |
float | 125.0 |
Base motor speed |
motors_node always opens the serial port at 115200 baud (not configurable).
| Parameter | Type | Default | Description |
|---|---|---|---|
serial_port |
string | /dev/ttyUSB0 |
Arduino port |
baudrate |
int | 115200 |
Serial baud rate (must match the sketch, which uses 115200) |
PC → Arduino (7 bytes):
0x02 | left_speed[0] | left_speed[1] | right_speed[0] | right_speed[1] | ctrl | 0x03
ctrl: 0x11 = normal run (request encoder reply), 0x12 = reset encoders.
Arduino → PC (10 bytes):
0x02 | right_enc[0..3] | left_enc[0..3] | 0x03
All multi-byte values are little-endian signed integers.
- No command timeout. The robot only stops on an explicit stop command. If the camera finds no line, or the PC side stops (node crash, USB cable pulled), no new command arrives and the Arduino keeps driving at the last commanded speed. Use the Stop button (
robot_command0) or cut the power. - Reverse driving is not verified. On the ATmega32U4 the sketch drives the motors through
Zumo32U4Motors, whose direction pins differ from those of the Zumo Shield v1.2 (see the Zumo328P-Library). With the default gains the controller practically never commands negative speeds, so this path is not exercised. - Odometry constants are nominal.
CPRandWHEEL_RADIUShave not been calibrated on the robot (see Calibration), so distances in the map are approximate. - Encoder data follows the motor commands. The Arduino only replies with encoder values to motor packets, so the odometry rate follows the camera and pauses while no line is detected.
- The serial protocol has no checksum. Frames are recognised by their fixed length and the
0x02/0x03markers only.
| Name | Role |
|---|---|
| Mutasem Bader | ROS 2 nodes, PID control, path mapping, Qt GUI, system integration |
| Felix Fritz Biermann | Image processing (camera node) |
The Qt GUI (gui/, interface_node.py) was created with AI assistance.
Copyright (c) 2026 Mutasem Bader — All Rights Reserved.
Viewing is permitted. Copying, modifying, or submitting as own work is strictly prohibited.
See LICENSE for details.

