diff --git a/requirements.txt b/requirements.txt index adade78..d75b21d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/services/memory/memory.py b/services/memory/memory.py index e74fe76..f48e276 100644 --- a/services/memory/memory.py +++ b/services/memory/memory.py @@ -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__) @@ -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. @@ -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: """ @@ -358,28 +368,23 @@ 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, @@ -387,20 +392,20 @@ def get_active_track_ids(self, camera_id: str) -> set[int]: 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() diff --git a/services/tracking/__init__.py b/services/tracking/__init__.py index de724e0..6544b6b 100644 --- a/services/tracking/__init__.py +++ b/services/tracking/__init__.py @@ -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}") diff --git a/tests/test_memory.py b/tests/test_memory.py index 09b9b93..16bab42 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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 @@ -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, @@ -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" -