feat: 链路控制、延迟校准与长测工具,补全文档
- 固件:新增 link_control / stream_control,覆盖运行中 Wi-Fi 断线恢复、取帧节拍、 温控与质量/帧率减载;UDP 发送增加有界重试预算与帧期限,PSRAM JPEG 直发减少拷贝。 - 协议:重组器严格校验分片布局,统计未收齐、超时与完全缺失的帧,处理帧号回绕与设备重启。 - 桌面端:视频改 latest-value 通道并前置帧时间标记,拒绝积压和慢解码的过期画面;新增 延迟标记页与 --latency-report 校准,完善录制、服务端与退出流程。 - 文档与脚本:新增硬件、开发、延迟、功耗、实时性、稳定性文档和改动记录,重写根 README; 新增构建、性能、延迟与串口长测脚本,以及长测客户端脚本。 - 构建:固定 esp-1.97.0.0 工具链,分区表随构建复制,更新 sdkconfig 与忽略规则。
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure real UDP video throughput, complete-frame loss and receive jitter.
|
||||
|
||||
Uses an ephemeral port by default so it can run beside the desktop client.
|
||||
The timings are receive-side metrics, not camera-to-display latency.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
|
||||
from udp_probe import decode
|
||||
|
||||
PORT = 5600
|
||||
FRAGMENT = 1400
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
if not values:
|
||||
return None
|
||||
values = sorted(values)
|
||||
return round(values[int((len(values) - 1) * p)], 3)
|
||||
|
||||
|
||||
def summary(source, duration):
|
||||
counts = source['counts']
|
||||
frames = counts['complete_frames']
|
||||
dropped = counts['superseded_frames'] + counts['missing_frames'] + counts['invalid_jpeg'] + counts['timed_out_frames']
|
||||
span = source['last_complete'] - source['first_complete'] if frames > 1 else 0
|
||||
return dict(
|
||||
address=source['address'], counts=dict(counts),
|
||||
window_fps=round(frames / duration, 3) if duration else 0,
|
||||
active_fps=round((frames - 1) / span, 3) if span else 0,
|
||||
frame_loss_percent=round(100 * dropped / max(1, frames + dropped), 3),
|
||||
resolution=source.get('resolution'),
|
||||
mean_jpeg_bytes=round(counts['jpeg_bytes'] / frames) if frames else 0,
|
||||
inter_frame_p50_ms=percentile(source['intervals'], .5),
|
||||
inter_frame_p95_ms=percentile(source['intervals'], .95),
|
||||
inter_frame_max_ms=round(max(source['intervals']), 3) if source['intervals'] else None,
|
||||
assembly_p95_ms=percentile(source['assembly'], .95),
|
||||
pending_frames=int(source['pending'] is not None),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('host', help='Board IPv4 address; avoids VPN limited-broadcast routing')
|
||||
parser.add_argument('--seconds', type=float, default=30)
|
||||
parser.add_argument('--local-port', type=int, default=0)
|
||||
parser.add_argument('--local-ip', default='')
|
||||
parser.add_argument('--receive-buffer', type=int, default=1024 * 1024, help='0 keeps the OS default')
|
||||
parser.add_argument('--json', type=Path)
|
||||
parser.add_argument('--save-frame', type=Path)
|
||||
args = parser.parse_args()
|
||||
client_id = secrets.randbelow(0xFFFFFFFF) + 1
|
||||
if client_id == 0x0C110001:
|
||||
client_id += 1
|
||||
subscribe = struct.pack('<IBHIIHHIHHB', 0x4D564331, 2, 1, client_id, 0, 0, 0, 0, 0, 0, 2)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
if args.receive_buffer:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, args.receive_buffer)
|
||||
sock.bind((args.local_ip, args.local_port))
|
||||
local_port = sock.getsockname()[1]
|
||||
buffer_bytes = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
|
||||
sock.settimeout(.05)
|
||||
sources = {}
|
||||
invalid = 0
|
||||
saved = False
|
||||
started = time.perf_counter()
|
||||
renew = started
|
||||
report_at = started + 5
|
||||
previous_frames = 0
|
||||
previous_report = started
|
||||
try:
|
||||
while time.perf_counter() - started < args.seconds:
|
||||
now = time.perf_counter()
|
||||
for source in sources.values():
|
||||
if source['pending'] is not None and now-source['pending']['started'] > .5:
|
||||
source['counts']['timed_out_frames'] += 1
|
||||
source['pending'] = None
|
||||
if now >= renew:
|
||||
sock.sendto(subscribe, (args.host, PORT))
|
||||
renew = now + .8
|
||||
if now >= report_at:
|
||||
frames = sum(s['counts']['complete_frames'] for s in sources.values())
|
||||
print(f'{now-started:5.1f}s {(frames-previous_frames)/(now-previous_report):5.1f} fps complete={frames}', flush=True)
|
||||
previous_frames, previous_report, report_at = frames, now, now + 5
|
||||
try:
|
||||
data, address = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
now = time.perf_counter()
|
||||
parsed = decode(data)
|
||||
if parsed is None:
|
||||
invalid += 1
|
||||
continue
|
||||
kind, h, payload = parsed
|
||||
if kind != 1:
|
||||
continue
|
||||
size = h['total_len']
|
||||
index, count = h['frag_index'], h['frag_count']
|
||||
offset = index * FRAGMENT
|
||||
if h['version'] != 1 or size > 4 * 1024 * 1024 or count != max(1, (size + FRAGMENT - 1) // FRAGMENT) or index >= count or offset > size or len(payload) != min(FRAGMENT, size-offset):
|
||||
invalid += 1
|
||||
continue
|
||||
sender, frame = h['sender_id'], h['frame_id']
|
||||
if sender not in sources:
|
||||
sources[sender] = dict(address=address[0], counts=Counter(), latest=None, last_seen=now, pending=None, intervals=[], assembly=[], first_complete=None, last_complete=None)
|
||||
s = sources[sender]
|
||||
counts = s['counts']
|
||||
counts['video_datagrams'] += 1
|
||||
latest = s['latest']
|
||||
if latest is not None:
|
||||
distance = (frame - latest) & 0xFFFFFFFF
|
||||
if distance >= 0x80000000 and now-s['last_seen'] >= 1:
|
||||
counts['restarts'] += 1
|
||||
latest = s['latest'] = None
|
||||
s['pending'] = None
|
||||
elif distance >= 0x80000000 or (distance == 0 and s['pending'] is None):
|
||||
counts['late_or_repeated_datagrams'] += 1
|
||||
continue
|
||||
elif distance > 0:
|
||||
counts['missing_frames'] += distance - 1
|
||||
counts['superseded_frames'] += int(s['pending'] is not None)
|
||||
s['pending'] = None
|
||||
s['latest'], s['last_seen'] = frame, now
|
||||
if s['pending'] is None:
|
||||
s['pending'] = dict(buffer=bytearray(size), seen=set(), count=count, size=size, width=h['width'], height=h['height'], started=now)
|
||||
pending = s['pending']
|
||||
if (size, count, h['width'], h['height']) != (pending['size'], pending['count'], pending['width'], pending['height']):
|
||||
counts['inconsistent_metadata'] += 1
|
||||
continue
|
||||
if index in pending['seen']:
|
||||
counts['duplicate_fragments'] += 1
|
||||
continue
|
||||
pending['seen'].add(index)
|
||||
pending['buffer'][offset:offset+len(payload)] = payload
|
||||
if len(pending['seen']) != count:
|
||||
continue
|
||||
jpeg = bytes(pending['buffer'])
|
||||
if jpeg[:2] == b'\xff\xd8' and jpeg[-2:] == b'\xff\xd9':
|
||||
completed = time.perf_counter()
|
||||
counts['complete_frames'] += 1
|
||||
counts['jpeg_bytes'] += size
|
||||
if s['last_complete'] is not None:
|
||||
s['intervals'].append((completed-s['last_complete'])*1000)
|
||||
s['last_complete'] = completed
|
||||
if s['first_complete'] is None:
|
||||
s['first_complete'] = completed
|
||||
s['assembly'].append((completed-pending['started'])*1000)
|
||||
s['resolution'] = [h['width'], h['height']]
|
||||
if args.save_frame and not saved:
|
||||
args.save_frame.write_bytes(jpeg)
|
||||
saved = True
|
||||
else:
|
||||
counts['invalid_jpeg'] += 1
|
||||
s['pending'] = None
|
||||
finally:
|
||||
sock.close()
|
||||
elapsed = time.perf_counter()-started
|
||||
result = dict(seconds=round(elapsed, 3), host=args.host, local_port=local_port, receive_buffer_bytes=buffer_bytes, invalid_datagrams=invalid, sources={f'0x{sender:08X}': summary(s, elapsed) for sender, s in sources.items()}, timing_scope='receiver inter-frame spacing and first-to-last fragment assembly; not camera-to-display latency')
|
||||
if args.json:
|
||||
args.json.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if any(s['counts']['complete_frames'] for s in sources.values()) else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user