VEX V5 PYTHON | 08.3 APRILTAG DETECTION
Guide objective
Select an expected AprilTag ID, confirm usable observations and immediately invalidate a missing target before a bounded reacquisition period.
01 | A MARKER WITH AN EXPLICIT ID
An AprilTag is a square visual marker whose pattern encodes an ID. A robot can use known IDs to distinguish a docking station, checkpoint or practice-field target.
This lesson asks a precise question: is the expected marker currently detected and usable? It does not yet steer, estimate field pose or drive toward the marker.
An ID identifies a marker pattern. If you print the same ID twice, it no longer uniquely identifies one physical location.
02 | USE AI VISION AND ENABLE TAG DETECTION
This guide uses the AI Vision Sensor, not the original Vision Sensor. In VEXcode Devices, configure AI Vision on port 15 as ai_vision_1.
- Open its configuration utility.
- Enable AprilTag detection.
- Present a supported printed marker and confirm its displayed ID.
- Close the utility and save the device configuration.
- Keep the generated sensor declarations and write your own logic below # Begin project code.
VEX provides a printable set numbered 0 to 37 through the official AprilTag guide linked below. Use that set for these examples rather than assuming an arbitrary QR code or another tag family will work.
03 | PLACE THE MARKER FOR RELIABLE OBSERVATION
Print clearly, keep the whole marker and its border visible, and mount it flat. Avoid glare, folds and motion blur. Check the actual marker in the utility before changing Python code.
Test distance, oblique angles and partial occlusion. A decoded ID can disappear when the marker is small or partly hidden, even when you can still see some of it.
Use different IDs for different practice locations. Keep a simple mapping such as ID 7 = loading station and ID 12 = parking checkpoint. These names are your own design choices.
04 | READ ALL TAGS, THEN SELECT BY ID
TARGET_TAG_ID = 7
objects = ai_vision_1.take_snapshot(AiVision.ALL_TAGS)
matches = [obj for obj in objects if obj.exists and obj.id == TARGET_TAG_ID]
if len(matches) == 1:
print("Expected tag", matches[0].id, "at", matches[0].centerX, matches[0].centerY)
elif len(matches) > 1:
print("Ambiguous: duplicate detections of the expected ID")
else:
print("Expected tag not detected")Never assume the first returned object is the desired marker. The tuple’s ordering can change as marker sizes change. Select by id explicitly.
The full monitor below adds size validation, confirmation and timing. This short example only demonstrates ID selection.
Tagdesc(7) can give an ID a meaningful name in a project. It does not enable detection, change the printed pattern or create a field-position estimate.
05 | EMPTY, WRONG AND AMBIGUOUS ARE DIFFERENT
| Observation | Meaning | Current target |
|---|---|---|
| Empty tuple | No tags returned | Unavailable |
| Only IDs other than 7 | Markers seen, expected marker absent | Unavailable |
| One usable ID 7 | Expected marker candidate | Confirm before READY |
| One very small ID 7 | Decoded but below this policy’s size threshold | Unavailable |
| Two entries with ID 7 | Same ID is ambiguous in this snapshot | Unavailable; end monitor |
The monitor requests up to 24 results. Even that limit does not prove every marker in the scene was detected. If the expected ID is absent from the returned set, it remains unavailable.
Duplicate-ID rejection helps when both copies are visible. One visible copy of a repeated ID is still not enough to identify its physical location.
06 | CONFIRM OBSERVATIONS BEFORE USING THEM
The starter requires three consecutive usable snapshot observations of the expected ID. Until then, it reports ACQUIRING and provides no current target.
A missing, wrong-ID or too-small observation resets the count. Reappearance must pass confirmation again.
This is a simple evidence gate, not proof of perfect decoding or a persistent track. Consecutive API samples may reuse a sensor frame; they are not guaranteed independent images.
07 | INVALIDATE IMMEDIATELY ON LOSS
| Time | Observation | State | Current coordinates usable? |
|---|---|---|---|
| 0 ms | ID 7 usable | ACQUIRING: 1 | No |
| 50 ms | ID 7 usable | ACQUIRING: 2 | No |
| 100 ms | ID 7 usable | READY | Yes, for this observation |
| 150 ms | No expected tag | Missing; count reset | No |
| 200 ms | ID 7 returns | ACQUIRING: 1 | No |
| 250 ms | ID 7 usable | ACQUIRING: 2 | No |
| 300 ms | ID 7 usable | READY | Yes, for this observation |
A reacquisition interval is not permission to keep moving. The diagnostic monitor waits while stationary. A later movement controller should brake on the first unusable observation.
Historical coordinates can help with logs or a deliberately designed search. They must be labelled historical and never returned as the current target.
08 | BOUND WAITING AND HANDLE FAULTS
The starter allows five seconds for the first usable sighting. After a valid sighting, an absence lasting 500 ms ends the session with TARGET_LOST_TIMEOUT. The whole monitor is also limited to twenty seconds.
These settings serve different purposes: initial acquisition, loss handling and overall session duration. None guarantees the marker will become visible.
A disconnected sensor, malformed ID or slow API call is a fault, not a missing marker. End the session rather than pretending an old reading is current.
The snapshot timing check measures call duration after it returns. It cannot interrupt a blocked call or prove the exposure time of the sensor image.
09 | COMPLETE STATIONARY APRILTAG MONITOR
Configure ai_vision_1 with AprilTag detection enabled. Use supported marker 7, or change TARGET_TAG_ID to the verified ID you are using.
Keep generated VEX imports and Brain setup above this program. It declares a Controller for B cancellation; avoid duplicating an existing generated controller. There are no motor commands.
# =============================================================================
# TEAM [MY TEAM NAME / NUMBER] | STATIONARY APRILTAG MONITOR
# =============================================================================
# Devices: AI Vision on port 15, named ai_vision_1, AprilTag detection enabled.
# Keep generated imports, brain and camera configuration above this code.
# No motors are declared or commanded. Run as a standalone diagnostic project.
import math
controller = Controller(PRIMARY) # Use existing generated controller if present.
TARGET_TAG_ID = 7 # Use a supported, verified printed marker
MIN_TAG_WIDTH_PX = 12
MIN_TAG_HEIGHT_PX = 12
CONFIRM_SAMPLES = 3
INITIAL_ACQUIRE_MS = 5000
LOSS_TIMEOUT_MS = 500
RUN_MS = 20000
POLL_MS = 50
MAX_SNAPSHOT_MS = 200
MAX_OBJECTS = 24 # Maximum requested results; not total markers in scene
IMAGE_WIDTH = 320
IMAGE_HEIGHT = 240
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
def display_gate(state, visible_ids, gate, now):
brain.screen.clear_screen() # Clear the previous current-target coordinates.
brain.screen.set_cursor(1, 1)
brain.screen.print("Tag ", TARGET_TAG_ID, ": ", state)
brain.screen.set_cursor(2, 1)
brain.screen.print("Samples: ", gate.streak)
if gate.current is not None:
brain.screen.set_cursor(3, 1)
brain.screen.print("CURRENT X/Y: ", gate.current["x"], "/", gate.current["y"])
else:
brain.screen.set_cursor(3, 1)
brain.screen.print("CURRENT: unavailable")
print("STATE", state, "VISIBLE_IDS", visible_ids, "STREAK", gate.streak,
"CURRENT", gate.current, "HISTORY_AGE_MS", gate.history_age(now))
def monitor_expected_tag():
gate = TagGate()
try:
settings = (TARGET_TAG_ID, MIN_TAG_WIDTH_PX, MIN_TAG_HEIGHT_PX,
CONFIRM_SAMPLES, INITIAL_ACQUIRE_MS, LOSS_TIMEOUT_MS,
RUN_MS, POLL_MS, MAX_SNAPSHOT_MS, MAX_OBJECTS)
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 < MIN_TAG_WIDTH_PX <= IMAGE_WIDTH
or not 0 < MIN_TAG_HEIGHT_PX <= IMAGE_HEIGHT
or CONFIRM_SAMPLES < 1 or int(CONFIRM_SAMPLES) != CONFIRM_SAMPLES
or min(INITIAL_ACQUIRE_MS, LOSS_TIMEOUT_MS, RUN_MS,
POLL_MS, MAX_SNAPSHOT_MS) <= 0
or not 1 <= MAX_OBJECTS <= 24 or int(MAX_OBJECTS) != MAX_OBJECTS):
return "INVALID_SETTINGS"
started = brain.timer.time(MSEC)
while brain.timer.time(MSEC)-started < RUN_MS:
gate.current = None # No previous box is eligible during a new call.
if controller.buttonB.pressing():
return "CANCELLED"
if not ai_vision_1.installed():
return "SENSOR_NOT_INSTALLED"
before = brain.timer.time(MSEC)
objects = ai_vision_1.take_snapshot(AiVision.ALL_TAGS, int(MAX_OBJECTS))
now = brain.timer.time(MSEC)
if controller.buttonB.pressing():
return "CANCELLED"
if now-started >= RUN_MS:
return "FINISHED"
if now-before > MAX_SNAPSHOT_MS:
return "SLOW_SNAPSHOT"
observation, visible_ids, target = select_expected_tag(objects)
state = gate.update(observation, target, now)
display_gate(state, visible_ids, gate, now)
if state == "AMBIGUOUS_TAG":
return state
age = gate.history_age(now)
if age is None:
if now-started >= INITIAL_ACQUIRE_MS:
return "ACQUIRE_TIMEOUT"
elif gate.current is None and age >= LOSS_TIMEOUT_MS:
return "TARGET_LOST_TIMEOUT"
wait(POLL_MS, MSEC)
return "FINISHED"
except Exception:
return "CONFIGURATION_OR_TAG_DATA_FAULT"
finally:
gate.current = None
result = monitor_expected_tag()
print("MONITOR_END", result)
brain.screen.clear_screen()
brain.screen.set_cursor(1, 1)
brain.screen.print("Tag monitor: ", result)
The Brain displays current availability. The console lists all visible IDs, confirmation count and the age of the last usable observation.
last_good is history only. gate.current is cleared before every new snapshot, on every unusable observation and when monitoring ends.
10 | DETECTION IS NOT A FIELD POSE
A tag ID and image centre do not directly provide the robot’s field X, Y and heading. Field localisation requires known marker geometry, camera calibration, mounting transforms and an appropriate pose-solving method.
Likewise, a reported image angle should not be copied into your odometry heading. Image orientation and field orientation have different meanings.
For this lesson, read ID and bounding-box data only. 08.4 uses image coordinates for centring, and 08.5 explores a calibrated apparent-size approach estimate.
Do not apply AI classification score thresholds to AprilTags. The classification score used in 08.2 is a different mode’s evidence.
11 | TEST A FULL LOSS SEQUENCE
- Show the expected ID and observe ACQUIRING followed by READY.
- Show a different ID and confirm it is not selected.
- Cover the expected marker briefly and check immediate invalidation.
- Reveal it and verify confirmation restarts.
- Keep it covered beyond the loss timeout.
- Present two copies of the expected ID.
- Test too-small boxes and invalid values with mocked data.
- Test B cancellation and a disconnected sensor.
Record the marker ID, size, placement, light, state transitions and result. Compare successful decoding with the actual scene, rather than relying on one screenshot.
12 | COMMON MISTAKES
- Using a QR code: use supported AprilTag markers.
- Selecting index zero: find the required ID.
- Keeping the last box on screen as current: clear it on loss.
- Continuing a drive during a grace period: missing feedback must invalidate movement input immediately.
- Printing duplicate station IDs: one ID can then refer to multiple locations.
- Equating tag angle with field heading: do not mix frames.
- Waiting indefinitely: bound acquisition, loss handling and the whole action.
CHALLENGES | IDENTIFY, CONFIRM AND LOSE
- Configure: enable detection and verify two supported marker IDs.
- Select: choose ID 7 from a tuple ordered [12, 3, 7].
- Trace: predict the gate for valid, valid, missing, valid, valid, valid.
- Mock: test empty results, wrong IDs and duplicate matches.
- Time: confirm the last-seen age increases during absence while current coordinates remain unavailable.
- Test: cover the marker, reveal it briefly, then cover it beyond the timeout.
- Explain: distinguish marker identity, image location and robot field pose.
- Design: propose a bounded reacquisition policy for a later movement controller, with an immediate brake on loss.
Sequence check
The confirmation counts are 1, 2, 0, 1, 2, 3. Only the final observation is READY. During the missing observation there is no current target.
OFFICIAL REFERENCES
The monitor, confirmation gate and loss policy are independently written teaching examples. Verify them with the actual sensor, marker set and project configuration.