VEX V5 PYTHON | 08.4 TAG ALIGNMENT
Guide objective
Use a confirmed AprilTag observation to turn toward a calibrated image aim point, then settle while handling target loss and reacquisition.
01 | USE IMAGE ERROR TO GUIDE A TURN
08.3 gave you a confirmed, current observation of a chosen tag ID. Now turn the robot until that tag’s image centre meets a desired horizontal aim coordinate.
This is turn-only visual alignment. It does not move forward, measure target range or solve the robot’s field pose.
Use one known marker and a clear turning area. The chassis and attached mechanisms need room to rotate.
02 | CHOOSE THE AIM COORDINATE
The AI Vision image is 320 pixels wide. Its nominal horizontal midpoint is X = 160. With an upright, forward-facing camera, centring there points the camera’s viewing direction toward the marker.
AIM_X_PX is configurable. A camera offset from an intake or tilted on a bracket may need a different aim point, calibrated against the real task.
A pixel offset does not universally compensate for a physical mounting offset at every distance. Test across the working range.
03 | SUBTRACT AIM X FROM TAG X
error_px = tag_center_x - AIM_X_PX
| Tag centre X | Aim X | Error | Correction after sign verification |
|---|---|---|---|
| 100 | 160 | −60 px | Anticlockwise |
| 160 | 160 | 0 px | Brake |
| 220 | 160 | +60 px | Clockwise |
Image X increases rightward. Image Y increases downward, but this routine uses horizontal error only. A chassis yaw turn cannot directly fix a vertical aiming error.
04 | USE A CAPPED PROPORTIONAL REQUEST
turn_percent = clamp(STEERING_SIGN * STEER_KP * error_px, -10, 10) left_request = turn_percent right_request = -turn_percent
With gain 0.12 percent per pixel, an error of +60 pixels requests +7.2%. The left side moves forward and the right side reverses, turning clockwise.
The cap prevents large image errors from requesting an excessive turn speed. PERCENT is a motor velocity request, not angular speed in degrees per second.
Verify the sign at low speed. If a right-image target moves further right during the turn, stop and check camera orientation, motor reversals and STEERING_SIGN.
05 | CONFIRM THE EXPECTED ID FIRST
The starter takes an ALL_TAGS snapshot, selects TARGET_TAG_ID explicitly and rejects duplicate matches or too-small boxes.
Three consecutive usable observations are required before movement. A large horizontal jump between otherwise consecutive selected observations ends the action.
These gates improve consistency, but cannot guarantee identity if the same ID has been printed at several locations. Snapshot index is not a persistent identity either.
Use the verified marker set and detection setup from 08.3. Keep camera declarations in the generated region and your own logic below # Begin project code.
06 | BRAKE ON LOSS, REACQUIRE WHILE STOPPED
On the first missing, wrong-ID or too-small observation, current coordinates are invalidated and the drivetrain brakes. The centred timer resets.
The robot may wait stationary for up to 500 ms since the last usable sighting. If the marker returns, the confirmation count starts again; historical coordinates are not used to steer.
| Condition | Motor policy |
|---|---|
| Unconfirmed candidate | Brake |
| Confirmed off-centre target | Turn proportionally |
| Confirmed target in deadband | Brake and observe settling |
| Missing or unusable target | Brake; bounded stationary reacquisition |
| Duplicate ID, jump or sensor fault | End and brake |
No target at startup means wait stationary until the overall alignment timeout. There is no automatic search rotation in this starter.
07 | REQUIRE A SETTLED FINISH
The routine uses a ±6 pixel deadband. Within it, the robot brakes rather than making small alternating corrections.
CENTRED requires the confirmed marker to stay in that window and inertial turn rate to remain at most 8°/s for 250 ms. A single centred sample while the robot is still turning is insufficient.
If the error leaves the window or feedback is lost, settling starts again. These are adjustable teaching tolerances; choose them from physical trials.
08 | COMPLETE TURN-ONLY APRILTAG STARTER
Configure AI Vision on port 15 as ai_vision_1, with AprilTag detection enabled. This example selects ID 7.
Drive motors use left ports 3/4 and right 1/2; inertial uses 20. Avoid duplicate manual and Devices declarations. Keep generated VEX imports and Brain above this code.
MOTION_CONFIGURED begins False. Verify the marker, camera orientation, drive signs and stationary inertial calibration before enabling movement.
# =============================================================================
# TEAM [MY TEAM NAME / NUMBER] | TURN-ONLY APRILTAG ALIGNMENT
# =============================================================================
# Keep generated imports, brain and camera/signature setup above this code.
# Configure AI Vision in Devices on port 15, named ai_vision_1.
# Enable AprilTag detection; keep generated camera configuration above this code.
# Manual drivetrain/inertial configuration below; do not duplicate in Devices.
import math
# =============================================================================
# FOUR-MOTOR DRIVETRAIN AND INERTIAL
# =============================================================================
left_1 = Motor(Ports.PORT3, GearSetting.RATIO_6_1, False)
left_2 = Motor(Ports.PORT4, GearSetting.RATIO_6_1, False)
right_1 = Motor(Ports.PORT1, GearSetting.RATIO_6_1, True)
right_2 = Motor(Ports.PORT2, GearSetting.RATIO_6_1, True)
left_drive = MotorGroup(left_1, left_2)
right_drive = MotorGroup(right_1, right_2)
inertial = Inertial(Ports.PORT20)
controller = Controller(PRIMARY)
# =============================================================================
# SETTINGS: EXAMPLES TO CHECK AND TUNE
# =============================================================================
TARGET_TAG_ID = 7
AIM_X_PX = 160.0
LOSS_TIMEOUT_MS = 500
MOTION_CONFIGURED = False
MIN_TAG_WIDTH_PX = 12
MIN_TAG_HEIGHT_PX = 12
MAX_CENTRE_JUMP_PX = 60
STEER_KP = 0.12 # percent per horizontal pixel
MAX_TURN_PERCENT = 10.0
STEERING_SIGN = 1.0 # Verify: right-image target must turn clockwise
CENTRE_TOL_PX = 6
CONFIRM_SAMPLES = 3
SETTLE_MS = 250
MAX_SETTLE_RATE_DEG_S = 8.0
POLL_MS = 50
MAX_UPDATE_GAP_MS = 200
ALIGN_TIMEOUT_MS = 5000
ready = False
IMAGE_WIDTH = 320
IMAGE_HEIGHT = 240
def clamp(value, low, high):
return max(low, min(high, value))
def stop_drive():
left_drive.stop(BRAKE)
right_drive.stop(BRAKE)
def side_output(group, output):
if output == 0:
group.stop(BRAKE)
else:
group.spin(FORWARD if output > 0 else REVERSE, abs(output), PERCENT)
def steering_request(center_x):
error = center_x-AIM_X_PX
if abs(error) <= CENTRE_TOL_PX:
return 0.0, error
return clamp(STEERING_SIGN*STEER_KP*error,
-MAX_TURN_PERCENT, MAX_TURN_PERCENT), error
def select_expected_tag(objects):
visible_ids, matches = [], []
for obj in objects:
if not obj.exists:
continue
identity = obj.id
if not math.isfinite(identity) or identity < 0 or int(identity) != identity:
raise ValueError("Invalid tag ID")
visible_ids.append(int(identity))
if int(identity) != TARGET_TAG_ID:
continue
values = (obj.centerX, obj.centerY, obj.width, obj.height)
if not all(math.isfinite(v) for v in values):
raise ValueError("Invalid target box")
x, y, width, height = values
if not (0 <= x <= IMAGE_WIDTH and 0 <= y <= IMAGE_HEIGHT
and 0 < width <= IMAGE_WIDTH and 0 < height <= IMAGE_HEIGHT):
raise ValueError("Target box outside image bounds")
matches.append({"id": int(identity), "x": x, "y": y,
"width": width, "height": height})
if len(matches) > 1:
return "AMBIGUOUS_TAG", visible_ids, None
if not matches:
return ("WRONG_TAG_ONLY" if visible_ids else "NO_TAGS"), visible_ids, None
tag = matches[0]
if tag["width"] < MIN_TAG_WIDTH_PX or tag["height"] < MIN_TAG_HEIGHT_PX:
return "TARGET_TOO_SMALL", visible_ids, None
return "VALID_TARGET", visible_ids, tag
class TagGate:
def __init__(self):
self.streak = 0
self.current = None
self.last_seen_ms = None
self.last_good = None # History only: never returned as current.
def update(self, observation, target, now):
self.current = None # Invalidate before processing EVERY observation.
if observation != "VALID_TARGET":
self.streak = 0
return observation
self.streak += 1
self.last_seen_ms = now
self.last_good = dict(target)
if self.streak < CONFIRM_SAMPLES:
return "ACQUIRING"
self.current = dict(target)
return "READY"
def history_age(self, now):
return None if self.last_seen_ms is None else now-self.last_seen_ms
# =============================================================================
# ONE OWNER: CONFIRM, TURN, BRAKE ON LOSS, SETTLE
# =============================================================================
def centre_on_tag(timeout_ms=ALIGN_TIMEOUT_MS):
stop_drive()
gate = TagGate()
try:
settings = (TARGET_TAG_ID, AIM_X_PX, LOSS_TIMEOUT_MS, MIN_TAG_WIDTH_PX,
MIN_TAG_HEIGHT_PX, CONFIRM_SAMPLES, MAX_CENTRE_JUMP_PX,
STEER_KP, MAX_TURN_PERCENT, STEERING_SIGN, CENTRE_TOL_PX,
SETTLE_MS, MAX_SETTLE_RATE_DEG_S, POLL_MS, MAX_UPDATE_GAP_MS,
timeout_ms)
if (not all(math.isfinite(v) for v in settings)
or not 0 <= TARGET_TAG_ID <= 37 or int(TARGET_TAG_ID) != TARGET_TAG_ID
or not 0 < AIM_X_PX < IMAGE_WIDTH
or min(LOSS_TIMEOUT_MS, MIN_TAG_WIDTH_PX, MIN_TAG_HEIGHT_PX,
STEER_KP, SETTLE_MS, timeout_ms, MAX_CENTRE_JUMP_PX) <= 0
or not 0 < MAX_TURN_PERCENT <= 25 or STEERING_SIGN not in (-1, 1)
or CONFIRM_SAMPLES < 1 or int(CONFIRM_SAMPLES) != CONFIRM_SAMPLES
or not 0 < CENTRE_TOL_PX < min(AIM_X_PX, IMAGE_WIDTH-AIM_X_PX)
or MAX_SETTLE_RATE_DEG_S < 0 or not 0 < POLL_MS < MAX_UPDATE_GAP_MS):
return "INVALID_SETTINGS"
started = previous_ms = brain.timer.time(MSEC)
previous_rotation = previous_x = None
centred_since = None
while True:
gate.current = None
if (not ready or not MOTION_CONFIGURED or not competition.is_enabled()
or not competition.is_autonomous() or controller.buttonB.pressing()):
return "CANCELLED_OR_NOT_READY"
if (not ai_vision_1.installed() or not inertial.installed()
or inertial.is_calibrating()):
return "SENSOR_NOT_READY"
if brain.timer.time(MSEC)-started >= timeout_ms:
return "TIMEOUT"
objects = ai_vision_1.take_snapshot(AiVision.ALL_TAGS, 24)
now = brain.timer.time(MSEC)
if (not competition.is_enabled() or not competition.is_autonomous()
or controller.buttonB.pressing()):
return "CANCELLED_OR_NOT_READY"
if now-started >= timeout_ms:
return "TIMEOUT"
if now-previous_ms > MAX_UPDATE_GAP_MS:
return "UPDATE_GAP"
rotation = inertial.rotation(DEGREES)
if not math.isfinite(rotation):
return "INVALID_ROTATION"
dt = (now-previous_ms)/1000.0
slow = (previous_rotation is not None and dt > 0 and
abs(rotation-previous_rotation)/dt <= MAX_SETTLE_RATE_DEG_S)
previous_rotation, previous_ms = rotation, now
observation, ids, target = select_expected_tag(objects)
if observation == "AMBIGUOUS_TAG":
return observation
if (target is not None and previous_x is not None
and abs(target["x"]-previous_x) > MAX_CENTRE_JUMP_PX):
return "TARGET_JUMP"
state = gate.update(observation, target, now)
if target is not None:
previous_x = target["x"]
if gate.current is None:
stop_drive() # First unusable observation or unconfirmed sample.
centred_since = None
if target is None:
previous_x = None
age = gate.history_age(now)
if age is not None and age >= LOSS_TIMEOUT_MS:
return "TARGET_LOST_TIMEOUT"
# No initial target: remain stopped until overall timeout.
wait(POLL_MS, MSEC)
continue
turn, error = steering_request(gate.current["x"])
if abs(error) <= CENTRE_TOL_PX:
stop_drive()
if slow:
if centred_since is None:
centred_since = now
if now-centred_since >= SETTLE_MS:
return "CENTRED"
else:
centred_since = None
else:
centred_since = None
side_output(left_drive, turn)
side_output(right_drive, -turn) # No forward approach.
wait(POLL_MS, MSEC)
except Exception:
return "CONFIGURATION_OR_TAG_DATA_FAULT"
finally:
gate.current = None
stop_drive()
# =============================================================================
# COMPETITION CALLBACKS AND STATIONARY DISABLED CALIBRATION
# =============================================================================
def autonomous():
stop_drive()
if ready and MOTION_CONFIGURED:
print("tag alignment", centre_on_tag())
def driver_control():
stop_drive() # Teaching project: no driver movement.
competition = Competition(driver_control, autonomous)
stop_drive()
if MOTION_CONFIGURED and not competition.is_enabled() and inertial.installed():
inertial.calibrate() # Keep stationary; may block.
ready = not inertial.is_calibrating() and not competition.is_enabled()
while True:
wait(100, MSEC)
One autonomous callback owns drivetrain output. It checks mode, B and the deadline again after taking a snapshot, before issuing another command.
The update-gap policy detects an excessive delay after an API call returns. It cannot interrupt that call while blocked or prove the image exposure time. Test API timing on the actual device.
09 | UNDERSTAND RETURN STATUSES
| Result | Meaning |
|---|---|
| CENTRED | Confirmed image alignment and low turn rate met the finish policy |
| TIMEOUT | Overall alignment budget expired |
| TARGET_LOST_TIMEOUT | Marker did not recover within the loss interval |
| AMBIGUOUS_TAG / TARGET_JUMP | Selection became unreliable |
| SENSOR_NOT_READY / UPDATE_GAP | Connection, calibration or timing failed |
| CANCELLED_OR_NOT_READY | Setup, enable, mode or B blocked continuation |
| CONFIGURATION_OR_TAG_DATA_FAULT | Generated names, image values or commands failed |
CENTRED describes alignment to the camera aim point. It does not guarantee a particular field heading, safe forward travel or a correct docking distance.
10 | TUNE WITH THE MARKER FIXED
- Use 08.3 to verify detection without movement.
- Print horizontal error at left, centre and right positions.
- Test the turn sign with a conservative cap.
- Run a short alignment from each side.
- Cover and reveal the marker while checking that the robot stops.
- Repeat with different distances and lighting.
- Measure overshoot, settling time and repeatability.
Reduce gain or turn cap if corrections oscillate. Very low requests can fail to overcome friction; a minimum-output floor may help but can also cause overshoot. The starter has no such floor.
11 | COMMON MISTAKES
- Turning toward the largest tag: select the intended ID.
- Using field heading to interpret image X: the image frame is local to the camera.
- Driving forward while testing yaw: separate centring from approach.
- Keeping a loss grace period at the last turn speed: brake immediately.
- Calling one centred frame a finish: require sustained alignment and low motion.
- Using a mounting offset at only one distance: test the full operating range.
CHALLENGES | AIM, TURN AND SETTLE
- Calculate: find errors for tag X values 80, 160 and 240.
- Predict: calculate capped turn requests with gain 0.12.
- Calibrate: test an aim point of 170 pixels and explain the sign changes.
- Mock: show that a missing tag resets confirmation and settling.
- Test: cover the tag briefly, then longer than the loss timeout.
- Compare: measure three gain settings from the same starting error.
- Verify: demonstrate that low angular speed is required for CENTRED.
- Explain: state what a centred image does and does not tell you about the robot’s physical pose.
Calculation checks
At aim X = 160, errors are −80, 0 and +80 pixels. Requests are −9.6%, 0% and +9.6%. At aim X = 170, a marker at X = 160 has error −10 pixels.
OFFICIAL REFERENCES
- AI Vision Python snapshot, ID and coordinate API
- Enable AprilTags and access supported markers
- VEX inertial rotation and calibration
The controller and gates are independently written educational examples requiring physical configuration, testing and tuning.