Howard Wang
A VEX competition field seen at an angle: the robot, four red sensor lines running to the walls, and a red-and-white target drawn on the floor where the robot has worked out it is.

I build robots that know where they are.

  1. Each dot is a guess at where the robot is.
  2. Its four sensors measure the walls. Guesses that don’t match fade away.
  3. The guesses agree. Now it knows where it is.

Rendered in Blender from VEX’s field model, running the robot’s own code.

I’m Howard Wang, a high school student who builds and programs robots.

Wheels alone
0.0 in off
With sensors
searching
The same idea, live, from above. Click anywhere on the field to move the robot, or ; it finds itself again in a few seconds. . A simplified simulation.

Our VEX robot

Team 1658S, 2025–26

For the first part of every match the robot drives itself, with nobody at the controls. I write the code that tells it where it is on the field.

The Push Back field from above at an angle: the robot with a red-and-white target under it, red sensor lines to the walls, and a gray ghost frame about fifteen inches away at the end of a diverging gray path

An autonomous run, from above

  1. The robot drives a route on its own. The gray trail is where its wheels think it went.
  2. A bump knocks the wheels 15 inches off. The sensors still put the robot in the right place.

Rendered in Blender on VEX’s field model, running the robot’s own code. The bump is simulated.

Team 1658S’s Push Back robot on a practice field, its blue 1658S plate on the side, with a 2025 banner on the wall behind
Team 1658S’s robot this season.
The same robot seen from the front on the practice field
From the front.
The robot on a competition field at a tournament, seen from the side at floor level
On a competition field.
Team 1658S with their robots and Tournament Champion banners at the Sunshine Showdown Signature Event, Orlando, 2025
The team with both Tournament Champion banners.

Tournament champions at two Signature Events

Push Back season, Sunshine Showdown, Orlando, 2025

A team of five, and two Signature Event wins in one season.

The 1658S drive team cheering at the field edge during a match, with a crowd behind them
Our drive team, mid-match.
Engineering notes for this chapter

I’m building a stack that gives the robot a better-grounded estimate. A Monte Carlo localization filter with 450 particles recovers absolute position from four distance sensors. An extended Kalman filter fuses that with odometry, so motion stays smooth without trusting bad measurements. A chi-square gate, outlier rejection and rate-limited corrections keep one bad reading from yanking the motion controller. The opening film, Fig. 1 and Fig. 2 all show this filter at work.

The prototype compares the fused position with odometry-only runs over repeated autonomous paths, with contact and wheel slip. Next I plan to publish the implementation, the test method and the logs, so other VEX teams can reproduce the result or find where it breaks.

The localization stack Wheel encoders and the IMU feed odometry, which predicts the extended Kalman filter. Four distance sensors feed a 450-particle Monte Carlo localization filter, whose estimate passes a chi-square gate before correcting the Kalman filter. The fused pose goes to motion control through a rate limit. Wheel encoders IMU heading Distance sensors ×4 Odometry Particle filter450 particles EKF Motioncontrol χ² gate predict correct every 50 ms every 10 ms The localization stack, drawn top to bottom Wheelencoders IMUheading Distancesensors ×4 Odometry Particle filter450 particles EKF Motioncontrol χ² gate predict every 50 ms every 10 ms
Odometry predicts; the particle filter, gated, corrects; the Kalman filter fuses the two for motion control.
1if (!turnCorrectionSuppressed && !sensorsStale &&
2    correctionGeometryReady && correctionStable &&
3    correctionCloseToTrack &&
4    (threeSensorCorrectionReady || twoSensorCorrectionReady)) {
5  const float nisGate = threeSensorCorrectionReady
6      ? config.fusion.nisGate
7      : config.fusion.nisGate * kTwoSensorCorrectionNisScale;
8  if (nis <= nisGate) {
9    ekf.update(rawMeas.pose, rawMeas.covariance);
10    correctionAccepted = true;
11  }
12}
The correction gate in localization.cpp: a fix reaches the Kalman filter only if every check passes, including an innovation gate of 8.0.

A humanoid robot

Summer internship, 2026

I set up two cameras on a humanoid robot so it could map a room in 3D and find itself in it.

A dark three-dimensional cloud of about sixty thousand points of light over a faint one-metre grid, with a small red ring marking where mapping began

The map the robot built

  1. From its two cameras, the robot built this 3D map of the lab: 62,112 points.
  2. When it sees these points again, it knows where it is.

Rendered in Blender from the map the robot saved, on a 1 m grid. The order the points appear in is illustrative.

The cameras

Close view of one of the two cameras mounted on the humanoid robot
One of the robot’s two cameras. A wrong default setting blurred every picture; fixing it made them four times sharper.

Measuring them

The focusing tool: the left and right cameras’ views of a checkerboard above a live sharpness graph A checkerboard filling a Dell monitor, used as the calibration target
A focusing tool I wrote for each lens, and the checkerboard on a monitor that the cameras are measured against.
Two cameras on an aluminum bar, with fine lines running from each lens to the 78 inner corners of a checkerboard on a monitor

Measuring the cameras

  1. The robot’s two cameras, 12.4 cm apart, look at a checkerboard on a screen.
  2. Each corner is found in both cameras. Together, the lines measure the cameras exactly.

Rendered in Blender at real size. The camera bodies are simplified and the screen positions illustrative.

Average error after calibration

About a seventh of a pixel. The first attempt was off by 27 pixels, nearly 200 times as much.

One camera pixel, magnified A four by four patch of camera pixels around a checkerboard corner. A cross marks the corner the detector found and a ring marks where the calibrated model predicts it; the two are 0.137 of a pixel apart. 1 px 0.137 px

One camera pixel, magnified. Cross: where a corner was seen. Ring: where the calibrated cameras put it.

Six rounds of calibration, from 27 pixels off to 0.137.

Timing

The robot’s motion sensor reads 100 times a second, but the readings arrived with uneven times. Its own clock made them exact.

A quarter second of motion readings, before and after.

The humanoid robot hanging from shoulder ropes on a rolling frame in the test room, beside the calibration computer

The robot hangs from ropes during testing, so I wrote a routine of bounces and turns to calibrate its motion sensor.

How much the position estimate wobbled while the robot stood still for a minute: four to six times less with the motion sensor.

On the robot

Drift in a minute standing still, drawn to scale Circles centered on the starting point: 1.7 cm with the cameras only, 1.3 cm with the motion sensor, a 1 cm goal, and 0.285 cm in the final version. 1.7 cmcameras only1.3 cmwith motion sensor1 cmgoal0.285 cmfinal

How far the robot’s position drifted in a minute standing still, to scale: 0.285 cm in the end, under the 1 cm goal.

Engineering notes for this chapter

The work started with RealSense feasibility tests on a workstation, moved to a four-wheel Jetson robot, and ended on a humanoid. Most of it was the system around the algorithm: stereo geometry, sensor timing, image quality, mapping modes, and tests that someone else could repeat.

A laptop running live cuVSLAM stereo tracking, with feature matches on the left and the camera trajectory in Rerun on the right
The live system: cuVSLAM tracking stereo features on the robot, visualized in Rerun.

The robot carries FRAMOS stereo cameras built on Sony IMX900 sensors, an Xsens MTi-300 IMU and a Jetson. Before trusting any pose estimate I checked what the sensors were really delivering. The first finding was a driver default: an exposure setting of zero was being read as roughly 660 ms of exposure, which blurred every frame and slowed startup. A fixed 2 ms exposure raised the sharpness score from about 2.1 to 8.4.1

Two things limited calibration that no solver can fix: the camera’s own image processing, and focus you can’t see. The driver sharpened and denoised every frame by default. That looks better to a person, and it moves corner positions by fractions of a pixel. I turned both off, then built a focus mode in Rerun: a per-lens sharpness meter with edge peaking and a center zoom, measured on full-resolution crops so that a change in stream bandwidth never moves the reading.

For geometry I moved the target onto a Dell P2722H monitor. Its panel is 1920 by 1080 pixels, and the greatest common divisor of 1920 and 1080 is 120. So a board of 120-pixel squares tiles the screen with nothing left over and no rounding between squares.2 The board is 14 × 7 squares, which gives 13 × 6 inner corners, drawn by a small Electron tool I wrote. With OpenCV’s findChessboardCornersSB and its accuracy refinement, the stereo error fell from 27 px on the first solve to 0.137 px.

The last step was a solver setting. The standard pipeline blended the new camera intrinsics with older ones, 35% new, as a hedge from the days when the focal length wouldn’t hold still. By now the new values were the reliable ones, and blending pulled the focal length toward a stale number and raised the error to 0.342 px. Using the new intrinsics outright, which the log calls steer-1.0, gave 0.137.

A camera and an IMU can each be accurate and still disagree, because their clocks don’t match. The IMU’s messages were being stamped when they arrived at the computer, which put anywhere from 4.1 to 15.8 ms between samples that should have been exactly 10 ms apart. For calibration recordings I switched to the IMU’s hardware sample clock. Every interval then came out at 10.000 ms across 1,200 samples,3 and the camera–IMU alignment solved to a +2.0 ms offset with 80% inliers.

The robot hangs from two shoulder ropes and can’t move freely, so I wrote a 90-second excitation routine that produces linear acceleration anyway: vertical bounces into free fall and a catch, pitch and roll rocking through the lever arm, and fast reversals in yaw. A script checked the IMU statistics against pass and fail thresholds before any recording was accepted. With fusion on,4 the static jitter path fell from 63–75 cm to 11–18 cm.5

The robot also carries a RoboSense RS-E1 solid-state LiDAR, with a targetless LiDAR-to-camera calibration inherited from an earlier effort. Before comparing methods I fixed the cheapest real bug: the pipeline was still using stale camera intrinsics, with the principal point off by 54 pixels. Then I redid the calibration properly, using a 151-second slow-motion capture to fill in the sparse point cloud.

The answer was clear. Intensity-based targetless calibration can’t work on a sensor that never produces a dense, calibrated intensity image. By the criteria we had agreed on beforehand, I rolled the stack back to cuVSLAM with IMU fusion and wrote down why. ORB-SLAM3 kept running alongside as an open-source reference, so the final choice of platform rested on a comparison rather than a preference.

ORB-SLAM3 running on a monitor: a sparse map of red and black points beside the current camera frame with tracked features
ORB-SLAM3, the open-source reference that ran next to cuVSLAM during the LiDAR evaluation.

I packaged the stack into one-command mapping, localization and odometry modes, made saving the map part of shutdown, and versioned every calibration so the previous deployment is one command away. Intrinsics and camera–IMU extrinsics always ship together, because new intrinsics quietly invalidate the old extrinsics.

I left with a workflow as much as a result. It reproduces the calibration, flags a weak measurement early, and gets from a desk test to a running robot without hiding what is still uncertain. Most of what made the final number possible was staying with the problem through failed targets, timing jitter, image-quality limits and a change of platform.

Final results
Stereo pairs detected1332 of 1332, none rejected
Camera–IMU alignment80% inliers (1399 of 1749), time offset +2.0 ms
IMU interval, calibration recordings10.000 ± 0.000 ms on the hardware clock
Static drift, 60 s0.285 cm at the end, 0.408 cm at peak, 0.07° of yaw
Live trackingabout 30 Hz on the Jetson
  1. Sharpness here is the variance of the Laplacian, a standard blur measure. Higher is sharper.
  2. Squares of 24 mm would have needed 77.06 screen pixels each, so the grid lines would alternate between 77 and 78 pixels and bake a tenth of a pixel of error into the “ground truth.”
  3. At runtime the system keeps arrival timestamps, because the hardware clock’s anchor wanders against the computer’s clock over long runs.
  4. Fusion first trusted the IMU too much at its datasheet noise values and drifted 12–17 cm a minute. Telling the filter the IMU was seven times noisier stabilized it.
  5. Jitter path is the total distance the estimate wanders while the robot sits still, added up step by step. It measures high-frequency noise.

Revision history

RevStageWhereWhat changed
ASystem bring-upcuVSLAM, FRAMOS stereoStereo SLAM, calibration and about 30 Hz real-time tracking running on the humanoid.
BIMU refinementXsens MTi-300Timestamps and noise model corrected; fusion tuned and checked standing still and swinging.
CStereo recalibration9×6 screen boardError down to 0.247 px; camera–IMU extrinsics re-run; first static drift under 1 cm.
DDetector auditfindChessboardCornersSBEvery deployed calibration re-checked; stereo extrinsics cross-checked two ways.
EReal auto-exposureArgus AESharp across lighting changes at runtime; fixed exposure kept for calibration capture.
FOperator toolsIsaac ROS 3.2.6One-command slam_map, localize and odom modes; maps saved on shutdown.
GLens forensicssession 004A fingerprint on the left lens had more than doubled that camera’s error. Cleaned, the stereo error fell to 0.189 px.
HLiDAR evaluationRS-E1, FAST-LIVO2Targetless calibration shown unworkable on this sensor; rolled back on the evidence.
IUnprocessed opticsArgus ISP, RerunDefault sharpening and denoise turned off; focus mode with a per-lens sharpness meter.
JExact-scale targetDell 13×6, steer-1.0120-pixel squares, subpixel corner refinement; 0.137 px with no rejected pairs.
KCoupled deploymentJetson, on the robotIntrinsics and realigned extrinsics shipped together; 0.285 cm over 60 s; one command to revert.

Four seasons of competition

VEX since 2022, and FRC with Team 2584.

  1. 2022 8th grade

    First build season

    My first VEX season. No finished robot, but it stuck.

  2. 2023 9th grade

    Finding a team

    Builder and driver on a complete competition robot for the first time.

  3. 2024 10th grade

    Starting a team

    Started a five-person team and built on about two hours a day. Its intake was Cerberus.

  4. 2024–25 11th grade

    Lead programmer

    Rented a workspace and wrote the code that drives the robot on its own. The game was High Stakes.

Overhead view of the Cerberus intake, with green and yellow rollers on a red frame
2024: Cerberus, the intake, from above.
The High Stakes robot on a practice field, with red and blue rings around it
2024–25: the High Stakes robot.

FRC Team 2584

Programmer and parts-maker on a full-size FRC robot. I worked on its climb at the end of each match and on a turret that aims itself using a camera.

Team 2584’s robot on its cart in the workshop: a red frame with green and white intake rollers and clear side panels, in front of a whiteboard listing the season’s scoring goals
Team 2584’s robot in the workshop, with the season’s scoring goals on the whiteboard behind.

Record

  • 2025VEX World Championship qualificationTeam 1658S
  • 2025FRC Regional Championship qualificationTeam 2584
  • 2024California State Championship qualificationOur new five-person team
  • 2024USACO Gold DivisionCompetitive programming

About me

Howard Wang in a dark suit and tie

I’m a student at Viewpoint School. I design robot mechanisms, write the code that lets them drive themselves, and make the parts on CNC machines, laser cutters and 3D printers.

Code
C++, Python, Java
Making
CNC, laser cutting, 3D printing
Design
Onshape, Blender

Write to me

howardwong1212@gmail.com

Code lives on GitHub.