detstream 0.1.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.
- detstream/__init__.py +6 -0
- detstream/__main__.py +31 -0
- detstream/annotate.py +26 -0
- detstream/config.py +86 -0
- detstream/detectors/__init__.py +26 -0
- detstream/detectors/yolo_world.py +37 -0
- detstream/events.py +18 -0
- detstream/registry.py +53 -0
- detstream/runner.py +118 -0
- detstream/sinks/__init__.py +25 -0
- detstream/sinks/console.py +19 -0
- detstream/sinks/discord.py +40 -0
- detstream/sinks/supabase.py +124 -0
- detstream/sources/__init__.py +73 -0
- detstream/sources/file_device.py +38 -0
- detstream/sources/stream.py +23 -0
- detstream/sources/youtube.py +40 -0
- detstream/state.py +59 -0
- detstream-0.1.0.dist-info/METADATA +133 -0
- detstream-0.1.0.dist-info/RECORD +24 -0
- detstream-0.1.0.dist-info/WHEEL +5 -0
- detstream-0.1.0.dist-info/entry_points.txt +2 -0
- detstream-0.1.0.dist-info/licenses/LICENSE +21 -0
- detstream-0.1.0.dist-info/top_level.txt +1 -0
detstream/__init__.py
ADDED
detstream/__main__.py
ADDED
|
@@ -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()
|
detstream/annotate.py
ADDED
|
@@ -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
|
detstream/config.py
ADDED
|
@@ -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
|
+
)
|
detstream/events.py
ADDED
|
@@ -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
|
detstream/registry.py
ADDED
|
@@ -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)
|
detstream/runner.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
from ..events import SightingEnded, SightingStarted
|
|
4
|
+
from . import sinks
|
|
5
|
+
|
|
6
|
+
log = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConsoleSink:
|
|
10
|
+
async def on_sighting_start(self, event: SightingStarted, feed_name: str) -> None:
|
|
11
|
+
log.info("sighting on %s (%.2f)", feed_name, event.confidence)
|
|
12
|
+
|
|
13
|
+
async def on_sighting_end(self, event: SightingEnded) -> None:
|
|
14
|
+
log.info("ended on %s (peak %.2f)", event.feed_id, event.peak_confidence)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@sinks.register("console")
|
|
18
|
+
def _build(config: dict) -> ConsoleSink:
|
|
19
|
+
return ConsoleSink()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
import httpx
|
|
4
|
+
from ..events import SightingEnded, SightingStarted
|
|
5
|
+
from . import sinks
|
|
6
|
+
|
|
7
|
+
DEFAULT_COLOR = 0x6EC9A9
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Posts a rich embed on each new sighting with feed name and peak confidence.
|
|
11
|
+
# Honors the per-feed cooldown upstream in SightingTracker
|
|
12
|
+
# A watch live link is per-feed, so it is passed via config.watch_urls
|
|
13
|
+
class DiscordSink:
|
|
14
|
+
def __init__(self, config: dict):
|
|
15
|
+
self.webhook_url = os.environ.get("DETSTREAM_DISCORD_WEBHOOK_URL", "")
|
|
16
|
+
if not self.webhook_url:
|
|
17
|
+
raise ValueError("discord sink needs DETSTREAM_DISCORD_WEBHOOK_URL")
|
|
18
|
+
self.color = config.get("color", DEFAULT_COLOR)
|
|
19
|
+
self.watch_urls: dict[str, str] = config.get("watch_urls", {})
|
|
20
|
+
|
|
21
|
+
async def on_sighting_start(self, event: SightingStarted, feed_name: str) -> None:
|
|
22
|
+
embed = {
|
|
23
|
+
"title": f"spotted on {feed_name}",
|
|
24
|
+
"color": self.color,
|
|
25
|
+
"fields": [{"name": "confidence", "value": f"{event.confidence:.0%}", "inline": True}],
|
|
26
|
+
}
|
|
27
|
+
watch_url = self.watch_urls.get(event.feed_id)
|
|
28
|
+
if watch_url:
|
|
29
|
+
embed["url"] = watch_url
|
|
30
|
+
embed["description"] = f"[watch live]({watch_url})"
|
|
31
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
32
|
+
await client.post(self.webhook_url, json={"embeds": [embed]})
|
|
33
|
+
|
|
34
|
+
async def on_sighting_end(self, event: SightingEnded) -> None:
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@sinks.register("discord")
|
|
39
|
+
def _build(config: dict) -> DiscordSink:
|
|
40
|
+
return DiscordSink(config)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
import cv2
|
|
6
|
+
from ..events import SightingEnded, SightingStarted
|
|
7
|
+
from . import sinks
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Writes a sightings row on start and patches ended_at on end. The peak confidence frame is
|
|
13
|
+
# downscaled, uploaded to Supabase Storage, and its URL is stored on the row. The website
|
|
14
|
+
# subscribes to the table via Realtime
|
|
15
|
+
class SupabaseSink:
|
|
16
|
+
def __init__(self, config: dict):
|
|
17
|
+
from supabase import create_client
|
|
18
|
+
|
|
19
|
+
url = os.environ.get("DETSTREAM_SUPABASE_URL", "")
|
|
20
|
+
key = os.environ.get("DETSTREAM_SUPABASE_KEY", "")
|
|
21
|
+
if not url or not key:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"supabase sink needs DETSTREAM_SUPABASE_URL and DETSTREAM_SUPABASE_KEY"
|
|
24
|
+
)
|
|
25
|
+
self.client = create_client(url, key)
|
|
26
|
+
self.bucket = config.get("bucket", "thumbnails")
|
|
27
|
+
self.detector_label = config.get("detector_label", "")
|
|
28
|
+
self.thumbnail_width = int(config.get("thumbnail_width", 960))
|
|
29
|
+
self.thumbnail_quality = int(config.get("thumbnail_quality", 70))
|
|
30
|
+
self.retention_hours = float(config.get("retention_hours", 3))
|
|
31
|
+
self._open_rows: dict[str, str] = {} # feed_id -> sighting row id
|
|
32
|
+
|
|
33
|
+
async def on_sighting_start(self, event: SightingStarted, feed_name: str) -> None:
|
|
34
|
+
# No thumbnail yet, it is uploaded on end from the peak-confidence frame
|
|
35
|
+
row = (
|
|
36
|
+
self.client.table("sightings")
|
|
37
|
+
.insert(
|
|
38
|
+
{
|
|
39
|
+
"cam_id": event.feed_id,
|
|
40
|
+
"confidence": event.confidence,
|
|
41
|
+
"detector": self.detector_label,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
.execute()
|
|
45
|
+
)
|
|
46
|
+
self._open_rows[event.feed_id] = row.data[0]["id"]
|
|
47
|
+
|
|
48
|
+
async def on_sighting_end(self, event: SightingEnded) -> None:
|
|
49
|
+
row_id = self._open_rows.pop(event.feed_id, None)
|
|
50
|
+
if row_id is None:
|
|
51
|
+
return
|
|
52
|
+
thumb_url = self._upload_thumbnail(event.feed_id, event.peak_frame)
|
|
53
|
+
self.client.table("sightings").update(
|
|
54
|
+
{
|
|
55
|
+
"ended_at": "now()",
|
|
56
|
+
"confidence": event.peak_confidence,
|
|
57
|
+
"thumbnail": thumb_url,
|
|
58
|
+
}
|
|
59
|
+
).eq("id", row_id).execute()
|
|
60
|
+
|
|
61
|
+
def _upload_thumbnail(self, feed_id: str, frame) -> str | None:
|
|
62
|
+
if frame is None:
|
|
63
|
+
return None
|
|
64
|
+
frame = self._downscale(frame)
|
|
65
|
+
ok, buf = cv2.imencode(
|
|
66
|
+
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.thumbnail_quality]
|
|
67
|
+
)
|
|
68
|
+
if not ok:
|
|
69
|
+
return None
|
|
70
|
+
path = f"{feed_id}/{uuid.uuid4().hex}.jpg"
|
|
71
|
+
# A storage failure (bucket full/missing) must not lose the row's ended_at patch,
|
|
72
|
+
# so the upload is isolated and the row keeps a null thumbnail
|
|
73
|
+
try:
|
|
74
|
+
self.client.storage.from_(self.bucket).upload(
|
|
75
|
+
path, buf.tobytes(), {"content-type": "image/jpeg"}
|
|
76
|
+
)
|
|
77
|
+
return self.client.storage.from_(self.bucket).get_public_url(path)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
log.warning("thumbnail upload failed for %s: %s", feed_id, e)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def _downscale(self, frame):
|
|
83
|
+
h, w = frame.shape[:2]
|
|
84
|
+
if w <= self.thumbnail_width:
|
|
85
|
+
return frame
|
|
86
|
+
new_h = round(h * self.thumbnail_width / w)
|
|
87
|
+
return cv2.resize(frame, (self.thumbnail_width, new_h), interpolation=cv2.INTER_AREA)
|
|
88
|
+
|
|
89
|
+
# Delete sightings and their thumbnail objects older than retention_hours
|
|
90
|
+
def cleanup(self) -> None:
|
|
91
|
+
if self.retention_hours <= 0:
|
|
92
|
+
return
|
|
93
|
+
from datetime import datetime, timedelta, timezone
|
|
94
|
+
|
|
95
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(hours=self.retention_hours)).isoformat()
|
|
96
|
+
old = (
|
|
97
|
+
self.client.table("sightings")
|
|
98
|
+
.select("id, thumbnail")
|
|
99
|
+
.lt("started_at", cutoff)
|
|
100
|
+
.execute()
|
|
101
|
+
.data
|
|
102
|
+
)
|
|
103
|
+
if not old:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
paths = []
|
|
107
|
+
for r in old:
|
|
108
|
+
url = r.get("thumbnail") or ""
|
|
109
|
+
marker = f"/{self.bucket}/"
|
|
110
|
+
i = url.find(marker)
|
|
111
|
+
if i != -1:
|
|
112
|
+
paths.append(url[i + len(marker) :])
|
|
113
|
+
for i in range(0, len(paths), 100):
|
|
114
|
+
self.client.storage.from_(self.bucket).remove(paths[i : i + 100])
|
|
115
|
+
|
|
116
|
+
ids = [r["id"] for r in old]
|
|
117
|
+
for i in range(0, len(ids), 100):
|
|
118
|
+
self.client.table("sightings").delete().in_("id", ids[i : i + 100]).execute()
|
|
119
|
+
log.info("cleanup: removed %d sighting(s) older than %gh", len(ids), self.retention_hours)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@sinks.register("supabase")
|
|
123
|
+
def _build(config: dict) -> SupabaseSink:
|
|
124
|
+
return SupabaseSink(config)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Callable, Protocol
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
from ..registry import Registry
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Source(Protocol):
|
|
15
|
+
def frames(self) -> AsyncIterator[tuple[float, np.ndarray]]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# How long VideoCapture reads can keep failing before the stream is treated as down,
|
|
19
|
+
# rather than emitting the last decoded frame
|
|
20
|
+
STREAM_DOWN_AFTER_S = 15.0
|
|
21
|
+
RECONNECT_BACKOFF_S = (2.0, 5.0, 15.0, 30.0, 60.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def capture_frames(
|
|
25
|
+
open_capture: Callable[[], "cv2.VideoCapture"],
|
|
26
|
+
interval_s: float,
|
|
27
|
+
*,
|
|
28
|
+
label: str,
|
|
29
|
+
reconnect: bool = True,
|
|
30
|
+
) -> AsyncIterator[tuple[float, np.ndarray]]:
|
|
31
|
+
"""Read frames from an OpenCV VideoCapture with reconnect + frozen-frame handling.
|
|
32
|
+
|
|
33
|
+
open_capture is called to (re)open the capture. Sources that need to re-resolve a
|
|
34
|
+
URL (YouTube) pass a closure that re-resolves. Sources reading a finite file pass
|
|
35
|
+
reconnect=False so EOF ends the stream instead of looping forever.
|
|
36
|
+
"""
|
|
37
|
+
attempt = 0
|
|
38
|
+
while True:
|
|
39
|
+
try:
|
|
40
|
+
cap = await asyncio.to_thread(open_capture)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
if not reconnect:
|
|
43
|
+
raise
|
|
44
|
+
backoff = RECONNECT_BACKOFF_S[min(attempt, len(RECONNECT_BACKOFF_S) - 1)]
|
|
45
|
+
log.warning("%s: open failed: %s; retrying in %ss", label, e, backoff)
|
|
46
|
+
attempt += 1
|
|
47
|
+
await asyncio.sleep(backoff)
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
attempt = 0
|
|
51
|
+
last_ok = time.monotonic()
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
ok, frame = await asyncio.to_thread(cap.read)
|
|
55
|
+
now = time.monotonic()
|
|
56
|
+
if ok and frame is not None:
|
|
57
|
+
last_ok = now
|
|
58
|
+
yield time.time(), frame
|
|
59
|
+
await asyncio.sleep(interval_s)
|
|
60
|
+
continue
|
|
61
|
+
if not reconnect:
|
|
62
|
+
return
|
|
63
|
+
if now - last_ok > STREAM_DOWN_AFTER_S:
|
|
64
|
+
log.warning("%s: stream down; reconnecting", label)
|
|
65
|
+
break
|
|
66
|
+
await asyncio.sleep(1.0)
|
|
67
|
+
finally:
|
|
68
|
+
await asyncio.to_thread(cap.release)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
sources: Registry[Source] = Registry("detstream.sources")
|
|
72
|
+
|
|
73
|
+
from . import file_device, stream, youtube # noqa: E402,F401
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from . import capture_frames, sources
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# A local video file or a webcam/capture device (integer index)
|
|
9
|
+
# By default a file stops at EOF, set loop: true to replay it (handy for
|
|
10
|
+
# demos). A device index always reconnects, like a live stream
|
|
11
|
+
class FileDeviceSource:
|
|
12
|
+
def __init__(self, path: str | int, interval_s: float, loop: bool):
|
|
13
|
+
self.path = path
|
|
14
|
+
self.interval_s = interval_s
|
|
15
|
+
self.loop = loop
|
|
16
|
+
|
|
17
|
+
def frames(self) -> AsyncIterator[tuple[float, np.ndarray]]:
|
|
18
|
+
is_device = isinstance(self.path, int)
|
|
19
|
+
reconnect = self.loop or is_device
|
|
20
|
+
return capture_frames(
|
|
21
|
+
lambda: cv2.VideoCapture(self.path),
|
|
22
|
+
self.interval_s,
|
|
23
|
+
label=f"file {self.path}",
|
|
24
|
+
reconnect=reconnect,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@sources.register("file")
|
|
29
|
+
def _build(config: dict) -> FileDeviceSource:
|
|
30
|
+
path = config["path"]
|
|
31
|
+
# An all-digit path is a capture device index, not a filename
|
|
32
|
+
if isinstance(path, str) and path.isdigit():
|
|
33
|
+
path = int(path)
|
|
34
|
+
return FileDeviceSource(
|
|
35
|
+
path=path,
|
|
36
|
+
interval_s=config.get("interval_s", 2.0),
|
|
37
|
+
loop=config.get("loop", False),
|
|
38
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from . import capture_frames, sources
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# A direct stream URL (RTSP, HLS, or HTTP) read straight by OpenCV, no resolve step.
|
|
9
|
+
# Covers IP cameras and raw stream endpoints
|
|
10
|
+
class StreamSource:
|
|
11
|
+
def __init__(self, url: str, interval_s: float):
|
|
12
|
+
self.url = url
|
|
13
|
+
self.interval_s = interval_s
|
|
14
|
+
|
|
15
|
+
def frames(self) -> AsyncIterator[tuple[float, np.ndarray]]:
|
|
16
|
+
return capture_frames(
|
|
17
|
+
lambda: cv2.VideoCapture(self.url), self.interval_s, label=f"stream {self.url}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@sources.register("stream")
|
|
22
|
+
def _build(config: dict) -> StreamSource:
|
|
23
|
+
return StreamSource(url=config["url"], interval_s=config.get("interval_s", 2.0))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from . import capture_frames, sources
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_stream(youtube_url: str) -> str:
|
|
9
|
+
import yt_dlp
|
|
10
|
+
|
|
11
|
+
# remote_components lets yt-dlp fetch and run its JS challenge solver
|
|
12
|
+
opts = {
|
|
13
|
+
"quiet": True,
|
|
14
|
+
"format": "best[protocol^=m3u8]/best",
|
|
15
|
+
"noplaylist": True,
|
|
16
|
+
"remote_components": ["ejs:github"],
|
|
17
|
+
}
|
|
18
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
19
|
+
info = ydl.extract_info(youtube_url, download=False)
|
|
20
|
+
return info["url"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# A YouTube live stream. yt-dlp resolves the page to an HLS manifest, which OpenCV reads
|
|
24
|
+
# The manifest URL expires, so each reconnect re-resolves rather than reusing the old one
|
|
25
|
+
class YouTubeSource:
|
|
26
|
+
def __init__(self, url: str, interval_s: float):
|
|
27
|
+
self.url = url
|
|
28
|
+
self.interval_s = interval_s
|
|
29
|
+
|
|
30
|
+
def frames(self) -> AsyncIterator[tuple[float, np.ndarray]]:
|
|
31
|
+
def open_capture() -> cv2.VideoCapture:
|
|
32
|
+
hls = resolve_stream(self.url)
|
|
33
|
+
return cv2.VideoCapture(hls)
|
|
34
|
+
|
|
35
|
+
return capture_frames(open_capture, self.interval_s, label=f"youtube {self.url}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@sources.register("youtube")
|
|
39
|
+
def _build(config: dict) -> YouTubeSource:
|
|
40
|
+
return YouTubeSource(url=config["url"], interval_s=config.get("interval_s", 2.0))
|
detstream/state.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .events import SightingEnded, SightingStarted
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# State tracking and cooldown over a stream of per-frame detections per feed
|
|
8
|
+
# Fires SightingStarted when a sighting begins, tracks peak confidence for the
|
|
9
|
+
# duration of the sighting, and doesn't fire again until the cooldown has elapsed
|
|
10
|
+
@dataclass
|
|
11
|
+
class SightingTracker:
|
|
12
|
+
feed_id: str
|
|
13
|
+
enter_frames: int
|
|
14
|
+
exit_frames: int
|
|
15
|
+
cooldown_s: float
|
|
16
|
+
|
|
17
|
+
present: bool = False
|
|
18
|
+
_over: int = 0
|
|
19
|
+
_under: int = 0
|
|
20
|
+
_peak: float = 0.0
|
|
21
|
+
_peak_frame: np.ndarray | None = None
|
|
22
|
+
_last_alert_at: float | None = field(default=None)
|
|
23
|
+
|
|
24
|
+
def update(
|
|
25
|
+
self, detected: bool, confidence: float, frame: np.ndarray, now: float
|
|
26
|
+
) -> SightingStarted | SightingEnded | None:
|
|
27
|
+
if detected:
|
|
28
|
+
self._over += 1
|
|
29
|
+
self._under = 0
|
|
30
|
+
else:
|
|
31
|
+
self._under += 1
|
|
32
|
+
self._over = 0
|
|
33
|
+
|
|
34
|
+
if self.present:
|
|
35
|
+
# Keep the frame from the highest-confidence moment for the thumbnail
|
|
36
|
+
if confidence > self._peak:
|
|
37
|
+
self._peak = confidence
|
|
38
|
+
self._peak_frame = frame
|
|
39
|
+
if self._under >= self.exit_frames:
|
|
40
|
+
self.present = False
|
|
41
|
+
ended = SightingEnded(self.feed_id, self._peak, self._peak_frame)
|
|
42
|
+
self._peak = 0.0
|
|
43
|
+
self._peak_frame = None
|
|
44
|
+
return ended
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
if self._over >= self.enter_frames and not self._in_cooldown(now):
|
|
48
|
+
self.present = True
|
|
49
|
+
self._last_alert_at = now
|
|
50
|
+
self._peak = confidence
|
|
51
|
+
self._peak_frame = frame
|
|
52
|
+
return SightingStarted(self.feed_id, confidence)
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def _in_cooldown(self, now: float) -> bool:
|
|
57
|
+
if self._last_alert_at is None:
|
|
58
|
+
return False
|
|
59
|
+
return (now - self._last_alert_at) < self.cooldown_s
|
|
@@ -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,24 @@
|
|
|
1
|
+
detstream/__init__.py,sha256=L7lddFxMl5oUZrs8ct43YyG3PY80tZMGkk-lxegUJmA,161
|
|
2
|
+
detstream/__main__.py,sha256=KcB6klF-sSOExKLC8m9tYrnzgD97ZzBMUa115nuQwOU,788
|
|
3
|
+
detstream/annotate.py,sha256=md5_gFA3bRYv1G6zqJrlD3jXPP-qa0n1-vrhjvgfdnM,976
|
|
4
|
+
detstream/config.py,sha256=gIdRcrGMhVO4F4Lr2ElXicbEsaj1z9n4QTgpoe06GIM,2736
|
|
5
|
+
detstream/events.py,sha256=BOFJHvgo7_cAeMORJWH1VQDjA8SYNWrhykXVkO2wDh8,444
|
|
6
|
+
detstream/registry.py,sha256=wEB_iWyIHEGNtmsTwCi-U3N8_7H-dwjEdsxjCugTzb0,2021
|
|
7
|
+
detstream/runner.py,sha256=Ds8CBjRR0y_2lLL5bmvv3mGeu8OOomr6WBcbaMLx3co,4288
|
|
8
|
+
detstream/state.py,sha256=BqrTtSBXwtZYcnG6hhBrm1V-NkOUX-BqqRUGck6s6ac,1978
|
|
9
|
+
detstream/detectors/__init__.py,sha256=tXs7QzebKo-ubMWSbGSmkeM_yBhjz0aiR5j6NH8gWdY,659
|
|
10
|
+
detstream/detectors/yolo_world.py,sha256=ZH1imGTdB6FiieBD89ldQhHIsaHYOJJH4n_72EjTBd8,1302
|
|
11
|
+
detstream/sinks/__init__.py,sha256=LQjbK6dbNPrJ9nkHIOKTPYnqptLUPb6vb69E-7T5FOk,747
|
|
12
|
+
detstream/sinks/console.py,sha256=9diJfRGJWOgVBnzpCxzKTXDk1nm9GBuUbyVfFuS5Xzg,581
|
|
13
|
+
detstream/sinks/discord.py,sha256=Wu3Hqg_w5EA71VMAWEj2wt_gt1kcablqj-KqGEps80w,1537
|
|
14
|
+
detstream/sinks/supabase.py,sha256=ysQYhB52V2sIxGm7i3lKCfY6f28ZcSPxDSHCDHcfo3w,4732
|
|
15
|
+
detstream/sources/__init__.py,sha256=yvFtB7k-5p85GPx1DJlCNFBf2psHwiG6KerP3ad3Ub0,2383
|
|
16
|
+
detstream/sources/file_device.py,sha256=HsSI2dr5hvQHvF04HyOE5nVcqQLXz7ysEND3cajAboM,1261
|
|
17
|
+
detstream/sources/stream.py,sha256=2Gwky7R3CuBIBaPH3_hnwtlNeHN_sw6Om6vuY8Owclw,766
|
|
18
|
+
detstream/sources/youtube.py,sha256=ON0FUJpNO7BhtXmAHgBmA3PUDEWcg_8NaZJhfhyJFz4,1335
|
|
19
|
+
detstream-0.1.0.dist-info/licenses/LICENSE,sha256=t6OtDm9utbKg4SC6nd9ojOssuacbnzHsPZCb6du-Nkg,1073
|
|
20
|
+
detstream-0.1.0.dist-info/METADATA,sha256=QLZAU37eFqB4atB71vQUNZ6EwigbHZraSwwQRoBxW7Y,4925
|
|
21
|
+
detstream-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
22
|
+
detstream-0.1.0.dist-info/entry_points.txt,sha256=DIJ756--PMRovx_pIVYV3fUoT0CXDs3bmqM0yOAeB3A,54
|
|
23
|
+
detstream-0.1.0.dist-info/top_level.txt,sha256=HV04D3nelp-oKiYr5w5322rnwuKcOYxGoWjhSO9enKs,10
|
|
24
|
+
detstream-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
detstream
|