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.

Turn to bring a detected tag box to the aim lineAn illustrative bounding box for tag ID 7 has centre X 220. The aim line is X 160, so horizontal error is positive 60 pixels and calls for a clockwise turn with an upright forward-facing camera.ID 7 box+60 pxAim X = 160Tag X = 220Bounding-box illustration; this is not a printable AprilTag.
Horizontal image error guides yaw. The image position does not supply a physical distance or a field heading.

03 | SUBTRACT AIM X FROM TAG X

error_px = tag_center_x - AIM_X_PX
Upright camera looking forward
Tag centre XAim XErrorCorrection after sign verification
100160−60 pxAnticlockwise
1601600 pxBrake
220160+60 pxClockwise

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.

A loss interval permits waiting, not blind movement
ConditionMotor policy
Unconfirmed candidateBrake
Confirmed off-centre targetTurn proportionally
Confirmed target in deadbandBrake and observe settling
Missing or unusable targetBrake; bounded stationary reacquisition
Duplicate ID, jump or sensor faultEnd 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

All normal returns and caught exceptions brake through finally
ResultMeaning
CENTREDConfirmed image alignment and low turn rate met the finish policy
TIMEOUTOverall alignment budget expired
TARGET_LOST_TIMEOUTMarker did not recover within the loss interval
AMBIGUOUS_TAG / TARGET_JUMPSelection became unreliable
SENSOR_NOT_READY / UPDATE_GAPConnection, calibration or timing failed
CANCELLED_OR_NOT_READYSetup, enable, mode or B blocked continuation
CONFIGURATION_OR_TAG_DATA_FAULTGenerated 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

  1. Use 08.3 to verify detection without movement.
  2. Print horizontal error at left, centre and right positions.
  3. Test the turn sign with a conservative cap.
  4. Run a short alignment from each side.
  5. Cover and reveal the marker while checking that the robot stops.
  6. Repeat with different distances and lighting.
  7. 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

  1. Calculate: find errors for tag X values 80, 160 and 240.
  2. Predict: calculate capped turn requests with gain 0.12.
  3. Calibrate: test an aim point of 170 pixels and explain the sign changes.
  4. Mock: show that a missing tag resets confirmation and settling.
  5. Test: cover the tag briefly, then longer than the loss timeout.
  6. Compare: measure three gain settings from the same starting error.
  7. Verify: demonstrate that low angular speed is required for CENTRED.
  8. 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

The controller and gates are independently written educational examples requiring physical configuration, testing and tuning.