From a44eb2d65dfb6f5a55ec51ac80f8b32f431f57b9 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 13:38:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BE=BE=E4=B8=8D=E5=88=B0=E5=B8=A7=E7=8E=87?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E6=8D=AE=E6=94=B9=E7=94=A8=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E9=80=9F=E7=8E=87=EF=BC=88DSHOW=20=E5=9B=9E=E8=AF=BB=E5=80=BC?= =?UTF-8?q?=E4=BC=9A=E8=AF=B4=E8=B0=8E=EF=BC=89=EF=BC=8C=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E5=8A=A0=E5=B8=A7=E7=8E=87=E6=89=AB=E6=8F=8F=20-=20What:=20sho?= =?UTF-8?q?rt=5Fcapture=5Ffallacy()=20=E6=94=B9=E4=B8=BA=E4=BB=A5**?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B**=E9=80=9F=E7=8E=87=E4=B8=BA=E5=87=86?= =?UTF-8?q?=EF=BC=88=E6=96=B0=E5=A2=9E=20measured=5Ffps=20=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E5=AD=97=E5=8F=82=E6=95=B0=EF=BC=89=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=94=B9=E6=88=90"it=20delivers=20about=2015=20fps=20?= =?UTF-8?q?while=20the=20config=20asks=20for=2030=20(1920x1080=20reported?= =?UTF-8?q?=2030,=20MJPG/DSHOW)"=E2=80=94=E2=80=94=E6=8A=8A=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E8=87=AA=E6=8A=A5=E5=80=BC=E9=99=8D=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?"=E7=9F=9B=E7=9B=BE=E7=9A=84=E4=B8=80=E9=83=A8=E5=88=86"?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E5=88=A4=E6=8D=AE=EF=BC=9Bapp=5Fwin?= =?UTF-8?q?dow=20=E7=9A=84=20=5Fcheck=5Fcapture=5Fnegotiation()=20?= =?UTF-8?q?=E7=9B=B8=E5=BA=94=E6=94=B9=E4=B8=BA=E7=94=A8=20packet=20?= =?UTF-8?q?=E7=9A=84=20capture=5Ffps=EF=BC=88=E5=AE=9E=E6=B5=8B=EF=BC=89?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E3=80=81=E5=AE=9E=E6=B5=8B=E4=B8=BA=200=20?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E4=B8=8B=E7=BB=93=E8=AE=BA=EF=BC=88=E7=AD=89?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=A1=AB=E6=BB=A1=EF=BC=89=E3=80=82tools/cam?= =?UTF-8?q?era=5Fprobe.py=20=E9=87=8D=E5=86=99=EF=BC=9Aprobe()=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=BB=93=E6=9E=84=E4=BD=93=E3=80=81format=5Frow()=20?= =?UTF-8?q?=E6=8A=8A=20reported=20=E4=B8=8E=20measured=20=E5=88=86?= =?UTF-8?q?=E5=88=97=E5=B9=B6=E5=9C=A8=E8=BE=BE=E4=B8=8D=E5=88=B0=2090%=20?= =?UTF-8?q?=E6=97=B6=E6=A0=87=20"<--=20misses=20the=20request"=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20--samples=20=E4=B8=8E**=E5=B8=A7=E7=8E=87?= =?UTF-8?q?=E6=89=AB=E6=8F=8F**=EF=BC=88=E5=90=8C=E4=B8=80=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=BE=9D=E6=AC=A1=E7=94=B3=E8=AF=B7=2060/30/20/15/10?= =?UTF-8?q?=20fps=EF=BC=8C=E9=80=90=E8=A1=8C=E7=BB=99=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=80=BC=E4=B8=8E"=E8=B7=9F=E9=9A=8F=E7=94=B3=E8=AF=B7=20/=20?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E7=94=B3=E8=AF=B7=E7=9A=84=20x=20=E5=80=8D"?= =?UTF-8?q?=E7=9A=84=E5=88=A4=E6=96=AD=EF=BC=8C=E6=9C=80=E5=90=8E=E7=BB=99?= =?UTF-8?q?"=E6=9C=AC=E6=AC=A1=E7=9C=8B=E5=88=B0=E7=9A=84ceiling"=EF=BC=89?= =?UTF-8?q?=E3=80=82tests/test=5Fworker=5Fframe=5Frate.py=20=E7=9A=84?= =?UTF-8?q?=E5=BD=92=E5=9B=A0=E6=B5=8B=E8=AF=95=E5=90=8C=E6=AD=A5=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=8C=E5=B9=B6=E6=98=BE=E5=BC=8F=E8=A6=86=E7=9B=96?= =?UTF-8?q?"=E9=A9=B1=E5=8A=A8=E8=87=AA=E6=8A=A5=2030=20=E8=80=8C=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=2015=20=E5=BF=85=E9=A1=BB=E5=88=A4=E4=B8=BA=E4=B8=8D?= =?UTF-8?q?=E8=BE=BE=E6=A0=87"=E4=B8=8E"=E6=B2=A1=E6=9C=89=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=80=BC=E6=97=B6=E4=B8=8D=E8=AF=B4=E8=AF=9D"?= =?UTF-8?q?=E3=80=82=20-=20Why:=20=E7=94=A8=E6=88=B7=E8=B7=91=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E7=9A=84=E7=BB=93=E6=9E=9C=E6=8E=A8=E7=BF=BB=E4=BA=86?= =?UTF-8?q?=E6=88=91=E4=B8=8A=E4=B8=80=E8=BD=AE=E7=9A=84=E5=88=A4=E6=8D=AE?= =?UTF-8?q?=EF=BC=9A`MJPG=201920x1080@30=20->=20MJPG=201920x1080=20@=2030.?= =?UTF-8?q?0=20measured=2015.0`=E3=80=82DSHOW=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=B8=8B=20CAP=5FPROP=5FFPS=20=E5=8F=AA=E6=98=AF=E5=9B=9E?= =?UTF-8?q?=E8=AF=BB=E4=BD=A0=E8=AE=BE=E7=9A=84=E5=80=BC=EF=BC=8C=E6=89=80?= =?UTF-8?q?=E4=BB=A5=20SDK=20=E7=9A=84=20status.fps=20=E5=90=8C=E6=A0=B7?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E4=BF=A1=20=E2=80=94=E2=80=94=20=E6=88=91?= =?UTF-8?q?=E5=8E=9F=E6=9D=A5=E6=AF=94=E8=BE=83"=E7=94=B3=E8=AF=B7=20vs=20?= =?UTF-8?q?=E5=8D=8F=E5=95=86"=E7=AD=89=E4=BA=8E=E6=8B=BF=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E8=B7=9F=E8=87=AA=E5=B7=B1=E6=AF=94=EF=BC=8C=E9=82=A3?= =?UTF-8?q?=E4=B8=AA=E6=8F=90=E7=A4=BA=E6=B0=B8=E8=BF=9C=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=EF=BC=88=E7=94=A8=E6=88=B7=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E7=9C=8B=E5=88=B0=E7=9A=84=E5=8F=AA=E6=9C=89=E8=BD=A8=E9=81=93?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E5=AE=9E=E6=B5=8B=2016.7=EF=BC=89=E3=80=82?= =?UTF-8?q?=E8=BF=99=E4=B9=9F=E8=A7=A3=E9=87=8A=E4=BA=86=E4=B8=BA=E4=BB=80?= =?UTF-8?q?=E4=B9=88=E8=BF=99=E4=BB=B6=E4=BA=8B=E5=BF=85=E9=A1=BB=E9=9D=A0?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=EF=BC=9A=E7=9C=9F=E5=AE=9E=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E5=8F=AA=E8=83=BD=E6=B5=8B=E5=87=BA=E6=9D=A5=E3=80=82=E5=8F=A6?= =?UTF-8?q?=E5=A4=96=E6=8E=A2=E9=92=88=E6=98=BE=E7=A4=BA=201280x720=20MJPG?= =?UTF-8?q?=20=E5=AE=9E=E6=B5=8B=E4=B9=9F=E6=98=AF=2015=E3=80=81YUYV=20?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=201=20fps=EF=BC=8C=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E6=8D=A2=E5=88=86=E8=BE=A8=E7=8E=87=E4=B8=8D=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E9=9C=80=E8=A6=81=E5=8C=BA=E5=88=86?= =?UTF-8?q?"=E6=91=84=E5=83=8F=E5=A4=B4=E6=9C=AC=E8=BA=AB=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E9=99=90"=E4=B8=8E"=E6=88=91=E4=BB=AC=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=A4=9A=E5=B0=91=E5=B0=B1=E8=A2=AB=E7=A0=8D=E5=8D=8A?= =?UTF-8?q?"=E3=80=82=20-=20Verification:=20shared=20=E5=85=A8=E9=87=8F=20?= =?UTF-8?q?161=20passed=EF=BC=88=E5=90=AB=E6=96=B0=E5=A2=9E/=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9A=84=203=20=E9=A1=B9=E6=96=AD=E8=A8=80=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bstudio=2091=20passed=EF=BC=9Bui=5Fcheck=20554=20?= =?UTF-8?q?=E9=A1=B9=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1=E8=B4=A5=EF=BC=9B?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E5=9C=A8=E6=9C=AC=E6=9C=BA=EF=BC=88=E6=97=A0?= =?UTF-8?q?=E6=91=84=E5=83=8F=E5=A4=B4=EF=BC=89=E4=BB=8D=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=201=20=E5=B9=B6=E7=BB=99=E5=87=BA=E8=A7=A3=E9=87=8A=EF=BC=8Cfo?= =?UTF-8?q?rmat=5Frow/=5Fdecode=20=E5=8D=95=E6=B5=8B=E9=80=9A=E8=BF=87?= =?UTF-8?q?=EF=BC=88"reported=2030.0=20measured=2015.0=20...=20<--=20misse?= =?UTF-8?q?s=20the=20request"=EF=BC=89=E3=80=82=20-=20Not=20in=20this=20ch?= =?UTF-8?q?ange:=20=E6=9C=AA=E6=94=B9=20SDK=20=E9=87=87=E9=9B=86=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E9=A1=BA=E5=BA=8F/=E5=90=8E=E7=AB=AF=EF=BC=9B?= =?UTF-8?q?=E6=9C=AA=E5=81=9A=E8=87=AA=E5=8A=A8=E9=99=8D=E7=BA=A7=EF=BC=9B?= =?UTF-8?q?frames.csv=20=E4=BB=8D=E6=9C=AA=E8=AE=B0=E5=BD=95=20process=5Fm?= =?UTF-8?q?s/capture=5Ffps=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/src/orisys_studio/app_window.py | 18 ++- apps/studio/tools/camera_probe.py | 139 ++++++++++++++---- .../acquisition/sdk_worker.py | 35 +++-- shared/tests/test_worker_frame_rate.py | 16 +- 4 files changed, 154 insertions(+), 54 deletions(-) diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index ae87894..2224119 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -630,12 +630,13 @@ class StudioMainWindow(QMainWindow): return {} def _check_capture_negotiation(self) -> None: - """Say it out loud when the camera cannot do what the config asks. + """Say it out loud when the camera delivers less than the config asks. - A camera that only offers a lower rate than the config requests is not - something the pipeline can fix, and it is not something the operator - should have to infer from a small number on the rail. Said once per - session, and only once the driver has confirmed a rate. + The evidence is the *measured* capture rate, not the driver's reported + one: OpenCV's DSHOW backend echoes ``CAP_PROP_FPS`` back as whatever was + set, so a camera advertising 30 and delivering 15 reports "30.0" (measured + on this project's own hardware). Said once per session, and only once a + measured rate exists. """ if self._capture_checked: return @@ -649,10 +650,11 @@ class StudioMainWindow(QMainWindow): logger.exception("Could not read the capture status") self._capture_checked = True return - if not ((status.get("negotiated") or {}).get("fps")): - return # the driver has not reported a rate yet: ask again next frame + measured = float(self._latest_capture_fps or 0.0) + if measured <= 0.0: + return # no measured rate yet: ask again on the next frame self._capture_checked = True - detail = short_capture_fallacy(status) + detail = short_capture_fallacy(status, measured_fps=measured) if detail: self._notify(t("status.capture_slow", detail=detail)) diff --git a/apps/studio/tools/camera_probe.py b/apps/studio/tools/camera_probe.py index b8d899e..ba6f816 100644 --- a/apps/studio/tools/camera_probe.py +++ b/apps/studio/tools/camera_probe.py @@ -1,24 +1,25 @@ """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: +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: -* 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. +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, from anywhere:: +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, 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). + 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 @@ -30,7 +31,7 @@ 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. +#: 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" @@ -44,7 +45,13 @@ BACKENDS: tuple[tuple[str, int], ...] = ( ("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: @@ -59,7 +66,15 @@ def _decode(code: float) -> str: return letters if letters.isprintable() else f"0x{value:08x}" -def probe(index: int, backend_name: str, backend: int, request) -> str | None: +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(): @@ -76,47 +91,116 @@ def probe(index: int, backend_name: str, backend: int, request) -> str | None: cap.grab() started = time.perf_counter() taken = 0 - for _ in range(SAMPLES): + 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) + 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} -> {got_cc} {got_w}x{got_h} @{got_fps:5.1f} " - f"measured {rate:5.1f} fps ({taken}/{SAMPLES}) [{backend_name}]" + 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: - line = probe(index, backend_name, backend, request) - if line is None: + 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(line) + 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( @@ -125,11 +209,6 @@ def main(argv: list[str] | None = None) -> int: "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 diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index bc34e05..082c72d 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -195,24 +195,33 @@ def capture_status_of(sensor) -> dict: } -def short_capture_fallacy(status: Mapping[str, Any] | None) -> str: - """A one-line description of a capture negotiation that fell short. +def short_capture_fallacy( + status: Mapping[str, Any] | None, *, measured_fps: float = 0.0 +) -> str: + """A one-line description of a capture that falls short of the request. - Empty when the driver gave what was asked for (or when either half is - unknown): this exists to be *said*, not to fill a field. + The rate to compare against is the **measured** one. OpenCV's DSHOW backend + reports ``CAP_PROP_FPS`` back as whatever was set — a camera that advertises + 30 and delivers 15 says "30.0" — so the reported figure cannot be the evidence + (it is included only to show the contradiction). + + Empty when nothing measurable fell short, or when either half is unknown: this + exists to be *said*, not to fill a field. """ if not isinstance(status, Mapping): return "" requested = dict(status.get("requested") or {}) negotiated = dict(status.get("negotiated") or {}) - wanted = requested.get("fps") - got = negotiated.get("fps") try: - wanted_value = float(wanted) - got_value = float(got) + wanted_value = float(requested.get("fps")) except (TypeError, ValueError): return "" - if wanted_value <= 0 or got_value <= 0 or got_value >= wanted_value * 0.9: + if wanted_value <= 0: + return "" + measured = float(measured_fps or 0.0) + if measured <= 0.0: + return "" + if measured >= wanted_value * 0.9: return "" size = "" width, height = negotiated.get("width"), negotiated.get("height") @@ -220,11 +229,13 @@ def short_capture_fallacy(status: Mapping[str, Any] | None) -> str: size = f"{int(width)}x{int(height)} " fourcc = str(negotiated.get("fourcc") or "").strip() backend = str(negotiated.get("backend") or "").strip() + reported = negotiated.get("fps") detail = "/".join(part for part in (fourcc, backend) if part) return ( - f"the camera negotiated {size}@{got_value:g} fps " - f"(the config asks for {wanted_value:g})" - + (f" [{detail}]" if detail else "") + f"it delivers about {measured:g} fps while the config asks for " + f"{wanted_value:g}" + + (f" ({size}reported {float(reported):g}" if reported else f" ({size}") + + (f", {detail})" if detail else ")") ) diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index ab2cc34..b7c489b 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -312,11 +312,19 @@ def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch): assert status["negotiated"]["fps"] == 16.67, status assert status["negotiated"]["fourcc"] == "MJPG" assert status["negotiated"]["backend"] == "MSMF" - detail = short_capture_fallacy(status) - assert "1920x1080" in detail and "16.67" in detail and "30" in detail, detail - # A driver that gives what was asked for says nothing at all. + # The *measured* rate is the evidence: DSHOW echoes the requested fps back + # as the "negotiated" one, so trusting that number would hide a camera + # that advertises 30 and delivers 15 (measured on this project's camera). + detail = short_capture_fallacy(status, measured_fps=16.67) + assert "16.67" in detail and "30" in detail and "1920x1080" in detail, detail + assert short_capture_fallacy(status, measured_fps=29.5) == "" + # A driver reporting 30 while delivering half of it is the case that + # matters, and it must not slip through. source.status.fps = 30.0 - assert short_capture_fallacy(worker.capture_status()) == "" + echoing = worker.capture_status() + assert short_capture_fallacy(echoing, measured_fps=15.0) != "" + # No measured rate yet: nothing to say. + assert short_capture_fallacy(echoing) == "" finally: worker.stop(wait=True) _restore(saved)