Streams are sent over 4G links with variable quality. After reviewing pyro_camera_api/services/stream.py, core/config.py and services/anonymizer_rtsp.py, here is a list of potential improvements to study, split between encoding/transport tuning and code robustness.
Quality / 4G transport tuning
1. SRT_LATENCY = 50 is too low for 4G (biggest lever)
SRT latency is the retransmission window: a lost packet can only be recovered within that budget. On 4G (RTT 50–150 ms with jitter), 50 ms means most lost packets are unrecoverable → corruption and freezes when the connection dips. Rule of thumb is 3–4× RTT; for mobile links 400–800 ms is standard. Only cost is a fixed extra delay on the viewer side. (core/config.py)
2. Restream command has no -maxrate / -bufsize
build_ffmpeg_restream_cmd only sets -b:v 700k (average target, unconstrained VBV). Keyframes and scene changes can burst to several Mbps, which is exactly what saturates a weak uplink and triggers loss. The anonymizer encoder already does it right (maxrate 900k, bufsize 800k); the restream path should match.
3. Consider capped CRF instead of fixed bitrate
Scenes are mostly static landscape. With ABR at 700k we spend 700k even when nothing moves. Capped CRF (-crf 23 -maxrate 900k -bufsize 800k) gives equal-or-better quality, lower bitrate on static scenes (good for 4G data budgets), and the cap still protects the link during motion. build_encoder_cmd already supports it (use_crf=True), it is just not enabled.
4. Re-evaluate tune zerolatency + rc-lookahead=0
This disables mbtree and lookahead, costing roughly 10–20% quality per bit. Justified if an operator steers PTZ live with sub-second glass-to-glass; but once SRT latency is raised to ~500 ms (point 1), the ~100–200 ms saved by zerolatency is no longer the dominant term. If the stream is mostly for verification/monitoring, dropping it buys quality for free.
5. Use available CPU headroom for a slower preset
SystemController skips inference while a stream is active, so the CPU is mostly idle during streaming. At 640×360@10fps, preset fast (or even medium) instead of veryfast should be affordable and gives better quality at the same bitrate. Needs a CPU test on the actual device.
6. Source quality is the ceiling
Both modes ingest the Reolink substream (h264Preview_01_sub), already heavily compressed by the camera. Re-encoding a mushy ~500k source at 700k cannot restore detail and adds a transcode generation loss. Options: raise the substream bitrate/resolution in the camera settings (cheap, big win), or for the non-anonymized path use -c:v copy restream (zero transcode loss, near-zero CPU) and control bandwidth via the camera encoder settings.
Minor
gop_size 14 at 10 fps = keyframe every 1.4 s; fine for fast viewer join, but each I-frame is a bitrate spike. With VBV caps in place, a ~2–3 s GOP would give a small quality gain.
pkt_size=1316 is correct for SRT over typical MTU.
Code robustness (services/stream.py)
7. Half-dead pipelines are never stopped
is_pipeline_running requires both decoder and encoder threads alive. If one crashes, stop_any_running_stream skips the pipeline: the surviving thread keeps pushing to SRT forever, the entry stays in workers, /stream/status reports no stream, and the engine resumes inference while the encoder still consumes bandwidth. Stop logic should stop a pipeline if at least one thread is alive.
8. No locking around start/stop
start_stream, stop_stream and the idle-stopper thread all mutate app.state.stream_workers / stream_processes without a lock. Two near-simultaneous starts (or a start racing the idle stopper) can both pass the "already running" check and spawn two pipelines to the same SRT output. A single threading.Lock around check-then-start and stop would fix it.
9. Killed ffmpeg processes are never reaped
In stop_any_running_stream, after proc.kill() there is no proc.wait(), and the entry is popped right after, so the process stays a zombie until the API restarts. Also worth closing proc.stderr so the log thread exits promptly.
10. is_thread_alive pokes at the private _thread attribute
Fragile coupling to the worker classes. Add an is_alive() method on RTSPDecoderWorker / EncoderWorker / AnonymizerWorker instead.
11. Avoidable _APP global
set_app_for_stream exists only so stop_stream_if_idle can find the app. main.py starts that thread itself and could pass the app as an argument, removing the global and the app=None fallback.
Suggested first pass
Low-risk, directly addressing bad-connection days: bump SRT_LATENCY to ~500, add maxrate/bufsize to the restream command, enable capped CRF on the anonymizer encoder, and fix the half-dead pipeline stop logic (point 7).
Streams are sent over 4G links with variable quality. After reviewing
pyro_camera_api/services/stream.py,core/config.pyandservices/anonymizer_rtsp.py, here is a list of potential improvements to study, split between encoding/transport tuning and code robustness.Quality / 4G transport tuning
1.
SRT_LATENCY = 50is too low for 4G (biggest lever)SRT latency is the retransmission window: a lost packet can only be recovered within that budget. On 4G (RTT 50–150 ms with jitter), 50 ms means most lost packets are unrecoverable → corruption and freezes when the connection dips. Rule of thumb is 3–4× RTT; for mobile links 400–800 ms is standard. Only cost is a fixed extra delay on the viewer side. (
core/config.py)2. Restream command has no
-maxrate/-bufsizebuild_ffmpeg_restream_cmdonly sets-b:v 700k(average target, unconstrained VBV). Keyframes and scene changes can burst to several Mbps, which is exactly what saturates a weak uplink and triggers loss. The anonymizer encoder already does it right (maxrate 900k, bufsize 800k); the restream path should match.3. Consider capped CRF instead of fixed bitrate
Scenes are mostly static landscape. With ABR at 700k we spend 700k even when nothing moves. Capped CRF (
-crf 23 -maxrate 900k -bufsize 800k) gives equal-or-better quality, lower bitrate on static scenes (good for 4G data budgets), and the cap still protects the link during motion.build_encoder_cmdalready supports it (use_crf=True), it is just not enabled.4. Re-evaluate
tune zerolatency+rc-lookahead=0This disables mbtree and lookahead, costing roughly 10–20% quality per bit. Justified if an operator steers PTZ live with sub-second glass-to-glass; but once SRT latency is raised to ~500 ms (point 1), the ~100–200 ms saved by zerolatency is no longer the dominant term. If the stream is mostly for verification/monitoring, dropping it buys quality for free.
5. Use available CPU headroom for a slower preset
SystemControllerskips inference while a stream is active, so the CPU is mostly idle during streaming. At 640×360@10fps,preset fast(or evenmedium) instead ofveryfastshould be affordable and gives better quality at the same bitrate. Needs a CPU test on the actual device.6. Source quality is the ceiling
Both modes ingest the Reolink substream (
h264Preview_01_sub), already heavily compressed by the camera. Re-encoding a mushy ~500k source at 700k cannot restore detail and adds a transcode generation loss. Options: raise the substream bitrate/resolution in the camera settings (cheap, big win), or for the non-anonymized path use-c:v copyrestream (zero transcode loss, near-zero CPU) and control bandwidth via the camera encoder settings.Minor
gop_size 14at 10 fps = keyframe every 1.4 s; fine for fast viewer join, but each I-frame is a bitrate spike. With VBV caps in place, a ~2–3 s GOP would give a small quality gain.pkt_size=1316is correct for SRT over typical MTU.Code robustness (
services/stream.py)7. Half-dead pipelines are never stopped
is_pipeline_runningrequires both decoder and encoder threads alive. If one crashes,stop_any_running_streamskips the pipeline: the surviving thread keeps pushing to SRT forever, the entry stays inworkers,/stream/statusreports no stream, and the engine resumes inference while the encoder still consumes bandwidth. Stop logic should stop a pipeline if at least one thread is alive.8. No locking around start/stop
start_stream,stop_streamand the idle-stopper thread all mutateapp.state.stream_workers/stream_processeswithout a lock. Two near-simultaneous starts (or a start racing the idle stopper) can both pass the "already running" check and spawn two pipelines to the same SRT output. A singlethreading.Lockaround check-then-start and stop would fix it.9. Killed ffmpeg processes are never reaped
In
stop_any_running_stream, afterproc.kill()there is noproc.wait(), and the entry is popped right after, so the process stays a zombie until the API restarts. Also worth closingproc.stderrso the log thread exits promptly.10.
is_thread_alivepokes at the private_threadattributeFragile coupling to the worker classes. Add an
is_alive()method onRTSPDecoderWorker/EncoderWorker/AnonymizerWorkerinstead.11. Avoidable
_APPglobalset_app_for_streamexists only sostop_stream_if_idlecan find the app.main.pystarts that thread itself and could pass the app as an argument, removing the global and theapp=Nonefallback.Suggested first pass
Low-risk, directly addressing bad-connection days: bump
SRT_LATENCYto ~500, addmaxrate/bufsizeto the restream command, enable capped CRF on the anonymizer encoder, and fix the half-dead pipeline stop logic (point 7).