From 50122d19a24734401fc237016e6c0af7e121d916 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 14 Sep 2026 13:27:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E6=91=84=E5=83=8F=E5=A4=B4=E5=8D=8F?= =?UTF-8?q?=E5=95=86=E7=BB=93=E6=9E=9C=E8=AE=B0=E8=BF=9B=20session.json?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=9C=A8=E8=BE=BE=E4=B8=8D=E5=88=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=B8=A7=E7=8E=87=E6=97=B6=E6=98=8E=E8=AF=B4=20-=20Wh?= =?UTF-8?q?at:=20sdk=5Fworker=20=E6=96=B0=E5=A2=9E=20capture=5Fstatus=5Fof?= =?UTF-8?q?(sensor)=EF=BC=88=E6=8A=8A=E9=87=87=E9=9B=86=E6=BA=90=E7=9A=84?= =?UTF-8?q?=20config=20=E4=B8=8E=20status=20=E5=90=88=E6=88=90=20{requeste?= =?UTF-8?q?d:{width,height,fps,fourcc},=20negotiated:{width,height,fps,fou?= =?UTF-8?q?rcc,backend},=20dropped=5Fframes}=EF=BC=89=E4=B8=8E=20short=5Fc?= =?UTF-8?q?apture=5Ffallacy(status)=EF=BC=88=E5=BD=93=E5=8D=8F=E5=95=86?= =?UTF-8?q?=E5=B8=A7=E7=8E=87=E4=BD=8E=E4=BA=8E=E7=94=B3=E8=AF=B7=E5=80=BC?= =?UTF-8?q?=2090%=20=E6=97=B6=E7=BB=99=E5=87=BA=E4=B8=80=E5=8F=A5=E8=AF=9D?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=EF=BC=8C=E5=90=AB=E5=88=86=E8=BE=A8=E7=8E=87?= =?UTF-8?q?/=E5=AE=9E=E9=99=85=E5=B8=A7=E7=8E=87/=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=E5=B8=A7=E7=8E=87/=E6=A0=BC=E5=BC=8F/=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=9B=E4=B8=A4=E8=80=85=E9=83=BD=E9=BD=90=E5=85=A8=E6=97=B6?= =?UTF-8?q?=E6=89=8D=E8=AF=B4=E8=AF=9D=EF=BC=89=EF=BC=9B=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=20worker=20=E9=83=BD=E5=8A=A0=20capture=5Fstatus()=20=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E5=99=A8=EF=BC=88box=20=E5=8F=96=E5=BD=93=E5=89=8D=20?= =?UTF-8?q?sensor=EF=BC=8Cfinger=20=E5=8F=96=20runtime.tactile.raw=5Fsenso?= =?UTF-8?q?r=EF=BC=89=E3=80=82app=5Fwindow=EF=BC=9Asession.json=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A1=B6=E5=B1=82=20capture=20=E5=9D=97?= =?UTF-8?q?=EF=BC=88=5Fcapture=5Fmetadata()=EF=BC=8C=E8=B5=B0=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E8=AE=BF=E9=97=AE=E5=99=A8=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20=5Fcheck=5Fcapture=5Fnegotiation()?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=AF=8F=E4=B8=AA=E4=BC=9A=E8=AF=9D=E5=9C=A8?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E7=AC=AC=E4=B8=80=E6=AC=A1=E6=8A=A5=E5=87=BA?= =?UTF-8?q?=E9=80=9F=E7=8E=87=E5=90=8E=E6=A3=80=E6=9F=A5=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=EF=BC=8C=E8=BE=BE=E4=B8=8D=E5=88=B0=E5=B0=B1=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20=5Fnotify=20=E6=98=8E=E7=A1=AE=E6=8F=90=E7=A4=BA=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E6=AF=8F=E5=B8=A7=E5=88=B7=E5=B1=8F=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E9=94=AE=20status.capture=5Fslow=EF=BC=88?= =?UTF-8?q?=E4=B8=AD=E8=8B=B1=EF=BC=89=E5=86=99=E6=98=8E"=E8=BD=AF?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E4=B8=8D=E4=BA=86=E3=80=81=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=8C=89=E5=AE=9E=E9=99=85=E5=B8=A7=E7=8E=87=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=80=81=E8=AF=B7=E9=99=8D=E5=88=86=E8=BE=A8=E7=8E=87/?= =?UTF-8?q?=E6=8D=A2=E6=A0=BC=E5=BC=8F=E6=88=96=E6=8D=A2=E6=91=84=E5=83=8F?= =?UTF-8?q?=E5=A4=B4"=E3=80=82tests/test=5Fworker=5Fframe=5Frate.py?= =?UTF-8?q?=EF=BC=9A=E5=81=87=E8=AE=BE=E5=A4=87=E8=A1=A5=E4=B8=8A=20Captur?= =?UTF-8?q?eConfig=20=E4=B8=8E=20CaptureStatus=20=E7=9A=84=20width/height/?= =?UTF-8?q?fps/fourcc/backend=5Fname=20=E5=AD=97=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20test=5Fa=5Flow=5Fcamera=5Frate=5Fis=5Freco?= =?UTF-8?q?rded=5Fas=5Fa=5Fnegotiation=EF=BC=881920x1080=E3=80=81=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=2030=E3=80=81=E5=8D=8F=E5=95=86=2016.67=20=E6=97=B6?= =?UTF-8?q?=20capture=5Fstatus()=20=E4=B8=A4=E5=8D=8A=E9=83=BD=E5=9C=A8?= =?UTF-8?q?=E3=80=81=E6=8F=8F=E8=BF=B0=E9=87=8C=E5=90=AB=201920x1080/16.67?= =?UTF-8?q?/30=EF=BC=9B=E5=8D=8F=E5=95=86=E5=88=B0=E4=BD=8D=E6=97=B6?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E4=B8=BA=E7=A9=BA=E4=B8=B2=EF=BC=89=E3=80=82?= =?UTF-8?q?=20-=20Why:=20=E7=94=A8=E6=88=B7=E6=8C=89=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E8=BD=AE=E7=9A=84=E8=AF=BB=E6=95=B0=E6=8A=A5=E5=91=8A"?= =?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=20=E2=80=94=E2=80=94=20=E8=BF=99?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E4=BA=86=E6=B5=81=E6=B0=B4=E7=BA=BF=EF=BC=88?= =?UTF-8?q?=E6=AF=8F=E5=B8=A7=E5=8F=AA=2012=20ms=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E4=BE=A7=E8=8A=82=E6=8B=8D=EF=BC=8C=E5=89=A9?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=E5=8F=AA=E8=83=BD=E6=98=AF=E9=87=87=E9=9B=86?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8D=8F=E5=95=86=EF=BC=9A=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E7=9A=84=E6=98=AF=20resolution=5Fraw=201920x?= =?UTF-8?q?1080=20@=2030=20fps=EF=BC=88fourcc=20=E8=B5=B0=20CaptureConfig?= =?UTF-8?q?=20=E9=BB=98=E8=AE=A4=20MJPG=EF=BC=89=E3=80=82=E8=BF=99?= =?UTF-8?q?=E7=B1=BB"=E9=A9=B1=E5=8A=A8=E5=8F=AA=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E8=BF=99=E4=B8=AA=E6=A0=BC=E5=BC=8F"=E7=9A=84=E6=83=85?= =?UTF-8?q?=E5=86=B5=E8=BD=AF=E4=BB=B6=E6=97=A0=E6=B3=95=E6=94=B9=E5=8F=98?= =?UTF-8?q?=EF=BC=8C=E4=BD=86=E8=AE=B0=E5=BD=95=E9=87=8C=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E7=95=99=E4=B8=8B=E8=AF=81=E6=8D=AE=E3=80=81=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=91=98=E5=BF=85=E9=A1=BB=E8=A2=AB=E5=91=8A=E7=9F=A5=EF=BC=8C?= =?UTF-8?q?=E5=90=A6=E5=88=99=E4=B8=80=E4=B8=AA=2016.7=20=E7=9A=84?= =?UTF-8?q?=E9=87=87=E9=9B=86=E5=92=8C=E4=B8=80=E6=AC=A1=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E7=9A=84=2030=20fps=20=E9=87=87=E9=9B=86=E5=9C=A8=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=87=8C=E9=95=BF=E5=BE=97=E4=B8=80=E6=A0=B7=EF=BC=8C?= =?UTF-8?q?=E4=BA=8B=E5=90=8E=E5=86=8D=E4=B9=9F=E6=97=A0=E6=B3=95=E8=A7=A3?= =?UTF-8?q?=E9=87=8A=E3=80=82=20-=20Verification:=20shared=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=20161=20passed=EF=BC=88=E6=96=B0=E5=A2=9E=201=20?= =?UTF-8?q?=E9=A1=B9=EF=BC=89=EF=BC=9Bstudio=2090=20passed=EF=BC=9Bui=5Fch?= =?UTF-8?q?eck=20554=20=E9=A1=B9=E6=A3=80=E6=9F=A5=200=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=EF=BC=9Blocale=5Fcheck=20287=20=E5=AD=97=E9=9D=A2?= =?UTF-8?q?=E9=94=AE=E5=85=A8=E9=83=A8=E5=91=BD=E4=B8=AD=E3=80=81=E4=B8=AD?= =?UTF-8?q?=E8=8B=B1=E9=94=AE=E9=9B=86=E4=B8=80=E8=87=B4=EF=BC=88343+1=3D3?= =?UTF-8?q?44=3F=20=E8=A7=81=E4=B8=8B=E6=96=B9=E9=94=AE=E6=95=B0=EF=BC=89?= =?UTF-8?q?=E3=80=82=20-=20Not=20in=20this=20change:=20=E6=9C=AA=E6=94=B9?= =?UTF-8?q?=20SDK=20=E7=9A=84=E9=87=87=E9=9B=86=E5=8F=82=E6=95=B0=EF=BC=88?= =?UTF-8?q?=E4=BB=8D=E6=98=AF=201920x1080/30/MJPG=E3=80=81=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20auto=EF=BC=89=EF=BC=9Bframes.csv=20=E4=BB=8D?= =?UTF-8?q?=E6=9C=AA=E8=AE=B0=E5=BD=95=20process=5Fms/capture=5Ffps?= =?UTF-8?q?=EF=BC=88=E4=B8=8B=E6=AC=A1=E6=94=B9=E8=AE=B0=E5=BD=95=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E6=97=B6=E4=B8=80=E5=B9=B6=E5=8A=A0=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=88?= =?UTF-8?q?=E4=BE=8B=E5=A6=82=E6=A3=80=E6=B5=8B=E5=88=B0=2016.7=20?= =?UTF-8?q?=E5=B0=B1=E8=87=AA=E5=8A=A8=E6=94=B9=E7=94=A8=E6=9B=B4=E4=BD=8E?= =?UTF-8?q?=E5=88=86=E8=BE=A8=E7=8E=87=EF=BC=89=E2=80=94=E2=80=94=E9=82=A3?= =?UTF-8?q?=E4=BC=9A=E6=94=B9=E5=8F=98=E4=BC=A0=E6=84=9F=E5=99=A8=E5=87=A0?= =?UTF-8?q?=E4=BD=95=EF=BC=8C=E5=BF=85=E9=A1=BB=E7=94=B1=E4=BA=BA=E5=86=B3?= =?UTF-8?q?=E5=AE=9A=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 | 1 + apps/studio/config/locales/zh_CN.json | 1 + apps/studio/src/orisys_studio/app_window.py | 55 ++++++++++++ .../acquisition/finger_worker.py | 9 ++ .../acquisition/sdk_worker.py | 87 ++++++++++++++++++- shared/tests/test_worker_frame_rate.py | 57 ++++++++++++ 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index bcd5cbd..fbb0c94 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -357,6 +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.", "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 993af71..b6a0197 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -357,6 +357,7 @@ "run_failed": "启动失败: {error}", "filters_saved": "滤波设置已保存:{describe}(下次采集生效)", "rail_camera_fps": "摄像头 {fps}", + "capture_slow": "摄像头达不到配置的帧率:{detail}。这不是软件能改的——驱动只提供这个格式;记录会按实际帧率保存,请降低分辨率/换格式或换摄像头。", "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", "reset_ok": "追踪器已重置", diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index f633515..ae87894 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -29,6 +29,7 @@ from PyQt5.QtWidgets import ( ) from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ +from orisys_app_shared.acquisition.sdk_worker import short_capture_fallacy from orisys_app_shared.force import select_display_forces from orisys_app_shared.force.calibration_recorder import CalibrationRecorder from orisys_app_shared.force.filters import FilterSettings, settings_from_mapping @@ -204,6 +205,9 @@ class StudioMainWindow(QMainWindow): #: window: together with the above they say who is the slow part. self._latest_capture_fps: float = 0.0 self._latest_capture_dropped: int = 0 + #: Whether this session has already reported a shortfall between the + #: requested and the negotiated capture rate (said once, not per frame). + self._capture_checked = True #: Last coefficients file written by the calibration panel (not applied). self._last_calibration_export = "" self._depth_range_value: tuple[float, float] | None = None @@ -518,6 +522,9 @@ class StudioMainWindow(QMainWindow): "shared_api": API_VERSION, "sdk": self._sdk_version(), }, + # What the camera was asked for and what the driver gave. A recording + # that runs at 16.7 fps instead of 30 has to say why by itself. + "capture": self._capture_metadata(), "calibration": { "config_ref": config_ref, "force_calibrated": bool(getattr(info, "force_calibrated", False)), @@ -605,6 +612,50 @@ class StudioMainWindow(QMainWindow): stored[key] = value return settings_from_mapping(stored) + def _capture_metadata(self) -> dict: + """Requested vs negotiated capture parameters, for ``session.json``. + + Empty while nothing is open. A recording that runs at 16.7 fps on a 30 fps + config has to be able to explain itself; from the frames alone nobody can + tell whether the camera or the pipeline was the ceiling. + """ + worker = getattr(self, "_active_worker", None) + reader = getattr(worker, "capture_status", None) if worker is not None else None + if not callable(reader): + return {} + try: + return dict(reader() or {}) + except Exception: # noqa: BLE001 - metadata must never block a record + logger.exception("Could not read the capture status") + return {} + + def _check_capture_negotiation(self) -> None: + """Say it out loud when the camera cannot do what 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. + """ + if self._capture_checked: + return + worker = getattr(self, "_active_worker", None) + reader = getattr(worker, "capture_status", None) if worker is not None else None + if not callable(reader): + return + try: + status = dict(reader() or {}) + except Exception: # noqa: BLE001 + 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 + self._capture_checked = True + detail = short_capture_fallacy(status) + if detail: + self._notify(t("status.capture_slow", detail=detail)) + def _filter_metadata(self) -> dict: """The filters this session actually ran, for ``session.json``. @@ -2133,6 +2184,7 @@ class StudioMainWindow(QMainWindow): self._latest_process_ms = None self._latest_capture_fps = 0.0 self._latest_capture_dropped = 0 + self._capture_checked = False self._depth_range_value = None self._last_depth_frame = None # The new session's store is cleared *before* the charts are attached to @@ -2222,6 +2274,7 @@ class StudioMainWindow(QMainWindow): self._latest_process_ms = None self._latest_capture_fps = 0.0 self._latest_capture_dropped = 0 + self._capture_checked = False self._last_sample_valid = None self._last_display_fallback = None self._last_display_available = None @@ -2395,6 +2448,8 @@ class StudioMainWindow(QMainWindow): self._latest_capture_dropped = int( getattr(packet, "capture_dropped", 0) or 0 ) + # Once per session, as soon as the driver has confirmed its rate. + self._check_capture_negotiation() # The rail carries the live telemetry (fps, validity, calibration) and # the record strip its own numbers. Both compare before repainting, so # this is safe on a 16 ms timer. diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index 2ef4ded..afc86ef 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -27,6 +27,7 @@ from orisys_app_shared.acquisition.sdk_worker import ( STOP_JOIN_TIMEOUT_S, VIDEO_EOF_TIMEOUT_S, calibration_digest, + capture_status_of, loaded_calibration_version, loaded_path, process_chain, @@ -126,6 +127,14 @@ class FingerAcquisitionWorker(RecordSinkMixin): with self._lock: return tuple(self._filters_unsupported) + def capture_status(self) -> dict: + """What the camera was asked for, and what the driver actually gave.""" + with self._lock: + runtime = next(iter(self._runtimes.values()), None) + 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) + @property def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive() diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index b43b8a7..bc34e05 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -11,7 +11,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path -from typing import Callable, Optional +from typing import Any, Callable, Mapping, Optional import cv2 import numpy as np @@ -84,6 +84,7 @@ __all__ = [ "SdkPacket", "SdkAcquisitionWorker", "calibration_digest", + "capture_status_of", "load_fixed_mode", "loaded_calibration_version", "loaded_path", @@ -91,6 +92,7 @@ __all__ = [ "process_chain", "read_depth", "read_forces_from_sensor", + "short_capture_fallacy", ] @@ -155,6 +157,77 @@ def _capture_source(sensor): return source +def capture_status_of(sensor) -> dict: + """Requested vs negotiated capture parameters, or ``{}`` when unknown. + + ``requested`` comes from the capture config the SDK was handed (built from the + sensor.json's ``camera`` block); ``negotiated`` is what OpenCV/the driver + reported back after the stream properties were applied. They disagree exactly + when a camera cannot do what the config asks for. + """ + source = _capture_source(sensor) if sensor is not None else None + if source is None: + return {} + status = getattr(source, "status", None) + config = getattr(source, "config", None) + if status is None and config is None: + return {} + + def _value(obj, name, default=None): + return getattr(obj, name, default) if obj is not None else default + + dropped = _value(status, "dropped_frames", 0) + return { + "requested": { + "width": _value(config, "width"), + "height": _value(config, "height"), + "fps": _value(config, "fps"), + "fourcc": _value(config, "fourcc"), + }, + "negotiated": { + "width": _value(status, "width"), + "height": _value(status, "height"), + "fps": _value(status, "fps"), + "fourcc": _value(status, "fourcc"), + "backend": _value(status, "backend_name"), + }, + "dropped_frames": int(dropped or 0), + } + + +def short_capture_fallacy(status: Mapping[str, Any] | None) -> str: + """A one-line description of a capture negotiation that fell short. + + 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. + """ + 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) + except (TypeError, ValueError): + return "" + if wanted_value <= 0 or got_value <= 0 or got_value >= wanted_value * 0.9: + return "" + size = "" + width, height = negotiated.get("width"), negotiated.get("height") + if width and height: + size = f"{int(width)}x{int(height)} " + fourcc = str(negotiated.get("fourcc") or "").strip() + backend = str(negotiated.get("backend") or "").strip() + 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 "") + ) + + def process_chain(sensor) -> tuple[Optional[np.ndarray], str]: """Run the live processing chain for one already-read frame. @@ -466,6 +539,18 @@ class SdkAcquisitionWorker(RecordSinkMixin): with self._lock: return tuple(self._filters_unsupported) + def capture_status(self) -> dict: + """What the camera was asked for, and what the driver actually gave. + + 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. + """ + with self._lock: + sensor = next(iter(self._sensors.values()), None) + return capture_status_of(sensor) + @property def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive() diff --git a/shared/tests/test_worker_frame_rate.py b/shared/tests/test_worker_frame_rate.py index 18bcf8d..ab2cc34 100644 --- a/shared/tests/test_worker_frame_rate.py +++ b/shared/tests/test_worker_frame_rate.py @@ -31,6 +31,23 @@ class _Status: def __init__(self) -> None: self.last_seq = 0 self.dropped_frames = 0 + #: What the driver reported back for the negotiated stream. + self.width = 1920 + self.height = 1080 + self.fps = 30.0 + self.fourcc = "MJPG" + self.backend_name = "MSMF" + + +class _CaptureConfig: + """The requested stream, as ``CaptureConfig`` carries it.""" + + def __init__(self) -> None: + self.width = 1920 + self.height = 1080 + self.fps = 30.0 + self.fourcc = "MJPG" + self.source = 0 class _CaptureSource: @@ -45,6 +62,7 @@ class _CaptureSource: def __init__(self, period_s: float = FRAME_PERIOD_S) -> None: self.status = _Status() + self.config = _CaptureConfig() self.running = True self._period = float(period_s) self._stop = threading.Event() @@ -265,6 +283,45 @@ def test_the_camera_s_own_rate_is_reported_next_to_ours(monkeypatch): monkeypatch.setattr(sys.modules[__name__], "PROCESS_COST_S", 0.0) +def test_a_low_camera_rate_is_recorded_as_a_negotiation(monkeypatch): + """A camera that cannot do the configured rate must be visible as such. + + The pipeline cannot turn 16.7 fps into 30, so the only honest thing left is to + record what was asked for and what the driver gave, and to have a one-line + description ready for the operator. + """ + from orisys_app_shared.acquisition.sdk_worker import ( + SdkAcquisitionWorker, + short_capture_fallacy, + ) + + CREATED.clear() + saved = _install() + worker = SdkAcquisitionWorker() + try: + worker.start(0, config_ref=BOX_CONFIG) + deadline = time.perf_counter() + 2.0 + while time.perf_counter() < deadline and not CREATED: + time.sleep(0.01) + assert CREATED, "the fake sensor was never constructed" + source = CREATED[-1].capture_source + source.status.fps = 16.67 # what the driver actually negotiated + status = worker.capture_status() + assert status["requested"]["fps"] == 30.0 + assert status["requested"]["width"] == 1920 + 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. + source.status.fps = 30.0 + assert short_capture_fallacy(worker.capture_status()) == "" + finally: + worker.stop(wait=True) + _restore(saved) + + def test_a_file_source_runs_on_the_video_s_own_timeline(): """A pass over a video is stamped with the video's time, not the wall clock.""" from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker