A 6.5MB Face Recognition Model Runs an Access Gate Entirely in the Browser
High-end residential communities often only need a simple access control system.
How simple?
Haha, just loading a small model in the browser is enough.
The portrait in this demo is AI-generated and used only for testing.
5 lines of core code
// demo/4/static/index.html
const r = await faceapi.detectSingleFace(img).withFaceDescriptor()
console.log("128-dimensional feature:", r.descriptor.length)
const d = Math.sqrt([...r.descriptor].reduce((s, v) => s + v * v, 0))
console.log("Magnitude:", d.toFixed(3))
After selecting an image, the browser outputs a 128-dimensional vector. No server upload, privacy preserved.
Flowchart
Upload photo
│
▼
Full-image blur + loading spinner
│
▼
Detect face + 68 keypoints
│
▼
Extract 128-dimensional feature
│
▼
Compare against whitelist
Distance < 0.6 → Open door
Distance ≥ 0.6 → Deny access
Pain Point
The company wanted an access control system. Vendor quotes were several thousand yuan per gate. The backend also required a GPU server. I wondered, could the frontend handle it? Data stays on the device, zero cost. After some research, I found face-api.js.
1. What is face-api.js
face-api.js is based on TensorFlow.js, Google's deep learning framework. Three models:
| Model | Purpose | Size |
|---|---|---|
tinyFaceDetector |
Detect faces | 190KB |
faceLandmark68Net |
68 keypoints | 90KB |
faceRecognitionNet |
128-dimensional feature | 6.3MB |
Total: 6.5MB.
2. Models
Downloaded from the jsDelivr CDN.
✓ tiny_face_detector_model-shard1 (190KB)
✓ face_landmark_68_model-shard1 (90KB)
✓ face_recognition_model-shard1 (6.3MB)
✓ face_recognition_model-shard2 (6.3MB)
All downloaded, approximately 6.5MB
Models are placed in static/models/.
3. Starting the Service
// demo/4/server.ts
Bun.serve({ port: 3003, async fetch(req) {
if (url.pathname === "/api/upload") return uploadProxy(req)
return new Response(Bun.file(join(dir, "static", url.pathname)))
}})
If BACKEND_URL is not configured, it uses mock mode; if configured, it forwards to the real backend.
$ bun run dev
http://localhost:3003
4. Loading Models
const MODEL_URL = "./models"
await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL)
await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
await faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL)
All loaded within 3 seconds.
5. Selecting an Image
For demo convenience, image upload is used. In production, this can be switched to navigator.mediaDevices.getUserMedia for camera streaming.
<input id="fileInput" type="file" accept="image/*" style="display:none" />
<button id="btnSelect">Select Face Photo</button>
fileInput.onchange = async (e) => {
const file = e.target.files[0]
if (file.size / 1024 / 1024 > 30) return setStatus("Exceeds 30MB", "err")
if (!file.type.startsWith("image/")) return setStatus("Not an image", "err")
previewImg.src = await fileToBase64(file)
}
Limit 30MB, only accepts image/*.
6. Uploading to Backend
const res = await fetch("/api/upload", {
method: "POST",
body: JSON.stringify({ name: file.name, fileBase64: base64 }),
})
return (await res.json()).data
Mock mode returns base64; real mode forwards to the backend.
7. Full-Image Blur + Loading Spinner
Before recognition, apply a Gaussian blur so the original image is not visible.
function startLoading() {
overlayCtx.filter = "blur(14px)"
overlayCtx.drawImage(previewImg, 0, 0, w, h)
// Draw rotating golden arc in the center + "Recognizing face..." + elapsed 0.0s
loadingAnimId = requestAnimationFrame(frame)
}
8. Detecting Faces
const results = await faceapi
.detectAllFaces(previewImg, new faceapi.TinyFaceDetectorOptions({
inputSize: 320, scoreThreshold: 0.5,
}))
.withFaceLandmarks()
.withFaceDescriptors()
console.log(`${results.length} faces`)
9. Drawing 68 Keypoints (5 Colors)
Colored by region, distinguishable even from a distance.
const groups = [
{ from: 0, to: 16, color: "#00e5ff" }, // Jawline
{ from: 17, to: 26, color: "#ffeb3b" }, // Eyebrows
{ from: 27, to: 35, color: "#76ff03" }, // Nose
{ from: 36, to: 47, color: "#ff1744" }, // Eyes
{ from: 48, to: 67, color: "#e040fb" }, // Mouth
]
10. Extracting 128-Dimensional Feature + Whitelist
const d = await faceapi.detectSingleFace(previewImg).withFaceDescriptor()
console.log("Dimensions:", d.descriptor.length) // 128
Stored in localStorage, persists after closing the page.
const WL_KEY = "tangchen_whitelist_v1"
const whitelist = new Map(JSON.parse(localStorage.getItem(WL_KEY) || "{}"))
btnCapture.onclick = async () => {
const name = nameInput.value.trim() // "Zhang San 2-1602"
const d = await faceapi.detectSingleFace(previewImg).withFaceDescriptor()
whitelist.set(name, Array.from(d.descriptor))
localStorage.setItem(WL_KEY, JSON.stringify(Object.fromEntries(whitelist)))
}
11. Euclidean Distance + Threshold
function euclideanDistance(a, b) {
return Math.sqrt(a.reduce((s, v, i) => s + (v - b[i]) ** 2, 0))
}
const MATCH_THRESHOLD = 0.6
const main = results.reduce((max, r) =>
r.detection.box.area > max.detection.box.area ? r : max)
let bestDist = Infinity, bestName = "Stranger"
for (const [name, stored] of whitelist) {
const d = euclideanDistance(main.descriptor, stored)
if (d < bestDist) { bestDist = d; bestName = name }
}
const accepted = bestDist < MATCH_THRESHOLD
Zhang San vs Li Si: 0.834
Zhang San vs Zhang San: 0.000
12. Access Control Result
if (accepted) setGate("open", `Welcome home, ${bestName}`, `Distance ${bestDist.toFixed(3)}`)
else setGate("deny", "Non-resident", `Nearest ${bestName}`)
┌──────────────────┐
│ Open │
│ Welcome home, │
│ Zhang San │
│ Distance 0.321 │
└──────────────────┘
13. Project Structure
demo/4/
├── server.ts # Bun service
├── scripts/download-models.ts
└── static/
├── index.html
└── models/ # 6.5MB
Code is in demo/4/. Run after downloading the models.