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)