"""Educational joint 2D landmark SLAM; no GPS or known landmark coordinates.
Run: python slam_demo.py
Writes: slam_result.png and results.json beside this script.
"""
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from scipy.optimize import least_squares
from scipy.sparse import lil_matrix
def wrap(a):
return (a + np.pi) % (2 * np.pi) - np.pi
def rotation(a):
return np.array([[np.cos(a), -np.sin(a)],
[np.sin(a), np.cos(a)]])
def relative_motion(a, b):
delta = rotation(a[2]).T @ (b[:2] - a[:2])
return np.r_[delta, wrap(b[2] - a[2])]
def make_data():
rng = np.random.default_rng(1616)
corners = np.array([[0., 0.], [4., 0.], [4., 4.], [0., 4.], [0., 0.]])
xy = np.vstack([np.linspace(corners[k], corners[k+1], 6, endpoint=False)
for k in range(4)] + [corners[-1:]])
yaw = np.r_[np.repeat([0., np.pi/2, np.pi, -np.pi/2], 6), 0.]
truth = np.c_[xy, yaw]
landmarks = np.array([[-1., -1.], [2., -1.], [5., -1.], [5., 2.],
[5., 5.], [2., 5.], [-1., 5.], [-1., 2.],
[1., 1.], [3., 1.], [3., 3.], [1., 3.]])
odom_sigma = np.array([0.10, 0.10, 0.04])
sensor_sigma = np.array([0.06, np.deg2rad(0.8)])
odom = np.array([relative_motion(a, b) for a, b in zip(truth[:-1], truth[1:])])
odom += rng.normal(size=odom.shape) * odom_sigma
observations = []
for i, pose in enumerate(truth):
count = 3 if i in (0, 5, 10, 15, 20) else 4
nearest = np.argsort(np.linalg.norm(landmarks - pose[:2], axis=1))[:count]
for j in nearest:
d = landmarks[j] - pose[:2]
measurement = np.array([np.linalg.norm(d),
wrap(np.arctan2(d[1], d[0]) - pose[2])])
measurement += rng.normal(size=2) * sensor_sigma
measurement[1] = wrap(measurement[1])
observations.append((i, int(j), *measurement))
return truth, landmarks, odom, observations, odom_sigma, sensor_sigma
def solve(odom, observations, n_landmarks, odom_sigma, sensor_sigma):
n_poses = len(odom) + 1
initial_poses = np.zeros((n_poses, 3))
for i, motion in enumerate(odom):
previous = initial_poses[i]
initial_poses[i+1, :2] = previous[:2] + rotation(previous[2]) @ motion[:2]
initial_poses[i+1, 2] = wrap(previous[2] + motion[2])
initial_map = np.zeros((n_landmarks, 2))
seen = set()
for i, j, distance, bearing in observations:
if j not in seen:
angle = initial_poses[i, 2] + bearing
initial_map[j] = initial_poses[i, :2] + distance * np.array([np.cos(angle), np.sin(angle)])
seen.add(j)
if len(seen) != n_landmarks:
raise ValueError("Every landmark must be observed at least once.")
pose_size = 3 * (n_poses - 1)
x0 = np.r_[initial_poses[1:].ravel(), initial_map.ravel()]
def unpack(x):
# Fixed origin removes global translation/rotation ambiguity.
poses = np.vstack([np.zeros(3), x[:pose_size].reshape(-1, 3)])
return poses, x[pose_size:].reshape(-1, 2)
def residuals(x):
poses, map_xy = unpack(x)
errors = []
for i, measurement in enumerate(odom):
error = relative_motion(poses[i], poses[i+1]) - measurement
error[2] = wrap(error[2])
errors.extend(error / odom_sigma)
for i, j, distance, bearing in observations:
d = map_xy[j] - poses[i, :2]
expected_bearing = wrap(np.arctan2(d[1], d[0]) - poses[i, 2])
error = np.array([np.linalg.norm(d) - distance,
wrap(expected_bearing - bearing)])
errors.extend(error / sensor_sigma)
return np.array(errors)
pattern = lil_matrix((3 * len(odom) + 2 * len(observations), len(x0)), dtype=int)
for i in range(len(odom)):
rows = slice(3*i, 3*i+3)
if i > 0:
pattern[rows, 3*(i-1):3*i] = 1
pattern[rows, 3*i:3*i+3] = 1
offset = 3 * len(odom)
for k, (i, j, _, _) in enumerate(observations):
rows = slice(offset+2*k, offset+2*k+2)
if i > 0:
pattern[rows, 3*(i-1):3*i] = 1
pattern[rows, pose_size+2*j:pose_size+2*j+2] = 1
result = least_squares(residuals, x0, jac_sparsity=pattern.tocsr(),
x_scale="jac", max_nfev=500,
ftol=1e-9, xtol=1e-9, gtol=1e-9)
if not result.success:
raise RuntimeError(result.message)
poses, map_xy = unpack(result.x)
return initial_poses, poses, map_xy
def rmse(estimate, truth):
return float(np.sqrt(np.mean(np.sum((estimate[:, :2] - truth[:, :2])**2, axis=1))))
def main():
truth, landmarks, odom, obs, odom_sigma, sensor_sigma = make_data()
initial, optimized, map_xy = solve(odom, obs, len(landmarks), odom_sigma, sensor_sigma)
seen = set()
first_only = []
for observation in obs:
if observation[1] not in seen:
seen.add(observation[1])
first_only.append(observation)
_, no_revisit, _ = solve(odom, first_only, len(landmarks), odom_sigma, sensor_sigma)
results = {
"poses": len(truth), "landmarks": len(landmarks), "observations": len(obs),
"odometry_rmse_m": rmse(initial, truth),
"slam_rmse_m": rmse(optimized, truth),
"first_observation_only_rmse_m": rmse(no_revisit, truth),
"odometry_endpoint_gap_m": float(np.linalg.norm(initial[-1, :2] - initial[0, :2])),
"slam_endpoint_gap_m": float(np.linalg.norm(optimized[-1, :2] - optimized[0, :2])),
}
output = Path(__file__).resolve().parent
(output / "results.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
print(json.dumps(results, indent=2))
fig, ax = plt.subplots(figsize=(9, 6), constrained_layout=True)
fig.patch.set_facecolor("#050505")
ax.set_facecolor("#0a0f0a")
ax.plot(*truth[:, :2].T, "--", color="#c8ffd4", linewidth=1.6, label="True trajectory")
ax.plot(*initial[:, :2].T, "o-", color="#ffb000", markersize=3, label="Odometry")
ax.plot(*optimized[:, :2].T, "o-", color="#00ff41", markersize=3, label="Joint SLAM")
ax.scatter(*landmarks.T, marker="x", color="#7d8f7d", s=60, label="True landmarks")
ax.scatter(*map_xy.T, marker="+", color="#00fff9", s=70, label="Estimated landmarks")
ax.set(xlabel="x (m)", ylabel="y (m)", title="2D landmark SLAM: poses and map estimated together")
ax.set_aspect("equal")
ax.grid(alpha=0.15, color="#c8ffd4", linewidth=0.5)
ax.tick_params(colors="#9fcdaa", labelsize=9)
for side in ax.spines.values():
side.set_color("#234530")
ax.xaxis.label.set_color("#c8ffd4")
ax.yaxis.label.set_color("#c8ffd4")
ax.title.set_color("#c8ffd4")
legend = ax.legend(loc="upper right", fontsize=8)
legend.get_frame().set_facecolor("#0a0f0a")
legend.get_frame().set_edgecolor("#234530")
for text in legend.get_texts():
text.set_color("#c8ffd4")
fig.savefig(output / "slam_result.png", dpi=180)
plt.close(fig)
if __name__ == "__main__":
main()