Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@
import logging
import os

<<<<<<< HEAD
import redis as redis_sync
from fastapi import FastAPI, Query
from fastapi.responses import Response
from prometheus_client import generate_latest
=======
from apps.backend.routes.zones import router as zones_router

app = FastAPI()
>>>>>>> 4d99088 (feat: adaptive anomaly baseline per zone using Welford's algorithm)
Comment on lines +26 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd apps/backend && wc -l main.py && head -100 main.py

Repository: Devnil434/Eagle

Length of output: 3019


🏁 Script executed:

cd apps/backend && sed -n '35,90p' main.py | cat -n

Repository: Devnil434/Eagle

Length of output: 2270


🏁 Script executed:

cd apps/backend && find . -name "zones.py" -type f && cat -n apps/backend/routes/zones.py

Repository: Devnil434/Eagle

Length of output: 156


🏁 Script executed:

cd apps/backend && cat -n routes/zones.py

Repository: Devnil434/Eagle

Length of output: 1679


Resolve this merge conflict and keep a single live app instance.

These conflict markers at lines 26–35 and 41–83 make the file invalid Python. The unresolved split also decides whether zones_router is included and whether app.state.redis is initialized—both required because zones_router depends on request.app.state.redis (see apps/backend/routes/zones.py:26).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/main.py` around lines 26 - 35, Resolve the merge conflict by
removing the Git conflict markers and creating a single FastAPI app instance:
import zones_router from apps.backend.routes.zones, keep the FastAPI()
instantiation as the single `app`, and ensure `app.state.redis` is initialized
(using the redis client currently aliased as `redis_sync` or whichever Redis
client you prefer) before including `zones_router` so routes that use
`request.app.state.redis` work; register the router
(`app.include_router(zones_router)`) and remove the duplicate/contradictory
blocks so there is only one `app` definition and no leftover conflict markers.


from apps.backend.routes.cameras import identity_router, router as cameras_router
from apps.backend.routes.feedback import router as feedback_router
from libs.observability.metrics import frames_processed_total

<<<<<<< HEAD
logger = logging.getLogger(__name__)

# ── App ───────────────────────────────────────────────────────────────────────
Expand All @@ -57,6 +64,23 @@ def _get_redis() -> redis_sync.Redis | None:
"""
global _redis
if _redis is None:
=======
try:
r = redis.from_url(REDIS_URL)
r.ping()
app.state.redis = r
print(f"[INFO] Connected to Redis at {REDIS_URL}")
except (redis.RedisError, redis.ConnectionError) as e:
print(f"[WARN] Redis not available: {e}")
r = None

app.include_router(zones_router)

@app.get("/health")
def health():
redis_status = "healthy"
if r is not None:
>>>>>>> 4d99088 (feat: adaptive anomaly baseline per zone using Welford's algorithm)
try:
client = redis_sync.from_url(REDIS_URL, socket_connect_timeout=2)
client.ping()
Expand Down
44 changes: 44 additions & 0 deletions apps/backend/routes/zones.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
apps/backend/routes/zones.py — Zone adaptive baseline statistics endpoint.

GET /zones/{name}/stats
Returns Welford running statistics for the named zone.
"""
from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel

from services.memory.baseline import ZoneBaseline, _ZONE_NAME_RE

router = APIRouter(prefix="/zones", tags=["zones"])


class ZoneStatsResponse(BaseModel):
zone: str
count: int
mean: float
variance: float
std: float
m2: float


def _get_redis(request: Request):
try:
return request.app.state.redis
except AttributeError:
raise HTTPException(status_code=503, detail="Redis not initialised in app.state")


@router.get("/{name}/stats", response_model=ZoneStatsResponse)
def get_zone_stats(name: str, redis=Depends(_get_redis)) -> ZoneStatsResponse:
"""
Return adaptive dwell-time statistics for *name* zone.

Statistics are computed incrementally via Welford's algorithm and
persisted in Redis under ``zone:{name}:stats``.
"""
if not _ZONE_NAME_RE.match(name):
raise HTTPException(status_code=422, detail="Invalid zone name")
stats = ZoneBaseline(redis, name).get_stats()
return ZoneStatsResponse(**stats)
7 changes: 7 additions & 0 deletions libs/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class Settings(BaseSettings):
reasoning_dwell_threshold_seconds: float = 5.0
reasoning_cooldown_seconds: float = 5.0

# Action classifier settings
lingering_threshold_sec: float = 10.0
movement_threshold_px: float = 5.0
near_keypad_dist_px: float = 80.0
keypad_center_x: float = 640.0
keypad_center_y: float = 360.0

# Kafka Settings
use_kafka: bool = False
kafka_bootstrap_servers: str = "localhost:9092"
Expand Down
1 change: 1 addition & 0 deletions libs/schemas/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class TrackedObject(BaseModel):
trajectory: list[TrajectoryPoint] = Field(default_factory=list)
zones_present: list[str] = Field(default_factory=list)
last_seen_frame: int = 0
is_anomalous: bool = Field(False, description="True when dwell exceeds adaptive zone baseline")


class TrackedFrame(BaseModel):
Expand Down
128 changes: 128 additions & 0 deletions services/memory/baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
baseline.py — Adaptive anomaly baseline per surveillance zone.

Uses Welford's online algorithm to maintain a running mean and variance of
dwell times without batch recomputation. Statistics are persisted in Redis
under ``zone:{name}:stats`` so they survive restarts.

Anomaly rule
------------
dwell > mean + 2.5 * std
"""
from __future__ import annotations

import json
import math
import re
from dataclasses import dataclass
from typing import Optional

_ZONE_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")

STATS_TTL = 0 # 0 = no expiry — stats should persist indefinitely
ANOMALY_THRESHOLD = 2.5
MIN_COUNT_FOR_ANOMALY = 10 # need enough samples before flagging outliers


@dataclass
class WelfordStats:
count: int = 0
mean: float = 0.0
m2: float = 0.0 # sum of squared deviations (Welford accumulator)

@property
def variance(self) -> float:
# Sample variance (Bessel's correction) — correct for anomaly detection
return self.m2 / (self.count - 1) if self.count > 1 else 0.0

@property
def std(self) -> float:
return math.sqrt(self.variance)


class ZoneBaseline:
"""
Per-zone adaptive dwell-time baseline backed by Redis.

Parameters
----------
redis_client:
Connected ``redis.Redis`` (or FakeRedis for tests).
zone_name:
Logical zone identifier, e.g. ``"restricted_exit"``.
"""

def __init__(self, redis_client, zone_name: str) -> None:
if not _ZONE_NAME_RE.match(zone_name):
raise ValueError(
f"Invalid zone name '{zone_name}'. "
"Only alphanumeric characters, hyphens, and underscores are allowed (max 64 chars)."
)
self._r = redis_client
self._zone = zone_name
self._key = f"zone:{zone_name}:stats"
self._stats: Optional[WelfordStats] = None # lazy-loaded

# ── Public API ────────────────────────────────────────────────────────────

def update(self, dwell: float) -> None:
"""Ingest one dwell-time observation and persist updated stats."""
s = self._load()
s.count += 1
delta = dwell - s.mean
s.mean += delta / s.count
delta2 = dwell - s.mean
s.m2 += delta * delta2
self._save(s)

def is_anomalous(self, dwell: float) -> bool:
"""
Return True when *dwell* exceeds mean + 2.5 * std.

Returns False until MIN_COUNT_FOR_ANOMALY samples have been collected
so early noise doesn't produce false positives.
Returns False when std == 0 (all identical values) to avoid flagging
any noise as anomalous.
"""
s = self._load()
if s.count < MIN_COUNT_FOR_ANOMALY:
return False
if s.std == 0:
return False
return dwell > s.mean + ANOMALY_THRESHOLD * s.std

def get_stats(self) -> dict:
"""Return serialisable stats dict for the API response."""
s = self._load()
return {
"zone": self._zone,
"count": s.count,
"mean": round(s.mean, 4),
"variance": round(s.variance, 4),
"std": round(s.std, 4),
"m2": round(s.m2, 6),
}

# ── Redis helpers ─────────────────────────────────────────────────────────

def _load(self) -> WelfordStats:
if self._stats is not None:
return self._stats
raw = self._r.get(self._key)
if raw is None:
self._stats = WelfordStats()
else:
d = json.loads(raw)
self._stats = WelfordStats(
count = d["count"],
mean = d["mean"],
m2 = d["m2"],
)
return self._stats

def _save(self, s: WelfordStats) -> None:
payload = json.dumps({"count": s.count, "mean": s.mean, "m2": s.m2})
if STATS_TTL:
self._r.setex(self._key, STATS_TTL, payload)
else:
self._r.set(self._key, payload)
Loading
Loading