koebelt-dash-scripts 1.3.0b1__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.
- koebelt_dash_scripts-1.3.0b1/LICENSE +21 -0
- koebelt_dash_scripts-1.3.0b1/PKG-INFO +134 -0
- koebelt_dash_scripts-1.3.0b1/README.md +106 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/__init__.py +17 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/__main__.py +4 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/camera.py +214 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/client.py +550 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/host.py +268 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/py.typed +0 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts/tools.py +112 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/PKG-INFO +134 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/SOURCES.txt +20 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/dependency_links.txt +1 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/entry_points.txt +2 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/requires.txt +4 -0
- koebelt_dash_scripts-1.3.0b1/koebelt_dash_scripts.egg-info/top_level.txt +1 -0
- koebelt_dash_scripts-1.3.0b1/pyproject.toml +44 -0
- koebelt_dash_scripts-1.3.0b1/setup.cfg +4 -0
- koebelt_dash_scripts-1.3.0b1/tests/test_camera.py +88 -0
- koebelt_dash_scripts-1.3.0b1/tests/test_client.py +217 -0
- koebelt_dash_scripts-1.3.0b1/tests/test_host.py +108 -0
- koebelt_dash_scripts-1.3.0b1/tests/test_tools.py +58 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Koebel
|
|
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,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: koebelt-dash-scripts
|
|
3
|
+
Version: 1.3.0b1
|
|
4
|
+
Summary: Control drones, robots and test rigs connected in the koebelt/dash app from Python.
|
|
5
|
+
Author-email: Thomas Koebel <contact@koebelt.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://koebelt.com/work/koebelt-dash
|
|
8
|
+
Project-URL: Source, https://github.com/koebelt/koebelt_dash/tree/main/libs/python-dash-scripts
|
|
9
|
+
Project-URL: Protocol, https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md
|
|
10
|
+
Project-URL: Issues, https://github.com/koebelt/koebelt_dash/issues
|
|
11
|
+
Keywords: robotics,drone,rover,automation,computer-vision,koebelt
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: Education
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: System :: Hardware
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: websockets>=12
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# koebelt-dash-scripts
|
|
30
|
+
|
|
31
|
+
Control drones, robots and test rigs connected in
|
|
32
|
+
[koebelt/dash](https://koebelt.com/work/koebelt-dash) from Python: read their
|
|
33
|
+
telemetry, drive them, react to buttons and conditions, with any Python
|
|
34
|
+
package you like (OpenCV, PyTorch, YOLO…).
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install koebelt-dash-scripts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from koebelt_dash_scripts import connect, every, pid
|
|
42
|
+
|
|
43
|
+
dev = connect() # the device selected in koebelt dash
|
|
44
|
+
speed = dev.setting("speed", 0.4, min=0, max=1) # a slider in dash
|
|
45
|
+
hold = pid(1.5, 0.0, 0.2)
|
|
46
|
+
|
|
47
|
+
@dev.trigger("Stop here", key="S") # a button in dash
|
|
48
|
+
def stop_here():
|
|
49
|
+
dev.mode("HOLD")
|
|
50
|
+
|
|
51
|
+
dev.mode("MANUAL")
|
|
52
|
+
for _ in every(0.05): # 20 Hz
|
|
53
|
+
error = 0.0 - dev.tlm.pos[0] # stay on the north-south line
|
|
54
|
+
dev.sticks(throttle=speed.value, steer=hold.update(error))
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run it from koebelt dash (Scripts), or from a terminal while dash is open on
|
|
58
|
+
the same computer: it finds dash by itself.
|
|
59
|
+
|
|
60
|
+
With a model on the camera:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from ultralytics import YOLO
|
|
64
|
+
from koebelt_dash_scripts import connect, pid
|
|
65
|
+
|
|
66
|
+
model = YOLO("yolov8n.pt")
|
|
67
|
+
dev = connect()
|
|
68
|
+
steer = pid(1.2, 0, 0.1)
|
|
69
|
+
|
|
70
|
+
for frame in dev.camera.frames():
|
|
71
|
+
result = model(frame, verbose=False)[0]
|
|
72
|
+
dev.overlay(result) # see what the model sees, in dash
|
|
73
|
+
people = [b for b in result.boxes if int(b.cls) == 0]
|
|
74
|
+
if not people:
|
|
75
|
+
dev.sticks(throttle=0, steer=0)
|
|
76
|
+
continue
|
|
77
|
+
x = float(people[0].xywhn[0][0]) - 0.5 # −0.5 left … 0.5 right
|
|
78
|
+
dev.sticks(throttle=0.4, steer=steer.update(-x))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Script hosts: scripts for phones and tablets
|
|
82
|
+
|
|
83
|
+
koebelt dash on an iPhone, iPad or Android device sends its scripts to a
|
|
84
|
+
computer on the same network, which runs them with its own Python (and its
|
|
85
|
+
GPU), while the phone keeps the device link, the sticks and STOP:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
pip install koebelt-dash-scripts
|
|
89
|
+
koebelt-dash-scripts host
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It prints a pairing code; in dash: Scripts > Runs on > Add a script host. A
|
|
93
|
+
laptop on the field, a Raspberry Pi 5 or a Jetson all work. A computer
|
|
94
|
+
running koebelt dash can also be a host (Settings > Scripts).
|
|
95
|
+
|
|
96
|
+
## Safety
|
|
97
|
+
|
|
98
|
+
dash stays in charge, whatever the script does:
|
|
99
|
+
|
|
100
|
+
- STOP stops every device and ends the script.
|
|
101
|
+
- Moving the sticks in dash takes over; the script's sticks are ignored until
|
|
102
|
+
you resume it.
|
|
103
|
+
- Each script has limits set in dash: maximum speed, geofence, ceiling.
|
|
104
|
+
Arming, take-off and parameter writes are refused unless you allow them.
|
|
105
|
+
- If the script crashes, hangs or leaves the geofence, the device holds, and
|
|
106
|
+
the script cannot drive again until you resume it.
|
|
107
|
+
|
|
108
|
+
## The API
|
|
109
|
+
|
|
110
|
+
| | |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `connect(device=None)` | The device selected in dash, or the one with that name. |
|
|
113
|
+
| `dev.tlm` | Latest telemetry (20 Hz): `pos` (m from home, x east, y north), `alt`, `vel`, `speed`, `roll` `pitch` `yaw`, `heading`, `mode`, `armed`, `battery`, `gps`, `joints`… |
|
|
114
|
+
| `dev.next_tlm()` | Waits for the next telemetry. |
|
|
115
|
+
| `dev.sticks(...)` | −1…1, held until changed. Drones: `throttle yaw pitch roll`; rovers: `throttle steer`. |
|
|
116
|
+
| `dev.mode(m)`, `dev.arm()`, `dev.disarm()`, `dev.speed_limit(v)`, `dev.cmd(name, **args)`, `dev.stop()`, `dev.release()` | Commands. |
|
|
117
|
+
| `dev.takeoff(alt)`, `dev.goto(x, y, z)`, `dev.land()`, `dev.rth()`, `dev.pose(*deg)`, `dev.gripper(pct)`, `dev.home()`, `dev.throttle(pct, s)`, `dev.ramp(a, b, s)` | Program steps; they return once done (or at once with `wait=False`). |
|
|
118
|
+
| `dev.run_program(name)`, `dev.pause_program()`, `dev.resume_program()`, `dev.stop_program()` | Programs from dash's Program screen. |
|
|
119
|
+
| `dev.camera.frame()`, `for img in dev.camera.frames():` | The camera as numpy images (BGR, for OpenCV; needs `opencv-python` or `pillow`). The newest frame only: a slow model never falls behind. |
|
|
120
|
+
| `dev.overlay(boxes)` | Boxes over the camera in dash: `(x, y, w, h, label)` tuples or an ultralytics result. |
|
|
121
|
+
| `dev.params["KEY"]`, `dev.params.set(KEY=v)`, `dev.params.save()` | Parameters. |
|
|
122
|
+
| `dev.setting(name, default, min, max)` | A slider or switch in dash; read `.value`. |
|
|
123
|
+
| `@dev.trigger(name, kind, key, gamepad)` | A button (`button`), switch (`switch`) or number (`value`) in dash. |
|
|
124
|
+
| `@dev.on_trigger`, `@dev.on_stop`, `@dev.on_takeover` | Other events. |
|
|
125
|
+
| `dev.plot(name, v)`, `dev.mark(text)`, `dev.notify(text)` | Shown in dash and recorded. |
|
|
126
|
+
| `dev.store` | A dictionary kept between runs. |
|
|
127
|
+
| `dev.run()` | Waits, for scripts that only react to triggers. |
|
|
128
|
+
| `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
|
|
129
|
+
| `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
|
|
130
|
+
|
|
131
|
+
The messages between scripts and dash are documented in
|
|
132
|
+
[protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
|
|
133
|
+
|
|
134
|
+
MIT licence.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# koebelt-dash-scripts
|
|
2
|
+
|
|
3
|
+
Control drones, robots and test rigs connected in
|
|
4
|
+
[koebelt/dash](https://koebelt.com/work/koebelt-dash) from Python: read their
|
|
5
|
+
telemetry, drive them, react to buttons and conditions, with any Python
|
|
6
|
+
package you like (OpenCV, PyTorch, YOLO…).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install koebelt-dash-scripts
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from koebelt_dash_scripts import connect, every, pid
|
|
14
|
+
|
|
15
|
+
dev = connect() # the device selected in koebelt dash
|
|
16
|
+
speed = dev.setting("speed", 0.4, min=0, max=1) # a slider in dash
|
|
17
|
+
hold = pid(1.5, 0.0, 0.2)
|
|
18
|
+
|
|
19
|
+
@dev.trigger("Stop here", key="S") # a button in dash
|
|
20
|
+
def stop_here():
|
|
21
|
+
dev.mode("HOLD")
|
|
22
|
+
|
|
23
|
+
dev.mode("MANUAL")
|
|
24
|
+
for _ in every(0.05): # 20 Hz
|
|
25
|
+
error = 0.0 - dev.tlm.pos[0] # stay on the north-south line
|
|
26
|
+
dev.sticks(throttle=speed.value, steer=hold.update(error))
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run it from koebelt dash (Scripts), or from a terminal while dash is open on
|
|
30
|
+
the same computer: it finds dash by itself.
|
|
31
|
+
|
|
32
|
+
With a model on the camera:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from ultralytics import YOLO
|
|
36
|
+
from koebelt_dash_scripts import connect, pid
|
|
37
|
+
|
|
38
|
+
model = YOLO("yolov8n.pt")
|
|
39
|
+
dev = connect()
|
|
40
|
+
steer = pid(1.2, 0, 0.1)
|
|
41
|
+
|
|
42
|
+
for frame in dev.camera.frames():
|
|
43
|
+
result = model(frame, verbose=False)[0]
|
|
44
|
+
dev.overlay(result) # see what the model sees, in dash
|
|
45
|
+
people = [b for b in result.boxes if int(b.cls) == 0]
|
|
46
|
+
if not people:
|
|
47
|
+
dev.sticks(throttle=0, steer=0)
|
|
48
|
+
continue
|
|
49
|
+
x = float(people[0].xywhn[0][0]) - 0.5 # −0.5 left … 0.5 right
|
|
50
|
+
dev.sticks(throttle=0.4, steer=steer.update(-x))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Script hosts: scripts for phones and tablets
|
|
54
|
+
|
|
55
|
+
koebelt dash on an iPhone, iPad or Android device sends its scripts to a
|
|
56
|
+
computer on the same network, which runs them with its own Python (and its
|
|
57
|
+
GPU), while the phone keeps the device link, the sticks and STOP:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
pip install koebelt-dash-scripts
|
|
61
|
+
koebelt-dash-scripts host
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It prints a pairing code; in dash: Scripts > Runs on > Add a script host. A
|
|
65
|
+
laptop on the field, a Raspberry Pi 5 or a Jetson all work. A computer
|
|
66
|
+
running koebelt dash can also be a host (Settings > Scripts).
|
|
67
|
+
|
|
68
|
+
## Safety
|
|
69
|
+
|
|
70
|
+
dash stays in charge, whatever the script does:
|
|
71
|
+
|
|
72
|
+
- STOP stops every device and ends the script.
|
|
73
|
+
- Moving the sticks in dash takes over; the script's sticks are ignored until
|
|
74
|
+
you resume it.
|
|
75
|
+
- Each script has limits set in dash: maximum speed, geofence, ceiling.
|
|
76
|
+
Arming, take-off and parameter writes are refused unless you allow them.
|
|
77
|
+
- If the script crashes, hangs or leaves the geofence, the device holds, and
|
|
78
|
+
the script cannot drive again until you resume it.
|
|
79
|
+
|
|
80
|
+
## The API
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `connect(device=None)` | The device selected in dash, or the one with that name. |
|
|
85
|
+
| `dev.tlm` | Latest telemetry (20 Hz): `pos` (m from home, x east, y north), `alt`, `vel`, `speed`, `roll` `pitch` `yaw`, `heading`, `mode`, `armed`, `battery`, `gps`, `joints`… |
|
|
86
|
+
| `dev.next_tlm()` | Waits for the next telemetry. |
|
|
87
|
+
| `dev.sticks(...)` | −1…1, held until changed. Drones: `throttle yaw pitch roll`; rovers: `throttle steer`. |
|
|
88
|
+
| `dev.mode(m)`, `dev.arm()`, `dev.disarm()`, `dev.speed_limit(v)`, `dev.cmd(name, **args)`, `dev.stop()`, `dev.release()` | Commands. |
|
|
89
|
+
| `dev.takeoff(alt)`, `dev.goto(x, y, z)`, `dev.land()`, `dev.rth()`, `dev.pose(*deg)`, `dev.gripper(pct)`, `dev.home()`, `dev.throttle(pct, s)`, `dev.ramp(a, b, s)` | Program steps; they return once done (or at once with `wait=False`). |
|
|
90
|
+
| `dev.run_program(name)`, `dev.pause_program()`, `dev.resume_program()`, `dev.stop_program()` | Programs from dash's Program screen. |
|
|
91
|
+
| `dev.camera.frame()`, `for img in dev.camera.frames():` | The camera as numpy images (BGR, for OpenCV; needs `opencv-python` or `pillow`). The newest frame only: a slow model never falls behind. |
|
|
92
|
+
| `dev.overlay(boxes)` | Boxes over the camera in dash: `(x, y, w, h, label)` tuples or an ultralytics result. |
|
|
93
|
+
| `dev.params["KEY"]`, `dev.params.set(KEY=v)`, `dev.params.save()` | Parameters. |
|
|
94
|
+
| `dev.setting(name, default, min, max)` | A slider or switch in dash; read `.value`. |
|
|
95
|
+
| `@dev.trigger(name, kind, key, gamepad)` | A button (`button`), switch (`switch`) or number (`value`) in dash. |
|
|
96
|
+
| `@dev.on_trigger`, `@dev.on_stop`, `@dev.on_takeover` | Other events. |
|
|
97
|
+
| `dev.plot(name, v)`, `dev.mark(text)`, `dev.notify(text)` | Shown in dash and recorded. |
|
|
98
|
+
| `dev.store` | A dictionary kept between runs. |
|
|
99
|
+
| `dev.run()` | Waits, for scripts that only react to triggers. |
|
|
100
|
+
| `every(seconds)`, `wait(seconds)` | Steady loops; dash shows the real rate. |
|
|
101
|
+
| `pid`, `lowpass`, `rate_limit`, `clamp`, `distance`, `bearing`, `angle_diff`, `geo_distance`, `geo_bearing` | Helpers. |
|
|
102
|
+
|
|
103
|
+
The messages between scripts and dash are documented in
|
|
104
|
+
[protocol/SCRIPTS.md](https://github.com/koebelt/koebelt_dash/blob/main/protocol/SCRIPTS.md).
|
|
105
|
+
|
|
106
|
+
MIT licence.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""koebelt-dash-scripts: control devices connected in koebelt/dash from Python.
|
|
2
|
+
|
|
3
|
+
from koebelt_dash_scripts import connect, every
|
|
4
|
+
dev = connect()
|
|
5
|
+
for _ in every(0.05):
|
|
6
|
+
dev.sticks(throttle=0.3)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.3.0b1"
|
|
10
|
+
|
|
11
|
+
from .camera import Camera, CameraError, decode_jpeg # noqa: E402
|
|
12
|
+
from .client import Device, Released, ScriptError, Setting, Telemetry, connect, every, wait # noqa: E402
|
|
13
|
+
from .tools import angle_diff, bearing, clamp, distance, geo_bearing, geo_distance, lowpass, pid, rate_limit # noqa: E402
|
|
14
|
+
|
|
15
|
+
__all__ = ["connect", "every", "wait", "Device", "Telemetry", "Setting", "ScriptError", "Released", "Camera", "CameraError", "decode_jpeg",
|
|
16
|
+
"pid", "lowpass", "rate_limit", "clamp", "distance", "bearing", "angle_diff", "geo_distance", "geo_bearing",
|
|
17
|
+
"__version__"]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""A device's camera, as numpy images for OpenCV, PyTorch, YOLO…
|
|
2
|
+
|
|
3
|
+
The stream is read straight from the device (MJPEG, or a still JPEG polled),
|
|
4
|
+
on a background thread that keeps only the newest picture: a slow model
|
|
5
|
+
always gets the latest frame, never a backlog.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Dict, Iterator, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CameraError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def decode_jpeg(data: bytes):
|
|
21
|
+
"""Image bytes (JPEG, PNG) → H×W×3 numpy array, BGR (OpenCV's order), with OpenCV or Pillow."""
|
|
22
|
+
try:
|
|
23
|
+
import cv2
|
|
24
|
+
import numpy as np
|
|
25
|
+
except ImportError:
|
|
26
|
+
cv2 = None
|
|
27
|
+
if cv2 is not None:
|
|
28
|
+
img = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR)
|
|
29
|
+
if img is None:
|
|
30
|
+
raise CameraError("could not decode a camera frame")
|
|
31
|
+
return img
|
|
32
|
+
try:
|
|
33
|
+
import numpy as np
|
|
34
|
+
from PIL import Image
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise CameraError("camera frames need numpy and OpenCV (pip install opencv-python) or Pillow (pip install pillow)") from None
|
|
37
|
+
return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))[:, :, ::-1].copy()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Camera:
|
|
41
|
+
"""One of the device's cameras. ``frame()`` is the newest picture;
|
|
42
|
+
``for img in camera.frames():`` yields each new one."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, info: Dict[str, Any]):
|
|
45
|
+
self.id: str = info.get("id", "cam")
|
|
46
|
+
self.label: str = info.get("label") or self.id
|
|
47
|
+
self.url: Optional[str] = info.get("url")
|
|
48
|
+
self.fmt: str = info.get("fmt", "mjpeg")
|
|
49
|
+
self._jpeg: Optional[bytes] = None
|
|
50
|
+
self._count = 0
|
|
51
|
+
self._cond = threading.Condition()
|
|
52
|
+
self._thread: Optional[threading.Thread] = None
|
|
53
|
+
self._stop = threading.Event()
|
|
54
|
+
self.error: Optional[str] = None
|
|
55
|
+
self.fps = 0.0
|
|
56
|
+
|
|
57
|
+
def __repr__(self):
|
|
58
|
+
return f"Camera({self.label!r}, {self.url!r})"
|
|
59
|
+
|
|
60
|
+
def _start(self) -> None:
|
|
61
|
+
if self._thread is not None:
|
|
62
|
+
return
|
|
63
|
+
if not self.url:
|
|
64
|
+
raise CameraError(f"camera {self.label} is not reachable from this computer")
|
|
65
|
+
self._thread = threading.Thread(target=self._read, daemon=True, name=f"koebelt-dash-camera-{self.id}")
|
|
66
|
+
self._thread.start()
|
|
67
|
+
|
|
68
|
+
def _publish(self, jpeg: bytes) -> None:
|
|
69
|
+
with self._cond:
|
|
70
|
+
self._jpeg = jpeg
|
|
71
|
+
self._count += 1
|
|
72
|
+
self._cond.notify_all()
|
|
73
|
+
|
|
74
|
+
def _read(self) -> None:
|
|
75
|
+
while not self._stop.is_set():
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(self.url, timeout=5) as r:
|
|
78
|
+
ctype = r.headers.get("Content-Type", "")
|
|
79
|
+
if self.fmt == "jpeg" or not ctype.startswith("multipart/"):
|
|
80
|
+
self._publish(r.read())
|
|
81
|
+
self._stop.wait(0.1)
|
|
82
|
+
continue
|
|
83
|
+
boundary = ctype.split("boundary=", 1)[-1].strip().strip('"').encode()
|
|
84
|
+
self._parts(r, b"--" + boundary.lstrip(b"-") if boundary else b"")
|
|
85
|
+
self.error = None
|
|
86
|
+
except Exception as e:
|
|
87
|
+
self.error = str(e)
|
|
88
|
+
self._stop.wait(1.0)
|
|
89
|
+
|
|
90
|
+
def _parts(self, r, boundary: bytes) -> None:
|
|
91
|
+
"""Reads multipart/x-mixed-replace parts: headers, then Content-Length
|
|
92
|
+
bytes (or, without a length, up to the next boundary)."""
|
|
93
|
+
rd = _Buffered(r)
|
|
94
|
+
last = time.monotonic()
|
|
95
|
+
while not self._stop.is_set():
|
|
96
|
+
line = rd.readline()
|
|
97
|
+
if line is None:
|
|
98
|
+
return
|
|
99
|
+
if not line.strip().startswith(b"--"):
|
|
100
|
+
continue
|
|
101
|
+
length = None
|
|
102
|
+
while True:
|
|
103
|
+
h = rd.readline()
|
|
104
|
+
if h is None:
|
|
105
|
+
return
|
|
106
|
+
if not h.strip():
|
|
107
|
+
break
|
|
108
|
+
k, _, v = h.decode("latin-1").partition(":")
|
|
109
|
+
if k.strip().lower() == "content-length":
|
|
110
|
+
length = int(v.strip())
|
|
111
|
+
data = rd.read(length) if length is not None else rd.until(b"\r\n" + boundary)
|
|
112
|
+
if data is None:
|
|
113
|
+
return
|
|
114
|
+
self._publish(data)
|
|
115
|
+
now = time.monotonic()
|
|
116
|
+
self.fps = self.fps * 0.9 + (1 / max(now - last, 1e-3)) * 0.1
|
|
117
|
+
last = now
|
|
118
|
+
|
|
119
|
+
def jpeg(self, timeout: float = 5.0) -> bytes:
|
|
120
|
+
"""The newest frame as the camera sends it (JPEG, or PNG for simulated cameras)."""
|
|
121
|
+
self._start()
|
|
122
|
+
with self._cond:
|
|
123
|
+
if not self._cond.wait_for(lambda: self._jpeg is not None, timeout=timeout):
|
|
124
|
+
raise CameraError(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
|
|
125
|
+
return self._jpeg # type: ignore[return-value]
|
|
126
|
+
|
|
127
|
+
def frame(self, timeout: float = 5.0):
|
|
128
|
+
"""The newest frame, H×W×3 BGR numpy array."""
|
|
129
|
+
return decode_jpeg(self.jpeg(timeout))
|
|
130
|
+
|
|
131
|
+
def frames(self, timeout: float = 5.0) -> Iterator[Any]:
|
|
132
|
+
"""Each new frame, newest first: frames the loop was too slow for are skipped."""
|
|
133
|
+
self._start()
|
|
134
|
+
seen = -1
|
|
135
|
+
while True:
|
|
136
|
+
with self._cond:
|
|
137
|
+
if not self._cond.wait_for(lambda: self._count != seen and self._jpeg is not None, timeout=timeout):
|
|
138
|
+
raise CameraError(f"no picture from camera {self.label} within {timeout} s" + (f": {self.error}" if self.error else ""))
|
|
139
|
+
seen, data = self._count, self._jpeg
|
|
140
|
+
yield decode_jpeg(data) # type: ignore[arg-type]
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
self._stop.set()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Buffered:
|
|
147
|
+
"""Line, length and marker reads over a live HTTP body, returning what has
|
|
148
|
+
arrived without waiting for more than it needs."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, r):
|
|
151
|
+
self.r = r
|
|
152
|
+
self.buf = b""
|
|
153
|
+
|
|
154
|
+
def _more(self) -> bool:
|
|
155
|
+
chunk = self.r.read1(65536) if hasattr(self.r, "read1") else self.r.read(4096)
|
|
156
|
+
if not chunk:
|
|
157
|
+
return False
|
|
158
|
+
self.buf += chunk
|
|
159
|
+
if len(self.buf) > 32_000_000:
|
|
160
|
+
raise CameraError("camera stream without frame boundaries")
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def readline(self) -> Optional[bytes]:
|
|
164
|
+
while b"\n" not in self.buf:
|
|
165
|
+
if not self._more():
|
|
166
|
+
return None
|
|
167
|
+
line, _, self.buf = self.buf.partition(b"\n")
|
|
168
|
+
return line + b"\n"
|
|
169
|
+
|
|
170
|
+
def read(self, n: int) -> Optional[bytes]:
|
|
171
|
+
while len(self.buf) < n:
|
|
172
|
+
if not self._more():
|
|
173
|
+
return None
|
|
174
|
+
data, self.buf = self.buf[:n], self.buf[n:]
|
|
175
|
+
return data
|
|
176
|
+
|
|
177
|
+
def until(self, marker: bytes) -> Optional[bytes]:
|
|
178
|
+
"""The bytes before marker; the marker stays for the next part."""
|
|
179
|
+
while marker not in self.buf:
|
|
180
|
+
if not self._more():
|
|
181
|
+
return None
|
|
182
|
+
i = self.buf.index(marker)
|
|
183
|
+
data, self.buf = self.buf[:i], self.buf[i + 2:]
|
|
184
|
+
return data
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def boxes_of(items: Any, width: Optional[float] = None, height: Optional[float] = None) -> list:
|
|
188
|
+
"""Boxes for dev.overlay(): from (x, y, w, h[, label]) tuples, normalised
|
|
189
|
+
or in pixels (with width and height), or straight from an ultralytics
|
|
190
|
+
result (``model(frame)[0]``) or its ``.boxes``."""
|
|
191
|
+
names = getattr(items, "names", None)
|
|
192
|
+
if hasattr(items, "boxes") and not hasattr(items, "xyxyn"):
|
|
193
|
+
items = items.boxes
|
|
194
|
+
if hasattr(items, "xyxyn"):
|
|
195
|
+
xyxy = items.xyxyn.tolist()
|
|
196
|
+
cls = items.cls.tolist() if hasattr(items, "cls") else [None] * len(xyxy)
|
|
197
|
+
conf = items.conf.tolist() if hasattr(items, "conf") else [None] * len(xyxy)
|
|
198
|
+
out = []
|
|
199
|
+
for (x1, y1, x2, y2), c, p in zip(xyxy, cls, conf):
|
|
200
|
+
label = "" if c is None else (names.get(int(c), str(int(c))) if isinstance(names, dict) else str(int(c)))
|
|
201
|
+
if p is not None:
|
|
202
|
+
label = f"{label} {p:.2f}".strip()
|
|
203
|
+
out.append([x1, y1, x2 - x1, y2 - y1, label])
|
|
204
|
+
return out
|
|
205
|
+
out = []
|
|
206
|
+
for b in items:
|
|
207
|
+
if hasattr(b, "xyxyn"):
|
|
208
|
+
out.extend(boxes_of(b))
|
|
209
|
+
continue
|
|
210
|
+
x, y, w, h = (float(v) for v in b[:4])
|
|
211
|
+
if width and height:
|
|
212
|
+
x, y, w, h = x / width, y / height, w / width, h / height
|
|
213
|
+
out.append([x, y, w, h, str(b[4]) if len(b) > 4 else ""])
|
|
214
|
+
return out
|