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 ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("detstream")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ detstream = detstream.__main__:main
@@ -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