keytic's picture
Add local Web demo (FastAPI + OpenCV, no browser permission needed)
5d1e2e6 verified
Raw
History Blame Contribute Delete
10.1 kB
<!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 &nbsp;·&nbsp; 👌 <b>ok</b> 开关切换 &nbsp;·&nbsp;<b>fist</b> 上一张<br/>
🖐️ <b>palm</b> 下一张 &nbsp;·&nbsp; ✌️ <b>peace</b> 暂停/恢复 &nbsp;·&nbsp; 🤘 <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) {
// 视频
// (video 持续加载 /video,浏览器会自动刷新)
// 当前手势
curGestureEl.textContent = s.curGesture ? (ZH[s.curGesture] || s.curGesture) : "—";
// Top1 + bars
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>