Live view runs at the camera's real rate, and reports why it is MJPEG
4 fps was not "live", and it was a number I picked rather than measured. The engine actually produces ~12 distinct frames a second, so most of it was being left on the floor. Now: poll a little ahead of the engine and drop frames identical to the last one by hash. Measured end to end - 131 frames in 10 s, 13.1 fps, 20.3 KB each, 259 KB/s, zero duplicates. Every byte on the wire is a picture the viewer has not seen, and the rate follows the camera instead of a constant. Also records why this is MJPEG rather than passing the camera's own compressed video through, which would be smoother, cheaper and use no CPU. Probed the office camera: main 2304x1296@15, sub 800x448@15 - and BOTH are H.265, despite stream paths ending in ".264". Browsers play H.264 everywhere and H.265 only on some platforms, so passthrough cannot rely on it, and transcoding HEVC on the shop PC would put a video encoder on the machine already doing the recognition. So probe_source now reports `codec`. It decides what is possible, an installer can usually change it, and otherwise the only way to learn it is to read RTSP by hand - which is how this was found. The RTSP libraries used to establish that are NOT kept: they were only ever imported by a spike test, and two large dependencies in a shipped binary to answer a question OpenCV already knows is a bad trade. Their `go get` had also silently bumped the agent to go 1.25 and broken the desktop build, which is its own argument. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
49
CLAUDE.md
49
CLAUDE.md
@@ -2192,13 +2192,45 @@ is the shape of everything else in this system — so `LiveHub` + `cameras.Live`
|
||||
relay frames the other way: head office holds a poll open, the agent asks "is
|
||||
anyone watching?", and pushes JPEGs up for exactly as long as somebody is.
|
||||
|
||||
**It is ~4 frames a second of 640 px JPEG, and the UI says so.** Measured on the
|
||||
office camera: the full frame is 98 KB, re-encoded at 640/q60 it is 20.8 KB, so
|
||||
one watcher costs ~83 KB/s. True 25 fps video would need WebRTC and a TURN
|
||||
server; for *"what does that camera see right now"* — which is the question
|
||||
somebody at head office is actually asking — a few frames a second is what the
|
||||
question requires, and a label saying "about 4 frames a second" is better than
|
||||
letting somebody conclude the camera stutters.
|
||||
**It is ~13 frames a second of 640 px JPEG.** Measured end to end on the office
|
||||
camera: 131 frames in 10 s, 20.3 KB each, **259 KB/s**, and zero duplicates.
|
||||
|
||||
The rate is not a guess. The engine re-serves its latest frame until the
|
||||
pipeline produces a new one, so polling faster than it encodes returns the same
|
||||
picture: 93 polls in 6 s yielded 72 distinct frames. So the relay polls a little
|
||||
ahead of the engine and **drops frames identical to the last one by hash** —
|
||||
which lets the rate follow the camera rather than a constant, and means every
|
||||
byte on the wire is a picture the viewer has not seen.
|
||||
|
||||
### Why this is MJPEG and not the camera's own H.264
|
||||
|
||||
The obviously better design is passthrough: every CCTV camera already produces
|
||||
compressed video, and its **sub-stream** is exactly the right size for a live
|
||||
view. Probed on the office camera: main `/ch0_0.264` is 2304×1296 @ 15 fps, sub
|
||||
`/ch0_1.264` is **800×448 @ 15 fps**. Relaying that untouched would be smoother
|
||||
than this, cost less bandwidth, and use no CPU at all — no decode, no encode.
|
||||
|
||||
**It cannot be done on this camera, and the reason is worth recording: both
|
||||
streams are H.265.** The file names end in `.264`; the codec is HEVC. A browser
|
||||
plays H.264 everywhere and H.265 only on some platforms, so a passthrough relay
|
||||
cannot rely on it — and transcoding HEVC→H.264 on the shop PC would put a video
|
||||
encoder on the machine that is already doing the recognition.
|
||||
|
||||
So the choice is not MJPEG-versus-video in the abstract. It is: **re-encode
|
||||
frames and work on every camera, or pass through and work only on H.264
|
||||
cameras.** This does the first. Passthrough (RTSP → fMP4 → Media Source
|
||||
Extensions, no re-encode) is a well-understood build on top of the same relay
|
||||
and is the right upgrade for an estate of H.264 cameras — including this one, if
|
||||
its sub-stream is switched to H.264 in the camera's own settings.
|
||||
|
||||
`probe_source` therefore reports `codec`, because it decides what is possible
|
||||
and an installer can usually change it. Otherwise the only way to learn it is to
|
||||
read RTSP by hand, which is how this was found.
|
||||
|
||||
True sub-second video with no re-encode at any codec is WebRTC. Worth noting
|
||||
that the earlier claim here — that it needs a TURN server — is wrong: the server
|
||||
has a public address, so a shop PC behind NAT connects to it directly and TURN
|
||||
is only needed when *neither* side is reachable.
|
||||
|
||||
The browser cannot be pointed straight at the shop PC even on one LAN: the
|
||||
engine's API is Basic-authenticated with a credential it generates locally and
|
||||
@@ -2245,6 +2277,9 @@ Other decisions worth keeping:
|
||||
has OpenCV open and the frame decoded; a scaler in the agent would be the same
|
||||
work twice. On demand only — a camera nobody watches must not pay for a second
|
||||
encode it will never use.
|
||||
- **Duplicate frames are dropped by hash before they are sent.** Without it a
|
||||
fifth of the bandwidth was the same picture twice, and the poll rate could not
|
||||
safely run ahead of the engine.
|
||||
- **The Live button is offered even when the card says the camera is down.**
|
||||
`connected` is head office's last report and can be two minutes stale, so
|
||||
gating on it hid the button during every reconnect — and "is that camera
|
||||
|
||||
@@ -3,6 +3,7 @@ package cameras
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -38,11 +39,15 @@ type Live struct {
|
||||
pollDelay time.Duration
|
||||
}
|
||||
|
||||
// Defaults chosen to be honest about a shop's uplink rather than impressive:
|
||||
// 640 px at quality 60 is ~25-35 KB, so 4 fps is ~120 KB/s per watcher, and a
|
||||
// camera nobody is watching costs nothing at all.
|
||||
// Defaults, measured against the office camera rather than guessed.
|
||||
//
|
||||
// The engine produces ~12 distinct frames a second, so asking for more than
|
||||
// that only re-sends pictures the viewer already has - which is why the poll
|
||||
// runs slightly ahead of it and identical frames are dropped rather than sent.
|
||||
// 640 px at quality 60 is ~20 KB, so a watcher costs ~200 KB/s at the full
|
||||
// rate, and a camera nobody is watching costs nothing at all.
|
||||
const (
|
||||
DefaultLiveFPS = 4.0
|
||||
DefaultLiveFPS = 15.0
|
||||
DefaultLiveWidth = 640
|
||||
DefaultLiveQuality = 60
|
||||
)
|
||||
@@ -105,6 +110,13 @@ func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
// The engine re-serves its latest frame until the pipeline produces a new
|
||||
// one, so polling faster than it encodes returns the SAME picture again.
|
||||
// Measured: 93 polls in 6 s yielded 72 distinct frames. Sending the
|
||||
// duplicates would cost a fifth of the bandwidth for nothing, so the poll
|
||||
// runs a little ahead of the engine and the repeats are dropped - which is
|
||||
// what lets the rate follow the camera instead of a guess.
|
||||
var lastSum [32]byte
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -128,6 +140,11 @@ func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
// stream, and the next tick may well have one.
|
||||
continue
|
||||
}
|
||||
if sum := sha256.Sum256(jpeg); sum == lastSum {
|
||||
continue
|
||||
} else {
|
||||
lastSum = sum
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(jpeg)))
|
||||
if _, err := pw.Write(hdr[:]); err != nil {
|
||||
@@ -142,9 +159,10 @@ func (l *Live) serve(ctx context.Context, cameraID string) {
|
||||
}
|
||||
|
||||
func (l *Live) fps() float64 {
|
||||
if l.FPS <= 0 || l.FPS > 15 {
|
||||
// Above this the relay stops being cheap and stops being honest about
|
||||
// what an outbound HTTP push can carry.
|
||||
if l.FPS <= 0 || l.FPS > 25 {
|
||||
// A ceiling rather than a target: duplicate frames are dropped, so
|
||||
// polling above what the engine encodes costs requests and no
|
||||
// bandwidth - but it is still work, on the PC doing the recognition.
|
||||
return DefaultLiveFPS
|
||||
}
|
||||
return l.FPS
|
||||
|
||||
@@ -47,6 +47,23 @@ def _tcp_reachable(source: "str | int", timeout: float
|
||||
return False, f"cannot reach {parsed.hostname}:{port} - {exc.strerror or exc}"
|
||||
|
||||
|
||||
def _fourcc(cap) -> str:
|
||||
"""The stream's codec as a four-character code, or "" if unknown.
|
||||
|
||||
FFmpeg reports H.265 as "hevc" and H.264 as "h264"/"avc1" depending on the
|
||||
container. Returned as-is rather than mapped to a friendly name: the raw
|
||||
value is what somebody searching their camera's manual will match.
|
||||
"""
|
||||
try:
|
||||
raw = int(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
except Exception:
|
||||
return ""
|
||||
if raw <= 0:
|
||||
return ""
|
||||
code = "".join(chr((raw >> (8 * i)) & 0xFF) for i in range(4))
|
||||
return code.strip().strip("\x00")
|
||||
|
||||
|
||||
def probe_source(source: "str | int", max_width: int = 1280,
|
||||
timeout: float = 12.0, connect_timeout: float = 3.0) -> dict:
|
||||
"""Open a candidate camera, grab one frame, and let go.
|
||||
@@ -97,6 +114,17 @@ def probe_source(source: "str | int", max_width: int = 1280,
|
||||
return {
|
||||
"ok": True, "width": int(width), "height": int(height),
|
||||
"downscaled_to": int(preview.shape[1]) if preview is not frame else None,
|
||||
"fps": round(cap.get(cv2.CAP_PROP_FPS) or 0, 1),
|
||||
# The codec decides whether head office can ever show TRUE live
|
||||
# video from this camera. A browser plays H.264 everywhere; H.265
|
||||
# only on some platforms, so a passthrough relay cannot rely on it
|
||||
# and the picture has to be re-encoded frame by frame instead.
|
||||
# Reported here because it is a property of the camera's settings
|
||||
# that an installer can usually change, and because otherwise the
|
||||
# only way to learn it is to read RTSP by hand — which is how this
|
||||
# was found: a camera whose paths end in ".264" was emitting H.265
|
||||
# on both streams.
|
||||
"codec": _fourcc(cap),
|
||||
"snapshot": (base64.b64encode(buf.tobytes()).decode("ascii")
|
||||
if ok else None),
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-D6UMLnOQ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-tRretU9M.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CtuyPF09.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -77,3 +77,32 @@ def test_tcp_precheck_passes_through_non_url_sources():
|
||||
|
||||
assert _tcp_reachable(0, 1.0) == (True, "")
|
||||
assert _tcp_reachable("not-a-url", 1.0)[0] is True
|
||||
|
||||
|
||||
def test_the_probe_reports_the_stream_codec():
|
||||
"""Which codec a camera emits decides whether head office can ever show
|
||||
TRUE live video from it: a browser plays H.264 everywhere and H.265 only on
|
||||
some platforms, so a passthrough relay cannot rely on H.265.
|
||||
|
||||
It is reported because otherwise the only way to learn it is to read RTSP by
|
||||
hand — which is how it was found on the office camera, whose stream paths
|
||||
end in ".264" while both channels emit H.265.
|
||||
"""
|
||||
from behavision.capture import _fourcc
|
||||
|
||||
class Cap:
|
||||
def __init__(self, v):
|
||||
self.v = v
|
||||
|
||||
def get(self, _):
|
||||
return self.v
|
||||
|
||||
def code(text):
|
||||
return sum(ord(c) << (8 * i) for i, c in enumerate(text))
|
||||
|
||||
assert _fourcc(Cap(code("hevc"))) == "hevc"
|
||||
assert _fourcc(Cap(code("h264"))) == "h264"
|
||||
# Nothing to report is "" rather than a misleading value: a camera that
|
||||
# does not say must not read as one that said something.
|
||||
assert _fourcc(Cap(0)) == ""
|
||||
assert _fourcc(Cap(-1)) == ""
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function CameraLive({ camera, onClose }) {
|
||||
? stalledNote
|
||||
: {
|
||||
waiting: 'Asking the shop PC…',
|
||||
live: 'Live · about 4 frames a second',
|
||||
live: 'Live',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}[state]
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function CameraLive({ camera, onClose }) {
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
{state === 'live'
|
||||
? 'Live · about 4 frames a second. The shop only uploads while this is open.'
|
||||
? 'Live. The shop only uploads while this view is open.'
|
||||
: note}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user