The engine was searching an empty room fifteen times a second
Measured rather than guessed, and the first guess was wrong. Wall clock said H.265 decode cost 58 ms a frame; cap.read() blocks until the next frame arrives, so that was the frame interval, not work. As CPU time: decode 3.7 ms, detection 31.0 ms - and detection ran on every frame whether or not anything was in front of the camera, 6,649 of 8,634 frames with faces_seen 0 and active_tracks 0 throughout. detect_threads: OpenCV spreads a small repeated job over eight threads, costing 31.0 ms of CPU for 8.9 ms of wall. One thread costs 15.3 ms for 15.3 ms, against a 66 ms budget at 15 fps. Half the CPU for latency nothing can notice. motion_gate: a 160x90 greyscale absdiff, 0.1 ms against detection's 15. Consulted only while no track is open; forced to look every motion_max_skip frames; compared against the last frame SEARCHED so a slow drift cannot creep under the threshold; and a threshold above this camera's measured noise and far below a person, so anything ambiguous detects. tests/test_motion_gate.py pins each of those rather than the saving, including asserting the longest run of skips rather than the total - counting the total would pass a gate that slept forty frames and then looked forty times. Together 80% -> 16% of a core, detection skipped on 92% of frames. faces_seen is still 0 and the gate is not why: run directly over the same frames the detector finds nothing at threshold 0.50 either. The placement is the limit, as recorded; the CPU was being spent to rediscover that fifteen times a second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
59
CLAUDE.md
59
CLAUDE.md
@@ -2226,6 +2226,65 @@ person does not re-file them:
|
|||||||
in the product a caller could reasonably guess wrong, and it is a route no
|
in the product a caller could reasonably guess wrong, and it is a route no
|
||||||
client app should ever call.
|
client app should ever call.
|
||||||
|
|
||||||
|
## CPU: the engine was searching an empty room 15 times a second
|
||||||
|
|
||||||
|
Measured on this Mac against the office camera, because "it feels hot" is not
|
||||||
|
a number. The first reading was **214% of a core**, and the first guess -
|
||||||
|
H.265 decode - was wrong. Wall-clock time said decode cost 58 ms a frame, but
|
||||||
|
`cap.read()` BLOCKS until the next frame arrives, so that number was the frame
|
||||||
|
interval, not work. Measured as CPU time instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
wall/frame CPU/frame at 15 fps
|
||||||
|
H.265 decode 58.8 ms 3.7 ms 5% of a core
|
||||||
|
YuNet detect 8.9 ms 31.0 ms 47% of a core
|
||||||
|
```
|
||||||
|
|
||||||
|
Detection costs three times its wall time because OpenCV spreads it over eight
|
||||||
|
threads. Decode is nearly free. So the cost is detection, and it was running on
|
||||||
|
**every frame whether or not anything was in front of the camera** - 6,649 of
|
||||||
|
8,634 frames searched, with `faces_seen: 0` and `active_tracks: 0` throughout.
|
||||||
|
|
||||||
|
Two changes, both measured:
|
||||||
|
|
||||||
|
- **`app.detect_threads: 1`.** OpenCV sizes its pool for one big job on an idle
|
||||||
|
machine; this is a small job repeated forever on a machine also running the
|
||||||
|
recogniser, the tracker and possibly three other cameras. One thread costs
|
||||||
|
15.3 ms of CPU against the default's 31.0 ms, for 6 ms more wall time against
|
||||||
|
a 66 ms frame budget. Half the CPU, no latency that matters.
|
||||||
|
- **`app.motion_gate`.** A 160x90 greyscale thumbnail and an `absdiff`: 0.1 ms
|
||||||
|
against detection's 15 ms, ninety times cheaper. A shop is empty most of the
|
||||||
|
day and an empty room costs exactly as much to search as a busy one.
|
||||||
|
|
||||||
|
Together: **80% -> 16% of a core**, with detection skipped on 92% of frames.
|
||||||
|
Against the original main-stream reading that is 214% -> 16%.
|
||||||
|
|
||||||
|
### Why the gate cannot lose a face
|
||||||
|
|
||||||
|
Cheapness is easy; this is the part that makes it acceptable, and
|
||||||
|
`tests/test_motion_gate.py` is the argument written down rather than asserted.
|
||||||
|
|
||||||
|
- It is only consulted while **no track is open**, so a person already being
|
||||||
|
followed is never subject to it.
|
||||||
|
- `motion_max_skip` forces a real detection about once a second whatever the
|
||||||
|
thumbnail says. The test asserts the longest *run* of skips, not the total:
|
||||||
|
what matters is the worst case a person can fall into, and counting the total
|
||||||
|
would pass a gate that skipped forty frames and then looked forty times.
|
||||||
|
- The comparison is against the last frame actually **searched**, not the last
|
||||||
|
frame seen, so a slow drift accumulates and trips the gate instead of sliding
|
||||||
|
under it one frame at a time. Someone easing into view slowly would otherwise
|
||||||
|
be invisible indefinitely.
|
||||||
|
- The threshold (1.0 mean absolute difference) sits above this camera's
|
||||||
|
measured noise floor (~0.3) and far below a person. Anything ambiguous falls
|
||||||
|
through to detection: when in doubt it looks.
|
||||||
|
|
||||||
|
Verified on the live camera after the change: `faces_seen: 0` - and the gate is
|
||||||
|
not why. Running the detector directly over the same frames finds **0 faces at
|
||||||
|
threshold 0.50**, let alone 0.82. The people in view are seated, side-on and
|
||||||
|
far away, which is the same `fraction_below_gate: 0.59` this file already
|
||||||
|
records. The placement is still the limit; the CPU was simply being spent to
|
||||||
|
discover that 15 times a second.
|
||||||
|
|
||||||
## Setting up on a new machine
|
## Setting up on a new machine
|
||||||
|
|
||||||
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
cfg = load_config(args.config)
|
cfg = load_config(args.config)
|
||||||
setup_logging(cfg.app.log_level, cfg.app.data_dir)
|
setup_logging(cfg.app.log_level, cfg.app.data_dir)
|
||||||
|
if cfg.app.detect_threads > 0:
|
||||||
|
# OpenCV sizes its pool for one big job on an idle machine. This is a
|
||||||
|
# small job repeated forever on a machine also running the recogniser,
|
||||||
|
# the tracker and possibly three other cameras, so the default costs
|
||||||
|
# twice the CPU for no useful latency. Measured: 31 ms CPU/frame at the
|
||||||
|
# default against 15 ms at one thread, for 6 ms more wall time against
|
||||||
|
# a 66 ms budget.
|
||||||
|
import cv2
|
||||||
|
cv2.setNumThreads(cfg.app.detect_threads)
|
||||||
|
log.info("detection threads: %d (OpenCV default was %d)",
|
||||||
|
cfg.app.detect_threads, cv2.getNumThreads())
|
||||||
missing = setup_models(cfg.app.models_dir)
|
missing = setup_models(cfg.app.models_dir)
|
||||||
if missing:
|
if missing:
|
||||||
log.error("required models missing: %s", ", ".join(missing))
|
log.error("required models missing: %s", ", ".join(missing))
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ class AppSection(BaseModel):
|
|||||||
# on changes what the system is under GDPR and India's DPDP, so it has to
|
# on changes what the system is under GDPR and India's DPDP, so it has to
|
||||||
# be a decision somebody makes rather than one they inherit.
|
# be a decision somebody makes rather than one they inherit.
|
||||||
store_faces: bool = False
|
store_faces: bool = False
|
||||||
|
# How many threads OpenCV may use for detection. Measured on the office
|
||||||
|
# camera (800x448 sub-stream): the default of 8 costs 31 ms of CPU per
|
||||||
|
# frame for 8.9 ms of wall time, while ONE thread costs 15.3 ms of CPU for
|
||||||
|
# 15.3 ms of wall - half the CPU for 6 ms more latency, against a 66 ms
|
||||||
|
# frame budget at 15 fps. The default is wrong here because OpenCV sizes it
|
||||||
|
# for one big job on an idle machine, and this is a small job repeated
|
||||||
|
# forever on a machine also running the recogniser, the tracker and three
|
||||||
|
# other cameras. 0 leaves OpenCV's own default alone.
|
||||||
|
detect_threads: int = 1
|
||||||
|
# Skip detection on frames where nothing has changed and nothing is being
|
||||||
|
# tracked. A shop is empty most of the day and a frame of an empty room
|
||||||
|
# costs exactly as much to search as a busy one. See CameraWorker.run for
|
||||||
|
# why this cannot lose a face.
|
||||||
|
motion_gate: bool = True
|
||||||
|
# Mean absolute difference, 0-255, over a 160x90 greyscale thumbnail. 1.0
|
||||||
|
# is well below the noise floor of a real camera - measured on this one,
|
||||||
|
# an empty room varies by ~0.3 between frames - so it triggers on movement
|
||||||
|
# rather than on sensor noise, and anything ambiguous detects.
|
||||||
|
motion_threshold: float = 1.0
|
||||||
|
# Detect at least this often regardless of the gate, so a change the
|
||||||
|
# thumbnail cannot see - someone entering at the far edge, a slow lean into
|
||||||
|
# frame - is still found within a second.
|
||||||
|
motion_max_skip: int = 12
|
||||||
|
|
||||||
|
|
||||||
class ApiSection(BaseModel):
|
class ApiSection(BaseModel):
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ class CameraWorker(threading.Thread):
|
|||||||
self._last_frame_ts = 0.0
|
self._last_frame_ts = 0.0
|
||||||
self._was_connected = False
|
self._was_connected = False
|
||||||
self.frames_processed = 0
|
self.frames_processed = 0
|
||||||
|
# Motion gate state: a 160x90 greyscale thumbnail of the last frame we
|
||||||
|
# actually searched, and how many frames we have skipped since.
|
||||||
|
self._motion_prev = None
|
||||||
|
self._motion_skipped = 0
|
||||||
|
self.frames_skipped = 0
|
||||||
self.faces_seen = 0
|
self.faces_seen = 0
|
||||||
self.pipeline = PipelineStats()
|
self.pipeline = PipelineStats()
|
||||||
# One outbox per worker, all writing into the same directory. Files are
|
# One outbox per worker, all writing into the same directory. Files are
|
||||||
@@ -236,6 +241,7 @@ class CameraWorker(threading.Thread):
|
|||||||
return {
|
return {
|
||||||
**self.source.stats(),
|
**self.source.stats(),
|
||||||
"frames_processed": self.frames_processed,
|
"frames_processed": self.frames_processed,
|
||||||
|
"frames_skipped": self.frames_skipped,
|
||||||
"faces_seen": self.faces_seen,
|
"faces_seen": self.faces_seen,
|
||||||
"active_tracks": len(self.tracker.tracks),
|
"active_tracks": len(self.tracker.tracks),
|
||||||
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
|
"pipeline": self.pipeline.snapshot(self.rcfg.min_enroll_quality),
|
||||||
@@ -244,6 +250,43 @@ class CameraWorker(threading.Thread):
|
|||||||
"enroll_threshold": self.rcfg.enroll_threshold},
|
"enroll_threshold": self.rcfg.enroll_threshold},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _nothing_moved(self, frame) -> bool:
|
||||||
|
"""True when this frame is close enough to the last searched one that
|
||||||
|
searching it again would find the same nothing.
|
||||||
|
|
||||||
|
It cannot lose a face, and that property is what makes it acceptable
|
||||||
|
rather than merely cheap. Three guards, in order:
|
||||||
|
|
||||||
|
* the caller only asks while NO track is open, so a person already
|
||||||
|
being followed is never affected by it;
|
||||||
|
* `motion_max_skip` forces a real detection about once a second
|
||||||
|
whatever the thumbnail says, which covers a change too small or too
|
||||||
|
gradual for it - someone easing into frame at the far edge;
|
||||||
|
* the threshold sits well above measured sensor noise and well below
|
||||||
|
a person, and anything ambiguous falls through to detection. When
|
||||||
|
in doubt it looks.
|
||||||
|
|
||||||
|
Cost is 0.1 ms against detection's 15 ms, so an empty shop stops paying
|
||||||
|
for a search of an empty room ~90 times a second.
|
||||||
|
"""
|
||||||
|
import cv2 as _cv2
|
||||||
|
small = _cv2.resize(_cv2.cvtColor(frame, _cv2.COLOR_BGR2GRAY), (160, 90),
|
||||||
|
interpolation=_cv2.INTER_AREA)
|
||||||
|
prev, self._motion_prev = self._motion_prev, small
|
||||||
|
if prev is None:
|
||||||
|
return False
|
||||||
|
if self._motion_skipped >= self.cfg.app.motion_max_skip:
|
||||||
|
self._motion_skipped = 0
|
||||||
|
return False
|
||||||
|
if float(_cv2.absdiff(small, prev).mean()) >= self.cfg.app.motion_threshold:
|
||||||
|
self._motion_skipped = 0
|
||||||
|
# Keep the thumbnail we just searched against, not this one, so a
|
||||||
|
# slow drift cannot creep past the threshold one frame at a time.
|
||||||
|
return False
|
||||||
|
self._motion_prev = prev
|
||||||
|
self._motion_skipped += 1
|
||||||
|
return True
|
||||||
|
|
||||||
# -- thread ---------------------------------------------------------
|
# -- thread ---------------------------------------------------------
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
tcfg = self.cfg.tracking
|
tcfg = self.cfg.tracking
|
||||||
@@ -256,6 +299,15 @@ class CameraWorker(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
self._last_frame_ts = ts
|
self._last_frame_ts = ts
|
||||||
|
|
||||||
|
# An empty room costs exactly as much to search as a busy one,
|
||||||
|
# and a shop is empty most of the day. Only ever while nothing
|
||||||
|
# is being tracked - see _nothing_moved.
|
||||||
|
if (self.cfg.app.motion_gate and not self.tracker.tracks
|
||||||
|
and self._nothing_moved(frame)):
|
||||||
|
self.frames_skipped += 1
|
||||||
|
self._remember_tracks([])
|
||||||
|
continue
|
||||||
|
|
||||||
detections = self.detector.detect(frame)
|
detections = self.detector.detect(frame)
|
||||||
for det in detections:
|
for det in detections:
|
||||||
det.quality = face_quality(frame, det.box, det.kps)
|
det.quality = face_quality(frame, det.box, det.kps)
|
||||||
|
|||||||
97
tests/test_motion_gate.py
Normal file
97
tests/test_motion_gate.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""The motion gate must save CPU without ever losing a face.
|
||||||
|
|
||||||
|
Cheapness is easy; the property that makes it acceptable is that every way it
|
||||||
|
could miss somebody is closed. These tests are that argument, written down.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from behavision.config import Config
|
||||||
|
from behavision.engine import CameraWorker
|
||||||
|
|
||||||
|
|
||||||
|
class _Worker:
|
||||||
|
"""CameraWorker's gate, without a camera, a model or a thread.
|
||||||
|
|
||||||
|
Built with object.__new__ and the two attributes the gate touches - the
|
||||||
|
pattern the other engine tests already use, so no test needs a 166 MB
|
||||||
|
model file or a live RTSP stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __new__(cls, **app):
|
||||||
|
w = object.__new__(CameraWorker)
|
||||||
|
w.cfg = Config()
|
||||||
|
for k, v in app.items():
|
||||||
|
setattr(w.cfg.app, k, v)
|
||||||
|
w._motion_prev = None
|
||||||
|
w._motion_skipped = 0
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def frame(value: int, size=(90, 160)) -> np.ndarray:
|
||||||
|
return np.full((*size, 3), value, dtype=np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_first_frame_is_always_searched():
|
||||||
|
"""Nothing to compare against is not evidence that nothing moved."""
|
||||||
|
w = _Worker()
|
||||||
|
assert w._nothing_moved(frame(40)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_still_room_is_skipped():
|
||||||
|
w = _Worker()
|
||||||
|
w._nothing_moved(frame(40)) # prime
|
||||||
|
assert w._nothing_moved(frame(40)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_movement_is_never_skipped():
|
||||||
|
w = _Worker()
|
||||||
|
w._nothing_moved(frame(40))
|
||||||
|
# a person is an enormous change next to a 1.0 threshold
|
||||||
|
assert w._nothing_moved(frame(120)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_gives_up_and_looks_anyway():
|
||||||
|
"""A change too small or too gradual for a thumbnail must still be found.
|
||||||
|
|
||||||
|
The invariant is about the longest RUN of skips, not the total: what
|
||||||
|
matters is the worst case a person could fall into, which is how long the
|
||||||
|
camera can go without actually looking. Counting the total instead would
|
||||||
|
pass a gate that skipped forty frames and then looked forty times.
|
||||||
|
"""
|
||||||
|
w = _Worker(motion_max_skip=5)
|
||||||
|
w._nothing_moved(frame(40))
|
||||||
|
run = longest = 0
|
||||||
|
for _ in range(40):
|
||||||
|
if w._nothing_moved(frame(40)):
|
||||||
|
run += 1
|
||||||
|
longest = max(longest, run)
|
||||||
|
else:
|
||||||
|
run = 0
|
||||||
|
assert longest <= 5, f"went {longest} frames without looking, cap is 5"
|
||||||
|
assert longest == 5, f"longest run was {longest} - the gate is not saving what it could"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_slow_drift_cannot_creep_past_the_threshold():
|
||||||
|
"""Each frame below the threshold, but the total far above it.
|
||||||
|
|
||||||
|
Compared against the last frame we SEARCHED rather than the last frame we
|
||||||
|
saw, so a gradual change accumulates and eventually trips the gate instead
|
||||||
|
of sliding under it one frame at a time. Without that, someone easing into
|
||||||
|
view slowly enough is invisible forever.
|
||||||
|
"""
|
||||||
|
w = _Worker(motion_max_skip=10_000) # the safety net must not rescue this
|
||||||
|
w._nothing_moved(frame(40))
|
||||||
|
tripped = None
|
||||||
|
for i in range(1, 30):
|
||||||
|
if w._nothing_moved(frame(40 + i)) is False:
|
||||||
|
tripped = i
|
||||||
|
break
|
||||||
|
assert tripped is not None, "a slow drift was never noticed"
|
||||||
|
assert tripped <= 5, f"took {tripped} frames of drift to notice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("gate", [True, False])
|
||||||
|
def test_the_gate_is_switchable(gate):
|
||||||
|
w = _Worker(motion_gate=gate)
|
||||||
|
assert w.cfg.app.motion_gate is gate
|
||||||
Reference in New Issue
Block a user