feat(大屏): 视频识别直连 park-vision + 绑定轮询/二维码

- config.js 增 getVisionApiBase(),优先绑定下发的本园区 vision_api(经 tenant info)
- main.jsx bootstrap 读 /park/api/config 的 vision_api
- useVisionDetection 帧 POST 到独立 vision 服务;未配置识别整体禁用
- BindScreen 连接码轮询(无码5s/有码30min) + 渲染二维码(qrcode.react)、绑定后入园区
- 部署文档补 vision_api/识别服务说明
This commit is contained in:
Pine
2026-08-25 01:52:53 +08:00
parent ba34f40baa
commit 1566477b97
9 changed files with 121 additions and 22 deletions
+21 -1
View File
@@ -11,13 +11,15 @@
"mqtt_url": "ws://192.168.1.3:8083/mqtt",
"mqtt_username": "dpm",
"mqtt_password": "123456",
"voice_url": "ws://192.168.1.3:8765/v1/realtime"
"voice_url": "ws://192.168.1.3:8765/v1/realtime",
"vision_api": "http://192.168.1.9:8091"
}
```
- `api_base`server-core 统一入口园区地址(`http://<IP>:8090/park`;生产 `https://opc.pinesound.cn/park`),大屏 API/媒体/OTA 都走它
- `mqtt_url`EMQX Broker 的 **WebSocket** 地址(端口 8083
- `mqtt_username/password`Broker 鉴权账号(默认 dpm / 123456,与后端 .env 一致)
- `vision_api`:**可选**。视频识别独立服务(code/park-vision`http://<IP>:8091`)。不填=视频识别禁用(摄像头不开启)。也可经 server-core `/park/api/config` 下发(见「三、视频识别」)。
地址解析优先级:**config.json > 后端 /api/config > 构建默认(192.168.1.9**。
@@ -76,3 +78,21 @@ Test-NetConnection 192.168.1.3 -Port 8083
- 应用日志打印 `[bootstrap] config.json MQTT -> ...` = config.json 生效
- 打印 `[bootstrap] /api/config MQTT -> ...` = 后端下发
- 两者都没有 = 两个来源都不可达,请检查 IP/防火墙
## 三、视频识别(可选,独立 park-vision 服务)
视频识别能力已从 server-core 剥离为**独立项目 `code/park-vision`**YOLO 帧识别 + LLM 场景语义),
server-core `/park` 不再承接识别请求(`/park/api/vision/frame|llm` 已移除,仅保留遥测 `/api/vision/event`)。
**配置方式(二选一)**,未配置则视频识别整体禁用(摄像头不开启、页面显示「手势识别未启用」):
1. **config.json(部署机)**`"vision_api": "http://<IP>:8091"`
2. **server-core 全局(admin 端)**:运营端「视频识别服务」页启用+填地址 → 写 `park_settings`
大屏启动时从 `GET /park/api/config``vision_api`enabled=1 才下发)
**部署完整识别链路**
```bash
# 独立识别服务(需 torch/ultralytics + 模型 yolov8n-face.pt / yolov8n-pose.pt 置于 models/
cd code/park-vision && uv sync && uv run uvicorn app.main:app --host 0.0.0.0 --port 8091
# 放模型后再启动;缺模型时 /vision/frame 返回「模型未加载」
```
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "dpm",
"version": "0.1.0",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dpm",
"version": "0.1.0",
"version": "0.1.2",
"dependencies": {
"@appica/icons-react": "^1.0.0",
"@appica/ui-react": "^1.1.0",
@@ -17,6 +17,7 @@
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-opener": "^2",
"mqtt": "^5.15.2",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.15.0",
@@ -3345,6 +3346,15 @@
"lie": "^3.0.2"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.6.tgz",
+1
View File
@@ -20,6 +20,7 @@
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-opener": "^2",
"mqtt": "^5.15.2",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.15.0",
+46 -9
View File
@@ -1,21 +1,45 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { getDeviceId, MQTT_TOPIC_BIND } from '../config';
import { connectMqtt, onMqttMessage } from '../utils/mqtt';
import { registerDevice, requestBindCode, applyBound } from '../utils/tenant';
import '../styles/global.css';
/* 大屏待绑定页:注册设备 + 申请 8 位连接码 + 监听绑定频道;园区端输码绑定后自动进入。 */
/* 轮询节奏:取码失败/无码 → 5 秒重取(尽快拿到首个码);取到码但未绑定 → 30 分钟刷新最新码。
(后端每次下发都会覆盖旧码——轮换——故大屏按此节奏保持当前码始终为最新可绑定码。) */
const RETRY_NO_CODE_MS = 5_000;
const REFRESH_HAS_CODE_MS = 30 * 60_000;
/* 大屏待绑定页:注册设备 + 申请 8 位连接码 + 轮询取码 + 渲染二维码(供园区端扫码)
+ 监听绑定频道;园区端输码/扫码绑定后自动进入。 */
export default function BindScreen() {
const [code, setCode] = useState('');
const [err, setErr] = useState('');
const [bound, setBound] = useState(false);
const codeRef = useRef(''); // 供轮询回调取最新码,决定下次节奏
const deviceId = getDeviceId();
useEffect(() => {
connectMqtt();
registerDevice().then(() =>
requestBindCode().then((r) => { if (r.ok) setCode(r.code); else setErr(r.error || '取码失败'); }).catch((e) => setErr(e.message))
).catch((e) => setErr(e.message));
// 注册设备(幂等;重复注册安全)
registerDevice().catch((e) => setErr(e.message));
// 轮询自调度:取码 → 失败/无码 5s 再取;有码 30 分钟刷新
let timer = null;
const refreshCode = () => {
requestBindCode()
.then((r) => {
if (r.ok) { codeRef.current = r.code || ''; setCode(r.code); setErr(''); }
else { codeRef.current = ''; setErr(r.error || '取码失败'); }
})
.catch((e) => setErr(e.message))
.finally(() => {
const delay = codeRef.current ? REFRESH_HAS_CODE_MS : RETRY_NO_CODE_MS;
timer = setTimeout(refreshCode, delay);
});
};
refreshCode();
const off = onMqttMessage((topic, data) => {
if (topic === `${MQTT_TOPIC_BIND}/${deviceId}` && data && data.event === 'bound') {
@@ -24,19 +48,32 @@ export default function BindScreen() {
setTimeout(() => window.location.reload(), 300);
}
});
return off;
return () => {
clearTimeout(timer);
if (off) off();
};
}, [deviceId]);
return (
<div style={wrap}>
<div style={card}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 6 }}>大屏待绑定</div>
<div style={sub}>请在园区管理端输入以下连接码绑定本屏</div>
<div style={sub}>请在园区管理端扫描二维码输入以下连接码绑定本屏</div>
{err ? <div style={{ color: '#f87171', margin: '10px 0', fontSize: 13 }}>{err}</div> : null}
<div style={codeStyle}>{code || '·······'}</div>
{code ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ padding: 12, background: '#fff', borderRadius: 10 }}>
<QRCodeSVG value={code} size={180} level="M" includeMargin={false} bgColor="#ffffff" fgColor="#0b1020" />
</div>
<div style={sub2}>扫描二维码 · 或输入下方连接码</div>
</div>
) : (
<div style={{ width: 180, height: 180, margin: '10px auto', borderRadius: 10, background: '#0e1428', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5568', fontSize: 13 }}>取码中</div>
)}
<div style={codeStyle}>{code || '······'}</div>
<div style={sub2}>设备 ID{deviceId}</div>
{bound && <div style={{ color: '#34d399', marginTop: 12 }}>绑定成功正在进入园区</div>}
<div style={sub2}>输入码大屏会自动跳转到该园区的大屏内容</div>
<div style={sub2}>绑定成功大屏会自动跳转到该园区的大屏内容</div>
</div>
</div>
);
+14
View File
@@ -27,6 +27,12 @@ const _apiFromWindow = window.__DPM_API__ && !_isLegacyApi(window.__DPM_API__) ?
const _apiFromEnv = env.VITE_API_BASE && !_isLegacyApi(env.VITE_API_BASE) ? env.VITE_API_BASE : null;
export const API_BASE = _apiFromWindow || _apiFromEnv || `http://${resolveHost()}:8090/park`;
// 视频识别:独立 park-vision 服务地址。window.__DPM_VISION__(bootstrap 从 /park/api/config 下发)>
// > VITE_VISION_API_BASE(构建期)> env 显式 0→禁用;空=视频识别禁用。
const _isLegacyVision = (u) => !!(u && (String(u).includes(':10085') || String(u).includes('10085')));
const _visionFromWindow = window.__DPM_VISION__ && !_isLegacyVision(window.__DPM_VISION__) ? window.__DPM_VISION__ : null;
const _visionFromEnv = env.VITE_VISION_API_BASE && !_isLegacyVision(env.VITE_VISION_API_BASE) ? env.VITE_VISION_API_BASE : null;
export const VISION_API_BASE = (_visionFromWindow || _visionFromEnv || '').replace(/\/$/, '');
export const MQTT_URL = window.__DPM_MQTT__ || env.VITE_MQTT_URL || `ws://${resolveHost()}:8083/mqtt`;
export const MQTT_USERNAME = window.__DPM_MQTT_USER__ || env.VITE_MQTT_USERNAME || '';
export const MQTT_PASSWORD = window.__DPM_MQTT_PASS__ || env.VITE_MQTT_PASSWORD || '';
@@ -42,6 +48,14 @@ export function getMqttUrl() {
return window.__DPM_MQTT__ || MQTT_URL;
}
/** 视频识别服务地址:优先绑定下发的本园区地址(园区端配置,可局域网),再退回环境/窗口配置;空=禁用 */
export function getVisionApiBase() {
const fromTenant = (function () {
try { return JSON.parse(localStorage.getItem('dpm_tenant_info') || 'null'); } catch { return null; }
})();
return (fromTenant?.vision_api || window.__DPM_VISION__ || VISION_API_BASE || '').replace(/\/$/, '');
}
export function getMqttCredentials() {
return {
username: window.__DPM_MQTT_USER__ || MQTT_USERNAME,
+2
View File
@@ -69,6 +69,8 @@ async function bootstrapRuntimeConfig() {
if (!bakedMqtt && cfg.mqtt_username) window.__DPM_MQTT_USER__ = cfg.mqtt_username;
if (!bakedMqtt && cfg.mqtt_password) window.__DPM_MQTT_PASS__ = cfg.mqtt_password;
if (!import.meta.env.VITE_VOICE_WS && cfg.voice_url) window.__DPM_VOICE_WS__ = cfg.voice_url;
// 视频识别:独立 park-vision 地址(enabled 才下发;空=禁用)
if (cfg.vision_api) window.__DPM_VISION__ = cfg.vision_api;
// eslint-disable-next-line no-console
if (cfg.mqtt_url) console.info(`[bootstrap] /api/config MQTT -> ${cfg.mqtt_url}${bakedMqtt ? '(已烘焙,忽略)' : ''}`);
}
+7 -3
View File
@@ -4,13 +4,15 @@
import { getApiBase, getDeviceId } from '../config';
const TOKEN_KEY = 'dpm_device_token';
const TENANT_KEY = 'dpm_tenant_info'; // {tenant_id, name, intro}
const TENANT_KEY = 'dpm_tenant_info'; // {tenant_id, name, intro, vision_api}
export function getDeviceToken() { return localStorage.getItem(TOKEN_KEY) || ''; }
export function getTenantId() { return (getTenantInfo() || {}).tenant_id || ''; }
export function getTenantInfo() {
try { return JSON.parse(localStorage.getItem(TENANT_KEY) || 'null'); } catch { return null; }
}
/** 本园区视频识别服务地址(独立 park-vision,园区端配置,可局域网);空=禁用 */
export function getVisionApi() { return (getTenantInfo() || {}).vision_api || ''; }
export function isDeviceBound() { return !!getDeviceToken() && !!getTenantInfo(); }
export async function registerDevice() {
@@ -27,9 +29,11 @@ export async function requestBindCode() {
}
export function applyBound(payload) {
// payload: {event:'bound', token, tenant_id, name, intro}
// payload: {event:'bound', token, tenant_id, name, intro, vision_api}
localStorage.setItem(TOKEN_KEY, payload.token);
localStorage.setItem(TENANT_KEY, JSON.stringify({ tenant_id: payload.tenant_id, name: payload.name, intro: payload.intro || [] }));
localStorage.setItem(TENANT_KEY, JSON.stringify({
tenant_id: payload.tenant_id, name: payload.name, intro: payload.intro || [], vision_api: payload.vision_api || '',
}));
}
export function clearBinding() {
+13 -7
View File
@@ -1,13 +1,14 @@
/* =========================================================
摄像头实时识别 Hook —— YOLO 人脸检测(后端推理)
链路:前端 getUserMedia 本地预览 → 每 ~700ms canvas 抽帧 → JPEG base64
POST {API_BASE}/api/vision/frame → 后端 YOLO(yolov8n-face) 推理
摄像头实时识别 Hook —— YOLO 人脸检测(独立 park-vision 服务推理)
链路:前端 getUserMedia 本地预览 → 每 ~400ms canvas 抽帧 → JPEG base64
POST {VISION_API}/vision/frame → park-vision YOLO(yolov8n-face) 推理
→ 返回人脸数/框 → 前端驱动状态机
- 状态机:检测到人脸(面向大屏)持续 ≥10s → onTrigger;触发后静默 60s
- 帧经局域网传到后端(大屏机同机部署则不出设备);画面预览仍本地
- 识别在独立 park-vision 服务;画面预览仍本地
- 未配置识别地址(VISION_API 空)→ 视频识别整体禁用,不进摄像头/不循环
========================================================= */
import { useCallback, useEffect, useRef, useState } from 'react';
import { getApiBase as API_BASE } from '../config';
import { getVisionApiBase as VISION_API } from '../config';
export const VISION_DWELL_MS = 10000; // 面向停留阈值
export const VISION_SILENT_MS = 180000; // 触发(自动问候)后静默 3 分钟,期间不重复触发
@@ -104,10 +105,10 @@ export default function useVisionDetection({ onTrigger, onGesture, enabled = tru
}
}
// JPEG base64 → 后端 YOLO
// JPEG base64 → 独立 park-vision YOLO
const b64 = canvasRef.current.toDataURL('image/jpeg', 0.6).split(',')[1];
setModelPhase('ok');
const res = await fetch(`${API_BASE()}/api/vision/frame`, {
const res = await fetch(`${VISION_API()}/vision/frame`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: b64 }),
@@ -215,6 +216,11 @@ export default function useVisionDetection({ onTrigger, onGesture, enabled = tru
const startCamera = useCallback(async () => {
if (cameraOn || !enabled) return;
// 未配置视频识别服务 → 禁用(admin 在 server-core 配 vision_api,经 /park/api/config 下发;空=关闭)
if (!VISION_API()) {
setStatus('off');
return;
}
cancelledRef.current = false;
setStatus('loading');
setModelPhase('loading');
+5
View File
@@ -1167,6 +1167,11 @@ promise-worker-transferable@^1.0.4:
is-promise "^2.1.0"
lie "^3.0.2"
qrcode.react@^4.2.0:
version "4.2.0"
resolved "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz"
integrity sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
react-day-picker@^10.0.1:
version "10.0.1"
resolved "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-10.0.1.tgz"