Files
ESP32S3_vt/client/ui/camera.slint
T
Horldsence 4f893ce140 ESP32-S3 摄像头图传:固件 + Slint 客户端 + 共享协议
两块 ESP32-S3-WROOM-1-N16R8 图传板(DVP 接口接 OV2640/OV3660/OV5640),
向队伍客户端与裁判终端推 UDP 图传。固件是跑在 ESP-IDF 上的 Rust,用官方
esp32-camera 组件采集;客户端是 Rust + Slint 桌面程序。

三个 crate 各自独立构建:
  .            firmware,交叉编译到 Xtensa
  protocol/    线路格式与帧重组,两端共用,无 ESP-IDF 依赖,有单元测试
  client/      Slint 桌面客户端

视频走 UDP 而非 TCP:实测传感器产出 22 fps,TCP MJPEG 只送出 1.9 fps,
且只占用链路能力的 1%——链路是空的,帧堵在队头阻塞后面等一个实时画面
等不起的重传计时器。改为独立数据报、不重传、缺片丢整帧后达到 20.8 fps。

发现是双向的:板子每 2 秒广播 Hello,客户端广播 Subscribe,板子据此定向
推流。板子身份由 MAC 派生,重启不变,所以客户端可以把绑定存进配置。

裁判端同时收两块板子(每个发送方一个重组器),队伍端绑定其中一块并只显示
自己那一边的血量。装甲板接口已预留(ArmorSource trait + ARMOR_PORT),
当前无数据源,界面显示占位符。

vendor 了 esp32-camera 2.1.7:组件注册表的版本求解器找不到它,且需要打一处
补丁(见 components/esp32-camera/target/esp32s3/ll_cam.c)。

.gitignore 三条 target/ 都锚定在根目录,因为 protocol/ 与 client/ 各自也是
独立 crate;而 components/esp32-camera/target/ 是上游的按芯片分目录,里面是
源码,必须保留。
2026-10-05 18:22:19 +08:00

416 lines
14 KiB
Plaintext
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.
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
export struct CameraStats {
// 在线状态
online: bool,
status-text: string,
// 实时测量
fps: string,
resolution: string,
frame-bytes: string,
// 累计
frames-shown: string,
frames-dropped: string,
loss-percent: string,
datagrams: string,
// 帧间隔,用于把"卡"和"帧率低"区分开
gap-ms: string,
}
/* 一边的血量(4 块装甲板)。
*
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
* 现场看到 0 会以为对方已经没血了。 */
export struct TeamHealth {
team-name: string,
available: bool,
// 占位符文案
placeholder: string,
// 每块装甲板的血量;available 时有效
hps: [int],
}
/* 首次运行时的摄像头选择。
*
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
export struct CameraChoice {
id-text: string,
addr-text: string,
online: bool,
}
// ---------------------------------------------------------------- 队伍端
/*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
preferred-width: 1000px;
preferred-height: 720px;
in property <image> frame;
in property <CameraStats> stats;
in property <TeamHealth> health;
// 选择绑定时显示,其余时候隐藏
in property <[CameraChoice]> choices;
in property <bool> choosing;
// 选中了列表里的第 N 项。
//
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
// 信息——.slint 里的模型只是给界面看的。
callback pick(int);
background: #101014;
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
if !root.choosing: VerticalLayout {
spacing: 0;
HorizontalLayout {
padding: 0;
spacing: 0;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
frame := Image {
source: root.frame;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 底部一条:左边自己这边的血量,右边这一路的统计。
Rectangle {
background: #1c1c22;
height: 72px;
HorizontalLayout {
padding: 14px;
spacing: 24px;
VerticalLayout {
padding: 0;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
}
// 装甲板还没接上,所以是一排占位而不是四个数字。
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health.hps: Rectangle {
width: 34px;
height: 12px;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #33333c;
background: root.health.available ? #7ddc7d : #1a1a20;
}
}
}
VerticalLayout {
padding: 0;
spacing: 2px;
preferred-width: 320px;
Text {
text: root.stats.status-text + " " + root.stats.fps + " fps "
+ root.stats.resolution + " " + root.stats.frame-bytes;
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
}
Text {
text: "shown " + root.stats.frames-shown
+ " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
+ " gap " + root.stats.gap-ms;
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 16px;
Text {
text: "选择要观看的摄像头";
color: #e8e8ec;
font-size: 20px;
font-weight: 600;
}
Text {
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
color: #8a8a95;
font-size: 13px;
wrap: word-wrap;
}
Rectangle { height: 8px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
for choice[index] in root.choices: Rectangle {
background: choice.online ? #1c1c22 : #16161b;
border-width: 1px;
border-color: #2a2a33;
height: 56px;
TouchArea {
clicked => {
root.pick(index);
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px;
Text {
text: choice.id-text;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: left;
}
Text {
text: choice.addr-text;
color: #8a8a95;
font-size: 12px;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
}
Text {
text: choice.online ? "在线" : "离线";
color: choice.online ? #7ddc7d : #8a8a95;
font-size: 12px;
vertical-alignment: center;
}
}
}
// 一块都没发现时给出可操作的提示,而不是一个空列表。
if root.choices.length == 0: Rectangle {
background: #1a1418;
border-width: 1px;
border-color: #4a2a35;
height: 64px;
Text {
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
color: #e0a060;
font-size: 13px;
wrap: word-wrap;
vertical-alignment: center;
}
}
}
}
// ---------------------------------------------------------------- 裁判端
/* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1400px;
preferred-height: 800px;
in property <image> frame-a;
in property <image> frame-b;
in property <CameraStats> stats-a;
in property <CameraStats> stats-b;
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排,各占一半。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-a := Image {
source: root.frame-a;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
background: #0a0a0d;
vertical-stretch: 1;
horizontal-stretch: 1;
frame-b := Image {
source: root.frame-b;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 每路画面下方的队伍条:名字、血量、统计。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-a.team-name + " " + root.stats-a.status-text
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
color: root.stats-a.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-a.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-a.available ? #4a4a55 : #33333c;
background: root.health-a.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-a.frames-shown
+ " dropped " + root.stats-a.frames-dropped
+ " (" + root.stats-a.loss-percent + ")"
+ " gap " + root.stats-a.gap-ms;
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
Rectangle {
background: #1c1c22;
height: 78px;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-b.team-name + " " + root.stats-b.status-text
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
color: root.stats-b.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-b.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-b.available ? #4a4a55 : #33333c;
background: root.health-b.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-b.frames-shown
+ " dropped " + root.stats-b.frames-dropped
+ " (" + root.stats-b.loss-percent + ")"
+ " gap " + root.stats-b.gap-ms;
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 26px;
Text {
text: root.health-a.placeholder;
color: #6a6a75;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}