Yogendran commited on
Commit
9b76f08
·
0 Parent(s):

Face tracker web demo

Browse files
Files changed (5) hide show
  1. README.md +61 -0
  2. app.py +104 -0
  3. face_engine.py +50 -0
  4. identity_store.py +45 -0
  5. requirements.txt +17 -0
README.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Face Tracker Demo
3
+ emoji: 🎥
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: "5.9.1"
8
+ app_file: app.py
9
+ python_version: "3.10"
10
+ pinned: false
11
+ ---
12
+
13
+ # Face Tracker Demo (web)
14
+
15
+ Browser demo of the pipeline in `../app`: live webcam → InsightFace detection
16
+ → ArcFace embedding → ByteTrack → FAISS identity match → boxes/IDs drawn in
17
+ your browser. Nothing is recorded or stored; state lives only in your tab for
18
+ the session and is discarded on reload.
19
+
20
+ This is **not** the NUC/production app - it's a lightweight, standalone copy
21
+ tuned for a shared free CPU (smaller `buffalo_s` model pack, 320px detection,
22
+ 2 ONNX Runtime threads by default). See `../app/README.md` for the real
23
+ RTSP/webcam app this demonstrates.
24
+
25
+ ## Deploying
26
+
27
+ 1. Create a new Space at https://huggingface.co/new-space with SDK "Gradio".
28
+ 2. Push the contents of this folder (not the whole repo) to the Space's git
29
+ remote:
30
+ ```bash
31
+ cd webdemo
32
+ git init
33
+ git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
34
+ git add -A
35
+ git commit -m "Face tracker web demo"
36
+ git push space main
37
+ ```
38
+ 3. The Space builds automatically from `requirements.txt` and launches
39
+ `app.py`. First build takes a few minutes (downloads the `buffalo_s` model
40
+ pack on first request).
41
+
42
+ ## Local test before deploying
43
+
44
+ ```bash
45
+ cd webdemo
46
+ pip install -r requirements.txt
47
+ python app.py
48
+ ```
49
+
50
+ Opens at http://127.0.0.1:7860 - allow webcam access in the browser.
51
+
52
+ ## Tuning via environment variables
53
+
54
+ Set these as Space secrets/variables, or env vars for local testing:
55
+
56
+ | Variable | Default | Notes |
57
+ |---|---|---|
58
+ | `DET_SIZE` | `320` | detector input size |
59
+ | `MODEL_NAME` | `buffalo_s` | or `buffalo_l` for more accuracy, slower |
60
+ | `ONNX_THREADS` | `2` | match the Space's CPU tier core count |
61
+ | `MATCH_THRESHOLD` | `0.38` | cosine similarity for matching a face to an existing ID |
app.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser-based live demo: face detection + tracking + per-session persistent ID.
2
+
3
+ Deploy target: Hugging Face Spaces (Gradio SDK, Python 3.10, free CPU Basic).
4
+ This is a demo of the pipeline in ../app, not the NUC/production app itself -
5
+ no RTSP, no SQLite, nothing saved. Each visitor gets their own tracker and
6
+ FAISS gallery for the lifetime of their browser tab (held in gr.State);
7
+ nothing is stored server-side or shared between visitors.
8
+ """
9
+ import os
10
+ from typing import Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+ import gradio as gr
14
+ import supervision as sv
15
+
16
+ from face_engine import FaceEngine
17
+ from identity_store import IdentityStore
18
+
19
+ DET_SIZE = int(os.environ.get("DET_SIZE", "320"))
20
+ MODEL_NAME = os.environ.get("MODEL_NAME", "buffalo_s")
21
+ ONNX_THREADS = int(os.environ.get("ONNX_THREADS", "2"))
22
+ MATCH_THRESHOLD = float(os.environ.get("MATCH_THRESHOLD", "0.38"))
23
+
24
+ box_annotator = sv.BoxAnnotator()
25
+ label_annotator = sv.LabelAnnotator()
26
+
27
+ # One shared FaceEngine process-wide (loading the ONNX models per visitor
28
+ # would be far too slow); it holds no per-visitor state, so this is safe.
29
+ face_engine = FaceEngine(
30
+ det_size=(DET_SIZE, DET_SIZE),
31
+ ctx_id=-1,
32
+ model_name=MODEL_NAME,
33
+ num_threads=ONNX_THREADS,
34
+ )
35
+
36
+ SessionState = Tuple[sv.ByteTrack, IdentityStore, Dict[int, int]]
37
+
38
+
39
+ def build_detections(faces: list) -> sv.Detections:
40
+ if not faces:
41
+ return sv.Detections.empty()
42
+
43
+ xyxy = np.array([face.bbox for face in faces], dtype=np.float32)
44
+ confidence = np.array([face.det_score for face in faces], dtype=np.float32)
45
+ embeddings = np.array([face.normed_embedding for face in faces], dtype=np.float32)
46
+ class_id = np.zeros(len(faces), dtype=int)
47
+
48
+ return sv.Detections(
49
+ xyxy=xyxy,
50
+ confidence=confidence,
51
+ class_id=class_id,
52
+ data={"embedding": embeddings},
53
+ )
54
+
55
+
56
+ def process_frame(
57
+ frame_rgb: Optional[np.ndarray], state: Optional[SessionState]
58
+ ) -> Tuple[Optional[np.ndarray], SessionState]:
59
+ if state is None:
60
+ state = (sv.ByteTrack(), IdentityStore(embedding_dim=512, match_threshold=MATCH_THRESHOLD), {})
61
+ tracker, identity_store, track_to_person = state
62
+
63
+ if frame_rgb is None:
64
+ return None, state
65
+
66
+ # gradio's numpy Image type is RGB; our pipeline (InsightFace, supervision) expects BGR.
67
+ frame_bgr = frame_rgb[:, :, ::-1]
68
+
69
+ faces = face_engine.detect(frame_bgr)
70
+ detections = build_detections(faces)
71
+ detections = tracker.update_with_detections(detections)
72
+
73
+ labels: List[str] = []
74
+ embeddings = detections.data.get("embedding") if detections.data else None
75
+ for i in range(len(detections)):
76
+ track_id = int(detections.tracker_id[i])
77
+ if embeddings is not None:
78
+ person_id = identity_store.match_or_register(embeddings[i])
79
+ track_to_person[track_id] = person_id
80
+ person_id = track_to_person.get(track_id, track_id)
81
+ labels.append(f"ID {person_id}")
82
+
83
+ annotated_bgr = box_annotator.annotate(scene=frame_bgr.copy(), detections=detections)
84
+ annotated_bgr = label_annotator.annotate(scene=annotated_bgr, detections=detections, labels=labels)
85
+
86
+ return annotated_bgr[:, :, ::-1], (tracker, identity_store, track_to_person)
87
+
88
+
89
+ with gr.Blocks(title="Face Tracker Demo") as demo:
90
+ gr.Markdown(
91
+ "# Real-time multi-person face identification & tracking\n"
92
+ "Allow webcam access below. Each detected face gets a box and a "
93
+ "persistent `ID n` for as long as this tab stays open. Nothing is "
94
+ "recorded, stored, or sent anywhere beyond this page - it all resets "
95
+ "when you reload."
96
+ )
97
+ state = gr.State(None)
98
+ with gr.Row():
99
+ webcam = gr.Image(sources=["webcam"], streaming=True, label="Your webcam", mirror_webcam=True)
100
+ output = gr.Image(label="Detected + tracked", streaming=True)
101
+ webcam.stream(fn=process_frame, inputs=[webcam, state], outputs=[output, state])
102
+
103
+ if __name__ == "__main__":
104
+ demo.queue().launch()
face_engine.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thin wrapper around InsightFace's FaceAnalysis (RetinaFace detector + ArcFace embedder).
2
+
3
+ Intentionally duplicated from ../app/face_engine.py: this folder is a separate,
4
+ self-contained deployable (Hugging Face Spaces web demo) with its own
5
+ requirements.txt (headless OpenCV, no GUI deps) and its own tuning defaults for
6
+ a shared/free CPU, independent of the NUC/local app in ../app.
7
+ """
8
+ from typing import List, Optional, Tuple
9
+
10
+ import numpy as np
11
+ import onnxruntime
12
+ from insightface.app import FaceAnalysis
13
+ from insightface.app.common import Face
14
+
15
+ # We only ever read .bbox, .det_score, .normed_embedding, so the landmark_3d_68,
16
+ # landmark_2d_106 and genderage models are pure overhead: excluding them via
17
+ # allowed_modules skips loading AND running 3 extra ONNX forward passes per face.
18
+ _REQUIRED_MODULES = ["detection", "recognition"]
19
+
20
+
21
+ class FaceEngine:
22
+ """Detects faces and produces L2-normalized 512-d ArcFace embeddings."""
23
+
24
+ def __init__(
25
+ self,
26
+ det_size: Tuple[int, int] = (320, 320),
27
+ ctx_id: int = -1,
28
+ model_name: str = "buffalo_s",
29
+ num_threads: Optional[int] = None,
30
+ ) -> None:
31
+ # ctx_id=-1 -> CPUExecutionProvider only; ctx_id>=0 -> CUDAExecutionProvider device id.
32
+ providers = ["CPUExecutionProvider"] if ctx_id < 0 else ["CUDAExecutionProvider", "CPUExecutionProvider"]
33
+
34
+ sess_options = None
35
+ if num_threads is not None and num_threads > 0:
36
+ sess_options = onnxruntime.SessionOptions()
37
+ sess_options.intra_op_num_threads = num_threads
38
+ sess_options.inter_op_num_threads = 1
39
+
40
+ self.app = FaceAnalysis(
41
+ name=model_name,
42
+ providers=providers,
43
+ allowed_modules=_REQUIRED_MODULES,
44
+ sess_options=sess_options,
45
+ )
46
+ self.app.prepare(ctx_id=ctx_id, det_size=det_size)
47
+
48
+ def detect(self, frame_bgr: np.ndarray) -> List[Face]:
49
+ """Returns InsightFace Face objects with .bbox, .det_score, .normed_embedding (512-d, L2-normed)."""
50
+ return self.app.get(frame_bgr)
identity_store.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory FAISS gallery assigning persistent person IDs to face embeddings.
2
+
3
+ Duplicated from ../app/identity_store.py for this standalone web demo deployable
4
+ (see face_engine.py for why). Here, one instance lives per browser session
5
+ (held in gr.State), not per process - nothing persists between visitors.
6
+ """
7
+ from typing import List
8
+
9
+ import faiss
10
+ import numpy as np
11
+
12
+
13
+ class IdentityStore:
14
+ """Cosine-similarity gallery over L2-normalized ArcFace embeddings.
15
+
16
+ Uses FAISS IndexFlatIP (inner product), which equals cosine similarity
17
+ when all stored and query vectors are L2-normalized.
18
+ """
19
+
20
+ def __init__(self, embedding_dim: int = 512, match_threshold: float = 0.38) -> None:
21
+ self.embedding_dim = embedding_dim
22
+ self.match_threshold = match_threshold
23
+ self.index = faiss.IndexFlatIP(embedding_dim)
24
+ self._next_person_id = 1
25
+ self.person_ids: List[int] = []
26
+
27
+ def match_or_register(self, normed_embedding: np.ndarray) -> int:
28
+ """Finds the closest known identity; registers a new one if none is close enough."""
29
+ query = normed_embedding.astype(np.float32).reshape(1, -1)
30
+
31
+ if self.index.ntotal > 0:
32
+ similarities, indices = self.index.search(query, 1)
33
+ best_similarity = float(similarities[0, 0])
34
+ best_index = int(indices[0, 0])
35
+ if best_similarity >= self.match_threshold:
36
+ return self.person_ids[best_index]
37
+
38
+ person_id = self._next_person_id
39
+ self._next_person_id += 1
40
+ self.index.add(query)
41
+ self.person_ids.append(person_id)
42
+ return person_id
43
+
44
+ def __len__(self) -> int:
45
+ return self.index.ntotal
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Standalone deployable for Hugging Face Spaces (Python 3.10, CPU Basic).
2
+ # Headless OpenCV (no GUI bindings/libGL needed) - do NOT use opencv-python
3
+ # here, it will fail to import on the Space's headless Linux container.
4
+ numpy==1.26.4
5
+ opencv-python-headless==4.10.0.84
6
+ onnxruntime==1.17.3
7
+ insightface==0.7.3
8
+ faiss-cpu==1.7.4
9
+ supervision==0.24.0
10
+ gradio==5.9.1
11
+ # Pinned: pydantic>=2.11 changed how it emits JSON schema for open/Any-typed
12
+ # values (e.g. our gr.State tuple) to `additionalProperties: true` (a bool).
13
+ # gradio_client's schema walker doesn't handle that and crashes on every page
14
+ # load ("TypeError: argument of type 'bool' is not iterable"). insightface's
15
+ # albumentations dependency requires pydantic>=2.9.2, so stay on the 2.10.x
16
+ # line - the last one before the regression - rather than going lower.
17
+ pydantic==2.10.6