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())