Files
ESP32S3_vt/desktop/core/src/stats.rs
T
Horldsence a89d7ae5fb desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层:

- desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按
  发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。
- desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、
  每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。
- protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来——
  装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选"
  不需要改固件。

几个改对了的旧问题:

- 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位
  会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。
- 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出
  几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。
- 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了"
  会被读成"密码错了"。现在要求盐与摘要都在。
- 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。
  现在先收尾上一段再开新的,并且重名往后排号。
- 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面**
  不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。

测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用
ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
2026-10-14 19:14:16 +08:00

229 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 帧率统计。
//!
//! # 必须按"帧数除以墙钟",不是"帧间隔的平均"
//!
//! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。
//!
//! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧
//! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏——
//! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场
//! 会以为还有性能余量,然后花时间去查一个不存在的问题。
//!
//! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印),
//! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。
//! 现在它的正确性由一个直接对着数字写的测试盯着。
//!
//! # 时钟是可注入的
//!
//! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用
//! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。
use std::time::{Duration, Instant};
/// 帧率统计窗口。默认 2 秒。
pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2);
/// 某一路的帧率窗口。
#[derive(Debug, Clone)]
pub struct FpsWindow {
window: Duration,
started: Instant,
/// 上一次结算时的累计帧数。
frames: u64,
/// 已经结算过几个窗口。
///
/// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]),
/// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个
/// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。
completed: u32,
fps: f32,
}
impl Default for FpsWindow {
fn default() -> Self {
Self::new(DEFAULT_WINDOW)
}
}
impl FpsWindow {
pub fn new(window: Duration) -> Self {
Self::starting_at(window, Instant::now())
}
/// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。
///
/// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的
/// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会
/// 报出几百 fps 的假数字。
pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self {
Self {
window,
started,
frames: frames_shown,
completed: 0,
fps: 0.0,
}
}
/// 窗口从指定的时刻开始,基线为 0。测试用。
pub fn starting_at(window: Duration, started: Instant) -> Self {
Self::seeded(window, started, 0)
}
/// 最近一次结算出的帧率。窗口还没满时是 0。
pub fn fps(&self) -> f32 {
self.fps
}
/// 窗口是否已经结算过至少一次。
///
/// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子
/// 卡死了"。
pub fn has_reading(&self) -> bool {
self.completed > 0
}
/// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。
///
/// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点:
/// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。
pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option<f32> {
let elapsed = now.saturating_duration_since(self.started);
if elapsed < self.window {
return None;
}
self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32();
self.frames = frames_shown;
self.started = now;
self.completed += 1;
Some(self.fps)
}
/// 换了一路画面:窗口重开。
///
/// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来
/// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条
/// 规则现在收在这里。
pub fn reset(&mut self, now: Instant) {
self.started = now;
self.frames = 0;
self.completed = 0;
self.fps = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_is_frames_over_wallclock() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
// 2 秒里到了 44 帧 = 22 fps。
let fps = window
.observe(44, t0 + Duration::from_secs(2))
.expect("the window is full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn a_half_full_window_reports_nothing() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None);
assert_eq!(window.fps(), 0.0);
assert!(!window.has_reading());
}
#[test]
fn a_stalled_stream_reports_zero_not_a_stale_number() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(44, t0 + Duration::from_secs(2));
// 板子不出帧了:累计值不变,下一个窗口必须报 0。
let fps = window
.observe(44, t0 + Duration::from_secs(4))
.expect("second window");
assert_eq!(fps, 0.0);
}
#[test]
fn averaging_frame_gaps_is_the_wrong_answer() {
// 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。
//
// 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里
// 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧
// (30fps 上下),而墙钟给出的才是真实的 22fps。
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
// 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。
let fps = window
.observe(44, t0 + Duration::from_secs(2))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}");
assert!(
fps < 25.0,
"a frame-gap average would have reported ~30fps here"
);
}
#[test]
fn a_completed_window_is_not_a_reading_yet_until_it_is_full() {
// 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。
let t0 = Instant::now();
let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000);
assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None);
assert!(!window.has_reading());
// 2 秒后:这 2 秒里到了 44 帧。
let fps = window
.observe(9_044, t0 + Duration::from_secs(2))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn resetting_clears_the_cumulative_count() {
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(1000, t0 + Duration::from_secs(2));
// 换了一路画面:累计值从 0 重新开始。
window.reset(t0 + Duration::from_secs(2));
let fps = window
.observe(44, t0 + Duration::from_secs(4))
.expect("window full");
assert!((fps - 22.0).abs() < 0.001, "got {fps}");
}
#[test]
fn a_counter_that_goes_backwards_does_not_underflow() {
// 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用
// u64 回绕算出一个天文数字。
let t0 = Instant::now();
let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0);
window.observe(500, t0 + Duration::from_secs(2));
let fps = window
.observe(10, t0 + Duration::from_secs(4))
.expect("window full");
assert_eq!(fps, 0.0);
}
}