diff --git a/README.md b/README.md index 5f8d801..277549f 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,18 @@ - 版本化插件注册表、检测模型注册表和 Python entry point 扩展机制; - cmvr-es RGB 相机 gRPC Source(带退避上限的指数重连)、AGV gRPC Sink; - PyAV H264/H265 有状态解码、通用检测模型节点和重复命中规则节点; -- Construction PPE YOLOv8 与六类 PPE YOLOv8n 模型注册,以及按标签、置信度和最大 FPS 的部署配置; +- Construction PPE、六类 PPE YOLOv8n、People-Talking YOLOv8x 与 YOLOv8n Mobile Phone 模型注册,以及按标签、置信度和最大 FPS 的部署配置; - 平台 HTTP JSON Sink,支持有限重试、`raise/log_and_drop` 失败策略,并使用告警 `event_id` 作为幂等键; - `RobotCommand -> ApprovedRobotCommand` 安全门和无重试的类型化 AGV 命令映射; - 文本/JSON 日志、共享 gRPC Channel 与 HTTP Client; -- 可直接执行的最小测试 fixture,以及包含 PPE 检测和对话占位链路的统一部署配置。 +- 可直接执行的最小测试 fixture,以及包含 PPE/电话使用检测和对话占位链路的统一部署配置。 `configs/edge_ai.yaml` 是统一部署配置,其中同时定义 `detection` 和 `talk` 两个 -Pipeline。`detection` 当前只启用 Construction PPE 模型,并经过时间窗口规则向 8081 -上报告警。六类 PPE 模型及第二 HTTP 平台的实现仍保留在注册表和连接器中,但不在当前 -Pipeline 图中实例化,因此不会加载第二份权重、执行第二次推理或访问 8082。 +Pipeline。`detection` 从同一个相机 decoder 并行运行 Construction PPE 与 +People-Talking 两个 detector,各自经过独立时间窗口规则后向同一个告警平台上报; +`model_id` 和 `rule_id` 用于区分事件。六类 PPE 模型仍保留在注册表中,但不在当前 +Pipeline 图中实例化。 VAD/ASR/LLM/TTS 尚未内置;`talk` 仍使用模拟音频数据,等待 cmvr-es 音频双向流 proto 落地。 @@ -49,14 +50,16 @@ cmvr_edge_ai/ ├── configs/ │ ├── edge_ai.yaml # detection + talk 统一部署配置 │ └── debug/ -│ └── detection_viewer.yaml # 远端相机 -> PPE 检测 -> 本地画框窗口 +│ └── detection_viewer.yaml # 远端相机 -> PPE+电话检测 -> 合并画框窗口 ├── detect_server/ -│ ├── README.md # PPE 检测链路与 Viewer 使用说明 +│ ├── README.md # PPE/电话检测链路与 Viewer 使用说明 │ └── show_detections.py # OpenCV 实时检测结果 Demo ├── models/ │ └── detection/ # 按模型 ID/版本组织的检测模型制品库 │ ├── construction-ppe-yolov8/v1/ # best.pt + 独立 model card -│ └── ppe-6classes-yolov8n/v1/ # best.pt + 独立 model card +│ ├── ppe-6classes-yolov8n/v1/ # best.pt + 独立 model card +│ ├── people-talking-yolov8x/v1/ # best.pt + 独立 model card +│ └── yolov8n-mobile-phone/ # Viewer 手机目标检测权重 + model card ├── talk_server/ │ └── nodes/ # 对话插件预留目录 ├── scripts/ @@ -160,7 +163,7 @@ CLI 的四个子命令如下: 链路;需要同进程运行两条链路时,可以重复传两个 `--pipeline`。如果完全省略 `--pipeline`,运行时会启动配置中所有 `enabled: true` 的 Pipeline。 -## 运行 PPE 检测链路 +## 运行视觉检测链路 默认 bootstrap 就是当前 YAML 使用的 CPU 检测环境。它会从相邻的 `../cmvr-es` 读取 proto、用锁定的 `grpcio-tools` 生成 bindings,然后安装完整 @@ -221,6 +224,20 @@ pipelines: alert_image: enabled: true jpeg_quality: 85 + phone_detector: + with: + model: people-talking-yolov8x@1 + detect_labels: [talking on phone] + max_fps: 5 + attach_frame: true + model_options: + weights: models/detection/people-talking-yolov8x/v1/best.pt + device: cpu + phone_repeat_gate: + with: + alert_image: + enabled: true + jpeg_quality: 85 alert_platform: with: endpoint: ppe_alert_platform @@ -240,16 +257,18 @@ uv run --no-sync cmvr-edge-ai run --config configs/edge_ai.yaml \ --log-format json ``` -启动后,detector 会先输出一条 `detection model loaded`,表示对应权重已经成功 -加载。收到解码帧并完成真实 `predict` 后,会立即输出第一条 `detection inference`,之后 +启动后,`detector` 和 `phone_detector` 会分别输出一条 `detection model loaded`,表示 +两份权重已经成功加载。收到解码帧并完成真实 `predict` 后,会分别输出 +`detection inference`,之后 按 `inference_log_interval_s` 聚合输出一次;其中 `window_frames` 是本周期推理帧数, `window_detections` 是检测框总数,`hit_labels` 是各标签的检测框累计数。持续只有 loaded 而没有 inference,说明相机或 decoder 尚未把帧送到模型;inference 中 detection 为 0 只表示 当前阈值下没有命中。短时调试可设为 `1` 秒,生产环境可设为 `30`~`60` 秒,省略则关闭 周期推理日志。这里使用标准日志而不是裸 `print`,因此与 `--log-format json` 兼容。 -如果需要直接观察每次推理对应的画框图像,使用独立的 OpenCV Demo。它连接同一个 -cmvr-es gRPC CameraService,但不经过重复触发规则,也不会访问 HTTP 平台: +如果需要直接观察两个模型在同一帧上的画框结果,使用独立的 OpenCV Demo。它连接同一个 +cmvr-es gRPC CameraService,共享解码帧并行执行 PPE 与 YOLOv8n Mobile Phone,再把两个结果 +合并到一个窗口;它不经过重复触发规则,也不会访问 HTTP 平台: ```bash uv run --no-sync python detect_server/show_detections.py \ @@ -258,8 +277,10 @@ uv run --no-sync python detect_server/show_detections.py \ --log-level INFO ``` -运行前在 `configs/debug/detection_viewer.yaml` 中配置远端地址、`device_id` 和模型权重;按 `q` 或 `Esc` -退出。详细说明见 [detect_server/README.md](detect_server/README.md#实时画框-demo)。 +运行前在 `configs/debug/detection_viewer.yaml` 中配置远端地址、`device_id` 和两份模型 +权重;按 `q` 或 `Esc` 退出。框标签使用 `PPE:`/`PHONE:` 前缀,手机框标签为 +`PHONE:mobile_phone`,窗口刷新率由较慢的模型分支决定。详细说明见 +[detect_server/README.md](detect_server/README.md#实时画框-demo)。 相机连接器会在每次首次连接或重连时先发 `CameraService.StartCamera`,收到成功反馈后 才建立 `GetRGBImageStream`。终端会依次出现 `camera start requested/succeeded`、 @@ -267,24 +288,28 @@ uv run --no-sync python detect_server/show_detections.py \ 如果只有 opening 而没有 first frame,progress 中会持续显示 `first_frame_received=false window_frames=0`,用于区分“RPC 已建立但相机没有出帧”。 -运行前需要确认 Construction PPE 权重存在、cmvr-es 已启用 `right_hand_cam`,8081 平台的 +运行前需要确认 Construction PPE 与 People-Talking 权重存在、cmvr-es 已启用 `right_hand_cam`,8081 平台的 `/v1/detection-alerts` 可访问。默认 `yolo-cpu` profile 将 PyTorch 2.7.0 和 torchvision 0.22.0 绑定到官方 CPU wheel,并固定 checkpoint 记录的 Ultralytics -8.4.31。`model_options.device` 在该环境中应保持 `cpu`,`half` 应保持 `false`。 +8.4.31;同时显式安装 People-Talking checkpoint 反序列化所需的 `dill`。 +`model_options.device` 在该环境中应保持 `cpu`,`half` 应保持 `false`。 x86 CUDA 和 Jetson/JetPack 的 PyTorch wheel 与驱动强绑定,不能复用这个 CPU profile。项目保留了不绑定 CPU index 的 `yolo` extra 作为设备专用环境的基础,但 GPU 部署前应为目标设备建立单独的 uv source/lock(或使用 NVIDIA 容器),再把 YAML 中的 `device` 改为 `cuda:0`;不要只改 YAML 就认为 CUDA 环境已经就绪。 -`detection.model@1` 根据 `model` 从 `DetectionModelRegistry` 解析模型。`detect_labels` 只选择需要检测的标签,省略时检测注册模型的全部标签;`confidence` 是全局阈值,也可以用 `label_confidence` 为个别标签覆盖。当前内置 `construction-ppe-yolov8@1` 的 19 个标签和 `ppe-6classes-yolov8n@1` 的 6 个标签都可以通过 `cmvr-edge-ai models` 查看。两个模型制品和独立 model card 位于仓库内: +`detection.model@1` 根据 `model` 从 `DetectionModelRegistry` 解析模型。`detect_labels` 只选择需要检测的标签,省略时检测注册模型的全部标签;`confidence` 是全局阈值,也可以用 `label_confidence` 为个别标签覆盖。当前四个内置模型都可以通过 `cmvr-edge-ai models` 查看,模型制品和独立 model card 位于仓库内: - [Construction PPE YOLOv8 v1](models/detection/construction-ppe-yolov8/v1/README.md):包含正向 PPE、`No-*` 违规类和施工现场设备类; - [PPE YOLOv8n 6 Classes v1](models/detection/ppe-6classes-yolov8n/v1/README.md):轻量的六类正向装备检测模型。 +- [People Talking YOLOv8x v1](models/detection/people-talking-yolov8x/v1/README.md):电话使用行为检测;Pipeline 只选择 `talking on phone` 类。 +- [YOLOv8n Mobile Phone v1](models/detection/yolov8n-mobile-phone/README.md):单类手机目标检测;Debug Viewer 使用 `mobile_phone` 类。 -注册 ID 中的 `@1` 与制品目录的 `v1` 对应;这是项目的版本组织约定,实际 -`weights` 路径仍由部署 YAML 显式指定。标签顺序、训练指标、局限和许可声明以上述 -model card 为准。`attach_frame: true` 让检测结果临时携带对应的解码帧,供后续告警节点使用;因此原 detector 到 repeat gate 的队列应保持较小,避免堆积未压缩图像。 +注册 ID 中的 `@1` 表示版本 1;多数制品使用 `v1` 子目录,当前 YOLOv8n Mobile +Phone 保留导入时的扁平目录和上游文件名。实际 `weights` 路径仍由部署 YAML 显式 +指定。标签顺序、训练指标、局限和许可声明以上述 model card 为准。 +`attach_frame: true` 让检测结果临时携带对应的解码帧,供后续告警节点使用;因此原 detector 到 repeat gate 的队列应保持较小,避免堆积未压缩图像。 六类模型的标签是 `Gloves`、`Vest`、`goggles`、`helmet`、`mask` 和 `safety_shoe`, 语义是“画面中检测到了该装备”,不是“人员缺少该装备”。它没有 `Person` 或 `No-*` @@ -292,8 +317,9 @@ model card 为准。`attach_frame: true` 让检测结果临时携带对应的解 注册、未被 `configs/edge_ai.yaml` 的 `detection` Pipeline 引用;需要恢复第二分支时,应同时配置 detector、 8082 endpoint、HTTP Sink 和两条关联 edge。 -若以后恢复双模型配置,应从 decoder 输出端口 fan-out,让两个 detector 共享同一个相机 -订阅和 PyAV decoder;两个模型仍会分别加载和推理,并共享应用的有界线程池。 +当前 Construction PPE 和 People-Talking 分支从 decoder 输出端口 fan-out,共享同一个 +相机订阅和 PyAV decoder;两个模型仍会分别加载和推理,并共享应用的有界线程池。 +People-Talking 是视觉行为判断,不能证明电话已接通,也不能区分来电和去电。 `detection.repeat_gate@1` 只在一个规则的 `window_ms` 内看到至少 `min_hits` 个不同帧后生成 `DetectionAlert/v1`。同一帧有多个同类框仍只算一次;触发后进入 `cooldown_ms`,冷却期间不累计,结束后必须重新满足次数。`scope: source` 按相机统计;`scope: track` 按 `track_id` 统计,但当前 YOLO adapter 只做逐帧检测,不产生 `track_id`,因此使用 track 规则前必须增加跟踪/关联节点。 diff --git a/configs/debug/detection_viewer.yaml b/configs/debug/detection_viewer.yaml index 996c31c..90668f7 100644 --- a/configs/debug/detection_viewer.yaml +++ b/configs/debug/detection_viewer.yaml @@ -1,8 +1,8 @@ api_version: cmvr.edge.ai/v1 runtime: - # Decoder, YOLO and viewer drawing share this bounded application pool. - thread_workers: 3 + # Decoder, two YOLO models and viewer drawing share this bounded pool. + thread_workers: 4 shutdown_timeout_s: 8 endpoints: @@ -33,6 +33,11 @@ pipelines: decoder: uses: media.video_decoder.pyav@1 + frame_fanout: + # One shared fan-out node chooses the latest decoded frame before both + # blocking detector queues. This keeps the two model inputs identical. + uses: core.passthrough@1 + detector: uses: detection.model@1 with: @@ -46,8 +51,6 @@ pipelines: - No-Mask - No-Vest confidence: 0.50 - # A 30 FPS stream starts at most 10 model inferences each second. - max_fps: 10 inference_log_interval_s: 5 # Required so the viewer receives the exact decoded inference frame. attach_frame: true @@ -60,16 +63,36 @@ pipelines: half: false max_det: 100 + phone_detector: + uses: detection.model@1 + with: + model: yolov8n-mobile-phone@1 + detect_labels: + - mobile_phone + confidence: 0.50 + inference_log_interval_s: 5 + attach_frame: true + model_options: + weights: models/detection/yolov8n-mobile-phone/yolov8n-mobile-phone.pt + device: cpu + imgsz: 640 + iou: 0.70 + half: false + max_det: 100 + viewer: uses: demo.opencv_detection_viewer@1 with: - window_name: CMVR PPE Detection + window_name: CMVR PPE + Phone Detection window_width: 1280 window_height: 720 wait_key_ms: 1 box_thickness: 2 font_scale: 0.6 show_stats: true + # Render only after both results for the exact same ImageFrame arrive. + expected_inputs: [ppe, phone] + max_pending_frames: 8 edges: # H264/H265 packets must remain contiguous until decoding. @@ -80,18 +103,42 @@ pipelines: capacity: 8 overflow: block - # Keep only the latest decoded frame while YOLO is busy. + # Drop only before the shared fan-out, so both models see the same + # surviving decoded frame rather than independently dropping frames. - from: decoder.frames - to: detector.frames + to: frame_fanout.input qos: profile: realtime_latest capacity: 1 overflow: drop_oldest - # A slow GUI must not accumulate raw frames or stale detection results. - - from: detector.detections - to: viewer.input + # These two blocking edges preserve the same frame set and ordering for + # both models. Do not add independent detector max_fps limits here. + - from: frame_fanout.output + to: detector.frames qos: - profile: realtime_latest + profile: telemetry capacity: 1 - overflow: drop_oldest + overflow: block + + - from: frame_fanout.output + to: phone_detector.frames + qos: + profile: telemetry + capacity: 1 + overflow: block + + # The viewer joins both result streams by frame identity before drawing. + - from: detector.detections + to: viewer.ppe + qos: + profile: telemetry + capacity: 2 + overflow: block + + - from: phone_detector.detections + to: viewer.phone + qos: + profile: telemetry + capacity: 2 + overflow: block diff --git a/configs/edge_ai.yaml b/configs/edge_ai.yaml index 797159b..2024fc9 100644 --- a/configs/edge_ai.yaml +++ b/configs/edge_ai.yaml @@ -1,7 +1,8 @@ api_version: cmvr.edge.ai/v1 runtime: - thread_workers: 3 + # PyAV plus the PPE and phone-use YOLO branches share this bounded pool. + thread_workers: 4 shutdown_timeout_s: 8 endpoints: @@ -16,7 +17,8 @@ endpoints: ppe_alert_platform: transport: http - # Violation-alert platform HTTP base URL. + # Detection-alert platform HTTP base URL. The alert payload model_id and + # rule_id distinguish PPE violations from phone-use violations. base_url: http://192.168.0.222:13080 timeout_s: 3 @@ -134,6 +136,45 @@ pipelines: min_confidence: 0.50 scope: source + # phone_detector: + # uses: detection.model@1 + # with: + # model: people-talking-yolov8x@1 + # # The checkpoint also contains a generic class named "label". It is + # # retained in model registration for class-ID safety but is not an + # # actionable phone-use event, so this branch selects only class 1. + # detect_labels: + # - talking on phone + # confidence: 0.50 + # # YOLOv8x is substantially heavier than the PPE model. Start with a + # # conservative CPU rate and tune only after measuring target hardware. + # max_fps: 5 + # inference_log_interval_s: 5 + # attach_frame: true + # model_options: + # weights: models/detection/people-talking-yolov8x/v1/best.pt + # device: cpu + # imgsz: 640 + # iou: 0.70 + # half: false + # max_det: 100 + + # phone_repeat_gate: + # uses: detection.repeat_gate@1 + # with: + # time_source: received + # alert_image: + # enabled: true + # jpeg_quality: 85 + # rules: + # - id: talking-on-phone + # labels: [talking on phone] + # min_hits: 3 + # window_ms: 2000 + # cooldown_ms: 30000 + # min_confidence: 0.50 + # scope: source + alert_platform: uses: platform.http_json_sink@1 with: @@ -163,6 +204,15 @@ pipelines: capacity: 1 overflow: drop_oldest + # Fan out the already decoded image; do not open a second camera stream + # or decode the same H264/H265 packet twice. + - from: decoder.frames + to: phone_detector.frames + qos: + profile: realtime_latest + capacity: 1 + overflow: drop_oldest + - from: detector.detections to: repeat_gate.detections qos: @@ -173,6 +223,14 @@ pipelines: capacity: 2 overflow: drop_oldest + # - from: phone_detector.detections + # to: phone_repeat_gate.detections + # qos: + # profile: telemetry + # # attach_frame carries the decoded image until the rule is evaluated. + # capacity: 2 + # overflow: drop_oldest + - from: repeat_gate.alerts to: alert_platform.input qos: @@ -180,6 +238,13 @@ pipelines: capacity: 64 overflow: block + # - from: phone_repeat_gate.alerts + # to: alert_platform.input + # qos: + # profile: telemetry + # capacity: 64 + # overflow: block + talk: enabled: true nodes: diff --git a/detect_server/README.md b/detect_server/README.md index 42a7b1c..6b24f97 100644 --- a/detect_server/README.md +++ b/detect_server/README.md @@ -1,17 +1,18 @@ -# PPE 检测流水线 +# PPE 与电话使用检测流水线 -`configs/edge_ai.yaml` 中的 `detection` Pipeline 是当前可运行的园区施工安全装备检测 -链路。它与 `talk` Pipeline 共用一个部署 YAML,当前只启用 Construction PPE 模型和 -8081 告警平台;六类模型与 8082 模拟平台的实现保留但不实例化: +`configs/edge_ai.yaml` 中的 `detection` Pipeline 是当前可运行的园区视觉告警链路。 +它与 `talk` Pipeline 共用一个部署 YAML,并从同一个 decoder 并行运行 Construction +PPE 与 People-Talking 两个模型。两个 detector 使用独立 repeat gate,再把带有不同 +`model_id`/`rule_id` 的告警交给同一个平台 Sink: ```text cmvr-es CameraService -> H264/H265 ImageFrame/v1 -> media.video_decoder.pyav@1 -> BGR8 ImageFrame/v1 - -> construction-ppe-yolov8@1 -> DetectionResult/v1 - -> detection.repeat_gate@1 -> DetectionAlert/v1 - -> POST 127.0.0.1:8081/v1/detection-alerts + |-> construction-ppe-yolov8@1 -> PPE repeat gate --| + `-> people-talking-yolov8x@1 -> phone repeat gate --+-> DetectionAlert/v1 + `-> POST /v1/detection-alerts ``` ## 安装与启动 @@ -32,8 +33,9 @@ bash scripts/bootstrap.sh \ ``` 环境固定 Python 3.10、Ultralytics 8.4.31、PyTorch 2.7.0 CPU 和 torchvision -0.22.0,并通过 `image` extra 安装 Pillow 以生成告警图片;所有具体包版本记录在 -仓库的 `uv.lock` 中。GPU/Jetson 需要单独适配与驱动或 JetPack 匹配的 PyTorch +0.22.0,通过 `image` extra 安装 Pillow 以生成告警图片,并显式安装 People-Talking +checkpoint 反序列化所需的 `dill`;所有具体包版本记录在仓库的 `uv.lock` 中。 +GPU/Jetson 需要单独适配与驱动或 JetPack 匹配的 PyTorch wheel,不能直接复用 `detection-cpu` profile。手动组合依赖时必须显式增加 `--extra image`,不能只依赖 YOLO 间接安装 Pillow。 @@ -65,6 +67,20 @@ pipelines: alert_image: enabled: true jpeg_quality: 85 + phone_detector: + with: + model: people-talking-yolov8x@1 + detect_labels: [talking on phone] + max_fps: 5 + attach_frame: true + model_options: + weights: models/detection/people-talking-yolov8x/v1/best.pt + device: cpu + phone_repeat_gate: + with: + alert_image: + enabled: true + jpeg_quality: 85 alert_platform: with: endpoint: ppe_alert_platform @@ -105,7 +121,7 @@ uv run --no-sync cmvr-edge-ai run -c configs/edge_ai.yaml \ - 短时观察可把 `inference_log_interval_s` 改为 `1` 秒,长期运行建议 `30`~`60` 秒, 省略该字段会关闭周期推理日志。 -运行前确认 cmvr-es 已启用 `right_hand_cam`、Construction PPE 权重存在,并且 8081 +运行前确认 cmvr-es 已启用 `right_hand_cam`、两份生产权重存在,并且 8081 平台接受 `POST /v1/detection-alerts`。默认 profile 下 `model_options.device` 应设为 `cpu`;只有完成设备专用的 CUDA/Jetson PyTorch 环境适配后,才能改为 `cuda:0` 等值。 @@ -115,7 +131,10 @@ uv run --no-sync cmvr-edge-ai run -c configs/edge_ai.yaml \ 独立调试链路: ```text -cmvr-es CameraService -> PyAV decoder -> YOLO detector -> OpenCV window +cmvr-es CameraService -> PyAV decoder -> latest decoded frame + |-> Construction PPE --| + `-> YOLOv8n Mobile ----+-> same-frame join + -> OpenCV window ``` 先编辑 `configs/debug/detection_viewer.yaml` 中的远端 cmvr-es 地址、相机 ID 和权重路径: @@ -133,15 +152,22 @@ pipelines: device_id: wrist_cam detector: with: - max_fps: 10 model_options: - weights: /absolute/path/to/best.pt + weights: models/detection/construction-ppe-yolov8/v1/best.pt + device: cpu + phone_detector: + with: + model: yolov8n-mobile-phone@1 + detect_labels: [mobile_phone] + model_options: + weights: models/detection/yolov8n-mobile-phone/yolov8n-mobile-phone.pt device: cpu ``` -`max_fps: 10` 表示最多每秒执行 10 次推理;视频流更快时,中间的已解码帧通过 -`realtime_latest + drop_oldest` 丢弃,以保持低延迟。`attach_frame: true` 已在 Demo 配置 -中启用,viewer 因而能拿到与本次推理严格对应的原图并绘制 bounding box。 +Demo 只在共享 fan-out 之前通过 `realtime_latest + drop_oldest` 选择最新解码帧,之后用 +两条 `block` 边把同一个 `ImageFrame` 交给两个 detector。两个 detector 都不设置独立 +`max_fps`,避免各自跳过不同帧。Viewer 按 frame identity 等待两个结果配齐,再在同一 +张图上绘制;因此预览吞吐由较慢的模型分支决定。 只检查配置和插件连线,不连接相机、不加载模型、也不创建窗口: @@ -161,10 +187,11 @@ uv run --no-sync python detect_server/show_detections.py \ --log-format json ``` -相机 Source 会先调用 `StartCamera`,成功后再建立 gRPC 视频流。窗口显示每个实际推理 -结果,即使本帧没有检测框也会刷新;按 `q`、`Q`、`Esc` 或关闭窗口可安全退出。这个 -Demo 直接订阅 detector 输出,刻意绕过 `repeat_gate` 和 HTTP Sink,因此只用于观察模型 -效果,不代表某条告警规则已满足。 +相机 Source 会先调用 `StartCamera`,成功后再建立 gRPC 视频流。窗口只在同一帧的两个 +推理结果都到达后刷新;PPE 框显示为 `PPE: