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)