sonar-vision 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sonar_vision-0.1.0/LICENSE +21 -0
- sonar_vision-0.1.0/PKG-INFO +10 -0
- sonar_vision-0.1.0/README.md +172 -0
- sonar_vision-0.1.0/pyproject.toml +16 -0
- sonar_vision-0.1.0/setup.cfg +4 -0
- sonar_vision-0.1.0/sonar_vision/__init__.py +22 -0
- sonar_vision-0.1.0/sonar_vision/display.py +165 -0
- sonar_vision-0.1.0/sonar_vision/map.py +230 -0
- sonar_vision-0.1.0/sonar_vision/signal.py +206 -0
- sonar_vision-0.1.0/sonar_vision/sonar.py +173 -0
- sonar_vision-0.1.0/sonar_vision/tracker.py +227 -0
- sonar_vision-0.1.0/sonar_vision.egg-info/PKG-INFO +10 -0
- sonar_vision-0.1.0/sonar_vision.egg-info/SOURCES.txt +16 -0
- sonar_vision-0.1.0/sonar_vision.egg-info/dependency_links.txt +1 -0
- sonar_vision-0.1.0/sonar_vision.egg-info/requires.txt +3 -0
- sonar_vision-0.1.0/sonar_vision.egg-info/top_level.txt +1 -0
- sonar_vision-0.1.0/tests/test_all.py +515 -0
- sonar_vision-0.1.0/tests/test_simulation_scenarios.py +277 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 SuperInstance Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sonar-vision
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python sonar ping/echo simulation, signal processing, multi-object tracking, and spatial mapping toolkit
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
10
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# sonar-vision
|
|
2
|
+
|
|
3
|
+
Pure-Python toolkit for simulating active sonar pings and echoes, processing signals, tracking objects, and building 2-D occupancy maps.
|
|
4
|
+
|
|
5
|
+
> **Documentation:** [`PLUG_AND_PLAY.md`](./PLUG_AND_PLAY.md) · [`GETTING_STARTED.md`](./GETTING_STARTED.md) · [`ARCHITECTURE.md`](./ARCHITECTURE.md) · [`API_REFERENCE.md`](./API_REFERENCE.md) · [`LOW_LEVEL.md`](./LOW_LEVEL.md)
|
|
6
|
+
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](https://www.python.org/)
|
|
9
|
+
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
Install from source into a virtual environment:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
git clone https://github.com/purplepincher/sonar-vision.git
|
|
16
|
+
cd sonar-vision
|
|
17
|
+
python3 -m venv .venv
|
|
18
|
+
source .venv/bin/activate
|
|
19
|
+
pip install -e ".[dev]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run the tests:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pytest tests/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Simulate a sonar ping
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from sonar_vision import Sonar
|
|
34
|
+
|
|
35
|
+
sonar = Sonar(frequency=50000, max_range=500)
|
|
36
|
+
result = sonar.ping(distance=100)
|
|
37
|
+
print(f"Distance: {result.distance:.1f}m, SNR: {result.snr_db:.1f} dB")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Output:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
Distance: 99.1m, SNR: 38.0 dB
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Track objects across detections
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sonar_vision import ObjectTracker, Detection
|
|
50
|
+
|
|
51
|
+
tracker = ObjectTracker()
|
|
52
|
+
tracker.update([Detection(x=10, y=20)])
|
|
53
|
+
tracker.update([Detection(x=12, y=22)])
|
|
54
|
+
tracker.update([Detection(x=14, y=19)])
|
|
55
|
+
|
|
56
|
+
print(f"Tracks: {len(tracker.tracks)}")
|
|
57
|
+
for t in tracker.tracks:
|
|
58
|
+
print(f" track {t.track_id}: ({t.x}, {t.y}), "
|
|
59
|
+
f"speed {tracker.speed(t.track_id):.2f} m/s")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Output:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
Tracks: 1
|
|
66
|
+
track 1: (14, 19), speed 5.13 m/s
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Generate and analyse a signal
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from sonar_vision import Signal
|
|
73
|
+
|
|
74
|
+
chirp = Signal.chirp(f0=1000, f1=5000, duration=0.01, sample_rate=44100)
|
|
75
|
+
print(f"Chirp samples: {chirp.n_samples}, "
|
|
76
|
+
f"dominant freq: {chirp.dominant_frequency():.0f} Hz")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Output:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
Chirp samples: 441, dominant freq: 2950 Hz
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Build an occupancy map
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from sonar_vision import SpatialMap, Obstacle
|
|
89
|
+
|
|
90
|
+
m = SpatialMap(width=20, height=20, resolution=1.0)
|
|
91
|
+
m.add_obstacle(Obstacle(x=5, y=5, radius=1.0))
|
|
92
|
+
print(f"Occupied at (5,5): {m.is_occupied(5, 5)}")
|
|
93
|
+
print(f"Coverage: {m.coverage():.2%}")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Output:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
Occupied at (5,5): True
|
|
100
|
+
Coverage: 1.00%
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
- **`Sonar`** models a single-beam active sonar. It computes round-trip time, applies two-way spreading and configurable absorption loss, adds target strength, and converts the resulting SNR into a detection probability and a noisy distance estimate.
|
|
106
|
+
- **`Signal`** provides discrete-time signal primitives: sine, noise, chirp, resampling, moving-average lowpass/highpass/bandpass filtering, thresholding, envelope, RMS/peak/energy metrics, and a naïve DFT magnitude.
|
|
107
|
+
- **`ObjectTracker`** uses greedy nearest-neighbour association with a constant-velocity prediction gate. Each track stores position and exponentially smoothed velocity; tracks time out after a configurable interval without updates.
|
|
108
|
+
- **`SpatialMap`** maintains a 2-D occupancy grid centred at the origin. Obstacles mark cells as occupied, free-space rays can mark cells as free, and ray casting returns the first occupied cell along a bearing.
|
|
109
|
+
|
|
110
|
+
## Configuration and options
|
|
111
|
+
|
|
112
|
+
### `Sonar`
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
Sonar(
|
|
116
|
+
sound_speed=1500.0, # m/s
|
|
117
|
+
frequency=50000.0, # Hz
|
|
118
|
+
pulse_duration=0.001, # seconds
|
|
119
|
+
max_range=1000.0, # meters
|
|
120
|
+
beam_width=30.0, # degrees
|
|
121
|
+
source_level=200.0, # arbitrary dB
|
|
122
|
+
noise_level=60.0, # arbitrary dB
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Key methods: `ping(distance, target_strength=-20.0)`, `ping_return_signal(...)`, `round_trip_time(distance)`, `spreading_loss(distance)`, `absorption_loss(distance, absorption_db_km=10.0)`, `in_beam(bearing_offset_deg)`.
|
|
127
|
+
|
|
128
|
+
### `ObjectTracker`
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
ObjectTracker(
|
|
132
|
+
association_gate=5.0, # meters
|
|
133
|
+
max_velocity=10.0, # m/s
|
|
134
|
+
lost_timeout=5.0, # seconds
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Key methods: `update(detections)`, `predict(track_id, dt)`, `active_tracks()`, `prune_lost()`, `speed(track_id)`, `heading(track_id)`.
|
|
139
|
+
|
|
140
|
+
### `SpatialMap`
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
SpatialMap(
|
|
144
|
+
width=100.0, # meters
|
|
145
|
+
height=100.0, # meters
|
|
146
|
+
resolution=1.0, # meters per cell
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Key methods: `add_obstacle(Obstacle(...))`, `get_cell(x, y)`, `set_cell(x, y, state)`, `ray_cast(ox, oy, angle_deg, max_dist)`, `mark_free_ray(...)`, `occupancy_count()`, `coverage()`.
|
|
151
|
+
|
|
152
|
+
See [`API_REFERENCE.md`](./API_REFERENCE.md) for the full API.
|
|
153
|
+
|
|
154
|
+
## Limitations
|
|
155
|
+
|
|
156
|
+
- **Simulation only.** No hydrophone, audio device, or NMEA interface support.
|
|
157
|
+
- **Single-beam sonar.** No beamforming, phased arrays, or multi-beam reconstruction.
|
|
158
|
+
- **Simplified acoustics.** Propagation uses geometric spreading plus a constant dB/km absorption term; it is not a full ocean-acoustics solver.
|
|
159
|
+
- **No image or video processing.** The toolkit works with 1-D signals and 2-D positions, not camera frames.
|
|
160
|
+
- **Not on PyPI.** Install from source for now.
|
|
161
|
+
|
|
162
|
+
## Testing
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
pytest tests/
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The suite covers unit tests (`tests/test_all.py`) and end-to-end simulation scenarios (`tests/test_simulation_scenarios.py`); all pass under Python 3.10+.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT. See [`LICENSE`](./LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonar-vision"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pure-Python sonar ping/echo simulation, signal processing, multi-object tracking, and spatial mapping toolkit"
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = ["pytest>=7"]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
include = ["sonar_vision*"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Sonar Vision — signal processing and spatial awareness for agents."""
|
|
2
|
+
|
|
3
|
+
from sonar_vision.signal import Signal
|
|
4
|
+
from sonar_vision.sonar import Sonar, PingResult, SOUND_SPEED_WATER
|
|
5
|
+
from sonar_vision.map import SpatialMap, Obstacle, CellState
|
|
6
|
+
from sonar_vision.tracker import ObjectTracker, Track, Detection
|
|
7
|
+
from sonar_vision.display import SonarDisplay
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Signal",
|
|
11
|
+
"Sonar",
|
|
12
|
+
"PingResult",
|
|
13
|
+
"SOUND_SPEED_WATER",
|
|
14
|
+
"SpatialMap",
|
|
15
|
+
"Obstacle",
|
|
16
|
+
"CellState",
|
|
17
|
+
"ObjectTracker",
|
|
18
|
+
"Track",
|
|
19
|
+
"Detection",
|
|
20
|
+
"SonarDisplay",
|
|
21
|
+
]
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""SonarDisplay — ASCII radar rendering for sonar data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from sonar_vision.map import SpatialMap, CellState, Obstacle
|
|
10
|
+
from sonar_vision.tracker import ObjectTracker, Track
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ASCII intensity ramp for signal strength / proximity
|
|
14
|
+
_RAMP = " .:-=+*#%@"
|
|
15
|
+
_RAMP_LEN = len(_RAMP)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SonarDisplay:
|
|
20
|
+
"""ASCII-based sonar radar display.
|
|
21
|
+
|
|
22
|
+
Renders a top-down polar or Cartesian view using characters.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
width: Display width in characters.
|
|
26
|
+
height: Display height in characters.
|
|
27
|
+
range_m: Maximum display range in meters.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
width: int = 41
|
|
31
|
+
height: int = 21
|
|
32
|
+
range_m: float = 50.0
|
|
33
|
+
|
|
34
|
+
# ── radar sweep ───────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def render_sweep(
|
|
37
|
+
self,
|
|
38
|
+
bearing_angles: list[float],
|
|
39
|
+
distances: list[float],
|
|
40
|
+
max_range: float | None = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Render a radar sweep from bearing/distance readings.
|
|
43
|
+
|
|
44
|
+
Each (bearing, distance) pair places a blip on the display.
|
|
45
|
+
"""
|
|
46
|
+
rng = max_range or self.range_m
|
|
47
|
+
grid: list[list[str]] = [
|
|
48
|
+
[" " for _ in range(self.width)] for _ in range(self.height)
|
|
49
|
+
]
|
|
50
|
+
cx = self.width // 2
|
|
51
|
+
cy = self.height // 2
|
|
52
|
+
|
|
53
|
+
# Draw crosshairs
|
|
54
|
+
for x in range(self.width):
|
|
55
|
+
grid[cy][x] = "·" if grid[cy][x] == " " else grid[cy][x]
|
|
56
|
+
for y in range(self.height):
|
|
57
|
+
grid[y][cx] = "·" if grid[y][cx] == " " else grid[y][cx]
|
|
58
|
+
|
|
59
|
+
# Range rings (25%, 50%, 75%)
|
|
60
|
+
for frac in (0.25, 0.5, 0.75):
|
|
61
|
+
r_cells = int(frac * min(cx, cy))
|
|
62
|
+
for angle_deg in range(0, 360, 5):
|
|
63
|
+
ar = math.radians(angle_deg)
|
|
64
|
+
px = cx + int(r_cells * math.cos(ar))
|
|
65
|
+
py = cy - int(r_cells * math.sin(ar))
|
|
66
|
+
if 0 <= px < self.width and 0 <= py < self.height:
|
|
67
|
+
grid[py][px] = "·"
|
|
68
|
+
|
|
69
|
+
# Centre marker (draw after range rings to avoid overwrite)
|
|
70
|
+
grid[cy][cx] = "+"
|
|
71
|
+
|
|
72
|
+
# Plot detections
|
|
73
|
+
for bearing, dist in zip(bearing_angles, distances):
|
|
74
|
+
if dist > rng or dist <= 0:
|
|
75
|
+
continue
|
|
76
|
+
ar = math.radians(bearing)
|
|
77
|
+
r_norm = dist / rng
|
|
78
|
+
px = cx + int(r_norm * cx * math.cos(ar))
|
|
79
|
+
py = cy - int(r_norm * cy * math.sin(ar))
|
|
80
|
+
if 0 <= px < self.width and 0 <= py < self.height:
|
|
81
|
+
intensity = int((1.0 - r_norm) * (_RAMP_LEN - 1))
|
|
82
|
+
intensity = max(0, min(_RAMP_LEN - 1, intensity))
|
|
83
|
+
grid[py][px] = _RAMP[intensity]
|
|
84
|
+
|
|
85
|
+
lines = ["".join(row) for row in grid]
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
# ── occupancy map ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def render_map(self, smap: SpatialMap) -> str:
|
|
91
|
+
"""Render a SpatialMap as ASCII."""
|
|
92
|
+
symbols = {CellState.UNKNOWN: " ", CellState.FREE: ".", CellState.OCCUPIED: "#"}
|
|
93
|
+
# Downsample map to display size
|
|
94
|
+
row_scale = smap.rows / self.height
|
|
95
|
+
col_scale = smap.cols / self.width
|
|
96
|
+
|
|
97
|
+
lines: list[str] = []
|
|
98
|
+
for dr in range(self.height):
|
|
99
|
+
row_start = int(dr * row_scale)
|
|
100
|
+
row_end = min(smap.rows, int((dr + 1) * row_scale))
|
|
101
|
+
line_chars: list[str] = []
|
|
102
|
+
for dc in range(self.width):
|
|
103
|
+
col_start = int(dc * col_scale)
|
|
104
|
+
col_end = min(smap.cols, int((dc + 1) * col_scale))
|
|
105
|
+
# Pick most interesting cell in block
|
|
106
|
+
cell = CellState.UNKNOWN
|
|
107
|
+
for r in range(row_start, row_end):
|
|
108
|
+
for c in range(col_start, col_end):
|
|
109
|
+
s = smap._grid[r][c]
|
|
110
|
+
if s == CellState.OCCUPIED:
|
|
111
|
+
cell = s
|
|
112
|
+
elif s == CellState.FREE and cell == CellState.UNKNOWN:
|
|
113
|
+
cell = s
|
|
114
|
+
line_chars.append(symbols[cell])
|
|
115
|
+
lines.append("".join(line_chars))
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
# ── tracker overlay ───────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def render_tracks(
|
|
121
|
+
self,
|
|
122
|
+
tracker: ObjectTracker,
|
|
123
|
+
cx_m: float = 0.0,
|
|
124
|
+
cy_m: float = 0.0,
|
|
125
|
+
now: float | None = None,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Render active tracks relative to a centre point."""
|
|
128
|
+
grid: list[list[str]] = [
|
|
129
|
+
[" " for _ in range(self.width)] for _ in range(self.height)
|
|
130
|
+
]
|
|
131
|
+
cx = self.width // 2
|
|
132
|
+
cy = self.height // 2
|
|
133
|
+
|
|
134
|
+
# Crosshairs
|
|
135
|
+
for x in range(self.width):
|
|
136
|
+
grid[cy][x] = "·"
|
|
137
|
+
for y in range(self.height):
|
|
138
|
+
grid[y][cx] = "·"
|
|
139
|
+
grid[cy][cx] = "+"
|
|
140
|
+
|
|
141
|
+
active = tracker.active_tracks(now)
|
|
142
|
+
for t in active:
|
|
143
|
+
dx = t.x - cx_m
|
|
144
|
+
dy = t.y - cy_m
|
|
145
|
+
px = cx + int(dx / self.range_m * cx)
|
|
146
|
+
py = cy - int(dy / self.range_m * cy)
|
|
147
|
+
if 0 <= px < self.width and 0 <= py < self.height:
|
|
148
|
+
# Draw track marker and velocity vector
|
|
149
|
+
marker = str(t.track_id % 10) if t.track_id < 10 else "*"
|
|
150
|
+
grid[py][px] = marker
|
|
151
|
+
# Velocity indicator
|
|
152
|
+
vscale = self.range_m / (tracker.max_velocity or 1.0) * 2
|
|
153
|
+
vx_px = int(t.vx * vscale / self.range_m * cx)
|
|
154
|
+
vy_px = int(t.vy * vscale / self.range_m * cy)
|
|
155
|
+
ex = px + vx_px
|
|
156
|
+
ey = py - vy_px
|
|
157
|
+
# Simple line
|
|
158
|
+
steps = max(abs(ex - px), abs(ey - py), 1)
|
|
159
|
+
for i in range(1, steps + 1):
|
|
160
|
+
lx = px + (ex - px) * i // steps
|
|
161
|
+
ly = py + (ey - py) * i // steps
|
|
162
|
+
if 0 <= lx < self.width and 0 <= ly < self.height and grid[ly][lx] == " ":
|
|
163
|
+
grid[ly][lx] = "~"
|
|
164
|
+
|
|
165
|
+
return "\n".join("".join(row) for row in grid)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""SpatialMap — obstacle detection, mapping, and spatial queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CellState(Enum):
|
|
12
|
+
"""State of a grid cell."""
|
|
13
|
+
UNKNOWN = 0
|
|
14
|
+
FREE = 1
|
|
15
|
+
OCCUPIED = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Obstacle:
|
|
20
|
+
"""Detected obstacle.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
x: X-coordinate in meters.
|
|
24
|
+
y: Y-coordinate in meters.
|
|
25
|
+
radius: Approximate radius in meters (0 for point obstacles).
|
|
26
|
+
confidence: Detection confidence (0–1).
|
|
27
|
+
label: Optional label.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
x: float
|
|
31
|
+
y: float
|
|
32
|
+
radius: float = 0.0
|
|
33
|
+
confidence: float = 1.0
|
|
34
|
+
label: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SpatialMap:
|
|
39
|
+
"""2-D occupancy grid map with obstacle management.
|
|
40
|
+
|
|
41
|
+
The map covers a rectangular region centred at the origin.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
width: Map width in meters.
|
|
45
|
+
height: Map height in meters.
|
|
46
|
+
resolution: Cell size in meters.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
width: float = 100.0
|
|
50
|
+
height: float = 100.0
|
|
51
|
+
resolution: float = 1.0
|
|
52
|
+
_obstacles: list[Obstacle] = field(default_factory=list)
|
|
53
|
+
_grid: list[list[CellState]] = field(init=False, repr=False)
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
cols = max(1, int(self.width / self.resolution))
|
|
57
|
+
rows = max(1, int(self.height / self.resolution))
|
|
58
|
+
self._grid = [[CellState.UNKNOWN for _ in range(cols)] for _ in range(rows)]
|
|
59
|
+
|
|
60
|
+
# ── grid helpers ──────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def cols(self) -> int:
|
|
64
|
+
return len(self._grid[0]) if self._grid else 0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def rows(self) -> int:
|
|
68
|
+
return len(self._grid)
|
|
69
|
+
|
|
70
|
+
def _world_to_cell(self, x: float, y: float) -> tuple[int, int]:
|
|
71
|
+
"""Convert world coords to cell (row, col)."""
|
|
72
|
+
col = int((x + self.width / 2.0) / self.resolution)
|
|
73
|
+
row = int((y + self.height / 2.0) / self.resolution)
|
|
74
|
+
return row, col
|
|
75
|
+
|
|
76
|
+
def _cell_to_world(self, row: int, col: int) -> tuple[float, float]:
|
|
77
|
+
"""Convert cell (row, col) to world coords (centre of cell)."""
|
|
78
|
+
x = (col + 0.5) * self.resolution - self.width / 2.0
|
|
79
|
+
y = (row + 0.5) * self.resolution - self.height / 2.0
|
|
80
|
+
return x, y
|
|
81
|
+
|
|
82
|
+
def _in_bounds(self, row: int, col: int) -> bool:
|
|
83
|
+
return 0 <= row < self.rows and 0 <= col < self.cols
|
|
84
|
+
|
|
85
|
+
# ── cell access ───────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
def get_cell(self, x: float, y: float) -> CellState:
|
|
88
|
+
"""Get the state of the cell at world coordinates (x, y)."""
|
|
89
|
+
row, col = self._world_to_cell(x, y)
|
|
90
|
+
if not self._in_bounds(row, col):
|
|
91
|
+
return CellState.UNKNOWN
|
|
92
|
+
return self._grid[row][col]
|
|
93
|
+
|
|
94
|
+
def set_cell(self, x: float, y: float, state: CellState) -> None:
|
|
95
|
+
"""Set the state of the cell at world coordinates (x, y)."""
|
|
96
|
+
row, col = self._world_to_cell(x, y)
|
|
97
|
+
if self._in_bounds(row, col):
|
|
98
|
+
self._grid[row][col] = state
|
|
99
|
+
|
|
100
|
+
# ── obstacles ─────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def add_obstacle(self, obstacle: Obstacle) -> None:
|
|
103
|
+
"""Add an obstacle and mark its cells as occupied."""
|
|
104
|
+
self._obstacles.append(obstacle)
|
|
105
|
+
# Mark cells within the obstacle radius (at least the center cell)
|
|
106
|
+
cx, cy = obstacle.x, obstacle.y
|
|
107
|
+
r_cells = max(1, int(math.ceil(obstacle.radius / self.resolution))) if obstacle.radius > 0 else 0
|
|
108
|
+
center_row, center_col = self._world_to_cell(cx, cy)
|
|
109
|
+
# Always mark the center cell
|
|
110
|
+
if self._in_bounds(center_row, center_col):
|
|
111
|
+
self._grid[center_row][center_col] = CellState.OCCUPIED
|
|
112
|
+
for dr in range(-r_cells, r_cells + 1):
|
|
113
|
+
for dc in range(-r_cells, r_cells + 1):
|
|
114
|
+
nr, nc = center_row + dr, center_col + dc
|
|
115
|
+
if self._in_bounds(nr, nc):
|
|
116
|
+
wx, wy = self._cell_to_world(nr, nc)
|
|
117
|
+
if obstacle.radius <= 0 or math.hypot(wx - cx, wy - cy) <= obstacle.radius:
|
|
118
|
+
self._grid[nr][nc] = CellState.OCCUPIED
|
|
119
|
+
|
|
120
|
+
def remove_obstacle(self, index: int) -> None:
|
|
121
|
+
"""Remove obstacle by index and clear its cells."""
|
|
122
|
+
if 0 <= index < len(self._obstacles):
|
|
123
|
+
obs = self._obstacles.pop(index)
|
|
124
|
+
self._mark_area_free(obs)
|
|
125
|
+
|
|
126
|
+
def _mark_area_free(self, obs: Obstacle) -> None:
|
|
127
|
+
r_cells = max(1, int(math.ceil(obs.radius / self.resolution))) if obs.radius > 0 else 1
|
|
128
|
+
center_row, center_col = self._world_to_cell(obs.x, obs.y)
|
|
129
|
+
for dr in range(-r_cells, r_cells + 1):
|
|
130
|
+
for dc in range(-r_cells, r_cells + 1):
|
|
131
|
+
nr, nc = center_row + dr, center_col + dc
|
|
132
|
+
if self._in_bounds(nr, nc):
|
|
133
|
+
wx, wy = self._cell_to_world(nr, nc)
|
|
134
|
+
if obs.radius <= 0 or math.hypot(wx - obs.x, wy - obs.y) <= obs.radius:
|
|
135
|
+
self._grid[nr][nc] = CellState.FREE
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def obstacles(self) -> list[Obstacle]:
|
|
139
|
+
return list(self._obstacles)
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
"""Clear all obstacles and reset grid."""
|
|
143
|
+
self._obstacles.clear()
|
|
144
|
+
for r in range(self.rows):
|
|
145
|
+
for c in range(self.cols):
|
|
146
|
+
self._grid[r][c] = CellState.UNKNOWN
|
|
147
|
+
|
|
148
|
+
# ── queries ───────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def is_occupied(self, x: float, y: float) -> bool:
|
|
151
|
+
"""Check if position (x, y) is occupied."""
|
|
152
|
+
return self.get_cell(x, y) == CellState.OCCUPIED
|
|
153
|
+
|
|
154
|
+
def is_free(self, x: float, y: float) -> bool:
|
|
155
|
+
"""Check if position (x, y) is known free."""
|
|
156
|
+
return self.get_cell(x, y) == CellState.FREE
|
|
157
|
+
|
|
158
|
+
def nearest_obstacle(self, x: float, y: float) -> Optional[Obstacle]:
|
|
159
|
+
"""Return the nearest obstacle to (x, y), or None."""
|
|
160
|
+
if not self._obstacles:
|
|
161
|
+
return None
|
|
162
|
+
return min(self._obstacles, key=lambda o: math.hypot(o.x - x, o.y - y))
|
|
163
|
+
|
|
164
|
+
def obstacles_in_radius(self, x: float, y: float, radius: float) -> list[Obstacle]:
|
|
165
|
+
"""Return obstacles within *radius* meters of (x, y)."""
|
|
166
|
+
return [o for o in self._obstacles if math.hypot(o.x - x, o.y - y) <= radius]
|
|
167
|
+
|
|
168
|
+
def distance_to_nearest(self, x: float, y: float) -> float:
|
|
169
|
+
"""Distance to the nearest occupied cell or obstacle."""
|
|
170
|
+
obs = self.nearest_obstacle(x, y)
|
|
171
|
+
if obs is None:
|
|
172
|
+
return float("inf")
|
|
173
|
+
return math.hypot(obs.x - x, obs.y - y)
|
|
174
|
+
|
|
175
|
+
# ── ray casting ───────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def ray_cast(self, ox: float, oy: float, angle_deg: float, max_dist: float) -> Optional[tuple[float, float]]:
|
|
178
|
+
"""Cast a ray from (ox, oy) at *angle_deg* and return first hit (x, y) or None.
|
|
179
|
+
|
|
180
|
+
Uses DDA-style stepping at cell resolution.
|
|
181
|
+
"""
|
|
182
|
+
angle_rad = math.radians(angle_deg)
|
|
183
|
+
dx = math.cos(angle_rad) * self.resolution * 0.5
|
|
184
|
+
dy = math.sin(angle_rad) * self.resolution * 0.5
|
|
185
|
+
steps = int(max_dist / (self.resolution * 0.5))
|
|
186
|
+
cx, cy = ox, oy
|
|
187
|
+
for _ in range(steps):
|
|
188
|
+
if self.is_occupied(cx, cy):
|
|
189
|
+
return (cx, cy)
|
|
190
|
+
# Also check out of bounds
|
|
191
|
+
row, col = self._world_to_cell(cx, cy)
|
|
192
|
+
if not self._in_bounds(row, col):
|
|
193
|
+
return None
|
|
194
|
+
cx += dx
|
|
195
|
+
cy += dy
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
# ── statistics ────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def occupancy_count(self) -> dict[CellState, int]:
|
|
201
|
+
"""Count cells by state."""
|
|
202
|
+
counts = {s: 0 for s in CellState}
|
|
203
|
+
for row in self._grid:
|
|
204
|
+
for cell in row:
|
|
205
|
+
counts[cell] += 1
|
|
206
|
+
return counts
|
|
207
|
+
|
|
208
|
+
def coverage(self) -> float:
|
|
209
|
+
"""Fraction of map that is explored (FREE + OCCUPIED)."""
|
|
210
|
+
total = self.rows * self.cols
|
|
211
|
+
if total == 0:
|
|
212
|
+
return 0.0
|
|
213
|
+
counts = self.occupancy_count()
|
|
214
|
+
explored = counts[CellState.FREE] + counts[CellState.OCCUPIED]
|
|
215
|
+
return explored / total
|
|
216
|
+
|
|
217
|
+
# ── mark free from sonar sweep ────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def mark_free_ray(self, ox: float, oy: float, angle_deg: float, distance: float) -> None:
|
|
220
|
+
"""Mark cells along a ray as FREE up to *distance* meters."""
|
|
221
|
+
angle_rad = math.radians(angle_deg)
|
|
222
|
+
steps = max(1, int(distance / self.resolution))
|
|
223
|
+
for i in range(steps):
|
|
224
|
+
t = (i + 0.5) * self.resolution
|
|
225
|
+
if t > distance:
|
|
226
|
+
break
|
|
227
|
+
wx = ox + t * math.cos(angle_rad)
|
|
228
|
+
wy = oy + t * math.sin(angle_rad)
|
|
229
|
+
if self.get_cell(wx, wy) != CellState.OCCUPIED:
|
|
230
|
+
self.set_cell(wx, wy, CellState.FREE)
|