Precision landing is SkyCore’s one computer-vision autonomy capability. It runs as the aruco_landing module inside the skyhub container’s ROS half, reads the SIYI down-camera over RTSP, and commands the flight controller directly with pymavlink over mavp2p:14561 — bypassing MAVROS.
For the video pipeline the landing camera rides on, see /drone-os/robotics/video-streaming. For the GUIDED-velocity safety model that manual movement (and the landing correction commands) build on, see /drone-os/safe-control. For the Redis channel contract and MAVLink port map, see /drone-os/message-bus and /drone-os/mavlink-topology.
Precision landing was silently disabled on every aircraft until 0.2.0. cv2 was absent from the vehicle image entirely, and detector.py imports it behind a warn-only try/except, so the module loaded, logged one warning, and detected nothing. python3-opencv is now installed explicitly (docker/core/Dockerfile:61) and ArUco detects end to end.OpenCV is pinned to apt’s 4.5.4 on purpose. cv2.aruco.estimatePoseSingleMarkers — the call detector.py makes — was removed in 4.9, and pip’s opencv-python (pulled in transitively by siyi-sdk) shadows apt’s on sys.path. So siyi-sdk is installed --no-deps under docker/skyhub/constraints.txt, and docker/skyhub/test/check-deps.sh asserts at build time that the function still exists. There is deliberately no solvePnP fallback: the build guarantees it could never run, and untested code in the descent path is worse than none. A future base-image bump fails the build instead of failing in flight.

The module

The landing module implements Embedded ArUco (Khazetdinov et al., 2021): a large outer marker with a small inner marker printed into its center cell, both from DICT_7X7_250. The outer marker is visible from high altitude; the inner marker gives centimeter precision on final approach. The module is off by default and lives entirely in the ROS half:
FileResponsibility
docker/core/src/modules/aruco_landing/module.pyROS2 module: RTSP capture, altitude/GPS subscriptions, Redis command loop, RTL auto-trigger, gimbal lock, tracking publish (1742 lines)
docker/core/src/modules/aruco_landing/detector.pyEArucoDetector — detects outer/inner markers, pose (rvec/tvec), yaw, distance
docker/core/src/modules/aruco_landing/controller.pyLandingControllerLandingState machine, yaw-align-then-descend, per-zone speeds
docker/core/src/modules/aruco_landing/drone_controller.pyDroneController — direct pymavlink body-NED velocity, yaw, land, mode

Printing a marker

Generate a print-ready e-ArUco marker with utils/generate_e_aruco_marker.py. The inner marker is always 1/9 of the outer size and is embedded in the outer marker’s center cell:
utils/generate_e_aruco_marker.py
# Single 87 cm marker (matches the shipped EARUCO_OUTER_MARKER_SIZE=0.87)
python3 utils/generate_e_aruco_marker.py --size 87 --outer-id 228 --inner-id 11 --output marker.png

# Large marker tiled across A4 pages (assembles into one big marker)
python3 utils/generate_e_aruco_marker.py --size 60 --tiled --output e_aruco_tiles

# List compatible outer/inner ID pairs for DICT_7X7_250
python3 utils/generate_e_aruco_marker.py --find-pairs
Print at 100% scale (no fit-to-page) and verify the printed dimension with a ruler. The detector estimates distance from the marker’s physical size (EARUCO_OUTER_MARKER_SIZE / EARUCO_INNER_MARKER_SIZE), so a mis-scaled print produces wrong altitude and unsafe descent speeds. The default pair is outer 228 + inner 11 (detector.py:46), chosen because 228 has a black center cell that the inner marker blends into.

Detection and the outer→inner switch

EArucoDetector.detect_both() (detector.py:110) detects both markers each frame; the module’s _detect_with_altitude_switch() (module.py:1292) picks which one to act on based on current altitude. Below EARUCO_SWITCH_ALTITUDE it uses the inner marker, otherwise the outer:
docker/core/src/modules/aruco_landing/module.py:1327
should_use_inner = False
if current_altitude is not None and current_altitude <= self.marker_switch_altitude:
    should_use_inner = True
Pose is estimated with cv2.aruco.estimatePoseSingleMarkers using a camera matrix derived from CAMERA_HFOV; each detection yields center (pixels), center_normalized (−1..1), distance (m), and yaw_degrees (0–360, compass). Frames come from a dedicated RTSP capture thread that opens rtsp://{CAMERA_IP}:{CAMERA_PORT}/{CAMERA_PATH} only while landing is active.

Descent state machine

LandingController (controller.py) drives the descent. It corrects yaw first (no descent during yaw alignment), then descends continuously while nudging XY to keep the marker centered. Speeds are chosen per altitude “zone”, so descent is fast up high and gentle near the ground. The three movement primitives all come from DroneController and bypass MAVROS entirely, talking pymavlink to mavp2p on UDP 14561:
ActionMAVLink messageFrame / notes
XY correction + descentSET_POSITION_TARGET_LOCAL_NEDMAV_FRAME_BODY_NED, type-mask 0b0000111111000111 (velocity only). vx=forward, vy=right, vz=down (drone_controller.py:117)
Yaw alignMAV_CMD_CONDITION_YAWrelative rotation, speed capped at 60°/s (controller.py:531)
TouchdownMAV_CMD_NAV_LANDat ARUCO_LANDING_DISTANCE (drone_controller.py:195)
Mode changeMAV_CMD_DO_SET_MODEGUIDED with retry before descent (drone_controller.py:267)
Landing commands are only sent in GUIDED mode. If the flight mode leaves GUIDED mid-descent, LandingController.on_drone_state() calls abort_landing() (controller.py:170). This lets an operator abort instantly by flipping the transmitter mode switch — a safety property a refactor must preserve.

Auto-trigger on RTL / AUTO

Landing can be started manually (a dock_start command on {ip}:gamepad_input, subscribed in _redis_command_loop, module.py:1398) or automatically during a return-to-launch. When RTL_DOCK_ENABLED, _check_rtl_dock_trigger() (module.py:1481) fires once the drone descends to RTL_DOCK_ALTITUDE and is within RTL_DOCK_HOME_DISTANCE of home (haversine distance from /mavros/home_position/home vs /mavros/global_position/global). The home-distance guard prevents a dock from starting at a mid-mission RTL waypoint far from the pad. It works for both a manual RTL and an AUTO mission whose final leg is an RTL.

Gimbal lock and the canvas overlay

When landing activates, _activate_landing() (module.py:1629) locks the camera: points the gimbal down, sets zoom to minimum (widest view), and triggers autofocus — issued both directly over the SIYI TCP protocol and as a Redis camera_command with source: "aruco_landing" to the agent half.
That source: aruco_landing camera command sets gimbal_locked in the agent half, so operator gimbal input is silently dropped for the duration of the landing (until dock_stop or disarm). Autofocus is re-triggered at each descent-zone transition because the SIYI ZR30 supports only one-shot autofocus.
Marker tracking is not burned into the video stream (the “CANVAS approach”). Instead _publish_tracking_data() publishes marker position/distance/yaw to Redis {ip}:aruco_tracking at ~10 Hz; the agent half forwards it to WS clients and the Dashboard renders it as a canvas overlay on top of the WebRTC video. This keeps the encoded stream clean and saves Jetson GPU — see /dashboard/features/video-and-control.

Configuration

All values are read via the EARUCO_* / ARUCO_* / RTL_DOCK_* env prefixes. The table shows the shipped .env.example values (what actually runs). Note the module’s hard-coded fallback defaults differ where called out — always trust .env.example.
Env var.env.examplePurpose
ARUCO_LANDING_ENABLEDfalseEnable the aruco_landing module
EARUCO_OUTER_MARKER_ID / _INNER_MARKER_ID228 / 11e-ArUco marker IDs (DICT_7X7_250)
EARUCO_OUTER_MARKER_SIZE / _INNER_MARKER_SIZE0.87 / 0.077 mPhysical marker sizes (code fallback 0.45 / 0.05)
EARUCO_SWITCH_ALTITUDE2.5 mOuter→inner switch altitude (code fallback 1.3)
RTL_DOCK_ENABLEDtrueAuto-trigger landing on RTL/AUTO descent
RTL_DOCK_ALTITUDE30 mAltitude that arms the auto-dock (code fallback 15)
RTL_DOCK_HOME_DISTANCE15 mMax distance from home to allow auto-dock
ARUCO_LANDING_DISTANCE1.2 mDistance at which MAV_CMD_NAV_LAND fires (code fallback 1.0)
ARUCO_LAND_LOCK_DURATION2.0 sFallback: centered-lock time before LAND
ARUCO_LAND_LOCK_RADIUS0.15Fallback: normalized frame radius around center
ARUCO_LAND_LOCK_MAX_DISTANCE1.5 mFallback: max marker distance allowed
ARUCO_LAND_LOCK_MIN_CONFIDENCE0.5Fallback: minimum marker confidence
ARUCO_LAND_LOCK_MAX_GAP1.0 sFallback: max detection gap before the lock timer restarts
ARUCO_TIMER_FRAMES15Frames the detector averages before acting
ARUCO_TARGET_YAW0Compass heading to align the airframe to (code fallback 270)
ARUCO_LEVEL_0..320 / 10 / 3 / 1.5 mZone altitude thresholds (code fallbacks 10 / 5 / 3 / 1.5)
ARUCO_XY_SPEED_LVL0..30.5 / 0.3 / 0.15 / 0.08 m/sPer-zone XY correction speed
ARUCO_DOWN_SPEED_LVL0..31.0 / 0.5 / 0.3 / 0.15 m/sPer-zone descent speed
ARUCO_MAVLINK_CONNECTIONudpout:127.0.0.1:14561pymavlink endpoint into mavp2p (controller.py:45)
CAMERA_HFOV81.0Camera horizontal FOV for pose intrinsics
ARUCO_TEST_MODEfalseDetect/track only, suppress movement commands
The mavp2p udps:14561 endpoint the landing module uses is defined only in the main docker-compose.yml. The minimal docker/docker-compose.installer.yml router omits 14561 (and the RTK port), so precision landing will not send velocity commands under the installer compose.

Gotchas a future editor must preserve

detector.py calls cv2.aruco.estimatePoseSingleMarkers, removed in OpenCV 4.9. The image holds apt’s 4.5.4 and keeps pip’s opencv-python out (siyi-sdk installed --no-deps); docker/skyhub/test/check-deps.sh fails the build if the function disappears. Do not “fix” a dependency resolver complaint by unpinning it.
aruco_landing sends camera_command with source: aruco_landing, which sets gimbal_locked in the agent half. Operator center/move gimbal commands are dropped until dock_stop or disarm. Removing this lock lets operator input fight the landing controller.
Marker overlays go to Redis {ip}:aruco_tracking and are drawn on the frontend. The module deliberately does not composite them into the encoded stream (preserves quality + Jetson GPU). Do not re-add a server-side overlay.
Several aruco_landing fallback defaults in module.py (marker sizes 0.45/0.05, switch 1.3 m, RTL altitude 15 m, land distance 1.0 m, target yaw 270°, zones 10/5/3/1.5) differ from the shipped .env.example (0.87/0.077, 2.5, 30, 1.2, 0, 20/10/3/1.5). Production runs the .env.example values — quote those when documenting behavior.

Video Streaming

The camera drivers, encode selection and WHIP/WebRTC pipeline the down-camera feed shares.

Guided Velocity & Safety

The GUIDED-mode velocity model the landing corrections ultimately execute through.

MAVLink Routing (mavp2p)

The single MAVLink hub and the 14550/14561/14777 port map this module’s 14561 endpoint sits in.

Redis Message Bus

The {ip}:gamepad_input and {ip}:aruco_tracking channel contract.