VEX V5 PYTHON | 08.5 TAG SIZE AND RANGE
Guide objective
Collect tag-width measurements at known distances, fit a simple range model and inspect bounded approach decisions without commanding motors.
01 | WHY A CLOSER TAG LOOKS LARGER
Hold the same marker in front of a fixed camera. As it moves closer, its image usually gets wider. This can provide an approximate range cue.
The cue depends on geometry. Use the same printed marker, camera, image scale and orientation. A different print size or a tilted marker changes the relationship.
This guide calibrates a simple model and inspects approach decisions while the robot stays stationary. Full docking combines additional checks in 08.6.
02 | DEFINE THE DISTANCE YOU ARE MEASURING
The simple model estimates depth along the camera’s viewing axis from the lens reference to the tag plane. It is most useful with a centred, front-facing marker.
That depth is not automatically slant range, robot-centre distance or front-edge clearance. Keep the tape-measure reference consistent during calibration and testing.
If the lens sits 100 mm behind the front edge and the tag is flush on a wall, an estimated lens-to-wall depth of 350 mm corresponds to a nominal 250 mm front-edge gap. A protruding docking mechanism changes the clearance again.
03 | USE AN INVERSE-WIDTH MODEL
For a front-facing target with fixed size under an ideal pinhole model, apparent width is approximately proportional to focal length × target width / depth.
# Empirical constant absorbs camera scale and the same marker size: K_mm_px = known_depth_mm * measured_width_px estimated_depth_mm = K_mm_px / current_width_px
Doubling depth roughly halves width. The constant K has units mm × pixels. This is an empirical approximation for the sensor’s reported bounding-box width, not a full camera calibration or pose solver.
Keep the marker’s in-plane rotation consistent too. An axis-aligned box around a rotated square can become wider even when the physical distance has not changed.
04 | WORKED CALIBRATION EXAMPLE
| Known depth | Median width | Depth × width |
|---|---|---|
| 250 mm | 120 px | 30,000 mm·px |
| 500 mm | 60 px | 30,000 mm·px |
| 750 mm | 40 px | 30,000 mm·px |
| 1,000 mm | 30 px | 30,000 mm·px |
These idealized readings give K = 30,000 mm·px. At 75 pixels, estimated depth is 30,000 / 75 = 400 mm. At 100 pixels, it is 300 mm.
The sample values in the Python program are examples. Replace them with measurements from your camera and printed marker, then validate the estimates before setting CALIBRATION_VERIFIED to True.
05 | COLLECT REPEATED MEASUREMENTS
- Mount the camera rigidly and enable AprilTag detection.
- Use the expected marker ID and keep its print size unchanged.
- Place the tag centred and approximately perpendicular to the viewing axis.
- Measure depth from a consistent camera reference.
- Keep both camera and marker still while collecting widths.
- Repeat at several depths across the intended working range.
- Record median, spread and the product depth × median width.
The starter collects 25 consecutive usable observations. Loss or poor centring resets the collection. It rejects a window whose width spread exceeds 10% of its median.
These are repeated API observations, not guaranteed independent exposures. They help reveal instability but do not establish accuracy by themselves.
06 | FIT, THEN VALIDATE ON DIFFERENT TRIALS
Compare the K values across calibration depths. If they vary markedly, investigate measurement references, angle, clipping, print size and lens distortion before averaging them.
For a simple robust fit, use the median of the per-distance K values. Then test at depths not used in that fit.
| Held-out measured depth | Observed width | Estimate with K = 30,000 | Signed error |
|---|---|---|---|
| 400 mm | 74 px | 405.4 mm | +5.4 mm |
| 600 mm | 51 px | 588.2 mm | −11.8 mm |
Define an acceptable error for your task and record repeated measured errors. Test motion separately after stationary validation; blur and delay can change results.
07 | REJECT UNUSABLE GEOMETRY
The example rejects duplicate expected IDs, tiny boxes, boxes near the image edge and extreme width-to-height ratios. It also refuses to extrapolate beyond the validated width range.
These filters are heuristics. A square-looking box can still be tilted, rotated or inaccurately detected. Aspect ratio alone does not prove the marker is front-facing.
A 45° in-plane rotation can keep a square box aspect ratio while inflating its width. Keep orientation consistent with calibration, or use a more complete calibrated method.
A missing marker produces no current range estimate. Do not substitute the last width or return zero distance as if that were a valid measurement.
08 | PIXEL ERROR MATTERS MORE FOR SMALL TAGS
With K = 30,000, a width of 30 pixels means 1,000 mm. Widths of 29 and 31 pixels mean about 1,034.5 and 967.7 mm.
At a nominal width of 100 pixels, 99 and 101 pixels mean about 303.0 and 297.0 mm. The same one-pixel change has a much larger range effect when the tag is small.
Use validated width bounds and a tolerance wider than the observed measurement noise. Smoothing may reduce flicker, but adds delay and must not hide target loss.
09 | INSPECT AN APPROACH POLICY
The diagnostic policy first requires horizontal alignment. It proposes a capped forward request only when estimated depth exceeds the desired depth plus tolerance.
| Condition | Printed decision | Printed forward request |
|---|---|---|
| Tag missing or unusable | No usable estimate | 0 |
| Outside calibrated width range | OUTSIDE_CALIBRATED_RANGE | 0 |
| Horizontal error outside ±6 px | ALIGN_FIRST | 0 |
| Depth below target minus tolerance | TOO_CLOSE_STOP | 0 |
| Depth within ±30 mm of target | STOP_WINDOW | 0 |
| Aligned and farther than the window | FORWARD_CANDIDATE | Capped proportional request |
STOP_WINDOW is a model-based range condition, not physical settling or proof of clearance. A real approach must monitor actual movement, braking distance, time budgets and obstacles.
The policy never automatically reverses when too close. That would need a verified rearward clearance policy.
10 | COMPLETE STATIONARY CALIBRATION AND ESTIMATE PROGRAM
Configure AI Vision on port 15 as ai_vision_1, with AprilTag detection enabled. Keep generated imports, Brain and camera setup above this code. Avoid duplicating a generated Controller.
Start in COLLECT mode. Set KNOWN_DEPTH_MM to the measured distance for each run. Read the median width and K result in the console.
After independent validation, enter your K and width bounds, set CALIBRATION_VERIFIED to True and choose ESTIMATE mode. Move the marker manually; the program remains stationary.
# =============================================================================
# TEAM [MY TEAM NAME / NUMBER] | STATIONARY TAG RANGE CALIBRATION
# =============================================================================
# AI Vision in Devices: port 15, ai_vision_1, AprilTag detection enabled.
# Keep generated VEX imports, brain and camera setup above this code.
# No motor commands. Approach requests below are diagnostic outputs only.
import math
controller = Controller(PRIMARY) # Avoid duplicating a generated Controller.
MODE = "COLLECT" # COLLECT or ESTIMATE
TARGET_TAG_ID = 7
KNOWN_DEPTH_MM = 500.0 # Measured lens-to-tag depth for this COLLECT run
SAMPLES_REQUIRED = 25
MAX_WIDTH_SPREAD_FRACTION = 0.10
RUN_MS = 20000
POLL_MS = 100
MAX_SNAPSHOT_MS = 250
IMAGE_WIDTH = 320
IMAGE_HEIGHT = 240
AIM_X_PX = 160.0
ALIGN_TOL_PX = 6.0
EDGE_MARGIN_PX = 4.0
MIN_BOX_PX = 12.0
MAX_ASPECT_RATIO = 1.25 # Heuristic only; not proof of front-facing geometry
CALIBRATION_VERIFIED = False
CAL_K_MM_PX = 30000.0 # Illustrative; replace from your own measured trials
CAL_MIN_WIDTH_PX = 30.0 # Width bounds of independently validated working range
CAL_MAX_WIDTH_PX = 120.0
DESIRED_DEPTH_MM = 350.0 # Lens-to-marker, not bumper clearance
DEPTH_TOL_MM = 30.0
MAX_DIAGNOSTIC_DRIVE = 12.0
APPROACH_KP = 0.03 # percent/mm; printed only, never sent to motors
# =============================================================================
# MEASUREMENT AND MODEL
# =============================================================================
def median(values):
ordered = sorted(values)
middle = len(ordered)//2
return ordered[middle] if len(ordered)%2 else (ordered[middle-1]+ordered[middle])/2
def usable_tag(objects):
matches = [obj for obj in objects if obj.exists and obj.id == TARGET_TAG_ID]
if len(matches) > 1:
return "AMBIGUOUS_TAG", None
if not matches:
return "NO_EXPECTED_TAG", None
obj = matches[0]
data = (obj.centerX, obj.centerY, obj.originX, obj.originY, obj.width, obj.height)
if not all(math.isfinite(v) for v in data):
raise ValueError("Invalid box data")
x, y, ox, oy, w, h = data
if not (0 <= x <= IMAGE_WIDTH and 0 <= y <= IMAGE_HEIGHT
and 0 < w <= IMAGE_WIDTH and 0 < h <= IMAGE_HEIGHT):
raise ValueError("Box outside image bounds")
if (ox < EDGE_MARGIN_PX or oy < EDGE_MARGIN_PX
or ox+w > IMAGE_WIDTH-EDGE_MARGIN_PX
or oy+h > IMAGE_HEIGHT-EDGE_MARGIN_PX):
return "EDGE_OR_CLIPPED", None
if min(w,h) < MIN_BOX_PX:
return "TOO_SMALL", None
if max(w,h)/min(w,h) > MAX_ASPECT_RATIO:
return "ASPECT_REJECTED", None
return "USABLE", {"x": x, "width": w, "height": h}
def estimate_depth(width_px):
if not math.isfinite(width_px) or width_px <= 0:
raise ValueError("Invalid width")
if not CALIBRATION_VERIFIED:
return "CALIBRATION_NOT_VERIFIED", None
if not CAL_MIN_WIDTH_PX <= width_px <= CAL_MAX_WIDTH_PX:
return "OUTSIDE_CALIBRATED_RANGE", None
return "ESTIMATE", CAL_K_MM_PX/width_px
def approach_decision(tag, estimated_depth):
# This function never operates a motor. It proposes an upper-bounded request.
error_x = tag["x"]-AIM_X_PX
if abs(error_x) > ALIGN_TOL_PX:
return "ALIGN_FIRST", 0.0
error_depth = estimated_depth-DESIRED_DEPTH_MM
if error_depth < -DEPTH_TOL_MM:
return "TOO_CLOSE_STOP", 0.0 # No automatic reverse.
if abs(error_depth) <= DEPTH_TOL_MM:
return "STOP_WINDOW", 0.0 # Not proof of physical settling.
return "FORWARD_CANDIDATE", min(MAX_DIAGNOSTIC_DRIVE, APPROACH_KP*error_depth)
# =============================================================================
# BOUNDED COLLECTION OR ESTIMATE MONITOR
# =============================================================================
def run_calibration_monitor():
widths = []
try:
settings = (TARGET_TAG_ID, KNOWN_DEPTH_MM, SAMPLES_REQUIRED,
MAX_WIDTH_SPREAD_FRACTION, RUN_MS, POLL_MS, MAX_SNAPSHOT_MS,
AIM_X_PX, ALIGN_TOL_PX, EDGE_MARGIN_PX, MIN_BOX_PX,
MAX_ASPECT_RATIO, CAL_K_MM_PX, CAL_MIN_WIDTH_PX, CAL_MAX_WIDTH_PX,
DESIRED_DEPTH_MM, DEPTH_TOL_MM, MAX_DIAGNOSTIC_DRIVE, APPROACH_KP)
if (MODE not in ("COLLECT", "ESTIMATE")
or 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 min(KNOWN_DEPTH_MM, RUN_MS, POLL_MS, MAX_SNAPSHOT_MS,
MIN_BOX_PX, CAL_K_MM_PX, DESIRED_DEPTH_MM,
MAX_DIAGNOSTIC_DRIVE, APPROACH_KP) <= 0
or SAMPLES_REQUIRED < 3 or int(SAMPLES_REQUIRED) != SAMPLES_REQUIRED
or not 0 < MAX_WIDTH_SPREAD_FRACTION < 1
or not 0 < CAL_MIN_WIDTH_PX < CAL_MAX_WIDTH_PX <= IMAGE_WIDTH
or not 0 < AIM_X_PX < IMAGE_WIDTH or ALIGN_TOL_PX <= 0
or EDGE_MARGIN_PX < 0 or MAX_ASPECT_RATIO < 1 or DEPTH_TOL_MM <= 0):
return "INVALID_SETTINGS"
started = brain.timer.time(MSEC)
while brain.timer.time(MSEC)-started < RUN_MS:
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, 24)
now = brain.timer.time(MSEC)
if controller.buttonB.pressing():
return "CANCELLED"
if now-started >= RUN_MS:
return "TIMEOUT" if MODE == "COLLECT" else "FINISHED"
if now-before > MAX_SNAPSHOT_MS:
return "SLOW_SNAPSHOT"
status, tag = usable_tag(objects)
depth, request = None, 0.0 # Clear previous estimate/request every sample.
if status == "AMBIGUOUS_TAG":
return status
if tag is None:
widths = [] # Require a new consecutive usable collection window.
elif MODE == "COLLECT":
if abs(tag["x"]-AIM_X_PX) > ALIGN_TOL_PX:
status = "CENTRE_BEFORE_COLLECTION"
widths = []
else:
widths.append(tag["width"])
status = "COLLECTING"
if len(widths) >= SAMPLES_REQUIRED:
typical = median(widths)
spread = (max(widths)-min(widths))/typical
if spread > MAX_WIDTH_SPREAD_FRACTION:
return "UNSTABLE_COLLECTION"
k = KNOWN_DEPTH_MM*typical
print("COLLECTION", "DEPTH_MM", KNOWN_DEPTH_MM,
"MEDIAN_WIDTH", typical, "SPREAD", spread, "K_MM_PX", k)
return "COLLECTED_CHECK_CONSOLE"
else:
status, depth = estimate_depth(tag["width"])
if depth is not None:
status, request = approach_decision(tag, depth)
print("MODE", MODE, "STATUS", status, "WIDTH", None if tag is None else tag["width"],
"DEPTH_EST_MM", depth, "DIAGNOSTIC_PERCENT", request)
brain.screen.clear_screen()
brain.screen.set_cursor(1,1)
brain.screen.print(MODE, " ", status)
brain.screen.set_cursor(2,1)
brain.screen.print("Depth estimate: ", depth)
brain.screen.set_cursor(3,1)
brain.screen.print("Printed request: ", request)
wait(POLL_MS, MSEC)
return "TIMEOUT" if MODE == "COLLECT" else "FINISHED"
except Exception:
return "CONFIGURATION_OR_RANGE_DATA_FAULT"
result = run_calibration_monitor()
print("CALIBRATION_END", result)
brain.screen.clear_screen()
brain.screen.set_cursor(1,1)
brain.screen.print("Calibration: ", result)
Every observation clears the previous depth and proposed request before processing. The Brain screen is cleared each cycle and when the session ends.
The bounded monitor checks B, connection and snapshot duration. The call-duration check cannot interrupt a blocked snapshot or identify the camera’s exact exposure time.
11 | BRING THE ESTIMATE INTO A MOVEMENT ROUTINE
Use the confirmed expected-ID selection from 08.3 and alignment policy from 08.4. Add the validated range model to the same controller that owns drivetrain output.
- Take a current snapshot and reject unusable geometry.
- Confirm the expected ID and require alignment before translation.
- Estimate depth only inside the validated range.
- Reduce the forward request as the target window approaches.
- Brake immediately on lost or invalid feedback.
- Require a settled finish and independent clearance evidence.
Repeated estimates below a stop threshold cannot remove latency or guarantee stopping before contact. Choose conservative speeds from measured stopping trials. Do not paste diagnostic print requests directly into a motor loop without the missing gates.
12 | COMMON MISTAKES
- Copying the sample K: calibrate your own marker and camera.
- Changing print size: the constant changes with target scale.
- Measuring from the bumper but using a lens model: use one documented reference.
- Ignoring angle and in-plane rotation: apparent width can change without a distance change.
- Extrapolating beyond calibration: reject unvalidated widths.
- Treating STOP_WINDOW as arrival: check physical settling and clearance separately.
CHALLENGES | MEASURE, FIT AND CHECK
- Calculate: find K from 600 mm and width 50 pixels.
- Estimate: use that K for widths 75 and 120 pixels.
- Collect: take repeated stationary readings at four known depths.
- Validate: test two different depths and record signed and absolute error.
- Rotate: hold depth fixed and compare tag widths at different orientations.
- Mock: test missing, clipped, duplicate and out-of-range tags.
- Explain: distinguish lens depth, slant distance and bumper clearance.
- Design: specify the additional checks needed before commanding a forward approach.
Calculation checks
K = 600 × 50 = 30,000 mm·px. Width 75 gives 400 mm; width 120 gives 250 mm.
OFFICIAL REFERENCES
- VEX AI Vision tag and bounding-box API
- OpenCV camera projection and calibration model
- VEX AprilTag configuration and supported markers
The empirical model, sample data and diagnostic policy are independently written teaching examples, not a factory-calibrated ranging system.