Three states that looked like health from outside
Audited the engine for what it does when something goes wrong rather than when it goes right. Each of these left the process healthy, the dashboard green and the product not working. A gallery the running encoder cannot read. Embeddings are model-tagged, so when the fallback chain fires every vector the previous encoder wrote goes invisible: the shop keeps its customer list and recognises nobody on it, enrolling each regular a second time. Footfall stays correct, which is why nothing looks wrong. The only evidence was an INFO line reading 'gallery ready: 0 embeddings (model w600k_mbf) across 21 identities' - a sentence that states the disaster and calls it ready. Gallery.health now warns with the count of PEOPLE lost, not vectors, and carries the same numbers to /api/stats and /api/health, because a log line on a shop PC is read by nobody. Proved against the real 87-embedding gallery. Connected, and sending nothing. 'connected' meant the socket opened, so a stream that went quiet kept it true while last_frame_age_s climbed and the heartbeat told head office the camera was up. OpenCV breaks a blocked read at 30s, but a camera trickling a frame every 20s never trips that and never recovers. streaming/stalled are reported beside connected and the dashboard says live/stalled/offline - three states because offline sends you to the network and stalled says the camera is answering and sending nothing. The 5-second RTSP timeout that never existed. stimeout;5000000 carried a comment claiming it bounded a dead camera. Measured on OpenCV 4.11 / FFmpeg 7.1 against a socket that accepts and then says nothing: 30.0s with stimeout, 30.0s with timeout, 30.3s with no option at all - identical, so it was never honoured. stimeout became timeout in FFmpeg 5.0 and neither reaches the RTSP protocol through this path; the real bound is OpenCV's own interrupt constant. Replaced by the _tcp_reachable pre-flight probe_source already used, in code we own: 30.3s -> 0.00-2.02s, each naming its cause. That matters beyond speed - the VideoCapture constructor is not interruptible, so stop() could not cut it short and a camera removed from head office left a daemon thread holding a socket for half a minute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -18,6 +18,7 @@ import numpy as np
|
||||
|
||||
from .attributes import AttributeEstimator, aggregate as aggregate_attrs
|
||||
from .cameras import CameraStore
|
||||
from . import capture
|
||||
from .capture import VideoSource
|
||||
from .faces import FaceOutbox
|
||||
from .commission import CommissionRun
|
||||
@@ -169,6 +170,7 @@ class CameraWorker(threading.Thread):
|
||||
self._overlay_ts = 0.0
|
||||
self._last_frame_ts = 0.0
|
||||
self._was_connected = False
|
||||
self._was_stalled = False
|
||||
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.
|
||||
@@ -346,6 +348,20 @@ class CameraWorker(threading.Thread):
|
||||
type="camera.up" if connected else "camera.down",
|
||||
camera_id=self.cam_cfg.id))
|
||||
|
||||
# A stall is not a disconnect and must not be reported as one: the
|
||||
# socket is fine, the camera is answering, and nothing is arriving.
|
||||
# Logged on the transition only — a per-frame warning would bury the
|
||||
# one line that matters under thousands of copies of itself.
|
||||
stalled = self.source.stalled()
|
||||
if stalled != self._was_stalled:
|
||||
self._was_stalled = stalled
|
||||
if stalled:
|
||||
log.warning("[%s] connected but no frame for over %.0fs - the "
|
||||
"camera is answering and sending nothing",
|
||||
self.cam_cfg.id, capture.STALL_AFTER_S)
|
||||
else:
|
||||
log.info("[%s] frames resumed", self.cam_cfg.id)
|
||||
|
||||
def _finish_track(self, track: Track, ts: float) -> None:
|
||||
"""Record what became of a track, once, as it ends.
|
||||
|
||||
@@ -681,7 +697,10 @@ class Engine:
|
||||
"age_model": ("genderage" if self.attributes is not None
|
||||
and self.attributes.has_genderage else "caffe/none"),
|
||||
},
|
||||
"gallery": self.store.stats(),
|
||||
# Counts, plus whether the running encoder can actually SEARCH
|
||||
# them. A gallery of 21 identities that the loaded model cannot
|
||||
# read is the silent version of an empty one.
|
||||
"gallery": {**self.store.stats(), **self.gallery.health},
|
||||
"cameras": [w.stats() for w in self.snapshot_workers()],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user