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
Binary file modified requirements.txt
Binary file not shown.
53 changes: 29 additions & 24 deletions services/memory/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from libs.schemas.tracking import TrackLifecycleEvent, TrackState
from libs.schemas.memory import TrackEvent, TrackSequence, ActionHint
from libs.config.settings import settings
from services.memory.baseline import ZoneBaseline
from services.tracking.cross_camera_reid import CrossCameraReID

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -214,7 +215,12 @@ def _load_record(self, camera_id: str, track_id: int) -> Optional[dict]:
raw = self._r.get(self._track_key(camera_id, track_id))
return json.loads(raw) if raw else None

def _update_record(self, event: TrackLifecycleEvent, state: str) -> None:
def _update_record(
self,
event: TrackLifecycleEvent,
state: str,
anomalous: bool = False,
) -> None:
"""
Update an existing track record's state and timing fields in Redis.

Expand Down Expand Up @@ -308,17 +314,21 @@ def __init__(self, redis_client, camera_id: str = "cam_01") -> None:

# ── Key helpers ───────────────────────────────────────────────────────────

def _seq_key(self, track_id: int) -> str:
return f"seq:{self._camera_id}:{track_id}"
def _seq_key(self, track_id: int, camera_id: Optional[str] = None) -> str:
cam = camera_id or self._camera_id
return f"seq:{cam}:{track_id}"

def _zones_key(self, track_id: int) -> str:
return f"zones:{self._camera_id}:{track_id}"
def _zones_key(self, track_id: int, camera_id: Optional[str] = None) -> str:
cam = camera_id or self._camera_id
return f"zones:{cam}:{track_id}"

def _zone_count_key(self, track_id: int, zone: str) -> str:
return f"zone_count:{self._camera_id}:{track_id}:{zone}"
def _zone_count_key(self, track_id: int, zone: str, camera_id: Optional[str] = None) -> str:
cam = camera_id or self._camera_id
return f"zone_count:{cam}:{track_id}:{zone}"

def _active_key(self) -> str:
return f"active:{self._camera_id}"
def _active_key(self, camera_id: Optional[str] = None) -> str:
cam = camera_id or self._camera_id
return f"active:{cam}"

def store_event(self, event) -> None:
"""
Expand Down Expand Up @@ -358,49 +368,44 @@ def get_sequence(self, track_id: int, last_n: Optional[int] = None, camera_id: O
Returns:
``TrackSequence`` (empty if the track has no stored events).
"""
from libs.schemas.memory import TrackEvent

key = self._seq_key(track_id)
key = self._seq_key(track_id, camera_id)
raw_list = self._r.lrange(key, -last_n, -1) if last_n else self._r.lrange(key, 0, -1)

def get_active_track_ids(self, camera_id: str) -> set[int]:
members = self._r.smembers(self._active_key(camera_id))
result: set[int] = set()
for m in members:
events: list[TrackEvent] = []
for raw in raw_list:
try:
data = json.loads(raw if isinstance(raw, str) else raw.decode())
events.append(TrackEvent(**data))
except Exception:
continue

zones_raw = self._r.smembers(self._zones_key(track_id))
zones_raw = self._r.smembers(self._zones_key(track_id, camera_id))
zones_visited = [z if isinstance(z, str) else z.decode() for z in zones_raw]
total_dwell = sum(e.dwell_time_seconds for e in events)

return TrackSequence(
track_id=track_id,
camera_id=self._camera_id,
camera_id=camera_id or self._camera_id,
events=events,
zones_visited=zones_visited,
total_dwell=total_dwell,
)

def get_zone_entry_count(self, track_id: int, zone: str, camera_id: Optional[str] = None) -> int:
"""Return the number of times *track_id* has entered *zone*."""
raw = self._r.get(self._zone_count_key(track_id, zone))
raw = self._r.get(self._zone_count_key(track_id, zone, camera_id))
if raw is None:
return 0
return int(raw if isinstance(raw, (int, str)) else raw.decode())

def get_active_track_ids(self, camera_id: str) -> set[int]:
"""Return the set of track IDs currently marked active for *camera_id*."""
members = self._r.smembers(f"active:{camera_id}")
members = self._r.smembers(self._active_key(camera_id))
return {int(m if isinstance(m, (int, str)) else m.decode()) for m in members}

def expire_track(self, track_id: int, camera_id: Optional[str] = None) -> None:
"""Remove all stored data for *track_id* and deregister it as active."""
pipe = self._r.pipeline()
pipe.delete(self._seq_key(track_id))
pipe.delete(self._zones_key(track_id))
pipe.srem(self._active_key(), str(track_id))
pipe.delete(self._seq_key(track_id, camera_id))
pipe.delete(self._zones_key(track_id, camera_id))
pipe.srem(self._active_key(camera_id), str(track_id))
pipe.execute()
9 changes: 9 additions & 0 deletions services/tracking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@
modules so that lightweight consumers (tests, memory service) can import
sub-modules like ``cross_camera_reid`` without pulling in the full stack.
"""
from importlib import import_module
from types import ModuleType

__all__: list[str] = []


def __getattr__(name: str) -> ModuleType:
if name == "tracker":
return import_module("services.tracking.tracker")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28 changes: 27 additions & 1 deletion tests/test_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import sys
import os
import time
import builtins
import importlib
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pytest
Expand Down Expand Up @@ -48,6 +50,31 @@ def test_track_event_serialises_cleanly():
assert ActionHint.WALKING.value == "walking"


def test_memory_import_does_not_require_cv2(monkeypatch):
"""Importing memory service should not eagerly import cv2-dependent tracker."""
real_import = builtins.__import__

def guarded_import(name, *args, **kwargs):
if name == "cv2":
raise ModuleNotFoundError("No module named 'cv2'")
return real_import(name, *args, **kwargs)

monkeypatch.setattr(builtins, "__import__", guarded_import)
monkeypatch.delitem(sys.modules, "services.tracking", raising=False)
monkeypatch.delitem(sys.modules, "services.tracking.tracker", raising=False)
monkeypatch.delitem(sys.modules, "services.memory.memory", raising=False)

imported = importlib.import_module("services.memory.memory")
assert hasattr(imported, "MemoryStore")
assert "cv2" not in sys.modules
store = imported.MemoryStore(redis_client=fakeredis.FakeRedis(decode_responses=True))
assert isinstance(store, imported.MemoryStore)
store.expire_track(999)
tracking = importlib.import_module("services.tracking")
with pytest.raises(AttributeError):
getattr(tracking, "does_not_exist")


def test_track_sequence_action_summary():
seq = TrackSequence(
track_id = 1,
Expand Down Expand Up @@ -407,4 +434,3 @@ def test_reasoning_result_id_present_after_set(store):
store.store_event(evt)
seq = store.get_sequence(track_id=51)
assert seq.events[0].reasoning_result_id == "test-alert-id-123"

Loading