poseproxy 0.0.1__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.
- poseproxy-0.0.1/.gitignore +17 -0
- poseproxy-0.0.1/PKG-INFO +78 -0
- poseproxy-0.0.1/README.md +66 -0
- poseproxy-0.0.1/build.sh +5 -0
- poseproxy-0.0.1/pose_server.py +231 -0
- poseproxy-0.0.1/pyproject.toml +23 -0
- poseproxy-0.0.1/requirements.txt +1 -0
- poseproxy-0.0.1/tests/test_pose_server.py +272 -0
- poseproxy-0.0.1/vicon_poses.py +104 -0
poseproxy-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: poseproxy
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Simple Pose Forwarder For Motion Capturing Systems
|
|
5
|
+
Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: websockets<16,>=15.0.1
|
|
9
|
+
Provides-Extra: vicon
|
|
10
|
+
Requires-Dist: pyvicon-datastream==0.2.4; extra == 'vicon'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Pose WebSocket server
|
|
14
|
+
|
|
15
|
+
Streams one rigid body from any mocap system to a browser. Requires Python 3.10+.
|
|
16
|
+
|
|
17
|
+
Run from the repository root:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
python3 -m venv .venv
|
|
21
|
+
. .venv/bin/activate
|
|
22
|
+
pip install ./pose
|
|
23
|
+
pose --demo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For Vicon:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install './pose[vicon]'
|
|
30
|
+
pose.vicon --host 192.154.4.124 --list # List current object names
|
|
31
|
+
pose.vicon --host 192.154.4.124 --subject crazyflie | pose
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Vicon uses the root segment; override with `--segment NAME`. Server defaults:
|
|
35
|
+
`127.0.0.1:8765`; change with `--host`/`--port`. Stop with Ctrl-C.
|
|
36
|
+
|
|
37
|
+
## Pose API
|
|
38
|
+
|
|
39
|
+
Connect to `ws://127.0.0.1:8765/pose`. Each text message contains:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{"timestamp":1789600000.125,"position":[0.1,-0.2,0.5],"quaternion":[0,0,0,1]}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `timestamp`: Unix seconds when the producer received the sample.
|
|
46
|
+
- `position`: meters, right-handed world frame: X forward, Y left, Z up.
|
|
47
|
+
- `quaternion`: normalized `[x,y,z,w]`, rotating body-local vectors into world coordinates.
|
|
48
|
+
|
|
49
|
+
All values must be finite numbers. The Vicon adapter converts millimeters to meters
|
|
50
|
+
and reorders its SDK quaternion to this shared convention.
|
|
51
|
+
|
|
52
|
+
The stream is read-only. Invalid/occluded poses are skipped; tracking loss means
|
|
53
|
+
silence. Clients should detect stale data. Slow clients skip pending samples;
|
|
54
|
+
new connections receive only new samples. Input EOF stops the server.
|
|
55
|
+
|
|
56
|
+
Other adapters must emit this format as flushed, newline-delimited JSON to
|
|
57
|
+
stdout, with diagnostics on stderr:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
python -u my_mocap_adapter.py | pose
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Browser
|
|
64
|
+
|
|
65
|
+
Run Python on the browser's computer. From a Connect button handler:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
const response = await fetch("http://127.0.0.1:8765/health");
|
|
69
|
+
if (!response.ok) throw new Error("Pose server unavailable");
|
|
70
|
+
const socket = new WebSocket("ws://127.0.0.1:8765/pose");
|
|
71
|
+
socket.onmessage = ({data}) => console.log(JSON.parse(data));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Grant Chrome's local/loopback permission. Allowed origins: `https://rc.rl.tools`,
|
|
75
|
+
`http://localhost:8000`, `http://127.0.0.1:8000`; extend with `--allow-origin URL`.
|
|
76
|
+
`/health` returns `last_pose_age_s` (`null` before any sample).
|
|
77
|
+
|
|
78
|
+
Tests: `python -B -m unittest discover -s pose/tests -v`.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Pose WebSocket server
|
|
2
|
+
|
|
3
|
+
Streams one rigid body from any mocap system to a browser. Requires Python 3.10+.
|
|
4
|
+
|
|
5
|
+
Run from the repository root:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
python3 -m venv .venv
|
|
9
|
+
. .venv/bin/activate
|
|
10
|
+
pip install ./pose
|
|
11
|
+
pose --demo
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For Vicon:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install './pose[vicon]'
|
|
18
|
+
pose.vicon --host 192.154.4.124 --list # List current object names
|
|
19
|
+
pose.vicon --host 192.154.4.124 --subject crazyflie | pose
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Vicon uses the root segment; override with `--segment NAME`. Server defaults:
|
|
23
|
+
`127.0.0.1:8765`; change with `--host`/`--port`. Stop with Ctrl-C.
|
|
24
|
+
|
|
25
|
+
## Pose API
|
|
26
|
+
|
|
27
|
+
Connect to `ws://127.0.0.1:8765/pose`. Each text message contains:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{"timestamp":1789600000.125,"position":[0.1,-0.2,0.5],"quaternion":[0,0,0,1]}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `timestamp`: Unix seconds when the producer received the sample.
|
|
34
|
+
- `position`: meters, right-handed world frame: X forward, Y left, Z up.
|
|
35
|
+
- `quaternion`: normalized `[x,y,z,w]`, rotating body-local vectors into world coordinates.
|
|
36
|
+
|
|
37
|
+
All values must be finite numbers. The Vicon adapter converts millimeters to meters
|
|
38
|
+
and reorders its SDK quaternion to this shared convention.
|
|
39
|
+
|
|
40
|
+
The stream is read-only. Invalid/occluded poses are skipped; tracking loss means
|
|
41
|
+
silence. Clients should detect stale data. Slow clients skip pending samples;
|
|
42
|
+
new connections receive only new samples. Input EOF stops the server.
|
|
43
|
+
|
|
44
|
+
Other adapters must emit this format as flushed, newline-delimited JSON to
|
|
45
|
+
stdout, with diagnostics on stderr:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
python -u my_mocap_adapter.py | pose
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Browser
|
|
52
|
+
|
|
53
|
+
Run Python on the browser's computer. From a Connect button handler:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const response = await fetch("http://127.0.0.1:8765/health");
|
|
57
|
+
if (!response.ok) throw new Error("Pose server unavailable");
|
|
58
|
+
const socket = new WebSocket("ws://127.0.0.1:8765/pose");
|
|
59
|
+
socket.onmessage = ({data}) => console.log(JSON.parse(data));
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Grant Chrome's local/loopback permission. Allowed origins: `https://rc.rl.tools`,
|
|
63
|
+
`http://localhost:8000`, `http://127.0.0.1:8000`; extend with `--allow-origin URL`.
|
|
64
|
+
`/health` returns `last_pose_age_s` (`null` before any sample).
|
|
65
|
+
|
|
66
|
+
Tests: `python -B -m unittest discover -s pose/tests -v`.
|
poseproxy-0.0.1/build.sh
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Forward newline-delimited JSON poses from stdin to ws://127.0.0.1:8765/pose."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import signal
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from websockets.asyncio.server import serve
|
|
16
|
+
from websockets.exceptions import ConnectionClosed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LOG = logging.getLogger("pose_server")
|
|
20
|
+
DEFAULT_ORIGINS = (
|
|
21
|
+
"https://rc.rl.tools",
|
|
22
|
+
"http://localhost:8000",
|
|
23
|
+
"http://127.0.0.1:8000",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def encode_pose(pose):
|
|
28
|
+
"""Validate the common pose format and normalize its xyzw quaternion."""
|
|
29
|
+
def number(value):
|
|
30
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
31
|
+
raise ValueError("pose values must be numbers")
|
|
32
|
+
value = float(value)
|
|
33
|
+
if not math.isfinite(value):
|
|
34
|
+
raise ValueError("pose values must be finite")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
def vector(name, size):
|
|
38
|
+
value = pose.get(name)
|
|
39
|
+
if not isinstance(value, list) or len(value) != size:
|
|
40
|
+
raise ValueError(f"{name} must be an array of {size} numbers")
|
|
41
|
+
return [number(item) for item in value]
|
|
42
|
+
|
|
43
|
+
if not isinstance(pose, dict):
|
|
44
|
+
raise ValueError("pose must be a JSON object")
|
|
45
|
+
timestamp = number(pose.get("timestamp"))
|
|
46
|
+
position = vector("position", 3)
|
|
47
|
+
quaternion = vector("quaternion", 4)
|
|
48
|
+
norm = math.hypot(*quaternion)
|
|
49
|
+
if norm == 0 or not math.isfinite(norm):
|
|
50
|
+
raise ValueError("quaternion must have a finite, nonzero norm")
|
|
51
|
+
return json.dumps({
|
|
52
|
+
"timestamp": timestamp,
|
|
53
|
+
"position": position,
|
|
54
|
+
"quaternion": [value / norm for value in quaternion],
|
|
55
|
+
}, separators=(",", ":"), allow_nan=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PoseServer:
|
|
59
|
+
def __init__(self, origins=DEFAULT_ORIGINS):
|
|
60
|
+
self.origins = set(origins)
|
|
61
|
+
self.clients = set()
|
|
62
|
+
self.last_received = None
|
|
63
|
+
|
|
64
|
+
async def publish(self, message):
|
|
65
|
+
"""Publish an encoded pose on the event loop; retain one pending pose/client."""
|
|
66
|
+
self.last_received = time.monotonic()
|
|
67
|
+
for queue in self.clients:
|
|
68
|
+
if queue.full():
|
|
69
|
+
queue.get_nowait()
|
|
70
|
+
queue.put_nowait(message)
|
|
71
|
+
|
|
72
|
+
def process_request(self, connection, request):
|
|
73
|
+
origins = request.headers.get_all("Origin")
|
|
74
|
+
if len(origins) > 1 or (origins and origins[0] not in self.origins):
|
|
75
|
+
return connection.respond(403, "Origin not allowed\n")
|
|
76
|
+
if request.path == "/health":
|
|
77
|
+
age = (None if self.last_received is None else
|
|
78
|
+
time.monotonic() - self.last_received)
|
|
79
|
+
response = connection.respond(200, json.dumps({
|
|
80
|
+
"last_pose_age_s": age,
|
|
81
|
+
}) + "\n")
|
|
82
|
+
del response.headers["Content-Type"]
|
|
83
|
+
response.headers["Content-Type"] = "application/json"
|
|
84
|
+
response.headers["Cache-Control"] = "no-store"
|
|
85
|
+
response.headers["Vary"] = "Origin"
|
|
86
|
+
if origins:
|
|
87
|
+
response.headers["Access-Control-Allow-Origin"] = origins[0]
|
|
88
|
+
return response
|
|
89
|
+
if request.path != "/pose":
|
|
90
|
+
return connection.respond(404, "Use /pose or /health\n")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
async def handle(self, websocket):
|
|
94
|
+
queue = asyncio.Queue(maxsize=1)
|
|
95
|
+
self.clients.add(queue)
|
|
96
|
+
|
|
97
|
+
async def send_poses():
|
|
98
|
+
while True:
|
|
99
|
+
message = await queue.get()
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.wait_for(websocket.send(message), timeout=1)
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
await websocket.close(code=1013, reason="Client is too slow")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
async def receive():
|
|
107
|
+
# Reading also detects disconnects when the mocap source is silent.
|
|
108
|
+
async for _ in websocket:
|
|
109
|
+
await websocket.close(code=1008, reason="Pose stream is read-only")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
tasks = [asyncio.create_task(send_poses()), asyncio.create_task(receive())]
|
|
113
|
+
try:
|
|
114
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
115
|
+
for task in done:
|
|
116
|
+
task.result()
|
|
117
|
+
except ConnectionClosed:
|
|
118
|
+
pass
|
|
119
|
+
finally:
|
|
120
|
+
self.clients.discard(queue)
|
|
121
|
+
for task in tasks:
|
|
122
|
+
task.cancel()
|
|
123
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
124
|
+
|
|
125
|
+
def listen(self, host="127.0.0.1", port=8765):
|
|
126
|
+
return serve(
|
|
127
|
+
self.handle, host, port, process_request=self.process_request,
|
|
128
|
+
origins=[None, *self.origins], compression=None,
|
|
129
|
+
max_size=1024, max_queue=1, write_limit=4096, close_timeout=1,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_stdin(fd, loop, server, finished):
|
|
134
|
+
"""A daemon reader keeps stdin blocking and SDK work out of the event loop."""
|
|
135
|
+
try:
|
|
136
|
+
# Unbuffered IO avoids a blocked buffered-reader lock during Ctrl-C exit.
|
|
137
|
+
with open(fd, "rb", buffering=0, closefd=False) as stream:
|
|
138
|
+
while line := stream.readline(4097):
|
|
139
|
+
if len(line) > 4096:
|
|
140
|
+
LOG.warning("Skipping input line longer than 4096 bytes")
|
|
141
|
+
while line and not line.endswith(b"\n"):
|
|
142
|
+
line = stream.readline(4097)
|
|
143
|
+
continue
|
|
144
|
+
if not line.strip():
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
message = encode_pose(json.loads(line))
|
|
148
|
+
except (ValueError, OverflowError, RecursionError) as error:
|
|
149
|
+
LOG.warning("Skipping invalid pose: %s", error)
|
|
150
|
+
continue
|
|
151
|
+
# At most one callback is outstanding, even for a fast producer.
|
|
152
|
+
publication = server.publish(message)
|
|
153
|
+
try:
|
|
154
|
+
future = asyncio.run_coroutine_threadsafe(publication, loop)
|
|
155
|
+
except RuntimeError:
|
|
156
|
+
publication.close()
|
|
157
|
+
return
|
|
158
|
+
future.result()
|
|
159
|
+
except (RuntimeError, concurrent.futures.CancelledError):
|
|
160
|
+
pass # Event loop shut down while stdin was still open.
|
|
161
|
+
except OSError as error:
|
|
162
|
+
LOG.error("Reading poses failed: %s", error)
|
|
163
|
+
finally:
|
|
164
|
+
try:
|
|
165
|
+
loop.call_soon_threadsafe(finished.set)
|
|
166
|
+
except RuntimeError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def demo(server, rate):
|
|
171
|
+
start = time.monotonic()
|
|
172
|
+
while True:
|
|
173
|
+
angle = (time.monotonic() - start) * 0.5
|
|
174
|
+
await server.publish(encode_pose({
|
|
175
|
+
"timestamp": time.time(),
|
|
176
|
+
"position": [0.5 * math.cos(angle), 0.5 * math.sin(angle), 1.0],
|
|
177
|
+
"quaternion": [0.0, 0.0, math.sin(angle / 2), math.cos(angle / 2)],
|
|
178
|
+
}))
|
|
179
|
+
await asyncio.sleep(1 / rate)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def run(args):
|
|
183
|
+
loop = asyncio.get_running_loop()
|
|
184
|
+
finished = asyncio.Event()
|
|
185
|
+
# asyncio.run handles Ctrl-C; also shut down cleanly on POSIX SIGTERM.
|
|
186
|
+
try:
|
|
187
|
+
loop.add_signal_handler(signal.SIGTERM, finished.set)
|
|
188
|
+
except NotImplementedError:
|
|
189
|
+
pass
|
|
190
|
+
server = PoseServer((*DEFAULT_ORIGINS, *args.allow_origin))
|
|
191
|
+
async with server.listen(args.host, args.port) as listener:
|
|
192
|
+
port = listener.sockets[0].getsockname()[1]
|
|
193
|
+
LOG.info("Pose stream: ws://%s:%s/pose", args.host, port)
|
|
194
|
+
if args.demo:
|
|
195
|
+
producer = asyncio.create_task(demo(server, args.rate))
|
|
196
|
+
else:
|
|
197
|
+
producer = None
|
|
198
|
+
threading.Thread(
|
|
199
|
+
target=read_stdin,
|
|
200
|
+
args=(sys.stdin.fileno(), loop, server, finished), daemon=True,
|
|
201
|
+
).start()
|
|
202
|
+
try:
|
|
203
|
+
await finished.wait()
|
|
204
|
+
finally:
|
|
205
|
+
if producer is not None:
|
|
206
|
+
producer.cancel()
|
|
207
|
+
await asyncio.gather(producer, return_exceptions=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main():
|
|
211
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
212
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind address")
|
|
213
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
214
|
+
parser.add_argument("--allow-origin", action="append", default=[],
|
|
215
|
+
help="Additional browser origin (repeatable)")
|
|
216
|
+
parser.add_argument("--demo", action="store_true", help="Generate poses instead of reading stdin")
|
|
217
|
+
parser.add_argument("--rate", type=float, default=100, help="Demo rate in Hz (default: 100)")
|
|
218
|
+
args = parser.parse_args()
|
|
219
|
+
if not 0 <= args.port <= 65535:
|
|
220
|
+
parser.error("port must be between 0 and 65535")
|
|
221
|
+
if not math.isfinite(args.rate) or args.rate <= 0:
|
|
222
|
+
parser.error("rate must be finite and positive")
|
|
223
|
+
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
|
|
224
|
+
try:
|
|
225
|
+
asyncio.run(run(args))
|
|
226
|
+
except KeyboardInterrupt:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "poseproxy"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Simple Pose Forwarder For Motion Capturing Systems"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{name = "Jonas Eschmann", email = "jonas.eschmann@gmail.com"}]
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["websockets>=15.0.1,<16"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
vicon = ["pyvicon-datastream==0.2.4"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
only-include = ["pose_server.py", "vicon_poses.py"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
pose = "pose_server:main"
|
|
23
|
+
"pose.vicon" = "vicon_poses:main"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets>=15.0.1,<16
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
3
|
+
from io import StringIO
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import Mock, patch
|
|
11
|
+
|
|
12
|
+
from websockets.asyncio.client import connect
|
|
13
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
14
|
+
|
|
15
|
+
POSE_DIR = Path(__file__).resolve().parents[1]
|
|
16
|
+
sys.path.insert(0, str(POSE_DIR))
|
|
17
|
+
from pose_server import PoseServer, encode_pose
|
|
18
|
+
from vicon_poses import main as vicon_main, poses
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
POSE = {"timestamp": 1789600000.125, "position": [1, 2, 3], "quaternion": [0, 0, 0, 1]}
|
|
22
|
+
ORIGIN = "https://rc.rl.tools"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FormatTests(unittest.TestCase):
|
|
26
|
+
def test_normalizes_quaternion_and_preserves_pose(self):
|
|
27
|
+
result = json.loads(encode_pose({**POSE, "quaternion": [0, 0, 2, 2]}))
|
|
28
|
+
self.assertEqual(result["position"], POSE["position"])
|
|
29
|
+
self.assertEqual(result["timestamp"], POSE["timestamp"])
|
|
30
|
+
self.assertAlmostEqual(result["quaternion"][2], 2 ** -0.5)
|
|
31
|
+
self.assertAlmostEqual(result["quaternion"][3], 2 ** -0.5)
|
|
32
|
+
|
|
33
|
+
def test_rejects_invalid_measurements(self):
|
|
34
|
+
invalid = [
|
|
35
|
+
None, [], {}, {**POSE, "position": [1, 2]},
|
|
36
|
+
{**POSE, "position": [1, float("nan"), 3]},
|
|
37
|
+
{**POSE, "quaternion": [0, 0, 0, 0]},
|
|
38
|
+
{**POSE, "quaternion": [0, 0, 0, float("inf")]},
|
|
39
|
+
{**POSE, "timestamp": True}, {**POSE, "timestamp": "123"},
|
|
40
|
+
]
|
|
41
|
+
for value in invalid:
|
|
42
|
+
with self.subTest(value=value), self.assertRaises(ValueError):
|
|
43
|
+
encode_pose(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ViconTests(unittest.TestCase):
|
|
47
|
+
def make_sdk(self):
|
|
48
|
+
sdk = Mock()
|
|
49
|
+
client = sdk.PyViconDatastream.return_value
|
|
50
|
+
for method in ("connect", "enable_segment_data", "set_stream_mode", "set_axis_mapping", "get_frame"):
|
|
51
|
+
getattr(client, method).return_value = sdk.Result.Success
|
|
52
|
+
client.get_subject_root_segment_name.return_value = "root"
|
|
53
|
+
client.get_segment_global_translation.side_effect = [None, [1000, -2000, 500]]
|
|
54
|
+
client.get_segment_global_quaternion.return_value = [0.5, 0.5, -0.5, 0.5]
|
|
55
|
+
return sdk, client
|
|
56
|
+
|
|
57
|
+
def test_occlusion_units_order_and_cleanup(self):
|
|
58
|
+
sdk, client = self.make_sdk()
|
|
59
|
+
stream = poses(sdk, "vicon:801", "drone")
|
|
60
|
+
pose = next(stream)
|
|
61
|
+
self.assertEqual(pose["position"], [1, -2, 0.5])
|
|
62
|
+
self.assertEqual(pose["quaternion"], [0.5, -0.5, 0.5, 0.5])
|
|
63
|
+
self.assertGreater(pose["timestamp"], 0)
|
|
64
|
+
self.assertEqual(client.get_frame.call_count, 2)
|
|
65
|
+
client.set_buffer_size.assert_called_once_with(1)
|
|
66
|
+
client.set_axis_mapping.assert_called_once_with(
|
|
67
|
+
sdk.Direction.Forward, sdk.Direction.Left, sdk.Direction.Up)
|
|
68
|
+
client.get_segment_global_translation.assert_called_with("drone", "root")
|
|
69
|
+
stream.close()
|
|
70
|
+
client.disconnect.assert_called_once()
|
|
71
|
+
|
|
72
|
+
def test_source_failure_is_reported_and_disconnected(self):
|
|
73
|
+
sdk, client = self.make_sdk()
|
|
74
|
+
client.get_frame.return_value = sdk.Result.NotConnected
|
|
75
|
+
with self.assertRaisesRegex(RuntimeError, "get frame failed"):
|
|
76
|
+
next(poses(sdk, "vicon", "drone"))
|
|
77
|
+
client.disconnect.assert_called_once()
|
|
78
|
+
|
|
79
|
+
def run_list_command(self, sdk):
|
|
80
|
+
stdout, stderr = StringIO(), StringIO()
|
|
81
|
+
with patch.dict(sys.modules, {"pyvicon_datastream": sdk}), \
|
|
82
|
+
patch.object(sys, "argv", ["pose.vicon", "--host", "vicon", "--list"]), \
|
|
83
|
+
redirect_stdout(stdout), redirect_stderr(stderr):
|
|
84
|
+
vicon_main()
|
|
85
|
+
return stdout.getvalue(), stderr.getvalue()
|
|
86
|
+
|
|
87
|
+
def test_list_waits_for_frame_and_prints_names_without_streaming(self):
|
|
88
|
+
sdk, client = self.make_sdk()
|
|
89
|
+
client.get_frame.side_effect = [sdk.Result.NoFrame, sdk.Result.Success]
|
|
90
|
+
client.get_subject_count.return_value = 2
|
|
91
|
+
client.get_subject_name.side_effect = ["crazyflie", "Calibration Wand"]
|
|
92
|
+
stdout, stderr = self.run_list_command(sdk)
|
|
93
|
+
self.assertEqual(stdout, "crazyflie\nCalibration Wand\n")
|
|
94
|
+
self.assertEqual(stderr, "")
|
|
95
|
+
self.assertEqual(client.get_frame.call_count, 2)
|
|
96
|
+
client.get_subject_root_segment_name.assert_not_called()
|
|
97
|
+
client.get_segment_global_translation.assert_not_called()
|
|
98
|
+
client.disconnect.assert_called_once()
|
|
99
|
+
|
|
100
|
+
def test_list_handles_empty_frame(self):
|
|
101
|
+
sdk, client = self.make_sdk()
|
|
102
|
+
client.get_subject_count.return_value = 0
|
|
103
|
+
stdout, stderr = self.run_list_command(sdk)
|
|
104
|
+
self.assertEqual(stdout, "")
|
|
105
|
+
self.assertEqual(stderr, "No objects found.\n")
|
|
106
|
+
client.disconnect.assert_called_once()
|
|
107
|
+
|
|
108
|
+
def test_list_connection_failure_exits_with_error_and_disconnects(self):
|
|
109
|
+
sdk, client = self.make_sdk()
|
|
110
|
+
client.connect.return_value = sdk.Result.ClientConnectionFailed
|
|
111
|
+
with self.assertRaises(SystemExit) as caught:
|
|
112
|
+
self.run_list_command(sdk)
|
|
113
|
+
self.assertEqual(caught.exception.code, 1)
|
|
114
|
+
client.get_subject_count.assert_not_called()
|
|
115
|
+
client.disconnect.assert_called_once()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def http_get(port, path="/health", origin=ORIGIN):
|
|
119
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
120
|
+
origin_header = "" if origin is None else f"Origin: {origin}\r\n"
|
|
121
|
+
writer.write((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n"
|
|
122
|
+
f"{origin_header}Connection: close\r\n\r\n").encode())
|
|
123
|
+
await writer.drain()
|
|
124
|
+
response = await asyncio.wait_for(reader.read(), 2)
|
|
125
|
+
writer.close()
|
|
126
|
+
await writer.wait_closed()
|
|
127
|
+
header, body = response.decode().split("\r\n\r\n", 1)
|
|
128
|
+
return header, body
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ServerTests(unittest.IsolatedAsyncioTestCase):
|
|
132
|
+
async def asyncSetUp(self):
|
|
133
|
+
self.server = PoseServer()
|
|
134
|
+
self.listener = await self.server.listen(port=0)
|
|
135
|
+
self.port = self.listener.sockets[0].getsockname()[1]
|
|
136
|
+
self.url = f"ws://127.0.0.1:{self.port}/pose"
|
|
137
|
+
|
|
138
|
+
async def asyncTearDown(self):
|
|
139
|
+
self.listener.close()
|
|
140
|
+
await self.listener.wait_closed()
|
|
141
|
+
|
|
142
|
+
def connect(self, **kwargs):
|
|
143
|
+
return connect(self.url, origin=ORIGIN, proxy=None, **kwargs)
|
|
144
|
+
|
|
145
|
+
async def test_fanout_without_replaying_stale_poses(self):
|
|
146
|
+
await self.server.publish(encode_pose(POSE))
|
|
147
|
+
async with self.connect() as first, self.connect() as second:
|
|
148
|
+
with self.assertRaises(asyncio.TimeoutError):
|
|
149
|
+
await asyncio.wait_for(first.recv(), 0.05)
|
|
150
|
+
next_pose = {**POSE, "timestamp": POSE["timestamp"] + 1}
|
|
151
|
+
await self.server.publish(encode_pose(next_pose))
|
|
152
|
+
for client in (first, second):
|
|
153
|
+
received = json.loads(await asyncio.wait_for(client.recv(), 1))
|
|
154
|
+
self.assertEqual(received, next_pose)
|
|
155
|
+
|
|
156
|
+
async def test_health_cors_and_origin_rejection(self):
|
|
157
|
+
headers, body = await http_get(self.port)
|
|
158
|
+
self.assertIn("200 OK", headers)
|
|
159
|
+
self.assertIn(f"Access-Control-Allow-Origin: {ORIGIN}", headers)
|
|
160
|
+
self.assertEqual(headers.lower().count("content-type:"), 1)
|
|
161
|
+
self.assertEqual(json.loads(body), {"last_pose_age_s": None})
|
|
162
|
+
await self.server.publish(encode_pose(POSE))
|
|
163
|
+
_, body = await http_get(self.port)
|
|
164
|
+
self.assertGreaterEqual(json.loads(body)["last_pose_age_s"], 0)
|
|
165
|
+
headers, _ = await http_get(self.port, origin="https://unapproved.example")
|
|
166
|
+
self.assertIn("403 Forbidden", headers)
|
|
167
|
+
self.assertNotIn("Access-Control-Allow-Origin", headers)
|
|
168
|
+
with self.assertRaises(InvalidStatus) as caught:
|
|
169
|
+
async with connect(self.url, origin="https://unapproved.example", proxy=None):
|
|
170
|
+
pass
|
|
171
|
+
self.assertEqual(caught.exception.response.status_code, 403)
|
|
172
|
+
headers, _ = await http_get(self.port, path="/unknown")
|
|
173
|
+
self.assertIn("404 Not Found", headers)
|
|
174
|
+
|
|
175
|
+
async def test_client_cannot_inject_poses(self):
|
|
176
|
+
async with self.connect() as client:
|
|
177
|
+
await client.send(json.dumps(POSE))
|
|
178
|
+
with self.assertRaises(ConnectionClosed):
|
|
179
|
+
await asyncio.wait_for(client.recv(), 1)
|
|
180
|
+
self.assertEqual(client.close_code, 1008)
|
|
181
|
+
|
|
182
|
+
async def test_slow_client_skips_pending_frames_without_blocking_others(self):
|
|
183
|
+
blocked = asyncio.Event()
|
|
184
|
+
release = asyncio.Event()
|
|
185
|
+
received = asyncio.Queue()
|
|
186
|
+
|
|
187
|
+
class SlowClient:
|
|
188
|
+
async def send(self, message):
|
|
189
|
+
blocked.set()
|
|
190
|
+
await release.wait()
|
|
191
|
+
await received.put(json.loads(message))
|
|
192
|
+
|
|
193
|
+
def __aiter__(self):
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
async def __anext__(self):
|
|
197
|
+
await asyncio.Future()
|
|
198
|
+
|
|
199
|
+
task = asyncio.create_task(self.server.handle(SlowClient()))
|
|
200
|
+
try:
|
|
201
|
+
async with self.connect() as fast:
|
|
202
|
+
await self.server.publish(encode_pose(POSE))
|
|
203
|
+
await asyncio.wait_for(blocked.wait(), 1)
|
|
204
|
+
await asyncio.wait_for(fast.recv(), 1)
|
|
205
|
+
for timestamp in (2, 3, 4):
|
|
206
|
+
await self.server.publish(encode_pose({**POSE, "timestamp": timestamp}))
|
|
207
|
+
self.assertEqual(json.loads(await asyncio.wait_for(fast.recv(), 1))["timestamp"], 4)
|
|
208
|
+
release.set()
|
|
209
|
+
self.assertEqual((await asyncio.wait_for(received.get(), 1))["timestamp"], POSE["timestamp"])
|
|
210
|
+
self.assertEqual((await asyncio.wait_for(received.get(), 1))["timestamp"], 4)
|
|
211
|
+
finally:
|
|
212
|
+
task.cancel()
|
|
213
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class CommandTests(unittest.IsolatedAsyncioTestCase):
|
|
217
|
+
async def launch(self, *args):
|
|
218
|
+
process = await asyncio.create_subprocess_exec(
|
|
219
|
+
sys.executable, "-B", str(POSE_DIR / "pose_server.py"), "--port", "0", *args,
|
|
220
|
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
|
|
221
|
+
stderr=asyncio.subprocess.PIPE,
|
|
222
|
+
)
|
|
223
|
+
async def cleanup():
|
|
224
|
+
if process.returncode is None:
|
|
225
|
+
process.terminate()
|
|
226
|
+
await asyncio.wait_for(process.communicate(), 5)
|
|
227
|
+
self.addAsyncCleanup(cleanup)
|
|
228
|
+
while line := await asyncio.wait_for(process.stderr.readline(), 5):
|
|
229
|
+
if match := re.search(rb"Pose stream: ws://127.0.0.1:(\d+)/pose", line):
|
|
230
|
+
return process, int(match[1])
|
|
231
|
+
self.fail("server did not start")
|
|
232
|
+
|
|
233
|
+
async def test_stdin_validation_streaming_and_eof(self):
|
|
234
|
+
process, port = await self.launch()
|
|
235
|
+
async with connect(f"ws://127.0.0.1:{port}/pose", origin=ORIGIN, proxy=None) as client:
|
|
236
|
+
process.stdin.write(b"not json\n" + b"x" * 5000 + b"\n" + json.dumps(POSE).encode() + b"\n")
|
|
237
|
+
await process.stdin.drain()
|
|
238
|
+
self.assertEqual(json.loads(await asyncio.wait_for(client.recv(), 2)), POSE)
|
|
239
|
+
process.stdin.close()
|
|
240
|
+
await asyncio.wait_for(process.wait(), 3)
|
|
241
|
+
self.assertEqual(process.returncode, 0)
|
|
242
|
+
with self.assertRaises(ConnectionClosed):
|
|
243
|
+
await asyncio.wait_for(client.recv(), 1)
|
|
244
|
+
|
|
245
|
+
async def test_demo_and_shutdown_with_open_stdin(self):
|
|
246
|
+
process, port = await self.launch("--demo", "--rate", "50")
|
|
247
|
+
async with connect(f"ws://127.0.0.1:{port}/pose", origin=ORIGIN, proxy=None) as client:
|
|
248
|
+
first = json.loads(await asyncio.wait_for(client.recv(), 2))
|
|
249
|
+
second = json.loads(await asyncio.wait_for(client.recv(), 2))
|
|
250
|
+
self.assertGreater(second["timestamp"], first["timestamp"])
|
|
251
|
+
self.assertEqual(first["position"][2], 1)
|
|
252
|
+
self.assertAlmostEqual(sum(q*q for q in first["quaternion"]), 1)
|
|
253
|
+
process.terminate()
|
|
254
|
+
await asyncio.wait_for(process.wait(), 3)
|
|
255
|
+
self.assertEqual(process.returncode, 0)
|
|
256
|
+
|
|
257
|
+
async def test_shutdown_while_waiting_for_stdin(self):
|
|
258
|
+
process, _ = await self.launch()
|
|
259
|
+
process.terminate()
|
|
260
|
+
await asyncio.wait_for(process.wait(), 3)
|
|
261
|
+
self.assertEqual(process.returncode, 0)
|
|
262
|
+
|
|
263
|
+
@unittest.skipIf(sys.platform == "win32", "POSIX Ctrl-C signal")
|
|
264
|
+
async def test_ctrl_c_while_waiting_for_stdin(self):
|
|
265
|
+
process, _ = await self.launch()
|
|
266
|
+
process.send_signal(signal.SIGINT)
|
|
267
|
+
await asyncio.wait_for(process.wait(), 3)
|
|
268
|
+
self.assertEqual(process.returncode, 0)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
unittest.main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Emit one Vicon body's poses as newline-delimited JSON for pose_server.py."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def frames(sdk, host):
|
|
12
|
+
client = sdk.PyViconDatastream()
|
|
13
|
+
|
|
14
|
+
def check(result, action):
|
|
15
|
+
if result != sdk.Result.Success:
|
|
16
|
+
raise RuntimeError(f"Vicon {action} failed: {result}")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
check(client.connect(host), f"connection to {host}")
|
|
20
|
+
check(client.enable_segment_data(), "enable segment data")
|
|
21
|
+
client.set_buffer_size(1)
|
|
22
|
+
check(client.set_stream_mode(sdk.StreamMode.ServerPush), "set stream mode")
|
|
23
|
+
check(client.set_axis_mapping(
|
|
24
|
+
sdk.Direction.Forward, sdk.Direction.Left, sdk.Direction.Up,
|
|
25
|
+
), "set axis mapping")
|
|
26
|
+
while True:
|
|
27
|
+
result = client.get_frame()
|
|
28
|
+
if result == sdk.Result.NoFrame:
|
|
29
|
+
time.sleep(0.001)
|
|
30
|
+
continue
|
|
31
|
+
check(result, "get frame")
|
|
32
|
+
yield client, time.time()
|
|
33
|
+
finally:
|
|
34
|
+
client.disconnect()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def list_objects(sdk, host):
|
|
38
|
+
with closing(frames(sdk, host)) as stream:
|
|
39
|
+
client, _ = next(stream)
|
|
40
|
+
return [client.get_subject_name(index)
|
|
41
|
+
for index in range(client.get_subject_count())]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def poses(sdk, host, subject, segment=None):
|
|
45
|
+
with closing(frames(sdk, host)) as stream:
|
|
46
|
+
for client, timestamp in stream:
|
|
47
|
+
if segment is None:
|
|
48
|
+
segment = client.get_subject_root_segment_name(subject)
|
|
49
|
+
if not segment:
|
|
50
|
+
raise RuntimeError(f"Vicon subject {subject!r} has no root segment")
|
|
51
|
+
position = client.get_segment_global_translation(subject, segment)
|
|
52
|
+
rotation = client.get_segment_global_quaternion(subject, segment)
|
|
53
|
+
# The wrapper returns None for occluded or unavailable segment data.
|
|
54
|
+
if position is None or rotation is None:
|
|
55
|
+
continue
|
|
56
|
+
w, x, y, z = map(float, rotation)
|
|
57
|
+
yield {
|
|
58
|
+
"timestamp": timestamp,
|
|
59
|
+
"position": [float(value) * 0.001 for value in position],
|
|
60
|
+
"quaternion": [x, y, z, w],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
66
|
+
parser.add_argument("--host", required=True, help="Vicon host[:port] (default port: 801)")
|
|
67
|
+
parser.add_argument("--subject", default="crazyflie")
|
|
68
|
+
parser.add_argument("--segment", help="Default: subject's root segment")
|
|
69
|
+
parser.add_argument("--list", action="store_true", dest="list_objects",
|
|
70
|
+
help="List current object (subject) names and exit")
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
try:
|
|
73
|
+
import pyvicon_datastream as sdk
|
|
74
|
+
except ImportError:
|
|
75
|
+
parser.exit(1, "Install the Vicon adapter dependency: pip install pyvicon-datastream==0.2.4\n")
|
|
76
|
+
stream = None
|
|
77
|
+
try:
|
|
78
|
+
if args.list_objects:
|
|
79
|
+
names = list_objects(sdk, args.host)
|
|
80
|
+
for name in names:
|
|
81
|
+
print(name)
|
|
82
|
+
if not names:
|
|
83
|
+
print("No objects found.", file=sys.stderr)
|
|
84
|
+
return
|
|
85
|
+
stream = poses(sdk, args.host, args.subject, args.segment)
|
|
86
|
+
for pose in stream:
|
|
87
|
+
print(json.dumps(pose, allow_nan=False), flush=True)
|
|
88
|
+
except (KeyboardInterrupt, BrokenPipeError):
|
|
89
|
+
pass
|
|
90
|
+
except (RuntimeError, ValueError) as error:
|
|
91
|
+
parser.exit(1, f"{error}\n")
|
|
92
|
+
finally:
|
|
93
|
+
if stream is not None:
|
|
94
|
+
stream.close()
|
|
95
|
+
# Avoid retrying a failed stdout flush during interpreter shutdown.
|
|
96
|
+
if sys.stdout is not None:
|
|
97
|
+
try:
|
|
98
|
+
sys.stdout.flush()
|
|
99
|
+
except BrokenPipeError:
|
|
100
|
+
sys.stdout = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|