477 lines
29 KiB
HTML
477 lines
29 KiB
HTML
<!doctype html>
|
||||
|
|
<html lang="zh-CN" data-theme="dark">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<meta name="color-scheme" content="dark">
|
|||
|
|
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
|
|||
|
|
<link rel="icon" href="data:,">
|
|||
|
|
<title>YoVision Brain · 单路区域事件工程原型</title>
|
|||
|
|
<style>
|
|||
|
|
:root {
|
|||
|
|
--bg: #0b1120;
|
|||
|
|
--surface: #111a2b;
|
|||
|
|
--surface-2: #172236;
|
|||
|
|
--surface-3: #1e2c43;
|
|||
|
|
--text: #f5f7fb;
|
|||
|
|
--muted: #aebbd0;
|
|||
|
|
--subtle: #8291a9;
|
|||
|
|
--line: #2b3a52;
|
|||
|
|
--primary: #8b7cf6;
|
|||
|
|
--primary-strong: #7567e8;
|
|||
|
|
--cyan: #38bdf8;
|
|||
|
|
--success: #34d399;
|
|||
|
|
--warning: #fbbf24;
|
|||
|
|
--danger: #fb7185;
|
|||
|
|
--focus: #b8e5ff;
|
|||
|
|
--radius: 14px;
|
|||
|
|
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
|||
|
|
}
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
html { min-width: 320px; background: var(--bg); }
|
|||
|
|
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|||
|
|
button, input, textarea { font: inherit; }
|
|||
|
|
button { cursor: pointer; }
|
|||
|
|
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
|||
|
|
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
|
|||
|
|
.skip-link:focus { transform: none; }
|
|||
|
|
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
|
|||
|
|
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
|
|||
|
|
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
|
|||
|
|
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
|
|||
|
|
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
|
|||
|
|
.brand strong, .brand span { display: block; }
|
|||
|
|
.brand strong { font-size: 16px; }
|
|||
|
|
.brand span { color: var(--muted); font-size: 12px; }
|
|||
|
|
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
|||
|
|
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
|
|||
|
|
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
|||
|
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
|
|||
|
|
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
|
|||
|
|
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
|
|||
|
|
main { padding-top: 18px; }
|
|||
|
|
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
|||
|
|
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
|
|||
|
|
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
|
|||
|
|
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
|
|||
|
|
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
|
|||
|
|
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
|
|||
|
|
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
|
|||
|
|
.card-head h2 { margin: 0; font-size: 15px; }
|
|||
|
|
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
|
|||
|
|
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|||
|
|
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
|
|||
|
|
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
|
|||
|
|
.btn:disabled { cursor: not-allowed; opacity: .45; }
|
|||
|
|
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
|
|||
|
|
.btn-quiet { background: transparent; }
|
|||
|
|
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|||
|
|
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
|
|||
|
|
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
|||
|
|
.video-stage img { object-fit: contain; background: #050912; }
|
|||
|
|
.video-stage canvas { touch-action: manipulation; }
|
|||
|
|
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
|
|||
|
|
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
|||
|
|
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
|||
|
|
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
|
|||
|
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|||
|
|
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
|
|||
|
|
.legend .zone-key { background: var(--warning); }
|
|||
|
|
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
|
|||
|
|
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
|||
|
|
.metric span { display: block; color: var(--muted); font-size: 12px; }
|
|||
|
|
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
|
|||
|
|
.side-stack { display: grid; align-content: start; gap: 14px; }
|
|||
|
|
.form-body { padding: 14px; display: grid; gap: 12px; }
|
|||
|
|
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
|
|||
|
|
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
|
|||
|
|
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|||
|
|
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
|
|||
|
|
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|||
|
|
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
|
|||
|
|
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
|
|||
|
|
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
|
|||
|
|
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|||
|
|
.event strong { font-size: 13px; }
|
|||
|
|
.event time, .event code { color: var(--muted); font-size: 12px; }
|
|||
|
|
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
|
|||
|
|
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
|
|||
|
|
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
|
|||
|
|
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
|
|||
|
|
.architecture-note strong { color: var(--text); }
|
|||
|
|
.architecture-note p { margin: 2px 0 0; }
|
|||
|
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|||
|
|
@media (max-width: 980px) {
|
|||
|
|
.grid { grid-template-columns: 1fr; }
|
|||
|
|
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
|||
|
|
}
|
|||
|
|
@media (max-width: 700px) {
|
|||
|
|
.app-shell { padding-inline: 12px; }
|
|||
|
|
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
|
|||
|
|
.top-context { margin-left: 0; justify-content: flex-start; }
|
|||
|
|
.page-head { flex-direction: column; }
|
|||
|
|
.mode-badge { white-space: normal; }
|
|||
|
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|||
|
|
.side-stack { grid-template-columns: 1fr; }
|
|||
|
|
.card-head { align-items: flex-start; flex-direction: column; }
|
|||
|
|
.toolbar { width: 100%; }
|
|||
|
|
.toolbar .btn { flex: 1; }
|
|||
|
|
.video-footer { align-items: flex-start; flex-direction: column; }
|
|||
|
|
.legend { margin-left: 0; }
|
|||
|
|
}
|
|||
|
|
@media (max-width: 420px) {
|
|||
|
|
.prototype-banner { letter-spacing: 0; }
|
|||
|
|
.chip { width: 100%; justify-content: flex-start; }
|
|||
|
|
.toolbar { display: grid; grid-template-columns: 1fr; }
|
|||
|
|
.toolbar .btn { width: 100%; min-width: 0; }
|
|||
|
|
.metrics { grid-template-columns: 1fr 1fr; }
|
|||
|
|
.metric strong { font-size: 17px; }
|
|||
|
|
.form-actions { grid-template-columns: 1fr; }
|
|||
|
|
}
|
|||
|
|
@media (prefers-reduced-motion: reduce) {
|
|||
|
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<a class="skip-link" href="#main">跳到主要内容</a>
|
|||
|
|
<div class="prototype-banner">
|
|||
|
|
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
|
|||
|
|
</div>
|
|||
|
|
<div class="app-shell">
|
|||
|
|
<header class="topbar">
|
|||
|
|
<div class="brand" aria-label="YoVision Brain">
|
|||
|
|
<div class="brand-mark" aria-hidden="true">Y</div>
|
|||
|
|
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="top-context" aria-label="运行上下文">
|
|||
|
|
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
|
|||
|
|
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
|
|||
|
|
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<main id="main" tabindex="-1">
|
|||
|
|
<div class="page-head">
|
|||
|
|
<div>
|
|||
|
|
<h1>单路检测与区域判定</h1>
|
|||
|
|
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。</p>
|
|||
|
|
</div>
|
|||
|
|
<div id="mode-badge" class="mode-badge">加载运行状态</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="grid">
|
|||
|
|
<section class="card" aria-labelledby="preview-title">
|
|||
|
|
<div class="card-head">
|
|||
|
|
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
|
|||
|
|
<div class="toolbar">
|
|||
|
|
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
|
|||
|
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
|||
|
|
<span>暂停刷新</span>
|
|||
|
|
</button>
|
|||
|
|
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
|
|||
|
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
|
|||
|
|
<span>画布加点</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="video-stage">
|
|||
|
|
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
|
|||
|
|
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
|
|||
|
|
<div id="video-label" class="video-label">等待首帧</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="video-footer">
|
|||
|
|
<span>帧序号 <strong id="sequence">—</strong></span>
|
|||
|
|
<span>采集时间 <strong id="captured-at">—</strong></span>
|
|||
|
|
<div class="legend" aria-label="画面图例">
|
|||
|
|
<span><i aria-hidden="true"></i>匿名人员框</span>
|
|||
|
|
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="metrics" aria-label="推理摘要">
|
|||
|
|
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
|
|||
|
|
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
|
|||
|
|
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
|
|||
|
|
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<aside class="side-stack" aria-label="区域配置与事件候选">
|
|||
|
|
<section class="card" aria-labelledby="zone-title">
|
|||
|
|
<div class="card-head">
|
|||
|
|
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
|
|||
|
|
</div>
|
|||
|
|
<form id="zone-form" class="form-body">
|
|||
|
|
<label for="zone-name">区域名称
|
|||
|
|
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
|
|||
|
|
</label>
|
|||
|
|
<label for="zone-points">顶点坐标(每行 x,y)
|
|||
|
|
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30 0.90,0.30 0.90,0.88 0.55,0.88</textarea>
|
|||
|
|
</label>
|
|||
|
|
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
|
|||
|
|
<div class="form-actions">
|
|||
|
|
<button id="undo-point" class="btn" type="button">撤销一点</button>
|
|||
|
|
<button id="clear-points" class="btn" type="button">清空顶点</button>
|
|||
|
|
</div>
|
|||
|
|
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
|
|||
|
|
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
|
|||
|
|
</form>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section class="card" aria-labelledby="event-title">
|
|||
|
|
<div class="card-head">
|
|||
|
|
<div><h2 id="event-title">最近候选事实</h2><p>只产生 source_event_id;Bell 才生成平台 ULID</p></div>
|
|||
|
|
<span id="event-count" class="chip"><strong>0</strong> 项</span>
|
|||
|
|
</div>
|
|||
|
|
<div id="event-list" class="event-list" aria-live="polite">
|
|||
|
|
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</aside>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<section class="architecture-note" aria-label="系统边界说明">
|
|||
|
|
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
|||
|
|
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
|
|||
|
|
</section>
|
|||
|
|
<div id="announcer" class="sr-only" aria-live="polite"></div>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
(() => {
|
|||
|
|
"use strict";
|
|||
|
|
const $ = (id) => document.getElementById(id);
|
|||
|
|
const frame = $("video-frame");
|
|||
|
|
const canvas = $("overlay");
|
|||
|
|
const context = canvas.getContext("2d");
|
|||
|
|
const token = document.querySelector('meta[name="brain-demo-token"]').content;
|
|||
|
|
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
|
|||
|
|
let paused = false;
|
|||
|
|
let editMode = false;
|
|||
|
|
let state = null;
|
|||
|
|
let offlineSequence = 0;
|
|||
|
|
let offlineTriggered = false;
|
|||
|
|
|
|||
|
|
const offlineState = () => {
|
|||
|
|
offlineSequence += 1;
|
|||
|
|
const phase = ((offlineSequence - 1) % 160) / 159;
|
|||
|
|
const center = -0.04 + phase * 1.08;
|
|||
|
|
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
|
|||
|
|
const inside = center >= .55 && center <= .90;
|
|||
|
|
const events = state && state.events ? state.events : [];
|
|||
|
|
if (inside && !offlineTriggered) {
|
|||
|
|
offlineTriggered = true;
|
|||
|
|
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
|
|||
|
|
}
|
|||
|
|
if (!inside && center < .50) offlineTriggered = false;
|
|||
|
|
return {
|
|||
|
|
prototype: true,
|
|||
|
|
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
|
|||
|
|
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
|
|||
|
|
detector: {name: "scripted_prototype", production_ready: false},
|
|||
|
|
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
|
|||
|
|
inference: {target_fps: 2, latency_ms: 0},
|
|||
|
|
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
|
|||
|
|
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
|
|||
|
|
events,
|
|||
|
|
last_error_code: null
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function parsePoints(showError = true) {
|
|||
|
|
try {
|
|||
|
|
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
|
|||
|
|
const parts = line.split(",").map(value => Number(value.trim()));
|
|||
|
|
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
|
|||
|
|
return {x: parts[0], y: parts[1]};
|
|||
|
|
});
|
|||
|
|
if (points.length < 3 || points.length > 32) throw new Error();
|
|||
|
|
return points;
|
|||
|
|
} catch (_) {
|
|||
|
|
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setPoints(points) {
|
|||
|
|
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setFormState(message, error = false) {
|
|||
|
|
const target = $("form-state");
|
|||
|
|
target.textContent = message;
|
|||
|
|
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatTime(value) {
|
|||
|
|
if (!value) return "—";
|
|||
|
|
const date = new Date(value);
|
|||
|
|
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function drawOfflineBackground() {
|
|||
|
|
context.fillStyle = "#111a2b";
|
|||
|
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
|||
|
|
context.fillStyle = "#1e2c43";
|
|||
|
|
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
|
|||
|
|
context.strokeStyle = "#2b3a52";
|
|||
|
|
for (let x = 0; x < canvas.width; x += 80) {
|
|||
|
|
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
|
|||
|
|
}
|
|||
|
|
context.fillStyle = "#7dd3fc";
|
|||
|
|
context.font = "700 18px Segoe UI";
|
|||
|
|
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function drawOverlay() {
|
|||
|
|
if (!state) return;
|
|||
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|||
|
|
if (!runtimeAvailable) drawOfflineBackground();
|
|||
|
|
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
|
|||
|
|
if (points.length >= 3) {
|
|||
|
|
context.beginPath();
|
|||
|
|
points.forEach((point, index) => {
|
|||
|
|
const x = point.x * canvas.width, y = point.y * canvas.height;
|
|||
|
|
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
|
|||
|
|
});
|
|||
|
|
context.closePath();
|
|||
|
|
context.fillStyle = "rgba(251, 191, 36, .10)";
|
|||
|
|
context.fill();
|
|||
|
|
context.strokeStyle = "#fbbf24";
|
|||
|
|
context.lineWidth = 3;
|
|||
|
|
context.stroke();
|
|||
|
|
points.forEach((point, index) => {
|
|||
|
|
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
|
|||
|
|
context.fillStyle = "#fbbf24"; context.fill();
|
|||
|
|
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
state.detections.forEach(item => {
|
|||
|
|
const [x1, y1, x2, y2] = item.bbox;
|
|||
|
|
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
|
|||
|
|
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
|
|||
|
|
context.lineWidth = 3;
|
|||
|
|
context.strokeRect(x, y, width, height);
|
|||
|
|
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
|
|||
|
|
context.font = "700 14px Segoe UI";
|
|||
|
|
const labelWidth = context.measureText(label).width + 14;
|
|||
|
|
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
|
|||
|
|
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
|
|||
|
|
context.fillStyle = "#fff";
|
|||
|
|
context.fillText(label, x + 7, Math.max(17, y - 8));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderEvents(events) {
|
|||
|
|
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
|
|||
|
|
if (!events.length) {
|
|||
|
|
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
|
|||
|
|
const article = document.createElement("article"); article.className = "event";
|
|||
|
|
const head = document.createElement("div"); head.className = "event-head";
|
|||
|
|
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
|
|||
|
|
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
|
|||
|
|
head.append(title, tag);
|
|||
|
|
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
|
|||
|
|
const code = document.createElement("code"); code.textContent = event.source_event_id;
|
|||
|
|
article.append(head, time, code); return article;
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function render(next) {
|
|||
|
|
state = next;
|
|||
|
|
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
|
|||
|
|
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
|
|||
|
|
$("source-label").textContent = next.source.label;
|
|||
|
|
$("zone-version").textContent = `v${next.zone.version}`;
|
|||
|
|
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
|
|||
|
|
$("sequence").textContent = String(next.frame.sequence);
|
|||
|
|
$("captured-at").textContent = formatTime(next.frame.captured_at);
|
|||
|
|
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
|
|||
|
|
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
|
|||
|
|
$("person-count").textContent = String(next.detections.length);
|
|||
|
|
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
|
|||
|
|
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
|
|||
|
|
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
|
|||
|
|
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
|
|||
|
|
}
|
|||
|
|
renderEvents(next.events);
|
|||
|
|
drawOverlay();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function poll() {
|
|||
|
|
if (paused) return;
|
|||
|
|
if (runtimeAvailable) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch("/api/v1/state", {cache: "no-store"});
|
|||
|
|
if (!response.ok) throw new Error();
|
|||
|
|
const next = await response.json();
|
|||
|
|
frame.hidden = false;
|
|||
|
|
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
|
|||
|
|
render(next);
|
|||
|
|
return;
|
|||
|
|
} catch (_) {
|
|||
|
|
runtimeAvailable = false;
|
|||
|
|
frame.hidden = true;
|
|||
|
|
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
render(offlineState());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
frame.addEventListener("load", drawOverlay);
|
|||
|
|
$("toggle-preview").addEventListener("click", event => {
|
|||
|
|
paused = !paused;
|
|||
|
|
event.currentTarget.setAttribute("aria-pressed", String(paused));
|
|||
|
|
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
|
|||
|
|
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
|
|||
|
|
if (!paused) poll();
|
|||
|
|
});
|
|||
|
|
$("edit-zone").addEventListener("click", event => {
|
|||
|
|
editMode = !editMode;
|
|||
|
|
event.currentTarget.setAttribute("aria-pressed", String(editMode));
|
|||
|
|
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
|
|||
|
|
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
|
|||
|
|
});
|
|||
|
|
canvas.addEventListener("pointerdown", event => {
|
|||
|
|
if (!editMode) return;
|
|||
|
|
const points = parsePoints(false);
|
|||
|
|
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
|
|||
|
|
const rect = canvas.getBoundingClientRect();
|
|||
|
|
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
|
|||
|
|
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
|
|||
|
|
});
|
|||
|
|
$("undo-point").addEventListener("click", () => {
|
|||
|
|
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
|
|||
|
|
});
|
|||
|
|
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
|
|||
|
|
$("zone-points").addEventListener("input", drawOverlay);
|
|||
|
|
$("zone-form").addEventListener("submit", async event => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
const points = parsePoints(true); if (!points.length) return;
|
|||
|
|
const name = $("zone-name").value.trim();
|
|||
|
|
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
|
|||
|
|
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
|
|||
|
|
if (runtimeAvailable) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
|
|||
|
|
if (!response.ok) throw new Error();
|
|||
|
|
const zone = await response.json();
|
|||
|
|
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
|
|||
|
|
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
|
|||
|
|
} else {
|
|||
|
|
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
|
|||
|
|
}
|
|||
|
|
button.disabled = false; button.textContent = "保存区域版本";
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
poll();
|
|||
|
|
window.setInterval(poll, 500);
|
|||
|
|
})();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|