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:

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
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
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:
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.
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.