按"硬件问题、配置不变"收尾:记录里写下实测帧率,探针加逐颗验收模式 - What: worker 把本会话实测到的采集速率记进 self._measured_capture_fps(box 与 finger 两条循环都算:finger 的帧率也从"1 秒窗口内计数"改成 (n-1)/span 的速率,两条视图对 fps 的含义必须一致),capture_status() 因此多出 measured_fps —— session.json 的 capture 块现在同时有 requested / negotiated / measured_fps / dropped_frames,一颗摄像头到底交付了多少 fps 由记录自证。status.capture_slow 的中英文案改写:删掉已被实测否证的"降低分辨率/换格式"建议,改成"这是采集端(摄像头/驱动)的问题、软件改不了,换一颗真能交付 30 fps 的(并先确认线材/端口没把它降速)再测"。tools/camera_probe.py 新增 --check 验收模式:只测 bundle 用的那一个格式(MJPG 1920x1080@30,120 帧),按**实测**速率打印一行 PASS/FAIL 并给出退出码,用于逐颗过检;无摄像头时报 "No camera answered on index N (is the app closed?)" 并返回 1。tests:假设备用上 capture_status() 的 measured_fps(0.05 s 粒度等它出现,断言 20–35 fps)、探针 --check 的无摄像头路径。 - Why: 用户判定这是驱动/硬件问题(同型号产品此前能跑 30 fps),决定**配置不变**、以后用能到 30 fps 的产品,并在之后逐颗检测。配置不变意味着应用继续按 30 fps 申请(这是对的:申请 15 只会把问题上移),但由此产生两件必须收尾的事:(1) 提示语里"降低分辨率/换格式"是错的建议——探针已经证明 720p MJPG 一样是 15 fps、YUYV 只有 1 fps;(2) 既然要逐颗检测,就必须有一个能回答"这一颗交付了多少"的权威数字,而驱动自报值不可信(DSHOW 回读),所以把实测值写进记录并给探针一个单行验收模式。 - Verification: shared 全量 161 passed;studio 91 passed;ui_check 554 项检查 0 失败;locale_check 中英键集一致;探针 --check 在无摄像头时打印指定文案并返回 1,--help 列出该选项。 - Not in this change: 未改任何采集配置(resolution_raw/fps/fourcc 都不动);未做自动降级;frames.csv 仍未记录 process_ms/capture_fps(留到加未滤波 17 列、记录格式升版时一起)。

This commit is contained in:
ykcheungab
2026-09-14 13:48:08 +08:00
parent 30ef50814f
commit 4a482e579f
7 changed files with 115 additions and 11 deletions
+1 -1
View File
@@ -357,7 +357,7 @@
"run_failed": "Run failed: {error}", "run_failed": "Run failed: {error}",
"filters_saved": "Filter settings saved: {describe} (applies to the next session)", "filters_saved": "Filter settings saved: {describe} (applies to the next session)",
"rail_camera_fps": "camera {fps}", "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", "video_ended": "Video ended",
"camera_disconnected": "Camera disconnected", "camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset", "reset_ok": "Tracker reset",
+1 -1
View File
@@ -357,7 +357,7 @@
"run_failed": "启动失败: {error}", "run_failed": "启动失败: {error}",
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)", "filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
"rail_camera_fps": "摄像头 {fps}", "rail_camera_fps": "摄像头 {fps}",
"capture_slow": "摄像头达不到配置的帧率:{detail}。这不是软件能改的——驱动只提供这个格式;记录会按实际帧率保存,请降低分辨率/换格式或换摄像头。", "capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一颗真能交付 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。",
"video_ended": "视频已结束", "video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开", "camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置", "reset_ok": "追踪器已重置",
+4
View File
@@ -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. # The fourcc round trip is what makes the table readable.
assert camera_probe._decode(camera_probe._fourcc_code("MJPG")) == "MJPG" assert camera_probe._decode(camera_probe._fourcc_code("MJPG")) == "MJPG"
assert camera_probe._decode(0) == "-" 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 # ------------------------------------------------------- plot settings dialog
+43
View File
@@ -175,13 +175,56 @@ def size_sweep(index: int, backend_name: str, backend: int, fps: float = 30.0,
return rows 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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--index", type=int, default=None, help="camera index to probe") 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("--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("--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) 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)) indices = [args.index] if args.index is not None else list(range(args.max_index + 1))
found = 0 found = 0
for index in indices: for index in indices:
@@ -23,9 +23,11 @@ from orisys_app_shared.acquisition.models import (
) )
from orisys_app_shared.acquisition.sdk_worker import ( from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S, CAMERA_DISCONNECT_TIMEOUT_S,
FPS_WINDOW_S,
HANDOVER_TIMEOUT_S, HANDOVER_TIMEOUT_S,
STOP_JOIN_TIMEOUT_S, STOP_JOIN_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S, VIDEO_EOF_TIMEOUT_S,
_capture_sequence,
calibration_digest, calibration_digest,
capture_status_of, capture_status_of,
loaded_calibration_version, loaded_calibration_version,
@@ -107,6 +109,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
self._filter_state: dict = {} self._filter_state: dict = {}
#: Keys the installed SDK could not apply. #: Keys the installed SDK could not apply.
self._filters_unsupported: tuple[str, ...] = () 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: def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
"""Choose the filters for the **next** session.""" """Choose the filters for the **next** session."""
@@ -128,12 +133,16 @@ class FingerAcquisitionWorker(RecordSinkMixin):
return tuple(self._filters_unsupported) return tuple(self._filters_unsupported)
def capture_status(self) -> dict: 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: with self._lock:
runtime = next(iter(self._runtimes.values()), None) 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 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 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 @property
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -396,6 +405,12 @@ class FingerAcquisitionWorker(RecordSinkMixin):
return return
frame_times: list[float] = [] 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 saw_frame = False
last_frame_mono = 0.0 last_frame_mono = 0.0
none_streak_start: Optional[float] = None none_streak_start: Optional[float] = None
@@ -457,10 +472,30 @@ class FingerAcquisitionWorker(RecordSinkMixin):
chain_depth, chain_error = process_chain(tactile) chain_depth, chain_error = process_chain(tactile)
compute_error = compute_error or chain_error compute_error = compute_error or chain_error
t1 = time.perf_counter() t1 = time.perf_counter()
frame_times.append(t1) frame_times.append(t0)
cutoff = t1 - 1.0 cutoff = t1 - FPS_WINDOW_S
frame_times = [t for t in frame_times if t >= cutoff] 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( forces = read_forces_from_sensor(
tactile, tactile,
config_ref=mode.config_ref, config_ref=mode.config_ref,
@@ -520,6 +520,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
self._filter_state: dict = {} self._filter_state: dict = {}
#: Keys the installed SDK cannot apply (an SDK older than 0.5.2). #: Keys the installed SDK cannot apply (an SDK older than 0.5.2).
self._filters_unsupported: tuple[str, ...] = () 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: def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
"""Choose the filters for the **next** session. """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 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. 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 Recording both halves — plus the rate this session *measured* — is what
rate instead of leaving the operator to guess. lets a recording explain its own frame rate instead of leaving the
operator to guess.
""" """
with self._lock: with self._lock:
sensor = next(iter(self._sensors.values()), None) 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 @property
def is_running(self) -> bool: def is_running(self) -> bool:
@@ -837,6 +845,7 @@ class SdkAcquisitionWorker(RecordSinkMixin):
source_times: list[tuple[float, int]] = [] source_times: list[tuple[float, int]] = []
source_dropped = 0 source_dropped = 0
last_source_seq: Optional[int] = None last_source_seq: Optional[int] = None
capture_fps = 0.0
frame_period = 1.0 / max(1.0, float(mode.fps or 30.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, # Per-session monotonic frame index: the join key shared by the video,
# the CSV and the calibration records. # the CSV and the calibration records.
@@ -956,7 +965,6 @@ class SdkAcquisitionWorker(RecordSinkMixin):
t_done = time.perf_counter() t_done = time.perf_counter()
# What the capture source published over the window, so a rate # What the capture source published over the window, so a rate
# below the camera's own can be attributed instead of guessed. # below the camera's own can be attributed instead of guessed.
capture_fps = 0.0
if len(source_times) > 1: if len(source_times) > 1:
source_span = source_times[-1][0] - source_times[0][0] source_span = source_times[-1][0] - source_times[0][0]
published = source_times[-1][1] - source_times[0][1] published = source_times[-1][1] - source_times[0][1]
@@ -965,6 +973,11 @@ class SdkAcquisitionWorker(RecordSinkMixin):
taken = _capture_dropped(sensor) taken = _capture_dropped(sensor)
if taken is not None: if taken is not None:
source_dropped = taken 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 invalid_reason = forces.get("invalid_reason") or compute_error
raw_frame = _ensure_bgr(raw) raw_frame = _ensure_bgr(raw)
# The frame the chain above actually consumed, kept as the # The frame the chain above actually consumed, kept as the
+9
View File
@@ -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) != "" assert short_capture_fallacy(echoing, measured_fps=15.0) != ""
# No measured rate yet: nothing to say. # No measured rate yet: nothing to say.
assert short_capture_fallacy(echoing) == "" 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: finally:
worker.stop(wait=True) worker.stop(wait=True)
_restore(saved) _restore(saved)