python: DLPack + health stats + CUDA stream + docs (tasks #199-#202)
#199 DLPack export: - frame.dlpack_y() / .dlpack_uv() — explicit multi-plane access для NV12 - frame.__dlpack__() / __dlpack_device__() — protocol для torch/cupy - Capsule deleter правильно держит refcount на frame_keep_alive, releases shape/strides arrays. CUDA pointer принадлежит frame. #200 Health/stats counters: - frames_received, timeouts, errors — per-call counters - last_seq, gap_count — proxy для drop count (NEWEST_ONLY mode) - last_frame_pts_ns - stats() — snapshot dict для MQTT health publish - counted в pybind layer т.к. C API не expose'ит ring_occupancy #201 Per-subscriber CUDA stream + thread-safety: - consumer_stream kwarg в subscribe() — int (cudaStream_t pointer) - subscriber.consumer_stream property - Thread-safety contract в docstring CuframesSubscriber - next_frame() передаёт consumer_stream_ в cuframes_subscriber_next #202 Smoke test + docs: - 10/10 pytest passed (расширен +2 теста на consumer_stream) - docs/python.md (~250 строк): quick start, API reference, integration с PyTorch/CuPy, reconnect-loop pattern, per-stream usage, pitch alignment, thread-safety, error taxonomy, backpressure, Phase 0 limitations Verify build + tests: cmake -B build-python -DBUILD_PYTHON_BINDINGS=ON cmake --build build-python -j pytest python/tests/ -v # 10/10 Закрывает Phase 0 issue gx/cuframes#6. Разблокирует goldix-smart-home/yolo-world-detector Phase 1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+284
@@ -0,0 +1,284 @@
|
||||
# cuframes Python bindings
|
||||
|
||||
Status: **v0.4 — Phase 0 alpha** (issue [gx/cuframes#6](http://server:3000/gx/cuframes/issues/6))
|
||||
|
||||
Python пакет `cuframes` — pybind11-обёртка над C ABI libcuframes. Цель —
|
||||
позволить downstream ML/CV пайплайнам (yolo-world-detector, zone-motion,
|
||||
custom скриптам) подписываться на cuframes **без CPU round-trip**: получать
|
||||
NV12 frames прямо как CUDA pointer / `torch.Tensor` (DLPack export, zero-copy
|
||||
из VRAM publisher'а в VRAM consumer'а).
|
||||
|
||||
## Установка
|
||||
|
||||
Standalone wheel (рекомендуемый):
|
||||
|
||||
```bash
|
||||
cd cuframes/python/
|
||||
pip install -e . --no-build-isolation
|
||||
```
|
||||
|
||||
Через корневой CMake:
|
||||
|
||||
```bash
|
||||
cmake -B build -DBUILD_PYTHON_BINDINGS=ON
|
||||
cmake --build build -j
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
import cuframes
|
||||
|
||||
print(cuframes.version_string()) # "0.4.0"
|
||||
|
||||
with cuframes.subscribe("cam-parking",
|
||||
consumer_name="yolo-world",
|
||||
connect_timeout_ms=5000) as sub:
|
||||
with sub.next_frame(timeout_ms=1000) as frame:
|
||||
print(f"{frame.width}x{frame.height} "
|
||||
f"format={frame.format} seq={frame.seq}")
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `cuframes.subscribe(key, ...)`
|
||||
|
||||
Создать подписку на publisher. Возвращает `CuframesSubscriber`.
|
||||
|
||||
| Параметр | Тип | Default | Назначение |
|
||||
|---|---|---|---|
|
||||
| `key` | `str` | (required) | Имя publisher'а (`"cam-parking"` и т.п.) |
|
||||
| `consumer_name` | `str \| None` | `None` (auto-generated) | Идентификатор подписки |
|
||||
| `mode` | `SubscriberMode` | `NEWEST_ONLY` | `NEWEST_ONLY` skip'ит промежуточные frames, `STRICT_ORDER` — все по порядку |
|
||||
| `cuda_device` | `int` | `0` | CUDA device id |
|
||||
| `connect_timeout_ms` | `int` | `-1` (бесконечно) | Сколько ждать publisher'а |
|
||||
| `consumer_stream` | `int` | `0` (default stream) | `cudaStream_t` как pointer |
|
||||
|
||||
### `CuframesSubscriber`
|
||||
|
||||
Контекст-менеджер. Methods/properties:
|
||||
|
||||
```python
|
||||
sub.next_frame(timeout_ms=-1) # → CuframesFrame
|
||||
sub.close() # idempotent
|
||||
|
||||
# read-only properties
|
||||
sub.key # str
|
||||
sub.consumer_name # str
|
||||
sub.mode # SubscriberMode
|
||||
sub.cuda_device # int
|
||||
sub.consumer_stream # int (cudaStream_t ptr)
|
||||
sub.closed # bool
|
||||
|
||||
# health / stats (Phase 0 counters)
|
||||
sub.frames_received # int
|
||||
sub.timeouts # int
|
||||
sub.errors # int
|
||||
sub.last_seq # int (sequence number последнего frame'а)
|
||||
sub.gap_count # int (proxy для drop count в NEWEST_ONLY)
|
||||
sub.last_frame_pts_ns # int
|
||||
sub.stats() # dict — snapshot всех counters для MQTT publish
|
||||
```
|
||||
|
||||
### `CuframesFrame`
|
||||
|
||||
Контекст-менеджер. Properties (read-only):
|
||||
|
||||
```python
|
||||
frame.cuda_ptr # int (uintptr_t)
|
||||
frame.format # PixelFormat
|
||||
frame.width # int
|
||||
frame.height # int
|
||||
frame.pitch_y # int — pitch Y plane (важно — может быть > width!)
|
||||
frame.pitch_uv # int
|
||||
frame.seq # int — sequence number у publisher'а
|
||||
frame.pts_ns # int — CLOCK_MONOTONIC у publisher'а
|
||||
frame.released # bool
|
||||
|
||||
# DLPack export (zero-copy)
|
||||
frame.dlpack_y() # capsule — Y plane как 2D uint8 GPU tensor
|
||||
frame.dlpack_uv() # capsule — UV plane (только NV12)
|
||||
frame.__dlpack__() # protocol для torch.from_dlpack(frame)
|
||||
frame.__dlpack_device__() # (kDLCUDA=2, device_id)
|
||||
```
|
||||
|
||||
## Интеграция с PyTorch
|
||||
|
||||
```python
|
||||
import torch
|
||||
import cuframes
|
||||
|
||||
with cuframes.subscribe("cam-parking", connect_timeout_ms=5000) as sub:
|
||||
with sub.next_frame() as frame:
|
||||
# Single-plane (default — Y plane для NV12)
|
||||
y_tensor = torch.from_dlpack(frame)
|
||||
# Multi-plane explicit
|
||||
y = torch.from_dlpack(frame.dlpack_y()) # shape=[H, W] uint8
|
||||
uv = torch.from_dlpack(frame.dlpack_uv()) # shape=[H/2, W] uint8
|
||||
|
||||
# Y plane уже в VRAM — никаких copy. Можно сразу feed в NN.
|
||||
y_float = y.float() / 255.0 # будет на CUDA device
|
||||
```
|
||||
|
||||
## Интеграция с CuPy
|
||||
|
||||
```python
|
||||
import cupy
|
||||
import cuframes
|
||||
|
||||
with cuframes.subscribe("cam-parking", connect_timeout_ms=5000) as sub:
|
||||
with sub.next_frame() as frame:
|
||||
y_array = cupy.from_dlpack(frame.dlpack_y()) # cupy.ndarray на GPU
|
||||
```
|
||||
|
||||
## Pattern: reconnect-loop для долгоживущего consumer'а
|
||||
|
||||
```python
|
||||
import time
|
||||
import cuframes
|
||||
|
||||
def consume_camera(key: str, on_frame):
|
||||
while True:
|
||||
try:
|
||||
with cuframes.subscribe(key, connect_timeout_ms=5000) as sub:
|
||||
while True:
|
||||
try:
|
||||
with sub.next_frame(timeout_ms=1000) as frame:
|
||||
on_frame(frame)
|
||||
except cuframes.CuframesFrameTimeout:
|
||||
# просто нет новых кадров — продолжаем ждать
|
||||
continue
|
||||
except cuframes.CuframesPublisherGone:
|
||||
# publisher умер / перезапускается — переподписываемся
|
||||
print(f"publisher {key} gone, reconnect через 1s")
|
||||
time.sleep(1)
|
||||
except cuframes.CuframesError as e:
|
||||
# фатальная ошибка — логируем и продолжаем
|
||||
print(f"error: {e!r}")
|
||||
time.sleep(5)
|
||||
```
|
||||
|
||||
## Per-subscriber CUDA stream
|
||||
|
||||
В продакшене на 4+ камеры каждый subscriber должен иметь свой stream —
|
||||
иначе `cudaStreamWaitEvent` сериализует всех consumer'ов через default
|
||||
stream.
|
||||
|
||||
С `cuda-python`:
|
||||
|
||||
```python
|
||||
from cuda import cudart
|
||||
import cuframes
|
||||
|
||||
err, stream = cudart.cudaStreamCreate()
|
||||
assert err == cudart.cudaError_t.cudaSuccess
|
||||
|
||||
with cuframes.subscribe("cam-parking", consumer_stream=int(stream)) as sub:
|
||||
...
|
||||
```
|
||||
|
||||
С `torch.cuda.Stream`:
|
||||
|
||||
```python
|
||||
import torch
|
||||
import cuframes
|
||||
|
||||
stream = torch.cuda.Stream()
|
||||
with cuframes.subscribe("cam-parking",
|
||||
consumer_stream=stream.cuda_stream) as sub:
|
||||
with torch.cuda.stream(stream):
|
||||
with sub.next_frame() as frame:
|
||||
tensor = torch.from_dlpack(frame)
|
||||
# ... inference на этом stream'е ...
|
||||
```
|
||||
|
||||
## Pitch alignment — важно!
|
||||
|
||||
NVDEC отдаёт NV12 с pitch alignment 256 байт. Для камер с шириной не
|
||||
кратной 256 (`gate_lpr 2688×1520` → pitch 2688 OK; но представьте `640×480`
|
||||
→ pitch обычно 640 байт, но **может быть 768**).
|
||||
|
||||
```python
|
||||
# WRONG — assume pitch == width
|
||||
y = torch.frombuffer(...) # данные смещены
|
||||
|
||||
# RIGHT — использовать DLPack который сам respect'ит strides
|
||||
y = torch.from_dlpack(frame.dlpack_y()) # stride учтён правильно
|
||||
|
||||
# ALTERNATIVELY — manual через cuda-python с правильным pitch
|
||||
ptr = frame.cuda_ptr
|
||||
pitch = frame.pitch_y
|
||||
height = frame.height
|
||||
```
|
||||
|
||||
## Thread-safety contract
|
||||
|
||||
- Каждый `CuframesSubscriber` принадлежит **одному Python потоку**.
|
||||
Создание и все вызовы (`next_frame`, `close`) — в одном thread.
|
||||
- Несколько subscriber'ов в разных потоках — **OK** (каждому свой handle,
|
||||
свой CUDA stream).
|
||||
- `CuframesFrame` тоже принадлежит одному потоку — после `release()` его
|
||||
CUDA pointer становится недействительным, доступ из другого потока —
|
||||
undefined behavior.
|
||||
- Внутренний GIL отпускается на блокирующих вызовах (`subscriber_create`,
|
||||
`next_frame`) — другие Python потоки могут выполняться.
|
||||
|
||||
Для multi-camera в одном процессе используйте `asyncio` или `threading`:
|
||||
|
||||
```python
|
||||
import threading
|
||||
import cuframes
|
||||
|
||||
def worker(camera_key):
|
||||
with cuframes.subscribe(camera_key, connect_timeout_ms=5000) as sub:
|
||||
# subscribe в этом же потоке
|
||||
while True:
|
||||
with sub.next_frame(timeout_ms=1000) as frame:
|
||||
process(frame)
|
||||
|
||||
for key in ["cam-parking", "cam-front_yard", "cam-gate_lpr", "cam-back_yard"]:
|
||||
threading.Thread(target=worker, args=(key,), daemon=True).start()
|
||||
```
|
||||
|
||||
## Error taxonomy
|
||||
|
||||
Все exception'ы наследуются от `CuframesError`. Конкретные subclass'ы
|
||||
позволяют разную обработку:
|
||||
|
||||
| Exception | Когда выбрасывается | Что делать |
|
||||
|---|---|---|
|
||||
| `CuframesPublisherGone` | publisher умер или ещё не стартовал | reconnect-loop |
|
||||
| `CuframesFrameTimeout` | timeout без frame'а | продолжать ждать или log'нуть |
|
||||
| `CuframesDeviceLost` | CUDA error на cross-process sync | abort, не recoverable |
|
||||
| `CuframesShmError` | socket/mmap/IPC error | log, abort или восстановить |
|
||||
| `CuframesProtocolMismatch` | версия libcuframes несовместима | пересобрать |
|
||||
| `CuframesInvalidArgument` | bug в caller | fix code |
|
||||
| `CuframesOutOfMemory` | cudaMalloc fail | reduce работу |
|
||||
| `CuframesInternal` | bug в libcuframes | report |
|
||||
|
||||
## Backpressure
|
||||
|
||||
`next_frame()` blocking call с GIL released. Если consumer медленнее
|
||||
publisher'а:
|
||||
- В `NEWEST_ONLY` mode (default) — publisher продолжает писать, consumer
|
||||
получает **самый свежий** frame (промежуточные пропускает). `gap_count`
|
||||
растёт.
|
||||
- В `STRICT_ORDER` mode — при ring overflow `CuframesPublisherGone` →
|
||||
reconnect.
|
||||
|
||||
Frame удерживать долго **нельзя**: в `STRICT_WAIT` policy publisher
|
||||
заблокирует ring. Pattern — забрать DLPack, инициировать GPU работу,
|
||||
release frame сразу.
|
||||
|
||||
## Текущие ограничения (Phase 0)
|
||||
|
||||
- Publisher API не обёрнут (только subscriber-side)
|
||||
- Packet ring (encoded video) не обёрнут
|
||||
- Async callback API не обёрнут
|
||||
- `ring_occupancy` / реальный drop count — нет в C API (counted в pybind как
|
||||
`gap_count`, это proxy)
|
||||
- Smoke test реального subscribe требует Docker IPC namespace (cuframes
|
||||
socket/SHM живут в namespace publisher'а)
|
||||
|
||||
Эти ограничения снимаются по мере необходимости — issues в
|
||||
[gx/cuframes](http://server:3000/gx/cuframes).
|
||||
Reference in New Issue
Block a user