新增摄像头能力探针 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:
@@ -2128,6 +2128,19 @@ def test_the_rail_reads_the_rate_and_what_it_cost(qt_app, make_window):
|
||||
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
|
||||
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."""
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user