Build pathAdvancedA few weekends

Build a ROS 2 Robot That Sees and Drives to a Marker

A robot that spots a marker across a room, works out where it is relative to itself, and drives up to it head-on — structured as a real ROS 2 node graph.

Build a ROS 2 Robot That Sees and Drives to a Marker technical schematicPOSE → STANDOFF → DOCK

What you are building

A robot that is put down somewhere in a room, turns until it finds a printed marker on a wall, and drives up to it — stopping half a metre away, square to the marker’s face rather than merely close to it.

Every other build on this site runs one program on a microcontroller. This one is a computer with wheels, and that changes what the project is about. The hard parts are no longer timing and wiring; they are knowing where things are relative to each other, and keeping several pieces of software honest with one another while they all run at once.

Why this robot needs a computer

An Arduino cannot do this, and the reason is worth being precise about. It is not raw speed — it is that a camera frame is a quarter of a megabyte, ArUco detection wants floating-point linear algebra, and the whole thing has to happen while the wheels are still being commanded at 50 Hz. That is a job for an operating system with real memory and real scheduling.

What you get in exchange for that Linux box is the ability to look inside a running robot. Every message between every part is a topic you can subscribe to from a laptop. When the robot drives past the marker instead of to it, you do not add print statements and reflash — you ros2 topic echo the pose and watch it be wrong in real time.

The shape of the node graph

Five responsibilities, one node each:

Node Publishes Job
camera /image_raw, /camera_info Pull frames off the CSI bus
aruco_detector TF camera_link → marker_7 Find the tag, solve its pose
docking_controller /cmd_vel Decide where to drive
diff_drive serial to the Arduino Twist to wheel speeds
odometry TF odom → base_link Integrate encoder ticks

The interfaces are what make this worth structuring. The controller never touches a camera or a motor — it consumes a transform and produces a Twist. That means you can drive the same robot with keyboard teleop, test the controller against a fake transform with no hardware at all, and later drop in a navigation stack that publishes the identical topic.

Read nodes, topics, services, and actions before drawing your own version of that table. The most common mistake at this stage is making the docking controller a service — it is a long-running goal you must be able to cancel, which is exactly what an action is for.

Bill of materials

This is the two-brain robot, and the parts list reflects that: a computer for perception and a microcontroller for the things a computer cannot do reliably.

Part Qty Approx. cost Notes
Raspberry Pi 4, 4 GB 1 $55 4 GB is the practical floor for ROS 2 plus vision. 1 GB swaps and dies
Pi Camera Module 3 1 $25 The Wide variant is better for navigation — 102° instead of 66°
A2-class microSD, 32 GB 1 $8 Or boot from a USB SSD, which is genuinely faster
Heatsink or fan 1 $5 A Pi 4 throttles at 80 °C, and a throttled Pi drops frames
Arduino Uno or Nano 1 $5 Encoders and motor control — the real-time half
N20 encoder motors 2 $16
TB6612FNG 1 $3
2WD chassis 1 $12 Room for a Pi, a camera mast and a battery
Battery pack 1 $15 3S with a switching 5 V regulator rated 3 A
Printed ArUco markers $0 Print, measure the printed size, and use the measurement

Total: roughly $145–175. This is the most expensive project on the site by a wide margin, and most of it is the Pi.

Power is where these robots fail, and the failure is expensive. A brown-out during an SD card write corrupts the whole system image. Use a switching regulator rated well above the Pi’s draw, never share the rail with the motors, and check vcgencmd get_throttled — anything non-zero means the supply is inadequate and a corrupted card is coming.

Measure your printed markers. An ArUco pose solution scales directly with the marker size you declare. A marker printed at 95% by a printer’s “fit to page” gives poses 5% wrong at every distance, and nothing in the pipeline will tell you.

Build order

1 — Get ROS 2 running and prove the network. Follow installing ROS 2 on a Raspberry Pi. Do not move on until ros2 topic echo on your laptop shows a topic published on the robot. Discovery over Wi-Fi is the single most likely thing to eat an evening, and you want it eaten now rather than while debugging vision.

2 — Make the wheels obey a Twist. Wire the Arduino, encoders and TB6612FNG, and build the cmd_vel bridge. Milestone: keyboard teleop from the laptop drives the real robot. Calibrate wheel separation here — everything downstream inherits it.

3 — Calibrate the camera. Properly, with a real target, following camera calibration and ArUco pose. An uncalibrated camera produces poses that look plausible and are wrong by tens of percent at the edges of the frame, which is the worst failure mode available to you.

4 — Publish the marker as a transform. Not as a custom message with an x and a y in it. Publishing camera_link → marker_7 into TF2 means the controller can ask “where is the marker, in base_link?” and the answer accounts for exactly how your camera is mounted — including the fact that it is tilted, which it is.

5 — Drive at the marker. A proportional controller on bearing: turn toward it, drive forward, stop at the standoff distance. This works, and it arrives at an angle, which is the point of the next step.

6 — Approach along the normal. Instead of steering at the marker, compute a waypoint standing off from the marker’s face along its normal, drive to that, and only then drive straight in. This is the difference between reaching the dock and docking. It is also where the marker’s orientation — the part of the pose you did not need until now — starts to matter, and where you will meet pose ambiguity.

7 — Wrap it in a state machine. Search, approach, align, final, done, plus the transitions out of each when the marker is lost. Finite state machines for robot behaviour covers the structure; the specific thing this robot needs is a lost-marker timeout that returns to search rather than continuing on the last known pose.

Hardware notes that matter

Mount the camera rigidly, and measure how. Every pose the robot computes is relative to the camera, and TF2 turns that into a robot-relative pose using the transform you declare from base_link to camera_link. A camera on a flexible bracket makes that declared transform a lie that varies with acceleration.

Tilt the camera down, and say so. A camera pointed at the horizon loses the marker exactly when the robot gets close. Ten to fifteen degrees down keeps it in frame through the final approach — and that tilt goes into the static transform, not into a fudge factor in the controller.

Measure the marker with calipers. Pose scale is directly proportional to the marker size you tell the solver. Printing at 96% because the printer scaled to fit makes every distance 4% wrong, silently and consistently.

Feed the Pi separately from the motors. A stalling motor drops the rail, and where an Arduino would reset harmlessly, a Pi corrupts its filesystem. Either a separate regulator or a separate pack — this is not the place to save a component.

Keep the camera ribbon away from motor leads. It is an unshielded high-speed link. Motor noise coupled into it appears as corrupted frames, which appear as detections that flicker for no visible reason.

What good looks like

Measurement Typical on a Pi 4
Camera capture at 640×480 30 fps
ArUco detection, 640×480 25–30 fps, using 40–60% of one core
ArUco detection, 1920×1080 5–8 fps — and every frame is stale
Marker detection range (100 mm marker, 640×480) 1.5–2.5 m
Pose accuracy at 1 m ±2–5 cm position, ±3–5° orientation
Total image-to-decision latency 60–120 ms
Motor control loop, on the Arduino 100–200 Hz
ROS 2 topic latency, Pi to Arduino over serial 5–15 ms

Two rows carry the lesson of the whole project.

Resolution is not free, and higher is often worse. Going from 640×480 to 1080p costs 4–6× the processing for roughly 1.7× the detection range. Unless you specifically need to see a marker further away, the smaller frame gives you a faster, fresher answer — and freshness matters more than precision in a control loop.

The latency row is why the two-brain split exists. At 0.3 m/s, 100 ms of vision latency is 3 cm of travel; turning at 90°/s it is 9° of rotation. Every vision measurement describes where the robot was. Close a fast loop directly on that and it will oscillate. The working structure is the one this robot uses: the Arduino runs the fast loop on encoders, and vision corrects it slowly — exactly the same shape as fusing GPS with odometry on the waypoint rover, and for exactly the same reason.

Rolling shutter, if the robot moves while it looks

The standard Pi camera modules have a rolling shutter: rows are exposed one after another, so the top of a frame is captured a few milliseconds before the bottom. Stationary, this is invisible. Turning at 90°/s, the four corners of an ArUco marker were captured at four different robot orientations — and the pose solved from them is wrong in a way calibration cannot fix.

If the robot must do vision while moving, that is what the Global Shutter module is for. If it can afford to stop, look, and then move, the cheaper module is entirely fine — and stopping to look is a legitimate design choice, not a workaround.

When it goes wrong

Symptom Usually
Laptop sees no topics Multicast blocked by the access point, or mismatched ROS_DOMAIN_ID
Marker detected, robot does not move Twist published against a TwistStamped subscriber
Distance consistently off by a few percent Marker size wrong, or the printer scaled the page
Pose jitters wildly at range Marker too small in frame — get closer or print bigger
Marker normal flips back and forth Pose ambiguity — approach off-axis, or use a ChArUco board
Robot arrives near the marker but crooked Steering at the marker instead of along its normal
Overshoots on the final approach Steering on a pose that is 50–100 ms old — slow down near the end
Drives on after the marker leaves frame No lost-marker timeout in the state machine

The distribution in that table is worth noticing before you start: almost none of it is computer vision. It is networking, frames, units and control — which is what building a ROS 2 robot actually teaches, and why the ROS 2 foundations roadmap spends its time on communication patterns and coordinate frames rather than on OpenCV.

If this is a step too far, the micromouse teaches mapping and planning on a microcontroller with no Linux involved, and the phone-controlled robot covers the “commands arrive over a network” half of this problem for a fraction of the parts.

Project roadmap

The build path

Follow the tech tree from parts to a robot that follows a taped line. Each node unlocks when its prerequisites are done, and your progress saves on this device.

0 / 19 done

100%
Learning path

ROS 2 foundations

Learning path

Open path
Build

Drive it from a laptop

Build

Build

Approach along the normal

Build

Goal

Dock on a marker, repeatably

Goal

Components

Tutorials in this path

Frequently asked questions

Why use ROS 2 for this instead of a single Python script?

For this robot alone, a single script would work, and it would be shorter. What it would not survive is the second feature. The moment the camera loop, the drive loop and the decision logic have to run at different rates and keep running when one of them stalls, a single script becomes a scheduling problem you have to solve yourself. ROS 2 gives you process separation, a wire format between the parts, and — the part you feel immediately — the ability to inspect any topic from a laptop while the robot is running. The project is worth doing because it teaches that structure on a problem small enough to still understand.

Do I need an Arduino as well as the Raspberry Pi?

You do not strictly need one, but the robot is much better with it. A Pi runs Linux, which means your motor loop is scheduled alongside everything else and will occasionally be late by milliseconds. That is invisible for vision and fatal for a wheel PID. Splitting the robot — Pi for perception and decisions, microcontroller for the loops that must not jitter — is the standard architecture for a reason, and it is exactly the split ROS 2 encourages anyway.

Why an ArUco marker rather than detecting a real object?

Because the marker gives you a full 6-DOF pose from one frame with no training data and no ambiguity about which object you found. Detecting a chair tells you roughly where a chair is; detecting an ArUco tag tells you its position and orientation in metres, which is what a controller actually needs. It is also the honest starting point — real docking stations, warehouse robots, and drone landing pads use fiducial markers for the same reason.

Why does the marker's orientation flip back and forth?

This is pose ambiguity, and it is a property of the geometry rather than a bug in your code. A flat square viewed nearly head-on has two 3D orientations that project onto almost the same pixels, so tiny amounts of noise flip the solver between them. The distance stays stable while the estimated normal jumps. Use a physically larger marker, approach from slightly off-axis where the two solutions separate, or move to a ChArUco board or several markers on the dock — all of which give the solver more geometry to disagree with itself about.

How accurate is the docking?

With a calibrated camera and a marker measured to the millimetre, a centimetre or two at a half-metre standoff is realistic. The errors that dominate are not the ones people expect: marker size entered wrong scales every range estimate proportionally, and camera pipeline latency means the pose you are steering on describes where the marker was 50 to 100 milliseconds ago. Slowing down over the last 30 cm removes most of the second problem for free.

Can I do this on a Raspberry Pi Zero?

For this project, no — not comfortably. A Zero 2 W can run ROS 2 and can decode a camera stream, but ArUco detection at a useful frame rate on top of that leaves nothing behind, and building a workspace on it is painful. Use a Pi 4 with 4 GB or a Pi 5. The Zero is a good board for a robot whose Pi only relays commands.