Unit 10 · Lesson 6

Fusing Vision with Odometry: addVisionMeasurement

Odometry alone drifts. Vision alone is noisy. SwerveDrivePoseEstimator fuses both — continuously updating a pose estimate that has the smooth continuity of odometry and the absolute accuracy of vision. This lesson is where every previous lesson in Unit 10 connects to the drivetrain.

By the end of this lesson, you will:

  • Explain the difference between SwerveDriveOdometry and SwerveDrivePoseEstimator and why vision fusion requires the latter
  • Implement addVisionMeasurement(pose, timestamp) correctly — with the capture timestamp, not the current time
  • Explain conceptually what SwerveDrivePoseEstimator's Kalman filter does and why it needs both the odometry standard deviations and the vision standard deviations
  • Structure the drivetrain subsystem so vision measurements are added from a VisionSubsystem without creating circular dependencies
  • Verify that vision fusion is working correctly by observing the Field2d overlay in SmartDashboard
  • Identify the three failure modes that prevent vision fusion from improving pose accuracy

SwerveDriveOdometry vs. SwerveDrivePoseEstimator

If you've already built your drivetrain subsystem with SwerveDriveOdometry (introduced in Unit 7), the first change this lesson requires is upgrading to SwerveDrivePoseEstimator. They serve the same basic function — tracking the robot's field-relative pose using encoder and gyro data — but the pose estimator adds the ability to receive external measurements (vision) and fuse them into the tracked pose.

Aspect SwerveDriveOdometry SwerveDrivePoseEstimator
Core function Tracks pose from encoder + gyro only Tracks pose from encoder + gyro, with optional external measurements
Vision measurements Not supported — no API for external input addVisionMeasurement(pose, timestamp)
Drift over time Accumulates uncorrected encoder/wheel slip error Drift corrected whenever a valid vision measurement arrives
Internal model Simple integration of encoder deltas Kalman filter — models uncertainty in both odometry and vision
Constructor 3 args: kinematics, gyroAngle, modulePositions 5 args: kinematics, gyroAngle, modulePositions, startPose, stdDevs
API compatibility Subset of PoseEstimator API Drop-in replacement — update() has the same signature
💡 SwerveDrivePoseEstimator is a drop-in replacement

If your drivetrain subsystem already uses SwerveDriveOdometry, upgrading to SwerveDrivePoseEstimator requires changing only the declaration and constructor — the update() call in periodic(), the getPose() method, and the resetPose() method all have identical signatures. You don't need to change any call sites. The only additions are the initial standard deviations in the constructor (covered in Lesson 7) and the new addVisionMeasurement() calls you'll add.

The Kalman Filter: What It Does and Why It Works

You don't need to understand the math of a Kalman filter to use SwerveDrivePoseEstimator correctly. But you do need to understand the conceptual model, because it explains every API decision and every tuning choice you'll make in Lessons 7 and 8.

At its core, the Kalman filter maintains two things simultaneously: a best estimate of the robot's current state (pose), and an estimate of the uncertainty in that estimate. Every 20 ms when update() is called, the filter integrates the encoder and gyro measurements — which adds small amounts of uncertainty (wheel slip, encoder error). When addVisionMeasurement() is called, the filter incorporates the vision measurement by blending it with the current estimate, weighted by how trustworthy each source is relative to the other.

The weights are determined by the standard deviations you provide:

  • Odometry standard deviations (set in the constructor): how much the Kalman filter trusts the encoder/gyro integration per loop. Smaller = trust odometry more. Usually small values like [0.1, 0.1, 0.1] meters/radians.
  • Vision standard deviations (passed with each measurement): how much the Kalman filter trusts this specific vision measurement. Larger = trust vision less for this measurement. Variable — should reflect how accurate the current detection actually is (distance, number of tags, etc.).

Fusion in Action: Odometry Drift vs. Vision-Corrected Pose

The visualizer below simulates a robot moving along a curved path for several seconds. Watch how odometry alone drifts from the true position, and how adding periodic vision measurements snaps the estimate back. Adjust drift rate and vision update frequency to understand the relationship.

Kalman filter pose fusion — simplified simulation press Run to simulate
4
3/s
3
Odometry only
Fused
true robot path
odometry-only estimate
fused (vision + odometry)
vision measurement event

Implementing the Fusion in DriveSubsystem

The architecture is clear: SwerveDrivePoseEstimator lives in the drivetrain subsystem, not in the vision subsystem. Vision measurements are passed into the drivetrain via a public method. This keeps the drivetrain as the single source of truth for robot pose, and the vision subsystem as a data provider — never a pose owner.

DriveSubsystem.java — complete pose estimator setup with vision fusion
import edu.wpi.first.math.estimator.SwerveDrivePoseEstimator;
import edu.wpi.first.math.geometry.*;
import edu.wpi.first.math.kinematics.*;
import edu.wpi.first.math.Matrix;
import edu.wpi.first.math.VecBuilder;
import edu.wpi.first.math.numbers.*;

public class DriveSubsystem extends SubsystemBase {

    // ... motor controllers, modules, gyro declared here ...

    private final SwerveDrivePoseEstimator m_poseEstimator;
    private final Field2d m_field = new Field2d();

    public DriveSubsystem() {
        // Odometry standard deviations: [x (m), y (m), theta (rad)]
        // These represent how much error accumulates per loop from encoders/gyro.
        // Lower = trust odometry more for the blend calculation.
        // Typical starting values: 0.1 m, 0.1 m, 0.1 rad
        Matrix<N3, N1> stateStdDevs = VecBuilder.fill(0.1, 0.1, 0.1);

        // Vision standard deviations: default for addVisionMeasurement() calls
        // that don't specify their own. We'll override these per-measurement
        // in the actual addVisionMeasurement() call based on detection quality.
        Matrix<N3, N1> visionStdDevs = VecBuilder.fill(0.9, 0.9, 0.9);

        m_poseEstimator = new SwerveDrivePoseEstimator(
            m_kinematics,
            m_gyro.getRotation2d(),
            getModulePositions(),
            new Pose2d(),         // reset to actual starting pose in autonomousInit()
            stateStdDevs,
            visionStdDevs
        );

        SmartDashboard.putData("Field", m_field);
    }

    @Override
    public void periodic() {
        // Update with encoder + gyro every loop — this is identical to
        // SwerveDriveOdometry.update(), same signature.
        m_poseEstimator.update(
            m_gyro.getRotation2d(),
            getModulePositions()
        );

        // Update Field2d widget for SmartDashboard visualization
        m_field.setRobotPose(getPose());
        SmartDashboard.putNumber("Drive/X", getPose().getX());
        SmartDashboard.putNumber("Drive/Y", getPose().getY());
    }

    // ── Public methods ────────────────────────────────────────────────────────

    /** Called by VisionSubsystem whenever a new pose estimate is ready. */
    public void addVisionMeasurement(Pose2d visionPose, double timestampSeconds) {
        m_poseEstimator.addVisionMeasurement(visionPose, timestampSeconds);
    }

    /** Overload: addVisionMeasurement with explicit per-measurement stddevs.
     *  Use this when measurement quality varies (multi-tag vs single-tag,
     *  close vs far). Lesson 7 covers how to compute these dynamically. */
    public void addVisionMeasurement(Pose2d visionPose, double timestampSeconds,
                                     Matrix<N3, N1> stdDevs) {
        m_poseEstimator.addVisionMeasurement(visionPose, timestampSeconds, stdDevs);
    }

    public Pose2d getPose() {
        return m_poseEstimator.getEstimatedPosition();
    }

    public void resetPose(Pose2d pose) {
        m_poseEstimator.resetPosition(
            m_gyro.getRotation2d(),
            getModulePositions(),
            pose
        );
    }

    private SwerveModulePosition[] getModulePositions() {
        return new SwerveModulePosition[] {
            m_frontLeft.getPosition(),
            m_frontRight.getPosition(),
            m_backLeft.getPosition(),
            m_backRight.getPosition()
        };
    }
}

The Architecture: Who Calls What

The flow of data from camera to robot pose has exactly one "write" point into the pose estimator, and the drivetrain owns it. The vision subsystem is a producer of measurements, not a consumer of poses.

Vision-odometry fusion architecture
💡 Pass the drivetrain to the vision subsystem, not the other way

The cleanest dependency pattern: VisionSubsystem takes the DriveSubsystem as a constructor argument and calls m_drive.addVisionMeasurement() directly in periodic(). This avoids the need for a callback or event system, and it keeps the data flow explicit: vision measures → drive accumulates. The alternative — having the drivetrain poll the vision subsystem — creates a polling chain that can silently use stale data if periodic ordering changes. The push model (vision calls drive) is simpler and more direct.

🔍 Why the timestamp matters so much

The Kalman filter in SwerveDrivePoseEstimator maintains a history of past odometry states. When a vision measurement arrives, the filter looks up where odometry thought the robot was at the measurement's timestamp, computes the correction needed to reconcile the vision pose with that historical odometry state, and applies that correction forward in time to the current estimate. If you pass the current FPGA timestamp instead of the capture timestamp, the filter looks up the robot's current position (not where it was when the image was taken) and applies the correction at the wrong point in the trajectory. The robot may have moved 10–20 cm during the 30–100 ms of pipeline latency. That 10–20 cm error is exactly what the timestamp-based correction is designed to eliminate — and passing the wrong timestamp eliminates the correction's benefit.

The Vision Subsystem Side

For completeness, here is the full VisionSubsystem that integrates with the drivetrain architecture above. This uses PhotonVision (Lessons 3–4) but the pattern is identical for Limelight (Lesson 5) — only the measurement source changes.

VisionSubsystem.java — calling addVisionMeasurement on DriveSubsystem
public class VisionSubsystem extends SubsystemBase {

    private final DriveSubsystem m_drive;
    private final PhotonCamera m_camera = new PhotonCamera("FrontCamera");
    private final PhotonPoseEstimator m_poseEstimator;

    public VisionSubsystem(DriveSubsystem drive) {
        m_drive = drive;
        AprilTagFieldLayout layout =
            AprilTagFields.k2025ReefScapeV2.loadAprilTagLayoutField();
        m_poseEstimator = new PhotonPoseEstimator(layout,
            PoseStrategy.MULTI_TAG_PNP_ON_COPROCESSOR,
            m_camera, VisionConstants.ROBOT_TO_FRONT_CAMERA);
        m_poseEstimator.setMultiTagFallbackStrategy(PoseStrategy.LOWEST_AMBIGUITY);
    }

    @Override
    public void periodic() {
        Optional<EstimatedRobotPose> estimateOpt = m_poseEstimator.update();

        estimateOpt.ifPresent(estimate -> {
            Pose2d visionPose     = estimate.estimatedPose.toPose2d();
            double captureTime  = estimate.timestampSeconds;
            int    numTagsUsed  = estimate.targetsUsed.size();

            // Basic quality check before passing to drivetrain.
            // Lesson 8 covers more sophisticated filtering.
            // For now: reject if pose is outside the field boundaries.
            if (visionPose.getX() > 0 && visionPose.getX() < 16.54 &&
                visionPose.getY() > 0 && visionPose.getY() < 8.21) {

                // Pass to drivetrain with the capture timestamp — not Timer.getFPGATimestamp()
                m_drive.addVisionMeasurement(visionPose, captureTime);

                SmartDashboard.putNumber("Vision/TagsUsed", numTagsUsed);
                SmartDashboard.putNumber("Vision/VisionX", visionPose.getX());
                SmartDashboard.putNumber("Vision/VisionY", visionPose.getY());
            }
        });
    }
}
🔍 Wiring the subsystems in RobotContainer

In RobotContainer, the DriveSubsystem must be created first, then passed to VisionSubsystem: private final DriveSubsystem m_drive = new DriveSubsystem(); followed by private final VisionSubsystem m_vision = new VisionSubsystem(m_drive);. Java evaluates field initializers in declaration order, so declaring m_drive before m_vision ensures the drivetrain is constructed before being passed to the vision subsystem. If they're in the wrong order, you'll get a NullPointerException at construction time when VisionSubsystem tries to store a reference to a not-yet-constructed DriveSubsystem.

Verifying Fusion Is Working

Vision fusion can be silently broken — the code compiles, runs, and even produces reasonable pose estimates — but the fusion itself may not be contributing anything. These are the tests that distinguish "working" from "looks like working."

Test 1: The vision measurement counter

Add a counter in your drivetrain's addVisionMeasurement() method that increments each time it's called. Log it to SmartDashboard. During autonomous when the robot faces tags, it should increment at roughly the camera's frame rate (30–60 times per second). If it's 0 or increments only once, the vision subsystem isn't calling through correctly.

Test 2: The drift correction test

Disable vision temporarily (comment out the addVisionMeasurement() call). Run a 3-meter path and mark the landing position with tape. Re-enable vision. Run the same path. The landing position with vision correction should be more consistent and closer to the target. If there's no visible difference, check that fusion is actually being called (Test 1) and that the standard deviations aren't set so high that vision measurements are ignored.

Test 3: The Field2d overlay

Open SmartDashboard's Field widget and add a second robot object for the raw vision pose (before fusion): m_field.getObject("visionPose").setPose(visionPose). You should see two robot icons — the green fused estimate and the orange raw vision estimate. They should generally agree within 5–10 cm when the camera is facing tags clearly. If the vision icon is jumping wildly while the fused estimate stays smooth, the Kalman filter is correctly dampening noisy measurements. If the fused estimate is also jumping, vision standard deviations are set too low.

Three Failure Modes That Prevent Fusion from Helping

Failure 1: Wrong timestamp — fusion happens at the wrong time

Passing Timer.getFPGATimestamp() instead of estimate.timestampSeconds means the Kalman filter applies the correction at the robot's current position, not where it was when the image was taken. The fusion still runs but applies the correction to the wrong historical state, reducing its accuracy. Symptom: fused pose oscillates slightly at the frame rate even when the robot is stationary.

Failure 2: Vision standard deviations too large — measurements ignored

If vision standard deviations are set very high (e.g., 100, 100, 100), the Kalman filter weights vision at nearly zero. addVisionMeasurement() runs without error, the counter increments, but the fused pose barely moves toward the vision estimate. Symptom: the fused estimate and odometry-only estimate are identical even when vision is producing good measurements.

Failure 3: Odometry standard deviations too low — odometry overrides everything

If odometry standard deviations are set near zero (e.g., 0.001, 0.001, 0.001), the Kalman filter trusts odometry so completely that vision corrections are ignored even when they're highly accurate. This is the inverse of Failure 2. Symptom: same as Failure 2, but identified by checking whether lowering odometry stddevs from their initial values worsened things.

🔌 System Check

⚙️ Before Relying on Vision-Fused Pose in Autonomous

Work through this checklist before any match that depends on vision-corrected positioning:

  • SwerveDrivePoseEstimator is used, not SwerveDriveOdometry. Check your drivetrain subsystem's import and field declaration. If it still says SwerveDriveOdometry, vision fusion is not occurring regardless of how many times addVisionMeasurement() is called — the method doesn't exist on the odometry class.
  • The vision measurement counter increments during autonomous. Log the call count. It should increment at frame rate (30–60x/second) when the robot faces tags. Zero means the data flow between subsystems is broken.
  • Starting pose is reset before autonomous begins. Call m_drive.resetPose(startingPose) in autonomousInit(). The pose estimator must start from the correct field position — vision measurements will correct drift but cannot fix a fundamentally wrong starting pose that's far from any visible tags.
  • Field2d shows both robot pose and vision pose markers. The fused estimate should track smoothly. The raw vision estimate marker should agree with the fused estimate within 5–10 cm when detections are good. Extreme divergence between them means vision standard deviations are too high or the measurement quality is poor.
  • Pose stays accurate after a path that takes the robot away from tags and back. Drive a path that moves the robot to a position with no visible tags (odometry only for 3–5 seconds), then back to a position with tags. The pose estimate when tags return should snap back toward the correct position. If it doesn't, verify that new vision measurements are being accepted (counter increments) and that standard deviations aren't configured to ignore them.

Knowledge Check

1. A team's vision fusion code calls m_poseEstimator.addVisionMeasurement(pose, Timer.getFPGATimestamp()) instead of using the capture timestamp. Their pose estimate counter increments normally. What is the most likely symptom during a fast autonomous routine?

  • A The fused pose never updates — wrong timestamp causes the estimator to throw an exception
  • B The fused pose receives corrections, but they're applied against the robot's current position rather than where it was when the image was captured — during fast motion the robot may have moved 10–20 cm during pipeline latency, making each correction slightly off and producing a fused estimate that oscillates or has small systematic drift
  • C No observable difference — timestamp only affects whether the measurement is accepted, not the correction magnitude
  • D The pose estimator resets to the vision pose entirely, discarding all odometry history

2. A team sets odometry standard deviations to [0.001, 0.001, 0.001] (near-zero trust in uncertainty) and vision standard deviations to [0.9, 0.9, 0.9]. The vision counter increments correctly. The Field2d fused estimate and the raw vision estimate are visibly different. What will happen when the robot drifts 30 cm from its true position due to wheel slip?

  • A Vision will immediately correct the 30 cm drift on the next measurement
  • B Vision corrections will have almost no effect — near-zero odometry standard deviations tell the Kalman filter odometry is essentially perfect, so it weights vision at near-zero relative to odometry; the 30 cm drift will persist even though accurate vision measurements are arriving
  • C The estimator will discard vision measurements that differ by more than 30 cm from odometry
  • D The robot will attempt to physically drive back to the vision-estimated position

3. A team wants to use vision fusion only during the first 5 seconds of autonomous (when scoring near the tags) and rely on odometry-only for the rest of the match. What is the simplest way to implement this?

  • A Swap between SwerveDrivePoseEstimator and SwerveDriveOdometry at the 5-second mark
  • B Conditionally call addVisionMeasurement() only when Timer.getMatchTime() > 10.0 (first 5s of a 15s auto) — the pose estimator naturally falls back to odometry-only behavior when no vision measurements arrive; no structural change required
  • C Set vision standard deviations to infinity after 5 seconds to reject all vision measurements
  • D Call resetPose() after 5 seconds to clear the vision correction history
💪 Practice Prompt

Wire Vision Fusion Into Your Drivetrain

  1. Upgrade your DriveSubsystem from SwerveDriveOdometry to SwerveDrivePoseEstimator. Add the two extra constructor arguments (stateStdDevs and visionStdDevs) using the starting values from this lesson: VecBuilder.fill(0.1, 0.1, 0.1) for state, VecBuilder.fill(0.9, 0.9, 0.9) for vision. Confirm the build succeeds and all existing call sites (update(), getPose(), resetPose()) still compile without changes.
  2. Add the public addVisionMeasurement(Pose2d, double) method to your drivetrain. Add a counter field that increments every time it's called, and publish the counter to SmartDashboard as "Vision/FusionCallCount". Deploy and confirm the counter is 0 before any vision integration is added.
  3. Update your VisionSubsystem to take DriveSubsystem as a constructor argument and call m_drive.addVisionMeasurement() whenever a valid estimate is present. Wire the construction order in RobotContainer (drive first, then vision). Confirm FusionCallCount increments when facing a tag at 2–4 meters.
  4. Add a second robot object to the Field2d widget for the raw vision pose: m_field.getObject("visionEstimate").setPose(visionPose) inside the ifPresent() block. Open SmartDashboard, add the Field widget, and verify you see both the fused robot estimate (changes smoothly) and the vision estimate (updates at frame rate). Describe in a comment what you observe when you physically move the robot quickly.
  5. Bonus: Implement the drift correction test from the Verifying section. Run a 3-meter path with vision disabled (commenting out addVisionMeasurement()). Run it again with vision enabled. Measure the landing position variance for 5 runs of each. Compute the standard deviation of landing position across runs for each mode. Is vision fusion reducing variance? By how much? Record in a code comment in your VisionConstants.java.