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:
+21
-1
@@ -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 返回「模型未加载」
|
||||
```
|
||||
|
||||
Generated
+12
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user