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
SwerveDriveOdometryandSwerveDrivePoseEstimatorand 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 |
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.
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.
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.
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.
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.
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()); } }); } }
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
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 timesaddVisionMeasurement()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)inautonomousInit(). 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?
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?
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?
Wire Vision Fusion Into Your Drivetrain
- Upgrade your
DriveSubsystemfromSwerveDriveOdometrytoSwerveDrivePoseEstimator. 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. - 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. - Update your
VisionSubsystemto takeDriveSubsystemas a constructor argument and callm_drive.addVisionMeasurement()whenever a valid estimate is present. Wire the construction order inRobotContainer(drive first, then vision). ConfirmFusionCallCountincrements when facing a tag at 2–4 meters. - Add a second robot object to the Field2d widget for the raw vision pose:
m_field.getObject("visionEstimate").setPose(visionPose)inside theifPresent()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. - 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 yourVisionConstants.java.