The bug that was making everything weird
Once everything worked technically, it did not work well. Follow mode would set a goal, start driving, and then along the way it would lose track of me and give up. Sometimes it would spin in slow circles. Flee mode was worse: some reasonable initial movement, then random turning, then wide arcs that put me out of frame entirely, ending with the robot stopped and facing me. Not ideal for something supposedly running away.
My first instinct was that everything was too slow. Which was partly true, but not the main thing.
The actual problem was that the goal position was computed roughly like this:
YOLO gives bearing and distance relative to the robot, transform that into the odom frame using the robot's current pose, set that as the Nav2 goal.
Every term on the right hand side depends on the robot's own pose estimate. So any odometry drift, any timestamp mismatch between when an image was captured and when the transform lookup happened, any small motor asymmetry, all of it gets injected directly into where the system believes I am standing.
Robot turns slightly. New bearing. Goal moves. Nav2 replans. Robot turns more. That is the circling.
The fix had three parts:
Track the person in a world fixed frame with a motion model. An alpha-beta filter holding position and velocity in the odom frame. A new detection corrects that estimate rather than replacing it. If the robot moves and I did not, the world frame estimate stays put.
Timestamp discipline. Look up the transform at the image's capture timestamp, not at processing time. At 2.5 m/s with 150ms of pipeline latency, the robot has moved 40cm. Fusing a detection against a pose from 40cm ago is a real error, not a rounding detail. It is also invisible when you test slowly on blocks and vicious at speed.
Persistence through dropout. Losing detection for 300ms should not mean giving up. The filter coasts on its velocity estimate with decaying confidence, full behavior for about half a second, degraded after that, and only then does it actually consider the target lost.
There is a wrinkle here worth mentioning. The ODIN's point cloud clock is offset from ROS time by tens of minutes. So now() - header.stamp produces nonsense. The estimator uses header.stamp only for the transform lookup itself, which is self consistent regardless of clock domain, and uses monotonic local time for all age and confidence bookkeeping.
I also built a persistence and recovery state machine on top: tracking, coasting, searching, recovering, lost. With one deliberate asymmetry between modes. In follow mode, losing the target means stop, rotate toward last known bearing, and scan. In flee mode, losing the target means keep going on the last heading while scanning, because if you are running from someone and briefly lose sight of them, you do not stop and turn around to look. That was exactly the observed bug.
Recovery from being stuck has a real constraint: the ODIN faces forward and the rear camera has no depth. Backing up is semi-blind. The saving grace is that the rolling local costmap retains recently observed obstacles even after they leave the field of view, so a backup maneuver can be checked against the map. Backups are short, slow, and costmap verified.
Two point three hertz
The perception pipeline was publishing detections at 2.3Hz. The target was 30.
At that rate, during a search sweep, the robot rotates in coarse angular chunks large enough to miss a person entirely between frames. It was not bad luck. Missing me was the expected outcome given the sampling rate.
I assumed the model was falling back to CPU. tegrastats seemed to confirm it: GR3D_FREQ 0% on every single sample, while all six CPU cores sat between 59 and 99 percent. The TensorRT engine file existed on disk. My conclusion was that it was not being loaded and something was silently running PyTorch on CPU instead.
I was wrong, and the real answer is better.
The engine path was correct. TensorRT version matched CUDA exactly. Device arguments were right. Profiling the live process with py-spy showed where the time was actually going: a point cloud callback doing a pure Python per-point list comprehension over up to 49,152 points, arriving at 5Hz, costing 50 to 150ms every single time.
rclpy uses a single threaded executor by default. That CPU-bound callback was blocking the image callback (the one that actually invokes TensorRT) from running at all. The GPU was not falling back. It was being starved.
Vectorizing that loop with numpy took it from 2.3Hz to about 10.5Hz and got GPU utilization off the floor. Gating debug image encoding on whether anything is actually subscribed, plus fixing the rear camera, took the rear tracker from 3.5Hz to 27Hz.
The front tracker is still at 10.5Hz against a 30Hz target. That remaining gap is per frame CPU preprocessing plus general system contention across all six cores, with two YOLO engines and the full Nav2 stack running concurrently. It is a real architectural problem rather than a config fix, and it is still open.
Three things I took from this:
tegrastats showing an idle GPU does not mean your model is on CPU. It might mean nothing is calling it.
Profile before you optimize. My hypothesis was reasonable, well supported by the evidence I had, and completely wrong.
One slow callback in a single threaded executor starves everything else in that node. That is a whole class of bug worth watching for.