mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-31 10:01:53 +08:00
fix: 修复适配全志平台 OTG 低端点情况
This commit is contained in:
566
test/bench_kvm.py
Normal file
566
test/bench_kvm.py
Normal file
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-KVM benchmark script (Windows-friendly).
|
||||
|
||||
Measures FPS + CPU usage across:
|
||||
- input pixel formats (capture card formats)
|
||||
- output codecs (mjpeg/h264/h265/vp8/vp9)
|
||||
- resolution/FPS matrix
|
||||
- encoder backends (software/hardware)
|
||||
|
||||
Requirements:
|
||||
pip install requests websockets playwright
|
||||
playwright install
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
SESSION_COOKIE = "one_kvm_session"
|
||||
DEFAULT_MATRIX = [
|
||||
(1920, 1080, 30),
|
||||
(1920, 1080, 60),
|
||||
(1280, 720, 30),
|
||||
(1280, 720, 60),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
input_format: str
|
||||
output_codec: str
|
||||
encoder: Optional[str]
|
||||
width: int
|
||||
height: int
|
||||
fps: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
input_format: str
|
||||
output_codec: str
|
||||
encoder: str
|
||||
width: int
|
||||
height: int
|
||||
fps: int
|
||||
avg_fps: float
|
||||
avg_cpu: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
class KvmClient:
|
||||
def __init__(self, base_url: str, username: str, password: str) -> None:
|
||||
self.base = base_url.rstrip("/")
|
||||
self.s = requests.Session()
|
||||
self.login(username, password)
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
r = self.s.post(f"{self.base}/api/auth/login", json={"username": username, "password": password})
|
||||
r.raise_for_status()
|
||||
|
||||
def get_cookie(self) -> str:
|
||||
return self.s.cookies.get(SESSION_COOKIE, "")
|
||||
|
||||
def get_video_config(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/config/video")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_stream_config(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/config/stream")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_devices(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/devices")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def get_codecs(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/stream/codecs")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def patch_video(self, device: Optional[str], fmt: str, w: int, h: int, fps: int) -> None:
|
||||
payload: Dict[str, object] = {"format": fmt, "width": w, "height": h, "fps": fps}
|
||||
if device:
|
||||
payload["device"] = device
|
||||
r = self.s.patch(f"{self.base}/api/config/video", json=payload)
|
||||
r.raise_for_status()
|
||||
|
||||
def patch_stream(self, encoder: Optional[str]) -> None:
|
||||
if encoder is None:
|
||||
return
|
||||
r = self.s.patch(f"{self.base}/api/config/stream", json={"encoder": encoder})
|
||||
r.raise_for_status()
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/mode", json={"mode": mode})
|
||||
r.raise_for_status()
|
||||
|
||||
def get_mode(self) -> Dict:
|
||||
r = self.s.get(f"{self.base}/api/stream/mode")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def wait_mode_ready(self, mode: str, timeout_sec: int = 20) -> None:
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
data = self.get_mode()
|
||||
if not data.get("switching") and data.get("mode") == mode:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError(f"mode switch timeout: {mode}")
|
||||
|
||||
def start_stream(self) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/start")
|
||||
r.raise_for_status()
|
||||
|
||||
def stop_stream(self) -> None:
|
||||
r = self.s.post(f"{self.base}/api/stream/stop")
|
||||
r.raise_for_status()
|
||||
|
||||
def cpu_sample(self) -> float:
|
||||
r = self.s.get(f"{self.base}/api/info")
|
||||
r.raise_for_status()
|
||||
return float(r.json()["device_info"]["cpu_usage"])
|
||||
|
||||
def close_webrtc_session(self, session_id: str) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
self.s.post(f"{self.base}/api/webrtc/close", json={"session_id": session_id})
|
||||
|
||||
|
||||
class MjpegStream:
|
||||
def __init__(self, url: str, cookie: str) -> None:
|
||||
self._stop = threading.Event()
|
||||
self._resp = requests.get(url, stream=True, headers={"Cookie": f"{SESSION_COOKIE}={cookie}"})
|
||||
self._thread = threading.Thread(target=self._reader, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _reader(self) -> None:
|
||||
try:
|
||||
for chunk in self._resp.iter_content(chunk_size=4096):
|
||||
if self._stop.is_set():
|
||||
break
|
||||
if not chunk:
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
try:
|
||||
self._resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def parse_matrix(values: Optional[List[str]]) -> List[Tuple[int, int, int]]:
|
||||
if not values:
|
||||
return DEFAULT_MATRIX
|
||||
result: List[Tuple[int, int, int]] = []
|
||||
for item in values:
|
||||
# WIDTHxHEIGHT@FPS
|
||||
part = item.strip().lower()
|
||||
if "@" not in part or "x" not in part:
|
||||
raise ValueError(f"invalid matrix item: {item}")
|
||||
res_part, fps_part = part.split("@", 1)
|
||||
w_str, h_str = res_part.split("x", 1)
|
||||
result.append((int(w_str), int(h_str), int(fps_part)))
|
||||
return result
|
||||
|
||||
|
||||
def avg(values: Iterable[float]) -> float:
|
||||
vals = list(values)
|
||||
return sum(vals) / len(vals) if vals else 0.0
|
||||
|
||||
|
||||
def normalize_format(fmt: str) -> str:
|
||||
return fmt.strip().upper()
|
||||
|
||||
|
||||
def select_device(devices: Dict, preferred: Optional[str]) -> Optional[Dict]:
|
||||
video_devices = devices.get("video", [])
|
||||
if preferred:
|
||||
for d in video_devices:
|
||||
if d.get("path") == preferred:
|
||||
return d
|
||||
return video_devices[0] if video_devices else None
|
||||
|
||||
|
||||
def build_supported_map(device: Dict) -> Dict[str, Dict[Tuple[int, int], List[int]]]:
|
||||
supported: Dict[str, Dict[Tuple[int, int], List[int]]] = {}
|
||||
for fmt in device.get("formats", []):
|
||||
fmt_name = normalize_format(fmt.get("format", ""))
|
||||
res_map: Dict[Tuple[int, int], List[int]] = {}
|
||||
for res in fmt.get("resolutions", []):
|
||||
key = (int(res.get("width", 0)), int(res.get("height", 0)))
|
||||
fps_list = [int(f) for f in res.get("fps", [])]
|
||||
res_map[key] = fps_list
|
||||
supported[fmt_name] = res_map
|
||||
return supported
|
||||
|
||||
|
||||
def is_combo_supported(
|
||||
supported: Dict[str, Dict[Tuple[int, int], List[int]]],
|
||||
fmt: str,
|
||||
width: int,
|
||||
height: int,
|
||||
fps: int,
|
||||
) -> bool:
|
||||
res_map = supported.get(fmt)
|
||||
if not res_map:
|
||||
return False
|
||||
fps_list = res_map.get((width, height), [])
|
||||
return fps in fps_list
|
||||
|
||||
|
||||
async def mjpeg_sample(
|
||||
base_url: str,
|
||||
cookie: str,
|
||||
client_id: str,
|
||||
duration_sec: float,
|
||||
cpu_sample_fn,
|
||||
) -> Tuple[float, float]:
|
||||
mjpeg_url = f"{base_url}/api/stream/mjpeg?client_id={client_id}"
|
||||
stream = MjpegStream(mjpeg_url, cookie)
|
||||
ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/ws"
|
||||
|
||||
fps_samples: List[float] = []
|
||||
cpu_samples: List[float] = []
|
||||
|
||||
# discard first cpu sample (needs delta)
|
||||
cpu_sample_fn()
|
||||
|
||||
try:
|
||||
async with websockets.connect(ws_url, extra_headers={"Cookie": f"{SESSION_COOKIE}={cookie}"}) as ws:
|
||||
start = time.time()
|
||||
while time.time() - start < duration_sec:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
msg = None
|
||||
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
if data.get("type") == "stream.stats_update":
|
||||
clients = data.get("clients_stat", {})
|
||||
if client_id in clients:
|
||||
fps = float(clients[client_id].get("fps", 0))
|
||||
fps_samples.append(fps)
|
||||
|
||||
cpu_samples.append(float(cpu_sample_fn()))
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
return avg(fps_samples), avg(cpu_samples)
|
||||
|
||||
|
||||
async def webrtc_sample(
|
||||
base_url: str,
|
||||
cookie: str,
|
||||
duration_sec: float,
|
||||
cpu_sample_fn,
|
||||
headless: bool,
|
||||
) -> Tuple[float, float, str]:
|
||||
fps_samples: List[float] = []
|
||||
cpu_samples: List[float] = []
|
||||
session_id = ""
|
||||
|
||||
# discard first cpu sample (needs delta)
|
||||
cpu_sample_fn()
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=headless)
|
||||
context = await browser.new_context()
|
||||
await context.add_cookies([{
|
||||
"name": SESSION_COOKIE,
|
||||
"value": cookie,
|
||||
"url": base_url,
|
||||
"path": "/",
|
||||
}])
|
||||
page = await context.new_page()
|
||||
await page.goto(base_url + "/", wait_until="domcontentloaded")
|
||||
|
||||
await page.evaluate(
|
||||
"""
|
||||
async (base) => {
|
||||
const pc = new RTCPeerConnection();
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
pc.onicecandidate = async (e) => {
|
||||
if (e.candidate && window.__sid) {
|
||||
await fetch(base + "/api/webrtc/ice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: window.__sid, candidate: e.candidate })
|
||||
});
|
||||
}
|
||||
};
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const resp = await fetch(base + "/api/webrtc/offer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sdp: offer.sdp })
|
||||
});
|
||||
const ans = await resp.json();
|
||||
window.__sid = ans.session_id;
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: ans.sdp });
|
||||
(ans.ice_candidates || []).forEach(c => pc.addIceCandidate(c));
|
||||
window.__kvmStats = { pc, lastTs: 0, lastFrames: 0 };
|
||||
}
|
||||
""",
|
||||
base_url,
|
||||
)
|
||||
|
||||
try:
|
||||
await page.wait_for_function(
|
||||
"window.__kvmStats && window.__kvmStats.pc && window.__kvmStats.pc.connectionState === 'connected'",
|
||||
timeout=15000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < duration_sec:
|
||||
fps = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const s = window.__kvmStats;
|
||||
const report = await s.pc.getStats();
|
||||
let fps = 0;
|
||||
for (const r of report.values()) {
|
||||
if (r.type === "inbound-rtp" && r.kind === "video") {
|
||||
if (r.framesPerSecond) {
|
||||
fps = r.framesPerSecond;
|
||||
} else if (r.framesDecoded && s.lastTs) {
|
||||
const dt = (r.timestamp - s.lastTs) / 1000.0;
|
||||
const df = r.framesDecoded - s.lastFrames;
|
||||
fps = dt > 0 ? df / dt : 0;
|
||||
}
|
||||
s.lastTs = r.timestamp;
|
||||
s.lastFrames = r.framesDecoded || s.lastFrames;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return fps;
|
||||
}
|
||||
"""
|
||||
)
|
||||
fps_samples.append(float(fps))
|
||||
cpu_samples.append(float(cpu_sample_fn()))
|
||||
await asyncio.sleep(1)
|
||||
|
||||
session_id = await page.evaluate("window.__sid || ''")
|
||||
await browser.close()
|
||||
|
||||
return avg(fps_samples), avg(cpu_samples), session_id
|
||||
|
||||
|
||||
async def run_case(
|
||||
client: KvmClient,
|
||||
device: Optional[str],
|
||||
case: Case,
|
||||
duration_sec: float,
|
||||
warmup_sec: float,
|
||||
headless: bool,
|
||||
) -> Result:
|
||||
client.patch_video(device, case.input_format, case.width, case.height, case.fps)
|
||||
|
||||
if case.output_codec != "mjpeg":
|
||||
client.patch_stream(case.encoder)
|
||||
|
||||
client.set_mode(case.output_codec)
|
||||
client.wait_mode_ready(case.output_codec)
|
||||
|
||||
client.start_stream()
|
||||
time.sleep(warmup_sec)
|
||||
|
||||
note = ""
|
||||
if case.output_codec == "mjpeg":
|
||||
avg_fps, avg_cpu = await mjpeg_sample(
|
||||
client.base,
|
||||
client.get_cookie(),
|
||||
client_id=f"bench-{int(time.time() * 1000)}",
|
||||
duration_sec=duration_sec,
|
||||
cpu_sample_fn=client.cpu_sample,
|
||||
)
|
||||
else:
|
||||
avg_fps, avg_cpu, session_id = await webrtc_sample(
|
||||
client.base,
|
||||
client.get_cookie(),
|
||||
duration_sec=duration_sec,
|
||||
cpu_sample_fn=client.cpu_sample,
|
||||
headless=headless,
|
||||
)
|
||||
if session_id:
|
||||
client.close_webrtc_session(session_id)
|
||||
else:
|
||||
note = "no-session-id"
|
||||
|
||||
client.stop_stream()
|
||||
|
||||
return Result(
|
||||
input_format=case.input_format,
|
||||
output_codec=case.output_codec,
|
||||
encoder=case.encoder or "n/a",
|
||||
width=case.width,
|
||||
height=case.height,
|
||||
fps=case.fps,
|
||||
avg_fps=avg_fps,
|
||||
avg_cpu=avg_cpu,
|
||||
note=note,
|
||||
)
|
||||
|
||||
|
||||
def write_csv(results: List[Result], path: str) -> None:
|
||||
with open(path, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["input_format", "output_codec", "encoder", "width", "height", "fps", "avg_fps", "avg_cpu", "note"])
|
||||
for r in results:
|
||||
w.writerow([r.input_format, r.output_codec, r.encoder, r.width, r.height, r.fps, f"{r.avg_fps:.2f}", f"{r.avg_cpu:.2f}", r.note])
|
||||
|
||||
|
||||
def write_md(results: List[Result], path: str) -> None:
|
||||
lines = [
|
||||
"| input_format | output_codec | encoder | width | height | fps | avg_fps | avg_cpu | note |",
|
||||
"|---|---|---|---:|---:|---:|---:|---:|---|",
|
||||
]
|
||||
for r in results:
|
||||
lines.append(
|
||||
f"| {r.input_format} | {r.output_codec} | {r.encoder} | {r.width} | {r.height} | {r.fps} | {r.avg_fps:.2f} | {r.avg_cpu:.2f} | {r.note} |"
|
||||
)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="One-KVM benchmark (FPS + CPU)")
|
||||
parser.add_argument("--base-url", required=True, help="e.g. http://192.168.1.50")
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password", required=True)
|
||||
parser.add_argument("--device", help="video device path, e.g. /dev/video0")
|
||||
parser.add_argument("--input-formats", help="comma list, e.g. MJPEG,YUYV,NV12")
|
||||
parser.add_argument("--output-codecs", help="comma list, e.g. mjpeg,h264,h265,vp8,vp9")
|
||||
parser.add_argument("--encoder-backends", help="comma list, e.g. software,auto,vaapi,nvenc,qsv,amf,rkmpp,v4l2m2m")
|
||||
parser.add_argument("--matrix", action="append", help="repeatable WIDTHxHEIGHT@FPS, e.g. 1920x1080@30")
|
||||
parser.add_argument("--duration", type=float, default=30.0, help="sample duration seconds (default 30)")
|
||||
parser.add_argument("--warmup", type=float, default=3.0, help="warmup seconds before sampling")
|
||||
parser.add_argument("--csv", default="bench_results.csv")
|
||||
parser.add_argument("--md", default="bench_results.md")
|
||||
parser.add_argument("--headless", action="store_true", help="run browser headless (default: headful)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
base_url = args.base_url.strip()
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
base_url = "http://" + base_url
|
||||
client = KvmClient(base_url, args.username, args.password)
|
||||
|
||||
devices = client.get_devices()
|
||||
video_cfg = client.get_video_config()
|
||||
device_path = args.device or video_cfg.get("device")
|
||||
device_info = select_device(devices, device_path)
|
||||
if not device_info:
|
||||
print("No video device found.", file=sys.stderr)
|
||||
return 2
|
||||
device_path = device_info.get("path")
|
||||
|
||||
supported_map = build_supported_map(device_info)
|
||||
|
||||
if args.input_formats:
|
||||
input_formats = [normalize_format(f) for f in args.input_formats.split(",") if f.strip()]
|
||||
else:
|
||||
input_formats = list(supported_map.keys())
|
||||
|
||||
matrix = parse_matrix(args.matrix)
|
||||
|
||||
codecs_info = client.get_codecs()
|
||||
available_codecs = {c["id"] for c in codecs_info.get("codecs", []) if c.get("available")}
|
||||
available_codecs.add("mjpeg")
|
||||
|
||||
if args.output_codecs:
|
||||
output_codecs = [c.strip().lower() for c in args.output_codecs.split(",") if c.strip()]
|
||||
else:
|
||||
output_codecs = sorted(list(available_codecs))
|
||||
|
||||
if args.encoder_backends:
|
||||
encoder_backends = [e.strip().lower() for e in args.encoder_backends.split(",") if e.strip()]
|
||||
else:
|
||||
encoder_backends = ["software", "auto"]
|
||||
|
||||
cases: List[Case] = []
|
||||
for fmt in input_formats:
|
||||
for (w, h, fps) in matrix:
|
||||
if not is_combo_supported(supported_map, fmt, w, h, fps):
|
||||
continue
|
||||
for codec in output_codecs:
|
||||
if codec not in available_codecs:
|
||||
continue
|
||||
if codec == "mjpeg":
|
||||
cases.append(Case(fmt, codec, None, w, h, fps))
|
||||
else:
|
||||
for enc in encoder_backends:
|
||||
cases.append(Case(fmt, codec, enc, w, h, fps))
|
||||
|
||||
print(f"Total cases: {len(cases)}")
|
||||
results: List[Result] = []
|
||||
|
||||
for idx, case in enumerate(cases, 1):
|
||||
print(f"[{idx}/{len(cases)}] {case.input_format} {case.output_codec} {case.encoder or 'n/a'} {case.width}x{case.height}@{case.fps}")
|
||||
try:
|
||||
result = asyncio.run(
|
||||
run_case(
|
||||
client,
|
||||
device=device_path,
|
||||
case=case,
|
||||
duration_sec=args.duration,
|
||||
warmup_sec=args.warmup,
|
||||
headless=args.headless,
|
||||
)
|
||||
)
|
||||
results.append(result)
|
||||
print(f" -> avg_fps={result.avg_fps:.2f}, avg_cpu={result.avg_cpu:.2f}")
|
||||
except Exception as exc:
|
||||
results.append(
|
||||
Result(
|
||||
input_format=case.input_format,
|
||||
output_codec=case.output_codec,
|
||||
encoder=case.encoder or "n/a",
|
||||
width=case.width,
|
||||
height=case.height,
|
||||
fps=case.fps,
|
||||
avg_fps=0.0,
|
||||
avg_cpu=0.0,
|
||||
note=f"error: {exc}",
|
||||
)
|
||||
)
|
||||
print(f" -> error: {exc}")
|
||||
|
||||
write_csv(results, args.csv)
|
||||
write_md(results, args.md)
|
||||
print(f"Saved: {args.csv}, {args.md}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user