darvision 1.0.0__py3-none-any.whl
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.
- darvision/__init__.py +10 -0
- darvision/camera/__init__.py +6 -0
- darvision/camera/base.py +59 -0
- darvision/camera/queue.py +55 -0
- darvision/camera/webcam.py +79 -0
- darvision/cli.py +161 -0
- darvision/client/__init__.py +4 -0
- darvision/client/client.py +100 -0
- darvision/config.py +89 -0
- darvision/exceptions.py +38 -0
- darvision/host/__init__.py +4 -0
- darvision/host/host.py +152 -0
- darvision/host/pipeline.py +160 -0
- darvision/plugins/__init__.py +5 -0
- darvision/plugins/base.py +69 -0
- darvision/plugins/manager.py +80 -0
- darvision/protocol/__init__.py +18 -0
- darvision/protocol/messages.py +131 -0
- darvision/rendering/__init__.py +5 -0
- darvision/rendering/base.py +38 -0
- darvision/rendering/opencv.py +99 -0
- darvision/serialization/__init__.py +5 -0
- darvision/serialization/decoder.py +37 -0
- darvision/serialization/encoder.py +45 -0
- darvision/transport/__init__.py +4 -0
- darvision/transport/base.py +53 -0
- darvision/transport/tunnel/__init__.py +4 -0
- darvision/transport/tunnel/base.py +35 -0
- darvision/transport/tunnel/ngrok.py +73 -0
- darvision/transport/websocket.py +153 -0
- darvision-1.0.0.dist-info/METADATA +150 -0
- darvision-1.0.0.dist-info/RECORD +35 -0
- darvision-1.0.0.dist-info/WHEEL +4 -0
- darvision-1.0.0.dist-info/entry_points.txt +2 -0
- darvision-1.0.0.dist-info/licenses/LICENSE +21 -0
darvision/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DarVision — Real-time computer vision streaming framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from darvision.host.host import Host
|
|
6
|
+
from darvision.client.client import Client
|
|
7
|
+
from darvision.plugins.base import DarVisionPlugin
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
__all__ = ["Host", "Client", "DarVisionPlugin", "__version__"]
|
darvision/camera/base.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract camera source interface.
|
|
3
|
+
All camera implementations must inherit from CameraSource.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CameraSource(ABC):
|
|
13
|
+
"""
|
|
14
|
+
Abstract base class for all DarVision camera sources.
|
|
15
|
+
|
|
16
|
+
Supported sources (v1: WebcamSource only):
|
|
17
|
+
- WebcamSource — USB or built-in webcam via OpenCV
|
|
18
|
+
- VideoSource — video file (future v1.1)
|
|
19
|
+
- RTSPSource — IP/RTSP camera (future v1.1)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def open(self) -> None:
|
|
24
|
+
"""Open and initialize the camera source. Raises CameraError on failure."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def read(self) -> Optional[np.ndarray]:
|
|
29
|
+
"""
|
|
30
|
+
Read the next frame.
|
|
31
|
+
Returns a BGR numpy array, or None if no frame is available.
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
"""Release the camera source and any associated resources."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def is_open(self) -> bool:
|
|
43
|
+
"""True if the source is currently open and ready to read."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def width(self) -> int:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def height(self) -> int:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def fps(self) -> float:
|
|
59
|
+
pass
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FrameQueue — thread-safe bounded queue connecting the camera thread
|
|
3
|
+
to the plugin/network workers.
|
|
4
|
+
|
|
5
|
+
Bounded so that if the network is slow, the oldest frames are dropped
|
|
6
|
+
rather than letting memory grow without limit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import queue
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FrameQueue:
|
|
19
|
+
"""
|
|
20
|
+
A thread-safe bounded queue for numpy frames.
|
|
21
|
+
When full, the oldest frame is discarded to make room (drop policy).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, maxsize: int = 4):
|
|
25
|
+
self._q: queue.Queue[np.ndarray] = queue.Queue(maxsize=maxsize)
|
|
26
|
+
|
|
27
|
+
def put(self, frame: np.ndarray) -> None:
|
|
28
|
+
"""Add a frame; drop the oldest if the queue is full."""
|
|
29
|
+
if self._q.full():
|
|
30
|
+
try:
|
|
31
|
+
self._q.get_nowait() # drop oldest
|
|
32
|
+
except queue.Empty:
|
|
33
|
+
pass
|
|
34
|
+
try:
|
|
35
|
+
self._q.put_nowait(frame)
|
|
36
|
+
except queue.Full:
|
|
37
|
+
pass # race condition — silently ignore
|
|
38
|
+
|
|
39
|
+
def get(self, timeout: float = 0.1) -> Optional[np.ndarray]:
|
|
40
|
+
"""Get the next frame, or None if none is available within timeout."""
|
|
41
|
+
try:
|
|
42
|
+
return self._q.get(timeout=timeout)
|
|
43
|
+
except queue.Empty:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def qsize(self) -> int:
|
|
47
|
+
return self._q.qsize()
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
"""Drain all frames from the queue."""
|
|
51
|
+
while not self._q.empty():
|
|
52
|
+
try:
|
|
53
|
+
self._q.get_nowait()
|
|
54
|
+
except queue.Empty:
|
|
55
|
+
break
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebcamSource — captures frames from a USB or built-in webcam via OpenCV.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from darvision.camera.base import CameraSource
|
|
12
|
+
from darvision.exceptions import CameraError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebcamSource(CameraSource):
|
|
18
|
+
"""
|
|
19
|
+
Captures frames from a local webcam using OpenCV VideoCapture.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, camera_id: int = 0, width: int = 640, height: int = 480, fps: int = 30):
|
|
23
|
+
self._camera_id = camera_id
|
|
24
|
+
self._width = width
|
|
25
|
+
self._height = height
|
|
26
|
+
self._fps = fps
|
|
27
|
+
self._cap: Optional[cv2.VideoCapture] = None
|
|
28
|
+
|
|
29
|
+
def open(self) -> None:
|
|
30
|
+
self._cap = cv2.VideoCapture(self._camera_id)
|
|
31
|
+
if not self._cap.isOpened():
|
|
32
|
+
raise CameraError(
|
|
33
|
+
f"Could not open camera {self._camera_id}. "
|
|
34
|
+
"Make sure a webcam is connected and not in use by another application."
|
|
35
|
+
)
|
|
36
|
+
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, self._width)
|
|
37
|
+
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self._height)
|
|
38
|
+
self._cap.set(cv2.CAP_PROP_FPS, self._fps)
|
|
39
|
+
logger.info(
|
|
40
|
+
f"Camera {self._camera_id} opened at "
|
|
41
|
+
f"{self._width}x{self._height} @ {self._fps}fps"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def read(self) -> Optional[np.ndarray]:
|
|
45
|
+
if not self.is_open:
|
|
46
|
+
return None
|
|
47
|
+
ret, frame = self._cap.read()
|
|
48
|
+
if not ret:
|
|
49
|
+
logger.warning("Failed to read frame from camera.")
|
|
50
|
+
return None
|
|
51
|
+
return frame
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
if self._cap and self._cap.isOpened():
|
|
55
|
+
self._cap.release()
|
|
56
|
+
logger.info(f"Camera {self._camera_id} closed.")
|
|
57
|
+
self._cap = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_open(self) -> bool:
|
|
61
|
+
return self._cap is not None and self._cap.isOpened()
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def width(self) -> int:
|
|
65
|
+
if self._cap:
|
|
66
|
+
return int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
67
|
+
return self._width
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def height(self) -> int:
|
|
71
|
+
if self._cap:
|
|
72
|
+
return int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
73
|
+
return self._height
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def fps(self) -> float:
|
|
77
|
+
if self._cap:
|
|
78
|
+
return self._cap.get(cv2.CAP_PROP_FPS)
|
|
79
|
+
return float(self._fps)
|
darvision/cli.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DarVision CLI — built with click.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
darvision host [--port] [--public] [--config]
|
|
6
|
+
darvision connect <address>
|
|
7
|
+
darvision doctor
|
|
8
|
+
darvision version
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from darvision import __version__
|
|
18
|
+
from darvision.config import DarVisionConfig, setup_logging
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.option("--log-level", default="INFO", show_default=True,
|
|
23
|
+
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False),
|
|
24
|
+
help="Logging verbosity.")
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def cli(ctx: click.Context, log_level: str) -> None:
|
|
27
|
+
"""DarVision — real-time computer vision streaming framework."""
|
|
28
|
+
ctx.ensure_object(dict)
|
|
29
|
+
ctx.obj["log_level"] = log_level
|
|
30
|
+
setup_logging(log_level)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# darvision host
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@cli.command()
|
|
38
|
+
@click.option("--port", default=8765, show_default=True, help="WebSocket port to listen on.")
|
|
39
|
+
@click.option("--public", is_flag=True, default=False,
|
|
40
|
+
help="Expose the host publicly via ngrok (requires pip install darvision[ngrok]).")
|
|
41
|
+
@click.option("--preview", is_flag=True, default=False,
|
|
42
|
+
help="Open a local window showing what the host camera sees.")
|
|
43
|
+
@click.option("--config", "config_file", default=None, type=click.Path(exists=True),
|
|
44
|
+
help="Path to a DarVision YAML config file.")
|
|
45
|
+
@click.option("--camera", "camera_id", default=0, show_default=True,
|
|
46
|
+
help="Camera device index.")
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def host(ctx: click.Context, port: int, public: bool, preview: bool, config_file: str | None, camera_id: int) -> None:
|
|
49
|
+
"""Start the DarVision host and stream your webcam."""
|
|
50
|
+
from darvision.host.host import Host
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
cfg = DarVisionConfig()
|
|
54
|
+
if config_file:
|
|
55
|
+
cfg = DarVisionConfig.from_yaml(Path(config_file))
|
|
56
|
+
|
|
57
|
+
cfg.network.port = port
|
|
58
|
+
cfg.camera.camera_id = camera_id
|
|
59
|
+
|
|
60
|
+
tunnel_provider = None
|
|
61
|
+
if public:
|
|
62
|
+
try:
|
|
63
|
+
from darvision.transport.tunnel.ngrok import NgrokTunnelProvider
|
|
64
|
+
tunnel_provider = NgrokTunnelProvider()
|
|
65
|
+
except ImportError:
|
|
66
|
+
click.secho(
|
|
67
|
+
"Error: pyngrok is not installed.\n"
|
|
68
|
+
"Install it with: pip install darvision[ngrok]",
|
|
69
|
+
fg="red", err=True,
|
|
70
|
+
)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
h = Host(config=cfg, tunnel_provider=tunnel_provider)
|
|
74
|
+
h.start(preview=preview)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# darvision connect
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@click.argument("address")
|
|
83
|
+
@click.pass_context
|
|
84
|
+
def connect(ctx: click.Context, address: str) -> None:
|
|
85
|
+
"""Connect to a DarVision host and display the stream.
|
|
86
|
+
|
|
87
|
+
ADDRESS can be:
|
|
88
|
+
192.168.1.15:8765
|
|
89
|
+
ws://192.168.1.15:8765
|
|
90
|
+
wss://xxxx.ngrok-free.app
|
|
91
|
+
"""
|
|
92
|
+
from darvision.client.client import Client
|
|
93
|
+
c = Client()
|
|
94
|
+
c.connect(address)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# darvision doctor
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@cli.command()
|
|
102
|
+
def doctor() -> None:
|
|
103
|
+
"""Check system dependencies and camera availability."""
|
|
104
|
+
import importlib
|
|
105
|
+
|
|
106
|
+
ok = True
|
|
107
|
+
|
|
108
|
+
def check(label: str, fn):
|
|
109
|
+
nonlocal ok
|
|
110
|
+
try:
|
|
111
|
+
fn()
|
|
112
|
+
click.secho(f" ✔ {label}", fg="green")
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
click.secho(f" ✗ {label}: {exc}", fg="red")
|
|
115
|
+
ok = False
|
|
116
|
+
|
|
117
|
+
click.echo("\nDarVision Doctor\n" + "─" * 40)
|
|
118
|
+
|
|
119
|
+
check("opencv-python", lambda: __import__("cv2"))
|
|
120
|
+
check("numpy", lambda: __import__("numpy"))
|
|
121
|
+
check("fastapi", lambda: __import__("fastapi"))
|
|
122
|
+
check("uvicorn", lambda: __import__("uvicorn"))
|
|
123
|
+
check("websockets", lambda: __import__("websockets"))
|
|
124
|
+
|
|
125
|
+
# Optional
|
|
126
|
+
try:
|
|
127
|
+
import pyngrok
|
|
128
|
+
click.secho(" ✔ pyngrok (optional — ngrok tunneling)", fg="green")
|
|
129
|
+
except ImportError:
|
|
130
|
+
click.secho(" ℹ pyngrok not installed (install with: pip install darvision[ngrok])", fg="yellow")
|
|
131
|
+
|
|
132
|
+
# Camera
|
|
133
|
+
def _camera_check():
|
|
134
|
+
import cv2
|
|
135
|
+
cap = cv2.VideoCapture(0)
|
|
136
|
+
if not cap.isOpened():
|
|
137
|
+
raise RuntimeError("Camera index 0 not available.")
|
|
138
|
+
cap.release()
|
|
139
|
+
|
|
140
|
+
check("Camera (index 0)", _camera_check)
|
|
141
|
+
|
|
142
|
+
click.echo("─" * 40)
|
|
143
|
+
if ok:
|
|
144
|
+
click.secho(" All checks passed!", fg="green", bold=True)
|
|
145
|
+
else:
|
|
146
|
+
click.secho(" Some checks failed. See above.", fg="red", bold=True)
|
|
147
|
+
click.echo()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# darvision version
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
@cli.command()
|
|
155
|
+
def version() -> None:
|
|
156
|
+
"""Show DarVision version."""
|
|
157
|
+
click.echo(f"DarVision v{__version__}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
cli()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client — the main DarVision client API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from darvision import Client
|
|
6
|
+
client = Client()
|
|
7
|
+
client.connect("192.168.1.15:8765")
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Optional, Type
|
|
15
|
+
|
|
16
|
+
from darvision.rendering.base import Renderer
|
|
17
|
+
from darvision.rendering.opencv import OpenCVRenderer
|
|
18
|
+
from darvision.serialization.decoder import FrameDecoder
|
|
19
|
+
from darvision.transport.base import Transport
|
|
20
|
+
from darvision.transport.websocket import WebSocketTransport
|
|
21
|
+
from darvision.protocol.messages import MessageType
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Client:
|
|
27
|
+
"""
|
|
28
|
+
DarVision client. Connects to a host and renders the incoming stream.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
renderer: Optional[Renderer] = None,
|
|
34
|
+
transport_class: Type[Transport] = WebSocketTransport,
|
|
35
|
+
):
|
|
36
|
+
self._renderer = renderer or OpenCVRenderer()
|
|
37
|
+
self._transport = transport_class()
|
|
38
|
+
self._decoder = FrameDecoder()
|
|
39
|
+
|
|
40
|
+
async def _on_bytes(self, data: bytes) -> None:
|
|
41
|
+
"""Handle an incoming binary FrameMessage."""
|
|
42
|
+
frame = self._decoder.decode(data)
|
|
43
|
+
if frame is not None:
|
|
44
|
+
self._renderer.update(frame, [])
|
|
45
|
+
|
|
46
|
+
async def _on_text(self, text: str) -> None:
|
|
47
|
+
"""Handle incoming text messages (heartbeats, events — future use)."""
|
|
48
|
+
import json
|
|
49
|
+
try:
|
|
50
|
+
msg = json.loads(text)
|
|
51
|
+
if msg.get("type") == MessageType.HEARTBEAT:
|
|
52
|
+
logger.debug("Heartbeat received.")
|
|
53
|
+
elif msg.get("type") == MessageType.ERROR:
|
|
54
|
+
logger.warning(f"Host error: {msg.get('payload', {}).get('message')}")
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
async def run(self, address: str) -> None:
|
|
59
|
+
"""Connect to the host and block until the window is closed."""
|
|
60
|
+
# Normalize address
|
|
61
|
+
if not address.startswith(("ws://", "wss://")):
|
|
62
|
+
address = f"ws://{address}"
|
|
63
|
+
|
|
64
|
+
# Register callbacks
|
|
65
|
+
self._transport.on_bytes(self._on_bytes)
|
|
66
|
+
self._transport.on_text(self._on_text)
|
|
67
|
+
|
|
68
|
+
# Connect transport
|
|
69
|
+
await self._transport.connect(address)
|
|
70
|
+
|
|
71
|
+
# Start renderer
|
|
72
|
+
self._renderer.start()
|
|
73
|
+
|
|
74
|
+
print("\n" + "─" * 50)
|
|
75
|
+
print(" 📺 DarVision Client connected")
|
|
76
|
+
print("─" * 50)
|
|
77
|
+
print(f" Host : {address}")
|
|
78
|
+
print(" Press 'q' in the video window to stop.")
|
|
79
|
+
print("─" * 50 + "\n")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
while self._renderer.is_running:
|
|
83
|
+
await asyncio.sleep(0.05)
|
|
84
|
+
except asyncio.CancelledError:
|
|
85
|
+
pass
|
|
86
|
+
finally:
|
|
87
|
+
await self._disconnect()
|
|
88
|
+
|
|
89
|
+
async def _disconnect(self) -> None:
|
|
90
|
+
logger.info("Disconnecting DarVision Client...")
|
|
91
|
+
self._renderer.stop()
|
|
92
|
+
await self._transport.stop()
|
|
93
|
+
logger.info("Client disconnected.")
|
|
94
|
+
|
|
95
|
+
def connect(self, address: str) -> None:
|
|
96
|
+
"""Convenience wrapper to run the client from non-async code."""
|
|
97
|
+
try:
|
|
98
|
+
asyncio.run(self.run(address))
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
pass
|
darvision/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DarVision configuration.
|
|
3
|
+
|
|
4
|
+
Supports three layers (highest priority first):
|
|
5
|
+
1. CLI arguments (passed programmatically)
|
|
6
|
+
2. Environment variables (DARVISION_*)
|
|
7
|
+
3. YAML configuration file (darvision.yaml)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CameraConfig:
|
|
23
|
+
camera_id: int = 0
|
|
24
|
+
width: int = 640
|
|
25
|
+
height: int = 480
|
|
26
|
+
fps: int = 30
|
|
27
|
+
queue_size: int = 4 # bounded queue, drops old frames under load
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class NetworkConfig:
|
|
32
|
+
host: str = "0.0.0.0"
|
|
33
|
+
port: int = 8765
|
|
34
|
+
ping_interval: float = 20.0 # seconds between heartbeats
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DarVisionConfig:
|
|
39
|
+
camera: CameraConfig = field(default_factory=CameraConfig)
|
|
40
|
+
network: NetworkConfig = field(default_factory=NetworkConfig)
|
|
41
|
+
log_level: str = "INFO"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_yaml(cls, path: Path) -> "DarVisionConfig":
|
|
45
|
+
"""Load config from a YAML file."""
|
|
46
|
+
with open(path) as f:
|
|
47
|
+
data = yaml.safe_load(f) or {}
|
|
48
|
+
|
|
49
|
+
cfg = cls()
|
|
50
|
+
if "camera" in data:
|
|
51
|
+
cam = data["camera"]
|
|
52
|
+
cfg.camera.camera_id = cam.get("camera_id", cfg.camera.camera_id)
|
|
53
|
+
cfg.camera.width = cam.get("width", cfg.camera.width)
|
|
54
|
+
cfg.camera.height = cam.get("height", cfg.camera.height)
|
|
55
|
+
cfg.camera.fps = cam.get("fps", cfg.camera.fps)
|
|
56
|
+
cfg.camera.queue_size = cam.get("queue_size", cfg.camera.queue_size)
|
|
57
|
+
|
|
58
|
+
if "network" in data:
|
|
59
|
+
net = data["network"]
|
|
60
|
+
cfg.network.host = net.get("host", cfg.network.host)
|
|
61
|
+
cfg.network.port = net.get("port", cfg.network.port)
|
|
62
|
+
cfg.network.ping_interval = net.get("ping_interval", cfg.network.ping_interval)
|
|
63
|
+
|
|
64
|
+
if "log_level" in data:
|
|
65
|
+
cfg.log_level = data["log_level"]
|
|
66
|
+
|
|
67
|
+
return cfg
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_env(cls) -> "DarVisionConfig":
|
|
71
|
+
"""Override defaults from DARVISION_* environment variables."""
|
|
72
|
+
cfg = cls()
|
|
73
|
+
cfg.camera.camera_id = int(os.getenv("DARVISION_CAMERA_ID", cfg.camera.camera_id))
|
|
74
|
+
cfg.camera.width = int(os.getenv("DARVISION_WIDTH", cfg.camera.width))
|
|
75
|
+
cfg.camera.height = int(os.getenv("DARVISION_HEIGHT", cfg.camera.height))
|
|
76
|
+
cfg.camera.fps = int(os.getenv("DARVISION_FPS", cfg.camera.fps))
|
|
77
|
+
cfg.network.port = int(os.getenv("DARVISION_PORT", cfg.network.port))
|
|
78
|
+
cfg.log_level = os.getenv("DARVISION_LOG_LEVEL", cfg.log_level)
|
|
79
|
+
return cfg
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def setup_logging(level: str = "INFO") -> None:
|
|
83
|
+
"""Configure the root logger for DarVision."""
|
|
84
|
+
numeric = getattr(logging, level.upper(), logging.INFO)
|
|
85
|
+
logging.basicConfig(
|
|
86
|
+
level=numeric,
|
|
87
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
88
|
+
datefmt="%H:%M:%S",
|
|
89
|
+
)
|
darvision/exceptions.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DarVision custom exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DarVisionError(Exception):
|
|
7
|
+
"""Base exception for all DarVision errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CameraError(DarVisionError):
|
|
12
|
+
"""Raised when a camera cannot be opened or fails during capture."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnectionError(DarVisionError):
|
|
17
|
+
"""Raised when a network connection cannot be established or is lost."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SerializationError(DarVisionError):
|
|
22
|
+
"""Raised when a frame or message cannot be encoded or decoded."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PluginError(DarVisionError):
|
|
27
|
+
"""Raised when a plugin fails to initialize, process, or shut down."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TunnelError(DarVisionError):
|
|
32
|
+
"""Raised when a tunnel provider fails to start or connect."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConfigError(DarVisionError):
|
|
37
|
+
"""Raised when configuration is invalid or missing required values."""
|
|
38
|
+
pass
|