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