irtsp 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.
- irtsp-0.1.0/.github/workflows/ci.yml +30 -0
- irtsp-0.1.0/.github/workflows/release.yml +45 -0
- irtsp-0.1.0/.gitignore +7 -0
- irtsp-0.1.0/LICENSE +21 -0
- irtsp-0.1.0/PKG-INFO +275 -0
- irtsp-0.1.0/README.md +235 -0
- irtsp-0.1.0/examples/README.md +19 -0
- irtsp-0.1.0/examples/depth_snapshot.py +74 -0
- irtsp-0.1.0/examples/imu_to_csv.py +60 -0
- irtsp-0.1.0/examples/live_pose_trace.py +71 -0
- irtsp-0.1.0/examples/print_all.py +78 -0
- irtsp-0.1.0/irtsp/__init__.py +135 -0
- irtsp-0.1.0/irtsp/aio.py +355 -0
- irtsp-0.1.0/irtsp/clock.py +99 -0
- irtsp-0.1.0/irtsp/discovery.py +113 -0
- irtsp-0.1.0/irtsp/py.typed +0 -0
- irtsp-0.1.0/irtsp/records.py +409 -0
- irtsp-0.1.0/irtsp/session.py +619 -0
- irtsp-0.1.0/irtsp/video.py +215 -0
- irtsp-0.1.0/irtsp/wire.py +260 -0
- irtsp-0.1.0/pyproject.toml +57 -0
- irtsp-0.1.0/tests/mockserver.py +524 -0
- irtsp-0.1.0/tests/test_aio.py +514 -0
- irtsp-0.1.0/tests/test_clock.py +281 -0
- irtsp-0.1.0/tests/test_records.py +443 -0
- irtsp-0.1.0/tests/test_session.py +464 -0
- irtsp-0.1.0/tests/test_wire.py +516 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Test on every push/PR: oldest & newest supported Python, Linux + macOS.
|
|
2
|
+
name: CI
|
|
3
|
+
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ${{ matrix.os }}
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
os: [ubuntu-latest, macos-latest]
|
|
19
|
+
python: ["3.10", "3.13", "3.14"]
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python }}
|
|
25
|
+
- name: Install
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
python -m pip install -e . pytest numpy
|
|
29
|
+
- name: Test
|
|
30
|
+
run: python -m pytest
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Publish irtsp to PyPI when a GitHub release is published.
|
|
2
|
+
#
|
|
3
|
+
# Uses PyPI Trusted Publishing (OIDC) — no API token stored in the repo.
|
|
4
|
+
# One-time setup on pypi.org → project "irtsp" → Publishing → add publisher:
|
|
5
|
+
# owner: ryanrudes repo: irtsp-python workflow: release.yml environment: pypi
|
|
6
|
+
name: Release
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
release:
|
|
10
|
+
types: [published]
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
- name: Build sdist + wheel
|
|
24
|
+
run: |
|
|
25
|
+
python -m pip install --upgrade build
|
|
26
|
+
python -m build
|
|
27
|
+
- uses: actions/upload-artifact@v4
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
publish:
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
environment:
|
|
36
|
+
name: pypi
|
|
37
|
+
url: https://pypi.org/p/irtsp
|
|
38
|
+
permissions:
|
|
39
|
+
id-token: write # OIDC for PyPI Trusted Publishing
|
|
40
|
+
steps:
|
|
41
|
+
- uses: actions/download-artifact@v4
|
|
42
|
+
with:
|
|
43
|
+
name: dist
|
|
44
|
+
path: dist/
|
|
45
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
irtsp-0.1.0/.gitignore
ADDED
irtsp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ryan Rudes
|
|
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.
|
irtsp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: irtsp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Friendly Python client for iRTSP — typed, time-synced iPhone camera + IMU / GPS / LiDAR-depth / ARKit-pose streams
|
|
5
|
+
Project-URL: Homepage, https://github.com/ryanrudes/irtsp-python
|
|
6
|
+
Project-URL: Documentation, https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md
|
|
7
|
+
Project-URL: Support, https://ryanrudes.github.io/irtsp-support/
|
|
8
|
+
Project-URL: Source, https://github.com/ryanrudes/irtsp-python
|
|
9
|
+
Author-email: Ryan Rudes <ryanrudes@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: arkit,camera,imu,ios,iphone,lidar,robotics,rtsp,sensors,slam,vio,visual-inertial-odometry
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Multimedia :: Video :: Capture
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: av>=10; extra == 'all'
|
|
30
|
+
Requires-Dist: numpy>=1.22; extra == 'all'
|
|
31
|
+
Requires-Dist: zeroconf>=0.39; extra == 'all'
|
|
32
|
+
Provides-Extra: discovery
|
|
33
|
+
Requires-Dist: zeroconf>=0.39; extra == 'discovery'
|
|
34
|
+
Provides-Extra: numpy
|
|
35
|
+
Requires-Dist: numpy>=1.22; extra == 'numpy'
|
|
36
|
+
Provides-Extra: video
|
|
37
|
+
Requires-Dist: av>=10; extra == 'video'
|
|
38
|
+
Requires-Dist: numpy>=1.22; extra == 'video'
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# irtsp
|
|
42
|
+
|
|
43
|
+
**Typed, SI-unit, one-shared-clock sensor streams from an iPhone.**
|
|
44
|
+
|
|
45
|
+
[iRTSP](https://ryanrudes.github.io/irtsp-support/) turns an iPhone into a streaming
|
|
46
|
+
sensor rig: RTSP video plus side channels for fused IMU, GNSS, compass heading,
|
|
47
|
+
barometric altitude, ARKit 6-DOF pose, camera intrinsics, and LiDAR metric depth.
|
|
48
|
+
This library is the Python client. Every sample arrives as a small, frozen,
|
|
49
|
+
pattern-matchable dataclass in SI units, carrying two timestamps on a **single clock
|
|
50
|
+
anchor captured once per session** — the same anchor that drives the video's RTP/RTCP
|
|
51
|
+
timeline — so video and odometry align with **no time offset to estimate**. The core
|
|
52
|
+
is pure stdlib, Python ≥ 3.10.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
| Command | What you get |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `pip install irtsp` | The core client — odometry + depth channels, all record types. Zero dependencies. |
|
|
59
|
+
| `pip install "irtsp[numpy]"` | `DepthFrame.meters` arrays and `point_cloud()` back-projection. |
|
|
60
|
+
| `pip install "irtsp[discovery]"` | `irtsp.discover()` — find phones over Bonjour/mDNS (zeroconf). |
|
|
61
|
+
| `pip install "irtsp[video]"` | Decoded video frames + `synced()` bundles (PyAV + numpy). **Experimental.** |
|
|
62
|
+
| `pip install "irtsp[all]"` | Everything above. |
|
|
63
|
+
|
|
64
|
+
## Quickstart
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import irtsp
|
|
68
|
+
|
|
69
|
+
with irtsp.connect("192.168.1.24") as phone: # or "ryans-iphone.local"
|
|
70
|
+
for imu in phone.imu:
|
|
71
|
+
print(imu.gyro, imu.accel) # rad/s, m/s²
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Start streaming in the iRTSP app, point `connect()` at the phone's address, iterate.
|
|
75
|
+
Records are regular frozen dataclasses — unpack them, stick them in a queue, feed them
|
|
76
|
+
to your filter.
|
|
77
|
+
|
|
78
|
+
## Pattern-match the whole odometry channel
|
|
79
|
+
|
|
80
|
+
`phone.odometry` yields every record in arrival order (including depth frames when the
|
|
81
|
+
depth channel is open), and every record type supports structural pattern matching:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import irtsp
|
|
85
|
+
|
|
86
|
+
with irtsp.connect("192.168.1.24", depth=True) as phone:
|
|
87
|
+
for rec in phone.odometry:
|
|
88
|
+
match rec:
|
|
89
|
+
case irtsp.IMU(gyro=g, accel=a):
|
|
90
|
+
propagate(g, a, rec.host_ts)
|
|
91
|
+
case irtsp.Pose(position=p, tracking=irtsp.Tracking.NORMAL):
|
|
92
|
+
update(p, rec.unix_ts)
|
|
93
|
+
case irtsp.GNSS(lat=lat, lon=lon) as fix if fix.h_accuracy is not None:
|
|
94
|
+
fuse_gps(lat, lon, fix.h_accuracy)
|
|
95
|
+
case irtsp.DepthFrame() as d:
|
|
96
|
+
enqueue_depth(d)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Unknown record types from a newer app version arrive as `irtsp.Unknown` (with the raw
|
|
100
|
+
payload bytes) instead of raising — old clients keep working.
|
|
101
|
+
|
|
102
|
+
## The streams
|
|
103
|
+
|
|
104
|
+
Each property on the session (`phone.imu`, `phone.gnss`, …) is a **fresh, independent
|
|
105
|
+
iterator** with its own buffer — create as many as you like, they don't compete.
|
|
106
|
+
|
|
107
|
+
| Record | Stream | Rate | SI fields | Wire-native view |
|
|
108
|
+
|---|---|---|---|---|
|
|
109
|
+
| `IMU` | `phone.imu` | ≤ ~100 Hz | `gyro` rad/s · `accel` m/s² (gravity **included**; face-up at rest ≈ (0, 0, −9.81)) · `quat` (x, y, z, w), body→world | `accel_g` |
|
|
110
|
+
| `RawGyro` / `RawAccel` | `phone.raw_gyro` / `phone.raw_accel` | raw sensor mode | rad/s · m/s² | `accel_g` |
|
|
111
|
+
| `Intrinsics` | `phone.intrinsics` | on zoom/lens change | `fx fy cx cy` px at `width×height` · `matrix` (3×3 K) · `scaled()` | — |
|
|
112
|
+
| `GNSS` | `phone.gnss` | ~1 Hz | `lat`/`lon` deg · `altitude` m · `speed` m/s · `h_accuracy`/`v_accuracy` m · `course_deg` | `course_rad` |
|
|
113
|
+
| `Altitude` | `phone.altitude` | ~1 Hz | `relative_altitude` m · `pressure` **Pa** | `pressure_kpa`, `pressure_hpa` |
|
|
114
|
+
| `Heading` | `phone.heading` | event-driven | `true_deg` · `magnetic_deg` · `accuracy_deg` | `true_rad`, `magnetic_rad` |
|
|
115
|
+
| `Pose` | `phone.pose` | ~60 Hz (AR mode) | `position` m (gravity-aligned world frame) · `orientation` quat · `tracking` | — |
|
|
116
|
+
| `DepthFrame` | `phone.depth` | ≤ 30 Hz | half-float **meters**; `meters` (ndarray), `at(x, y)`, `point_cloud(K)` | — |
|
|
117
|
+
|
|
118
|
+
Unit conventions, in one breath: SI everywhere by default — the wire's g-units become
|
|
119
|
+
m/s² (× 9.80665) and its kPa becomes Pa at decode time, with the wire-native value
|
|
120
|
+
always one property away. Angles conventionally spoken in degrees (lat/lon, compass,
|
|
121
|
+
GNSS course) stay degrees under explicit `*_deg` names, with `*_rad` properties.
|
|
122
|
+
Fields CoreLocation marks invalid (negative on the wire) decode to `None`, and an
|
|
123
|
+
attitude-off session's zeroed quaternion slots decode to `quat=None` — no sentinel
|
|
124
|
+
values ever reach your code.
|
|
125
|
+
|
|
126
|
+
Every record also carries `host_ts`, `unix_ts`, `seq`, `gap`, and a `time` property
|
|
127
|
+
(`unix_ts` as an aware UTC `datetime`). More on the two clocks below.
|
|
128
|
+
|
|
129
|
+
## Discovery
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import irtsp # pip install "irtsp[discovery]"
|
|
133
|
+
|
|
134
|
+
for device in irtsp.discover():
|
|
135
|
+
print(device.name, device.host, device.ports)
|
|
136
|
+
# iPhone 192.168.1.24 {'video': 8554, 'imu': 8555, 'depth': 8556}
|
|
137
|
+
|
|
138
|
+
phone = irtsp.discover()[0].connect(depth=True)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Only devices advertising the iRTSP odometry service (`_irtsp-imu._tcp`) are returned,
|
|
142
|
+
so a random RTSP camera on your network won't show up. No zeroconf installed? Just
|
|
143
|
+
connect by IP.
|
|
144
|
+
|
|
145
|
+
## Callbacks and `run()`
|
|
146
|
+
|
|
147
|
+
If you'd rather push than pull:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import irtsp
|
|
151
|
+
|
|
152
|
+
phone = irtsp.connect("192.168.1.24", reconnect=True)
|
|
153
|
+
phone.on(irtsp.GNSS, lambda fix: print(f"{fix.lat:.6f}, {fix.lon:.6f}"))
|
|
154
|
+
phone.on((irtsp.IMU, irtsp.Pose), sink.write)
|
|
155
|
+
phone.run() # blocks until the session closes; Ctrl-C exits cleanly
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Callbacks run on the reader thread, so keep them quick; a callback that raises is
|
|
159
|
+
logged and never kills the reader. `phone.latest(irtsp.Intrinsics, wait=2.0)` gives
|
|
160
|
+
you the most recent record of a type — handy for intrinsics, which the server replays
|
|
161
|
+
to late joiners on connect.
|
|
162
|
+
|
|
163
|
+
## asyncio
|
|
164
|
+
|
|
165
|
+
The async client is a native asyncio implementation (not a thread wrapper) with the
|
|
166
|
+
same shapes:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import asyncio, irtsp
|
|
170
|
+
|
|
171
|
+
async def main():
|
|
172
|
+
async with await irtsp.connect_async("192.168.1.24") as phone:
|
|
173
|
+
async for imu in phone.imu:
|
|
174
|
+
print(imu.gyro, imu.accel)
|
|
175
|
+
|
|
176
|
+
asyncio.run(main())
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Depth → numpy → point cloud
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
import irtsp # pip install "irtsp[numpy]"
|
|
183
|
+
|
|
184
|
+
with irtsp.connect("192.168.1.24", depth=True) as phone:
|
|
185
|
+
K = phone.latest(irtsp.Intrinsics, wait=2.0) # replayed on connect
|
|
186
|
+
|
|
187
|
+
for frame in phone.depth:
|
|
188
|
+
depth = frame.meters # (H, W) float32, meters
|
|
189
|
+
center = frame.at(frame.width // 2, frame.height // 2) # stdlib, no numpy
|
|
190
|
+
pts = frame.point_cloud(K, stride=4) # (N, 3) float32, camera frame
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`point_cloud()` accepts intrinsics at any resolution (the stream's intrinsics are for
|
|
194
|
+
the video) and rescales them to the depth map automatically. The camera frame is the
|
|
195
|
+
standard pinhole convention: +X right, +Y down, +Z forward. Non-finite depths are
|
|
196
|
+
dropped.
|
|
197
|
+
|
|
198
|
+
## Video + `synced()` — EXPERIMENTAL
|
|
199
|
+
|
|
200
|
+
With the `video` extra, the session can decode the RTSP video and hand you each frame
|
|
201
|
+
bundled with the odometry that belongs to it, all on one clock:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import irtsp # pip install "irtsp[video]"
|
|
205
|
+
|
|
206
|
+
with irtsp.connect("192.168.1.24", video=True, depth=True) as phone:
|
|
207
|
+
for f in phone.synced():
|
|
208
|
+
f.image # (H, W, 3) RGB uint8
|
|
209
|
+
f.timestamp # unix seconds — same axis as every record's unix_ts
|
|
210
|
+
f.imu # IMU records since the previous frame (pre-integration ready)
|
|
211
|
+
f.pose # Pose SLERP-interpolated at f.timestamp, or None
|
|
212
|
+
f.depth # nearest DepthFrame within tolerance, or None
|
|
213
|
+
f.intrinsics # latest camera matrix
|
|
214
|
+
f.approx_clock # ← read the note below
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**The honest note.** Frame times are exact only when FFmpeg exposes the RTCP
|
|
218
|
+
wall-clock anchor (`start_time_realtime`); iRTSP builds its RTCP Sender Reports from
|
|
219
|
+
the same anchor as the odometry, so when that value is available the alignment is as
|
|
220
|
+
good as the record timestamps. When FFmpeg *doesn't* expose it, the stream falls back
|
|
221
|
+
to anchoring the first frame at local receive time — alignment is then off by network
|
|
222
|
+
plus decode latency (typically a few tens of ms) and every frame is flagged
|
|
223
|
+
`approx_clock=True`. Check that flag before trusting tight sync, and if you need
|
|
224
|
+
guaranteed-exact video timing, consume the RTSP stream with your own RTCP-aware
|
|
225
|
+
pipeline as described in the
|
|
226
|
+
[integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md) §4.
|
|
227
|
+
|
|
228
|
+
## How the synchronization works
|
|
229
|
+
|
|
230
|
+
At the start of each streaming session the phone captures **one anchor pair** — a
|
|
231
|
+
host-clock reading (`mach_absolute_time`, seconds) and the Unix wall time at the same
|
|
232
|
+
instant — and *every* stream derives its timestamps from it. That's the whole trick:
|
|
233
|
+
the streams were never on different clocks, so there is no offset to estimate and
|
|
234
|
+
nothing to cross-correlate.
|
|
235
|
+
|
|
236
|
+
Every record carries both axes:
|
|
237
|
+
|
|
238
|
+
- **`host_ts`** — seconds on the phone's monotonic host clock. The same axis as the
|
|
239
|
+
video/audio presentation timestamps, CoreMotion, ARKit, and the depth frames.
|
|
240
|
+
Cleanest for intra-session alignment; not comparable across reboots.
|
|
241
|
+
- **`unix_ts`** — wall-clock seconds, `unix_ts = wall_anchor + (host_ts − host_anchor)`.
|
|
242
|
+
The same axis as the video's RTCP Sender-Report NTP timeline, and comparable across
|
|
243
|
+
machines.
|
|
244
|
+
|
|
245
|
+
The handshake ships both anchors; `phone.clock` is a `StreamClock` that converts
|
|
246
|
+
either way (`to_unix()` / `to_host()`). Because the anchor is frozen at session start,
|
|
247
|
+
no mid-session NTP adjustment will ever warp your timeline. The full derivation —
|
|
248
|
+
64-byte record layout, depth framing, RTP→wall-time math — is in the
|
|
249
|
+
[integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md).
|
|
250
|
+
|
|
251
|
+
## Reliability notes
|
|
252
|
+
|
|
253
|
+
- **Slow consumers can't stall capture.** Each iterator/callback subscription has its
|
|
254
|
+
own bounded buffer (default 8192 records); a consumer that falls behind drops its
|
|
255
|
+
*own* oldest records and never affects the reader or other consumers. The count is
|
|
256
|
+
on `stream.dropped`.
|
|
257
|
+
- **Nothing is lost silently.** Each channel carries a wire sequence number; if
|
|
258
|
+
records were lost upstream (or dropped before your consumer attached), the next
|
|
259
|
+
record's `gap` field says how many went missing right before it (`gap=0` means
|
|
260
|
+
none).
|
|
261
|
+
- **Reconnects.** By default a dropped connection closes the session (iterators end,
|
|
262
|
+
`run()` returns). With `connect(..., reconnect=True)` the client redials with
|
|
263
|
+
backoff and re-reads the handshake — picking up fresh clock anchors if the phone
|
|
264
|
+
restarted its stream.
|
|
265
|
+
- **Bad bytes fail loudly.** Connecting to a non-iRTSP port or desyncing raises
|
|
266
|
+
`irtsp.ProtocolError` rather than yielding garbage.
|
|
267
|
+
|
|
268
|
+
## Links
|
|
269
|
+
|
|
270
|
+
- iRTSP app + support: <https://ryanrudes.github.io/irtsp-support/>
|
|
271
|
+
- Wire protocol & synchronization spec: [INTEGRATION.md](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md)
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT — see [LICENSE](LICENSE).
|
irtsp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# irtsp
|
|
2
|
+
|
|
3
|
+
**Typed, SI-unit, one-shared-clock sensor streams from an iPhone.**
|
|
4
|
+
|
|
5
|
+
[iRTSP](https://ryanrudes.github.io/irtsp-support/) turns an iPhone into a streaming
|
|
6
|
+
sensor rig: RTSP video plus side channels for fused IMU, GNSS, compass heading,
|
|
7
|
+
barometric altitude, ARKit 6-DOF pose, camera intrinsics, and LiDAR metric depth.
|
|
8
|
+
This library is the Python client. Every sample arrives as a small, frozen,
|
|
9
|
+
pattern-matchable dataclass in SI units, carrying two timestamps on a **single clock
|
|
10
|
+
anchor captured once per session** — the same anchor that drives the video's RTP/RTCP
|
|
11
|
+
timeline — so video and odometry align with **no time offset to estimate**. The core
|
|
12
|
+
is pure stdlib, Python ≥ 3.10.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
| Command | What you get |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `pip install irtsp` | The core client — odometry + depth channels, all record types. Zero dependencies. |
|
|
19
|
+
| `pip install "irtsp[numpy]"` | `DepthFrame.meters` arrays and `point_cloud()` back-projection. |
|
|
20
|
+
| `pip install "irtsp[discovery]"` | `irtsp.discover()` — find phones over Bonjour/mDNS (zeroconf). |
|
|
21
|
+
| `pip install "irtsp[video]"` | Decoded video frames + `synced()` bundles (PyAV + numpy). **Experimental.** |
|
|
22
|
+
| `pip install "irtsp[all]"` | Everything above. |
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import irtsp
|
|
28
|
+
|
|
29
|
+
with irtsp.connect("192.168.1.24") as phone: # or "ryans-iphone.local"
|
|
30
|
+
for imu in phone.imu:
|
|
31
|
+
print(imu.gyro, imu.accel) # rad/s, m/s²
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Start streaming in the iRTSP app, point `connect()` at the phone's address, iterate.
|
|
35
|
+
Records are regular frozen dataclasses — unpack them, stick them in a queue, feed them
|
|
36
|
+
to your filter.
|
|
37
|
+
|
|
38
|
+
## Pattern-match the whole odometry channel
|
|
39
|
+
|
|
40
|
+
`phone.odometry` yields every record in arrival order (including depth frames when the
|
|
41
|
+
depth channel is open), and every record type supports structural pattern matching:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import irtsp
|
|
45
|
+
|
|
46
|
+
with irtsp.connect("192.168.1.24", depth=True) as phone:
|
|
47
|
+
for rec in phone.odometry:
|
|
48
|
+
match rec:
|
|
49
|
+
case irtsp.IMU(gyro=g, accel=a):
|
|
50
|
+
propagate(g, a, rec.host_ts)
|
|
51
|
+
case irtsp.Pose(position=p, tracking=irtsp.Tracking.NORMAL):
|
|
52
|
+
update(p, rec.unix_ts)
|
|
53
|
+
case irtsp.GNSS(lat=lat, lon=lon) as fix if fix.h_accuracy is not None:
|
|
54
|
+
fuse_gps(lat, lon, fix.h_accuracy)
|
|
55
|
+
case irtsp.DepthFrame() as d:
|
|
56
|
+
enqueue_depth(d)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Unknown record types from a newer app version arrive as `irtsp.Unknown` (with the raw
|
|
60
|
+
payload bytes) instead of raising — old clients keep working.
|
|
61
|
+
|
|
62
|
+
## The streams
|
|
63
|
+
|
|
64
|
+
Each property on the session (`phone.imu`, `phone.gnss`, …) is a **fresh, independent
|
|
65
|
+
iterator** with its own buffer — create as many as you like, they don't compete.
|
|
66
|
+
|
|
67
|
+
| Record | Stream | Rate | SI fields | Wire-native view |
|
|
68
|
+
|---|---|---|---|---|
|
|
69
|
+
| `IMU` | `phone.imu` | ≤ ~100 Hz | `gyro` rad/s · `accel` m/s² (gravity **included**; face-up at rest ≈ (0, 0, −9.81)) · `quat` (x, y, z, w), body→world | `accel_g` |
|
|
70
|
+
| `RawGyro` / `RawAccel` | `phone.raw_gyro` / `phone.raw_accel` | raw sensor mode | rad/s · m/s² | `accel_g` |
|
|
71
|
+
| `Intrinsics` | `phone.intrinsics` | on zoom/lens change | `fx fy cx cy` px at `width×height` · `matrix` (3×3 K) · `scaled()` | — |
|
|
72
|
+
| `GNSS` | `phone.gnss` | ~1 Hz | `lat`/`lon` deg · `altitude` m · `speed` m/s · `h_accuracy`/`v_accuracy` m · `course_deg` | `course_rad` |
|
|
73
|
+
| `Altitude` | `phone.altitude` | ~1 Hz | `relative_altitude` m · `pressure` **Pa** | `pressure_kpa`, `pressure_hpa` |
|
|
74
|
+
| `Heading` | `phone.heading` | event-driven | `true_deg` · `magnetic_deg` · `accuracy_deg` | `true_rad`, `magnetic_rad` |
|
|
75
|
+
| `Pose` | `phone.pose` | ~60 Hz (AR mode) | `position` m (gravity-aligned world frame) · `orientation` quat · `tracking` | — |
|
|
76
|
+
| `DepthFrame` | `phone.depth` | ≤ 30 Hz | half-float **meters**; `meters` (ndarray), `at(x, y)`, `point_cloud(K)` | — |
|
|
77
|
+
|
|
78
|
+
Unit conventions, in one breath: SI everywhere by default — the wire's g-units become
|
|
79
|
+
m/s² (× 9.80665) and its kPa becomes Pa at decode time, with the wire-native value
|
|
80
|
+
always one property away. Angles conventionally spoken in degrees (lat/lon, compass,
|
|
81
|
+
GNSS course) stay degrees under explicit `*_deg` names, with `*_rad` properties.
|
|
82
|
+
Fields CoreLocation marks invalid (negative on the wire) decode to `None`, and an
|
|
83
|
+
attitude-off session's zeroed quaternion slots decode to `quat=None` — no sentinel
|
|
84
|
+
values ever reach your code.
|
|
85
|
+
|
|
86
|
+
Every record also carries `host_ts`, `unix_ts`, `seq`, `gap`, and a `time` property
|
|
87
|
+
(`unix_ts` as an aware UTC `datetime`). More on the two clocks below.
|
|
88
|
+
|
|
89
|
+
## Discovery
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import irtsp # pip install "irtsp[discovery]"
|
|
93
|
+
|
|
94
|
+
for device in irtsp.discover():
|
|
95
|
+
print(device.name, device.host, device.ports)
|
|
96
|
+
# iPhone 192.168.1.24 {'video': 8554, 'imu': 8555, 'depth': 8556}
|
|
97
|
+
|
|
98
|
+
phone = irtsp.discover()[0].connect(depth=True)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Only devices advertising the iRTSP odometry service (`_irtsp-imu._tcp`) are returned,
|
|
102
|
+
so a random RTSP camera on your network won't show up. No zeroconf installed? Just
|
|
103
|
+
connect by IP.
|
|
104
|
+
|
|
105
|
+
## Callbacks and `run()`
|
|
106
|
+
|
|
107
|
+
If you'd rather push than pull:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import irtsp
|
|
111
|
+
|
|
112
|
+
phone = irtsp.connect("192.168.1.24", reconnect=True)
|
|
113
|
+
phone.on(irtsp.GNSS, lambda fix: print(f"{fix.lat:.6f}, {fix.lon:.6f}"))
|
|
114
|
+
phone.on((irtsp.IMU, irtsp.Pose), sink.write)
|
|
115
|
+
phone.run() # blocks until the session closes; Ctrl-C exits cleanly
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Callbacks run on the reader thread, so keep them quick; a callback that raises is
|
|
119
|
+
logged and never kills the reader. `phone.latest(irtsp.Intrinsics, wait=2.0)` gives
|
|
120
|
+
you the most recent record of a type — handy for intrinsics, which the server replays
|
|
121
|
+
to late joiners on connect.
|
|
122
|
+
|
|
123
|
+
## asyncio
|
|
124
|
+
|
|
125
|
+
The async client is a native asyncio implementation (not a thread wrapper) with the
|
|
126
|
+
same shapes:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import asyncio, irtsp
|
|
130
|
+
|
|
131
|
+
async def main():
|
|
132
|
+
async with await irtsp.connect_async("192.168.1.24") as phone:
|
|
133
|
+
async for imu in phone.imu:
|
|
134
|
+
print(imu.gyro, imu.accel)
|
|
135
|
+
|
|
136
|
+
asyncio.run(main())
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Depth → numpy → point cloud
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import irtsp # pip install "irtsp[numpy]"
|
|
143
|
+
|
|
144
|
+
with irtsp.connect("192.168.1.24", depth=True) as phone:
|
|
145
|
+
K = phone.latest(irtsp.Intrinsics, wait=2.0) # replayed on connect
|
|
146
|
+
|
|
147
|
+
for frame in phone.depth:
|
|
148
|
+
depth = frame.meters # (H, W) float32, meters
|
|
149
|
+
center = frame.at(frame.width // 2, frame.height // 2) # stdlib, no numpy
|
|
150
|
+
pts = frame.point_cloud(K, stride=4) # (N, 3) float32, camera frame
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`point_cloud()` accepts intrinsics at any resolution (the stream's intrinsics are for
|
|
154
|
+
the video) and rescales them to the depth map automatically. The camera frame is the
|
|
155
|
+
standard pinhole convention: +X right, +Y down, +Z forward. Non-finite depths are
|
|
156
|
+
dropped.
|
|
157
|
+
|
|
158
|
+
## Video + `synced()` — EXPERIMENTAL
|
|
159
|
+
|
|
160
|
+
With the `video` extra, the session can decode the RTSP video and hand you each frame
|
|
161
|
+
bundled with the odometry that belongs to it, all on one clock:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
import irtsp # pip install "irtsp[video]"
|
|
165
|
+
|
|
166
|
+
with irtsp.connect("192.168.1.24", video=True, depth=True) as phone:
|
|
167
|
+
for f in phone.synced():
|
|
168
|
+
f.image # (H, W, 3) RGB uint8
|
|
169
|
+
f.timestamp # unix seconds — same axis as every record's unix_ts
|
|
170
|
+
f.imu # IMU records since the previous frame (pre-integration ready)
|
|
171
|
+
f.pose # Pose SLERP-interpolated at f.timestamp, or None
|
|
172
|
+
f.depth # nearest DepthFrame within tolerance, or None
|
|
173
|
+
f.intrinsics # latest camera matrix
|
|
174
|
+
f.approx_clock # ← read the note below
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**The honest note.** Frame times are exact only when FFmpeg exposes the RTCP
|
|
178
|
+
wall-clock anchor (`start_time_realtime`); iRTSP builds its RTCP Sender Reports from
|
|
179
|
+
the same anchor as the odometry, so when that value is available the alignment is as
|
|
180
|
+
good as the record timestamps. When FFmpeg *doesn't* expose it, the stream falls back
|
|
181
|
+
to anchoring the first frame at local receive time — alignment is then off by network
|
|
182
|
+
plus decode latency (typically a few tens of ms) and every frame is flagged
|
|
183
|
+
`approx_clock=True`. Check that flag before trusting tight sync, and if you need
|
|
184
|
+
guaranteed-exact video timing, consume the RTSP stream with your own RTCP-aware
|
|
185
|
+
pipeline as described in the
|
|
186
|
+
[integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md) §4.
|
|
187
|
+
|
|
188
|
+
## How the synchronization works
|
|
189
|
+
|
|
190
|
+
At the start of each streaming session the phone captures **one anchor pair** — a
|
|
191
|
+
host-clock reading (`mach_absolute_time`, seconds) and the Unix wall time at the same
|
|
192
|
+
instant — and *every* stream derives its timestamps from it. That's the whole trick:
|
|
193
|
+
the streams were never on different clocks, so there is no offset to estimate and
|
|
194
|
+
nothing to cross-correlate.
|
|
195
|
+
|
|
196
|
+
Every record carries both axes:
|
|
197
|
+
|
|
198
|
+
- **`host_ts`** — seconds on the phone's monotonic host clock. The same axis as the
|
|
199
|
+
video/audio presentation timestamps, CoreMotion, ARKit, and the depth frames.
|
|
200
|
+
Cleanest for intra-session alignment; not comparable across reboots.
|
|
201
|
+
- **`unix_ts`** — wall-clock seconds, `unix_ts = wall_anchor + (host_ts − host_anchor)`.
|
|
202
|
+
The same axis as the video's RTCP Sender-Report NTP timeline, and comparable across
|
|
203
|
+
machines.
|
|
204
|
+
|
|
205
|
+
The handshake ships both anchors; `phone.clock` is a `StreamClock` that converts
|
|
206
|
+
either way (`to_unix()` / `to_host()`). Because the anchor is frozen at session start,
|
|
207
|
+
no mid-session NTP adjustment will ever warp your timeline. The full derivation —
|
|
208
|
+
64-byte record layout, depth framing, RTP→wall-time math — is in the
|
|
209
|
+
[integration guide](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md).
|
|
210
|
+
|
|
211
|
+
## Reliability notes
|
|
212
|
+
|
|
213
|
+
- **Slow consumers can't stall capture.** Each iterator/callback subscription has its
|
|
214
|
+
own bounded buffer (default 8192 records); a consumer that falls behind drops its
|
|
215
|
+
*own* oldest records and never affects the reader or other consumers. The count is
|
|
216
|
+
on `stream.dropped`.
|
|
217
|
+
- **Nothing is lost silently.** Each channel carries a wire sequence number; if
|
|
218
|
+
records were lost upstream (or dropped before your consumer attached), the next
|
|
219
|
+
record's `gap` field says how many went missing right before it (`gap=0` means
|
|
220
|
+
none).
|
|
221
|
+
- **Reconnects.** By default a dropped connection closes the session (iterators end,
|
|
222
|
+
`run()` returns). With `connect(..., reconnect=True)` the client redials with
|
|
223
|
+
backoff and re-reads the handshake — picking up fresh clock anchors if the phone
|
|
224
|
+
restarted its stream.
|
|
225
|
+
- **Bad bytes fail loudly.** Connecting to a non-iRTSP port or desyncing raises
|
|
226
|
+
`irtsp.ProtocolError` rather than yielding garbage.
|
|
227
|
+
|
|
228
|
+
## Links
|
|
229
|
+
|
|
230
|
+
- iRTSP app + support: <https://ryanrudes.github.io/irtsp-support/>
|
|
231
|
+
- Wire protocol & synchronization spec: [INTEGRATION.md](https://github.com/ryanrudes/irtsp-support/blob/main/INTEGRATION.md)
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# irtsp examples
|
|
2
|
+
|
|
3
|
+
Runnable scripts, smallest first. Each takes the phone's hostname or IP as
|
|
4
|
+
its first argument (find it with `irtsp.discover()` or in the iRTSP app) and
|
|
5
|
+
exits cleanly on Ctrl-C.
|
|
6
|
+
|
|
7
|
+
| Script | What it shows | Needs |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| [`print_all.py`](print_all.py) | every record type, pattern-matched and printed; wire gaps (`record.gap`) and consumer drops (`stream.dropped`) | stdlib |
|
|
10
|
+
| [`imu_to_csv.py`](imu_to_csv.py) | the IMU stream to CSV (SI units) with a `--seconds` duration | stdlib |
|
|
11
|
+
| [`live_pose_trace.py`](live_pose_trace.py) | callbacks + `Session.run()`: distance travelled and current position at 1 Hz | stdlib |
|
|
12
|
+
| [`depth_snapshot.py`](depth_snapshot.py) | one LiDAR depth frame to PNG/`.npy`, plus the point-cloud shape | numpy (matplotlib optional) |
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
python print_all.py 192.168.1.24 --depth
|
|
16
|
+
python imu_to_csv.py ryans-iphone.local --seconds 30 -o imu.csv
|
|
17
|
+
python live_pose_trace.py 192.168.1.24
|
|
18
|
+
python depth_snapshot.py 192.168.1.24 -o depth
|
|
19
|
+
```
|