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 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__"]
@@ -0,0 +1,6 @@
1
+ """DarVision Camera package."""
2
+ from darvision.camera.base import CameraSource
3
+ from darvision.camera.webcam import WebcamSource
4
+ from darvision.camera.queue import FrameQueue
5
+
6
+ __all__ = ["CameraSource", "WebcamSource", "FrameQueue"]
@@ -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,4 @@
1
+ """DarVision Client package."""
2
+ from darvision.client.client import Client
3
+
4
+ __all__ = ["Client"]
@@ -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
+ )
@@ -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
@@ -0,0 +1,4 @@
1
+ """DarVision Host package."""
2
+ from darvision.host.host import Host
3
+
4
+ __all__ = ["Host"]