update auto detect face
This commit is contained in:
parent
25918fcb62
commit
a23f2155dc
|
|
@ -1,4 +1,4 @@
|
|||
Run client: npm run dev or npm run build && npm run preview
|
||||
Run client: cd client && npm run dev or npm run build && npm run preview
|
||||
|
||||
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /camera/static/assets
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"face-api.js": "^0.22.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"lucide-react": "^0.556.0",
|
||||
"moment": "^2.30.1",
|
||||
|
|
@ -2445,6 +2446,22 @@
|
|||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tensorflow/tfjs-core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.0.tgz",
|
||||
"integrity": "sha512-uwQdiklNjqBnHPeseOdG0sGxrI3+d6lybaKu2+ou3ajVeKdPEwpWbgqA6iHjq1iylnOGkgkbbnQ6r2lwkiIIHw==",
|
||||
"dependencies": {
|
||||
"@types/offscreencanvas": "~2019.3.0",
|
||||
"@types/seedrandom": "2.4.27",
|
||||
"@types/webgl-ext": "0.0.30",
|
||||
"@types/webgl2": "0.0.4",
|
||||
"node-fetch": "~2.1.2",
|
||||
"seedrandom": "2.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"yarn": ">= 1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -2513,6 +2530,11 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
|
||||
"integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||
|
|
@ -2533,6 +2555,21 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/seedrandom": {
|
||||
"version": "2.4.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
|
||||
"integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ=="
|
||||
},
|
||||
"node_modules/@types/webgl-ext": {
|
||||
"version": "0.0.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
|
||||
"integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg=="
|
||||
},
|
||||
"node_modules/@types/webgl2": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz",
|
||||
"integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
||||
|
|
@ -3513,6 +3550,20 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/face-api.js": {
|
||||
"version": "0.22.2",
|
||||
"resolved": "https://registry.npmjs.org/face-api.js/-/face-api.js-0.22.2.tgz",
|
||||
"integrity": "sha512-9Bbv/yaBRTKCXjiDqzryeKhYxmgSjJ7ukvOvEBy6krA0Ah/vNBlsf7iBNfJljWiPA8Tys1/MnB3lyP2Hfmsuyw==",
|
||||
"dependencies": {
|
||||
"@tensorflow/tfjs-core": "1.7.0",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/face-api.js/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -4430,6 +4481,14 @@
|
|||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
|
||||
"integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==",
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
|
|
@ -4765,6 +4824,11 @@
|
|||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
|
||||
"integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"face-api.js": "^0.22.2",
|
||||
"framer-motion": "^12.23.25",
|
||||
"lucide-react": "^0.556.0",
|
||||
"moment": "^2.30.1",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
[{"weights":[{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}],"paths":["tiny_face_detector_model-shard1"]}]
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
export type FaceZoomTransform = {
|
||||
scale: number;
|
||||
translateX: number; // percent of element width
|
||||
translateY: number; // percent of element height
|
||||
};
|
||||
|
||||
export type FaceBox = {
|
||||
/** All values are ratios (0..1) of the source video frame. */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type FaceZoomResult = {
|
||||
transform: FaceZoomTransform;
|
||||
box: FaceBox | null;
|
||||
/** Detector confidence (0..1). */
|
||||
confidence: number;
|
||||
/** 0..1 fill progress of the stable-face timer. Updates at 60fps. */
|
||||
stabilityProgress: number;
|
||||
};
|
||||
|
||||
const IDLE_TRANSFORM: FaceZoomTransform = {
|
||||
scale: 1,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
};
|
||||
|
||||
type Options = {
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
targetFaceRatio?: number;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
/** Per-frame lerp factor at 60fps (0..1). Higher = snappier. */
|
||||
followSpeed?: number;
|
||||
/** EMA weight on each new detection (0..1). Lower = smoother target. */
|
||||
detectionSmoothing?: number;
|
||||
detectorInputSize?: number;
|
||||
detectorScoreThreshold?: number;
|
||||
|
||||
// --- Stable-face auto trigger ---
|
||||
/** Enable the stable-face callback. */
|
||||
stabilityEnabled?: boolean;
|
||||
/** Continuous duration (ms) the same face must stay before firing. */
|
||||
stableMs?: number;
|
||||
/** Max center movement (frame ratio) to still count as same face. */
|
||||
stabilityPositionThreshold?: number;
|
||||
/** Max relative face-size change to still count as same face. */
|
||||
stabilitySizeThreshold?: number;
|
||||
/** Fires once when stability duration is met; re-arms on absence/new person. */
|
||||
onStableFace?: () => void;
|
||||
};
|
||||
|
||||
type FilteredSample = {
|
||||
fx: number; // face center x ratio
|
||||
fy: number; // face center y ratio
|
||||
fh: number; // face height ratio
|
||||
};
|
||||
|
||||
export function useFaceZoom(
|
||||
videoRef: RefObject<HTMLVideoElement | null>,
|
||||
{
|
||||
enabled = true,
|
||||
intervalMs = 120,
|
||||
targetFaceRatio = 0.6,
|
||||
minScale = 1.35,
|
||||
maxScale = 4,
|
||||
followSpeed = 0.11,
|
||||
detectionSmoothing = 0.35,
|
||||
detectorInputSize = 416,
|
||||
detectorScoreThreshold = 0.3,
|
||||
stabilityEnabled = false,
|
||||
stableMs = 2000,
|
||||
stabilityPositionThreshold = 0.12,
|
||||
stabilitySizeThreshold = 0.4,
|
||||
onStableFace,
|
||||
}: Options = {}
|
||||
): FaceZoomResult {
|
||||
const [transform, setTransform] = useState<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const [box, setBox] = useState<FaceBox | null>(null);
|
||||
const [confidence, setConfidence] = useState(0);
|
||||
const [stabilityProgress, setStabilityProgress] = useState(0);
|
||||
const stabilityProgressRef = useRef(0);
|
||||
const currentRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const targetRef = useRef<FaceZoomTransform>(IDLE_TRANSFORM);
|
||||
const filteredRef = useRef<FilteredSample | null>(null);
|
||||
const filteredBoxRef = useRef<FaceBox | null>(null);
|
||||
const filteredConfRef = useRef(0);
|
||||
const missCountRef = useRef(0);
|
||||
|
||||
// Stability state — kept in refs so they survive ticks without re-rendering.
|
||||
const stableSinceRef = useRef<number | null>(null);
|
||||
const stableFiredRef = useRef(false);
|
||||
const stablePrevRef = useRef<FilteredSample | null>(null);
|
||||
// Latest stability config (so updating it doesn't tear down the detection loop).
|
||||
const stabilityCfgRef = useRef({
|
||||
enabled: stabilityEnabled,
|
||||
stableMs,
|
||||
positionThreshold: stabilityPositionThreshold,
|
||||
sizeThreshold: stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
});
|
||||
useEffect(() => {
|
||||
stabilityCfgRef.current = {
|
||||
enabled: stabilityEnabled,
|
||||
stableMs,
|
||||
positionThreshold: stabilityPositionThreshold,
|
||||
sizeThreshold: stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
};
|
||||
// When disabling, also clear pending state so it re-arms cleanly next time.
|
||||
if (!stabilityEnabled) {
|
||||
stableSinceRef.current = null;
|
||||
stableFiredRef.current = false;
|
||||
stablePrevRef.current = null;
|
||||
}
|
||||
}, [
|
||||
stabilityEnabled,
|
||||
stableMs,
|
||||
stabilityPositionThreshold,
|
||||
stabilitySizeThreshold,
|
||||
onStableFace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
currentRef.current = IDLE_TRANSFORM;
|
||||
targetRef.current = IDLE_TRANSFORM;
|
||||
filteredRef.current = null;
|
||||
filteredBoxRef.current = null;
|
||||
filteredConfRef.current = 0;
|
||||
setTransform(IDLE_TRANSFORM);
|
||||
setBox(null);
|
||||
setConfidence(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: number | null = null;
|
||||
let rafId: number | null = null;
|
||||
let lastTs = 0;
|
||||
let faceapi: any = null;
|
||||
let detectorOptions: any = null;
|
||||
let running = false;
|
||||
|
||||
const animate = (ts: number) => {
|
||||
if (cancelled) return;
|
||||
const dt = lastTs ? (ts - lastTs) / 1000 : 1 / 60;
|
||||
lastTs = ts;
|
||||
// Frame-rate-aware lerp: stays consistent at 60/120Hz.
|
||||
const k = 1 - Math.pow(1 - followSpeed, dt * 60);
|
||||
|
||||
const cur = currentRef.current;
|
||||
const tgt = targetRef.current;
|
||||
const next: FaceZoomTransform = {
|
||||
scale: cur.scale + (tgt.scale - cur.scale) * k,
|
||||
translateX: cur.translateX + (tgt.translateX - cur.translateX) * k,
|
||||
translateY: cur.translateY + (tgt.translateY - cur.translateY) * k,
|
||||
};
|
||||
|
||||
currentRef.current = next;
|
||||
|
||||
const dScale = Math.abs(next.scale - tgt.scale);
|
||||
const dTx = Math.abs(next.translateX - tgt.translateX);
|
||||
const dTy = Math.abs(next.translateY - tgt.translateY);
|
||||
// Skip re-render when essentially settled — avoids React churn.
|
||||
if (dScale > 0.0005 || dTx > 0.02 || dTy > 0.02) {
|
||||
setTransform(next);
|
||||
} else if (
|
||||
next.scale !== tgt.scale ||
|
||||
next.translateX !== tgt.translateX ||
|
||||
next.translateY !== tgt.translateY
|
||||
) {
|
||||
currentRef.current = tgt;
|
||||
setTransform(tgt);
|
||||
}
|
||||
|
||||
// Smooth 60fps fill of the stability progress (independent of the
|
||||
// ~110ms detector tick → no stair-step in the progress bar).
|
||||
const cfg = stabilityCfgRef.current;
|
||||
let nextProgress = 0;
|
||||
if (cfg.enabled) {
|
||||
if (stableFiredRef.current) {
|
||||
nextProgress = 1;
|
||||
} else if (stableSinceRef.current !== null && cfg.stableMs > 0) {
|
||||
nextProgress = Math.min(
|
||||
1,
|
||||
(ts - stableSinceRef.current) / cfg.stableMs
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Math.abs(nextProgress - stabilityProgressRef.current) > 0.003) {
|
||||
stabilityProgressRef.current = nextProgress;
|
||||
setStabilityProgress(nextProgress);
|
||||
} else if (
|
||||
(nextProgress === 0 || nextProgress === 1) &&
|
||||
stabilityProgressRef.current !== nextProgress
|
||||
) {
|
||||
// Snap to exact endpoints so the bar fully clears / fills.
|
||||
stabilityProgressRef.current = nextProgress;
|
||||
setStabilityProgress(nextProgress);
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (running) return;
|
||||
const video = videoRef.current;
|
||||
if (!video || video.readyState < 2 || !video.videoWidth) return;
|
||||
running = true;
|
||||
try {
|
||||
const detection = await faceapi.detectSingleFace(video, detectorOptions);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!detection) {
|
||||
missCountRef.current += 1;
|
||||
// Stability resets quickly so the timer truly restarts on absence.
|
||||
if (missCountRef.current >= 2) {
|
||||
stableSinceRef.current = null;
|
||||
stableFiredRef.current = false;
|
||||
stablePrevRef.current = null;
|
||||
}
|
||||
// Hold zoom position longer to avoid jarring zoom-out on brief misses.
|
||||
if (missCountRef.current >= 25) {
|
||||
targetRef.current = IDLE_TRANSFORM;
|
||||
filteredRef.current = null;
|
||||
filteredBoxRef.current = null;
|
||||
filteredConfRef.current = 0;
|
||||
setBox(null);
|
||||
setConfidence(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
missCountRef.current = 0;
|
||||
|
||||
const { x, y, width, height } = detection.box;
|
||||
const score = detection.score ?? detection.classScore ?? 0;
|
||||
const vw = video.videoWidth;
|
||||
const vh = video.videoHeight;
|
||||
if (!vw || !vh) return;
|
||||
|
||||
const sample: FilteredSample = {
|
||||
fx: (x + width / 2) / vw,
|
||||
fy: (y + height / 2) / vh,
|
||||
fh: height / vh,
|
||||
};
|
||||
const sampleBox: FaceBox = {
|
||||
x: x / vw,
|
||||
y: y / vh,
|
||||
width: width / vw,
|
||||
height: height / vh,
|
||||
};
|
||||
|
||||
const a = detectionSmoothing;
|
||||
const prev = filteredRef.current;
|
||||
const filt: FilteredSample = prev
|
||||
? {
|
||||
fx: prev.fx + (sample.fx - prev.fx) * a,
|
||||
fy: prev.fy + (sample.fy - prev.fy) * a,
|
||||
fh: prev.fh + (sample.fh - prev.fh) * a,
|
||||
}
|
||||
: sample;
|
||||
filteredRef.current = filt;
|
||||
|
||||
const prevBox = filteredBoxRef.current;
|
||||
const filtBox: FaceBox = prevBox
|
||||
? {
|
||||
x: prevBox.x + (sampleBox.x - prevBox.x) * a,
|
||||
y: prevBox.y + (sampleBox.y - prevBox.y) * a,
|
||||
width: prevBox.width + (sampleBox.width - prevBox.width) * a,
|
||||
height: prevBox.height + (sampleBox.height - prevBox.height) * a,
|
||||
}
|
||||
: sampleBox;
|
||||
filteredBoxRef.current = filtBox;
|
||||
setBox(filtBox);
|
||||
|
||||
const filtConf =
|
||||
filteredConfRef.current + (score - filteredConfRef.current) * a;
|
||||
filteredConfRef.current = filtConf;
|
||||
setConfidence(filtConf);
|
||||
|
||||
// --- Stable-face tracking (uses RAW sample so a new person trips it
|
||||
// immediately, before EMA can drift toward the new position). ---
|
||||
const cfg = stabilityCfgRef.current;
|
||||
if (cfg.enabled) {
|
||||
const prevS = stablePrevRef.current;
|
||||
const now = performance.now();
|
||||
let sameFace = false;
|
||||
if (prevS) {
|
||||
const dx = Math.abs(sample.fx - prevS.fx);
|
||||
const dy = Math.abs(sample.fy - prevS.fy);
|
||||
const dh =
|
||||
Math.abs(sample.fh - prevS.fh) / Math.max(prevS.fh, 0.01);
|
||||
sameFace =
|
||||
dx < cfg.positionThreshold &&
|
||||
dy < cfg.positionThreshold &&
|
||||
dh < cfg.sizeThreshold;
|
||||
}
|
||||
|
||||
if (!sameFace) {
|
||||
// First detection after absence OR a different person → restart timer.
|
||||
stableSinceRef.current = now;
|
||||
stableFiredRef.current = false;
|
||||
} else if (
|
||||
!stableFiredRef.current &&
|
||||
stableSinceRef.current !== null &&
|
||||
now - stableSinceRef.current >= cfg.stableMs
|
||||
) {
|
||||
stableFiredRef.current = true;
|
||||
try {
|
||||
cfg.onStableFace?.();
|
||||
} catch (e) {
|
||||
console.error("onStableFace handler threw", e);
|
||||
}
|
||||
}
|
||||
stablePrevRef.current = sample;
|
||||
}
|
||||
|
||||
let scale = targetFaceRatio / Math.max(filt.fh, 0.05);
|
||||
scale = Math.max(minScale, Math.min(scale, maxScale));
|
||||
|
||||
const halfViewX = 0.5 / scale;
|
||||
const halfViewY = 0.5 / scale;
|
||||
const fx = Math.min(1 - halfViewX, Math.max(halfViewX, filt.fx));
|
||||
const fy = Math.min(1 - halfViewY, Math.max(halfViewY, filt.fy));
|
||||
|
||||
targetRef.current = {
|
||||
scale,
|
||||
translateX: (0.5 - fx * scale) * 100,
|
||||
translateY: (0.5 - fy * scale) * 100,
|
||||
};
|
||||
} catch (err) {
|
||||
console.debug("face detect error", err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
faceapi = await import("face-api.js");
|
||||
const modelUrl = `${import.meta.env.BASE_URL}models`.replace(
|
||||
/\/\/+/g,
|
||||
"/"
|
||||
);
|
||||
if (!faceapi.nets.tinyFaceDetector.isLoaded) {
|
||||
await faceapi.nets.tinyFaceDetector.loadFromUri(modelUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
detectorOptions = new faceapi.TinyFaceDetectorOptions({
|
||||
inputSize: detectorInputSize,
|
||||
scoreThreshold: detectorScoreThreshold,
|
||||
});
|
||||
intervalId = window.setInterval(tick, intervalMs);
|
||||
rafId = requestAnimationFrame(animate);
|
||||
} catch (err) {
|
||||
console.error("[useFaceZoom] failed to load face-api models", err);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId !== null) clearInterval(intervalId);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [
|
||||
videoRef,
|
||||
enabled,
|
||||
intervalMs,
|
||||
targetFaceRatio,
|
||||
minScale,
|
||||
maxScale,
|
||||
followSpeed,
|
||||
detectionSmoothing,
|
||||
detectorInputSize,
|
||||
detectorScoreThreshold,
|
||||
]);
|
||||
|
||||
return { transform, box, confidence, stabilityProgress };
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import moment from "moment";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import useAppStore from "@/stores/use-app-store";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -18,12 +19,50 @@ export function capture(videoRef: any, canvasRef: any) {
|
|||
const video = videoRef.current;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
const vw: number = video.videoWidth;
|
||||
const vh: number = video.videoHeight;
|
||||
|
||||
// Crop to the same region the user sees on screen (zoomed/focused view).
|
||||
// The video element has transform: translate(tx%, ty%) scale(s) with
|
||||
// transform-origin (0,0). The visible source region in video coords is:
|
||||
// srcX = (-tx/100)/s * vw
|
||||
// srcY = (-ty/100)/s * vh
|
||||
// srcW = vw / s
|
||||
// srcH = vh / s
|
||||
let srcX = 0;
|
||||
let srcY = 0;
|
||||
let srcW = vw;
|
||||
let srcH = vh;
|
||||
|
||||
const t = useAppStore.getState().faceZoomTransform;
|
||||
if (t && t.scale > 1.0001) {
|
||||
const s = t.scale;
|
||||
srcW = vw / s;
|
||||
srcH = vh / s;
|
||||
srcX = (-t.translateX / 100 / s) * vw;
|
||||
srcY = (-t.translateY / 100 / s) * vh;
|
||||
// Clamp inside the source frame (the on-screen clamp already prevents
|
||||
// empty edges, but rounding can drift a sub-pixel out of range).
|
||||
srcX = Math.max(0, Math.min(vw - srcW, srcX));
|
||||
srcY = Math.max(0, Math.min(vh - srcH, srcY));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.width = Math.round(srcW);
|
||||
canvas.height = Math.round(srcH);
|
||||
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(
|
||||
video,
|
||||
srcX,
|
||||
srcY,
|
||||
srcW,
|
||||
srcH,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob: unknown) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import type { FaceBox, FaceZoomTransform } from "@/lib/use-face-zoom";
|
||||
|
||||
type Props = {
|
||||
box: FaceBox | null;
|
||||
transform: FaceZoomTransform;
|
||||
/** 0..1 stability fill — drives the progress bar above the brackets. */
|
||||
progress: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders 4 AF-style corner brackets around the detected face, plus a thin
|
||||
* progress bar above them that fills as the same face remains in view.
|
||||
*
|
||||
* Coordinates: `box` is in source-video ratios (0..1). The video element on
|
||||
* screen has a zoom transform applied. We re-apply the same `translate + scale`
|
||||
* to the bracket position so it stays glued to the face — but place it OUTSIDE
|
||||
* the transformed element so border width does not scale with zoom.
|
||||
*/
|
||||
export default function FaceBracket({ box, transform, progress }: Props) {
|
||||
if (!box) return null;
|
||||
|
||||
const s = transform.scale;
|
||||
const tx = transform.translateX / 100;
|
||||
const ty = transform.translateY / 100;
|
||||
|
||||
// Apply the same transform as the video (origin 0,0): point' = point * s + t
|
||||
const left = box.x * s + tx;
|
||||
const top = box.y * s + ty;
|
||||
const width = box.width * s;
|
||||
const height = box.height * s;
|
||||
|
||||
if (
|
||||
left + width < -0.05 ||
|
||||
top + height < -0.05 ||
|
||||
left > 1.05 ||
|
||||
top > 1.05 ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const armPx = Math.max(14, Math.min(40, width * 1000 * 0.18));
|
||||
const cornerSize = { width: armPx, height: armPx } as const;
|
||||
|
||||
const cornerBase =
|
||||
"absolute border-emerald-400/90 transition-opacity duration-150";
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
const isFull = clamped >= 0.999;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${left * 100}%`,
|
||||
top: `${top * 100}%`,
|
||||
width: `${width * 100}%`,
|
||||
height: `${height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${cornerBase} border-t-[3px] border-l-[3px] rounded-tl-md`}
|
||||
style={{ ...cornerSize, left: 0, top: 0 }}
|
||||
/>
|
||||
<div
|
||||
className={`${cornerBase} border-t-[3px] border-r-[3px] rounded-tr-md`}
|
||||
style={{ ...cornerSize, right: 0, top: 0 }}
|
||||
/>
|
||||
<div
|
||||
className={`${cornerBase} border-b-[3px] border-l-[3px] rounded-bl-md`}
|
||||
style={{ ...cornerSize, left: 0, bottom: 0 }}
|
||||
/>
|
||||
<div
|
||||
className={`${cornerBase} border-b-[3px] border-r-[3px] rounded-br-md`}
|
||||
style={{ ...cornerSize, right: 0, bottom: 0 }}
|
||||
/>
|
||||
|
||||
{/* Stability progress bar — sits above the box. Width is driven by the
|
||||
60fps `progress` value from the hook, so no CSS transition is needed
|
||||
(and a transition would actually fight the rAF updates). */}
|
||||
<div className="absolute left-0 right-0 -top-3 h-1.5 rounded-full bg-white/25 overflow-hidden backdrop-blur-sm">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
isFull
|
||||
? "bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.9)]"
|
||||
: "bg-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${clamped * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,27 +19,17 @@ export default function TabFeatures() {
|
|||
|
||||
const { currentUser, setCurrentUser } = useUserStore();
|
||||
|
||||
const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
|
||||
|
||||
const autoCheckIntervalRef = useRef<any>(null);
|
||||
const { isAutoChecking, setIsAutoChecking, setRefreshLog, autoCheckinTick } =
|
||||
useAppStore();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
|
||||
|
||||
const toggleAutoCheck = () => {
|
||||
if (isAutoChecking) {
|
||||
if (autoCheckIntervalRef.current) {
|
||||
clearInterval(autoCheckIntervalRef.current);
|
||||
autoCheckIntervalRef.current = null;
|
||||
}
|
||||
setIsAutoChecking(false);
|
||||
} else {
|
||||
autoCheckIntervalRef.current = setInterval(() => {
|
||||
captureAndCheck();
|
||||
}, 3000);
|
||||
setIsAutoChecking(true);
|
||||
}
|
||||
// Auto mode is now driven by stable-face detection in <Main> — toggling
|
||||
// this flag enables/disables the 2s presence timer + auto fire.
|
||||
setIsAutoChecking(!isAutoChecking);
|
||||
};
|
||||
|
||||
const createCheckpoint = async () => {
|
||||
|
|
@ -120,6 +110,18 @@ export default function TabFeatures() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Stable-face auto trigger: Main bumps autoCheckinTick when a face has been
|
||||
// present for 2s, and we fire the same checkin path used by the manual
|
||||
// button. Skip the initial 0 tick on mount.
|
||||
const lastHandledTick = useRef(0);
|
||||
useEffect(() => {
|
||||
if (autoCheckinTick === 0) return;
|
||||
if (autoCheckinTick === lastHandledTick.current) return;
|
||||
lastHandledTick.current = autoCheckinTick;
|
||||
if (loading) return;
|
||||
captureAndCheck();
|
||||
}, [autoCheckinTick, captureAndCheck, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space") {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import { checkingApi } from "@/api/checking-api";
|
|||
import { msApi } from "@/api/ms-api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useFaceZoom } from "@/lib/use-face-zoom";
|
||||
import { capture, cn } from "@/lib/utils";
|
||||
import useAppStore from "@/stores/use-app-store";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import CountDown from "./components/count-down";
|
||||
import FaceBracket from "./components/face-bracket";
|
||||
import LeftSlidebar from "./components/left-slidebar";
|
||||
import RightSlidebar from "./components/right-slidebar";
|
||||
import TabFeatures from "./components/tab-features";
|
||||
|
|
@ -20,13 +23,47 @@ export default function Main() {
|
|||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||
// const { currentUser, setCurrentUser } = useUserStore();
|
||||
|
||||
const { setCanvasRef, setVideoRef } = useAppStore();
|
||||
const {
|
||||
setCanvasRef,
|
||||
setVideoRef,
|
||||
setFaceZoomTransform,
|
||||
isAutoChecking,
|
||||
bumpAutoCheckinTick,
|
||||
} = useAppStore();
|
||||
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
|
||||
useAppStore();
|
||||
|
||||
const [flashKey, setFlashKey] = useState(0);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const onStableFace = useCallback(() => {
|
||||
// Fire the same path as the "Điểm Danh Ngay" button via the store trigger,
|
||||
// and play a camera-shutter flash to acknowledge the capture.
|
||||
setFlashKey((k) => k + 1);
|
||||
bumpAutoCheckinTick();
|
||||
}, [bumpAutoCheckinTick]);
|
||||
|
||||
const {
|
||||
transform: faceZoom,
|
||||
box: faceBox,
|
||||
stabilityProgress: faceStability,
|
||||
} = useFaceZoom(videoRef, {
|
||||
enabled: true,
|
||||
intervalMs: 110,
|
||||
targetFaceRatio: 0.6,
|
||||
minScale: 1.35,
|
||||
maxScale: 4,
|
||||
followSpeed: 0.11,
|
||||
detectionSmoothing: 0.3,
|
||||
detectorInputSize: 416,
|
||||
detectorScoreThreshold: 0.3,
|
||||
stabilityEnabled: isAutoChecking,
|
||||
stableMs: 2000,
|
||||
onStableFace,
|
||||
});
|
||||
|
||||
const sync = async () => {
|
||||
try {
|
||||
const { data } = await msApi.timekeepings();
|
||||
|
|
@ -88,6 +125,12 @@ export default function Main() {
|
|||
setVideoRef(videoRef);
|
||||
}, [videoRef, canvasRef]);
|
||||
|
||||
// Keep store in sync with the live zoom so capture() can crop to the
|
||||
// visible focused region instead of the full frame.
|
||||
useEffect(() => {
|
||||
setFaceZoomTransform(faceZoom);
|
||||
}, [faceZoom, setFaceZoomTransform]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="flex h-screen">
|
||||
|
|
@ -107,10 +150,34 @@ export default function Main() {
|
|||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full object-cover will-change-transform"
|
||||
style={{
|
||||
transformOrigin: "0 0",
|
||||
transform: `translate3d(${faceZoom.translateX}%, ${faceZoom.translateY}%, 0) scale(${faceZoom.scale})`,
|
||||
backfaceVisibility: "hidden",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
|
||||
|
||||
<FaceBracket
|
||||
box={faceBox}
|
||||
transform={faceZoom}
|
||||
progress={isAutoChecking ? faceStability : 0}
|
||||
/>
|
||||
|
||||
{/* Camera shutter flash — keyed so each fire replays the animation. */}
|
||||
<AnimatePresence>
|
||||
{flashKey > 0 && (
|
||||
<motion.div
|
||||
key={flashKey}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 0.85, 0] }}
|
||||
transition={{ duration: 0.45, times: [0, 0.18, 1] }}
|
||||
className="absolute inset-0 bg-white pointer-events-none z-40"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* <AnimatePresence>
|
||||
{currentUser && (
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// src/stores/useUserStore.ts
|
||||
import type { FaceZoomTransform } from "@/lib/use-face-zoom";
|
||||
import { create } from "zustand";
|
||||
|
||||
type AppState = {
|
||||
|
|
@ -9,6 +10,9 @@ type AppState = {
|
|||
captureRegisterImage: any;
|
||||
videoRef: any;
|
||||
canvasRef: any;
|
||||
faceZoomTransform: FaceZoomTransform | null;
|
||||
/** Incrementing trigger — bumped when the stable-face timer fires. */
|
||||
autoCheckinTick: number;
|
||||
|
||||
// actions
|
||||
setIsAutoChecking: (data: boolean) => void;
|
||||
|
|
@ -17,6 +21,8 @@ type AppState = {
|
|||
setVideoRef: (data: any) => void;
|
||||
setCanvasRef: (data: any) => void;
|
||||
setCaptureRegisterImage: (data: any) => void;
|
||||
setFaceZoomTransform: (data: FaceZoomTransform | null) => void;
|
||||
bumpAutoCheckinTick: () => void;
|
||||
};
|
||||
|
||||
const useAppStore = create<AppState>((set) => ({
|
||||
|
|
@ -26,6 +32,8 @@ const useAppStore = create<AppState>((set) => ({
|
|||
canvasRef: null,
|
||||
videoRef: null,
|
||||
refreshLog: false,
|
||||
faceZoomTransform: null,
|
||||
autoCheckinTick: 0,
|
||||
|
||||
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
|
||||
setRefreshLog: (data) => set({ refreshLog: data }),
|
||||
|
|
@ -33,6 +41,9 @@ const useAppStore = create<AppState>((set) => ({
|
|||
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
|
||||
setVideoRef: (data) => set({ videoRef: data }),
|
||||
setCanvasRef: (data) => set({ canvasRef: data }),
|
||||
setFaceZoomTransform: (data) => set({ faceZoomTransform: data }),
|
||||
bumpAutoCheckinTick: () =>
|
||||
set((s) => ({ autoCheckinTick: s.autoCheckinTick + 1 })),
|
||||
}));
|
||||
|
||||
export default useAppStore;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "school-checkin",
|
||||
"name": "TrackingToolWeb",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue