Files
orisys-app/apps/studio/tools/camera_probe.py
T
ykcheungab a44eb2d65d 达不到帧率的判据改用实测速率(DSHOW 回读值会说谎),探针加帧率扫描 - What: short_capture_fallacy() 改为以**实测**速率为准(新增 measured_fps 关键字参数),消息改成"it delivers about 15 fps while the config asks for 30 (1920x1080 reported 30, MJPG/DSHOW)"——把驱动自报值降级为"矛盾的一部分"而不是判据;app_window 的 _check_capture_negotiation() 相应改为用 packet 的 capture_fps(实测)判据、实测为 0 时不下结论(等窗口填满)。tools/camera_probe.py 重写:probe() 返回结构体、format_row() 把 reported 与 measured 分列并在达不到 90% 时标 "<-- misses the request";新增 --samples 与**帧率扫描**(同一格式依次申请 60/30/20/15/10 fps,逐行给实测值与"跟随申请 / 只有申请的 x 倍"的判断,最后给"本次看到的ceiling")。tests/test_worker_frame_rate.py 的归因测试同步更新,并显式覆盖"驱动自报 30 而实测 15 必须判为不达标"与"没有实测值时不说话"。 - Why: 用户跑探针的结果推翻了我上一轮的判据:MJPG 1920x1080@30 -> MJPG 1920x1080 @ 30.0 measured 15.0。DSHOW 后端下 CAP_PROP_FPS 只是回读你设的值,所以 SDK 的 status.fps 同样不可信 —— 我原来比较"申请 vs 协商"等于拿申请跟自己比,那个提示永远不会出现(用户实际看到的只有轨道上的实测 16.7)。这也解释了为什么这件事必须靠实测:真实速率只能测出来。另外探针显示 1280x720 MJPG 实测也是 15、YUYV 只有 1 fps,说明换分辨率不解决问题,需要区分"摄像头本身的上限"与"我们申请多少就被砍半"。 - Verification: shared 全量 161 passed(含新增/更新的 3 项断言);studio 91 passed;ui_check 554 项检查 0 失败;探针在本机(无摄像头)仍返回 1 并给出解释,format_row/_decode 单测通过("reported 30.0 measured 15.0 ... <-- misses the request")。 - Not in this change: 未改 SDK 采集申请顺序/后端;未做自动降级;frames.csv 仍未记录 process_ms/capture_fps。
2026-09-14 13:38:57 +08:00

217 lines
8.0 KiB
Python

"""Ask each camera what it can actually deliver, without the app in the way.
The studio's sensor configs pin the capture at 1920x1080 @ 30 fps (the tile grid
is 5x3 tiles of 384x360, so the size is structural), and nothing downstream can
raise a frame rate the driver never offered. When a session runs at ~15-17 fps,
there are three questions to separate, and this script answers all three:
1. what does the driver *report* for a given request?
2. what does it actually **deliver**? (Measured from 60 reads. On DSHOW the
reported figure is a liar: ``CAP_PROP_FPS`` echoes back whatever was set, so a
camera that advertises 30 and delivers 15 reports "30.0".)
3. does the delivered rate follow the request? The sweep at the end asks for
60/30/20/15/10 fps at one format. A rate that *halves* with the request means
the request path is wrong; a rate that sticks at the same number whatever is
asked means that number is the hardware's ceiling.
Run it with the venv interpreter, with the app closed (two processes cannot own
the same camera)::
python apps/studio/tools/camera_probe.py # every camera
python apps/studio/tools/camera_probe.py --index 0 # just one
python apps/studio/tools/camera_probe.py --samples 120 # longer measurement
"""
from __future__ import annotations
import argparse
import sys
import time
import cv2
#: What the sensor bundles ask for, and the alternatives worth trying when the
#: delivered rate falls short of the requested one.
REQUESTS: tuple[tuple[str, int, int, float], ...] = (
("MJPG", 1920, 1080, 30.0),
("MJPG", 1920, 1080, 0.0), # "whatever the driver prefers at this size"
("MJPG", 1280, 720, 30.0),
("YUYV", 1920, 1080, 30.0),
("YUYV", 1280, 720, 30.0),
)
BACKENDS: tuple[tuple[str, int], ...] = (
("DSHOW", cv2.CAP_DSHOW),
("MSMF", cv2.CAP_MSMF),
)
#: Frame rates asked for at one fixed format, to see whether the delivered rate
#: follows the request or ignores it.
SWEEP_RATES: tuple[float, ...] = (60.0, 30.0, 20.0, 15.0, 10.0)
SAMPLES = 60
#: A request is "missed" when the measured rate is below this fraction of it.
TOLERANCE = 0.9
def _fourcc_code(text: str) -> int:
return cv2.VideoWriter_fourcc(*text)
def _decode(code: float) -> str:
value = int(code)
if value <= 0:
return "-"
letters = "".join(chr((value >> (8 * index)) & 0xFF) for index in range(4))
return letters if letters.isprintable() else f"0x{value:08x}"
def probe(
index: int,
backend_name: str,
backend: int,
request: tuple[str, int, int, float],
*,
samples: int = SAMPLES,
) -> dict | None:
"""Open, ask for one format, and measure what actually comes out."""
fourcc, width, height, fps = request
cap = cv2.VideoCapture(index, backend)
if not cap.isOpened():
cap.release()
return None
try:
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
if fps > 0:
cap.set(cv2.CAP_PROP_FPS, float(fps))
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
for _ in range(5): # let the format settle before believing anything
cap.grab()
started = time.perf_counter()
taken = 0
for _ in range(samples):
ok, frame = cap.read()
if not ok or frame is None:
break
taken += 1
span = time.perf_counter() - started
got_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
got_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
reported = cap.get(cv2.CAP_PROP_FPS)
got_cc = _decode(cap.get(cv2.CAP_PROP_FOURCC))
finally:
cap.release()
return {
"asked": (fourcc, width, height, fps),
"backend": backend_name,
"fourcc": got_cc,
"width": got_w,
"height": got_h,
"reported_fps": reported,
"measured_fps": (taken / span) if span > 0 else 0.0,
"taken": taken,
"samples": samples,
}
def format_row(result: dict) -> str:
fourcc, width, height, fps = result["asked"]
asked = f"{fourcc} {width}x{height}" + (f"@{fps:g}" if fps else "@auto")
measured = result["measured_fps"]
missed = ""
if fps > 0 and measured < fps * TOLERANCE:
missed = " <-- misses the request"
return (
f" {asked:<20} -> {result['fourcc']} {result['width']}x{result['height']} "
f"reported {result['reported_fps']:5.1f} measured {measured:5.1f} fps "
f"({result['taken']}/{result['samples']}) [{result['backend']}]{missed}"
)
def sweep(index: int, backend_name: str, backend: int, size: tuple[int, int],
*, samples: int = SAMPLES) -> list[dict]:
"""Ask for a range of rates at one format: does the delivery follow?"""
width, height = size
rows = []
for rate in SWEEP_RATES:
result = probe(
index, backend_name, backend, ("MJPG", width, height, rate),
samples=samples,
)
if result is not None:
rows.append(result)
return rows
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--index", type=int, default=None, help="camera index to probe")
parser.add_argument("--max-index", type=int, default=4, help="highest index to try")
parser.add_argument("--samples", type=int, default=SAMPLES, help="frames per row")
args = parser.parse_args(argv)
indices = [args.index] if args.index is not None else list(range(args.max_index + 1))
found = 0
for index in indices:
header = False
first_size: tuple[int, int] | None = None
first_backend: tuple[str, int] | None = None
for backend_name, backend in BACKENDS:
for request in REQUESTS:
result = probe(index, backend_name, backend, request, samples=args.samples)
if result is None:
continue
if not header:
print(f"\ncamera {index}:")
header = True
print(format_row(result))
if first_size is None and request[0] == "MJPG" and request[3] > 0:
first_size = (request[1], request[2])
first_backend = (backend_name, backend)
if header and first_size is not None and first_backend is not None:
print(
f"\n sweep at MJPG {first_size[0]}x{first_size[1]} "
f"[{first_backend[0]}] -- does the delivered rate follow the request?"
)
rows = sweep(index, first_backend[0], first_backend[1], first_size,
samples=args.samples)
for result in rows:
asked = result["asked"][3]
measured = result["measured_fps"]
verdict = ""
if asked > 0 and measured > 0:
ratio = measured / asked
if ratio < 0.35:
verdict = f" -> {ratio:.2f}x the request"
elif ratio > 0.9:
verdict = " -> follows the request"
print(
f" asked {asked:5.1f} fps -> measured {measured:5.1f} fps"
f"{verdict}"
)
rates = [r["measured_fps"] for r in rows if r["measured_fps"] > 0]
if rates:
print(
f" ceiling seen here: {max(rates):.1f} fps "
f"(a ceiling that does not move with the request is the "
f"hardware's, not the request's)"
)
if header:
found += 1
if not found:
print("No camera answered on indices 0..%d." % args.max_index)
print(
"That is expected on a machine with no camera attached. On the machine "
"with the sensor, run it with the app closed: two processes cannot own "
"the same camera."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())