From 4a482e579fcf0329a014df83322c8d0e9ff40813 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 13:48:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89"=E7=A1=AC=E4=BB=B6=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E3=80=81=E9=85=8D=E7=BD=AE=E4=B8=8D=E5=8F=98"=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=EF=BC=9A=E8=AE=B0=E5=BD=95=E9=87=8C=E5=86=99=E4=B8=8B?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=B8=A7=E7=8E=87=EF=BC=8C=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E5=8A=A0=E9=80=90=E9=A2=97=E9=AA=8C=E6=94=B6=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=20-=20What:=20worker=20=E6=8A=8A=E6=9C=AC=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=88=B0=E7=9A=84=E9=87=87=E9=9B=86=E9=80=9F?= =?UTF-8?q?=E7=8E=87=E8=AE=B0=E8=BF=9B=20self.=5Fmeasured=5Fcapture=5Ffps?= =?UTF-8?q?=EF=BC=88box=20=E4=B8=8E=20finger=20=E4=B8=A4=E6=9D=A1=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E9=83=BD=E7=AE=97=EF=BC=9Afinger=20=E7=9A=84=E5=B8=A7?= =?UTF-8?q?=E7=8E=87=E4=B9=9F=E4=BB=8E"1=20=E7=A7=92=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=86=85=E8=AE=A1=E6=95=B0"=E6=94=B9=E6=88=90=20(n-1)/span=20?= =?UTF-8?q?=E7=9A=84=E9=80=9F=E7=8E=87=EF=BC=8C=E4=B8=A4=E6=9D=A1=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=AF=B9=20fps=20=E7=9A=84=E5=90=AB=E4=B9=89=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E4=B8=80=E8=87=B4=EF=BC=89=EF=BC=8Ccapture=5Fstatus()?= =?UTF-8?q?=20=E5=9B=A0=E6=AD=A4=E5=A4=9A=E5=87=BA=20measured=5Ffps=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20session.json=20=E7=9A=84=20capture=20?= =?UTF-8?q?=E5=9D=97=E7=8E=B0=E5=9C=A8=E5=90=8C=E6=97=B6=E6=9C=89=20reques?= =?UTF-8?q?ted=20/=20negotiated=20/=20measured=5Ffps=20/=20dropped=5Fframe?= =?UTF-8?q?s=EF=BC=8C=E4=B8=80=E9=A2=97=E6=91=84=E5=83=8F=E5=A4=B4?= =?UTF-8?q?=E5=88=B0=E5=BA=95=E4=BA=A4=E4=BB=98=E4=BA=86=E5=A4=9A=E5=B0=91?= =?UTF-8?q?=20fps=20=E7=94=B1=E8=AE=B0=E5=BD=95=E8=87=AA=E8=AF=81=E3=80=82?= =?UTF-8?q?status.capture=5Fslow=20=E7=9A=84=E4=B8=AD=E8=8B=B1=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=94=B9=E5=86=99=EF=BC=9A=E5=88=A0=E6=8E=89=E5=B7=B2?= =?UTF-8?q?=E8=A2=AB=E5=AE=9E=E6=B5=8B=E5=90=A6=E8=AF=81=E7=9A=84"?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E5=88=86=E8=BE=A8=E7=8E=87/=E6=8D=A2?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F"=E5=BB=BA=E8=AE=AE=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E6=88=90"=E8=BF=99=E6=98=AF=E9=87=87=E9=9B=86=E7=AB=AF?= =?UTF-8?q?=EF=BC=88=E6=91=84=E5=83=8F=E5=A4=B4/=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=EF=BC=89=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=81=E8=BD=AF=E4=BB=B6?= =?UTF-8?q?=E6=94=B9=E4=B8=8D=E4=BA=86=EF=BC=8C=E6=8D=A2=E4=B8=80=E9=A2=97?= =?UTF-8?q?=E7=9C=9F=E8=83=BD=E4=BA=A4=E4=BB=98=2030=20fps=20=E7=9A=84?= =?UTF-8?q?=EF=BC=88=E5=B9=B6=E5=85=88=E7=A1=AE=E8=AE=A4=E7=BA=BF=E6=9D=90?= =?UTF-8?q?/=E7=AB=AF=E5=8F=A3=E6=B2=A1=E6=8A=8A=E5=AE=83=E9=99=8D?= =?UTF-8?q?=E9=80=9F=EF=BC=89=E5=86=8D=E6=B5=8B"=E3=80=82tools/camera=5Fpr?= =?UTF-8?q?obe.py=20=E6=96=B0=E5=A2=9E=20--check=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=9A=E5=8F=AA=E6=B5=8B=20bundle=20?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E9=82=A3=E4=B8=80=E4=B8=AA=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=88MJPG=201920x1080@30=EF=BC=8C120=20=E5=B8=A7=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=8C=89**=E5=AE=9E=E6=B5=8B**=E9=80=9F=E7=8E=87?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E4=B8=80=E8=A1=8C=20PASS/FAIL=20=E5=B9=B6?= =?UTF-8?q?=E7=BB=99=E5=87=BA=E9=80=80=E5=87=BA=E7=A0=81=EF=BC=8C=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E9=80=90=E9=A2=97=E8=BF=87=E6=A3=80=EF=BC=9B=E6=97=A0?= =?UTF-8?q?=E6=91=84=E5=83=8F=E5=A4=B4=E6=97=B6=E6=8A=A5=20"No=20camera=20?= =?UTF-8?q?answered=20on=20index=20N=20(is=20the=20app=20closed=3F)"=20?= =?UTF-8?q?=E5=B9=B6=E8=BF=94=E5=9B=9E=201=E3=80=82tests=EF=BC=9A=E5=81=87?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=94=A8=E4=B8=8A=20capture=5Fstatus()=20?= =?UTF-8?q?=E7=9A=84=20measured=5Ffps=EF=BC=880.05=20s=20=E7=B2=92?= =?UTF-8?q?=E5=BA=A6=E7=AD=89=E5=AE=83=E5=87=BA=E7=8E=B0=EF=BC=8C=E6=96=AD?= =?UTF-8?q?=E8=A8=80=2020=E2=80=9335=20fps=EF=BC=89=E3=80=81=E6=8E=A2?= =?UTF-8?q?=E9=92=88=20--check=20=E7=9A=84=E6=97=A0=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E8=B7=AF=E5=BE=84=E3=80=82=20-=20Why:=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=88=A4=E5=AE=9A=E8=BF=99=E6=98=AF=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?/=E7=A1=AC=E4=BB=B6=E9=97=AE=E9=A2=98=EF=BC=88=E5=90=8C?= =?UTF-8?q?=E5=9E=8B=E5=8F=B7=E4=BA=A7=E5=93=81=E6=AD=A4=E5=89=8D=E8=83=BD?= =?UTF-8?q?=E8=B7=91=2030=20fps=EF=BC=89=EF=BC=8C=E5=86=B3=E5=AE=9A**?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=8D=E5=8F=98**=E3=80=81=E4=BB=A5?= =?UTF-8?q?=E5=90=8E=E7=94=A8=E8=83=BD=E5=88=B0=2030=20fps=20=E7=9A=84?= =?UTF-8?q?=E4=BA=A7=E5=93=81=EF=BC=8C=E5=B9=B6=E5=9C=A8=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E9=80=90=E9=A2=97=E6=A3=80=E6=B5=8B=E3=80=82=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E6=84=8F=E5=91=B3=E7=9D=80=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=8C=89=2030=20fps=20=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=EF=BC=88=E8=BF=99=E6=98=AF=E5=AF=B9=E7=9A=84=EF=BC=9A=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=2015=20=E5=8F=AA=E4=BC=9A=E6=8A=8A=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=B8=8A=E7=A7=BB=EF=BC=89=EF=BC=8C=E4=BD=86=E7=94=B1=E6=AD=A4?= =?UTF-8?q?=E4=BA=A7=E7=94=9F=E4=B8=A4=E4=BB=B6=E5=BF=85=E9=A1=BB=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E7=9A=84=E4=BA=8B=EF=BC=9A(1)=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=AD=E9=87=8C"=E9=99=8D=E4=BD=8E=E5=88=86=E8=BE=A8?= =?UTF-8?q?=E7=8E=87/=E6=8D=A2=E6=A0=BC=E5=BC=8F"=E6=98=AF=E9=94=99?= =?UTF-8?q?=E7=9A=84=E5=BB=BA=E8=AE=AE=E2=80=94=E2=80=94=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E5=B7=B2=E7=BB=8F=E8=AF=81=E6=98=8E=20720p=20MJPG=20=E4=B8=80?= =?UTF-8?q?=E6=A0=B7=E6=98=AF=2015=20fps=E3=80=81YUYV=20=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=201=20fps=EF=BC=9B(2)=20=E6=97=A2=E7=84=B6=E8=A6=81=E9=80=90?= =?UTF-8?q?=E9=A2=97=E6=A3=80=E6=B5=8B=EF=BC=8C=E5=B0=B1=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=9C=89=E4=B8=80=E4=B8=AA=E8=83=BD=E5=9B=9E=E7=AD=94"?= =?UTF-8?q?=E8=BF=99=E4=B8=80=E9=A2=97=E4=BA=A4=E4=BB=98=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E5=B0=91"=E7=9A=84=E6=9D=83=E5=A8=81=E6=95=B0=E5=AD=97?= =?UTF-8?q?=EF=BC=8C=E8=80=8C=E9=A9=B1=E5=8A=A8=E8=87=AA=E6=8A=A5=E5=80=BC?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E4=BF=A1=EF=BC=88DSHOW=20=E5=9B=9E=E8=AF=BB?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=89=80=E4=BB=A5=E6=8A=8A=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E5=80=BC=E5=86=99=E8=BF=9B=E8=AE=B0=E5=BD=95=E5=B9=B6=E7=BB=99?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E4=B8=80=E4=B8=AA=E5=8D=95=E8=A1=8C=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=E6=A8=A1=E5=BC=8F=E3=80=82=20-=20Verification:=20shar?= =?UTF-8?q?ed=20=E5=85=A8=E9=87=8F=20161=20passed=EF=BC=9Bstudio=2091=20pa?= =?UTF-8?q?ssed=EF=BC=9Bui=5Fcheck=20554=20=E9=A1=B9=E6=A3=80=E6=9F=A5=200?= =?UTF-8?q?=20=E5=A4=B1=E8=B4=A5=EF=BC=9Blocale=5Fcheck=20=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E9=94=AE=E9=9B=86=E4=B8=80=E8=87=B4=EF=BC=9B=E6=8E=A2?= =?UTF-8?q?=E9=92=88=20--check=20=E5=9C=A8=E6=97=A0=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4=E6=97=B6=E6=89=93=E5=8D=B0=E6=8C=87=E5=AE=9A=E6=96=87?= =?UTF-8?q?=E6=A1=88=E5=B9=B6=E8=BF=94=E5=9B=9E=201=EF=BC=8C--help=20?= =?UTF-8?q?=E5=88=97=E5=87=BA=E8=AF=A5=E9=80=89=E9=A1=B9=E3=80=82=20-=20No?= =?UTF-8?q?t=20in=20this=20change:=20=E6=9C=AA=E6=94=B9=E4=BB=BB=E4=BD=95?= =?UTF-8?q?=E9=87=87=E9=9B=86=E9=85=8D=E7=BD=AE=EF=BC=88resolution=5Fraw/f?= =?UTF-8?q?ps/fourcc=20=E9=83=BD=E4=B8=8D=E5=8A=A8=EF=BC=89=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=EF=BC=88=E7=95=99=E5=88=B0=E5=8A=A0=E6=9C=AA?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2=2017=20=E5=88=97=E3=80=81=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8D=87=E7=89=88=E6=97=B6=E4=B8=80=E8=B5=B7?= =?UTF-8?q?=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/studio/config/locales/en_US.json | 2 +- apps/studio/config/locales/zh_CN.json | 2 +- apps/studio/tests/test_ui_smoke.py | 4 ++ apps/studio/tools/camera_probe.py | 43 ++++++++++++++++++ .../acquisition/finger_worker.py | 45 ++++++++++++++++--- .../acquisition/sdk_worker.py | 21 +++++++-- shared/tests/test_worker_frame_rate.py | 9 ++++ 7 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index fbb0c94..5cf4ab9 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -357,7 +357,7 @@ "run_failed": "Run failed: {error}", "filters_saved": "Filter settings saved: {describe} (applies to the next session)", "rail_camera_fps": "camera {fps}", - "capture_slow": "The camera cannot deliver the configured frame rate: {detail}. Software cannot change that — the driver only offers this format; the recording keeps the real rate. Use a smaller resolution/another format, or another camera.", + "capture_slow": "The camera delivers less than the configured frame rate: {detail}. The recording keeps the real rate. This is a capture-side (camera/driver) matter that software cannot change: use a camera that really does deliver 30 fps — after checking that the cable or port is not slowing this one down — and measure again.", "video_ended": "Video ended", "camera_disconnected": "Camera disconnected", "reset_ok": "Tracker reset", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index b6a0197..5397a4a 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -357,7 +357,7 @@ "run_failed": "启动失败: {error}", "filters_saved": "滤波设置已保存:{describe}(下次采集生效)", "rail_camera_fps": "摄像头 {fps}", - "capture_slow": "摄像头达不到配置的帧率:{detail}。这不是软件能改的——驱动只提供这个格式;记录会按实际帧率保存,请降低分辨率/换格式或换摄像头。", + "capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一颗真能交付 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。", "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", "reset_ok": "追踪器已重置", diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index b2e94cd..991a8f7 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -2139,6 +2139,10 @@ def test_the_camera_probe_is_usable_without_a_camera(capsys): # 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) == "-" + # Acceptance mode: one line, one exit code, no camera means failure. + assert camera_probe.main(["--check", "--index", "9", "--samples", "2"]) == 1 + check_out = capsys.readouterr().out + assert "No camera answered on index 9" in check_out # ------------------------------------------------------- plot settings dialog diff --git a/apps/studio/tools/camera_probe.py b/apps/studio/tools/camera_probe.py index 3959d85..9c54325 100644 --- a/apps/studio/tools/camera_probe.py +++ b/apps/studio/tools/camera_probe.py @@ -175,13 +175,56 @@ def size_sweep(index: int, backend_name: str, backend: int, fps: float = 30.0, return rows +def check_format( + index: int, + *, + request: tuple[str, int, int, float] = REQUESTS[0], + samples: int = 120, +) -> int: + """Acceptance mode: does this camera deliver the bundle's format? + + One line, one exit code, for going through units in turn. The request is the + one the sensor bundles make (MJPG 1920x1080@30); the verdict is the *measured* + rate, because the reported one is an echo. + """ + fourcc, width, height, fps = request + for backend_name, backend in BACKENDS: + result = probe(index, backend_name, backend, request, samples=samples) + if result is None: + continue + measured = result["measured_fps"] + asked = f"{fourcc} {width}x{height}@{fps:g}" + if measured >= fps * TOLERANCE: + print(f"PASS camera {index}: {asked} -> {measured:.1f} fps " + f"[{backend_name}, {result['fourcc']}]") + return 0 + print( + f"FAIL camera {index}: {asked} -> {measured:.1f} fps " + f"(reported {result['reported_fps']:.1f}, {result['fourcc']}, " + f"{backend_name})" + ) + return 1 + print(f"No camera answered on index {index} (is the app closed?).") + return 1 + + 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") + parser.add_argument( + "--check", + action="store_true", + help="acceptance mode: measure the bundle's format once, exit 0 when it " + "delivers it (use this to go through units)", + ) args = parser.parse_args(argv) + if args.check: + return check_format(args.index if args.index is not None else 0, + samples=args.samples) + indices = [args.index] if args.index is not None else list(range(args.max_index + 1)) found = 0 for index in indices: diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index afc86ef..9eb2062 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -23,9 +23,11 @@ from orisys_app_shared.acquisition.models import ( ) from orisys_app_shared.acquisition.sdk_worker import ( CAMERA_DISCONNECT_TIMEOUT_S, + FPS_WINDOW_S, HANDOVER_TIMEOUT_S, STOP_JOIN_TIMEOUT_S, VIDEO_EOF_TIMEOUT_S, + _capture_sequence, calibration_digest, capture_status_of, loaded_calibration_version, @@ -107,6 +109,9 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._filter_state: dict = {} #: Keys the installed SDK could not apply. self._filters_unsupported: tuple[str, ...] = () + #: The capture rate this session measured for itself (the driver's + #: reported rate is an echo of the request and cannot be trusted). + self._measured_capture_fps: float = 0.0 def set_filter_settings(self, settings: Optional[FilterSettings]) -> None: """Choose the filters for the **next** session.""" @@ -128,12 +133,16 @@ class FingerAcquisitionWorker(RecordSinkMixin): return tuple(self._filters_unsupported) def capture_status(self) -> dict: - """What the camera was asked for, and what the driver actually gave.""" + """What the camera was asked for, what it gave, and what we measured.""" with self._lock: runtime = next(iter(self._runtimes.values()), None) + measured = float(self._measured_capture_fps or 0.0) tactile = getattr(runtime, "tactile", None) if runtime is not None else None raw_sensor = getattr(tactile, "raw_sensor", None) if tactile is not None else None - return capture_status_of(raw_sensor) + status = capture_status_of(raw_sensor) + if status and measured > 0.0: + status["measured_fps"] = measured + return status @property def is_running(self) -> bool: @@ -396,6 +405,12 @@ class FingerAcquisitionWorker(RecordSinkMixin): return frame_times: list[float] = [] + #: (collection time, published sequence), so the camera's own rate + #: can be told apart from the rate this loop consumes (see the box + #: worker: the two are the same measurement and must mean the same). + source_times: list[tuple[float, int]] = [] + last_source_seq = None + capture_fps = 0.0 saw_frame = False last_frame_mono = 0.0 none_streak_start: Optional[float] = None @@ -457,10 +472,30 @@ class FingerAcquisitionWorker(RecordSinkMixin): chain_depth, chain_error = process_chain(tactile) compute_error = compute_error or chain_error t1 = time.perf_counter() - frame_times.append(t1) - cutoff = t1 - 1.0 + frame_times.append(t0) + cutoff = t1 - FPS_WINDOW_S frame_times = [t for t in frame_times if t >= cutoff] - fps = float(len(frame_times)) if frame_times else 0.0 + # A *rate*, like the box view: counting frames in a one-second + # window reads one frame high, and the two views have to agree + # about what "fps" means. + span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0 + fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0 + # The camera's own rate, so this view can attribute a low rate too. + delivered = _capture_sequence(tactile) + if delivered is not None and delivered != last_source_seq: + last_source_seq = delivered + source_times.append((t0, delivered)) + source_cutoff = t0 - FPS_WINDOW_S + source_times = [ + item for item in source_times if item[0] >= source_cutoff + ] + if len(source_times) > 1: + source_span = source_times[-1][0] - source_times[0][0] + published = source_times[-1][1] - source_times[0][1] + if source_span > 0.0: + capture_fps = max(0.0, published / source_span) + with self._lock: + self._measured_capture_fps = capture_fps forces = read_forces_from_sensor( tactile, config_ref=mode.config_ref, diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 082c72d..cf757ac 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -520,6 +520,9 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._filter_state: dict = {} #: Keys the installed SDK cannot apply (an SDK older than 0.5.2). self._filters_unsupported: tuple[str, ...] = () + #: The capture rate this session measured for itself. The driver's + #: reported rate is an echo of the request, so only this can be trusted. + self._measured_capture_fps: float = 0.0 def set_filter_settings(self, settings: Optional[FilterSettings]) -> None: """Choose the filters for the **next** session. @@ -555,12 +558,17 @@ class SdkAcquisitionWorker(RecordSinkMixin): The negotiation is where a camera that cannot do the configured frame rate is decided, and nothing downstream can turn 16.7 fps back into 30. - Recording both halves is what lets a recording explain its own frame - rate instead of leaving the operator to guess. + Recording both halves — plus the rate this session *measured* — is what + lets a recording explain its own frame rate instead of leaving the + operator to guess. """ with self._lock: sensor = next(iter(self._sensors.values()), None) - return capture_status_of(sensor) + measured = float(self._measured_capture_fps or 0.0) + status = capture_status_of(sensor) + if status and measured > 0.0: + status["measured_fps"] = measured + return status @property def is_running(self) -> bool: @@ -837,6 +845,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): source_times: list[tuple[float, int]] = [] source_dropped = 0 last_source_seq: Optional[int] = None + capture_fps = 0.0 frame_period = 1.0 / max(1.0, float(mode.fps or 30.0)) # Per-session monotonic frame index: the join key shared by the video, # the CSV and the calibration records. @@ -956,7 +965,6 @@ class SdkAcquisitionWorker(RecordSinkMixin): t_done = time.perf_counter() # What the capture source published over the window, so a rate # below the camera's own can be attributed instead of guessed. - capture_fps = 0.0 if len(source_times) > 1: source_span = source_times[-1][0] - source_times[0][0] published = source_times[-1][1] - source_times[0][1] @@ -965,6 +973,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): taken = _capture_dropped(sensor) if taken is not None: source_dropped = taken + with self._lock: + # Kept for the record: "the camera delivered N fps" is the + # number a unit has to be judged by, and the driver's own + # report for it is not trustworthy. + self._measured_capture_fps = capture_fps invalid_reason = forces.get("invalid_reason") or compute_error raw_frame = _ensure_bgr(raw) # The frame the chain above actually consumed, kept as the diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index b7c489b..6dc53c0 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -325,6 +325,15 @@ def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch): assert short_capture_fallacy(echoing, measured_fps=15.0) != "" # No measured rate yet: nothing to say. assert short_capture_fallacy(echoing) == "" + # ...and the session's own measurement is part of what gets recorded, so + # a unit can be judged by the rate it really delivered. + deadline = time.perf_counter() + 2.0 + while time.perf_counter() < deadline and not worker.capture_status().get("measured_fps"): + time.sleep(0.05) + measured = float(worker.capture_status().get("measured_fps") or 0.0) + assert 20.0 <= measured <= 35.0, ( + f"the fake camera publishes at ~30 fps, recorded {measured}" + ) finally: worker.stop(wait=True) _restore(saved)