System designTroubleshooting

Apriltag undefined reference in ROS2: link the C library in CMake

An undefined reference to apriltag functions means the library was never linked: the CMake target_link_libraries lines that fix a ROS2 build.

LumaSpec Vision9 min read Share on LinkedIn

On this page 8 sections
  1. 01What the linker log tells you
  2. 02The fix: put the library on the link line
  3. 03Keep the extern "C" wrapper
  4. 04Confirm the fix instead of rebuilding on hope
  5. 05When the one-line fix is not enough
  6. 06Size the image so the tags decode
  7. 07A five-line habit that prevents the next one
  8. 08Questions

You built AprilTag detection into a ROS 2 C++ node: the includes sit inside an extern "C" block, find_package(apriltag REQUIRED) is in the CMakeLists.txt, and apriltag is listed in package.xml. The build still dies at the last step with undefined reference to apriltag_detector_create, apriltag_detector_detect or tagStandard41h12_create, and collect2: error: ld returned 1 exit status. This guide reads that log, gives the CMake lines that end it, shows how to confirm the fix with nm, and closes with the camera-side numbers — tag size in pixels, focal length, working distance — so the tags decode once the node runs.

What the linker log tells you

Every failing line has the same shape: your object file, show_img.cpp.o, contains a call to a function the linker cannot find. Compilation succeeded — the headers were located, the prototypes matched, and the compiler emitted a call to the exact symbol name. The break is in the link step, and your include block is not the problem.

Two details in the log narrow it further:

  • The missing names are plain C symbols such as apriltag_detector_create and tagStandard41h12_create, with no _Z prefix. Name lookup matched the library's exported names, so your extern "C" wrapper is already doing its job.
  • apriltag_detector_add_family is a thin inline wrapper in apriltag.h whose body calls apriltag_detector_add_family_bits, which lives in the library. Seeing it in the log is not a second defect; it is the same missing library.
What the log shows What it means Fix
Plain C name, e.g. undefined reference to apriltag_detector_create The library is not on the link line of this target target_link_libraries with the apriltag library
Long _Z-style name containing the function name The call was compiled as C++ but the library exports C Wrap the includes in extern "C"
find_package(apriltag REQUIRED) itself errors during configure CMake never found the installed library Reinstall it and point CMAKE_PREFIX_PATH at the prefix
The build links, then the node cannot open libapriltag.so at startup Runtime loader path, not the linker ldconfig or LD_LIBRARY_PATH

The first row is this case, and it is the most common one by far.

find_package(apriltag REQUIRED) makes the package's variables and targets available to CMake. It does not add a single object file or -l flag to your executable. In an ament_cmake package, ament_target_dependencies links the ROS dependencies; apriltag is a plain C library outside ament, so it needs its own line.

  1. Keep find_package(apriltag REQUIRED) next to the other find_package calls.
  2. After the add_executable line that creates your node, attach the library and its headers (replace your_node with the target name you gave add_executable):
target_include_directories(your_node PRIVATE ${APRILTAG_INCLUDE_DIRS})
target_link_libraries(your_node ${APRILTAG_LIBRARIES})
  1. If your install exports the apriltag CMake target by name, one line carries both the headers and the library:
target_link_libraries(your_node apriltag)
  1. Rebuild the package in your workspace (colcon build --packages-select your_package) and run the node again.

Every target_ call must come after the add_executable that defines the target, and every target that calls apriltag functions needs the line — a second executable in the same package does not inherit it. If you hide the detector inside your own library, mark the dependency PUBLIC so the link reaches downstream executables:

target_link_libraries(your_lib PUBLIC apriltag)

The apriltag dependency entry in package.xml stays as it is: rosdep and the release tooling read it, the linker ignores it. It was never the bug, and removing it changes nothing at build time.

Undefined references to a camera SDK produce the same signature and the same reading; the diagnosis in camera SDK undefined reference: diagnose C++ linker errors applies to any C library. For where a dependency belongs in a package's CMake graph, see C++ camera SDK linking errors: diagnose CMake dependencies.

Keep the extern "C" wrapper

Take the wrapper off and the errors change character: the missing symbols grow _Z prefixes and stop matching the library's exported names, because the C++ compiler mangled your calls. The fix is the block you already have:

extern "C" {
#include <apriltag/apriltag.h>
#include <apriltag/tag36h11.h>
#include <apriltag/tagStandard41h12.h>
}

Nesting is harmless when a header carries its own extern "C" guard, so one wrapper around the whole include block is safe everywhere. Keep it, and keep every family header you actually create families from — tagStandard41h12_create and its siblings come from those headers.

Confirm the fix instead of rebuilding on hope

  1. Ask the installed library what it exports: nm -D /usr/local/lib/libapriltag.so | grep apriltag_detector_create. A line beginning with T means the library defines the symbol; use the path where your install actually put the file.
  2. Rebuild, then run ldd on the node binary: ldd your_node | grep apriltag must print a resolved path to libapriltag.so, not not found.
  3. If the node starts and then fails with libapriltag.so: cannot open shared object file, run ldconfig (a source install under /usr/local needs its cache refreshed) or point LD_LIBRARY_PATH at the library directory. That is the loader, not the linker, and no CMake line fixes it.

Then smoke-test the pipeline: one subscription callback, one detector pass, one printed count of detections. The callback wiring is the same shape as live edge detection from a video stream: wiring the grab loop, and when you hand pixels to OpenCV, wrapping an SDK frame buffer in a cv::Mat without copying saves a memcpy on every frame.

When the one-line fix is not enough

Three cases look similar but are not this bug:

  • find_package(apriltag REQUIRED) fails during configure with its own message. The library was never installed where CMake searches. Rebuild it from source, run make install, and add the install prefix to CMAKE_PREFIX_PATH.
  • You link a static apriltag into your own shared library and the link stops on relocation errors. Work through CMake -fPIC relocation errors when linking a vision SDK; the library must be built position-independent for that arrangement.
  • The undefined names belong to cv_bridge or OpenCV, not apriltag. Different dependency, same table: put each one on the link line of the target that calls it.

Size the image so the tags decode

The linker cannot fix geometry. The decoder resolves individual cells of a tag, so cell size in pixels sets the detection range. Commit to these numbers: 2 pixels per cell is the decode floor, 5 pixels per cell is dependable under rotation, blur and uneven light. A tag36h11 tag is 6 × 6 data cells inside a one-cell black border — 8 × 8 cells, 16 px at the floor — so design the station so the smallest tag covers at least 40 × 40 px.

m = tag width on sensor / tag width in the scene tag width on sensor = tag pixels × pixel pitch WD ≈ f / m

Worked example. Take a 4000 × 3000 sensor on a 1/1.7" optical format: the imaging area is about 7 × 5 mm, so the pixel pitch is 7 mm ÷ 4000 ≈ 1.75 µm — call it 1.8 µm. The smallest printed tag is 30 mm.

  • Tag on sensor at the 40 px target: 40 × 1.8 µm = 72 µm.
  • Magnification: m = 72 µm ÷ 30 mm = 0.0024.
  • With a 12 mm lens: WD ≈ 12 mm ÷ 0.0024 = 5000 mm = 5.0 m.
  • Field of view check: 7 mm ÷ 0.0024 ≈ 2.9 m wide, so a 30 mm tag sits well inside the frame at 5 m.

Decision: a 12 mm lens on a 4000-pixel-wide 1/1.7" sensor reads a 30 mm tag out to about 5 m. Fit a 6 mm lens and the range halves to 2.5 m; print the tag at 60 mm and the same 12 mm lens reaches about 10 m; demanding 20 m takes roughly a 48 mm lens at this pixel pitch.

Lens focal length, field of view and working distance
Units
Solve for
Focal length
– mm
Field of view
– mm
Working distance
– mm
Magnification
– x
Angle of view
– °
Pixel on the part
– mm

Thin-lens model: m = sensor width / field of view, f = WD × m / (1 + m). Accurate to a few percent above about 150 mm; check the lens's minimum object distance.

Find lenses with this focal length Open the full calculator

For the camera behind the topic: the LS-CMR-0166 is a 12 MP USB3 camera with a 1/1.7" rolling shutter sensor, a 4000 × 3000 output and 30 fps. A rolling shutter is fine for tags that pause in front of the lens; on a moving conveyor pick a global shutter sensor instead — the MV-CU020-90GC is a 2.5 MP global shutter GigE camera with a 1/1.7" sensor and a C-mount. Filter area scan cameras by shutter and resolution to match the range you computed.

Area scan cameras12 MP USB3 area scan camera, IMX226, 1/1.7" rolling shutter, 30 fps, 4000 × 3000 resolution (LS-CMR-0166)LS-CMR-0166
Sensor format
1/1.7"
Interface
USB3
$560.00View part
Area scan camerasHikrobot MV-CU020-90GC 2.5 MP Area Scan Camera, GigE, GMAX4002, ColorMV-CU020-90GC
Sensor format
1/1.7"
Lens mount
C-mount
$309.00View part

A GigE camera wants its own link: the MV-GE2002P is a dual-port PoE NIC for GigE cameras, so power and data share the cable and the camera traffic stays off the PC's shared port.

Frame grabbersHikrobot MV-GE2002P Dual-port PoE NIC, full height V2.0MV-GE2002P
Interface
GigE
Ports
2 ports
$249.00View partBuild a system with it

Bolt the camera to a mount you can re-position: the LS-MNT-0040 is a slide-table stand with a 700 mm height and a 400 × 450 mm base, so a change of working distance is a column and slide adjustment, not a bracket rework.

Camera standsMachine vision camera stand, professional slide-table type, height 700 mm, 400 x 450 mm base (LS-MNT-0040)LS-MNT-0040
Type
Camera stands
$629.00View partBuild a system with it

A five-line habit that prevents the next one

  • Every non-ament library gets a target_link_libraries line on every target that calls it.
  • Every target_ call sits after the add_executable or add_library that defines the target.
  • C headers stay inside one extern "C" block at the top of the source file.
  • Run the nm -D check before blaming the code, and keep it in a script for the next library.
  • Pin the apriltag version in package.xml and in the CMake find step so the CI machine and the robot build the same library.

More build and integration walkthroughs are collected under vision systems integration. If the linking side is clean and the tags still misread, send the camera model, lens focal length and the smallest tag size through the contact page and we will run the pixel math with you.

Questions

Does adding apriltag to package.xml link the library?

No. The apriltag dependency entry in package.xml tells rosdep what to install and the release tooling what to declare; the linker only reads your CMakeLists.txt. Keep the entry, and add target_link_libraries on the executable that calls the apriltag functions. Removing the entry does not cause undefined references either — the two files solve different problems.

My undefined references show long names starting with _Z — same fix?

No, that one is name mangling. A _Z-style symbol means the C++ compiler mangled your calls while the library exports plain C names, so the two never meet at link time. Wrap the apriltag includes in an extern "C" block. You can check any suspect symbol by piping it through c++filt: a mangled name expands back to a readable signature.

How many pixels does an apriltag need to be detected?

Plan on the full pattern, black border included, covering at least 40 × 40 pixels. The decoder resolves individual cells, so a 36h11 tag — 6 × 6 data cells inside a one-cell border, 8 × 8 cells overall — needs roughly 2 pixels per cell to decode at all and about 5 per cell to stay dependable under rotation and blur. Keep a white margin of at least one cell around the border.

Why does the node fail at startup with libapriltag.so not found?

The linker found the shared object at build time; the runtime loader cannot. That happens when a source install puts the library outside the loader cache, typically under /usr/local/lib. Run ldconfig so the cache picks it up, or point LD_LIBRARY_PATH at the directory holding libapriltag.so. Confirm with ldd on the node binary: the apriltag line must show a resolved path.

Do I need OpenCV to run the detector?

No. The detector consumes 8-bit grayscale pixels; OpenCV is just convenient plumbing around your image topic. Fill the library's grayscale image structure from your buffer's width, height and stride, or convert the message with cv_bridge and copy once. The linking fix is identical either way: target_link_libraries with the apriltag library on the target that makes the calls.