Files
ykcheungab 4a482e579f 按"硬件问题、配置不变"收尾:记录里写下实测帧率,探针加逐颗验收模式 - 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 列、记录格式升版时一起)。
2026-09-14 13:48:08 +08:00

305 lines
11 KiB
Python

"""Ask each camera what it can actually deliver, without the app in the way.
The studio's sensor configs pin the capture at 1920x1080 @ 30 fps (the tile grid
is 5x3 tiles of 384x360, so the size is structural), and nothing downstream can
raise a frame rate the driver never offered. When a session runs at ~15-17 fps,
there are three questions to separate, and this script answers all three:
1. what does the driver *report* for a given request?
2. what does it actually **deliver**? (Measured from 60 reads. On DSHOW the
reported figure is a liar: ``CAP_PROP_FPS`` echoes back whatever was set, so a
camera that advertises 30 and delivers 15 reports "30.0".)
3. does the delivered rate follow the request? The sweep at the end asks for
60/30/20/15/10 fps at one format. A rate that *halves* with the request means
the request path is wrong; a rate that sticks at the same number whatever is
asked means that number is the hardware's ceiling.
Run it with the venv interpreter, with the app closed (two processes cannot own
the same camera)::
python apps/studio/tools/camera_probe.py # every camera
python apps/studio/tools/camera_probe.py --index 0 # just one
python apps/studio/tools/camera_probe.py --samples 120 # longer measurement
"""
from __future__ import annotations
import argparse
import sys
import time
import cv2
#: What the sensor bundles ask for, and the alternatives worth trying when the
#: delivered rate falls short of the requested one.
REQUESTS: tuple[tuple[str, int, int, float], ...] = (
("MJPG", 1920, 1080, 30.0),
("MJPG", 1920, 1080, 0.0), # "whatever the driver prefers at this size"
("MJPG", 1280, 720, 30.0),
("YUYV", 1920, 1080, 30.0),
("YUYV", 1280, 720, 30.0),
)
BACKENDS: tuple[tuple[str, int], ...] = (
("DSHOW", cv2.CAP_DSHOW),
("MSMF", cv2.CAP_MSMF),
)
#: Frame rates asked for at one fixed format, to see whether the delivered rate
#: follows the request or ignores it.
SWEEP_RATES: tuple[float, ...] = (60.0, 30.0, 20.0, 15.0, 10.0)
#: Sizes asked for at one fixed rate. This is what separates the two remaining
#: explanations for a low rate: "the link cannot carry the pixels" (smaller
#: frames go faster) from "this camera is a 15 fps device" (the rate does not
#: move at all).
SWEEP_SIZES: tuple[tuple[int, int], ...] = (
(1920, 1080),
(1280, 720),
(960, 540),
(848, 480),
(640, 480),
)
SAMPLES = 60
#: A request is "missed" when the measured rate is below this fraction of it.
TOLERANCE = 0.9
def _fourcc_code(text: str) -> int:
return cv2.VideoWriter_fourcc(*text)
def _decode(code: float) -> str:
value = int(code)
if value <= 0:
return "-"
letters = "".join(chr((value >> (8 * index)) & 0xFF) for index in range(4))
return letters if letters.isprintable() else f"0x{value:08x}"
def probe(
index: int,
backend_name: str,
backend: int,
request: tuple[str, int, int, float],
*,
samples: int = SAMPLES,
) -> dict | None:
"""Open, ask for one format, and measure what actually comes out."""
fourcc, width, height, fps = request
cap = cv2.VideoCapture(index, backend)
if not cap.isOpened():
cap.release()
return None
try:
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
if fps > 0:
cap.set(cv2.CAP_PROP_FPS, float(fps))
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
for _ in range(5): # let the format settle before believing anything
cap.grab()
started = time.perf_counter()
taken = 0
for _ in range(samples):
ok, frame = cap.read()
if not ok or frame is None:
break
taken += 1
span = time.perf_counter() - started
got_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
got_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
reported = cap.get(cv2.CAP_PROP_FPS)
got_cc = _decode(cap.get(cv2.CAP_PROP_FOURCC))
finally:
cap.release()
return {
"asked": (fourcc, width, height, fps),
"backend": backend_name,
"fourcc": got_cc,
"width": got_w,
"height": got_h,
"reported_fps": reported,
"measured_fps": (taken / span) if span > 0 else 0.0,
"taken": taken,
"samples": samples,
}
def format_row(result: dict) -> str:
fourcc, width, height, fps = result["asked"]
asked = f"{fourcc} {width}x{height}" + (f"@{fps:g}" if fps else "@auto")
measured = result["measured_fps"]
missed = ""
if fps > 0 and measured < fps * TOLERANCE:
missed = " <-- misses the request"
return (
f" {asked:<20} -> {result['fourcc']} {result['width']}x{result['height']} "
f"reported {result['reported_fps']:5.1f} measured {measured:5.1f} fps "
f"({result['taken']}/{result['samples']}) [{result['backend']}]{missed}"
)
def sweep(index: int, backend_name: str, backend: int, size: tuple[int, int],
*, samples: int = SAMPLES) -> list[dict]:
"""Ask for a range of rates at one format: does the delivery follow?"""
width, height = size
rows = []
for rate in SWEEP_RATES:
result = probe(
index, backend_name, backend, ("MJPG", width, height, rate),
samples=samples,
)
if result is not None:
rows.append(result)
return rows
def size_sweep(index: int, backend_name: str, backend: int, fps: float = 30.0,
*, samples: int = SAMPLES) -> list[dict]:
"""Ask for one rate at shrinking sizes: bandwidth, or a fixed ceiling?
A link that cannot carry the pixels gets faster as the frames get smaller. A
camera whose own rate is 15 fps delivers 15 whatever it is asked for.
"""
rows = []
for width, height in SWEEP_SIZES:
result = probe(
index, backend_name, backend, ("MJPG", width, height, fps),
samples=samples,
)
if result is not None:
rows.append(result)
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:
header = False
first_size: tuple[int, int] | None = None
first_backend: tuple[str, int] | None = None
for backend_name, backend in BACKENDS:
for request in REQUESTS:
result = probe(index, backend_name, backend, request, samples=args.samples)
if result is None:
continue
if not header:
print(f"\ncamera {index}:")
header = True
print(format_row(result))
if first_size is None and request[0] == "MJPG" and request[3] > 0:
first_size = (request[1], request[2])
first_backend = (backend_name, backend)
if header and first_size is not None and first_backend is not None:
print(
f"\n sweep at MJPG {first_size[0]}x{first_size[1]} "
f"[{first_backend[0]}] -- does the delivered rate follow the request?"
)
rows = sweep(index, first_backend[0], first_backend[1], first_size,
samples=args.samples)
for result in rows:
asked = result["asked"][3]
measured = result["measured_fps"]
verdict = ""
if asked > 0 and measured > 0:
ratio = measured / asked
if ratio < 0.35:
verdict = f" -> {ratio:.2f}x the request"
elif ratio > 0.9:
verdict = " -> follows the request"
print(
f" asked {asked:5.1f} fps -> measured {measured:5.1f} fps"
f"{verdict}"
)
rates = [r["measured_fps"] for r in rows if r["measured_fps"] > 0]
if rates:
print(
f" ceiling seen here: {max(rates):.1f} fps "
f"(a ceiling that does not move with the request is the "
f"hardware's, not the request's)"
)
print(
f"\n sizes at MJPG@{30:g} fps [{first_backend[0]}] -- bandwidth, "
f"or a fixed ceiling?"
)
for result in size_sweep(
index, first_backend[0], first_backend[1], samples=args.samples
):
width, height = result["asked"][1], result["asked"][2]
print(
f" {width}x{height:<5} -> measured {result['measured_fps']:5.1f} fps"
)
print(
" smaller frames going faster means the link is the limit; the "
"same number at every size means the camera is"
)
if header:
found += 1
if not found:
print("No camera answered on indices 0..%d." % args.max_index)
print(
"That is expected on a machine with no camera attached. On the machine "
"with the sensor, run it with the app closed: two processes cannot own "
"the same camera."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())