9. Landing on a marker¶
A recipe for a drone that lands on a fractal marker: one function per frame that says what to do — search, steer towards something that looks like the marker, or use the pose. It puts together what the other pages explain one by one: the tracker (Video), the size from a rangefinder, the lookalikes, and the pose with its uncertainty (Pose).
One step per frame¶
import numpy as np
import nanofractal as nf
F = 1000.0 # focal length, px (from your calibration)
K = np.array([[F, 0, 320], [0, F, 240], [0, 0, 1]])
DIST = np.zeros(5)
SIZE = 0.18 # printed side of the marker, m
det = nf.FractalDetector("FRACTAL_3L_6", marker_size=SIZE)
tracker = nf.FractalTracker(det, capture_frames=3) # also reads through the last result
def landing_step(frame, height_m=None):
"""('pose', Pose) | ('steer', centre px) | ('search', None)"""
hint = None
if height_m: # a rangefinder: the marker's side in px
side = SIZE * F / height_m
hint = (side / 1.3, side * 1.3) # room for the height error and for tilt
res = tracker.detect(frame, expected_side_px=hint)
if len(res):
return "pose", det.estimate_pose(res, K, DIST, full=True)
if res.lookalikes: # the marker's square, cells not readable yet
return "steer", res.lookalikes[0].centre
return "search", None
FractalTracker(capture_frames=3)keeps the marker once read (it reads the next frames through the last result, so a few cells lost to blur do not drop it) and picks it up before its first clean read.expected_side_pxfrom the height reads a far marker with a looser tolerance — on overexposed flight video the first read came 13–36 s earlier — and fillsres.lookalikeswhen the cells still do not read.- A wrong height does not lose a marker that reads without it; with no
height, pass
None.
A simulated approach¶
Frames of the marker growing from 4 to 7 px per cell (the drone descending), with cells read wrong on the far ones, as blur or glare would:
def frame_with_marker(px_per_cell, wrong_cells=0):
big = nf.generate_fractal("FRACTAL_3L_6", 14 * px_per_cell * 4).astype(float)
for k in range(wrong_cells): # flip outer-grid cells (row 2, then row 3)
r, c = 2 + k // 10, 2 + k % 10
cell = 4 * px_per_cell
big[r * cell:(r + 1) * cell, c * cell:(c + 1) * cell] *= -1
big[r * cell:(r + 1) * cell, c * cell:(c + 1) * cell] += 255
side = 14 * px_per_cell
small = big.reshape(side, 4, side, 4).mean((1, 3)) # 4x area downscale
frame = np.full((480, 640), 200.0)
frame[240 - side // 2:240 - side // 2 + side, 320 - side // 2:320 - side // 2 + side] = small
return frame.astype(np.uint8)
approach = [ # (frame, height from the rangefinder)
(frame_with_marker(4, wrong_cells=18), SIZE * F / 56),
(frame_with_marker(4, wrong_cells=12), SIZE * F / 56),
(frame_with_marker(5, wrong_cells=4), SIZE * F / 70),
(frame_with_marker(6), SIZE * F / 84),
(frame_with_marker(7), SIZE * F / 98),
]
steps = [landing_step(frame, h) for frame, h in approach]
for (what, x), (_, h) in zip(steps, approach):
print(f"{h:.2f} m: {what}", x.tvec.round(3) if what == "pose" else x)
assert [s[0] for s in steps] == ["steer", "pose", "pose", "pose", "pose"]
At 3.2 m the marker is 18 cells off its code: no reading accepts it, but it is a lookalike of the expected size, and its centre is enough to steer. One frame later, 12 cells off, the height hint reads it; from then on the tracker keeps it.
When to trust the pose¶
Pose.sd_t is the position uncertainty in metres. Far away, only the outer
corners are seen and it varies a lot from frame to frame (about ×5; above,
7.2 → 0.0 → 5.7 mm — 0 when the four corners happen to fit exactly), so
decide on a running median, relative to the height:
from collections import deque
recent = deque(maxlen=3)
for what, pose in steps:
if what != "pose":
recent.clear() # lost: start again
continue
recent.append(pose.sd_t)
height = float(pose.tvec[2])
trusted = len(recent) == recent.maxlen and np.median(recent) < 0.02 * height
print(f"z {height:.2f} m sd_t {pose.sd_t * 1000:.1f} mm trusted: {trusted}")
Thresholds to start from (measure on your own flights): a running median of
sd_t under ~2 % of the height, reproj_err_rel under 0.005, and
res.bit_errors 0 on at least one level (a level read through the prior or
the height hint has bit_errors > 0). PoseSmoother (Pose)
smooths the pose itself.
What it cannot do¶
- A marker cut by the frame edge. At touchdown, if the camera looks past the centre, every level can be cut and nothing is found — mount the camera or place the marker so the centre stays in view.
- Below ~2.5 px per cell. The cells cannot be told apart; a
FRACTAL_3L_6print of 18 cm is about 45 px wide at 4 m with f ≈ 1000 px (3.2 px per cell) and 30 px at 6 m. For more height, print the marker bigger: the range grows with its size. - Glare. A glossy print saturates: the white is lost and the black cells turn grey. A matte print, or a shorter exposure, reads from farther away than any setting of the detector.