新增摄像头能力探针 tools/camera_probe.py,用来把 16.7 fps 归因到格式协商 - What: 新脚本对每个摄像头索引 × 两种后端(DSHOW/MSMF)× 五组请求(MJPG 1920x1080@30、MJPG 1920x1080@auto、MJPG 1280x720@30、YUYV 1920x1080@30、YUYV 1280x720@30)实际打开并连续抓 60 帧,逐行打印"申请 → 驱动协商到的 fourcc/尺寸/帧率 + 实测帧率",这样\"协商到 30\"与\"真的给到 30\"是两个分开的事实;没有摄像头时给出明确说明(含"要关掉 app,两个进程不能同时占用同一个摄像头")并返回 1;--help/--index/--max-index 可用。tests/test_ui_smoke.py 增加 test_the_camera_probe_is_usable_without_a_camera:无摄像头时返回 1 且输出解释、fourcc 编解码往返正确('MJPG' → 'MJPG'、0 → '-')。 - Why: 用户按上一轮读数确认"还是摄像头 16.7",即摄像头自身只发布 16.7。配置申请的是 resolution_raw=1920x1080、fps=30、fourcc 走 CaptureConfig 默认 MJPG、backend=auto;SDK 已在 open 里做了 width/height → fourcc → fps → fourcc 再设一次 + warmup 后校验并在 fourcc 不符时重试一次,但仍存在两种可能:这颗摄像头在 1080p 下就只有 16.67(UVC 常见),或者它在 MJPG 之外落到了别的格式/后端。这两种的处置完全不同(前者要改分辨率/换摄像头,而 1920x1080 是 5x3 瓦片网格的结构参数,改它要重做拼接标定;后者是申请顺序/格式的问题,可以在 SDK 侧修),而在应用里看不出来。因此需要一个不经过应用的独立探针,把驱动真正提供的东西列出来。 - Verification: 本机(无摄像头)运行 --index 9 → 输出 "No camera answered on indices 9..9." 与关闭 app 的提示、退出码 1;--help 正常;_decode(_fourcc_code('MJPG')) == 'MJPG'。studio 全量 91 passed(新增 1 项);ui_check 554 项检查 0 失败。 - Not in this change: 未改 SDK 的采集申请顺序或后端选择(等探针结果:若显示 MJPG+1080p 下驱动只给 16.67,则是硬件边界;若显示申请 MJPG 却协商成 YUYV,再改 SDK);未做分辨率自动降级。

This commit is contained in:
ykcheungab
2026-09-14 13:30:21 +08:00
parent 50122d19a2
commit 3db55b2077
2 changed files with 150 additions and 0 deletions
+13
View File
@@ -2128,6 +2128,19 @@ def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window):
window.close() window.close()
def test_the_camera_probe_is_usable_without_a_camera(capsys):
"""The probe has to be safe to run on a machine with no camera attached."""
import camera_probe
assert camera_probe.main(["--index", "9"]) == 1
out = capsys.readouterr().out
assert "No camera answered" in out
assert "with the app closed" in out, "the probe must say why it found nothing"
# The fourcc round trip is what makes the table readable.
assert camera_probe._decode(camera_probe._fourcc_code("MJPG")) == "MJPG"
assert camera_probe._decode(0) == "-"
# ------------------------------------------------------- plot settings dialog # ------------------------------------------------------- plot settings dialog
def test_the_chart_gear_opens_the_settings_dialog(qt_app, make_window): def test_the_chart_gear_opens_the_settings_dialog(qt_app, make_window):
"""The gear is the one place the filters can be changed from the UI.""" """The gear is the one place the filters can be changed from the UI."""
+137
View File
@@ -0,0 +1,137 @@
"""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
depends on it), and nothing downstream can raise a frame rate the driver never
offered. When a session runs at 16.7 fps, the question is which of these it is:
* the camera cannot do 1080p30 at all — then the config has to change, and the
tile geometry with it;
* it can, but only in a particular pixel format or backend — then the SDK's
request sequence/format is what to fix;
* it can, and the rate really is ~30 — then the frames are being lost after the
capture, and the rail's camera rate will say so.
Run it with the venv interpreter, from anywhere::
python apps/studio/tools/camera_probe.py # every camera, every combo
python apps/studio/tools/camera_probe.py --index 0 # just one
Each row is what OpenCV/the driver negotiated for that request, plus the rate
measured from 60 grabs (so "negotiated 30" and "actually delivers 30" are two
separate facts).
"""
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
#: negotiated 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),
)
SAMPLES = 60
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) -> str | None:
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
rate = taken / span if span > 0 else 0.0
got_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
got_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
got_fps = cap.get(cv2.CAP_PROP_FPS)
got_cc = _decode(cap.get(cv2.CAP_PROP_FOURCC))
finally:
cap.release()
asked = f"{fourcc} {width}x{height}" + (f"@{fps:g}" if fps else "@auto")
return (
f" {asked:<20} -> {got_cc} {got_w}x{got_h} @{got_fps:5.1f} "
f"measured {rate:5.1f} fps ({taken}/{SAMPLES}) [{backend_name}]"
)
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")
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
for backend_name, backend in BACKENDS:
for request in REQUESTS:
line = probe(index, backend_name, backend, request)
if line is None:
continue
if not header:
print(f"\ncamera {index}:")
header = True
print(line)
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
print(
"\nRead it this way: a row whose measured rate tracks its negotiated rate "
"is the truth about that camera; a row that negotiates 30 and measures ~17 "
"is dropping frames, and the request sequence is what to fix."
)
return 0
if __name__ == "__main__":
sys.exit(main())