Skip to content

Commit 12cfe76

Browse files
Ilia O.claude
andcommitted
Add --demo mode: synthetic LiDAR, no hardware needed (v0.6.0)
DemoLidar ray-casts an animated 2D scene (walls, pillars, a desk, a pacing person) into full 360-degree scans through the normal scans()/points() API — no serial port or GPIO. 'lds2d viz --demo' and 'lds2d read --demo' let anyone try the browser radar with zero hardware. Deterministic per (seed, frame); hardware-free tested. Bumps version to 0.6.0 and features it in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 94c65b4 commit 12cfe76

6 files changed

Lines changed: 227 additions & 6 deletions

File tree

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ or over full 360° scans.
1515
**Read the intro:** [lds2d: one Python library for 2D LiDARs — now with a live
1616
browser radar](https://makerspet.com/blog/lds2d-python-2d-lidar-library-live-browser-radar/).
1717

18+
**Try the radar without any hardware**`pip install 'lds2d[viz]'` then
19+
`lds2d viz --demo` and open `http://localhost:8080`.
20+
1821
## Install
1922

2023
```
@@ -146,13 +149,25 @@ The `read`/`motor` commands default to `LDROBOT-LD14P`; pass `--model` for other
146149
Want to *see* the sweep? `lds2d viz` serves a live polar plot you can open in any
147150
browser on your network — no GUI on the Pi required.
148151

152+
**No LiDAR yet? Try it right now with the built-in demo** — it synthesises a moving
153+
2D scene (walls, pillars, a desk, someone pacing), so you get the radar with zero
154+
hardware:
155+
149156
```
150157
pip install 'lds2d[viz]'
151-
lds2d viz # LD14P on /dev/serial0, port 8080
158+
lds2d viz --demo # then open http://localhost:8080
159+
```
160+
161+
With a real sensor attached:
162+
163+
```
164+
lds2d viz # LDROBOT-LD14P on /dev/serial0, port 8080
152165
lds2d --model XIAOMI-LDS02RR --pwm software viz # host-driven-motor models work too
153166
lds2d viz --port 9000
154167
```
155168

169+
(`lds2d read --demo` prints the same synthetic scans as text, no browser needed.)
170+
156171
Then open `http://<your-pi>:8080`. Points are coloured by signal strength and the
157172
range ring auto-scales to the room; the HUD shows the live scan rate and point
158173
count. Under the hood it's a background reader thread feeding a thread-safe

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "lds2d"
7-
version = "0.5.0"
7+
version = "0.6.0"
88
description = "Python driver for 2D LiDARs (LDROBOT, YDLIDAR, RPLIDAR, 3irobotix, Neato/Xiaomi, Camsense, Hitachi-LG) — a Pythonic port of kaiaai/LDS"
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/lds2d/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717
from . import drivers # noqa: F401,E402 — register bundled drivers on import
1818

19-
__version__ = "0.5.0"
19+
__version__ = "0.6.0"
2020

2121
__all__ = [
2222
"Lidar",

src/lds2d/cli.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515

1616
def _open(args):
1717
"""Open the LiDAR, forwarding PWM settings only for host-driven-motor models."""
18+
if getattr(args, "demo", False):
19+
from .demo import DemoLidar # synthetic scene, no hardware
20+
return DemoLidar()
1821
cls = driver_for(args.model)
1922
kwargs = {}
2023
if cls is not None and getattr(cls, "NEEDS_MOTOR", False):
@@ -25,8 +28,11 @@ def _open(args):
2528

2629
def _cmd_read(args) -> int:
2730
lidar = _open(args)
28-
print(f"{lidar.MODEL_NAME}: reading {args.port} @ {args.baud or lidar.DEFAULT_BAUD} "
29-
f"baud (Ctrl-C to stop)", file=sys.stderr)
31+
if getattr(args, "demo", False):
32+
print("Demo: synthesizing scans — no hardware (Ctrl-C to stop)", file=sys.stderr)
33+
else:
34+
print(f"{lidar.MODEL_NAME}: reading {args.port} @ {args.baud or lidar.DEFAULT_BAUD} "
35+
f"baud (Ctrl-C to stop)", file=sys.stderr)
3036
try:
3137
if args.raw:
3238
print(f"{'angle':>7} {'dist_mm':>7} {'quality':>7}")
@@ -54,7 +60,8 @@ def _cmd_viz(args) -> int:
5460
from .viz import serve
5561
lidar = _open(args)
5662
shown = "localhost" if args.host in ("0.0.0.0", "") else args.host
57-
print(f"{lidar.MODEL_NAME}: live plot at http://{shown}:{args.port} "
63+
tag = "Demo (synthetic)" if getattr(args, "demo", False) else lidar.MODEL_NAME
64+
print(f"{tag}: live plot at http://{shown}:{args.port} "
5865
f"(Ctrl-C to stop)", file=sys.stderr)
5966
try:
6067
serve(lidar, host=args.host, port=args.port)
@@ -112,13 +119,17 @@ def build_parser() -> argparse.ArgumentParser:
112119
ap.add_argument("--pwm-freq", type=int, default=10000, help="PWM frequency (Hz)")
113120
sub = ap.add_subparsers(dest="cmd", required=True)
114121

122+
_demo_help = "stream a synthetic moving scene — no hardware needed"
123+
115124
r = sub.add_parser("read", help="print live scan data")
116125
r.add_argument("--raw", action="store_true", help="one line per measurement")
126+
r.add_argument("--demo", action="store_true", help=_demo_help)
117127
r.set_defaults(func=_cmd_read)
118128

119129
v = sub.add_parser("viz", help="live polar plot in your browser")
120130
v.add_argument("--host", default="0.0.0.0", help="bind address (default all interfaces)")
121131
v.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080)")
132+
v.add_argument("--demo", action="store_true", help=_demo_help)
122133
v.set_defaults(func=_cmd_viz)
123134

124135
m = sub.add_parser("motor", help="control the motor (command-driven models)")

src/lds2d/demo.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright 2026 KAIA.AI
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License").
4+
"""A synthetic LiDAR so you can try lds2d — and especially ``lds2d viz`` — with
5+
no hardware at all.
6+
7+
``DemoLidar`` ray-casts a small 2D scene (four walls, a couple of pillars, a desk,
8+
and a person pacing back and forth) into a full 360° scan, frame by frame, so the
9+
browser radar shows a recognisable room that *moves*. It speaks the exact same
10+
``scans()`` / ``points()`` API as a real driver, needs no serial port or GPIO, and
11+
is deterministic given a seed.
12+
13+
from lds2d.demo import DemoLidar
14+
from lds2d.viz import serve
15+
serve(DemoLidar()) # http://localhost:8080
16+
17+
On the CLI it's just a flag: ``lds2d viz --demo`` / ``lds2d read --demo``.
18+
"""
19+
from __future__ import annotations
20+
21+
import math
22+
import random
23+
import time
24+
from typing import List, Optional
25+
26+
from .core import LidarDriver, ScanPoint
27+
28+
# --- the scene, in millimetres; the LiDAR sits at the origin -----------------
29+
# A closed rectangular room (the LiDAR is inside it, off-centre), a desk, two
30+
# pillars, plus a person who walks — added per frame in ``_moving_person``.
31+
32+
def _rect(x0: float, y0: float, x1: float, y1: float):
33+
"""Four wall segments of an axis-aligned rectangle."""
34+
return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)),
35+
((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
36+
37+
38+
_WALLS = _rect(-2200, -2400, 3000, 1600) # the room
39+
_DESK = _rect(1000, 200, 2000, 800) # a desk block
40+
_SEGMENTS = _WALLS + _DESK
41+
_PILLARS = [(-1300, -700, 230), (2200, -1400, 170)] # (cx, cy, radius)
42+
43+
_MAX_RANGE_MM = 6000
44+
45+
46+
def _ray_segment(dx: float, dy: float, seg) -> Optional[float]:
47+
"""Distance from the origin along unit dir (dx,dy) to a segment, or None."""
48+
(ax, ay), (bx, by) = seg
49+
ex, ey = bx - ax, by - ay
50+
det = ex * dy - dx * ey
51+
if abs(det) < 1e-9: # ray parallel to the segment
52+
return None
53+
t = (ex * ay - ey * ax) / det # distance along the ray
54+
s = (dx * ay - dy * ax) / det # position along the segment, 0..1
55+
if t > 0 and 0.0 <= s <= 1.0:
56+
return t
57+
return None
58+
59+
60+
def _ray_circle(dx: float, dy: float, cx: float, cy: float, r: float) -> Optional[float]:
61+
"""Distance from the origin along unit dir (dx,dy) to a circle, or None."""
62+
b = -2.0 * (dx * cx + dy * cy)
63+
c = cx * cx + cy * cy - r * r
64+
disc = b * b - 4.0 * c
65+
if disc < 0.0:
66+
return None
67+
sq = math.sqrt(disc)
68+
t = (-b - sq) / 2.0
69+
if t > 0.0:
70+
return t
71+
t = (-b + sq) / 2.0
72+
return t if t > 0.0 else None
73+
74+
75+
class DemoLidar(LidarDriver):
76+
"""A hardware-free LiDAR that streams a synthetic, animated 2D scene."""
77+
78+
MODEL_NAME = "Demo (synthetic scene)"
79+
NEEDS_TRANSPORT = False
80+
81+
def __init__(self, rate_hz: float = 5.0, points_per_scan: int = 360, seed: int = 1):
82+
super().__init__(transport=None) # no serial port
83+
self.rate_hz = rate_hz
84+
self.points_per_scan = points_per_scan
85+
self._seed = seed
86+
self._frame = 0
87+
88+
# -- the moving actor --
89+
@staticmethod
90+
def _moving_person(frame: int):
91+
"""(cx, cy, radius) of the pacing person for this frame."""
92+
phase = frame * 0.07
93+
cx = -200.0 + 1400.0 * (0.5 + 0.5 * math.sin(phase))
94+
cy = -1700.0 + 250.0 * math.sin(phase * 1.6)
95+
return (cx, cy, 200.0)
96+
97+
def render_scan(self, frame: int) -> List[ScanPoint]:
98+
"""Ray-cast one full 360° scan. Deterministic for a given (seed, frame)."""
99+
rng = random.Random(self._seed * 1_000_003 + frame)
100+
circles = _PILLARS + [self._moving_person(frame)]
101+
pts: List[ScanPoint] = []
102+
for i in range(self.points_per_scan):
103+
angle = i * 360.0 / self.points_per_scan
104+
rad = math.radians(angle)
105+
dx, dy = math.sin(rad), math.cos(rad) # 0° points +y ("up")
106+
best = None
107+
for seg in _SEGMENTS:
108+
t = _ray_segment(dx, dy, seg)
109+
if t is not None and (best is None or t < best):
110+
best = t
111+
for (cx, cy, r) in circles:
112+
t = _ray_circle(dx, dy, cx, cy, r)
113+
if t is not None and (best is None or t < best):
114+
best = t
115+
# occasional dropout, like a real sensor missing a return
116+
if best is None or best > _MAX_RANGE_MM or rng.random() < 0.012:
117+
pts.append(ScanPoint(angle, 0, 0))
118+
continue
119+
dist = best * (1.0 + rng.uniform(-0.004, 0.004)) # ranging noise
120+
dist_mm = int(round(dist))
121+
quality = max(8, min(255, int(260 - dist_mm / 22) + rng.randint(-12, 12)))
122+
pts.append(ScanPoint(angle, dist_mm, quality))
123+
return pts
124+
125+
# -- the lds2d driver interface --
126+
def _packets(self):
127+
while True:
128+
yield self.rate_hz, self.render_scan(self._frame)
129+
self._frame += 1
130+
if self.rate_hz > 0:
131+
time.sleep(1.0 / self.rate_hz) # pace like real hardware
132+
133+
def get_scan_freq(self, listen_s: float = 1.0) -> Optional[float]:
134+
return self.rate_hz
135+
136+
def close(self) -> None: # no transport to close
137+
pass

tests/test_demo.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for the synthetic DemoLidar: it needs no hardware, produces a full,
2+
plausible scan, animates between frames, and speaks the normal driver API."""
3+
from itertools import islice
4+
5+
from lds2d import ScanPoint
6+
from lds2d.demo import DemoLidar
7+
8+
9+
def test_render_scan_shape_and_ranges():
10+
pts = DemoLidar(seed=1).render_scan(0)
11+
assert len(pts) == 360
12+
assert all(isinstance(p, ScanPoint) for p in pts)
13+
# angles march 0..359 in order
14+
assert [round(p.angle_deg) for p in pts] == list(range(360))
15+
valid = [p for p in pts if p.valid]
16+
assert len(valid) > 320 # mostly returns, a few dropouts
17+
assert all(0 < p.dist_mm <= 6000 for p in valid) # inside the synthetic room
18+
assert all(0 <= p.quality <= 255 for p in valid)
19+
20+
21+
def test_deterministic_for_seed_and_frame():
22+
a = DemoLidar(seed=7).render_scan(3)
23+
b = DemoLidar(seed=7).render_scan(3)
24+
assert [(p.dist_mm, p.quality) for p in a] == [(p.dist_mm, p.quality) for p in b]
25+
26+
27+
def test_scene_animates_between_frames():
28+
d = DemoLidar(seed=1)
29+
moved = sum(1 for x, y in zip(d.render_scan(0), d.render_scan(40))
30+
if x.dist_mm != y.dist_mm)
31+
assert moved > 0 # the walking person (and noise) move
32+
33+
34+
def test_scans_yields_a_full_rotation():
35+
# rate_hz=0 disables the inter-scan sleep so the test is instant.
36+
lidar = DemoLidar(rate_hz=0)
37+
scan = next(islice(lidar.scans(), 1))
38+
assert len(scan) == 360
39+
assert len(scan.valid_points) > 320
40+
41+
42+
def test_points_flat_stream():
43+
lidar = DemoLidar(rate_hz=0)
44+
pts = list(islice(lidar.points(), 500))
45+
assert len(pts) == 500
46+
assert all(isinstance(p, ScanPoint) for p in pts)
47+
48+
49+
def test_driver_api_needs_no_hardware():
50+
lidar = DemoLidar() # no port, no transport
51+
assert lidar.NEEDS_TRANSPORT is False
52+
assert lidar.get_scan_freq() == 5.0
53+
lidar.close() # must not raise (no transport)
54+
55+
56+
def test_context_manager():
57+
with DemoLidar(rate_hz=0) as lidar:
58+
assert next(islice(lidar.scans(), 1)) is not None

0 commit comments

Comments
 (0)