from edge.app import run_once from edge.tracking import IouTracker from edge.smoothing import TrackSmootherPool from edge.detectors.fall import FallDetector from edge.detectors.immobility import ImmobilityWatchdog from edge.detectors.ghost import GhostIncidentDetector from edge.detectors.zone import ZoneIntrusionDetector from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig from edge.engine import EventEngine from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.tuning import apply_thresholds class PipelineRuntime: def __init__(self, config, source, pose, sink, wall_clock=None): self.config = config self.camera_id = config.camera_id # for per-camera zone routing (multi-cam) self.source = source self.pose = pose self.sink = sink # Tier-2 fallback (hardware-bringup.md): when the device cannot sustain full-rate pose # for every camera, infer on every Nth tick instead of dropping cameras. Detector timers # are wall/monotonic-based, not frame-count-based, so they stay correct at a lower # sampling rate — only the reaction granularity coarsens. self.infer_every = max(1, int(getattr(config, "infer_every", 1))) self._tick = 0 # Wall clock for Event.occurred_at only; the loop clock (step's `now`) is monotonic. None # off-daemon -> run_once stamps occurred_at with the monotonic `now`, preserving old tests. self._wall = wall_clock self.tracker = IouTracker() self.pool = TrackSmootherPool() self.zone_detector = ZoneIntrusionDetector([], config.zone) self.engine = EventEngine( FallDetector(config.fall), ImmobilityWatchdog(config.immobility), GhostIncidentDetector(config.ghost), self.zone_detector, config.engine, ) self.ring = RingBuffer() self.snapshotter = Snapshotter(FaceBlurrer()) # Watches the frame itself rather than the people in it: a knocked-askew or covered # camera keeps delivering healthy frames while every zone drawn on it is now wrong. self.integrity = CameraIntegrityWatch( getattr(config, "integrity", None) or IntegrityConfig()) def step(self, now: float) -> list: self._tick += 1 if self.infer_every > 1 and self._tick % self.infer_every != 0: # Drain-only tick. Pull the frame so the v4l2/CSI buffer stays current — a backed-up # buffer hands back seconds-old frames and delays every alert — then DISCARD it. # It must NOT reach the ring buffer: head boxes come from inference, so an # un-analyzed frame carries no blur regions and would ship an identifiable face in # the evidence timeline (PIPA). Returning [] (not None) keeps this a healthy tick # for the daemon's camera-health accounting; a dead camera still reports None below. ok, _ = self.source.read() return [] if ok else None return run_once(self.source, self.pose, self.tracker, self.pool, self.engine, self.ring, self.snapshotter, self.sink, now_fn=lambda: now, wall_fn=self._wall, integrity=self.integrity) def apply_zones(self, zones: list) -> None: self.zone_detector.zones = list(zones) def apply_thresholds(self, thresholds: dict, log=None) -> dict: """Push server-published tunables into the live detectors. Until this existed the server stored thresholds, /api/config delivered them and the edge parsed them into a dict it then threw away — the two ends of the wire were built and the middle was missing, so nothing an administrator changed ever reached a device. """ return apply_thresholds(self, thresholds, log=log)