sonar-vision 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- sonar_vision/display.py +165 -0
- sonar_vision/map.py +230 -0
- sonar_vision/signal.py +206 -0
- sonar_vision/sonar.py +173 -0
- sonar_vision/tracker.py +227 -0
- sonar_vision-0.1.0.dist-info/METADATA +10 -0
- sonar_vision-0.1.0.dist-info/RECORD +11 -0
- sonar_vision-0.1.0.dist-info/WHEEL +5 -0
- sonar_vision-0.1.0.dist-info/licenses/LICENSE +21 -0
- sonar_vision-0.1.0.dist-info/top_level.txt +1 -0
sonar_vision/__init__.py
ADDED
|
@@ -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"
|
sonar_vision/display.py
ADDED
|
@@ -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)
|
sonar_vision/map.py
ADDED
|
@@ -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)
|
sonar_vision/signal.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Signal processing — sampling, filtering, and frequency analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Signal:
|
|
12
|
+
"""A discrete-time signal with uniform sampling.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
samples: Amplitude values.
|
|
16
|
+
sample_rate: Samples per second (Hz).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
samples: list[float] = field(default_factory=list)
|
|
20
|
+
sample_rate: float = 44100.0
|
|
21
|
+
|
|
22
|
+
# ── constructors ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def sine(cls, frequency: float, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0) -> Signal:
|
|
26
|
+
"""Generate a sine-wave signal."""
|
|
27
|
+
n = int(sample_rate * duration)
|
|
28
|
+
samples = [amplitude * math.sin(2.0 * math.pi * frequency * i / sample_rate) for i in range(n)]
|
|
29
|
+
return cls(samples=samples, sample_rate=sample_rate)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def noise(cls, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0, seed: int | None = None) -> Signal:
|
|
33
|
+
"""Generate uniform random noise."""
|
|
34
|
+
import random
|
|
35
|
+
rng = random.Random(seed)
|
|
36
|
+
n = int(sample_rate * duration)
|
|
37
|
+
samples = [amplitude * (rng.random() * 2.0 - 1.0) for _ in range(n)]
|
|
38
|
+
return cls(samples=samples, sample_rate=sample_rate)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def chirp(cls, f0: float, f1: float, duration: float, sample_rate: float = 44100.0, amplitude: float = 1.0) -> Signal:
|
|
42
|
+
"""Generate a linear chirp from *f0* to *f1* Hz."""
|
|
43
|
+
n = int(sample_rate * duration)
|
|
44
|
+
rate = (f1 - f0) / duration
|
|
45
|
+
samples = [
|
|
46
|
+
amplitude * math.sin(2.0 * math.pi * (f0 * t + 0.5 * rate * t * t))
|
|
47
|
+
for t in (i / sample_rate for i in range(n))
|
|
48
|
+
]
|
|
49
|
+
return cls(samples=samples, sample_rate=sample_rate)
|
|
50
|
+
|
|
51
|
+
# ── properties ────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def duration(self) -> float:
|
|
55
|
+
"""Signal duration in seconds."""
|
|
56
|
+
return len(self.samples) / self.sample_rate if self.sample_rate else 0.0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def n_samples(self) -> int:
|
|
60
|
+
return len(self.samples)
|
|
61
|
+
|
|
62
|
+
# ── sampling / resampling ─────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def resample(self, target_rate: float) -> Signal:
|
|
65
|
+
"""Linear-interpolation resample to *target_rate*."""
|
|
66
|
+
if target_rate <= 0 or not self.samples:
|
|
67
|
+
return Signal(samples=[], sample_rate=target_rate)
|
|
68
|
+
ratio = self.sample_rate / target_rate
|
|
69
|
+
new_n = max(1, int(len(self.samples) / ratio))
|
|
70
|
+
new_samples: list[float] = []
|
|
71
|
+
for i in range(new_n):
|
|
72
|
+
pos = i * ratio
|
|
73
|
+
idx = int(pos)
|
|
74
|
+
frac = pos - idx
|
|
75
|
+
if idx + 1 < len(self.samples):
|
|
76
|
+
new_samples.append(self.samples[idx] * (1.0 - frac) + self.samples[idx + 1] * frac)
|
|
77
|
+
else:
|
|
78
|
+
new_samples.append(self.samples[min(idx, len(self.samples) - 1)])
|
|
79
|
+
return Signal(samples=new_samples, sample_rate=target_rate)
|
|
80
|
+
|
|
81
|
+
# ── filtering ─────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def lowpass(self, cutoff: float, order: int = 5) -> Signal:
|
|
84
|
+
"""Simple moving-average low-pass filter (approximation).
|
|
85
|
+
|
|
86
|
+
Window size is derived from *cutoff* relative to Nyquist.
|
|
87
|
+
"""
|
|
88
|
+
nyquist = self.sample_rate / 2.0
|
|
89
|
+
if cutoff <= 0 or cutoff >= nyquist:
|
|
90
|
+
return Signal(samples=list(self.samples), sample_rate=self.sample_rate)
|
|
91
|
+
window = max(1, int(nyquist / cutoff) * order)
|
|
92
|
+
window = min(window, len(self.samples))
|
|
93
|
+
if window <= 1:
|
|
94
|
+
return Signal(samples=list(self.samples), sample_rate=self.sample_rate)
|
|
95
|
+
out: list[float] = []
|
|
96
|
+
half = window // 2
|
|
97
|
+
for i in range(len(self.samples)):
|
|
98
|
+
start = max(0, i - half)
|
|
99
|
+
end = min(len(self.samples), i + half + 1)
|
|
100
|
+
out.append(sum(self.samples[start:end]) / (end - start))
|
|
101
|
+
return Signal(samples=out, sample_rate=self.sample_rate)
|
|
102
|
+
|
|
103
|
+
def highpass(self, cutoff: float, order: int = 5) -> Signal:
|
|
104
|
+
"""Cascaded high-pass by subtracting low-pass from original."""
|
|
105
|
+
lp = self.lowpass(cutoff, order)
|
|
106
|
+
return Signal(
|
|
107
|
+
samples=[s - l for s, l in zip(self.samples, lp.samples)],
|
|
108
|
+
sample_rate=self.sample_rate,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def bandpass(self, low_cutoff: float, high_cutoff: float, order: int = 5) -> Signal:
|
|
112
|
+
"""Band-pass via lowpass then highpass."""
|
|
113
|
+
return self.lowpass(high_cutoff, order).highpass(low_cutoff, order)
|
|
114
|
+
|
|
115
|
+
def threshold(self, level: float) -> Signal:
|
|
116
|
+
"""Hard threshold — samples below *level* in absolute value become 0."""
|
|
117
|
+
return Signal(
|
|
118
|
+
samples=[s if abs(s) >= level else 0.0 for s in self.samples],
|
|
119
|
+
sample_rate=self.sample_rate,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def envelope(self) -> Signal:
|
|
123
|
+
"""Simple magnitude envelope via sliding maximum of absolute value."""
|
|
124
|
+
if not self.samples:
|
|
125
|
+
return Signal(sample_rate=self.sample_rate)
|
|
126
|
+
window = max(1, int(self.sample_rate * 0.005)) # 5 ms window
|
|
127
|
+
out: list[float] = []
|
|
128
|
+
for i in range(len(self.samples)):
|
|
129
|
+
start = max(0, i - window)
|
|
130
|
+
out.append(max(abs(s) for s in self.samples[start : i + 1]))
|
|
131
|
+
return Signal(samples=out, sample_rate=self.sample_rate)
|
|
132
|
+
|
|
133
|
+
# ── analysis ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def rms(self) -> float:
|
|
136
|
+
"""Root-mean-square amplitude."""
|
|
137
|
+
if not self.samples:
|
|
138
|
+
return 0.0
|
|
139
|
+
return math.sqrt(sum(s * s for s in self.samples) / len(self.samples))
|
|
140
|
+
|
|
141
|
+
def peak(self) -> float:
|
|
142
|
+
"""Peak (max absolute) amplitude."""
|
|
143
|
+
if not self.samples:
|
|
144
|
+
return 0.0
|
|
145
|
+
return max(abs(s) for s in self.samples)
|
|
146
|
+
|
|
147
|
+
def snr_db(self, noise: Signal) -> float:
|
|
148
|
+
"""Signal-to-noise ratio in dB relative to *noise*."""
|
|
149
|
+
sig_power = self.rms()
|
|
150
|
+
noise_power = noise.rms()
|
|
151
|
+
if noise_power == 0.0:
|
|
152
|
+
return float("inf") if sig_power > 0 else 0.0
|
|
153
|
+
return 20.0 * math.log10(sig_power / noise_power)
|
|
154
|
+
|
|
155
|
+
def energy(self) -> float:
|
|
156
|
+
"""Total signal energy."""
|
|
157
|
+
return sum(s * s for s in self.samples)
|
|
158
|
+
|
|
159
|
+
def dominant_frequency(self) -> float:
|
|
160
|
+
"""Estimate dominant frequency via zero-crossing count."""
|
|
161
|
+
if len(self.samples) < 2:
|
|
162
|
+
return 0.0
|
|
163
|
+
crossings = 0
|
|
164
|
+
for i in range(1, len(self.samples)):
|
|
165
|
+
if self.samples[i - 1] * self.samples[i] < 0:
|
|
166
|
+
crossings += 1
|
|
167
|
+
return (crossings / 2.0) * self.sample_rate / len(self.samples)
|
|
168
|
+
|
|
169
|
+
def dft_magnitude(self, n: int | None = None) -> list[float]:
|
|
170
|
+
"""Compute DFT magnitude spectrum (naïve O(N²), for small signals).
|
|
171
|
+
|
|
172
|
+
Returns list of magnitudes for bins 0 … N/2.
|
|
173
|
+
"""
|
|
174
|
+
N = n or len(self.samples)
|
|
175
|
+
N = min(N, len(self.samples))
|
|
176
|
+
magnitudes: list[float] = []
|
|
177
|
+
half = N // 2 + 1
|
|
178
|
+
for k in range(half):
|
|
179
|
+
re = 0.0
|
|
180
|
+
im = 0.0
|
|
181
|
+
for n_idx in range(N):
|
|
182
|
+
angle = 2.0 * math.pi * k * n_idx / N
|
|
183
|
+
re += self.samples[n_idx] * math.cos(angle)
|
|
184
|
+
im -= self.samples[n_idx] * math.sin(angle)
|
|
185
|
+
magnitudes.append(math.sqrt(re * re + im * im) / N)
|
|
186
|
+
return magnitudes
|
|
187
|
+
|
|
188
|
+
# ── arithmetic ────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def __add__(self, other: Signal) -> Signal:
|
|
191
|
+
if self.sample_rate != other.sample_rate:
|
|
192
|
+
raise ValueError("Sample rates must match for addition")
|
|
193
|
+
n = max(len(self.samples), len(other.samples))
|
|
194
|
+
a = self.samples + [0.0] * (n - len(self.samples))
|
|
195
|
+
b = other.samples + [0.0] * (n - len(other.samples))
|
|
196
|
+
return Signal(samples=[x + y for x, y in zip(a, b)], sample_rate=self.sample_rate)
|
|
197
|
+
|
|
198
|
+
def __mul__(self, other: float | Signal) -> Signal:
|
|
199
|
+
if isinstance(other, Signal):
|
|
200
|
+
if self.sample_rate != other.sample_rate:
|
|
201
|
+
raise ValueError("Sample rates must match")
|
|
202
|
+
return Signal(
|
|
203
|
+
samples=[a * b for a, b in zip(self.samples, other.samples)],
|
|
204
|
+
sample_rate=self.sample_rate,
|
|
205
|
+
)
|
|
206
|
+
return Signal(samples=[s * other for s in self.samples], sample_rate=self.sample_rate)
|
sonar_vision/sonar.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Sonar — ping/echo simulation and distance estimation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from sonar_vision.signal import Signal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Default speed of sound in water (m/s)
|
|
13
|
+
SOUND_SPEED_WATER: float = 1500.0
|
|
14
|
+
SOUND_SPEED_AIR: float = 343.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PingResult:
|
|
19
|
+
"""Result of a sonar ping.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
distance: Estimated distance in meters.
|
|
23
|
+
travel_time: Round-trip time in seconds.
|
|
24
|
+
signal_strength: Received signal amplitude (0–1 normalised).
|
|
25
|
+
snr_db: Signal-to-noise ratio in decibels.
|
|
26
|
+
frequency: Ping frequency in Hz.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
distance: float
|
|
30
|
+
travel_time: float
|
|
31
|
+
signal_strength: float
|
|
32
|
+
snr_db: float
|
|
33
|
+
frequency: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Sonar:
|
|
38
|
+
"""Active sonar model with configurable medium and transducer.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
sound_speed: Speed of sound in the medium (m/s).
|
|
42
|
+
frequency: Transducer centre frequency (Hz).
|
|
43
|
+
pulse_duration: Transmit pulse length (seconds).
|
|
44
|
+
max_range: Maximum detectable range (meters).
|
|
45
|
+
beam_width: Transducer beam width (degrees).
|
|
46
|
+
source_level: Transmit source level (arbitrary dB scale).
|
|
47
|
+
noise_level: Ambient noise floor (arbitrary dB scale).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
sound_speed: float = SOUND_SPEED_WATER
|
|
51
|
+
frequency: float = 50000.0
|
|
52
|
+
pulse_duration: float = 0.001
|
|
53
|
+
max_range: float = 1000.0
|
|
54
|
+
beam_width: float = 30.0
|
|
55
|
+
source_level: float = 200.0
|
|
56
|
+
noise_level: float = 60.0
|
|
57
|
+
|
|
58
|
+
# ── transmit ──────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def generate_ping(self, sample_rate: float = 44100.0) -> Signal:
|
|
61
|
+
"""Generate a transmit pulse (tone burst)."""
|
|
62
|
+
return Signal.sine(
|
|
63
|
+
frequency=self.frequency,
|
|
64
|
+
duration=self.pulse_duration,
|
|
65
|
+
sample_rate=sample_rate,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# ── propagation ───────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def round_trip_time(self, distance: float) -> float:
|
|
71
|
+
"""Compute round-trip travel time for a given *distance*."""
|
|
72
|
+
return 2.0 * distance / self.sound_speed
|
|
73
|
+
|
|
74
|
+
def distance_from_time(self, travel_time: float) -> float:
|
|
75
|
+
"""Estimate distance from measured round-trip *travel_time*."""
|
|
76
|
+
return travel_time * self.sound_speed / 2.0
|
|
77
|
+
|
|
78
|
+
def spreading_loss(self, distance: float) -> float:
|
|
79
|
+
"""Cylindrical + spherical spreading loss in dB (simplified)."""
|
|
80
|
+
if distance <= 0:
|
|
81
|
+
return 0.0
|
|
82
|
+
return 20.0 * math.log10(max(distance, 1e-12))
|
|
83
|
+
|
|
84
|
+
def absorption_loss(self, distance: float, absorption_db_km: float = 10.0) -> float:
|
|
85
|
+
"""Absorption loss in dB using a constant dB/km coefficient.
|
|
86
|
+
|
|
87
|
+
The coefficient is configurable but does not vary with frequency in
|
|
88
|
+
this simplified model; pass an appropriate value for the transducer
|
|
89
|
+
frequency and water conditions.
|
|
90
|
+
"""
|
|
91
|
+
return absorption_db_km * distance / 1000.0
|
|
92
|
+
|
|
93
|
+
def total_loss(self, distance: float, absorption_db_km: float = 10.0) -> float:
|
|
94
|
+
"""Total one-way transmission loss (spreading + absorption) in dB."""
|
|
95
|
+
return self.spreading_loss(distance) + self.absorption_loss(distance, absorption_db_km)
|
|
96
|
+
|
|
97
|
+
# ── detect ────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def ping(self, distance: float, target_strength: float = -20.0) -> PingResult:
|
|
100
|
+
"""Simulate a ping at *distance* and return detection result.
|
|
101
|
+
|
|
102
|
+
Models two-way transmission loss (outgoing plus return path) plus
|
|
103
|
+
target strength. Returns ``PingResult`` with a distance estimate
|
|
104
|
+
that includes small stochastic jitter proportional to range.
|
|
105
|
+
"""
|
|
106
|
+
if distance <= 0 or distance > self.max_range:
|
|
107
|
+
return PingResult(
|
|
108
|
+
distance=float("nan"),
|
|
109
|
+
travel_time=float("nan"),
|
|
110
|
+
signal_strength=0.0,
|
|
111
|
+
snr_db=-float("inf"),
|
|
112
|
+
frequency=self.frequency,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
rtt = self.round_trip_time(distance)
|
|
116
|
+
# Active sonar: sound travels to the target and back, so use two-way loss.
|
|
117
|
+
two_way_loss = 2.0 * self.total_loss(distance)
|
|
118
|
+
received_level = self.source_level - two_way_loss + target_strength
|
|
119
|
+
snr = received_level - self.noise_level
|
|
120
|
+
strength = max(0.0, min(1.0, 1.0 / (1.0 + math.exp(-(snr - 6.0) / 3.0))))
|
|
121
|
+
|
|
122
|
+
# Small measurement jitter proportional to range
|
|
123
|
+
import random
|
|
124
|
+
rng = random.Random()
|
|
125
|
+
jitter = rng.gauss(0, 0.005 * distance)
|
|
126
|
+
estimated_distance = distance + jitter
|
|
127
|
+
estimated_rtt = self.round_trip_time(estimated_distance)
|
|
128
|
+
|
|
129
|
+
return PingResult(
|
|
130
|
+
distance=estimated_distance,
|
|
131
|
+
travel_time=estimated_rtt,
|
|
132
|
+
signal_strength=strength,
|
|
133
|
+
snr_db=snr,
|
|
134
|
+
frequency=self.frequency,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def ping_return_signal(
|
|
138
|
+
self,
|
|
139
|
+
distance: float,
|
|
140
|
+
target_strength: float = -20.0,
|
|
141
|
+
sample_rate: float = 44100.0,
|
|
142
|
+
) -> Signal:
|
|
143
|
+
"""Generate a synthetic echo return signal for a target.
|
|
144
|
+
|
|
145
|
+
Returns a ``Signal`` containing transmit pulse, silence, and attenuated echo.
|
|
146
|
+
"""
|
|
147
|
+
if distance <= 0 or distance > self.max_range:
|
|
148
|
+
return Signal(sample_rate=sample_rate)
|
|
149
|
+
|
|
150
|
+
tx = self.generate_ping(sample_rate)
|
|
151
|
+
rtt_samples = int(self.round_trip_time(distance) * sample_rate)
|
|
152
|
+
|
|
153
|
+
# Echo is attenuated by the two-way propagation path; target strength
|
|
154
|
+
# increases received level, so it reduces the effective loss.
|
|
155
|
+
loss_db = 2.0 * self.total_loss(distance) - target_strength
|
|
156
|
+
gain = 10.0 ** (-loss_db / 20.0)
|
|
157
|
+
gain = max(0.0, min(1.0, gain))
|
|
158
|
+
echo_samples = [s * gain for s in tx.samples]
|
|
159
|
+
|
|
160
|
+
# Build return: tx pulse → silence → echo
|
|
161
|
+
silence_len = max(0, rtt_samples - len(tx.samples))
|
|
162
|
+
combined = tx.samples + [0.0] * silence_len + echo_samples
|
|
163
|
+
return Signal(samples=combined, sample_rate=sample_rate)
|
|
164
|
+
|
|
165
|
+
# ── beam geometry ─────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def in_beam(self, bearing_offset_deg: float) -> bool:
|
|
168
|
+
"""Check whether a target at *bearing_offset_deg* from boresight is within the beam."""
|
|
169
|
+
return abs(bearing_offset_deg) <= self.beam_width / 2.0
|
|
170
|
+
|
|
171
|
+
def beam_coverage(self) -> float:
|
|
172
|
+
"""Return the angular coverage in degrees of the beam."""
|
|
173
|
+
return self.beam_width
|
sonar_vision/tracker.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""ObjectTracker — multi-object tracking with motion prediction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time as _time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Track:
|
|
13
|
+
"""A tracked object.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
track_id: Unique integer ID.
|
|
17
|
+
x: Current estimated X position (m).
|
|
18
|
+
y: Current estimated Y position (m).
|
|
19
|
+
vx: Estimated X velocity (m/s).
|
|
20
|
+
vy: Estimated Y velocity (m/s).
|
|
21
|
+
last_seen: Timestamp of last update.
|
|
22
|
+
detections: Number of detections received.
|
|
23
|
+
label: Optional semantic label.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
track_id: int
|
|
27
|
+
x: float = 0.0
|
|
28
|
+
y: float = 0.0
|
|
29
|
+
vx: float = 0.0
|
|
30
|
+
vy: float = 0.0
|
|
31
|
+
last_seen: float = 0.0
|
|
32
|
+
detections: int = 1
|
|
33
|
+
label: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Detection:
|
|
38
|
+
"""A single detection observation.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
x: Measured X position (m).
|
|
42
|
+
y: Measured Y position (m).
|
|
43
|
+
timestamp: Observation time (epoch seconds). None means use current time.
|
|
44
|
+
confidence: Detection confidence (0–1).
|
|
45
|
+
label: Optional label.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
x: float
|
|
49
|
+
y: float
|
|
50
|
+
timestamp: float | None = None
|
|
51
|
+
confidence: float = 1.0
|
|
52
|
+
label: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ObjectTracker:
|
|
57
|
+
"""Simple multi-object tracker with nearest-neighbour association.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
association_gate: Maximum distance for associating a detection
|
|
61
|
+
with an existing track (meters).
|
|
62
|
+
max_velocity: Assumed maximum object velocity (m/s) — used
|
|
63
|
+
for gating and prediction clamping.
|
|
64
|
+
lost_timeout: Seconds without detection before a track is
|
|
65
|
+
considered lost.
|
|
66
|
+
next_id: Counter for assigning track IDs.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
association_gate: float = 5.0
|
|
70
|
+
max_velocity: float = 10.0
|
|
71
|
+
lost_timeout: float = 5.0
|
|
72
|
+
next_id: int = 1
|
|
73
|
+
_tracks: dict[int, Track] = field(default_factory=dict)
|
|
74
|
+
|
|
75
|
+
# ── track management ──────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def tracks(self) -> list[Track]:
|
|
79
|
+
"""Return all active tracks."""
|
|
80
|
+
return list(self._tracks.values())
|
|
81
|
+
|
|
82
|
+
def get_track(self, track_id: int) -> Optional[Track]:
|
|
83
|
+
return self._tracks.get(track_id)
|
|
84
|
+
|
|
85
|
+
def active_tracks(self, now: float | None = None) -> list[Track]:
|
|
86
|
+
"""Return tracks that have been seen within *lost_timeout*."""
|
|
87
|
+
now = now or _time.time()
|
|
88
|
+
return [t for t in self._tracks.values() if (now - t.last_seen) <= self.lost_timeout]
|
|
89
|
+
|
|
90
|
+
def lost_tracks(self, now: float | None = None) -> list[Track]:
|
|
91
|
+
"""Return tracks that have timed out."""
|
|
92
|
+
now = now or _time.time()
|
|
93
|
+
return [t for t in self._tracks.values() if (now - t.last_seen) > self.lost_timeout]
|
|
94
|
+
|
|
95
|
+
def remove_track(self, track_id: int) -> None:
|
|
96
|
+
self._tracks.pop(track_id, None)
|
|
97
|
+
|
|
98
|
+
def prune_lost(self, now: float | None = None) -> int:
|
|
99
|
+
"""Remove all lost tracks. Returns count removed."""
|
|
100
|
+
lost = self.lost_tracks(now)
|
|
101
|
+
for t in lost:
|
|
102
|
+
del self._tracks[t.track_id]
|
|
103
|
+
return len(lost)
|
|
104
|
+
|
|
105
|
+
# ── update ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def _create_track(self, det: Detection) -> Track:
|
|
108
|
+
tid = self.next_id
|
|
109
|
+
self.next_id += 1
|
|
110
|
+
t = Track(
|
|
111
|
+
track_id=tid,
|
|
112
|
+
x=det.x,
|
|
113
|
+
y=det.y,
|
|
114
|
+
last_seen=det.timestamp,
|
|
115
|
+
label=det.label,
|
|
116
|
+
)
|
|
117
|
+
self._tracks[tid] = t
|
|
118
|
+
return t
|
|
119
|
+
|
|
120
|
+
def _update_track(self, track: Track, det: Detection) -> None:
|
|
121
|
+
dt = det.timestamp - track.last_seen
|
|
122
|
+
if dt > 0:
|
|
123
|
+
new_vx = (det.x - track.x) / dt
|
|
124
|
+
new_vy = (det.y - track.y) / dt
|
|
125
|
+
# Clamp velocity
|
|
126
|
+
speed = math.hypot(new_vx, new_vy)
|
|
127
|
+
if speed > self.max_velocity:
|
|
128
|
+
scale = self.max_velocity / speed
|
|
129
|
+
new_vx *= scale
|
|
130
|
+
new_vy *= scale
|
|
131
|
+
# Exponential smoothing on velocity
|
|
132
|
+
alpha = 0.5
|
|
133
|
+
track.vx = alpha * new_vx + (1.0 - alpha) * track.vx
|
|
134
|
+
track.vy = alpha * new_vy + (1.0 - alpha) * track.vy
|
|
135
|
+
track.x = det.x
|
|
136
|
+
track.y = det.y
|
|
137
|
+
track.last_seen = det.timestamp
|
|
138
|
+
track.detections += 1
|
|
139
|
+
|
|
140
|
+
def update(self, detections: list[Detection], now: float | None = None) -> list[int]:
|
|
141
|
+
"""Process a batch of detections.
|
|
142
|
+
|
|
143
|
+
Returns list of track IDs that were updated or created.
|
|
144
|
+
"""
|
|
145
|
+
now = now or _time.time()
|
|
146
|
+
updated_ids: list[int] = []
|
|
147
|
+
|
|
148
|
+
# Simple greedy nearest-neighbour association
|
|
149
|
+
used_tracks: set[int] = set()
|
|
150
|
+
# Sort detections by confidence descending for greedy matching
|
|
151
|
+
sorted_dets = sorted(detections, key=lambda d: d.confidence, reverse=True)
|
|
152
|
+
|
|
153
|
+
for det in sorted_dets:
|
|
154
|
+
if det.timestamp is None:
|
|
155
|
+
det.timestamp = now
|
|
156
|
+
best_id: Optional[int] = None
|
|
157
|
+
best_dist = self.association_gate
|
|
158
|
+
for t in self._tracks.values():
|
|
159
|
+
if t.track_id in used_tracks:
|
|
160
|
+
continue
|
|
161
|
+
# Predict position
|
|
162
|
+
dt = det.timestamp - t.last_seen
|
|
163
|
+
px = t.x + t.vx * dt
|
|
164
|
+
py = t.y + t.vy * dt
|
|
165
|
+
d = math.hypot(px - det.x, py - det.y)
|
|
166
|
+
if d < best_dist:
|
|
167
|
+
best_dist = d
|
|
168
|
+
best_id = t.track_id
|
|
169
|
+
|
|
170
|
+
if best_id is not None:
|
|
171
|
+
self._update_track(self._tracks[best_id], det)
|
|
172
|
+
used_tracks.add(best_id)
|
|
173
|
+
updated_ids.append(best_id)
|
|
174
|
+
else:
|
|
175
|
+
t = self._create_track(det)
|
|
176
|
+
updated_ids.append(t.track_id)
|
|
177
|
+
|
|
178
|
+
return updated_ids
|
|
179
|
+
|
|
180
|
+
# ── prediction ────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def predict(self, track_id: int, dt: float) -> Optional[tuple[float, float]]:
|
|
183
|
+
"""Predict position of track *track_id* *dt* seconds into the future.
|
|
184
|
+
|
|
185
|
+
Returns (x, y) or None if track doesn't exist.
|
|
186
|
+
"""
|
|
187
|
+
t = self._tracks.get(track_id)
|
|
188
|
+
if t is None:
|
|
189
|
+
return None
|
|
190
|
+
return (t.x + t.vx * dt, t.y + t.vy * dt)
|
|
191
|
+
|
|
192
|
+
def predict_all(self, dt: float) -> dict[int, tuple[float, float]]:
|
|
193
|
+
"""Predict positions of all tracks *dt* seconds ahead."""
|
|
194
|
+
return {
|
|
195
|
+
tid: (t.x + t.vx * dt, t.y + t.vy * dt)
|
|
196
|
+
for tid, t in self._tracks.items()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# ── queries ───────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def nearest_track(self, x: float, y: float, now: float | None = None) -> Optional[Track]:
|
|
202
|
+
"""Return the nearest active track to (x, y)."""
|
|
203
|
+
active = self.active_tracks(now)
|
|
204
|
+
if not active:
|
|
205
|
+
return None
|
|
206
|
+
return min(active, key=lambda t: math.hypot(t.x - x, t.y - y))
|
|
207
|
+
|
|
208
|
+
def tracks_in_radius(self, x: float, y: float, radius: float, now: float | None = None) -> list[Track]:
|
|
209
|
+
"""Return active tracks within *radius* meters of (x, y)."""
|
|
210
|
+
return [
|
|
211
|
+
t for t in self.active_tracks(now)
|
|
212
|
+
if math.hypot(t.x - x, t.y - y) <= radius
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def speed(self, track_id: int) -> float:
|
|
216
|
+
"""Speed of a track in m/s."""
|
|
217
|
+
t = self._tracks.get(track_id)
|
|
218
|
+
if t is None:
|
|
219
|
+
return 0.0
|
|
220
|
+
return math.hypot(t.vx, t.vy)
|
|
221
|
+
|
|
222
|
+
def heading(self, track_id: int) -> float:
|
|
223
|
+
"""Heading of a track in degrees (0 = east, 90 = north)."""
|
|
224
|
+
t = self._tracks.get(track_id)
|
|
225
|
+
if t is None or (t.vx == 0 and t.vy == 0):
|
|
226
|
+
return 0.0
|
|
227
|
+
return math.degrees(math.atan2(t.vy, t.vx)) % 360.0
|
|
@@ -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,11 @@
|
|
|
1
|
+
sonar_vision/__init__.py,sha256=cBkyRSBw7J8EfvvkdxtFUoDiwV--9CG69_jqe7e4pgE,584
|
|
2
|
+
sonar_vision/display.py,sha256=fRsZq4P_fA6W9CqzMjOTl_jfD20eYK2uxR4n0ddaJOw,6251
|
|
3
|
+
sonar_vision/map.py,sha256=ewAmIjttyMj9LRS727obNR7osHm8EhbsMAFEY2fMshc,9350
|
|
4
|
+
sonar_vision/signal.py,sha256=07xj_JPIfQNXOSAJwPKggiMmsXdc7GP-XuD04AZ7PaM,8921
|
|
5
|
+
sonar_vision/sonar.py,sha256=Qw-rSIaUgaobKiDu3zr1p4kQ1BJVG9U31FoBQJdzVws,6835
|
|
6
|
+
sonar_vision/tracker.py,sha256=mWZFRYFdCjFu1fDVup_5z2YSeDRQUVkkiYDFo-BtF7w,8011
|
|
7
|
+
sonar_vision-0.1.0.dist-info/licenses/LICENSE,sha256=J5l0Qtagxszkc1jxXLBi6hMd6dutpirm_6GQAnVMlnY,1075
|
|
8
|
+
sonar_vision-0.1.0.dist-info/METADATA,sha256=8M4FRkMNFYstIcEwlIBMFdV55vUSOczs4uuJJZVZkas,317
|
|
9
|
+
sonar_vision-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
sonar_vision-0.1.0.dist-info/top_level.txt,sha256=NJjD5ZYQRVPR9YgDoHsXxwczqNRxAxaqOUfjmIhuScE,13
|
|
11
|
+
sonar_vision-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
sonar_vision
|