|
| 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 |
0 commit comments