Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ArUco AMR Visual Docking

Pose estimation error analysis and closed-loop visual docking for a differential-drive AMR, measured against exactly-known ground truth.

I shipped ArUco marker tracking on a Jetson-based AMR using the apparent-width method — measure the marker in pixels, divide, get a distance. This project rebuilds that estimator properly, measures where it breaks, and then runs both versions closed-loop to see which one actually lands the robot on the dock.

tests python license opencv

Rendered marker at 35 degrees yaw

A synthetic frame: marker at 2 m, yawed 35°. Every image is rendered through a known camera model at a chosen pose, so the ground truth is exact to machine precision and any error an estimator reports is genuinely its own.


Why synthetic ground truth

You cannot measure a pose estimator by photographing a printed marker, because then you are comparing one estimate against another — the tape-measure reading is itself uncertain, and at 3 m a 2 cm placement error is already larger than the effect you are trying to see.

So the marker is rendered: a pose is chosen, its corners are projected through the camera model, and the marker bitmap is warped onto the image plane by the homography between them. The pose that produced the pixels is known exactly. Detection then recovers the corners to better than 0.1 px at 2 m, so the residual is the estimator's, not the harness's.

The headline result

Range error against marker yaw

Marker yaw known-width error solvePnP error predicted $1/\cos\psi - 1$
−0.1% −0.07% 0.0%
15° 3.0% 0.05% 3.5%
30° 15.1% 0.05% 15.5%
45° 40.6% −0.42% 41.4%
60° 99.8% 0.35% 100.0%
70° 185.1% 0.40% 192.4%

The known-width method is not randomly wrong — it is wrong in a shape you can derive. A marker rotated by yaw $\psi$ projects to a width scaled by $\cos\psi$, so the estimator sees a narrower marker and concludes it is further away by exactly $1/\cos\psi$. The measured curve tracks that prediction within a percentage point at every angle, which is the strongest evidence that both the renderer and the explanation are correct.

At 60° the marker is reported at twice its true distance. solvePnP stays under 0.42% across the entire range.

Bearing, by contrast, survives: both estimators hold bearing error under 0.06°, because horizontal image position is unaffected by the marker's rotation. The naive method fails in range, not in direction — which is precisely why it feels like it works right up until it doesn't.

Does the error actually matter?

Static error curves show which estimator is more accurate. They do not show whether a controller can absorb the error. Only closed-loop testing answers that, so both estimators drive a real docking run: every control step renders what the camera would see from the robot's current pose, detects, estimates, and steers on that estimate. Nothing consults ground truth except the scoring.

Docking trajectories

Estimator Docked Final position error Final heading error Lost frames
solvePnP yes 0.039 m 9.9° 0 / 401
known-width no 0.237 m 27.3° 0 / 401

Both see the marker the whole way. The difference is entirely estimation quality, and it is a gap in final position error — the difference between docking and missing.

There is a second, deeper failure. The known-width method returns a distance and a bearing but cannot recover the marker's orientation at all. A docking controller needs that: the goal pose is a standoff point out along the marker's normal, not just somewhere near the marker. With no yaw estimate the controller must assume it is already on the marker's axis — an assumption that is only true at the end of a successful approach. That is why its heading error is 27° at the dock.

Sensor fusion: what an EKF actually buys

The obvious next step was an EKF fusing wheel odometry with the vision measurements, so the robot could coast through the dropouts measured above. Building it produced two results I did not expect.

A single marker does not observe your pose

Range and bearing to one point are two measurements for a three-degree-of-freedom pose. They constrain the robot to a circle around the marker and fix its heading relative to the line of sight, but say nothing about where on that circle it sits. Measured: the filter settles at a 0.230 m error that never shrinks, no matter how many clean detections arrive, because a stationary robot never excites the unobservable direction.

The fix was already in hand. solvePnP recovers the marker's apparent yaw — the angle between its normal and the line of sight — which is exactly the missing coordinate. Adding it as a third measurement takes the measurement Jacobian from rank 2 to rank 3, and the standing error to under 1 cm. Both facts are asserted in the tests.

That is also the second reason the known-width estimator is a dead end: it returns no yaw at all, so it cannot supply the one measurement that would make the problem well posed. It cannot be rescued by filtering.

Random dropout is the wrong test

My first fusion experiment used random frame dropout, and the filter lost. At 10 Hz, even 70% loss still delivers three corrections a second — and the unfiltered controller re-derives everything relative to the marker each frame, so it accumulates no drift to correct. It simply does not need help.

The realistic failure is a sustained occlusion: someone walks in front, or the marker leaves the frame mid-turn. A safe-stop controller halts for exactly the duration; a filter dead-reckons through it.

Time to dock against occlusion duration

Occlusion vision only vision + EKF stall recovered
none 5/5, dock 8.9 s 5/5, dock 8.9 s
3 s 5/5, dock 11.9 s 5/5, dock 9.1 s 93%
7 s 5/5, dock 15.9 s 5/5, dock 10.5 s 77%
13 s 5/5, dock 21.9 s 5/5, dock 15.1 s 52%
21 s 5/5, dock 29.9 s 5/5, dock 21.5 s 40%
31 s 4/5, dock 39.9 s 5/5, dock 29.5 s 34%

The unfiltered controller loses precisely the occlusion duration — 8.9 s plus the blackout, every time. The filter recovers most of that, and the fraction it recovers decays as odometry drift accumulates, which is exactly what dead reckoning should do.

Final accuracy is unchanged at 4 cm either way. That is the honest headline: the EKF does not make docking more accurate, it stops the robot from stopping. Those are different benefits and it is worth being precise about which one you are claiming.

One caveat stated plainly: the filter treats the dock as surveyed and estimates the robot's absolute pose, while the unfiltered baseline works purely relative and assumes nothing. That is how real docking works — an AMR knows where its charger is — but the two are not strictly like-for-like.

Everything else that breaks

Calibration. Neither estimator survives a wrong focal length; the error passes through essentially 1:1, so a focal length 10% too long puts every distance 10% too far. No amount of clever estimation recovers it — this is the one input you cannot afford to guess.

Effect of focal length error

Detection envelope. 79% of a 0.3–12 m × 0–85° grid detects at all. The limit is not gradual: past roughly 60° of yaw, or 8 m at a 0.15 m marker, the marker stops being found rather than being found inaccurately.

Detection envelope

Blur and noise. These are what actually end a docking run on a moving AMR — the pose is not wrong, it is simply absent for a few frames. Detection is robust to sensor noise alone, but collapses to zero at 4 px of blur regardless of noise. On a real robot that sets an upper bound on approach speed for a given exposure time.

Detection under blur and noise

Three bugs worth keeping

The interesting parts of this project were the failures, all of which are now regression tests.

1. Every single detection failed at first. The renderer placed the marker's "top" at $+Y$, but OpenCV's camera $+Y$ points down, so every marker was rendered vertically mirrored. ArUco bit patterns are not symmetric, so a mirrored marker matches no dictionary entry and simply never detects — at any distance, at any angle, with no error message. A test now asserts the top corner projects above the bottom one.

2. SOLVEPNP_IPPE_SQUARE is not usable here. It is the solver advertised for exactly this case — a planar square of known size — but on OpenCV 5.0 it returns NaN at some yaw angles even with OpenCV's own documented point ordering, and a degenerate near-zero translation with any other. A sweep over solver and ordering combinations gave:

Solver non-finite results worst finite range error
IPPE_SQUARE fails at multiple angles
IPPE 14 / 686 (2%) 2.63%
ITERATIVE 0 53.99%
IPPE + ITERATIVE fallback 0 / 686 2.63%

Neither is usable alone: IPPE is accurate but intermittently NaN, ITERATIVE is always finite but occasionally lands in the wrong local minimum — the classic planar pose ambiguity. Running IPPE first and using ITERATIVE only to rescue the frames it drops gives both properties at once.

3. One NaN cost 386 frames. An unguarded non-finite estimate did not just lose its own frame: it propagated into the controller, corrupted the robot's state, and every subsequent render then had nothing to detect. One bad estimate turned into 386 lost frames out of 401 and a completely failed run. Estimators now validate finiteness at the boundary, and a test asserts the trajectory stays finite.

How it is verified

41 unit tests on Python 3.10-3.13 in CI. The ones that matter check claims, not code paths:

Test What it protects
Known-width error follows $1/\cos\psi$ The headline claim itself, asserted rather than just written down
solvePnP accurate at 0–60° yaw The comparison is fair and not a tuning artefact
Marker renders upright, not mirrored The bug that silently broke every detection
solvePnP never returns non-finite values The NaN that cost 386 frames
Known-width returns None for yaw The limitation cannot quietly disappear
Projected size falls as $1/d$ The renderer obeys perspective
Docking gains satisfy $k_\alpha > k_\rho$, $k_\beta < 0$ Stability condition, checked not assumed
Trajectory stays finite State corruption cannot recur
Wrong focal length scales range 1:1 The calibration finding
EKF Jacobians vs. finite differences A sign error here would not crash, just quietly degrade the filter
Range+bearing Jacobian is rank 2, with yaw rank 3 The observability finding, as linear algebra
Range+bearing alone leaves a standing error The 0.23 m that never converges
Wheel slip actually perturbs the trajectory Stops the filter winning because odometry is secretly perfect

CI additionally re-runs every experiment and fails the build if no estimator manages to dock.

Quick start

git clone https://github.com/yongjunmun/ArUco-AMR-Docking.git
cd ArUco-AMR-Docking
pip install -r requirements.txt

python run.py                        # simplest: open run.py in an editor and press Run

python -m arucodock.cli errors       # estimator accuracy sweeps
python -m arucodock.cli envelope     # detection limits, blur and noise
python -m arucodock.cli dock         # closed-loop docking runs
python -m arucodock.cli fusion       # EKF under sustained occlusion
python -m arucodock.cli all          # everything into results/

python -m unittest discover -s tests -t . -v

Adding your own estimator takes one class:

import numpy as np
from arucodock import DEFAULT_CAMERA, MarkerPose, PoseEstimate, PoseEstimator, detect_corners, render

class MyEstimator(PoseEstimator):
    name = "mine"

    def estimate(self, corners, camera):
        width = np.linalg.norm(corners[1] - corners[0])
        return PoseEstimate(range_m=0.15 * camera.focal_px / width, bearing_deg=0.0)

pose = MarkerPose(distance_m=2.0, yaw_deg=30.0)
print(MyEstimator().estimate(detect_corners(render(pose), 7), DEFAULT_CAMERA))

Layout

run.py                click-to-run entry point
arucodock/
  camera.py           pinhole intrinsics, distortion, deliberate miscalibration
  scene.py            synthetic marker rendering at exactly known poses
  detect.py           ArUco detection with sub-pixel corner refinement
  estimators.py       known-width vs solvePnP, with the solver fallback
  experiments.py      sweeps over yaw, distance, calibration, blur and noise
  robot.py            differential-drive AMR model
  ekf.py              EKF fusing wheel odometry with range/bearing/yaw
  docking.py          closed-loop visual servoing, perception in the loop
  plotting.py         figures
  cli.py              command line entry point
tests/                41 unit tests
results/              committed figures and CSVs

Limitations

Stated plainly, because a simulation result is not a hardware result:

  • No hardware validation. Rendering is a perfect pinhole camera. A real sensor adds rolling shutter, auto-exposure hunting, chromatic aberration and lens distortion that this models only crudely.
  • The lighting model is trivial. Uniform illumination, no specular reflection off the marker, no shadow across it. On a real floor, glare on a laminated marker is a common detection failure that does not appear here at all.
  • The robot model is kinematic. No wheel slip, no acceleration limits, no controller latency between capturing a frame and acting on it — all of which matter at speed.
  • One marker, one dictionary. No occlusion, no multiple markers, no false positives from marker-like clutter in the background.
  • Blur is isotropic Gaussian. Real motion blur is directional and depends on the velocity, so the 4 px cliff is indicative rather than a number to design against.

Next steps

Camera distortion recovery and a calibration routine, a marker-board pose estimate rather than a single tag, multiple markers so the pose stays observable when one is occluded, and validation against a real webcam with a printed marker on a measured rail.

License

MIT — free to use, modify and distribute, with attribution and no warranty.

About

ArUco pose estimation error analysis and closed-loop visual docking for a differential-drive AMR. Measures where the apparent-width method breaks against exact synthetic ground truth, then proves it closed-loop.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages