| <!DOCTYPE html>
|
| <html lang="zh-CN">
|
| <head>
|
| <meta charset="UTF-8" />
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| <title>本地手势识别 · 控制台</title>
|
| <style>
|
| :root {
|
| --bg1: #0f172a; --bg2: #1e293b; --accent: #6366f1; --accent2: #22d3ee;
|
| --text: #e2e8f0; --muted: #94a3b8; --card: rgba(255,255,255,0.04);
|
| }
|
| * { box-sizing: border-box; }
|
| body {
|
| margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
| background: radial-gradient(1200px 600px at 70% -10%, #312e81 0%, transparent 60%),
|
| linear-gradient(160deg, var(--bg1), var(--bg2));
|
| color: var(--text); min-height: 100vh; padding: 32px 16px;
|
| }
|
| .wrap { max-width: 900px; margin: 0 auto; }
|
| header h1 { font-size: 28px; margin: 0 0 6px;
|
| background: linear-gradient(90deg, var(--accent2), var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
| header p { color: var(--muted); margin: 0 0 24px; line-height: 1.6; }
|
| .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| @media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
|
| .card { background: var(--card); border: 1px solid rgba(255,255,255,0.08);
|
| border-radius: 16px; padding: 18px; backdrop-filter: blur(6px); }
|
| #stage { border: 2px dashed rgba(99,102,241,0.5); border-radius: 14px; min-height: 260px;
|
| display: flex; align-items: center; justify-content: center; text-align: center;
|
| position: relative; overflow: hidden; background: #000; }
|
| #stage img#video { max-width: 100%; max-height: 360px; border-radius: 12px; display: block; }
|
| #stage .ph { color: var(--muted); padding: 30px; }
|
| .btns { margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| button { background: linear-gradient(90deg, var(--accent), var(--accent2)); color: #fff; border: 0;
|
| padding: 10px 16px; border-radius: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
|
| button.ghost { background: rgba(255,255,255,0.08); }
|
| button:disabled { opacity: .5; cursor: not-allowed; }
|
| .status { margin-top: 12px; font-size: 13px; color: var(--muted); min-height: 18px; }
|
| .bar { margin: 10px 0; }
|
| .bar .row { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 4px; }
|
| .bar .track { height: 10px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden; }
|
| .bar .fill { height: 100%; width: 0; border-radius: 6px;
|
| background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width .3s ease; }
|
| #top1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; min-height: 26px; }
|
| .hint { color: var(--muted); font-size: 12px; }
|
|
|
| .ctrl { margin-top: 20px; }
|
| .ctrl-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
|
| .ctrl-title { font-size: 16px; font-weight: 700; }
|
| .cur-gesture { font-size: 24px; font-weight: 800; white-space: nowrap;
|
| background: linear-gradient(90deg, var(--accent2), var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
| .ctrl-grid { display: grid; grid-template-columns: 240px 1fr; gap: 16px; }
|
| @media (max-width: 640px) { .ctrl-grid { grid-template-columns: 1fr; } }
|
| .gallery-box { text-align: center; }
|
| .gallery-box img { width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 12px; }
|
| .gallery-box .glabel { margin: 8px 0; font-weight: 600; }
|
| .widgets { display: flex; flex-direction: column; gap: 10px; }
|
| .wrow { display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.05);
|
| border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; }
|
| .wrow .val { font-size: 18px; font-weight: 800; }
|
| .map { font-size: 12px; color: var(--muted); line-height: 1.8; }
|
| .logbox { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; }
|
| .logbox .title { font-size: 13px; color: var(--muted); margin-bottom: 8px; }
|
| .log-item { font-size: 13px; padding: 5px 0; border-bottom: 1px dashed rgba(255,255,255,0.06); }
|
| .log-item .t { color: var(--muted); font-size: 11px; margin-left: 8px; }
|
| a { color: var(--accent2); text-decoration: none; }
|
| </style>
|
| </head>
|
| <body>
|
| <div class="wrap">
|
| <header>
|
| <h1>🖐️ 本地手势识别 · 控制台</h1>
|
| <p>由本地后端(<code>serve.py</code>,FastAPI + OpenCV)打开摄像头并做实时识别,
|
| 浏览器只显示视频流和交互控制台,<b>无需摄像头授权</b>,无跨域问题。
|
| 识别结果与状态由后端通过 <code>/state</code> 推送给前端。</p>
|
| </header>
|
|
|
| <div class="grid">
|
| <div class="card">
|
| <div id="stage">
|
| <div class="ph" id="videoPh">等待视频流…<br/><span class="hint">(后端需成功打开摄像头)</span></div>
|
| <img id="video" alt="video stream" />
|
| </div>
|
| <div class="status" id="status">已连接到本地服务。</div>
|
| </div>
|
|
|
| <div class="card">
|
| <div id="top1">等待识别…</div>
|
| <div id="bars"></div>
|
| <div class="hint">后端每 0.4s 取一帧做推理(可在启动时用 <code>--interval-ms</code> 调整)。</div>
|
| </div>
|
| </div>
|
|
|
| <div class="card ctrl">
|
| <div class="ctrl-head">
|
| <div>
|
| <div class="ctrl-title">🎮 手势交互控制台</div>
|
| <div class="hint">后端做稳定判定(连续 2 帧 ≈ 1s)。识别到稳定手势即触发一次动作。</div>
|
| </div>
|
| <div class="cur-gesture" id="curGesture">—</div>
|
| </div>
|
| <div class="ctrl-grid">
|
| <div class="gallery-box">
|
| <img id="galleryImg" src="/gallery/like.jpg" alt="gallery" />
|
| <div class="glabel" id="galleryLabel">👍 点赞</div>
|
| <div class="btns" style="margin-top:6px;justify-content:center;">
|
| <button class="ghost" id="prevBtn">◀ 上一张</button>
|
| <button class="ghost" id="nextBtn">下一张 ▶</button>
|
| </div>
|
| </div>
|
| <div class="widgets">
|
| <div class="wrow"><span>❤️ 点赞</span><span class="val" id="likeVal">0</span></div>
|
| <div class="wrow"><span>🔘 开关</span><span class="val" id="toggleVal">关</span></div>
|
| <div class="map">
|
| 手势 → 动作:<br/>
|
| 👍 <b>like</b> 点赞 +1 · 👌 <b>ok</b> 开关切换 · ✊ <b>fist</b> 上一张<br/>
|
| 🖐️ <b>palm</b> 下一张 · ✌️ <b>peace</b> 暂停/恢复 · 🤘 <b>rock</b> 重置
|
| </div>
|
| </div>
|
| </div>
|
| <div class="logbox">
|
| <div class="title">交互日志(最近 6 条)</div>
|
| <div id="log"></div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <script>
|
| const ZH = {
|
| call:"✋ 招手 / 呼叫", dislike:"👎 倒赞(拇指朝下)", fist:"✊ 握拳",
|
| four:"🖖 四指张开(4)", like:"👍 点赞(拇指朝上)", mute:"🤫 噤声(食指贴唇)",
|
| ok:"👌 OK(拇指食指环)", one:"☝️ 食指(1)", palm:"🖐️ 手掌张开",
|
| peace:"✌️ 和平(V 字)", peace_inverted:"✌️ 倒 V 和平", rock:"🤘 摇滚(角)",
|
| stop:"🛑 停(手掌向前)", stop_inverted:"🛑 倒停", three:"🤟 三指(3)",
|
| three2:"🤟 三指变体(3)", two_up:"✌️ 双指(2)", two_up_inverted:"✌️ 倒双指(2)",
|
| };
|
| const GALLERY_FILES = ["like.jpg","ok.jpg","peace.jpg","fist.jpg","rock.jpg","palm.jpg"];
|
| const GALLERY_LABELS = ["👍 点赞","👌 OK","✌️ 和平","✊ 握拳","🤘 摇滚","🖐️ 手掌"];
|
|
|
| const $ = (id) => document.getElementById(id);
|
| const stage = $("stage"), video = $("video"), videoPh = $("videoPh");
|
| const curGestureEl = $("curGesture"), galleryImg = $("galleryImg"), galleryLabel = $("galleryLabel");
|
| const likeVal = $("likeVal"), toggleVal = $("toggleVal"), logEl = $("log"), top1El = $("top1"), barsEl = $("bars"), statusEl = $("status");
|
|
|
| let lastGalleryIdx = -1;
|
|
|
| async function poll() {
|
| try {
|
| const r = await fetch("/state", { cache: "no-store" });
|
| if (!r.ok) throw new Error("status " + r.status);
|
| const s = await r.json();
|
| render(s);
|
| statusEl.textContent = "✅ 已连接,识别中…";
|
| videoPh.style.display = "none";
|
| if (video.style.display === "none") video.style.display = "block";
|
| } catch (e) {
|
| statusEl.textContent = "❌ 无法连接本地服务:" + e.message;
|
| }
|
| }
|
|
|
| function render(s) {
|
|
|
|
|
|
|
| curGestureEl.textContent = s.curGesture ? (ZH[s.curGesture] || s.curGesture) : "—";
|
|
|
|
|
| if (s.top && s.top.length) {
|
| const top = s.top[0];
|
| top1El.textContent = (ZH[top[0]] || top[0]) + " · " + (top[1] * 100).toFixed(1) + "%";
|
| barsEl.innerHTML = s.top.map(([lbl, sc]) => `
|
| <div class="bar">
|
| <div class="row"><span>${ZH[lbl]||lbl} <span class="hint">(${lbl})</span></span><span>${(sc*100).toFixed(1)}%</span></div>
|
| <div class="track"><div class="fill" style="width:${(sc*100).toFixed(1)}%"></div></div>
|
| </div>`).join("");
|
| }
|
|
|
|
|
| if (s.galleryIdx !== lastGalleryIdx) {
|
| lastGalleryIdx = s.galleryIdx;
|
| galleryImg.src = "/gallery/" + GALLERY_FILES[s.galleryIdx] + "?t=" + Date.now();
|
| galleryLabel.textContent = GALLERY_LABELS[s.galleryIdx];
|
| }
|
|
|
|
|
| likeVal.textContent = s.likeCount;
|
| toggleVal.textContent = s.toggleOn ? "开" : "关";
|
|
|
|
|
| logEl.innerHTML = (s.logs || []).map(l => `<div class="log-item">${l.msg}<span class="t">${l.t}</span></div>`).join("");
|
| }
|
|
|
| $("prevBtn").addEventListener("click", () => fetch("/action/prev", { method: "POST" }));
|
| $("nextBtn").addEventListener("click", () => fetch("/action/next", { method: "POST" }));
|
|
|
| setInterval(poll, 300);
|
| poll();
|
| </script>
|
| </body>
|
| </html>
|
|
|