keytic commited on
Commit
5d1e2e6
·
verified ·
1 Parent(s): b43e89e

Add local Web demo (FastAPI + OpenCV, no browser permission needed)

Browse files
Files changed (1) hide show
  1. local_web/index.html +199 -0
local_web/index.html ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>本地手势识别 · 控制台</title>
7
+ <style>
8
+ :root {
9
+ --bg1: #0f172a; --bg2: #1e293b; --accent: #6366f1; --accent2: #22d3ee;
10
+ --text: #e2e8f0; --muted: #94a3b8; --card: rgba(255,255,255,0.04);
11
+ }
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
15
+ background: radial-gradient(1200px 600px at 70% -10%, #312e81 0%, transparent 60%),
16
+ linear-gradient(160deg, var(--bg1), var(--bg2));
17
+ color: var(--text); min-height: 100vh; padding: 32px 16px;
18
+ }
19
+ .wrap { max-width: 900px; margin: 0 auto; }
20
+ header h1 { font-size: 28px; margin: 0 0 6px;
21
+ background: linear-gradient(90deg, var(--accent2), var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
22
+ header p { color: var(--muted); margin: 0 0 24px; line-height: 1.6; }
23
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
24
+ @media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
25
+ .card { background: var(--card); border: 1px solid rgba(255,255,255,0.08);
26
+ border-radius: 16px; padding: 18px; backdrop-filter: blur(6px); }
27
+ #stage { border: 2px dashed rgba(99,102,241,0.5); border-radius: 14px; min-height: 260px;
28
+ display: flex; align-items: center; justify-content: center; text-align: center;
29
+ position: relative; overflow: hidden; background: #000; }
30
+ #stage img#video { max-width: 100%; max-height: 360px; border-radius: 12px; display: block; }
31
+ #stage .ph { color: var(--muted); padding: 30px; }
32
+ .btns { margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
33
+ button { background: linear-gradient(90deg, var(--accent), var(--accent2)); color: #fff; border: 0;
34
+ padding: 10px 16px; border-radius: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
35
+ button.ghost { background: rgba(255,255,255,0.08); }
36
+ button:disabled { opacity: .5; cursor: not-allowed; }
37
+ .status { margin-top: 12px; font-size: 13px; color: var(--muted); min-height: 18px; }
38
+ .bar { margin: 10px 0; }
39
+ .bar .row { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 4px; }
40
+ .bar .track { height: 10px; background: rgba(255,255,255,0.08); border-radius: 6px; overflow: hidden; }
41
+ .bar .fill { height: 100%; width: 0; border-radius: 6px;
42
+ background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width .3s ease; }
43
+ #top1 { font-size: 18px; font-weight: 700; margin-bottom: 6px; min-height: 26px; }
44
+ .hint { color: var(--muted); font-size: 12px; }
45
+ /* 控制台 */
46
+ .ctrl { margin-top: 20px; }
47
+ .ctrl-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
48
+ .ctrl-title { font-size: 16px; font-weight: 700; }
49
+ .cur-gesture { font-size: 24px; font-weight: 800; white-space: nowrap;
50
+ background: linear-gradient(90deg, var(--accent2), var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }
51
+ .ctrl-grid { display: grid; grid-template-columns: 240px 1fr; gap: 16px; }
52
+ @media (max-width: 640px) { .ctrl-grid { grid-template-columns: 1fr; } }
53
+ .gallery-box { text-align: center; }
54
+ .gallery-box img { width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 12px; }
55
+ .gallery-box .glabel { margin: 8px 0; font-weight: 600; }
56
+ .widgets { display: flex; flex-direction: column; gap: 10px; }
57
+ .wrow { display: flex; justify-content: space-between; align-items: center; background: rgba(255,255,255,0.05);
58
+ border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; }
59
+ .wrow .val { font-size: 18px; font-weight: 800; }
60
+ .map { font-size: 12px; color: var(--muted); line-height: 1.8; }
61
+ .logbox { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; }
62
+ .logbox .title { font-size: 13px; color: var(--muted); margin-bottom: 8px; }
63
+ .log-item { font-size: 13px; padding: 5px 0; border-bottom: 1px dashed rgba(255,255,255,0.06); }
64
+ .log-item .t { color: var(--muted); font-size: 11px; margin-left: 8px; }
65
+ a { color: var(--accent2); text-decoration: none; }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div class="wrap">
70
+ <header>
71
+ <h1>🖐️ 本地手势识别 · 控制台</h1>
72
+ <p>由本地后端(<code>serve.py</code>,FastAPI + OpenCV)打开摄像头并做实时识别,
73
+ 浏览器只显示视频流和交互控制台,<b>无需摄像头授权</b>,无跨域问题。
74
+ 识别结果与状态由后端通过 <code>/state</code> 推送给前端。</p>
75
+ </header>
76
+
77
+ <div class="grid">
78
+ <div class="card">
79
+ <div id="stage">
80
+ <div class="ph" id="videoPh">等待视频流…<br/><span class="hint">(后端需成功打开摄像头)</span></div>
81
+ <img id="video" alt="video stream" />
82
+ </div>
83
+ <div class="status" id="status">已连接到本地服务。</div>
84
+ </div>
85
+
86
+ <div class="card">
87
+ <div id="top1">等待识别…</div>
88
+ <div id="bars"></div>
89
+ <div class="hint">后端每 0.4s 取一帧做推理(可在启动时用 <code>--interval-ms</code> 调整)。</div>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="card ctrl">
94
+ <div class="ctrl-head">
95
+ <div>
96
+ <div class="ctrl-title">🎮 手势交互控制台</div>
97
+ <div class="hint">后端做稳定判定(连续 2 帧 ≈ 1s)。识别到稳定手势即触发一次动作。</div>
98
+ </div>
99
+ <div class="cur-gesture" id="curGesture">—</div>
100
+ </div>
101
+ <div class="ctrl-grid">
102
+ <div class="gallery-box">
103
+ <img id="galleryImg" src="/gallery/like.jpg" alt="gallery" />
104
+ <div class="glabel" id="galleryLabel">👍 点赞</div>
105
+ <div class="btns" style="margin-top:6px;justify-content:center;">
106
+ <button class="ghost" id="prevBtn">◀ 上一张</button>
107
+ <button class="ghost" id="nextBtn">下一张 ▶</button>
108
+ </div>
109
+ </div>
110
+ <div class="widgets">
111
+ <div class="wrow"><span>❤️ 点赞</span><span class="val" id="likeVal">0</span></div>
112
+ <div class="wrow"><span>🔘 开关</span><span class="val" id="toggleVal">关</span></div>
113
+ <div class="map">
114
+ 手势 → 动作:<br/>
115
+ 👍 <b>like</b> 点赞 +1 &nbsp;·&nbsp; 👌 <b>ok</b> 开关切换 &nbsp;·&nbsp; ✊ <b>fist</b> 上一张<br/>
116
+ 🖐️ <b>palm</b> 下一张 &nbsp;·&nbsp; ✌️ <b>peace</b> 暂停/恢复 &nbsp;·&nbsp; 🤘 <b>rock</b> 重置
117
+ </div>
118
+ </div>
119
+ </div>
120
+ <div class="logbox">
121
+ <div class="title">交互日志(最近 6 条)</div>
122
+ <div id="log"></div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <script>
128
+ const ZH = {
129
+ call:"✋ 招手 / 呼叫", dislike:"👎 倒赞(拇指朝下)", fist:"✊ 握拳",
130
+ four:"🖖 四指张开(4)", like:"👍 点赞(拇指朝上)", mute:"🤫 噤声(食指贴唇)",
131
+ ok:"👌 OK(拇指食指环)", one:"☝️ 食指(1)", palm:"🖐️ 手掌张开",
132
+ peace:"✌️ 和平(V 字)", peace_inverted:"✌️ 倒 V 和平", rock:"🤘 摇滚(角)",
133
+ stop:"🛑 停(手掌向前)", stop_inverted:"🛑 倒停", three:"🤟 三指(3)",
134
+ three2:"🤟 三指变体(3)", two_up:"✌️ 双指(2)", two_up_inverted:"✌️ 倒双指(2)",
135
+ };
136
+ const GALLERY_FILES = ["like.jpg","ok.jpg","peace.jpg","fist.jpg","rock.jpg","palm.jpg"];
137
+ const GALLERY_LABELS = ["👍 点赞","👌 OK","✌️ 和平","✊ 握拳","🤘 摇滚","🖐️ 手掌"];
138
+
139
+ const $ = (id) => document.getElementById(id);
140
+ const stage = $("stage"), video = $("video"), videoPh = $("videoPh");
141
+ const curGestureEl = $("curGesture"), galleryImg = $("galleryImg"), galleryLabel = $("galleryLabel");
142
+ const likeVal = $("likeVal"), toggleVal = $("toggleVal"), logEl = $("log"), top1El = $("top1"), barsEl = $("bars"), statusEl = $("status");
143
+
144
+ let lastGalleryIdx = -1;
145
+
146
+ async function poll() {
147
+ try {
148
+ const r = await fetch("/state", { cache: "no-store" });
149
+ if (!r.ok) throw new Error("status " + r.status);
150
+ const s = await r.json();
151
+ render(s);
152
+ statusEl.textContent = "✅ 已连接,识别中…";
153
+ videoPh.style.display = "none";
154
+ if (video.style.display === "none") video.style.display = "block";
155
+ } catch (e) {
156
+ statusEl.textContent = "❌ 无法连接本地服务:" + e.message;
157
+ }
158
+ }
159
+
160
+ function render(s) {
161
+ // 视频
162
+ // (video 持续加载 /video,浏览器会自动刷新)
163
+ // 当前手势
164
+ curGestureEl.textContent = s.curGesture ? (ZH[s.curGesture] || s.curGesture) : "—";
165
+
166
+ // Top1 + bars
167
+ if (s.top && s.top.length) {
168
+ const top = s.top[0];
169
+ top1El.textContent = (ZH[top[0]] || top[0]) + " · " + (top[1] * 100).toFixed(1) + "%";
170
+ barsEl.innerHTML = s.top.map(([lbl, sc]) => `
171
+ <div class="bar">
172
+ <div class="row"><span>${ZH[lbl]||lbl} <span class="hint">(${lbl})</span></span><span>${(sc*100).toFixed(1)}%</span></div>
173
+ <div class="track"><div class="fill" style="width:${(sc*100).toFixed(1)}%"></div></div>
174
+ </div>`).join("");
175
+ }
176
+
177
+ // 相册
178
+ if (s.galleryIdx !== lastGalleryIdx) {
179
+ lastGalleryIdx = s.galleryIdx;
180
+ galleryImg.src = "/gallery/" + GALLERY_FILES[s.galleryIdx] + "?t=" + Date.now();
181
+ galleryLabel.textContent = GALLERY_LABELS[s.galleryIdx];
182
+ }
183
+
184
+ // 点赞 / 开关
185
+ likeVal.textContent = s.likeCount;
186
+ toggleVal.textContent = s.toggleOn ? "开" : "关";
187
+
188
+ // 日志
189
+ logEl.innerHTML = (s.logs || []).map(l => `<div class="log-item">${l.msg}<span class="t">${l.t}</span></div>`).join("");
190
+ }
191
+
192
+ $("prevBtn").addEventListener("click", () => fetch("/action/prev", { method: "POST" }));
193
+ $("nextBtn").addEventListener("click", () => fetch("/action/next", { method: "POST" }));
194
+
195
+ setInterval(poll, 300);
196
+ poll();
197
+ </script>
198
+ </body>
199
+ </html>