detstream 0.1.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.
- detstream-0.1.0/LICENSE +21 -0
- detstream-0.1.0/PKG-INFO +133 -0
- detstream-0.1.0/README.md +99 -0
- detstream-0.1.0/detstream/__init__.py +6 -0
- detstream-0.1.0/detstream/__main__.py +31 -0
- detstream-0.1.0/detstream/annotate.py +26 -0
- detstream-0.1.0/detstream/config.py +86 -0
- detstream-0.1.0/detstream/detectors/__init__.py +26 -0
- detstream-0.1.0/detstream/detectors/yolo_world.py +37 -0
- detstream-0.1.0/detstream/events.py +18 -0
- detstream-0.1.0/detstream/registry.py +53 -0
- detstream-0.1.0/detstream/runner.py +118 -0
- detstream-0.1.0/detstream/sinks/__init__.py +25 -0
- detstream-0.1.0/detstream/sinks/console.py +19 -0
- detstream-0.1.0/detstream/sinks/discord.py +40 -0
- detstream-0.1.0/detstream/sinks/supabase.py +124 -0
- detstream-0.1.0/detstream/sources/__init__.py +73 -0
- detstream-0.1.0/detstream/sources/file_device.py +38 -0
- detstream-0.1.0/detstream/sources/stream.py +23 -0
- detstream-0.1.0/detstream/sources/youtube.py +40 -0
- detstream-0.1.0/detstream/state.py +59 -0
- detstream-0.1.0/detstream.egg-info/PKG-INFO +133 -0
- detstream-0.1.0/detstream.egg-info/SOURCES.txt +34 -0
- detstream-0.1.0/detstream.egg-info/dependency_links.txt +1 -0
- detstream-0.1.0/detstream.egg-info/entry_points.txt +2 -0
- detstream-0.1.0/detstream.egg-info/requires.txt +19 -0
- detstream-0.1.0/detstream.egg-info/top_level.txt +1 -0
- detstream-0.1.0/pyproject.toml +62 -0
- detstream-0.1.0/setup.cfg +4 -0
- detstream-0.1.0/tests/test_config.py +119 -0
- detstream-0.1.0/tests/test_detectors.py +104 -0
- detstream-0.1.0/tests/test_registry.py +52 -0
- detstream-0.1.0/tests/test_runner.py +76 -0
- detstream-0.1.0/tests/test_sinks.py +130 -0
- detstream-0.1.0/tests/test_sources.py +60 -0
- detstream-0.1.0/tests/test_state.py +118 -0
detstream-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 catherinepereira
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
detstream-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: detstream
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Modular object detection for live video feeds
|
|
5
|
+
Author: catherinepereira
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/catherinepereira/detstream
|
|
8
|
+
Project-URL: Repository, https://github.com/catherinepereira/detstream
|
|
9
|
+
Keywords: object-detection,video,streaming,yolo,computer-vision
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
14
|
+
Classifier: Topic :: Multimedia :: Video
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numpy>=1.26.0
|
|
19
|
+
Requires-Dist: opencv-python-headless>=4.9.0
|
|
20
|
+
Requires-Dist: pydantic>=2.6.0
|
|
21
|
+
Requires-Dist: pyyaml>=6.0
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Provides-Extra: yolo
|
|
24
|
+
Requires-Dist: ultralytics>=8.3.0; extra == "yolo"
|
|
25
|
+
Requires-Dist: torch>=2.2.0; extra == "yolo"
|
|
26
|
+
Provides-Extra: youtube
|
|
27
|
+
Requires-Dist: yt-dlp>=2026.03.17; extra == "youtube"
|
|
28
|
+
Requires-Dist: deno>=2.8.0; extra == "youtube"
|
|
29
|
+
Provides-Extra: supabase
|
|
30
|
+
Requires-Dist: supabase>=2.4.0; extra == "supabase"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# detstream
|
|
36
|
+
|
|
37
|
+
Modular object detection framework for live video feeds.
|
|
38
|
+
|
|
39
|
+
## Pipeline
|
|
40
|
+
|
|
41
|
+
1. **Source**: Where frames come from. `youtube`, `stream` (RTSP/HLS/HTTP), `file` (a video
|
|
42
|
+
file or webcam device)
|
|
43
|
+
2. **Detector**: What to look for. `yolo-world`, an open-vocabulary model prompted with a
|
|
44
|
+
word or phrase (`person`, `forklift`, `bird`). Bring your own by registering one (see Plugins).
|
|
45
|
+
3. **Tracker**: When a detection counts as a sighting. Hysteresis and cooldown give you one
|
|
46
|
+
alert per sighting instead of one per frame, which is what keeps alerts from becoming spam.
|
|
47
|
+
4. **Sinks**: Where alerts go. `console`, `supabase` (rows + thumbnails for a website),
|
|
48
|
+
`discord` (rich embeds).
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install detstream # core only
|
|
54
|
+
pip install "detstream[yolo,youtube,supabase]" # for example, everything otterwatch uses
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The core install pulls what every feed needs: `numpy`, `opencv-python-headless`
|
|
58
|
+
(decoding and annotation), `pydantic` (config), `pyyaml`, and `httpx`.
|
|
59
|
+
|
|
60
|
+
| Extra | Enables | Pulls in |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `yolo` | the `yolo-world` detector | `ultralytics>=8.3.0`, `torch>=2.2.0`. ultralytics fetches CLIP on first use of the detector |
|
|
63
|
+
| `youtube` | the `youtube` source | `yt-dlp>=2026.03.17` and `deno>=2.8.0`, which ships the deno binary yt-dlp runs to solve YouTube's stream challenge |
|
|
64
|
+
| `supabase` | the `supabase` sink | `supabase>=2.4.0` |
|
|
65
|
+
|
|
66
|
+
## Run
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
detstream --config examples/otters.yaml
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A config lists feeds and shared sink settings:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
feeds:
|
|
76
|
+
- id: monterey-otters
|
|
77
|
+
name: Monterey Sea Otters
|
|
78
|
+
source: { type: youtube, url: "https://www.youtube.com/watch?v=abbR-Ttd-cA" }
|
|
79
|
+
detector: { type: yolo-world, prompt: otter swimming, confidence_threshold: 0.4 }
|
|
80
|
+
debounce: { enter_frames: 3, exit_frames: 5, cooldown_s: 120, sample_interval_s: 2 }
|
|
81
|
+
sinks: [console, supabase]
|
|
82
|
+
sinks:
|
|
83
|
+
supabase: { bucket: thumbnails, detector_label: yolo-world, retention_hours: 3, thumbnail_width: 960 }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Credentials and webhook URLs are configured in `.env`: `DETSTREAM_SUPABASE_URL`, `DETSTREAM_SUPABASE_KEY`, and `DETSTREAM_DISCORD_WEBHOOK_URL`.
|
|
87
|
+
|
|
88
|
+
## Plugins
|
|
89
|
+
|
|
90
|
+
Built-in components register themselves on import. To add your own detector (an HF model, a
|
|
91
|
+
cloud API, a fine-tuned ONNX, etc.), register a factory and declare an entry point:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# mypkg/detector.py
|
|
95
|
+
from detstream.detectors import detectors, Detection
|
|
96
|
+
|
|
97
|
+
class MyDetector:
|
|
98
|
+
def detect(self, frame) -> Detection: ...
|
|
99
|
+
|
|
100
|
+
@detectors.register("my-model")
|
|
101
|
+
def _build(config: dict) -> MyDetector:
|
|
102
|
+
return MyDetector(**config)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
# mypkg/pyproject.toml
|
|
107
|
+
[project.entry-points."detstream.detectors"]
|
|
108
|
+
my-model = "mypkg.detector"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
After `pip install`, reference it in config as `detector: { type: my-model, ... }`. detstream
|
|
112
|
+
discovers it through the entry point with no change to detstream itself. The same pattern works
|
|
113
|
+
for `detstream.sources` and `detstream.sinks`.
|
|
114
|
+
|
|
115
|
+
## Layout
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
detstream/
|
|
119
|
+
registry.py register + create + entry-point discovery
|
|
120
|
+
config.py FeedConfig / AppConfig, loads YAML
|
|
121
|
+
runner.py per-feed asyncio loop
|
|
122
|
+
state.py SightingTracker: hysteresis + cooldown, no I/O
|
|
123
|
+
events.py SightingStarted / SightingEnded
|
|
124
|
+
sources/ youtube, stream, file_device (+ shared reconnect base)
|
|
125
|
+
detectors/ yolo_world
|
|
126
|
+
sinks/ console, supabase, discord
|
|
127
|
+
examples/ otters.yaml, eagles.yaml
|
|
128
|
+
tests/ config, registry, sources, state, sinks, detectors
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# detstream
|
|
2
|
+
|
|
3
|
+
Modular object detection framework for live video feeds.
|
|
4
|
+
|
|
5
|
+
## Pipeline
|
|
6
|
+
|
|
7
|
+
1. **Source**: Where frames come from. `youtube`, `stream` (RTSP/HLS/HTTP), `file` (a video
|
|
8
|
+
file or webcam device)
|
|
9
|
+
2. **Detector**: What to look for. `yolo-world`, an open-vocabulary model prompted with a
|
|
10
|
+
word or phrase (`person`, `forklift`, `bird`). Bring your own by registering one (see Plugins).
|
|
11
|
+
3. **Tracker**: When a detection counts as a sighting. Hysteresis and cooldown give you one
|
|
12
|
+
alert per sighting instead of one per frame, which is what keeps alerts from becoming spam.
|
|
13
|
+
4. **Sinks**: Where alerts go. `console`, `supabase` (rows + thumbnails for a website),
|
|
14
|
+
`discord` (rich embeds).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install detstream # core only
|
|
20
|
+
pip install "detstream[yolo,youtube,supabase]" # for example, everything otterwatch uses
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The core install pulls what every feed needs: `numpy`, `opencv-python-headless`
|
|
24
|
+
(decoding and annotation), `pydantic` (config), `pyyaml`, and `httpx`.
|
|
25
|
+
|
|
26
|
+
| Extra | Enables | Pulls in |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `yolo` | the `yolo-world` detector | `ultralytics>=8.3.0`, `torch>=2.2.0`. ultralytics fetches CLIP on first use of the detector |
|
|
29
|
+
| `youtube` | the `youtube` source | `yt-dlp>=2026.03.17` and `deno>=2.8.0`, which ships the deno binary yt-dlp runs to solve YouTube's stream challenge |
|
|
30
|
+
| `supabase` | the `supabase` sink | `supabase>=2.4.0` |
|
|
31
|
+
|
|
32
|
+
## Run
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
detstream --config examples/otters.yaml
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A config lists feeds and shared sink settings:
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
feeds:
|
|
42
|
+
- id: monterey-otters
|
|
43
|
+
name: Monterey Sea Otters
|
|
44
|
+
source: { type: youtube, url: "https://www.youtube.com/watch?v=abbR-Ttd-cA" }
|
|
45
|
+
detector: { type: yolo-world, prompt: otter swimming, confidence_threshold: 0.4 }
|
|
46
|
+
debounce: { enter_frames: 3, exit_frames: 5, cooldown_s: 120, sample_interval_s: 2 }
|
|
47
|
+
sinks: [console, supabase]
|
|
48
|
+
sinks:
|
|
49
|
+
supabase: { bucket: thumbnails, detector_label: yolo-world, retention_hours: 3, thumbnail_width: 960 }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Credentials and webhook URLs are configured in `.env`: `DETSTREAM_SUPABASE_URL`, `DETSTREAM_SUPABASE_KEY`, and `DETSTREAM_DISCORD_WEBHOOK_URL`.
|
|
53
|
+
|
|
54
|
+
## Plugins
|
|
55
|
+
|
|
56
|
+
Built-in components register themselves on import. To add your own detector (an HF model, a
|
|
57
|
+
cloud API, a fine-tuned ONNX, etc.), register a factory and declare an entry point:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
# mypkg/detector.py
|
|
61
|
+
from detstream.detectors import detectors, Detection
|
|
62
|
+
|
|
63
|
+
class MyDetector:
|
|
64
|
+
def detect(self, frame) -> Detection: ...
|
|
65
|
+
|
|
66
|
+
@detectors.register("my-model")
|
|
67
|
+
def _build(config: dict) -> MyDetector:
|
|
68
|
+
return MyDetector(**config)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
# mypkg/pyproject.toml
|
|
73
|
+
[project.entry-points."detstream.detectors"]
|
|
74
|
+
my-model = "mypkg.detector"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
After `pip install`, reference it in config as `detector: { type: my-model, ... }`. detstream
|
|
78
|
+
discovers it through the entry point with no change to detstream itself. The same pattern works
|
|
79
|
+
for `detstream.sources` and `detstream.sinks`.
|
|
80
|
+
|
|
81
|
+
## Layout
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
detstream/
|
|
85
|
+
registry.py register + create + entry-point discovery
|
|
86
|
+
config.py FeedConfig / AppConfig, loads YAML
|
|
87
|
+
runner.py per-feed asyncio loop
|
|
88
|
+
state.py SightingTracker: hysteresis + cooldown, no I/O
|
|
89
|
+
events.py SightingStarted / SightingEnded
|
|
90
|
+
sources/ youtube, stream, file_device (+ shared reconnect base)
|
|
91
|
+
detectors/ yolo_world
|
|
92
|
+
sinks/ console, supabase, discord
|
|
93
|
+
examples/ otters.yaml, eagles.yaml
|
|
94
|
+
tests/ config, registry, sources, state, sinks, detectors
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from .config import load_config
|
|
7
|
+
from .runner import run
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="detstream")
|
|
12
|
+
parser.add_argument("--config", required=True, help="path to a YAML/JSON feed config")
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
|
|
15
|
+
# Force line buffering so log lines appear as they happen
|
|
16
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
17
|
+
sys.stderr.reconfigure(line_buffering=True)
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = load_config(args.config)
|
|
24
|
+
try:
|
|
25
|
+
asyncio.run(run(app))
|
|
26
|
+
except KeyboardInterrupt:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import cv2
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .detectors import Detection
|
|
5
|
+
|
|
6
|
+
BOX_COLOR = (208, 147, 0) # BGR (a mid blue)
|
|
7
|
+
BOX_THICKNESS = 3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Draw the detection box and a confidence label onto a copy of the frame. Returns the
|
|
11
|
+
# frame unchanged when there is no box, so it is safe to call on every sampled frame
|
|
12
|
+
def annotate(frame: np.ndarray, detection: Detection, label: str = "") -> np.ndarray:
|
|
13
|
+
if detection.box is None:
|
|
14
|
+
return frame
|
|
15
|
+
out = frame.copy()
|
|
16
|
+
x1, y1, x2, y2 = (int(v) for v in detection.box)
|
|
17
|
+
cv2.rectangle(out, (x1, y1), (x2, y2), BOX_COLOR, BOX_THICKNESS)
|
|
18
|
+
|
|
19
|
+
text = f"{label} {detection.confidence:.0%}".strip()
|
|
20
|
+
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
|
21
|
+
ty = max(y1, th + 6)
|
|
22
|
+
cv2.rectangle(out, (x1, ty - th - 6), (x1 + tw + 6, ty), BOX_COLOR, -1)
|
|
23
|
+
cv2.putText(
|
|
24
|
+
out, text, (x1 + 3, ty - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2
|
|
25
|
+
)
|
|
26
|
+
return out
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
_ENV_REF = re.compile(r"\$\{(\w+)\}")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ComponentConfig(BaseModel):
|
|
12
|
+
type: str
|
|
13
|
+
# Everything else is the component's own config sub-block, passed through to its
|
|
14
|
+
# factory. Sources/detectors vary by type, so the schema is intentionally open
|
|
15
|
+
model_config = {"extra": "allow"}
|
|
16
|
+
|
|
17
|
+
def options(self) -> dict:
|
|
18
|
+
return self.model_dump(exclude={"type"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DebounceConfig(BaseModel):
|
|
22
|
+
sample_interval_s: float = 2.0
|
|
23
|
+
enter_frames: int = 3
|
|
24
|
+
exit_frames: int = 5
|
|
25
|
+
cooldown_s: float = 120.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FeedConfig(BaseModel):
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
source: ComponentConfig
|
|
32
|
+
detector: ComponentConfig
|
|
33
|
+
debounce: DebounceConfig = Field(default_factory=DebounceConfig)
|
|
34
|
+
sinks: list[str] = Field(default_factory=lambda: ["console"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AppConfig(BaseModel):
|
|
38
|
+
feeds: list[FeedConfig]
|
|
39
|
+
# Shared sink settings keyed by sink name, e.g. {"supabase": {...}, "discord": {...}}.
|
|
40
|
+
# A feed enables a sink by listing its name, the settings come from here
|
|
41
|
+
sinks: dict[str, dict] = Field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
# A `sinks:` block with everything commented out parses as None
|
|
44
|
+
@field_validator("sinks", mode="before")
|
|
45
|
+
@classmethod
|
|
46
|
+
def _none_sinks_to_empty(cls, v):
|
|
47
|
+
return v or {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _expand_env(value):
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
return _ENV_REF.sub(lambda m: os.environ.get(m.group(1), ""), value)
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return {k: _expand_env(v) for k, v in value.items()}
|
|
55
|
+
if isinstance(value, list):
|
|
56
|
+
return [_expand_env(v) for v in value]
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_dotenv(path: Path) -> None:
|
|
61
|
+
env_file = path.parent / ".env"
|
|
62
|
+
if not env_file.exists():
|
|
63
|
+
return
|
|
64
|
+
for line in env_file.read_text().splitlines():
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
67
|
+
continue
|
|
68
|
+
key, _, value = line.partition("=")
|
|
69
|
+
key = key.removeprefix("export ").strip()
|
|
70
|
+
value = value.strip()
|
|
71
|
+
if value and value[0] in "'\"":
|
|
72
|
+
# Quoted value: everything up to the matching quote is literal
|
|
73
|
+
quote = value[0]
|
|
74
|
+
end = value.find(quote, 1)
|
|
75
|
+
value = value[1:end] if end != -1 else value[1:]
|
|
76
|
+
else:
|
|
77
|
+
# Unquoted: an inline ` # comment` is not part of the value
|
|
78
|
+
value = value.split(" #", 1)[0].strip()
|
|
79
|
+
os.environ.setdefault(key, value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(path: str | Path) -> AppConfig:
|
|
83
|
+
path = Path(path)
|
|
84
|
+
_load_dotenv(path)
|
|
85
|
+
data = _expand_env(yaml.safe_load(path.read_text()))
|
|
86
|
+
return AppConfig(**data)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ..registry import Registry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Detection:
|
|
10
|
+
present: bool
|
|
11
|
+
confidence: float
|
|
12
|
+
box: tuple[float, float, float, float] | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Detector(Protocol):
|
|
16
|
+
def detect(self, frame: np.ndarray) -> Detection: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
detectors: Registry[Detector] = Registry("detstream.detectors")
|
|
20
|
+
|
|
21
|
+
# Import built-ins so they self-register
|
|
22
|
+
# A deployment that did not install the yolo extra can still use a plugin detector without importing ultralytics
|
|
23
|
+
try:
|
|
24
|
+
from . import yolo_world # noqa: F401
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
from . import Detection, detectors
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Open-vocabulary detector prompted with a class name
|
|
7
|
+
class YoloWorldDetector:
|
|
8
|
+
def __init__(self, prompt: str, confidence_threshold: float, weights: str = ""):
|
|
9
|
+
from ultralytics import YOLO
|
|
10
|
+
|
|
11
|
+
self.prompt = prompt
|
|
12
|
+
self.threshold = confidence_threshold
|
|
13
|
+
self.model = YOLO(weights or "yolov8s-world.pt")
|
|
14
|
+
self.model.set_classes([prompt])
|
|
15
|
+
|
|
16
|
+
def detect(self, frame: np.ndarray) -> Detection:
|
|
17
|
+
results = self.model.predict(frame, conf=0.01, verbose=False)
|
|
18
|
+
best = 0.0
|
|
19
|
+
box = None
|
|
20
|
+
for r in results:
|
|
21
|
+
if r.boxes is None or len(r.boxes) == 0:
|
|
22
|
+
continue
|
|
23
|
+
top = int(r.boxes.conf.argmax())
|
|
24
|
+
conf = float(r.boxes.conf[top])
|
|
25
|
+
if conf > best:
|
|
26
|
+
best = conf
|
|
27
|
+
box = tuple(r.boxes.xyxy[top].cpu().tolist())
|
|
28
|
+
return Detection(present=best >= self.threshold, confidence=best, box=box)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@detectors.register("yolo-world")
|
|
32
|
+
def _build(config: dict) -> YoloWorldDetector:
|
|
33
|
+
return YoloWorldDetector(
|
|
34
|
+
prompt=config.get("prompt", "object"),
|
|
35
|
+
confidence_threshold=config.get("confidence_threshold", 0.4),
|
|
36
|
+
weights=config.get("weights", ""),
|
|
37
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class SightingStarted:
|
|
8
|
+
feed_id: str
|
|
9
|
+
confidence: float
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SightingEnded:
|
|
14
|
+
feed_id: str
|
|
15
|
+
peak_confidence: float
|
|
16
|
+
# The peak-confidence frame, annotated, captured over the sighting's life. The
|
|
17
|
+
# thumbnail is uploaded from this so it matches the stored peak confidence
|
|
18
|
+
peak_frame: np.ndarray | None = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from importlib.metadata import entry_points
|
|
4
|
+
from typing import Callable, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
# A factory takes the component's config sub-block (a plain dict) and returns an
|
|
11
|
+
# instance. Built-ins register a factory under a name, config selects by that name
|
|
12
|
+
Factory = Callable[[dict], T]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Registry(Generic[T]):
|
|
16
|
+
def __init__(self, entry_point_group: str):
|
|
17
|
+
self._factories: dict[str, Factory[T]] = {}
|
|
18
|
+
self._group = entry_point_group
|
|
19
|
+
self._loaded_entry_points = False
|
|
20
|
+
|
|
21
|
+
def register(self, name: str) -> Callable[[Factory[T]], Factory[T]]:
|
|
22
|
+
def decorator(factory: Factory[T]) -> Factory[T]:
|
|
23
|
+
if name in self._factories:
|
|
24
|
+
raise ValueError(f"{name!r} is already registered in {self._group}")
|
|
25
|
+
self._factories[name] = factory
|
|
26
|
+
return factory
|
|
27
|
+
|
|
28
|
+
return decorator
|
|
29
|
+
|
|
30
|
+
def create(self, name: str, config: dict) -> T:
|
|
31
|
+
if name not in self._factories:
|
|
32
|
+
self._load_entry_points()
|
|
33
|
+
try:
|
|
34
|
+
factory = self._factories[name]
|
|
35
|
+
except KeyError:
|
|
36
|
+
known = ", ".join(sorted(self._factories)) or "(none)"
|
|
37
|
+
raise ValueError(f"unknown {self._group} {name!r}; known: {known}") from None
|
|
38
|
+
return factory(config)
|
|
39
|
+
|
|
40
|
+
# Discover plugins declared by installed packages under this group. A plugin's
|
|
41
|
+
# entry point points at a callable that registers its factories on import, so
|
|
42
|
+
# loading the module is enough. The value is called if it is callable
|
|
43
|
+
def _load_entry_points(self) -> None:
|
|
44
|
+
if self._loaded_entry_points:
|
|
45
|
+
return
|
|
46
|
+
self._loaded_entry_points = True
|
|
47
|
+
for ep in entry_points(group=self._group):
|
|
48
|
+
try:
|
|
49
|
+
obj = ep.load()
|
|
50
|
+
if callable(obj):
|
|
51
|
+
obj()
|
|
52
|
+
except Exception as e:
|
|
53
|
+
log.warning("failed to load plugin %r in %s: %s", ep.name, self._group, e)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from .annotate import annotate
|
|
6
|
+
from .config import AppConfig, FeedConfig
|
|
7
|
+
from .detectors import Detector, detectors
|
|
8
|
+
from .events import SightingStarted
|
|
9
|
+
from .sinks import Sink, sinks as sink_registry
|
|
10
|
+
from .sources import sources as source_registry
|
|
11
|
+
from .state import SightingTracker
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_sinks(feed: FeedConfig, app: AppConfig) -> list[Sink]:
|
|
17
|
+
built: list[Sink] = []
|
|
18
|
+
for name in feed.sinks:
|
|
19
|
+
config = app.sinks.get(name, {})
|
|
20
|
+
built.append(sink_registry.create(name, config))
|
|
21
|
+
return built
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Restart backoff for a feed whose watch loop crashed
|
|
25
|
+
FEED_RESTART_BACKOFF_S = (5.0, 15.0, 30.0, 60.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _dispatch(sinks: list[Sink], call) -> None:
|
|
29
|
+
# One sink failing (a network blip on the supabase POST) must not stop the others or
|
|
30
|
+
# kill the feed, so each call is awaited independently and its error logged
|
|
31
|
+
results = await asyncio.gather(*(call(s) for s in sinks), return_exceptions=True)
|
|
32
|
+
for sink, result in zip(sinks, results):
|
|
33
|
+
if isinstance(result, Exception):
|
|
34
|
+
log.warning("sink %s failed: %s", type(sink).__name__, result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def watch_feed(feed: FeedConfig, detector: Detector, sinks: list[Sink]) -> None:
|
|
38
|
+
tracker = SightingTracker(
|
|
39
|
+
feed_id=feed.id,
|
|
40
|
+
enter_frames=feed.debounce.enter_frames,
|
|
41
|
+
exit_frames=feed.debounce.exit_frames,
|
|
42
|
+
cooldown_s=feed.debounce.cooldown_s,
|
|
43
|
+
)
|
|
44
|
+
loop = asyncio.get_running_loop()
|
|
45
|
+
source = source_registry.create(
|
|
46
|
+
feed.source.type,
|
|
47
|
+
{**feed.source.options(), "interval_s": feed.debounce.sample_interval_s},
|
|
48
|
+
)
|
|
49
|
+
async for _ts, frame in source.frames():
|
|
50
|
+
det = await asyncio.to_thread(detector.detect, frame)
|
|
51
|
+
# Annotate before the tracker captures the frame, so the saved thumbnail shows
|
|
52
|
+
# the detection box
|
|
53
|
+
annotated = annotate(frame, det, feed.name) if det.present else frame
|
|
54
|
+
event = tracker.update(det.present, det.confidence, annotated, loop.time())
|
|
55
|
+
if event is None:
|
|
56
|
+
continue
|
|
57
|
+
if isinstance(event, SightingStarted):
|
|
58
|
+
await _dispatch(sinks, lambda s: s.on_sighting_start(event, feed.name))
|
|
59
|
+
else:
|
|
60
|
+
await _dispatch(sinks, lambda s: s.on_sighting_end(event))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def supervise_feed(feed: FeedConfig, detector: Detector, sinks: list[Sink]) -> None:
|
|
64
|
+
attempt = 0
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
await watch_feed(feed, detector, sinks)
|
|
68
|
+
return
|
|
69
|
+
except asyncio.CancelledError:
|
|
70
|
+
raise
|
|
71
|
+
except Exception as e:
|
|
72
|
+
backoff = FEED_RESTART_BACKOFF_S[min(attempt, len(FEED_RESTART_BACKOFF_S) - 1)]
|
|
73
|
+
log.exception("feed %s crashed: %s; restarting in %ss", feed.id, e, backoff)
|
|
74
|
+
attempt += 1
|
|
75
|
+
await asyncio.sleep(backoff)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
CLEANUP_INTERVAL_S = float(os.environ.get("DETSTREAM_CLEANUP_INTERVAL_S", str(60 * 60)))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Run each sink's cleanup() on an interval (hourly by default)
|
|
82
|
+
async def cleanup_loop(sinks: list[Sink]) -> None:
|
|
83
|
+
targets = [s for s in sinks if hasattr(s, "cleanup")]
|
|
84
|
+
if not targets:
|
|
85
|
+
return
|
|
86
|
+
while True:
|
|
87
|
+
await asyncio.sleep(CLEANUP_INTERVAL_S)
|
|
88
|
+
for sink in targets:
|
|
89
|
+
try:
|
|
90
|
+
await asyncio.to_thread(sink.cleanup)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
log.warning("cleanup failed for %s: %s", type(sink).__name__, e)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def run(app: AppConfig) -> None:
|
|
96
|
+
if not app.feeds:
|
|
97
|
+
log.error("no feeds configured")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
tasks = []
|
|
101
|
+
all_sinks: list[Sink] = []
|
|
102
|
+
for feed in app.feeds:
|
|
103
|
+
detector = detectors.create(feed.detector.type, feed.detector.options())
|
|
104
|
+
sinks = build_sinks(feed, app)
|
|
105
|
+
all_sinks.extend(sinks)
|
|
106
|
+
log.info("watching %s with %d sink(s)", feed.id, len(sinks))
|
|
107
|
+
tasks.append(asyncio.create_task(supervise_feed(feed, detector, sinks)))
|
|
108
|
+
|
|
109
|
+
# Dedupe sinks of the same type so cleanup runs once
|
|
110
|
+
unique_sinks = list({type(s): s for s in all_sinks}.values())
|
|
111
|
+
tasks.append(asyncio.create_task(cleanup_loop(unique_sinks)))
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await asyncio.gather(*tasks)
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
for t in tasks:
|
|
117
|
+
t.cancel()
|
|
118
|
+
raise
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
from ..events import SightingEnded, SightingStarted
|
|
4
|
+
from ..registry import Registry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Sink(Protocol):
|
|
8
|
+
async def on_sighting_start(self, event: SightingStarted, feed_name: str) -> None: ...
|
|
9
|
+
async def on_sighting_end(self, event: SightingEnded) -> None: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
sinks: Registry[Sink] = Registry("detstream.sinks")
|
|
13
|
+
|
|
14
|
+
# console has no optional deps. The others self-register but their factories import their
|
|
15
|
+
# client libraries lazily, so a deployment only pays for the sinks it enables
|
|
16
|
+
from . import console # noqa: E402,F401
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from . import supabase # noqa: F401
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
try:
|
|
23
|
+
from . import discord # noqa: F401
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|