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

Illustrative values, not factory calibration for AI Vision
Known depthMedian widthDepth × width
250 mm120 px30,000 mm·px
500 mm60 px30,000 mm·px
750 mm40 px30,000 mm·px
1,000 mm30 px30,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

  1. Mount the camera rigidly and enable AprilTag detection.
  2. Use the expected marker ID and keep its print size unchanged.
  3. Place the tag centred and approximately perpendicular to the viewing axis.
  4. Measure depth from a consistent camera reference.
  5. Keep both camera and marker still while collecting widths.
  6. Repeat at several depths across the intended working range.
  7. 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.

Illustrative validation calculations
Held-out measured depthObserved widthEstimate with K = 30,000Signed error
400 mm74 px405.4 mm+5.4 mm
600 mm51 px588.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.

No motor commands are sent by the calibration program
ConditionPrinted decisionPrinted forward request
Tag missing or unusableNo usable estimate0
Outside calibrated width rangeOUTSIDE_CALIBRATED_RANGE0
Horizontal error outside ±6 pxALIGN_FIRST0
Depth below target minus toleranceTOO_CLOSE_STOP0
Depth within ±30 mm of targetSTOP_WINDOW0
Aligned and farther than the windowFORWARD_CANDIDATECapped 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.

  1. Take a current snapshot and reject unusable geometry.
  2. Confirm the expected ID and require alignment before translation.
  3. Estimate depth only inside the validated range.
  4. Reduce the forward request as the target window approaches.
  5. Brake immediately on lost or invalid feedback.
  6. 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

  1. Calculate: find K from 600 mm and width 50 pixels.
  2. Estimate: use that K for widths 75 and 120 pixels.
  3. Collect: take repeated stationary readings at four known depths.
  4. Validate: test two different depths and record signed and absolute error.
  5. Rotate: hold depth fixed and compare tag widths at different orientations.
  6. Mock: test missing, clipped, duplicate and out-of-range tags.
  7. Explain: distinguish lens depth, slant distance and bumper clearance.
  8. 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

The empirical model, sample data and diagnostic policy are independently written teaching examples, not a factory-calibrated ranging system.