braidpipe 0.3.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.
- braidpipe-0.3.0/PKG-INFO +49 -0
- braidpipe-0.3.0/README.md +29 -0
- braidpipe-0.3.0/braidpipe/__init__.py +44 -0
- braidpipe-0.3.0/braidpipe/__main__.py +16 -0
- braidpipe-0.3.0/braidpipe/background.py +78 -0
- braidpipe-0.3.0/braidpipe/contract.py +32 -0
- braidpipe-0.3.0/braidpipe/py.typed +0 -0
- braidpipe-0.3.0/braidpipe/remote.py +137 -0
- braidpipe-0.3.0/braidpipe/runner.py +245 -0
- braidpipe-0.3.0/braidpipe/shm.py +126 -0
- braidpipe-0.3.0/braidpipe/worker.py +47 -0
- braidpipe-0.3.0/braidpipe.egg-info/PKG-INFO +49 -0
- braidpipe-0.3.0/braidpipe.egg-info/SOURCES.txt +17 -0
- braidpipe-0.3.0/braidpipe.egg-info/dependency_links.txt +1 -0
- braidpipe-0.3.0/braidpipe.egg-info/requires.txt +4 -0
- braidpipe-0.3.0/braidpipe.egg-info/top_level.txt +1 -0
- braidpipe-0.3.0/pyproject.toml +34 -0
- braidpipe-0.3.0/setup.cfg +4 -0
- braidpipe-0.3.0/tests/test_runner.py +249 -0
braidpipe-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: braidpipe
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop.
|
|
5
|
+
Author: braidpipe contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/emdiple/braidpipe
|
|
8
|
+
Keywords: video,streaming,gstreamer,computer-vision,shared-memory
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: examples
|
|
19
|
+
Requires-Dist: opencv-python; extra == "examples"
|
|
20
|
+
|
|
21
|
+
# braidpipe (worker SDK)
|
|
22
|
+
|
|
23
|
+
The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
|
|
24
|
+
never-dark AI video middleware. The Rust daemon owns the media path; this
|
|
25
|
+
package is how a Python process receives its frames — as zero-copy NumPy views
|
|
26
|
+
over shared memory locally, or over the tcp-raw transport from another machine —
|
|
27
|
+
and hands them back.
|
|
28
|
+
|
|
29
|
+
A complete worker:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import braidpipe
|
|
33
|
+
|
|
34
|
+
def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
|
|
35
|
+
frame[:, :, 0] //= 2 # your inference here
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
braidpipe.run(process)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run()` owns everything else: the handshake, the per-frame notification loop,
|
|
42
|
+
freeing the shared-memory slot, acking (including `"success": false` when your
|
|
43
|
+
code raises, so the stream falls back to passthrough instead of going dark),
|
|
44
|
+
and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
|
|
45
|
+
attaches over the network instead.
|
|
46
|
+
|
|
47
|
+
This package does not contain the daemon. Build it from the
|
|
48
|
+
[repository](https://github.com/emdiple/braidpipe), which also has the full
|
|
49
|
+
worker contract, examples, and deadline rules in `docs/workers.md`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# braidpipe (worker SDK)
|
|
2
|
+
|
|
3
|
+
The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
|
|
4
|
+
never-dark AI video middleware. The Rust daemon owns the media path; this
|
|
5
|
+
package is how a Python process receives its frames — as zero-copy NumPy views
|
|
6
|
+
over shared memory locally, or over the tcp-raw transport from another machine —
|
|
7
|
+
and hands them back.
|
|
8
|
+
|
|
9
|
+
A complete worker:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import braidpipe
|
|
13
|
+
|
|
14
|
+
def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
|
|
15
|
+
frame[:, :, 0] //= 2 # your inference here
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
braidpipe.run(process)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`run()` owns everything else: the handshake, the per-frame notification loop,
|
|
22
|
+
freeing the shared-memory slot, acking (including `"success": false` when your
|
|
23
|
+
code raises, so the stream falls back to passthrough instead of going dark),
|
|
24
|
+
and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
|
|
25
|
+
attaches over the network instead.
|
|
26
|
+
|
|
27
|
+
This package does not contain the daemon. Build it from the
|
|
28
|
+
[repository](https://github.com/emdiple/braidpipe), which also has the full
|
|
29
|
+
worker contract, examples, and deadline rules in `docs/workers.md`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The braidpipe worker SDK: your function, our loop.
|
|
2
|
+
|
|
3
|
+
A complete worker is a processing function handed to `run()`:
|
|
4
|
+
|
|
5
|
+
import braidpipe
|
|
6
|
+
|
|
7
|
+
def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
|
|
8
|
+
frame[:, :, 0] //= 2 # your inference here
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
braidpipe.run(process)
|
|
12
|
+
|
|
13
|
+
`run()` owns the handshake, the per-frame loop, slot release, acking (including
|
|
14
|
+
`"success": false` when the handler raises, so the stream falls back to
|
|
15
|
+
passthrough instead of going dark) and the transport: shared memory next to a
|
|
16
|
+
local daemon, tcp-raw when `BRAIDPIPE_DAEMON=host:port` is set. Take a second
|
|
17
|
+
`ctx` parameter for frame ids and timestamps, and use `BackgroundModel` for
|
|
18
|
+
models too slow for the 1.5-frame-period deadline.
|
|
19
|
+
|
|
20
|
+
The transports underneath (`attach`/`SharedMemoryManager` for shared memory,
|
|
21
|
+
`connect`/`RemoteWorkerLink` for tcp-raw) stay importable for workers that need
|
|
22
|
+
the loop itself.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .background import BackgroundModel
|
|
26
|
+
from .contract import CONTRACT_VERSION
|
|
27
|
+
from .remote import RemoteWorkerLink, connect
|
|
28
|
+
from .runner import FrameContext, run, worker
|
|
29
|
+
from .shm import SharedMemoryManager, attach
|
|
30
|
+
|
|
31
|
+
__version__ = "0.3.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BackgroundModel",
|
|
35
|
+
"CONTRACT_VERSION",
|
|
36
|
+
"FrameContext",
|
|
37
|
+
"RemoteWorkerLink",
|
|
38
|
+
"SharedMemoryManager",
|
|
39
|
+
"attach",
|
|
40
|
+
"connect",
|
|
41
|
+
"run",
|
|
42
|
+
"worker",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""`python3 -m braidpipe`: the raw worker with no processing at all.
|
|
2
|
+
|
|
3
|
+
Attaches, acks every frame untouched, and proves the AI loop is closed — the
|
|
4
|
+
same thing python/braidpipe/worker.py does, without needing a path to a script.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .runner import run
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def process(frame: np.ndarray) -> None:
|
|
13
|
+
"""Deliberately empty: every frame passes through the loop unmodified."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
run(process, name="braidpipe")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Off-hot-path inference: run a slow model on a thread, read cached results.
|
|
2
|
+
|
|
3
|
+
The relay gives a worker 1.5 frame periods, and most real models do not fit in
|
|
4
|
+
that. The trade that works is to decouple "what is in the frame" (slow, cached)
|
|
5
|
+
from "draw it" (fast, every frame): submit an occasional frame to the model,
|
|
6
|
+
annotate every frame with the most recent answer. Results lag the picture by a
|
|
7
|
+
frame or two; no frame ever misses its deadline.
|
|
8
|
+
|
|
9
|
+
`BackgroundModel` is that pattern with the threading taken out of your hands:
|
|
10
|
+
|
|
11
|
+
model = braidpipe.BackgroundModel(infer, initial=[])
|
|
12
|
+
|
|
13
|
+
def process(frame, ctx):
|
|
14
|
+
if ctx.frame_id % 3 == 0:
|
|
15
|
+
model.submit(frame)
|
|
16
|
+
draw(frame, model.latest())
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
from typing import Callable, Generic, TypeVar
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BackgroundModel(Generic[T]):
|
|
30
|
+
"""Runs `infer(frame) -> result` on a daemon thread, one frame at a time.
|
|
31
|
+
|
|
32
|
+
`submit()` copies the frame — the shared-memory slot is recycled the moment
|
|
33
|
+
the handler returns — and is dropped, not queued, while a previous frame is
|
|
34
|
+
still being inferred, so the model always works on the newest picture it
|
|
35
|
+
can get. `latest()` returns the most recent result, or `initial` until the
|
|
36
|
+
first inference completes. An exception in `infer` is logged and the result
|
|
37
|
+
left unchanged; the thread never dies.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, infer: Callable[[np.ndarray], T], initial: T = None):
|
|
41
|
+
self._infer = infer
|
|
42
|
+
self._pending: np.ndarray | None = None
|
|
43
|
+
self._latest: T = initial
|
|
44
|
+
self._lock = threading.Lock()
|
|
45
|
+
self._wake = threading.Event()
|
|
46
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
47
|
+
self._thread.start()
|
|
48
|
+
|
|
49
|
+
def submit(self, frame: np.ndarray) -> None:
|
|
50
|
+
"""Offer a frame for inference; skipped if one is already waiting."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
if self._pending is not None:
|
|
53
|
+
return
|
|
54
|
+
self._pending = frame.copy()
|
|
55
|
+
self._wake.set()
|
|
56
|
+
|
|
57
|
+
def latest(self) -> T:
|
|
58
|
+
with self._lock:
|
|
59
|
+
return self._latest
|
|
60
|
+
|
|
61
|
+
def _loop(self) -> None:
|
|
62
|
+
while True:
|
|
63
|
+
self._wake.wait()
|
|
64
|
+
self._wake.clear()
|
|
65
|
+
|
|
66
|
+
with self._lock:
|
|
67
|
+
frame, self._pending = self._pending, None
|
|
68
|
+
if frame is None:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = self._infer(frame)
|
|
73
|
+
except Exception as exc: # a bad frame must not kill the thread
|
|
74
|
+
print(f"[background] inference error: {exc}", flush=True)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._latest = result
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The IPC contract version this SDK speaks, and the check against the daemon.
|
|
2
|
+
|
|
3
|
+
The contract is everything both sides must agree on: the shm/slot layouts, the
|
|
4
|
+
control-packet fields, and the tcp-raw wire format. The daemon stamps its
|
|
5
|
+
version into every config packet it answers a hello with; a mismatch here must
|
|
6
|
+
fail at attach time, loudly, because the alternative is silently misreading
|
|
7
|
+
shared memory.
|
|
8
|
+
|
|
9
|
+
A config with no version at all comes from a daemon older than versioning
|
|
10
|
+
(braidpipe <= 0.2.0). Those daemons speak contract 1, so attaching proceeds
|
|
11
|
+
with a warning rather than refusing streams that would work.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
CONTRACT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_contract(config: dict, transport: str) -> None:
|
|
18
|
+
"""Raises RuntimeError if the daemon's config names a different contract."""
|
|
19
|
+
theirs = config.get("contract")
|
|
20
|
+
if theirs is None:
|
|
21
|
+
print(
|
|
22
|
+
f"[braidpipe] warning: daemon sent no contract version over {transport} "
|
|
23
|
+
f"(daemon <= 0.2.0); assuming contract {CONTRACT_VERSION}",
|
|
24
|
+
flush=True,
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
if theirs != CONTRACT_VERSION:
|
|
28
|
+
raise RuntimeError(
|
|
29
|
+
f"daemon speaks IPC contract {theirs}, this SDK speaks {CONTRACT_VERSION} "
|
|
30
|
+
f"(over {transport}); upgrade whichever side is older instead of attaching "
|
|
31
|
+
"and misreading frames"
|
|
32
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The tcp-raw transport: attach to a braidpipe daemon on another machine.
|
|
2
|
+
|
|
3
|
+
Mirrors the shm handshake in shape: say hello, get a config packet back.
|
|
4
|
+
Here the hello goes over UDP to the daemon's --worker-listen address, the
|
|
5
|
+
config carries a TCP port instead of a file descriptor, and frames flow both
|
|
6
|
+
ways on one TCP connection as a fixed 24-byte header plus raw pixel bytes.
|
|
7
|
+
|
|
8
|
+
The frames are uncompressed, so this needs a fast link: 720p RGB at 30 fps is
|
|
9
|
+
~660 Mbit/s each way. Use it on a 10 GbE LAN (or 720p on a quiet gigabit
|
|
10
|
+
link), not across the internet.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import socket
|
|
15
|
+
import struct
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .contract import check_contract
|
|
21
|
+
|
|
22
|
+
# Wire header matching Rust net.rs, 24 bytes little-endian:
|
|
23
|
+
# frame_id u64, time_us u64, payload_len u32, slot u8, flags u8, 2 pad.
|
|
24
|
+
# time_us carries the capture timestamp daemon -> worker and this worker's
|
|
25
|
+
# processing time on the way back. Bit 0 of flags is "success" on results.
|
|
26
|
+
WIRE_HEADER_FMT = "<QQIBB2x"
|
|
27
|
+
WIRE_HEADER_SIZE = struct.calcsize(WIRE_HEADER_FMT)
|
|
28
|
+
assert WIRE_HEADER_SIZE == 24
|
|
29
|
+
|
|
30
|
+
HELLO = b'{"type":"hello","transports":["tcp-raw"]}'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def connect(daemon: str, retry_interval: float = 1.0) -> "RemoteWorkerLink":
|
|
34
|
+
"""Negotiates with the daemon at "host:port" until a data connection is up.
|
|
35
|
+
|
|
36
|
+
Retries forever, so a worker may start before the daemon and simply wait
|
|
37
|
+
for it -- the same contract the shm attach gives a local worker.
|
|
38
|
+
"""
|
|
39
|
+
host, _, port_text = daemon.rpartition(":")
|
|
40
|
+
address = (host, int(port_text))
|
|
41
|
+
|
|
42
|
+
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
43
|
+
udp.settimeout(retry_interval)
|
|
44
|
+
try:
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
udp.sendto(HELLO, address)
|
|
48
|
+
reply, _ = udp.recvfrom(512)
|
|
49
|
+
except (TimeoutError, OSError):
|
|
50
|
+
time.sleep(retry_interval)
|
|
51
|
+
continue
|
|
52
|
+
config = json.loads(reply)
|
|
53
|
+
if config.get("type") == "config" and config.get("transport") == "tcp-raw":
|
|
54
|
+
check_contract(config, "tcp-raw")
|
|
55
|
+
break
|
|
56
|
+
raise RuntimeError(f"daemon refused tcp-raw: {config}")
|
|
57
|
+
finally:
|
|
58
|
+
udp.close()
|
|
59
|
+
|
|
60
|
+
tcp = socket.create_connection((host, config["data_port"]))
|
|
61
|
+
tcp.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
62
|
+
return RemoteWorkerLink(tcp, config)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RemoteWorkerLink:
|
|
66
|
+
def __init__(self, sock: socket.socket, config: dict):
|
|
67
|
+
self.sock = sock
|
|
68
|
+
self.width = config["width"]
|
|
69
|
+
self.height = config["height"]
|
|
70
|
+
self.channels = config["channels"]
|
|
71
|
+
self._payload_len = self.width * self.height * self.channels
|
|
72
|
+
# One reusable buffer: frames are processed in place and sent back
|
|
73
|
+
# from the same bytes, so no per-frame allocation happens.
|
|
74
|
+
self._buf = bytearray(self._payload_len)
|
|
75
|
+
self._frame_view = np.frombuffer(self._buf, dtype=np.uint8).reshape(
|
|
76
|
+
self.height, self.width, self.channels
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def frames(self):
|
|
80
|
+
"""Yields (frame_id, slot, timestamp_us, frame) until the daemon hangs up.
|
|
81
|
+
|
|
82
|
+
`frame` is a NumPy view over an internal buffer that is reused for the
|
|
83
|
+
next frame -- process it (in place is fine) and call `send_processed`
|
|
84
|
+
before advancing the loop; copy it if you need to keep it longer.
|
|
85
|
+
"""
|
|
86
|
+
header_buf = bytearray(WIRE_HEADER_SIZE)
|
|
87
|
+
frame = self._frame_view
|
|
88
|
+
while True:
|
|
89
|
+
if not self._recv_exact(header_buf):
|
|
90
|
+
return
|
|
91
|
+
frame_id, timestamp_us, payload_len, slot, _flags = struct.unpack(
|
|
92
|
+
WIRE_HEADER_FMT, header_buf
|
|
93
|
+
)
|
|
94
|
+
if payload_len != self._payload_len:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"daemon sent {payload_len} payload bytes, expected {self._payload_len}"
|
|
97
|
+
)
|
|
98
|
+
if not self._recv_exact(self._buf):
|
|
99
|
+
return
|
|
100
|
+
yield frame_id, slot, timestamp_us, frame
|
|
101
|
+
|
|
102
|
+
def send_processed(
|
|
103
|
+
self,
|
|
104
|
+
frame_id: int,
|
|
105
|
+
slot: int,
|
|
106
|
+
frame: np.ndarray,
|
|
107
|
+
processing_time_us: int,
|
|
108
|
+
success: bool = True,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Returns a result to the daemon; this doubles as the frame's ack."""
|
|
111
|
+
header = struct.pack(
|
|
112
|
+
WIRE_HEADER_FMT,
|
|
113
|
+
frame_id,
|
|
114
|
+
processing_time_us,
|
|
115
|
+
self._payload_len,
|
|
116
|
+
slot,
|
|
117
|
+
1 if success else 0,
|
|
118
|
+
)
|
|
119
|
+
self.sock.sendall(header)
|
|
120
|
+
if np.shares_memory(frame, self._frame_view):
|
|
121
|
+
# The frame is (a view of) the internal buffer; send it as-is.
|
|
122
|
+
self.sock.sendall(self._buf)
|
|
123
|
+
else:
|
|
124
|
+
self.sock.sendall(np.ascontiguousarray(frame).tobytes())
|
|
125
|
+
|
|
126
|
+
def _recv_exact(self, buf) -> bool:
|
|
127
|
+
"""Fills `buf` completely, or returns False on a closed connection."""
|
|
128
|
+
view = memoryview(buf)
|
|
129
|
+
while view:
|
|
130
|
+
received = self.sock.recv_into(view, len(view))
|
|
131
|
+
if received == 0:
|
|
132
|
+
return False
|
|
133
|
+
view = view[received:]
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
self.sock.close()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""The worker loop as a library: write `process(frame)`, call `run(process)`.
|
|
2
|
+
|
|
3
|
+
Every worker has the same four obligations — attach, receive a notification per
|
|
4
|
+
frame, free the slot, ack — and the same failure contract: report an exception
|
|
5
|
+
with `"success": false` instead of dying, so the daemon passes the original
|
|
6
|
+
frame through and the stream never goes dark. This module owns all of that,
|
|
7
|
+
for both transports:
|
|
8
|
+
|
|
9
|
+
- shared memory + Unix datagrams when launched by (or next to) a local daemon,
|
|
10
|
+
- tcp-raw when `BRAIDPIPE_DAEMON=host:port` points at a remote one.
|
|
11
|
+
|
|
12
|
+
The processing callback is the only thing a worker author writes. It takes the
|
|
13
|
+
frame — an (H, W, 3) uint8 RGB array to mutate in place — and optionally a
|
|
14
|
+
`FrameContext`; raising is safe and costs one passthrough frame, but the
|
|
15
|
+
callback must finish inside 1.5 frame periods (50 ms at 30 fps) or the daemon
|
|
16
|
+
falls back to passthrough. Anything slower belongs off the hot path — see
|
|
17
|
+
`braidpipe.BackgroundModel`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import socket
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Callable
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from .remote import connect
|
|
33
|
+
from .shm import attach
|
|
34
|
+
|
|
35
|
+
DEFAULT_RUST_SOCK = "/tmp/braidpipe_rust.sock"
|
|
36
|
+
DEFAULT_PYTHON_SOCK = "/tmp/braidpipe_python.sock"
|
|
37
|
+
|
|
38
|
+
ProcessFn = Callable[..., None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class FrameContext:
|
|
43
|
+
"""Everything known about the frame besides its pixels.
|
|
44
|
+
|
|
45
|
+
`timestamp_us` is the wall clock the daemon recorded as it handed the frame
|
|
46
|
+
over (written into the slot header locally, carried on the wire remotely),
|
|
47
|
+
so `time.time_ns() // 1000 - ctx.timestamp_us` is the one-way IPC delay.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
frame_id: int
|
|
51
|
+
slot: int
|
|
52
|
+
timestamp_us: int
|
|
53
|
+
width: int
|
|
54
|
+
height: int
|
|
55
|
+
channels: int
|
|
56
|
+
transport: str # "shm" or "tcp-raw"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_registered: ProcessFn | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def worker(process: ProcessFn) -> ProcessFn:
|
|
63
|
+
"""Marks `process` as this script's frame handler, so `run()` finds it
|
|
64
|
+
without being passed anything. Sugar only: `run(process)` is the same."""
|
|
65
|
+
global _registered
|
|
66
|
+
_registered = process
|
|
67
|
+
return process
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _wants_context(process: ProcessFn) -> bool:
|
|
71
|
+
"""Whether to call `process(frame, ctx)` or just `process(frame)`.
|
|
72
|
+
|
|
73
|
+
Decided once from the signature, not per frame; anything uninspectable is
|
|
74
|
+
given both arguments.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
parameters = inspect.signature(process).parameters.values()
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
return True
|
|
80
|
+
positional = [
|
|
81
|
+
p
|
|
82
|
+
for p in parameters
|
|
83
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
84
|
+
]
|
|
85
|
+
return len(positional) >= 2 or any(p.kind == p.VAR_POSITIONAL for p in parameters)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(
|
|
89
|
+
process: ProcessFn | None = None,
|
|
90
|
+
*,
|
|
91
|
+
daemon: str | None = None,
|
|
92
|
+
rust_sock: str | None = None,
|
|
93
|
+
python_sock: str | None = None,
|
|
94
|
+
name: str = "worker",
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Runs the worker loop until the daemon hangs up or Ctrl-C.
|
|
97
|
+
|
|
98
|
+
Arguments not given fall back to the environment: `BRAIDPIPE_DAEMON`
|
|
99
|
+
selects the tcp-raw transport, otherwise `BRAIDPIPE_RUST_SOCK` and
|
|
100
|
+
`BRAIDPIPE_PYTHON_SOCK` name the local sockets. `name` is only the log
|
|
101
|
+
prefix. Returns normally on shutdown, so final reporting can follow it.
|
|
102
|
+
"""
|
|
103
|
+
if process is None:
|
|
104
|
+
process = _registered
|
|
105
|
+
if process is None:
|
|
106
|
+
raise TypeError(
|
|
107
|
+
"no frame handler: pass one to run() or decorate it with @braidpipe.worker"
|
|
108
|
+
)
|
|
109
|
+
wants_ctx = _wants_context(process)
|
|
110
|
+
|
|
111
|
+
if daemon is None:
|
|
112
|
+
daemon = os.environ.get("BRAIDPIPE_DAEMON")
|
|
113
|
+
if daemon:
|
|
114
|
+
_run_remote(process, wants_ctx, daemon, name)
|
|
115
|
+
else:
|
|
116
|
+
_run_shm(
|
|
117
|
+
process,
|
|
118
|
+
wants_ctx,
|
|
119
|
+
rust_sock or os.environ.get("BRAIDPIPE_RUST_SOCK", DEFAULT_RUST_SOCK),
|
|
120
|
+
python_sock or os.environ.get("BRAIDPIPE_PYTHON_SOCK", DEFAULT_PYTHON_SOCK),
|
|
121
|
+
name,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _invoke(
|
|
126
|
+
process: ProcessFn,
|
|
127
|
+
wants_ctx: bool,
|
|
128
|
+
frame: np.ndarray,
|
|
129
|
+
ctx: FrameContext,
|
|
130
|
+
name: str,
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Calls the handler; an exception becomes a failed (passthrough) frame."""
|
|
133
|
+
try:
|
|
134
|
+
if wants_ctx:
|
|
135
|
+
process(frame, ctx)
|
|
136
|
+
else:
|
|
137
|
+
process(frame)
|
|
138
|
+
return True
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
print(f"[{name}] frame {ctx.frame_id} failed: {exc}", flush=True)
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_shm(
|
|
145
|
+
process: ProcessFn,
|
|
146
|
+
wants_ctx: bool,
|
|
147
|
+
rust_sock_path: str,
|
|
148
|
+
python_sock_path: str,
|
|
149
|
+
name: str,
|
|
150
|
+
) -> None:
|
|
151
|
+
if os.path.exists(python_sock_path):
|
|
152
|
+
os.remove(python_sock_path)
|
|
153
|
+
|
|
154
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
155
|
+
sock.bind(python_sock_path)
|
|
156
|
+
|
|
157
|
+
# Handshake: the daemon answers our hello with the shared-memory fd.
|
|
158
|
+
shm = attach(sock, rust_sock_path)
|
|
159
|
+
print(
|
|
160
|
+
f"[{name}] attached to SHM ({shm.width}x{shm.height} @ {shm.channels}ch)",
|
|
161
|
+
flush=True,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
while True:
|
|
166
|
+
packet = json.loads(sock.recvfrom(512)[0])
|
|
167
|
+
if "frame_id" not in packet:
|
|
168
|
+
continue # a control packet, e.g. a duplicate handshake reply
|
|
169
|
+
frame_id = packet["frame_id"]
|
|
170
|
+
slot_idx = packet["slot_index"]
|
|
171
|
+
started = time.perf_counter_ns()
|
|
172
|
+
|
|
173
|
+
# The daemon's write time must be read before the slot is freed,
|
|
174
|
+
# or the next frame may already have overwritten the header.
|
|
175
|
+
_, _, written_us = shm.read_slot_header(slot_idx)
|
|
176
|
+
ctx = FrameContext(
|
|
177
|
+
frame_id=frame_id,
|
|
178
|
+
slot=slot_idx,
|
|
179
|
+
timestamp_us=written_us,
|
|
180
|
+
width=shm.width,
|
|
181
|
+
height=shm.height,
|
|
182
|
+
channels=shm.channels,
|
|
183
|
+
transport="shm",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
frame = shm.get_slot_numpy_array(slot_idx)
|
|
187
|
+
success = _invoke(process, wants_ctx, frame, ctx, name)
|
|
188
|
+
|
|
189
|
+
# The slot is recycled the moment it is freed: the handler must
|
|
190
|
+
# have copied any pixels it wants to keep.
|
|
191
|
+
shm.mark_slot_free(slot_idx)
|
|
192
|
+
|
|
193
|
+
ack = {
|
|
194
|
+
"frame_id": frame_id,
|
|
195
|
+
"slot_index": slot_idx,
|
|
196
|
+
"processing_time_us": (time.perf_counter_ns() - started) // 1000,
|
|
197
|
+
"success": success,
|
|
198
|
+
}
|
|
199
|
+
try:
|
|
200
|
+
sock.sendto(json.dumps(ack).encode("utf-8"), rust_sock_path)
|
|
201
|
+
except OSError as exc:
|
|
202
|
+
# A full datagram buffer is backpressure, not a fatal error.
|
|
203
|
+
print(f"[{name}] dropped ack for frame {frame_id}: {exc}", flush=True)
|
|
204
|
+
|
|
205
|
+
except KeyboardInterrupt:
|
|
206
|
+
print(f"[{name}] shutting down cleanly...", flush=True)
|
|
207
|
+
finally:
|
|
208
|
+
sock.close()
|
|
209
|
+
shm.close()
|
|
210
|
+
if os.path.exists(python_sock_path):
|
|
211
|
+
os.remove(python_sock_path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _run_remote(process: ProcessFn, wants_ctx: bool, daemon: str, name: str) -> None:
|
|
215
|
+
link = connect(daemon)
|
|
216
|
+
print(
|
|
217
|
+
f"[{name}] connected to daemon at {daemon} "
|
|
218
|
+
f"({link.width}x{link.height} @ {link.channels}ch)",
|
|
219
|
+
flush=True,
|
|
220
|
+
)
|
|
221
|
+
try:
|
|
222
|
+
for frame_id, slot_idx, timestamp_us, frame in link.frames():
|
|
223
|
+
started = time.perf_counter_ns()
|
|
224
|
+
ctx = FrameContext(
|
|
225
|
+
frame_id=frame_id,
|
|
226
|
+
slot=slot_idx,
|
|
227
|
+
timestamp_us=timestamp_us,
|
|
228
|
+
width=link.width,
|
|
229
|
+
height=link.height,
|
|
230
|
+
channels=link.channels,
|
|
231
|
+
transport="tcp-raw",
|
|
232
|
+
)
|
|
233
|
+
success = _invoke(process, wants_ctx, frame, ctx, name)
|
|
234
|
+
link.send_processed(
|
|
235
|
+
frame_id,
|
|
236
|
+
slot_idx,
|
|
237
|
+
frame,
|
|
238
|
+
(time.perf_counter_ns() - started) // 1000,
|
|
239
|
+
success,
|
|
240
|
+
)
|
|
241
|
+
print(f"[{name}] daemon hung up", flush=True)
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
print(f"[{name}] shutting down cleanly...", flush=True)
|
|
244
|
+
finally:
|
|
245
|
+
link.close()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import mmap
|
|
3
|
+
import os
|
|
4
|
+
import socket as socket_module
|
|
5
|
+
import struct
|
|
6
|
+
import time
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .contract import check_contract
|
|
10
|
+
|
|
11
|
+
# Slot State Constants matching Rust shm.rs
|
|
12
|
+
SLOT_FREE = 0
|
|
13
|
+
SLOT_READY_FOR_AI = 1
|
|
14
|
+
SLOT_PROCESSING = 2
|
|
15
|
+
|
|
16
|
+
# Struct layouts matching the explicit #[repr(C)] layouts in Rust shm.rs:
|
|
17
|
+
# ShmHeader: width u32, height u32, channels u8, slot_count u8, 2 pad bytes,
|
|
18
|
+
# slot_size u32, 16 reserved bytes -> 32 bytes total
|
|
19
|
+
HEADER_FMT = "<IIBB2xI16s"
|
|
20
|
+
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
|
21
|
+
assert HEADER_SIZE == 32
|
|
22
|
+
|
|
23
|
+
# SlotHeader: state u8, 7 pad bytes, frame_id u64, timestamp_us u64 -> 24 bytes
|
|
24
|
+
SLOT_HEADER_FMT = "<B7xQQ"
|
|
25
|
+
SLOT_HEADER_SIZE = struct.calcsize(SLOT_HEADER_FMT)
|
|
26
|
+
assert SLOT_HEADER_SIZE == 24
|
|
27
|
+
|
|
28
|
+
# The handshake: a worker announces itself with HELLO on the daemon's socket
|
|
29
|
+
# and receives a datagram carrying the shared segment's fd as SCM_RIGHTS
|
|
30
|
+
# ancillary data. The segment is anonymous -- the fd is the only way in.
|
|
31
|
+
HELLO = b'{"type":"hello"}'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def attach(sock: socket_module.socket, rust_sock_path: str, retry_interval: float = 1.0):
|
|
35
|
+
"""Says hello to the daemon until it answers with the shared-memory fd.
|
|
36
|
+
|
|
37
|
+
`sock` must already be bound to this worker's own socket path, because the
|
|
38
|
+
daemon addresses its reply there. Retries forever, so a worker may start
|
|
39
|
+
before the daemon and simply wait for it.
|
|
40
|
+
"""
|
|
41
|
+
previous_timeout = sock.gettimeout()
|
|
42
|
+
sock.settimeout(retry_interval)
|
|
43
|
+
try:
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
sock.sendto(HELLO, rust_sock_path)
|
|
47
|
+
except OSError:
|
|
48
|
+
# The daemon is not up (no socket file yet); try again.
|
|
49
|
+
time.sleep(retry_interval)
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
msg, fds, _flags, _addr = socket_module.recv_fds(sock, 512, 1)
|
|
53
|
+
except TimeoutError:
|
|
54
|
+
continue
|
|
55
|
+
except OSError:
|
|
56
|
+
time.sleep(retry_interval)
|
|
57
|
+
continue
|
|
58
|
+
if fds:
|
|
59
|
+
# The config body riding beside the fd names the daemon's
|
|
60
|
+
# contract version; refuse the fd rather than misread it.
|
|
61
|
+
try:
|
|
62
|
+
config = json.loads(msg)
|
|
63
|
+
except ValueError:
|
|
64
|
+
config = {}
|
|
65
|
+
try:
|
|
66
|
+
check_contract(config, "shm")
|
|
67
|
+
except Exception:
|
|
68
|
+
os.close(fds[0])
|
|
69
|
+
raise
|
|
70
|
+
return SharedMemoryManager(fds[0])
|
|
71
|
+
# A frame notification that raced the handshake; ignore it. The
|
|
72
|
+
# daemon passes those frames through unchanged.
|
|
73
|
+
finally:
|
|
74
|
+
sock.settimeout(previous_timeout)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SharedMemoryManager:
|
|
78
|
+
def __init__(self, fd: int):
|
|
79
|
+
# The fd received from the daemon describes the whole segment; its
|
|
80
|
+
# size comes from the fd itself, and everything else from the header.
|
|
81
|
+
size = os.fstat(fd).st_size
|
|
82
|
+
self.shm = mmap.mmap(fd, size)
|
|
83
|
+
os.close(fd) # the mapping keeps the segment alive on its own
|
|
84
|
+
|
|
85
|
+
# Unpack header metadata
|
|
86
|
+
header_bytes = bytes(self.shm[:HEADER_SIZE])
|
|
87
|
+
(
|
|
88
|
+
self.width,
|
|
89
|
+
self.height,
|
|
90
|
+
self.channels,
|
|
91
|
+
self.slot_count,
|
|
92
|
+
self.slot_size,
|
|
93
|
+
_,
|
|
94
|
+
) = struct.unpack(HEADER_FMT, header_bytes)
|
|
95
|
+
|
|
96
|
+
def get_slot_numpy_array(self, slot_idx: int) -> np.ndarray:
|
|
97
|
+
"""Returns a zero-copy NumPy view over a specific SHM slot's pixel buffer."""
|
|
98
|
+
slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
|
|
99
|
+
payload_offset = slot_offset + SLOT_HEADER_SIZE
|
|
100
|
+
payload_bytes = self.width * self.height * self.channels
|
|
101
|
+
|
|
102
|
+
# Point NumPy directly to the shared memory buffer slice
|
|
103
|
+
return np.ndarray(
|
|
104
|
+
shape=(self.height, self.width, self.channels),
|
|
105
|
+
dtype=np.uint8,
|
|
106
|
+
buffer=self.shm,
|
|
107
|
+
offset=payload_offset,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def read_slot_header(self, slot_idx: int) -> tuple[int, int, int]:
|
|
111
|
+
"""Returns (state, frame_id, timestamp_us) for a slot.
|
|
112
|
+
|
|
113
|
+
`timestamp_us` is the wall clock the daemon recorded as it wrote the
|
|
114
|
+
frame, so subtracting `time.time()` here gives the one-way IPC delay.
|
|
115
|
+
"""
|
|
116
|
+
slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
|
|
117
|
+
raw = bytes(self.shm[slot_offset : slot_offset + SLOT_HEADER_SIZE])
|
|
118
|
+
return struct.unpack(SLOT_HEADER_FMT, raw)
|
|
119
|
+
|
|
120
|
+
def mark_slot_free(self, slot_idx: int):
|
|
121
|
+
"""Resets the slot state back to FREE so Rust can write the next frame."""
|
|
122
|
+
slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
|
|
123
|
+
self.shm[slot_offset] = SLOT_FREE
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
self.shm.close()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The raw worker template: your code in `process()`, the SDK does the rest.
|
|
2
|
+
|
|
3
|
+
This is the file to copy when writing your own worker. The braidpipe package
|
|
4
|
+
owns the whole IPC contract — attach, one datagram per frame, a zero-copy NumPy
|
|
5
|
+
view of the slot, slot release, the ack — and an exception in `process()` costs
|
|
6
|
+
one passthrough frame, never the stream. The one thing this template does not
|
|
7
|
+
do is touch the pixels.
|
|
8
|
+
|
|
9
|
+
Demonstration workers live in examples/ — an edge transform, threaded YOLO
|
|
10
|
+
detection, and latency stamping.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
cargo run -p braidpipe --release -- --python-script python/braidpipe/worker.py
|
|
14
|
+
|
|
15
|
+
Environment:
|
|
16
|
+
BRAIDPIPE_DAEMON a daemon's --worker-listen address; switches the
|
|
17
|
+
worker to the tcp-raw transport (default: unset)
|
|
18
|
+
BRAIDPIPE_RUST_SOCK daemon's ack socket (default: /tmp/braidpipe_rust.sock)
|
|
19
|
+
BRAIDPIPE_PYTHON_SOCK this worker's socket (default: /tmp/braidpipe_python.sock)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import braidpipe
|
|
26
|
+
except ModuleNotFoundError: # run from a source checkout, package not installed
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
31
|
+
import braidpipe
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def process(frame: np.ndarray) -> None:
|
|
35
|
+
"""Your code goes here. Modify `frame` in place; writing to the view *is*
|
|
36
|
+
writing the output.
|
|
37
|
+
|
|
38
|
+
Frames are RGB, not BGR. Finish inside 1.5 frame periods (50 ms at 30 fps)
|
|
39
|
+
or the daemon falls back to passthrough; anything slower than that belongs
|
|
40
|
+
on a thread with cached results — see braidpipe.BackgroundModel and
|
|
41
|
+
examples/worker_detect.py. Take a second `ctx` parameter if you need frame
|
|
42
|
+
ids or timestamps.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
braidpipe.run(process)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: braidpipe
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop.
|
|
5
|
+
Author: braidpipe contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/emdiple/braidpipe
|
|
8
|
+
Keywords: video,streaming,gstreamer,computer-vision,shared-memory
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: numpy>=1.24
|
|
18
|
+
Provides-Extra: examples
|
|
19
|
+
Requires-Dist: opencv-python; extra == "examples"
|
|
20
|
+
|
|
21
|
+
# braidpipe (worker SDK)
|
|
22
|
+
|
|
23
|
+
The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
|
|
24
|
+
never-dark AI video middleware. The Rust daemon owns the media path; this
|
|
25
|
+
package is how a Python process receives its frames — as zero-copy NumPy views
|
|
26
|
+
over shared memory locally, or over the tcp-raw transport from another machine —
|
|
27
|
+
and hands them back.
|
|
28
|
+
|
|
29
|
+
A complete worker:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import braidpipe
|
|
33
|
+
|
|
34
|
+
def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
|
|
35
|
+
frame[:, :, 0] //= 2 # your inference here
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
braidpipe.run(process)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`run()` owns everything else: the handshake, the per-frame notification loop,
|
|
42
|
+
freeing the shared-memory slot, acking (including `"success": false` when your
|
|
43
|
+
code raises, so the stream falls back to passthrough instead of going dark),
|
|
44
|
+
and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
|
|
45
|
+
attaches over the network instead.
|
|
46
|
+
|
|
47
|
+
This package does not contain the daemon. Build it from the
|
|
48
|
+
[repository](https://github.com/emdiple/braidpipe), which also has the full
|
|
49
|
+
worker contract, examples, and deadline rules in `docs/workers.md`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
braidpipe/__init__.py
|
|
4
|
+
braidpipe/__main__.py
|
|
5
|
+
braidpipe/background.py
|
|
6
|
+
braidpipe/contract.py
|
|
7
|
+
braidpipe/py.typed
|
|
8
|
+
braidpipe/remote.py
|
|
9
|
+
braidpipe/runner.py
|
|
10
|
+
braidpipe/shm.py
|
|
11
|
+
braidpipe/worker.py
|
|
12
|
+
braidpipe.egg-info/PKG-INFO
|
|
13
|
+
braidpipe.egg-info/SOURCES.txt
|
|
14
|
+
braidpipe.egg-info/dependency_links.txt
|
|
15
|
+
braidpipe.egg-info/requires.txt
|
|
16
|
+
braidpipe.egg-info/top_level.txt
|
|
17
|
+
tests/test_runner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
braidpipe
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "braidpipe"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "braidpipe contributors" }]
|
|
13
|
+
keywords = ["video", "streaming", "gstreamer", "computer-vision", "shared-memory"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: POSIX :: Linux",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Video",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["numpy>=1.24"]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
examples = ["opencv-python"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/emdiple/braidpipe"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
include = ["braidpipe*"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
braidpipe = ["py.typed"]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""SDK tests against a fake daemon: pure Python, no Rust or GStreamer needed.
|
|
2
|
+
|
|
3
|
+
Each test stands up the daemon side of one transport — the UDS handshake with a
|
|
4
|
+
real fd for shared memory, a UDP-hello/TCP-data pair for tcp-raw — and runs
|
|
5
|
+
`braidpipe.run()` against it, asserting the contract the Rust daemon relies on:
|
|
6
|
+
pixels mutated in place, slots freed, every frame acked, and an exception in
|
|
7
|
+
the handler reported as `"success": false` rather than a dead worker.
|
|
8
|
+
|
|
9
|
+
Run with: python3 -m unittest discover python/tests
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import array
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
import struct
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import unittest
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
26
|
+
|
|
27
|
+
import braidpipe
|
|
28
|
+
from braidpipe.runner import _wants_context
|
|
29
|
+
from braidpipe.shm import HEADER_FMT, HEADER_SIZE, SLOT_HEADER_FMT, SLOT_HEADER_SIZE
|
|
30
|
+
|
|
31
|
+
WIDTH, HEIGHT, CHANNELS, SLOT_COUNT = 8, 4, 3, 2
|
|
32
|
+
PAYLOAD = WIDTH * HEIGHT * CHANNELS
|
|
33
|
+
SLOT_SIZE = SLOT_HEADER_SIZE + PAYLOAD
|
|
34
|
+
|
|
35
|
+
TIMEOUT = 5.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_segment():
|
|
39
|
+
"""A real mmap-able fd laid out exactly like the Rust daemon's segment."""
|
|
40
|
+
f = tempfile.TemporaryFile()
|
|
41
|
+
f.truncate(HEADER_SIZE + SLOT_COUNT * SLOT_SIZE)
|
|
42
|
+
f.seek(0)
|
|
43
|
+
f.write(struct.pack(HEADER_FMT, WIDTH, HEIGHT, CHANNELS, SLOT_COUNT, SLOT_SIZE, b""))
|
|
44
|
+
f.flush()
|
|
45
|
+
return f
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ShmTransportTest(unittest.TestCase):
|
|
49
|
+
def test_frames_processed_freed_and_acked(self):
|
|
50
|
+
tmp = tempfile.mkdtemp()
|
|
51
|
+
rust_sock_path = os.path.join(tmp, "rust.sock")
|
|
52
|
+
python_sock_path = os.path.join(tmp, "python.sock")
|
|
53
|
+
|
|
54
|
+
daemon = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
55
|
+
daemon.bind(rust_sock_path)
|
|
56
|
+
daemon.settimeout(TIMEOUT)
|
|
57
|
+
self.addCleanup(daemon.close)
|
|
58
|
+
|
|
59
|
+
segment = make_segment()
|
|
60
|
+
self.addCleanup(segment.close)
|
|
61
|
+
view = np.memmap(segment, dtype=np.uint8, mode="r+")
|
|
62
|
+
|
|
63
|
+
seen = []
|
|
64
|
+
|
|
65
|
+
def process(frame, ctx):
|
|
66
|
+
seen.append((ctx.frame_id, ctx.timestamp_us, ctx.transport))
|
|
67
|
+
if ctx.frame_id == 2:
|
|
68
|
+
raise RuntimeError("deliberate failure")
|
|
69
|
+
frame += 1
|
|
70
|
+
|
|
71
|
+
worker = threading.Thread(
|
|
72
|
+
target=braidpipe.run,
|
|
73
|
+
kwargs=dict(
|
|
74
|
+
process=process,
|
|
75
|
+
rust_sock=rust_sock_path,
|
|
76
|
+
python_sock=python_sock_path,
|
|
77
|
+
name="test",
|
|
78
|
+
),
|
|
79
|
+
daemon=True, # left blocked in recvfrom once the test is over
|
|
80
|
+
)
|
|
81
|
+
worker.start()
|
|
82
|
+
|
|
83
|
+
# Handshake: the worker's hello is answered with the segment's fd.
|
|
84
|
+
# (sendmsg directly: socket.send_fds does not forward `address`.)
|
|
85
|
+
_, worker_addr = daemon.recvfrom(512)
|
|
86
|
+
daemon.sendmsg(
|
|
87
|
+
[b'{"type":"config","transport":"shm","contract":1}'],
|
|
88
|
+
[(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", [segment.fileno()]))],
|
|
89
|
+
0,
|
|
90
|
+
worker_addr,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def send_frame(frame_id, slot, fill):
|
|
94
|
+
offset = HEADER_SIZE + slot * SLOT_SIZE
|
|
95
|
+
view[offset : offset + SLOT_HEADER_SIZE] = np.frombuffer(
|
|
96
|
+
struct.pack(SLOT_HEADER_FMT, 1, frame_id, 1_000_000 + frame_id),
|
|
97
|
+
dtype=np.uint8,
|
|
98
|
+
)
|
|
99
|
+
view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] = fill
|
|
100
|
+
daemon.sendto(
|
|
101
|
+
json.dumps({"frame_id": frame_id, "slot_index": slot}).encode(),
|
|
102
|
+
python_sock_path,
|
|
103
|
+
)
|
|
104
|
+
ack = json.loads(daemon.recvfrom(512)[0])
|
|
105
|
+
return offset, ack
|
|
106
|
+
|
|
107
|
+
# A control packet with no frame_id must be skipped, not crash the loop.
|
|
108
|
+
daemon.sendto(b'{"type":"noise"}', python_sock_path)
|
|
109
|
+
|
|
110
|
+
offset, ack = send_frame(1, 0, fill=10)
|
|
111
|
+
self.assertEqual(ack["frame_id"], 1)
|
|
112
|
+
self.assertTrue(ack["success"])
|
|
113
|
+
self.assertEqual(view[offset], 0) # slot freed
|
|
114
|
+
self.assertTrue((view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] == 11).all())
|
|
115
|
+
|
|
116
|
+
# A raising handler acks success=false and leaves the loop alive.
|
|
117
|
+
offset, ack = send_frame(2, 1, fill=20)
|
|
118
|
+
self.assertFalse(ack["success"])
|
|
119
|
+
self.assertEqual(view[offset], 0)
|
|
120
|
+
self.assertTrue((view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] == 20).all())
|
|
121
|
+
|
|
122
|
+
_, ack = send_frame(3, 0, fill=30)
|
|
123
|
+
self.assertTrue(ack["success"])
|
|
124
|
+
|
|
125
|
+
self.assertEqual(
|
|
126
|
+
seen,
|
|
127
|
+
[(1, 1_000_001, "shm"), (2, 1_000_002, "shm"), (3, 1_000_003, "shm")],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TcpRawTransportTest(unittest.TestCase):
|
|
132
|
+
def test_frames_round_trip_and_clean_shutdown(self):
|
|
133
|
+
from braidpipe.remote import WIRE_HEADER_FMT, WIRE_HEADER_SIZE
|
|
134
|
+
|
|
135
|
+
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
136
|
+
udp.bind(("127.0.0.1", 0))
|
|
137
|
+
udp.settimeout(TIMEOUT)
|
|
138
|
+
self.addCleanup(udp.close)
|
|
139
|
+
|
|
140
|
+
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
141
|
+
tcp.bind(("127.0.0.1", 0))
|
|
142
|
+
tcp.listen(1)
|
|
143
|
+
tcp.settimeout(TIMEOUT)
|
|
144
|
+
self.addCleanup(tcp.close)
|
|
145
|
+
|
|
146
|
+
def process(frame):
|
|
147
|
+
frame += 1
|
|
148
|
+
|
|
149
|
+
worker = threading.Thread(
|
|
150
|
+
target=braidpipe.run,
|
|
151
|
+
kwargs=dict(
|
|
152
|
+
process=process,
|
|
153
|
+
daemon=f"127.0.0.1:{udp.getsockname()[1]}",
|
|
154
|
+
name="test",
|
|
155
|
+
),
|
|
156
|
+
daemon=True, # a failed test must not hang the runner at exit
|
|
157
|
+
)
|
|
158
|
+
worker.start()
|
|
159
|
+
|
|
160
|
+
hello, worker_addr = udp.recvfrom(512)
|
|
161
|
+
self.assertIn("tcp-raw", json.loads(hello)["transports"])
|
|
162
|
+
udp.sendto(
|
|
163
|
+
json.dumps(
|
|
164
|
+
{
|
|
165
|
+
"type": "config",
|
|
166
|
+
"transport": "tcp-raw",
|
|
167
|
+
"contract": 1,
|
|
168
|
+
"data_port": tcp.getsockname()[1],
|
|
169
|
+
"width": WIDTH,
|
|
170
|
+
"height": HEIGHT,
|
|
171
|
+
"channels": CHANNELS,
|
|
172
|
+
}
|
|
173
|
+
).encode(),
|
|
174
|
+
worker_addr,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
conn, _ = tcp.accept()
|
|
178
|
+
conn.settimeout(TIMEOUT)
|
|
179
|
+
for frame_id in (1, 2):
|
|
180
|
+
conn.sendall(
|
|
181
|
+
struct.pack(WIRE_HEADER_FMT, frame_id, 5_000, PAYLOAD, 0, 0)
|
|
182
|
+
+ bytes([frame_id * 10]) * PAYLOAD
|
|
183
|
+
)
|
|
184
|
+
reply = b""
|
|
185
|
+
while len(reply) < WIRE_HEADER_SIZE + PAYLOAD:
|
|
186
|
+
reply += conn.recv(4096)
|
|
187
|
+
reply_id, _, _, _, flags = struct.unpack(
|
|
188
|
+
WIRE_HEADER_FMT, reply[:WIRE_HEADER_SIZE]
|
|
189
|
+
)
|
|
190
|
+
self.assertEqual(reply_id, frame_id)
|
|
191
|
+
self.assertEqual(flags & 1, 1)
|
|
192
|
+
self.assertEqual(set(reply[WIRE_HEADER_SIZE:]), {frame_id * 10 + 1})
|
|
193
|
+
|
|
194
|
+
# Hanging up must end run() rather than strand the worker.
|
|
195
|
+
conn.close()
|
|
196
|
+
worker.join(TIMEOUT)
|
|
197
|
+
self.assertFalse(worker.is_alive())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ContractTest(unittest.TestCase):
|
|
201
|
+
def test_matching_and_missing_versions_attach(self):
|
|
202
|
+
from braidpipe.contract import CONTRACT_VERSION, check_contract
|
|
203
|
+
|
|
204
|
+
check_contract({"contract": CONTRACT_VERSION}, "shm") # must not raise
|
|
205
|
+
check_contract({}, "shm") # pre-versioning daemon: warn, not refuse
|
|
206
|
+
|
|
207
|
+
def test_mismatch_refuses_loudly(self):
|
|
208
|
+
from braidpipe.contract import check_contract
|
|
209
|
+
|
|
210
|
+
with self.assertRaises(RuntimeError):
|
|
211
|
+
check_contract({"contract": 999}, "shm")
|
|
212
|
+
|
|
213
|
+
def test_remote_connect_refuses_mismatched_daemon(self):
|
|
214
|
+
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
215
|
+
udp.bind(("127.0.0.1", 0))
|
|
216
|
+
udp.settimeout(TIMEOUT)
|
|
217
|
+
self.addCleanup(udp.close)
|
|
218
|
+
|
|
219
|
+
def answer_hello():
|
|
220
|
+
_, addr = udp.recvfrom(512)
|
|
221
|
+
udp.sendto(
|
|
222
|
+
b'{"type":"config","transport":"tcp-raw","data_port":1,'
|
|
223
|
+
b'"width":8,"height":4,"channels":3,"contract":999}',
|
|
224
|
+
addr,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
threading.Thread(target=answer_hello, daemon=True).start()
|
|
228
|
+
with self.assertRaises(RuntimeError):
|
|
229
|
+
braidpipe.connect(f"127.0.0.1:{udp.getsockname()[1]}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class SignatureTest(unittest.TestCase):
|
|
233
|
+
def test_handler_arity_detection(self):
|
|
234
|
+
self.assertFalse(_wants_context(lambda frame: None))
|
|
235
|
+
self.assertTrue(_wants_context(lambda frame, ctx: None))
|
|
236
|
+
self.assertTrue(_wants_context(lambda *args: None))
|
|
237
|
+
|
|
238
|
+
def test_worker_decorator_registers_handler(self):
|
|
239
|
+
@braidpipe.worker
|
|
240
|
+
def handler(frame):
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
from braidpipe import runner
|
|
244
|
+
|
|
245
|
+
self.assertIs(runner._registered, handler)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
unittest.main()
|