按"硬件问题、配置不变"收尾:记录里写下实测帧率,探针加逐颗验收模式 - 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:
@@ -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",
|
||||
|
||||
@@ -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": "追踪器已重置",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user