Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ Spring은 AI의 비공개 경로를 `Authorization: Bearer {FACE_SERVICE_TOKEN}`

브라우저는 Spring의 `POST /api/v1/face/enrollments`에 `multipart/form-data`의 `video` 파트로 영상을 보냅니다. Spring은 영상을 AI의 `POST /internal/v1/face/enrollments/extract`에 `video/webm` 또는 `video/mp4` 원본 body로 전달하고, AI가 반환한 벡터를 Spring이 학생과 연결해 저장합니다. AI에는 브라우저 로그인이나 공개 등록 경로가 없습니다.

### 2. 얼굴 등록 흐름
### 2. 얼굴 등록·재등록 벡터 추출 흐름

1. `POST /internal/v1/face/enrollments/extract`에 서비스 Bearer 인증을 넣고 `video/webm` 또는 `video/mp4` 원본 body를 보냅니다.
2. AI 서비스가 최대 약 100개 프레임을 검토합니다.
3. 품질이 충분한 단일 얼굴에서 대표 임베딩 약 20개를 반환합니다.
4. Spring은 반환된 `model` 정보와 벡터를 같은 모델 버전으로 관리하고, 등록 성공·중복·동의 정책을 처리합니다.

최초 등록과 재등록은 같은 추출 API를 사용하며 영상 검증·대표 벡터 추출 기준도 같습니다. AI는 학생의 등록 여부를 조회하지 않습니다. 추출은 벡터를 반환하는 작업이며, 성공·실패와 관계없이 기존 인식 세션의 갤러리를 변경하지 않습니다.

원본 영상과 디코딩 프레임은 요청 처리 중에만 사용되며, 처리 후 폐기됩니다.

### 3. 얼굴 인식 흐름
Expand All @@ -49,6 +51,16 @@ Spring은 AI의 비공개 경로를 `Authorization: Bearer {FACE_SERVICE_TOKEN}`

각 얼굴 결과에는 `trackId`, `bbox`, `landmarks`, `quality`, `recognition`이 포함됩니다.

### 얼굴 재등록과 활성 세션 갱신

새 벡터를 활성 인식에 반영할 때는 기존 `PUT /internal/v1/face/sessions/{session_id}`를 다시 호출합니다. 같은 학생의 `student_id`에 새 벡터를 넣고, 유지할 다른 학생까지 포함한 **전체 후보 목록**을 보냅니다. PUT은 전체 후보를 교체하므로 요청에서 빠진 학생은 후보에서 제거됩니다. 먼저 DELETE를 호출할 필요는 없습니다.

AI는 새 후보와 모델·벡터를 검증한 뒤 갤러리를 교체합니다. 검증에 실패하면 기존 갤러리와 트래커를 유지하고, 성공하면 새 트래커로 시작하므로 기존 트랙·연속 실패 횟수·쿨다운은 초기화됩니다. 이전 벡터는 처리 중인 요청의 참조가 모두 해제된 뒤 메모리에서 정리합니다.

교체 또는 종료와 겹친 프레임의 성공 응답은 기존 신원 결과를 보내지 않고 `200 {"frameId": "요청의 프레임 ID", "faces": []}`를 반환합니다. 이 빈 배열에는 인식 실패 시도 결과가 없습니다. 갱신 PUT 성공 뒤 획득한 프레임은 새 갤러리로 처리됩니다.

활성 카메라의 자동 반영에는 Spring이 새 벡터를 저장한 뒤 영향을 받는 각 세션에 갱신 PUT을 보내는 연결이 필요합니다. 각 요청은 해당 세션을 보유한 같은 AI 프로세스로 전달해야 합니다. 이 연결과 공개 재등록 API·저장 정책 변경은 백엔드 담당 범위이며 이번 AI 변경에 포함하지 않습니다.

### 인식 상태 해석

- `KNOWN`: 임계값과 후보 간 margin을 모두 통과했습니다. 이때만 `studentId`를 사용합니다.
Expand Down
194 changes: 194 additions & 0 deletions benchmarks/face_api_synthetic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Measure the existing AI frame endpoint using only generated synthetic data.

The actual model files and route are used, but no camera image or enrollment data
is loaded. Output contains only aggregate per-stage durations and synthetic counts.

Example:
.venv/Scripts/python.exe benchmarks/face_api_synthetic.py
"""

from __future__ import annotations

import argparse
import ast
import io
import json
import logging
import sys
from pathlib import Path

import cv2
import numpy as np
from fastapi.testclient import TestClient

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(Path(__file__).resolve().parent))

from face_detection_modes import synthetic_frame # noqa: E402

from face_api import create_app # noqa: E402
from face_config import Settings # noqa: E402
from face_models import EMBEDDING_DIMENSION, MODEL_ID, ModelMetadata # noqa: E402


def positive_int(value: str) -> int:
number = int(value)
if number < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return number


def synthetic_candidates(students: int, vectors_per_student: int, seed: int) -> tuple[np.ndarray, list[dict]]:
rng = np.random.default_rng(seed)
vectors = rng.standard_normal(
(students, vectors_per_student, EMBEDDING_DIMENSION), dtype=np.float32
)
norms = np.linalg.norm(vectors, axis=2, keepdims=True)
vectors /= norms
candidates = [
{
"student_id": f"synthetic-{student:03d}",
"vectors": vectors[student].tolist(),
}
for student in range(students)
]
return vectors, candidates


def parse_timing_records(log_text: str) -> list[dict[str, object]]:
records = []
marker = "face_frame_timing_ms "
for line in log_text.splitlines():
if marker in line:
records.append(ast.literal_eval(line.split(marker, 1)[1]))
return records


def summarize(values: list[float]) -> dict[str, float]:
array = np.asarray(values, dtype=np.float64)
return {"median_ms": float(np.median(array)), "p95_ms": float(np.percentile(array, 95))}


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--requests", type=positive_int, default=100)
parser.add_argument("--warmup", type=positive_int, default=10)
parser.add_argument("--students", type=positive_int, default=50)
parser.add_argument("--vectors", type=positive_int, default=20)
parser.add_argument("--seed", type=int, default=20261011)
args = parser.parse_args()
if args.warmup >= args.requests:
parser.error("--warmup must be less than --requests")
if args.students > 200 or args.vectors > 20:
parser.error("synthetic gallery is limited to 200 students and 20 vectors")

token = "synthetic-benchmark-token"
settings = Settings(
service_token=token,
match_threshold=0.6,
match_margin=0.1,
)
metadata = ModelMetadata(version=settings.model_version)
candidate_vectors, candidates = synthetic_candidates(
args.students, args.vectors, args.seed
)
payload = {
"model": {
"model_id": MODEL_ID,
"version": metadata.version,
"dimension": EMBEDDING_DIMENSION,
"normalization": metadata.normalization,
},
"candidates": candidates,
}

log_buffer = io.StringIO()
handler = logging.StreamHandler(log_buffer)
logger = logging.getLogger("face_api")
previous_level = logger.level
previous_handlers = logger.handlers[:]
previous_propagate = logger.propagate
logger.handlers = [handler]
logger.setLevel(logging.DEBUG)
logger.propagate = False
session_id = "synthetic-benchmark-session"
try:
with TestClient(create_app(settings=settings)) as client:
headers = {"Authorization": f"Bearer {token}"}
ready = client.put(
f"/internal/v1/face/sessions/{session_id}",
headers=headers,
json=payload,
)
ready.raise_for_status()
for index in range(args.requests):
frame = synthetic_frame(index)
encoded = None
try:
success, encoded = cv2.imencode(".jpg", frame)
if not success:
raise RuntimeError("could not encode synthetic frame")
response = client.post(
f"/internal/v1/face/sessions/{session_id}/frames",
headers={**headers, "Content-Type": "image/jpeg"},
content=encoded.tobytes(),
)
response.raise_for_status()
del response
finally:
frame.fill(0)
if encoded is not None:
encoded.fill(0)
client.delete(
f"/internal/v1/face/sessions/{session_id}",
headers=headers,
).raise_for_status()
finally:
logger.handlers = previous_handlers
logger.setLevel(previous_level)
logger.propagate = previous_propagate
handler.close()
candidate_vectors.fill(0)
for candidate in candidates:
for vector in candidate["vectors"]:
vector[:] = [0.0] * len(vector)

records = parse_timing_records(log_buffer.getvalue())[args.warmup :]
if len(records) != args.requests - args.warmup:
raise RuntimeError("frame timing records were incomplete")

stages = ("read", "decode", "detect", "embed", "match", "cleanup", "total")
stage_results = {}
for stage in stages:
values = [float(record[stage]) for record in records if stage in record]
if values:
stage_results[stage] = {**summarize(values), "samples": len(values)}
print(
json.dumps(
{
"scope": "synthetic_ai_frame_endpoint_latency_only",
"input_kind": "synthetic_non_photographic",
"python": sys.version.split()[0],
"opencv": cv2.__version__,
"requests": args.requests,
"warmup": args.warmup,
"measured_requests": len(records),
"synthetic_students": args.students,
"synthetic_vectors_per_student": args.vectors,
"face_observations_total": sum(int(record["faces"]) for record in records),
"embedding_attempts_total": sum(
int(record["embeddings"]) for record in records
),
"stages": stage_results,
"note": "Only aggregate timing and synthetic counts are printed.",
},
indent=2,
allow_nan=False,
)
)


if __name__ == "__main__":
main()
Loading
Loading