From 3db55b2077a9a386b5318accbbb2b09946bfe1c0 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 13:30:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=8E=A2=E9=92=88=20tools/camera=5Fprobe.py?= =?UTF-8?q?=EF=BC=8C=E7=94=A8=E6=9D=A5=E6=8A=8A=2016.7=20fps=20=E5=BD=92?= =?UTF-8?q?=E5=9B=A0=E5=88=B0=E6=A0=BC=E5=BC=8F=E5=8D=8F=E5=95=86=20-=20Wh?= =?UTF-8?q?at:=20=E6=96=B0=E8=84=9A=E6=9C=AC=E5=AF=B9=E6=AF=8F=E4=B8=AA?= =?UTF-8?q?=E6=91=84=E5=83=8F=E5=A4=B4=E7=B4=A2=E5=BC=95=20=C3=97=20?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E5=90=8E=E7=AB=AF=EF=BC=88DSHOW/MSMF?= =?UTF-8?q?=EF=BC=89=C3=97=20=E4=BA=94=E7=BB=84=E8=AF=B7=E6=B1=82=EF=BC=88?= =?UTF-8?q?MJPG=201920x1080@30=E3=80=81MJPG=201920x1080@auto=E3=80=81MJPG?= =?UTF-8?q?=201280x720@30=E3=80=81YUYV=201920x1080@30=E3=80=81YUYV=201280x?= =?UTF-8?q?720@30=EF=BC=89=E5=AE=9E=E9=99=85=E6=89=93=E5=BC=80=E5=B9=B6?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E6=8A=93=2060=20=E5=B8=A7=EF=BC=8C=E9=80=90?= =?UTF-8?q?=E8=A1=8C=E6=89=93=E5=8D=B0"=E7=94=B3=E8=AF=B7=20=E2=86=92=20?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E5=8D=8F=E5=95=86=E5=88=B0=E7=9A=84=20fourcc?= =?UTF-8?q?/=E5=B0=BA=E5=AF=B8/=E5=B8=A7=E7=8E=87=20+=20=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=B8=A7=E7=8E=87"=EF=BC=8C=E8=BF=99=E6=A0=B7\"=E5=8D=8F?= =?UTF-8?q?=E5=95=86=E5=88=B0=2030\"=E4=B8=8E\"=E7=9C=9F=E7=9A=84=E7=BB=99?= =?UTF-8?q?=E5=88=B0=2030\"=E6=98=AF=E4=B8=A4=E4=B8=AA=E5=88=86=E5=BC=80?= =?UTF-8?q?=E7=9A=84=E4=BA=8B=E5=AE=9E=EF=BC=9B=E6=B2=A1=E6=9C=89=E6=91=84?= =?UTF-8?q?=E5=83=8F=E5=A4=B4=E6=97=B6=E7=BB=99=E5=87=BA=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=EF=BC=88=E5=90=AB"=E8=A6=81=E5=85=B3?= =?UTF-8?q?=E6=8E=89=20app=EF=BC=8C=E4=B8=A4=E4=B8=AA=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E5=90=8C=E6=97=B6=E5=8D=A0=E7=94=A8=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=91=84=E5=83=8F=E5=A4=B4"=EF=BC=89?= =?UTF-8?q?=E5=B9=B6=E8=BF=94=E5=9B=9E=201=EF=BC=9B--help/--index/--max-in?= =?UTF-8?q?dex=20=E5=8F=AF=E7=94=A8=E3=80=82tests/test=5Fui=5Fsmoke.py=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20test=5Fthe=5Fcamera=5Fprobe=5Fis=5Fusable?= =?UTF-8?q?=5Fwithout=5Fa=5Fcamera=EF=BC=9A=E6=97=A0=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E6=97=B6=E8=BF=94=E5=9B=9E=201=20=E4=B8=94=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=A7=A3=E9=87=8A=E3=80=81fourcc=20=E7=BC=96=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E5=BE=80=E8=BF=94=E6=AD=A3=E7=A1=AE=EF=BC=88'MJPG'=20?= =?UTF-8?q?=E2=86=92=20'MJPG'=E3=80=810=20=E2=86=92=20'-'=EF=BC=89?= =?UTF-8?q?=E3=80=82=20-=20Why:=20=E7=94=A8=E6=88=B7=E6=8C=89=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E8=BD=AE=E8=AF=BB=E6=95=B0=E7=A1=AE=E8=AE=A4"?= =?UTF-8?q?=E8=BF=98=E6=98=AF=E6=91=84=E5=83=8F=E5=A4=B4=2016.7"=EF=BC=8C?= =?UTF-8?q?=E5=8D=B3=E6=91=84=E5=83=8F=E5=A4=B4=E8=87=AA=E8=BA=AB=E5=8F=AA?= =?UTF-8?q?=E5=8F=91=E5=B8=83=2016.7=E3=80=82=E9=85=8D=E7=BD=AE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E7=9A=84=E6=98=AF=20resolution=5Fraw=3D1920x1080?= =?UTF-8?q?=E3=80=81fps=3D30=E3=80=81fourcc=20=E8=B5=B0=20CaptureConfig=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=20MJPG=E3=80=81backend=3Dauto=EF=BC=9BSDK=20?= =?UTF-8?q?=E5=B7=B2=E5=9C=A8=20open=20=E9=87=8C=E5=81=9A=E4=BA=86=20width?= =?UTF-8?q?/height=20=E2=86=92=20fourcc=20=E2=86=92=20fps=20=E2=86=92=20fo?= =?UTF-8?q?urcc=20=E5=86=8D=E8=AE=BE=E4=B8=80=E6=AC=A1=20+=20warmup=20?= =?UTF-8?q?=E5=90=8E=E6=A0=A1=E9=AA=8C=E5=B9=B6=E5=9C=A8=20fourcc=20?= =?UTF-8?q?=E4=B8=8D=E7=AC=A6=E6=97=B6=E9=87=8D=E8=AF=95=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=EF=BC=8C=E4=BD=86=E4=BB=8D=E5=AD=98=E5=9C=A8=E4=B8=A4=E7=A7=8D?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=EF=BC=9A=E8=BF=99=E9=A2=97=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E5=9C=A8=201080p=20=E4=B8=8B=E5=B0=B1=E5=8F=AA?= =?UTF-8?q?=E6=9C=89=2016.67=EF=BC=88UVC=20=E5=B8=B8=E8=A7=81=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=88=96=E8=80=85=E5=AE=83=E5=9C=A8=20MJPG=20?= =?UTF-8?q?=E4=B9=8B=E5=A4=96=E8=90=BD=E5=88=B0=E4=BA=86=E5=88=AB=E7=9A=84?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F/=E5=90=8E=E7=AB=AF=E3=80=82=E8=BF=99?= =?UTF-8?q?=E4=B8=A4=E7=A7=8D=E7=9A=84=E5=A4=84=E7=BD=AE=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=EF=BC=88=E5=89=8D=E8=80=85=E8=A6=81=E6=94=B9?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87/=E6=8D=A2=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=EF=BC=8C=E8=80=8C=201920x1080=20=E6=98=AF=205x3=20?= =?UTF-8?q?=E7=93=A6=E7=89=87=E7=BD=91=E6=A0=BC=E7=9A=84=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=8C=E6=94=B9=E5=AE=83=E8=A6=81=E9=87=8D?= =?UTF-8?q?=E5=81=9A=E6=8B=BC=E6=8E=A5=E6=A0=87=E5=AE=9A=EF=BC=9B=E5=90=8E?= =?UTF-8?q?=E8=80=85=E6=98=AF=E7=94=B3=E8=AF=B7=E9=A1=BA=E5=BA=8F/?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E5=9C=A8=20SDK=20=E4=BE=A7=E4=BF=AE=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E8=80=8C=E5=9C=A8=E5=BA=94=E7=94=A8=E9=87=8C=E7=9C=8B=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E6=9D=A5=E3=80=82=E5=9B=A0=E6=AD=A4=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E4=B8=8D=E7=BB=8F=E8=BF=87=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E7=8B=AC=E7=AB=8B=E6=8E=A2=E9=92=88=EF=BC=8C=E6=8A=8A?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E7=9C=9F=E6=AD=A3=E6=8F=90=E4=BE=9B=E7=9A=84?= =?UTF-8?q?=E4=B8=9C=E8=A5=BF=E5=88=97=E5=87=BA=E6=9D=A5=E3=80=82=20-=20Ve?= =?UTF-8?q?rification:=20=E6=9C=AC=E6=9C=BA=EF=BC=88=E6=97=A0=E6=91=84?= =?UTF-8?q?=E5=83=8F=E5=A4=B4=EF=BC=89=E8=BF=90=E8=A1=8C=20--index=209=20?= =?UTF-8?q?=E2=86=92=20=E8=BE=93=E5=87=BA=20"No=20camera=20answered=20on?= =?UTF-8?q?=20indices=209..9."=20=E4=B8=8E=E5=85=B3=E9=97=AD=20app=20?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E7=A4=BA=E3=80=81=E9=80=80=E5=87=BA=E7=A0=81?= =?UTF-8?q?=201=EF=BC=9B--help=20=E6=AD=A3=E5=B8=B8=EF=BC=9B=5Fdecode(=5Ff?= =?UTF-8?q?ourcc=5Fcode('MJPG'))=20=3D=3D=20'MJPG'=E3=80=82studio=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=2091=20passed=EF=BC=88=E6=96=B0=E5=A2=9E=201?= =?UTF-8?q?=20=E9=A1=B9=EF=BC=89=EF=BC=9Bui=5Fcheck=20554=20=E9=A1=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1=E8=B4=A5=E3=80=82=20-=20Not?= =?UTF-8?q?=20in=20this=20change:=20=E6=9C=AA=E6=94=B9=20SDK=20=E7=9A=84?= =?UTF-8?q?=E9=87=87=E9=9B=86=E7=94=B3=E8=AF=B7=E9=A1=BA=E5=BA=8F=E6=88=96?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E9=80=89=E6=8B=A9=EF=BC=88=E7=AD=89=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=BB=93=E6=9E=9C=EF=BC=9A=E8=8B=A5=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=20MJPG+1080p=20=E4=B8=8B=E9=A9=B1=E5=8A=A8=E5=8F=AA=E7=BB=99?= =?UTF-8?q?=2016.67=EF=BC=8C=E5=88=99=E6=98=AF=E7=A1=AC=E4=BB=B6=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=EF=BC=9B=E8=8B=A5=E6=98=BE=E7=A4=BA=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=20MJPG=20=E5=8D=B4=E5=8D=8F=E5=95=86=E6=88=90=20YUYV=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=E6=94=B9=20SDK=EF=BC=89=EF=BC=9B=E6=9C=AA=E5=81=9A?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87=E8=87=AA=E5=8A=A8=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/tests/test_ui_smoke.py | 13 +++ apps/studio/tools/camera_probe.py | 137 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 apps/studio/tools/camera_probe.py diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index 751ece1..b2e94cd 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -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.""" diff --git a/apps/studio/tools/camera_probe.py b/apps/studio/tools/camera_probe.py new file mode 100644 index 0000000..b8d899e --- /dev/null +++ b/apps/studio/tools/camera_probe.py @@ -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())