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_createandtagStandard41h12_create, with no_Zprefix. Name lookup matched the library's exported names, so yourextern "C"wrapper is already doing its job. -
apriltag_detector_add_familyis a thin inline wrapper inapriltag.hwhose body callsapriltag_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.
The fix: put the library on the link line
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.
- Keep
find_package(apriltag REQUIRED)next to the otherfind_packagecalls. - After the
add_executableline that creates your node, attach the library and its headers (replaceyour_nodewith the target name you gaveadd_executable):
target_include_directories(your_node PRIVATE ${APRILTAG_INCLUDE_DIRS})
target_link_libraries(your_node ${APRILTAG_LIBRARIES})
- 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)
- 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
- Ask the installed library what it exports:
nm -D /usr/local/lib/libapriltag.so | grep apriltag_detector_create. A line beginning withTmeans the library defines the symbol; use the path where your install actually put the file. - Rebuild, then run
lddon the node binary:ldd your_node | grep apriltagmust print a resolved path tolibapriltag.so, notnot found. - If the node starts and then fails with
libapriltag.so: cannot open shared object file, runldconfig(a source install under/usr/localneeds its cache refreshed) or pointLD_LIBRARY_PATHat 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, runmake install, and add the install prefix toCMAKE_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_bridgeor 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.
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.
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.
- Sensor format
- 1/1.7"
- Interface
- USB3
- Sensor format
- 1/1.7"
- Lens mount
- C-mount
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.
- Interface
- GigE
- Ports
- 2 ports
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.
- Type
- Camera stands
A five-line habit that prevents the next one
- Every non-ament library gets a
target_link_librariesline on every target that calls it. - Every
target_call sits after theadd_executableoradd_librarythat defines the target. - C headers stay inside one
extern "C"block at the top of the source file. - Run the
nm -Dcheck 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.



