Ray casting the map

The forward model: given a map and a pose, what would the sensor read?

U8.5MappingUniversity35 min

Do this lesson in the simulator

U8.2 went from a reading to a map. This lesson goes the other way: given a map and a pose, what would the sensor read? That is the forward sensor model, and it is the piece U7 quietly assumed.

def cast(x, y, h):
    step out from (x, y) along heading h
    the first occupied cell you enter is the return
    if you leave the map first, it is a timeout

Why it matters

It closes the loop with localisation. U7 weighted each particle by comparing a measured range with a predicted one, and the prediction was hard-coded as 200 - y because the map was one wall. With a grid and a ray caster, the same filter works in any room, and the pair of them is the standard architecture: a grid map, and MCL localising against it by ray casting per particle per beam.

Monte Carlo localisation with a grid: each particle's pose is ray cast through the map, and the predicted ranges are compared with the measured onesparticleposesgrid map (U8.4)ray cast eachbeam from eachposepredictedrangesmeasuredranges: scan()compare:weight eachparticleresample, as in U7, and go round again
The standard pairing. The grid map built in U8.4 is the only picture of the room; a ray cast from each particle's pose turns it into the ranges that particle would see, and the particles whose predictions match scan() best get the most weight.

It is how you check a map. Cast a ray from where the robot is, compare with what the sensor says, and the residual tells you whether the map is right. Large residuals in one direction mean the map is stale or the pose is wrong. This is the map's own innovation, in the U6 sense.

It is what a planner asks. "Would I be able to see that from over there?" is a ray cast, and it is how a robot decides where to go to learn the most.

The cost, and what everybody does about it

Marching a ray in 1 cm steps over 150 cm is 150 array lookups. One beam, one pose: nothing. A particle filter with 500 particles and 8 beams at 10 Hz is six million lookups a second, and that is on the edge.

The standard answers, in order of how often they are reached for:

  1. Subsample the beams. Use two of the eight. The readings are correlated anyway, and a filter fed 8 nearly-identical beams is overconfident, so throwing some away often improves the estimate as well as the speed.
  2. A likelihood field. Precompute, once, the distance from every cell to the nearest occupied cell. Then scoring a beam is one lookup of the hit point instead of a march. This is what most production MCL uses, and it is smoother to optimise against because it has no discontinuities where a ray just clips a corner.
  3. Amanatides and Woo's algorithm, the standard grid traversal from voxel ray tracing: step cell to cell rather than in fixed increments, so no cell is visited twice and none is skipped.
A likelihood field: every cell holds its distance to the nearest occupied cell, so a beam is scored by one look-up at its end252525252725252525252525252525212020212221202021202120202120161515161816151516151615151615111010111411101011101110101110755710755757557555555555555757555555557575green, particle at (100, 50): the end's cell reads 0red, particle at (85, 35): the end's cell reads 18numbers: cm to the nearest occupied cell (red)
The field worked out once from the map the code below builds, near its wall. The same reading, 99.1 cm, laid out from two particles: from the true pose it ends in an occupied cell, a perfect score; from a pose 15 cm back and to the left it ends in a cell 18 cm from the nearest wall, a poor score. Each score is one look-up, where marching the beam took 96.

Doing it

from bugbot import *
import math
connect()

CELL, W = 5.0, 40
L_OCC, L_FREE, L_MAX, FAR = 0.85, -0.4, 8.0, 170.0
START_X, START_Y = 100.0, 50.0
grid = [0.0] * (W * W)

def bump(x, y, amount):
    c, r = int(x / CELL), int(y / CELL)
    if 0 <= c < W and 0 <= r < W:
        grid[r * W + c] = max(-L_MAX, min(L_MAX, grid[r * W + c] + amount))

for i in range(20):
    px, py = position()
    x, y, h = START_X + px, START_Y + py, heading()
    for a, d in scan():
        th = math.radians(h + a)
        sx, sy = math.sin(th), math.cos(th)
        r = 0.0
        while r < min(d, FAR) - CELL:
            bump(x + r * sx, y + r * sy, L_FREE)
            r += CELL / 2
        if d < FAR:
            bump(x + d * sx, y + d * sy, L_OCC)
    wait(0.1)

def cast(x, y, h, limit=250.0):
    th = math.radians(h)
    sx, sy = math.sin(th), math.cos(th)
    r = 0.0
    while r < limit:
        c, row = int((x + r * sx) / CELL), int((y + r * sy) / CELL)
        if not (0 <= c < W and 0 <= row < W):
            return limit
        if grid[row * W + c] > 1.0:
            return r
        r += 1.0
    return limit

print("from here the map predicts", round(cast(START_X, START_Y, 0.0), 1))
print("the sensor says            ", round(distance(), 1))
print("from 20 cm back it predicts", round(cast(START_X, START_Y - 20.0, 0.0), 1))

Run this in the simulator

The prediction comes out a few centimetres short of the reading, every time, and that is not a bug. A caster stops when it enters an occupied cell, so it reports the range to the near edge of a 5 cm cell rather than to the surface inside it. Here the map predicts 95.0 cm and the sensor reads 99.1 cm, most of a cell short, because the wall's face at y = 150 sits right on a cell boundary and readings a little short of it mark the cell below. Over surfaces that fall anywhere in a cell the bias averages about half a cell, and it is always systematic, which matters: a localiser scored on these predictions will be pulled forward unless the bias is either modelled or absorbed into a measurement noise large enough to cover it. Half a cell is another argument for a cell size smaller than the sensor's noise.

The cast stops as it enters the first occupied cell, short of the surface inside it859095100105110range, cmthe sensor: 99.1the cast stops: 95on entering an occupied cellthe wall, face at y = 150dots: the cast's 1 cm steps
The cast from (100, 50) straight up, and the rows it reaches. Short readings made row 29, the floor in front of the wall, occupied (red), so the cast stops as it steps into that row, at 95. The sensor, reading the real surface, says 99.1: 4.1 cm more, and the cast comes up short the same way every time.

Unknown is not occupied

A ray that crosses cells the robot has never seen has to decide what they are, and the choice is a policy, not a fact.

  • Treat unknown as free, and the caster reports the range to the nearest thing you actually know about. This is right for checking a map and for localisation, where you only want to be scored on evidence you have.
  • Treat unknown as blocked, and the caster reports the edge of knowledge. This is right for a planner that must not drive into a place it has not looked at.

Both are used, in the same system, for different questions. Write the caster so the caller says which.

Two rays cast through the same map, with unknown cells treated as free and as blockedray at 10°dot, unknown as free: 102square, as blocked: 61ray at 26°unknown as free: itleaves the map, 250square, as blocked: 23green: freered: occupiedblank: unknown
The map from the code above, and two rays from the robot. At 10° the ray slips between the beams' stripes: counted as free, the unknown gaps let it reach the wall at 102; counted as blocked, it stops at the first gap, 61. At 26° it is outside the fan: nothing known is in the way, so one policy reports a timeout and the other the edge of what the robot has seen, 23 cm out.

Task: predict a reading from the map

Standing at (100, 50), build a map from the scans. Then ray cast from a pose 20 cm behind the robot and print predicted:. Drive back 20 cm, measure, and print measured: and error:.

from bugbot import *
import math
connect()

CELL, W = 5.0, 40
L_OCC, L_FREE, L_MAX, FAR = 0.85, -0.4, 8.0, 170.0
START_X, START_Y = 100.0, 50.0
grid = [0.0] * (W * W)

Challenges

  1. Cast rays at the eight angles scan() uses and compare the whole predicted fan with the real one.
  2. Add a mode that treats unknown cells as blocked and see how much shorter the predictions get.
  3. Time 1,000 casts. How many particles could you afford at 10 Hz?