keytic commited on
Commit
b43e89e
·
verified ·
1 Parent(s): 690853a

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

Browse files
Files changed (1) hide show
  1. local_web/serve.py +310 -0
local_web/serve.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 手势识别 · 本地 Web 版(FastAPI + OpenCV + ONNX Runtime)
5
+ - 摄像头由后端直接打开(无需浏览器授权,零权限问题)
6
+ - 浏览器只显示视频流和交互控制台
7
+ - 实时手势识别 + 手势驱动页面交互(点赞/开关/相册/暂停/重置)
8
+
9
+ 用法:
10
+ pip install -r requirements.txt
11
+ python serve.py
12
+ # 浏览器打开 http://localhost:8000
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import sys
19
+ import threading
20
+ import time
21
+
22
+ import cv2
23
+ import numpy as np
24
+ import onnxruntime as ort
25
+ from fastapi import FastAPI
26
+ from fastapi.responses import HTMLResponse, StreamingResponse
27
+ from fastapi.staticfiles import StaticFiles
28
+ from PIL import Image
29
+
30
+ # 让 Windows 控制台能打印 emoji
31
+ for _s in (sys.stdout, sys.stderr):
32
+ if hasattr(_s, "reconfigure"):
33
+ _s.reconfigure(encoding="utf-8", errors="replace")
34
+
35
+ # ---------- 常量 ----------
36
+ MEAN = np.array([0.5, 0.5, 0.5], dtype=np.float32)
37
+ STD = np.array([0.5, 0.5, 0.5], dtype=np.float32)
38
+ CONF_THRESHOLD = 0.3
39
+ STABLE_FRAMES = 2
40
+
41
+ ZH = {
42
+ "call": "✋ 招手 / 呼叫", "dislike": "👎 倒赞(拇指朝下)", "fist": "✊ 握拳",
43
+ "four": "🖖 四指张开(4)", "like": "👍 点赞(拇指朝上)", "mute": "🤫 噤声(食指贴唇)",
44
+ "ok": "👌 OK(拇指食指环)", "one": "☝️ 食指(1)", "palm": "🖐️ 手掌张开",
45
+ "peace": "✌️ 和平(V 字)", "peace_inverted": "✌️ 倒 V 和平", "rock": "🤘 摇滚(角)",
46
+ "stop": "🛑 停(手掌向前)", "stop_inverted": "🛑 倒停", "three": "🤟 三指(3)",
47
+ "three2": "🤟 三指变体(3)", "two_up": "✌️ 双指(2)", "two_up_inverted": "✌️ 倒双指(2)",
48
+ }
49
+
50
+ GALLERY_FILES = ["like.jpg", "ok.jpg", "peace.jpg", "fist.jpg", "rock.jpg", "palm.jpg"]
51
+ GALLERY_LABELS = ["👍 点赞", "👌 OK", "✌️ 和平", "✊ 握拳", "🤘 摇滚", "🖐️ 手掌"]
52
+
53
+ HERE = os.path.dirname(os.path.abspath(__file__))
54
+
55
+
56
+ # ---------- 应用主体 ----------
57
+ class GestureApp:
58
+ def __init__(self, model_dir: str, examples_dir: str, camera_index: int, interval_ms: int):
59
+ # 模型
60
+ self.session = ort.InferenceSession(
61
+ os.path.join(model_dir, "model_quantized.onnx"),
62
+ providers=["CPUExecutionProvider"],
63
+ )
64
+ self.input_name = self.session.get_inputs()[0].name
65
+ cfg = json.load(open(os.path.join(model_dir, "config.json"), encoding="utf-8"))
66
+ self.labels = [cfg["id2label"][str(i)] for i in range(len(cfg["id2label"]))]
67
+ print(f"✅ 已加载模型: {model_dir}({len(self.labels)} 类)")
68
+
69
+ # 摄像头
70
+ self.cap = cv2.VideoCapture(camera_index)
71
+ if not self.cap.isOpened():
72
+ print(f"⚠️ 无法打开摄像头 index={camera_index},将以无摄像头模式运行(仅显示示例与日志)")
73
+ else:
74
+ print(f"✅ 已打开摄像头 index={camera_index}({int(self.cap.get(3))}x{int(self.cap.get(4))})")
75
+
76
+ self.examples_dir = examples_dir
77
+ self.interval = interval_ms / 1000
78
+ self.lock = threading.Lock()
79
+ self.running = True
80
+
81
+ # 状态
82
+ self.gallery_idx = 0
83
+ self.like_count = 0
84
+ self.toggle_on = False
85
+ self.paused = False
86
+ self.stable_gesture = None
87
+ self.cand_gesture = None
88
+ self.cand_count = 0
89
+ self.cur_gesture = None
90
+ self.cur_score = 0.0
91
+ self.top5 = []
92
+ self.logs = []
93
+ self.latest_jpeg = None # 镜像后的 jpeg bytes
94
+
95
+ self.thread = threading.Thread(target=self._loop, daemon=True)
96
+ self.thread.start()
97
+
98
+ @staticmethod
99
+ def softmax(x: np.ndarray) -> np.ndarray:
100
+ e = np.exp(x - x.max(-1, keepdims=True))
101
+ return e / e.sum(-1, keepdims=True)
102
+
103
+ def _preprocess(self, bgr: np.ndarray) -> np.ndarray:
104
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
105
+ img = Image.fromarray(rgb).resize((224, 224), Image.Resampling.BILINEAR)
106
+ a = np.asarray(img, dtype=np.float32) / 255.0
107
+ a = (a - MEAN) / STD
108
+ return a.transpose(2, 0, 1)[None]
109
+
110
+ def _log(self, msg: str):
111
+ t = time.strftime("%H:%M:%S")
112
+ with self.lock:
113
+ self.logs.insert(0, {"msg": msg, "t": t})
114
+ if len(self.logs) > 6:
115
+ self.logs.pop()
116
+ print(f"[{t}] {msg}")
117
+
118
+ def _trigger(self, g: str):
119
+ with self.lock:
120
+ if g == "like":
121
+ self.like_count += 1
122
+ self._log(f"👍 点赞 +1,当前 {self.like_count}")
123
+ elif g == "ok":
124
+ self.toggle_on = not self.toggle_on
125
+ self._log(f"👌 开关切换为「{'开' if self.toggle_on else '关'}」")
126
+ elif g == "fist":
127
+ self.gallery_idx = (self.gallery_idx - 1) % len(GALLERY_FILES)
128
+ self._log(f"✊ 相册上一张 → {GALLERY_LABELS[self.gallery_idx]}")
129
+ elif g == "palm":
130
+ self.gallery_idx = (self.gallery_idx + 1) % len(GALLERY_FILES)
131
+ self._log(f"🖐️ 相册下一张 → {GALLERY_LABELS[self.gallery_idx]}")
132
+ elif g == "peace":
133
+ self.paused = not self.paused
134
+ self._log(f"✌️ 交互已{'暂停' if self.paused else '恢复'}")
135
+ elif g == "rock":
136
+ self.like_count = 0
137
+ self.logs = []
138
+ self._log("🤘 已重置点赞与日志")
139
+
140
+ def _handle(self, label: str, score: float):
141
+ with self.lock:
142
+ self.cur_gesture, self.cur_score = label, score
143
+ if score < CONF_THRESHOLD:
144
+ self.cand_gesture = None
145
+ self.cand_count = 0
146
+ return
147
+ if label == self.cand_gesture:
148
+ self.cand_count += 1
149
+ else:
150
+ self.cand_gesture = label
151
+ self.cand_count = 1
152
+ if self.cand_count >= STABLE_FRAMES and label != self.stable_gesture:
153
+ self.stable_gesture = label
154
+ if self.paused and label != "peace":
155
+ return
156
+ self._trigger(label)
157
+
158
+ def _loop(self):
159
+ while self.running:
160
+ frame = None
161
+ if self.cap.isOpened():
162
+ ok, frame = self.cap.read()
163
+ if not ok:
164
+ frame = None
165
+ if frame is not None:
166
+ frame_m = cv2.flip(frame, 1) # 镜像
167
+ with self.lock:
168
+ paused = self.paused
169
+ if not paused:
170
+ x = self._preprocess(frame_m)
171
+ logits = self.session.run(None, {self.input_name: x})[0]
172
+ probs = self.softmax(logits)[0]
173
+ idx = probs.argsort()[::-1][:5]
174
+ top = [(self.labels[i], float(probs[i])) for i in idx]
175
+ with self.lock:
176
+ self.top5 = top
177
+ self._handle(top[0][0], top[0][1])
178
+
179
+ with self.lock:
180
+ cg, cs = self.cur_gesture, self.cur_score
181
+ paused = self.paused
182
+ if cg:
183
+ label_text = f"{ZH.get(cg, cg)} {cs * 100:.0f}%"
184
+ cv2.putText(frame_m, label_text, (16, 36),
185
+ cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2, cv2.LINE_AA)
186
+ if paused:
187
+ cv2.putText(frame_m, "PAUSED", (frame_m.shape[1] - 200, 36),
188
+ cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 2, cv2.LINE_AA)
189
+
190
+ _, buf = cv2.imencode(".jpg", frame_m, [cv2.IMWRITE_JPEG_QUALITY, 80])
191
+ with self.lock:
192
+ self.latest_jpeg = buf.tobytes()
193
+ time.sleep(self.interval)
194
+
195
+ # 公共方法
196
+ def get_jpeg(self):
197
+ with self.lock:
198
+ return self.latest_jpeg
199
+
200
+ def get_state(self):
201
+ with self.lock:
202
+ return {
203
+ "top": self.top5,
204
+ "curGesture": self.cur_gesture,
205
+ "curScore": self.cur_score,
206
+ "galleryIdx": self.gallery_idx,
207
+ "likeCount": self.like_count,
208
+ "toggleOn": self.toggle_on,
209
+ "paused": self.paused,
210
+ "logs": list(self.logs),
211
+ }
212
+
213
+ def prev_gallery(self):
214
+ with self.lock:
215
+ self.gallery_idx = (self.gallery_idx - 1) % len(GALLERY_FILES)
216
+ self._log(f"🖱️ 手动: 相册上一张 → {GALLERY_LABELS[self.gallery_idx]}")
217
+
218
+ def next_gallery(self):
219
+ with self.lock:
220
+ self.gallery_idx = (self.gallery_idx + 1) % len(GALLERY_FILES)
221
+ self._log(f"🖱️ 手动: 相册下一张 → {GALLERY_LABELS[self.gallery_idx]}")
222
+
223
+
224
+ # ---------- FastAPI ----------
225
+ app = FastAPI(title="本地手势识别")
226
+
227
+
228
+ def create_app(model_dir, examples_dir, camera_index, interval_ms):
229
+ gesture = GestureApp(model_dir, examples_dir, camera_index, interval_ms)
230
+
231
+ # 相册图片静态服务
232
+ if os.path.isdir(examples_dir):
233
+ app.mount("/gallery", StaticFiles(directory=examples_dir), name="gallery")
234
+
235
+ @app.get("/", response_class=HTMLResponse)
236
+ def index():
237
+ with open(os.path.join(HERE, "index.html"), encoding="utf-8") as f:
238
+ return f.read()
239
+
240
+ @app.get("/video")
241
+ def video():
242
+ def gen():
243
+ boundary = b"--frame\r\n"
244
+ while True:
245
+ jpg = gesture.get_jpeg()
246
+ if jpg is None:
247
+ time.sleep(0.1)
248
+ continue
249
+ yield (boundary
250
+ + b"Content-Type: image/jpeg\r\n"
251
+ + b"Content-Length: " + str(len(jpg)).encode() + b"\r\n\r\n"
252
+ + jpg + b"\r\n")
253
+ time.sleep(1 / 30)
254
+ return StreamingResponse(gen(), media_type="multipart/x-mixed-replace; boundary=frame")
255
+
256
+ @app.get("/state")
257
+ def state():
258
+ return gesture.get_state()
259
+
260
+ @app.post("/action/prev")
261
+ def action_prev():
262
+ gesture.prev_gallery()
263
+ return {"ok": True}
264
+
265
+ @app.post("/action/next")
266
+ def action_next():
267
+ gesture.next_gallery()
268
+ return {"ok": True}
269
+
270
+ return gesture
271
+
272
+
273
+ def main():
274
+ ap = argparse.ArgumentParser(description="手势识别 · 本地 Web 服务")
275
+ ap.add_argument("--model-dir", default=os.path.dirname(os.path.abspath(__file__)),
276
+ help="模型目录(含 model_quantized.onnx 与 config.json)")
277
+ ap.add_argument("--examples-dir", default=None, help="示例图目录(相册)")
278
+ ap.add_argument("--camera", type=int, default=0, help="摄像头 index")
279
+ ap.add_argument("--interval-ms", type=int, default=400, help="识别间隔(毫秒)")
280
+ ap.add_argument("--host", default="127.0.0.1", help="监听地址")
281
+ ap.add_argument("--port", type=int, default=8000, help="监听端口")
282
+ args = ap.parse_args()
283
+
284
+ # 自动寻找 examples 目录
285
+ if args.examples_dir is None:
286
+ for cand in [
287
+ os.path.join(args.model_dir, "examples"),
288
+ os.path.join(HERE, "examples"),
289
+ os.path.join(os.path.dirname(HERE), "static-space", "examples"),
290
+ ]:
291
+ if os.path.isdir(cand):
292
+ args.examples_dir = cand
293
+ break
294
+ if not args.examples_dir or not os.path.isdir(args.examples_dir):
295
+ print("⚠️ 未找到 examples 目录,相册可能不可用")
296
+ args.examples_dir = HERE
297
+
298
+ print(f"📦 模型: {args.model_dir}")
299
+ print(f"🖼️ 示例图: {args.examples_dir}")
300
+ print(f"🎥 摄像头: index={args.camera}")
301
+ print(f"🌐 打开浏览器访问: http://{args.host}:{args.port}")
302
+ print("(按 Ctrl+C 退出)\n")
303
+
304
+ create_app(args.model_dir, args.examples_dir, args.camera, args.interval_ms)
305
+ import uvicorn
306
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()