diff --git a/README.md b/README.md index 63c7f09..fd4fa50 100644 --- a/README.md +++ b/README.md @@ -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. 얼굴 인식 흐름 @@ -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`를 사용합니다. diff --git a/benchmarks/face_api_synthetic.py b/benchmarks/face_api_synthetic.py new file mode 100644 index 0000000..eb5d4a0 --- /dev/null +++ b/benchmarks/face_api_synthetic.py @@ -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() diff --git a/benchmarks/face_detection_modes.py b/benchmarks/face_detection_modes.py new file mode 100644 index 0000000..fd73fe8 --- /dev/null +++ b/benchmarks/face_detection_modes.py @@ -0,0 +1,209 @@ +"""Compare MediaPipe IMAGE and VIDEO face detection on the same frame stream. + +With --video, the input is decoded in memory and is never copied or saved. +Without it, a procedural, non-photographic face sequence is used as a smoke input. +Only timing and aggregate geometry parity are printed; no frame or face data is. + +Example: + .venv/Scripts/python.exe benchmarks/face_detection_modes.py --video local-test.mp4 +""" + +from __future__ import annotations + +import argparse +import json +import platform +import sys +from pathlib import Path +from time import perf_counter_ns + +import cv2 +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be at least 1") + return number + + +def synthetic_frame(index: int) -> np.ndarray: + """Make a moving, non-photographic test drawing without face imagery.""" + image = np.full((480, 640, 3), 224, dtype=np.uint8) + center_x = 320 + int(12 * np.sin(index / 12)) + cv2.ellipse(image, (center_x, 240), (126, 164), 0, 0, 360, (174, 194, 218), -1) + cv2.ellipse(image, (center_x, 142), (128, 74), 0, 180, 360, (42, 48, 58), -1) + cv2.ellipse(image, (center_x - 48, 222), (28, 14), 0, 0, 360, (247, 247, 247), -1) + cv2.ellipse(image, (center_x + 48, 222), (28, 14), 0, 0, 360, (247, 247, 247), -1) + cv2.circle(image, (center_x - 48, 222), 7, (30, 35, 45), -1) + cv2.circle(image, (center_x + 48, 222), 7, (30, 35, 45), -1) + cv2.line(image, (center_x - 78, 190), (center_x - 20, 190), (48, 48, 55), 8) + cv2.line(image, (center_x + 20, 190), (center_x + 78, 190), (48, 48, 55), 8) + cv2.line(image, (center_x, 226), (center_x - 9, 286), (143, 160, 181), 11) + cv2.ellipse(image, (center_x, 315), (44, 15), 0, 0, 180, (104, 38, 116), 5) + return image + + +def open_video(path: Path): + capture = cv2.VideoCapture(str(path)) + if not capture.isOpened(): + capture.release() + raise RuntimeError("could not open benchmark video") + fps = float(capture.get(cv2.CAP_PROP_FPS)) + return capture, fps if np.isfinite(fps) and fps > 0 else 30.0 + + +def face_points(result) -> list[np.ndarray]: + faces = [ + np.asarray([(point.x, point.y) for point in landmarks], dtype=np.float32) + for landmarks in result.face_landmarks + ] + return sorted(faces, key=lambda points: (float(points[:, 0].mean()), float(points[:, 1].mean()))) + + +def summarize(values_ns: list[int]) -> dict[str, float]: + values_ms = np.asarray(values_ns, dtype=np.float64) / 1_000_000 + return { + "median_ms": float(np.median(values_ms)), + "p95_ms": float(np.percentile(values_ms, 95)), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--video", type=Path, help="local test video; frames stay in memory") + parser.add_argument("--warmup", type=positive_int, default=10) + parser.add_argument("--max-frames", type=positive_int, default=120) + args = parser.parse_args() + + import mediapipe as mp + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + + model_path = ROOT / "models" / "face_landmarker.task" + if not model_path.is_file(): + raise SystemExit("face landmarker model is unavailable") + + capture = None + fps = 30.0 + if args.video is not None: + capture, fps = open_video(args.video) + + def options_for(mode): + return vision.FaceLandmarkerOptions( + base_options=python.BaseOptions(model_asset_path=str(model_path)), + running_mode=mode, + num_faces=8, + min_face_detection_confidence=0.5, + min_face_presence_confidence=0.5, + min_tracking_confidence=0.5, + ) + + image_task = vision.FaceLandmarker.create_from_options(options_for(vision.RunningMode.IMAGE)) + video_task = vision.FaceLandmarker.create_from_options(options_for(vision.RunningMode.VIDEO)) + + image_times: list[int] = [] + video_times: list[int] = [] + image_face_total = 0 + video_face_total = 0 + parity_frames = 0 + count_mismatches = 0 + landmark_max_deltas: list[float] = [] + frame_count = 0 + previous_timestamp = -1 + try: + for index in range(args.max_frames): + if capture is None: + frame = synthetic_frame(index) + else: + success, frame = capture.read() + if not success: + break + + rgb = None + mp_image = None + image_result = None + video_result = None + try: + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) + timestamp = max(index, int(round(index * 1000 / fps))) + timestamp = max(timestamp, previous_timestamp + 1) + previous_timestamp = timestamp + + if index % 2 == 0: + started = perf_counter_ns() + image_result = image_task.detect(mp_image) + image_elapsed = perf_counter_ns() - started + started = perf_counter_ns() + video_result = video_task.detect_for_video(mp_image, timestamp) + video_elapsed = perf_counter_ns() - started + else: + started = perf_counter_ns() + video_result = video_task.detect_for_video(mp_image, timestamp) + video_elapsed = perf_counter_ns() - started + started = perf_counter_ns() + image_result = image_task.detect(mp_image) + image_elapsed = perf_counter_ns() - started + + image_faces = face_points(image_result) + video_faces = face_points(video_result) + image_face_total += len(image_faces) + video_face_total += len(video_faces) + if index >= args.warmup: + image_times.append(image_elapsed) + video_times.append(video_elapsed) + if len(image_faces) != len(video_faces): + count_mismatches += 1 + else: + parity_frames += 1 + for image_points, video_points in zip(image_faces, video_faces, strict=True): + if image_points.shape == video_points.shape and image_points.size: + landmark_max_deltas.append( + float(np.max(np.abs(image_points - video_points))) + ) + finally: + mp_image = None + if rgb is not None: + rgb.fill(0) + frame.fill(0) + frame_count += 1 + finally: + if capture is not None: + capture.release() + image_task.close() + video_task.close() + + if not image_times: + raise SystemExit("input did not contain enough frames after warmup") + + deltas = np.asarray(landmark_max_deltas, dtype=np.float64) + result = { + "scope": "mediapipe_detection_latency_and_geometry_only", + "input_kind": "local_video" if args.video is not None else "synthetic_non_photographic", + "mediapipe": mp.__version__, + "opencv": cv2.__version__, + "python": platform.python_version(), + "frames": frame_count, + "warmup_frames": min(args.warmup, frame_count), + "measured_frames": len(image_times), + "image_mode": {**summarize(image_times), "faces_total": image_face_total}, + "video_mode": {**summarize(video_times), "faces_total": video_face_total}, + "geometry_comparison": { + "face_count_mismatch_frames": count_mismatches, + "same_count_frames": parity_frames, + "landmark_max_abs_delta_median": float(np.median(deltas)) if deltas.size else None, + "landmark_max_abs_delta_p95": float(np.percentile(deltas, 95)) if deltas.size else None, + }, + "note": "No image, frame, landmark, or identity data is written or printed.", + } + print(json.dumps(result, indent=2, allow_nan=False)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/face_resolution_sweep.py b/benchmarks/face_resolution_sweep.py new file mode 100644 index 0000000..cddb8c3 --- /dev/null +++ b/benchmarks/face_resolution_sweep.py @@ -0,0 +1,252 @@ +"""Measure detector latency and geometry across input resolutions. + +With --video, frames are decoded and processed in memory only. By default, the +script uses a procedural non-photographic face at several apparent sizes. +Only aggregate timing and detection counts are printed. + +Example: + .venv/Scripts/python.exe benchmarks/face_resolution_sweep.py + .venv/Scripts/python.exe benchmarks/face_resolution_sweep.py --video local-test.mp4 +""" + +from __future__ import annotations + +import argparse +import json +import platform +import sys +from pathlib import Path +from time import perf_counter_ns + +import cv2 +import numpy as np + +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 face_points, synthetic_frame # 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 resolution_scale(value: str) -> float: + scale = float(value) + if not np.isfinite(scale) or not 0 < scale <= 1: + raise argparse.ArgumentTypeError("resolution scales must be in (0, 1]") + return scale + + +def synthetic_frame_at_size(index: int, face_size: float) -> np.ndarray: + image = synthetic_frame(index) + if face_size == 1.0: + return image + transform = cv2.getRotationMatrix2D((320, 240), 0, face_size) + scaled = cv2.warpAffine( + image, + transform, + (640, 480), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(224, 224, 224), + ) + image.fill(0) + return scaled + + +def empty_measurement() -> dict[str, object]: + return { + "durations_ns": [], + "faces_total": 0, + "reference_faces_total": 0, + "count_mismatch_frames": 0, + "same_count_frames": 0, + "landmark_max_deltas": [], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--video", type=Path, help="local test video; frames stay in memory") + parser.add_argument("--input-scales", nargs="+", type=resolution_scale, default=[1.0, 0.75, 0.5, 0.375]) + parser.add_argument("--face-sizes", nargs="+", type=resolution_scale, default=[1.0, 0.75, 0.5, 0.375, 0.25]) + parser.add_argument("--warmup", type=positive_int, default=10) + parser.add_argument("--max-frames", type=positive_int, default=60) + args = parser.parse_args() + if args.warmup >= args.max_frames: + parser.error("--warmup must be less than --max-frames") + if 1.0 not in args.input_scales: + parser.error("--input-scales must include 1.0 as the full-resolution reference") + if len(set(args.input_scales)) != len(args.input_scales): + parser.error("--input-scales cannot contain duplicates") + if len(set(args.face_sizes)) != len(args.face_sizes): + parser.error("--face-sizes cannot contain duplicates") + + import mediapipe as mp + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + + model_path = ROOT / "models" / "face_landmarker.task" + if not model_path.is_file(): + raise SystemExit("face landmarker model is unavailable") + + video_capture = None + if args.video is not None: + video_capture = cv2.VideoCapture(str(args.video)) + if not video_capture.isOpened(): + video_capture.release() + raise SystemExit("could not open benchmark video") + face_sizes = [1.0] + else: + face_sizes = args.face_sizes + + options = vision.FaceLandmarkerOptions( + base_options=python.BaseOptions(model_asset_path=str(model_path)), + running_mode=vision.RunningMode.IMAGE, + num_faces=8, + min_face_detection_confidence=0.5, + min_face_presence_confidence=0.5, + ) + task = vision.FaceLandmarker.create_from_options(options) + all_measurements = [] + base_width = None + base_height = None + frame_total = 0 + try: + for face_size in face_sizes: + measurements = {scale: empty_measurement() for scale in args.input_scales} + frames_for_size = 0 + try: + for index in range(args.max_frames): + if video_capture is None: + frame = synthetic_frame_at_size(index, face_size) + else: + success, frame = video_capture.read() + if not success: + break + base_height, base_width = frame.shape[:2] + frame_results: dict[float, list[np.ndarray]] = {} + try: + order = list(args.input_scales) + offset = index % len(order) + order = order[offset:] + order[:offset] + for scale in order: + target_width = max(1, int(round(base_width * scale))) + target_height = max(1, int(round(base_height * scale))) + input_frame = frame + if scale != 1.0: + input_frame = cv2.resize( + frame, + (target_width, target_height), + interpolation=cv2.INTER_AREA, + ) + rgb = None + mp_image = None + result = None + try: + started = perf_counter_ns() + rgb = cv2.cvtColor(input_frame, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) + result = task.detect(mp_image) + elapsed = perf_counter_ns() - started + frame_results[scale] = face_points(result) + if index >= args.warmup: + measurement = measurements[scale] + measurement["durations_ns"].append(elapsed) + measurement["faces_total"] += len(frame_results[scale]) + finally: + result = None + mp_image = None + if rgb is not None: + rgb.fill(0) + if input_frame is not frame: + input_frame.fill(0) + + reference = frame_results[1.0] + if index >= args.warmup: + for scale, faces in frame_results.items(): + measurement = measurements[scale] + measurement["reference_faces_total"] += len(reference) + if len(faces) != len(reference): + measurement["count_mismatch_frames"] += 1 + else: + measurement["same_count_frames"] += 1 + frame_deltas = [] + for reference_points, points in zip(reference, faces, strict=True): + if reference_points.shape == points.shape and points.size: + frame_deltas.append( + float(np.max(np.abs(reference_points - points))) + ) + if frame_deltas: + measurement["landmark_max_deltas"].append(max(frame_deltas)) + finally: + for faces in frame_results.values(): + for points in faces: + points.fill(0) + frame.fill(0) + frames_for_size += 1 + finally: + if video_capture is not None: + video_capture.release() + video_capture = None + frame_total = max(frame_total, frames_for_size) + + for scale, measurement in measurements.items(): + durations = np.asarray(measurement["durations_ns"], dtype=np.float64) / 1_000_000 + deltas = np.asarray(measurement["landmark_max_deltas"], dtype=np.float64) + if not durations.size: + continue + all_measurements.append( + { + "synthetic_face_size": face_size if args.video is None else None, + "input_scale": scale, + "input_width": max(1, int(round(base_width * scale))), + "input_height": max(1, int(round(base_height * scale))), + "samples": int(durations.size), + "median_ms": float(np.median(durations)), + "p95_ms": float(np.percentile(durations, 95)), + "faces_total": int(measurement["faces_total"]), + "full_resolution_reference_faces": int(measurement["reference_faces_total"]), + "face_count_mismatch_frames": int(measurement["count_mismatch_frames"]), + "same_count_frames": int(measurement["same_count_frames"]), + "landmark_max_abs_delta_median": float(np.median(deltas)) if deltas.size else None, + "landmark_max_abs_delta_p95": float(np.percentile(deltas, 95)) if deltas.size else None, + } + ) + finally: + if video_capture is not None: + video_capture.release() + task.close() + + if not all_measurements: + raise SystemExit("input did not contain enough frames after warmup") + + print( + json.dumps( + { + "scope": "mediapipe_input_resolution_latency_and_geometry_only", + "input_kind": "local_video" if args.video is not None else "synthetic_non_photographic", + "mediapipe": mp.__version__, + "opencv": cv2.__version__, + "python": platform.python_version(), + "base_width": base_width, + "base_height": base_height, + "frames_per_face_size": frame_total, + "warmup_frames": args.warmup, + "results": all_measurements, + "note": "No image, frame, landmark, or identity data is written or printed.", + }, + indent=2, + allow_nan=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/openvino_cpu_threads.py b/benchmarks/openvino_cpu_threads.py new file mode 100644 index 0000000..136ff7c --- /dev/null +++ b/benchmarks/openvino_cpu_threads.py @@ -0,0 +1,155 @@ +"""Measure the 256D embedder on synthetic input with CPU thread-count variants. + +This measures inference and preprocessing latency only. It does not measure face +recognition accuracy or use real enrollment data. + +Example: + .venv/Scripts/python.exe benchmarks/openvino_cpu_threads.py +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import sys +from pathlib import Path +from time import perf_counter_ns + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from face_models import EMBEDDING_DIMENSION # 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 run_inference(compiled, image: np.ndarray) -> np.ndarray: + blob = np.transpose(image.astype(np.float32), (2, 0, 1))[None, ...] + outputs = None + result = None + normalized = None + try: + outputs = compiled([blob]) + result = np.asarray(outputs[compiled.output(0)], dtype=np.float32).reshape(-1) + if result.size != EMBEDDING_DIMENSION: + raise RuntimeError("embedding model returned an unexpected vector dimension") + norm = float(np.linalg.norm(result)) + if not np.isfinite(norm) or norm == 0 or not np.isfinite(result).all(): + raise RuntimeError("embedding model returned an invalid vector") + normalized = result / norm + return normalized.astype(np.float32, copy=True) + finally: + if normalized is not None and normalized.flags.writeable: + normalized.fill(0) + if result is not None and result.flags.writeable: + result.fill(0) + if outputs is not None: + for output in outputs.values(): + if isinstance(output, np.ndarray) and output.flags.writeable: + output.fill(0) + blob.fill(0) + + +def timing_summary(durations_ns: list[int]) -> dict[str, float]: + milliseconds = np.asarray(durations_ns, dtype=np.float64) / 1_000_000 + return { + "median_ms": float(np.median(milliseconds)), + "p95_ms": float(np.percentile(milliseconds, 95)), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--threads", nargs="+", default=["default", "1", "2", "4", "8"]) + parser.add_argument("--warmup", type=positive_int, default=20) + parser.add_argument("--repeats", type=positive_int, default=200) + parser.add_argument("--seed", type=int, default=20261011) + args = parser.parse_args() + if any(value != "default" and not value.isdigit() for value in args.threads): + parser.error("--threads accepts default or positive integer values") + if any(value != "default" and int(value) < 1 for value in args.threads): + parser.error("thread counts must be positive") + + from openvino import Core, __version__ + + model_path = ROOT / "models" / "face-reidentification-retail-0095.xml" + core = Core() + model = core.read_model(str(model_path)) + rng = np.random.default_rng(args.seed) + image = rng.integers(0, 256, size=(128, 128, 3), dtype=np.uint8) + reference = None + results = [] + try: + for setting in args.threads: + properties = {} if setting == "default" else {"INFERENCE_NUM_THREADS": int(setting)} + compiled = core.compile_model(model, "CPU", properties) + first = run_inference(compiled, image) + try: + if reference is None: + reference = first.copy() + similarity = float(reference @ first) + max_abs_diff = float(np.max(np.abs(reference - first))) + finally: + first.fill(0) + + for _ in range(args.warmup): + output = run_inference(compiled, image) + output.fill(0) + durations_ns = [] + for _ in range(args.repeats): + started = perf_counter_ns() + output = run_inference(compiled, image) + durations_ns.append(perf_counter_ns() - started) + output.fill(0) + + try: + resolved_threads = int(compiled.get_property("INFERENCE_NUM_THREADS")) + except (AttributeError, RuntimeError, TypeError, ValueError): + resolved_threads = None + results.append( + { + "requested_threads": setting, + "resolved_threads": resolved_threads, + **timing_summary(durations_ns), + "cosine_similarity_to_default": similarity, + "max_abs_vector_difference": max_abs_diff, + } + ) + compiled = None + finally: + image.fill(0) + if reference is not None: + reference.fill(0) + + print( + json.dumps( + { + "scope": "synthetic_embedder_latency_only", + "model": "face-reidentification-retail-0095", + "dimension": EMBEDDING_DIMENSION, + "openvino": __version__, + "python": platform.python_version(), + "logical_cpus": os.cpu_count(), + "seed": args.seed, + "warmup": args.warmup, + "repeats": args.repeats, + "results": results, + }, + indent=2, + allow_nan=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/openvino_infer_request.py b/benchmarks/openvino_infer_request.py new file mode 100644 index 0000000..fb866e8 --- /dev/null +++ b/benchmarks/openvino_infer_request.py @@ -0,0 +1,167 @@ +"""Compare OpenVINO's callable wrapper with a reused InferRequest. + +Only a synthetic 128x128 input is used. The benchmark includes the same input +layout conversion, output normalization, and buffer cleanup as InferenceModels.embed. + +Example: + .venv/Scripts/python.exe benchmarks/openvino_infer_request.py +""" + +from __future__ import annotations + +import argparse +import json +import platform +import sys +from pathlib import Path +from time import perf_counter_ns + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from face_models import EMBEDDING_DIMENSION # noqa: E402 + +STRATEGIES = ( + "compiled_callable", + "compiled_callable_shared_output", + "infer_request", + "infer_request_shared_output", +) + + +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be at least 1") + return number + + +def run(compiled, request, image: np.ndarray, strategy: str) -> np.ndarray: + blob = np.transpose(image.astype(np.float32), (2, 0, 1))[None, ...] + outputs = None + result = None + normalized = None + try: + if strategy.startswith("compiled_callable"): + outputs = compiled( + [blob], + share_inputs=True, + share_outputs=strategy == "compiled_callable_shared_output", + ) + else: + outputs = request.infer( + [blob], + share_inputs=True, + share_outputs=strategy == "infer_request_shared_output", + ) + result = np.asarray(outputs[compiled.output(0)], dtype=np.float32).reshape(-1) + if result.size != EMBEDDING_DIMENSION: + raise RuntimeError("embedding model returned an unexpected vector dimension") + norm = float(np.linalg.norm(result)) + if not np.isfinite(norm) or norm == 0 or not np.isfinite(result).all(): + raise RuntimeError("embedding model returned an invalid vector") + normalized = result / norm + return normalized.astype(np.float32, copy=True) + finally: + if normalized is not None and normalized.flags.writeable: + normalized.fill(0) + if result is not None and result.flags.writeable: + result.fill(0) + if outputs is not None: + for output in outputs.values(): + if isinstance(output, np.ndarray) and output.flags.writeable: + output.fill(0) + blob.fill(0) + + +def timing_summary(durations_ns: list[int]) -> dict[str, float]: + milliseconds = np.asarray(durations_ns, dtype=np.float64) / 1_000_000 + return { + "median_ms": float(np.median(milliseconds)), + "p95_ms": float(np.percentile(milliseconds, 95)), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--warmup", type=positive_int, default=100) + parser.add_argument("--repeats", type=positive_int, default=1000) + parser.add_argument("--seed", type=int, default=20261011) + args = parser.parse_args() + + from openvino import Core, __version__ + + model_path = ROOT / "models" / "face-reidentification-retail-0095.xml" + core = Core() + model = core.read_model(str(model_path)) + compiled = core.compile_model(model, "CPU") + request = compiled.create_infer_request() + rng = np.random.default_rng(args.seed) + image = rng.integers(0, 256, size=(128, 128, 3), dtype=np.uint8) + references: dict[str, np.ndarray] = {} + results = {} + try: + for strategy in STRATEGIES: + output = run(compiled, request, image, strategy) + try: + if not references: + references["default"] = output.copy() + reference = references["default"] + results[strategy] = { + "cosine_similarity_to_default": float(reference @ output), + "max_abs_vector_difference": float(np.max(np.abs(reference - output))), + } + finally: + output.fill(0) + + for _ in range(args.warmup): + for strategy in STRATEGIES: + output = run(compiled, request, image, strategy) + output.fill(0) + + durations = {strategy: [] for strategy in STRATEGIES} + for index in range(args.repeats): + offset = index % len(STRATEGIES) + order = STRATEGIES[offset:] + STRATEGIES[:offset] + for strategy in order: + started = perf_counter_ns() + output = run(compiled, request, image, strategy) + durations[strategy].append(perf_counter_ns() - started) + output.fill(0) + + for strategy in STRATEGIES: + results[strategy].update(timing_summary(durations[strategy])) + results[strategy]["samples"] = len(durations[strategy]) + finally: + image.fill(0) + for reference in references.values(): + reference.fill(0) + request = None + compiled = None + + print( + json.dumps( + { + "scope": "synthetic_embedding_api_latency_and_vector_parity", + "model": "face-reidentification-retail-0095", + "dimension": EMBEDDING_DIMENSION, + "openvino": __version__, + "python": platform.python_version(), + "logical_cpus": __import__("os").cpu_count(), + "input": "synthetic_128x128", + "warmup_per_strategy": args.warmup, + "repeats_per_strategy": args.repeats, + "serialized_calls": True, + "results": results, + }, + indent=2, + allow_nan=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/openvino_precision.py b/benchmarks/openvino_precision.py new file mode 100644 index 0000000..d69aaa4 --- /dev/null +++ b/benchmarks/openvino_precision.py @@ -0,0 +1,180 @@ +"""Compare the current CPU precision with a BF16 inference hint. + +The output dimension stays 256. Only a synthetic 128x128 input is used, so this +measures vector drift and latency, not face recognition accuracy. + +Example: + .venv/Scripts/python.exe benchmarks/openvino_precision.py +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import sys +from pathlib import Path +from time import perf_counter_ns + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from face_models import EMBEDDING_DIMENSION # 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 infer(compiled, image: np.ndarray) -> np.ndarray: + blob = np.transpose(image.astype(np.float32), (2, 0, 1))[None, ...] + outputs = None + result = None + normalized = None + try: + outputs = compiled([blob], share_inputs=True, share_outputs=False) + result = np.asarray(outputs[compiled.output(0)], dtype=np.float32).reshape(-1) + if result.size != EMBEDDING_DIMENSION: + raise RuntimeError("embedding model returned an unexpected vector dimension") + norm = float(np.linalg.norm(result)) + if not np.isfinite(norm) or norm == 0 or not np.isfinite(result).all(): + raise RuntimeError("embedding model returned an invalid vector") + normalized = result / norm + return normalized.astype(np.float32, copy=True) + finally: + if normalized is not None and normalized.flags.writeable: + normalized.fill(0) + if result is not None and result.flags.writeable: + result.fill(0) + if outputs is not None: + for output in outputs.values(): + if isinstance(output, np.ndarray) and output.flags.writeable: + output.fill(0) + blob.fill(0) + + +def timing_summary(durations_ns: list[int]) -> dict[str, float]: + milliseconds = np.asarray(durations_ns, dtype=np.float64) / 1_000_000 + return { + "median_ms": float(np.median(milliseconds)), + "p95_ms": float(np.percentile(milliseconds, 95)), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--warmup", type=positive_int, default=100) + parser.add_argument("--repeats", type=positive_int, default=1000) + parser.add_argument("--seed", type=int, default=20261011) + args = parser.parse_args() + + from openvino import Core, __version__ + + model_path = ROOT / "models" / "face-reidentification-retail-0095.xml" + core = Core() + model = core.read_model(str(model_path)) + cpu_name = str(core.get_property("CPU", "FULL_DEVICE_NAME")) + try: + capabilities = [str(value) for value in core.get_property("CPU", "OPTIMIZATION_CAPABILITIES")] + except (AttributeError, RuntimeError, TypeError): + capabilities = [] + + compiled_models = {"default": core.compile_model(model, "CPU")} + precision_error = None + try: + compiled_models["bf16"] = core.compile_model( + model, + "CPU", + {"INFERENCE_PRECISION_HINT": "bf16"}, + ) + except RuntimeError as exc: + precision_error = type(exc).__name__ + + rng = np.random.default_rng(args.seed) + image = rng.integers(0, 256, size=(128, 128, 3), dtype=np.uint8) + references = None + results: dict[str, dict[str, object]] = {} + try: + default_output = infer(compiled_models["default"], image) + references = default_output.copy() + results["default"] = { + "status": "ok", + "resolved_precision_hint": str( + compiled_models["default"].get_property("INFERENCE_PRECISION_HINT") + ), + } + default_output.fill(0) + if "bf16" in compiled_models: + bf16_output = infer(compiled_models["bf16"], image) + try: + results["bf16"] = { + "status": "ok", + "resolved_precision_hint": str( + compiled_models["bf16"].get_property("INFERENCE_PRECISION_HINT") + ), + "cosine_similarity_to_default": float(references @ bf16_output), + "max_abs_vector_difference": float( + np.max(np.abs(references - bf16_output)) + ), + } + finally: + bf16_output.fill(0) + else: + results["bf16"] = {"status": "unsupported", "error_type": precision_error} + + strategies = [name for name in ("default", "bf16") if name in compiled_models] + for _ in range(args.warmup): + for strategy in strategies: + output = infer(compiled_models[strategy], image) + output.fill(0) + + durations = {strategy: [] for strategy in strategies} + for index in range(args.repeats): + offset = index % len(strategies) + order = strategies[offset:] + strategies[:offset] + for strategy in order: + started = perf_counter_ns() + output = infer(compiled_models[strategy], image) + durations[strategy].append(perf_counter_ns() - started) + output.fill(0) + + for strategy in strategies: + results[strategy].update(timing_summary(durations[strategy])) + results[strategy]["samples"] = len(durations[strategy]) + finally: + image.fill(0) + if references is not None: + references.fill(0) + compiled_models.clear() + + print( + json.dumps( + { + "scope": "synthetic_embedding_precision_latency_and_vector_drift", + "model": "face-reidentification-retail-0095", + "dimension": EMBEDDING_DIMENSION, + "openvino": __version__, + "python": platform.python_version(), + "logical_cpus": os.cpu_count(), + "cpu": cpu_name, + "cpu_optimization_capabilities": capabilities, + "input": "synthetic_128x128", + "warmup_per_strategy": args.warmup, + "repeats_per_strategy": args.repeats, + "results": results, + }, + indent=2, + allow_nan=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/compose.prod.yaml b/compose.prod.yaml index ed78c8a..d122e0a 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -11,5 +11,6 @@ services: FACE_SERVICE_TOKEN: ${FACE_SERVICE_TOKEN} FACE_MATCH_THRESHOLD: ${FACE_MATCH_THRESHOLD:-0.6} FACE_MATCH_MARGIN: ${FACE_MATCH_MARGIN:-0.1} + FACE_INFERENCE_PRECISION_HINT: ${FACE_INFERENCE_PRECISION_HINT:-f32} FACE_SESSION_IDLE_SECONDS: 300 FACE_SESSION_CLEANUP_INTERVAL_SECONDS: 60 diff --git a/contracts/ai-face.openapi.yaml b/contracts/ai-face.openapi.yaml index 076af19..fc59da0 100644 --- a/contracts/ai-face.openapi.yaml +++ b/contracts/ai-face.openapi.yaml @@ -43,8 +43,13 @@ paths: status: {type: string, const: not_ready} /internal/v1/face/enrollments/extract: post: - summary: Extract representative vectors from one enrollment video - description: Returns vectors only. Spring owns consent, user identity, duplicate checks, and persistence. + summary: Extract representative vectors for initial enrollment or re-enrollment + description: >- + Stateless vector extraction shared by initial enrollment and re-enrollment. + Each video goes through the same quality and identity-consistency checks. + Extraction does not read enrollment state, persist vectors, or update any + recognition session. Spring owns consent, user identity, enrollment policy, + and persistence. security: - serviceBearer: [] requestBody: @@ -89,7 +94,16 @@ paths: /internal/v1/face/sessions/{session_id}: put: summary: Create or replace an in-memory recognition session - description: Candidate vectors stay in memory for this session and are cleared when the session is replaced or deleted. + description: >- + Replaces the complete candidate gallery only after payload validation; this + is not a per-student merge. For re-enrollment, send the same student_id with + replacement vectors and include every other candidate that must remain. + No preliminary DELETE is required. Successful replacement creates a fresh + tracker and resets track history and recognition attempts. Invalid payloads + leave the previous session intact. Candidate vectors stay in memory; retired + vectors are cleared after the last in-flight request releases the old session. + Automatic camera updates require Spring to send this PUT for each affected + session to the same AI process that holds that session. security: - serviceBearer: [] parameters: @@ -111,13 +125,13 @@ paths: status: {type: string, const: ready} "401": {$ref: "#/components/responses/Unauthorized"} "422": - description: Invalid payload, incompatible model, invalid vector, or duplicate student IDs. + description: Invalid payload, incompatible model, invalid vector, or duplicate student IDs; the previous session remains unchanged. content: application/json: schema: {$ref: "#/components/schemas/ErrorResponse"} delete: summary: Delete a recognition session - description: Idempotent; returns deleted even when the session does not exist. + description: Idempotent; returns deleted even when the session does not exist. In-flight requests suppress retired-session results, and vectors are cleared after the last in-flight request releases the session. security: - serviceBearer: [] parameters: @@ -141,7 +155,12 @@ paths: /internal/v1/face/sessions/{session_id}/frames: post: summary: Recognize faces in one camera frame - description: Returns inference results only. It does not authorize or record attendance. + description: >- + Returns inference results only. It does not authorize or record attendance. + If a session is replaced or closed before a successful frame result is + returned, the response is 200 with the same frameId and an empty faces array + rather than results from the retired gallery. Frames acquired after a + successful PUT use the replacement gallery and fresh tracker. security: - serviceBearer: [] parameters: @@ -160,7 +179,7 @@ paths: schema: {type: string, format: binary} responses: "200": - description: One result for each face detected in this frame. + description: One result for each detected face; faces is empty when none are detected or the acquired session was replaced or closed before the result was returned. content: application/json: schema: {$ref: "#/components/schemas/FrameResponse"} @@ -269,7 +288,7 @@ components: type: array maxItems: 200 items: {$ref: "#/components/schemas/Candidate"} - description: Student IDs must be unique. Every vector must match the declared model and contain 256 finite values. + description: Complete replacement candidate list, not a partial update. Student IDs must be unique. Every vector must match the declared model and contain 256 finite values. Omitted candidates are removed after a successful PUT. EnrollmentResponse: type: object required: [model, reviewedFrames, acceptedFrames, vectors] diff --git a/docs/plans/face-recognition-test-plan.md b/docs/plans/face-recognition-test-plan.md index 51ad659..ab78463 100644 --- a/docs/plans/face-recognition-test-plan.md +++ b/docs/plans/face-recognition-test-plan.md @@ -9,6 +9,7 @@ the service returns recognition results to the backend contract. - A frame with two faces produces two independent `faces[]` results and stable track IDs. - A below-threshold or below-margin match is `UNKNOWN` with `studentId: null`. - Candidate model metadata mismatch is rejected; vectors are not compared across model versions. +- The optional BF16 inference hint is rejected if the CPU cannot apply BF16; its model version differs from FP32 while both return normalized float32 256D vectors. - Bounding boxes and pixel landmarks are clipped to the decoded frame before returning; malformed non-finite coordinates return a JSON inference error. - Frame and enrollment inference exceptions return JSON `500 INTERNAL_ERROR`; undecodable enrollment media remains `400 INVALID_MEDIA`. - Idle recognition sessions expire on schedule, never expire while a frame is in flight, and clear cached candidate vectors when removed. @@ -33,6 +34,8 @@ Use a consented, labeled validation set split by person and capture session. Rep true accept rate, false accept rate, false reject rate, unknown rate, score distributions, and the effect of threshold/margin. Thresholds must be calibrated from held-out data and configured through deployment settings; they are not inferred from MediaPipe confidence. +Compare FP32 and BF16 on the same held-out identities before enabling BF16. Re-enroll all vectors +under the selected precision and do not mix the model versions in one recognition session. ## Low-light evaluation diff --git a/docs/plans/face-recognition.md b/docs/plans/face-recognition.md index 0d02dcd..e6b920e 100644 --- a/docs/plans/face-recognition.md +++ b/docs/plans/face-recognition.md @@ -26,6 +26,18 @@ - Regression matrix: `tests/face_service/test_api.py` covers missing, wrong, DataGSM, and SESSION-only credentials across all four protected methods, including early rejection and secret-free errors. - OAuth/AI end-to-end scenario `ACC-AUTH-001` stays unverified until Spring integration is implemented and exercised. +## AI 얼굴 재등록 지원 — 2026-10-11 + +최신 사용자 요청에 따라 AI 코드·테스트·내부 계약에 한해 재등록 지원을 추가한다. 기존 공통 제품 명세의 재등록 제외 정책과 Spring·DB·웹 구현은 이번 작업에서 변경하지 않는다. + +- 기존 `POST /internal/v1/face/enrollments/extract`를 최초 등록과 재등록에 재사용한다. 등록 여부를 조회하지 않는 추출이며, 어떤 인식 갤러리도 변경하지 않는다. +- 기존 `PUT /internal/v1/face/sessions/{session_id}`로 검증된 전체 후보를 교체한다. 같은 학생 ID에 새 벡터를 넣고 유지할 다른 후보를 포함하며, 사전 DELETE는 필요 없다. +- 유효하지 않은 PUT은 이전 세션을 유지하고, 유효한 PUT은 새 갤러리와 트래커를 사용한다. 이전 벡터는 마지막 처리 중 요청의 참조가 해제되면 정리한다. +- 교체·종료된 세션의 처리 중 프레임은 성공 응답 직전에 확인해 `200 {frameId, faces: []}`로 구 결과를 생략한다. 후속 프레임은 새 갤러리를 사용한다. +- 자동 카메라 반영은 Spring이 저장 후 관련 세션을 보유한 같은 AI 프로세스에 갱신 PUT을 보내야 한다. 해당 백엔드 연결은 이번 AI 작업 범위 밖이다. +- 자동 테스트 검증: 반복 추출의 무상태성, 동일 학생 벡터 교체, 실패 시 기존 세션 유지, 트래커 초기화·벡터 폐기, 처리 중 PUT·DELETE·종료와 후속 프레임, 반복 PUT·독립 세션을 합성 데이터로 확인했다. 근거: `tests/face_service/test_api.py`, `tests/face_service/test_api_latency.py`. +- 실행 결과: AI 저장소에서 `.\.venv\Scripts\python.exe -m pytest tests/face_service` 126개 통과, `.\.venv\Scripts\ruff.exe check .` 통과, `npm.cmd run harness:check` 문서 검사 및 23개 하네스 테스트 통과. 기존 의존성의 deprecation 경고 3개가 있었다. 실제 카메라·Spring 연동·운영 배포는 실행하지 않았으며 수용 시나리오 검증 상태는 변경하지 않았다. + ## 계약 보완 - [ ] Spring 공개 등록 API는 `SESSION` 세션에서 학생을 확인하고 동의·중복 여부를 검사한다. 실제 Spring API 경로와 request/response는 server 구현자와 별도 계약한다. diff --git a/docs/reviews/face-detection-benchmark-2026-10-11.md b/docs/reviews/face-detection-benchmark-2026-10-11.md new file mode 100644 index 0000000..3a32c8c --- /dev/null +++ b/docs/reviews/face-detection-benchmark-2026-10-11.md @@ -0,0 +1,64 @@ +# 얼굴 프레임 지연시간 및 실행 모드 비교 — 2026-10-11 + +이번 측정은 현재 AI 프레임 API와 MediaPipe `IMAGE`/`VIDEO` 모드를 비교한다. +실학생 데이터 대신 코드로 그린 비사진 합성 프레임과 무작위 256차원 벡터만 사용했다. +프레임·랜드마크·학생 ID는 파일에 저장하거나 출력하지 않았다. + +## 프레임 API 단계 지연시간 + +[`face_api_synthetic.py`](../../benchmarks/face_api_synthetic.py)는 실제 FastAPI 라우트, +MediaPipe 태스크, OpenVINO 임베더를 구동하고 기존 `face_frame_timing_ms` 로그를 집계한다. +60개 합성 JPEG 요청 중 10개를 워밍업으로 제외했다. 비교 갤러리는 합성 학생 50명, +학생당 벡터 20개였다. 로컬 환경은 Windows, Python 3.12.0, OpenCV 4.11.0, +MediaPipe 0.10.21, OpenVINO 2026.3.1, 논리 CPU 16개다. + +| 단계 | 표본 수 | 중앙값 | p95 | +| --- | ---: | ---: | ---: | +| read | 50 | 0.202 ms | 0.272 ms | +| decode | 50 | 0.437 ms | 0.567 ms | +| detect | 50 | 11.319 ms | 11.805 ms | +| embed | 7 | 2.858 ms | 3.398 ms | +| match | 7 | 0.211 ms | 0.222 ms | +| cleanup | 50 | 0.045 ms | 0.053 ms | +| total | 50 | 12.164 ms | 15.363 ms | + +측정 요청마다 합성 얼굴 1개가 검출됐다. 품질·쿨다운 조건을 통과해 임베딩을 시도한 +요청은 7개다. 따라서 embed/match 표본은 7개뿐이고, 실제 카메라·네트워크·서버 운영 +환경의 지연시간으로 해석하면 안 된다. 이 합성 API 실행에서는 detect가 가장 큰 단계였다. + +## IMAGE와 VIDEO 비교 + +[`face_detection_modes.py`](../../benchmarks/face_detection_modes.py)는 같은 합성 프레임을 +두 독립 태스크에 전달한다. 첫 비교는 120프레임 중 워밍업 10개, 재비교는 600프레임 중 +워밍업 30개를 제외했다. 표의 시간은 MediaPipe 검출 호출만 측정한다. + +| 실행 | 모드 | 표본 | 중앙값 | p95 | +| --- | --- | ---: | ---: | ---: | +| 120프레임 | IMAGE | 110 | 9.340 ms | 10.091 ms | +| 120프레임 | VIDEO | 110 | 9.283 ms | 10.205 ms | +| 600프레임 | IMAGE | 570 | 9.197 ms | 10.098 ms | +| 600프레임 | VIDEO | 570 | 9.143 ms | 10.078 ms | + +600프레임 실행에서 VIDEO 중앙값은 약 0.6%, p95는 약 0.2% 낮았다. 짧은 실행에서는 +p95가 반대로 VIDEO에서 높았다. 합성 입력에서 얼굴 수 불일치 프레임은 없었고 좌표도 +같았지만, 이는 실제 영상의 검출 품질이나 오인식률을 검증한 결과가 아니다. + +## 결정과 제한 + +이 결과만으로 서비스 기본 실행 모드를 바꾸지 않는다. 측정된 차이가 작고 실행마다 +방향이 일정하지 않다. 또한 MediaPipe VIDEO 모드는 프레임마다 단조 증가하는 타임스탬프를 +요구한다. 세션 사이 추적 상태가 섞이지 않게 하려면 현재 공유 Landmarker를 세션별 상태로 +바꾸고 세션 생성·종료·동시 요청을 함께 다뤄야 한다. [FaceLandmarker 공식 API 문서](https://ai.google.dev/edge/api/mediapipe/python/mp/tasks/vision/FaceLandmarker) +는 `detect_for_video`와 단조 타임스탬프 조건을 설명한다. + +AI 서비스에는 `face_frame_timing_ms` DEBUG 단계 로거가 이미 있으나 이번 실행에는 실제 +운영 요청 로그가 제공되지 않았다. 실제 승인된 테스트 영상이 있으면 아래 명령으로 같은 +두 모드를 비교할 수 있다. 스크립트는 경로·이미지·랜드마크를 출력하지 않는다. + +```powershell +.\.venv\Scripts\python.exe benchmarks\face_detection_modes.py --video "C:\path\to\test.mp4" +``` + +현재 확정한 것은 합성 입력에서 detect가 큰 단계라는 진단뿐이다. VIDEO 전환은 보류한다. +다음 최적화 후보는 얼굴 크기와 검출 결과를 함께 비교하는 입력 해상도 단계별 측정이다. +256차원 임베딩, 벡터 형식, 임계값, 인식 로직은 변경하지 않았다. diff --git a/docs/reviews/face-resolution-sweep-2026-10-11.md b/docs/reviews/face-resolution-sweep-2026-10-11.md new file mode 100644 index 0000000..4fa6fc6 --- /dev/null +++ b/docs/reviews/face-resolution-sweep-2026-10-11.md @@ -0,0 +1,43 @@ +# Face detector input-resolution sweep — 2026-10-11 + +The API-level synthetic run in [the detection-mode review](face-detection-benchmark-2026-10-11.md) +found a 11.319 ms median `detect` stage, the largest measured part of the frame request. +This follow-up checks whether resizing the image before MediaPipe reduces that cost. + +[`face_resolution_sweep.py`](../../benchmarks/face_resolution_sweep.py) runs the existing +MediaPipe IMAGE task at 640×480, 480×360, 320×240, and 240×180. Its default input is a +procedurally drawn, non-photographic face. Apparent face sizes are varied to expose the +limits of this synthetic check. The script also accepts a local video; its frames and +landmarks stay in memory and only aggregate results are printed. + +The larger sweep used 90 measured frames for apparent face sizes 1.0 and 0.5. Both were +detected at every tested resolution. Results below compare each resized result to the same +frame processed at 640×480. Latency includes resize, BGR-to-RGB conversion, MediaPipe image +creation, and the detector call; it does not include the API's downstream quality checks. + +| Synthetic face size | Input size | Median | p95 | Count mismatches | Landmark max-delta p95 | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 1.0 | 640×480 | 9.774 ms | 10.321 ms | 0/90 | 0 | +| 1.0 | 480×360 | 9.072 ms | 10.035 ms | 0/90 | 0.0139 | +| 1.0 | 320×240 | 9.127 ms | 9.718 ms | 0/90 | 0.0135 | +| 1.0 | 240×180 | 9.058 ms | 9.718 ms | 0/90 | 0.0190 | +| 0.5 | 640×480 | 10.089 ms | 11.440 ms | 0/90 | 0 | +| 0.5 | 480×360 | 9.404 ms | 10.554 ms | 0/90 | 0.0089 | +| 0.5 | 320×240 | 9.265 ms | 10.532 ms | 0/90 | 0.0165 | +| 0.5 | 240×180 | 9.236 ms | 10.225 ms | 0/90 | 0.0137 | + +For these two synthetic sizes, 480×360 was about 7% faster at the median and retained +the face count. Its landmark max-delta p95 was about 0.9% of image width for the smaller +face. Lower scales also retained the count here, but their coordinate differences were +larger or varied by case. A smaller synthetic face (size 0.3125) was not detected at +full resolution; one 240×180 frame then produced a detection, so parity with the full-size +reference cannot establish recall or accuracy. Size 0.25 was not detected at any tested +scale. + +## Decision + +Do not downscale production frames yet. The apparent 7–8% speed gain is promising, but a +procedural drawing cannot validate landmark stability for real faces or embedding/match +accuracy. Use an approved representative video to compare full resolution with 480×360 +before changing runtime behavior. The 256-dimensional embedding and wire/data format stay +unchanged. diff --git a/docs/reviews/face-thread-benchmark-2026-10-11.md b/docs/reviews/face-thread-benchmark-2026-10-11.md new file mode 100644 index 0000000..9941cf6 --- /dev/null +++ b/docs/reviews/face-thread-benchmark-2026-10-11.md @@ -0,0 +1,22 @@ +# OpenVINO CPU thread benchmark — 2026-10-11 + +The reproducible benchmark is [`benchmarks/openvino_cpu_threads.py`](../../benchmarks/openvino_cpu_threads.py). +It measures preprocessing and inference for the existing `face-reidentification-retail-0095` +model with synthetic 128×128 input. It does not use face or enrollment data and does not +measure detection, full-request latency, production concurrency, or recognition accuracy. + +Environment: Windows, Python 3.12.0, NumPy 1.26.4, OpenVINO 2026.3.1, 16 logical CPUs. +Each setting used 20 warmups and 200 timed runs. + +| CPU threads | Resolved threads | Median (ms) | p95 (ms) | +| ---: | ---: | ---: | ---: | +| OpenVINO default | 14 | 1.857 | 2.568 | +| 1 | 1 | 5.439 | 6.728 | +| 2 | 2 | 3.510 | 4.087 | +| 4 | 4 | 2.371 | 2.873 | +| 8 | 8 | 1.974 | 2.543 | + +All settings returned the same normalized 256-dimensional vector in this run +(cosine similarity 1.0; maximum absolute difference 0.0). The default had the best +median latency, so this measurement does not support changing the service's thread +configuration. It also does not establish performance on the production host. diff --git a/docs/reviews/openvino-bf16-benchmark-2026-10-11.md b/docs/reviews/openvino-bf16-benchmark-2026-10-11.md new file mode 100644 index 0000000..ca37a79 --- /dev/null +++ b/docs/reviews/openvino-bf16-benchmark-2026-10-11.md @@ -0,0 +1,38 @@ +# OpenVINO BF16 embedding benchmark — 2026-10-11 + +[`openvino_precision.py`](../../benchmarks/openvino_precision.py) compares the current +FP32 CPU compile with `INFERENCE_PRECISION_HINT=bf16`. Both use the same 0095 model, +128×128 synthetic input, HWC-to-NCHW conversion, L2 normalization, and buffer cleanup. +Each run used 100 warmups and 1,000 timed calls per precision. + +Environment: Windows, Intel Core Ultra 7 255H, 16 logical CPUs, Python 3.12.0, +OpenVINO 2026.3.1. The CPU runtime reports BF16 support and resolves the explicit hint to +`bfloat16`. + +| Run | Precision | Median | p95 | +| ---: | --- | ---: | ---: | +| 1 | FP32 | 2.396 ms | 3.436 ms | +| 1 | BF16 | 2.275 ms | 3.262 ms | +| 2 | FP32 | 2.083 ms | 2.971 ms | +| 2 | BF16 | 1.944 ms | 2.775 ms | + +BF16 was about 5–7% faster in both runs. It returned the same 256-dimensional shape; +normalized vector cosine similarity to FP32 was 0.999281 and maximum per-component +difference was 0.00721. These are synthetic-input comparisons, not genuine face match, +false-accept, or false-reject results. + +OpenVINO separates model storage precision from runtime inference precision, and CPU BF16 +execution depends on hardware support. See its [precision control guide](https://docs.openvino.ai/2026/openvino-workflow/running-inference/optimize-inference/precision-control.html) +and [CPU device guide](https://docs.openvino.ai/2026/openvino-workflow/running-inference/inference-devices-and-modes/cpu-device.html). + +## Decision + +Expose BF16 as an opt-in CPU setting, keep FP32 as the default, and put the precision in +the model version so vectors from different inference modes cannot be mixed accidentally. +Set `FACE_INFERENCE_PRECISION_HINT=bf16` in the AI service environment to opt in; the +default `f32` produces the existing `@2023.0-fp32` version, while BF16 produces +`@2023.0-bf16`. Startup fails clearly if the CPU cannot apply the requested BF16 mode. +The API still carries normalized float32 vectors with dimension 256; there is no vector +schema change or DB migration. Enrollments must be regenerated together under BF16 before +using that model version. Do not enable BF16 for attendance until a representative, +consented validation set confirms the matching thresholds and error rates. diff --git a/docs/reviews/openvino-infer-request-benchmark-2026-10-11.md b/docs/reviews/openvino-infer-request-benchmark-2026-10-11.md new file mode 100644 index 0000000..3f139a8 --- /dev/null +++ b/docs/reviews/openvino-infer-request-benchmark-2026-10-11.md @@ -0,0 +1,29 @@ +# OpenVINO inference-request benchmark — 2026-10-11 + +[`openvino_infer_request.py`](../../benchmarks/openvino_infer_request.py) compares the +current `CompiledModel` callable with a dedicated reusable `InferRequest`. Both variants +also run with output sharing enabled to check whether skipping the output copy helps. +The timed function includes the existing 128×128 HWC-to-NCHW conversion, inference, +256D L2 normalization, and cleanup. Input pixels are generated noise; no face data is used. + +Environment: Windows, Python 3.12.0, NumPy 1.26.4, OpenVINO 2026.3.1, 16 logical CPUs. +Each strategy used 100 warmups and 1,000 timed runs. Strategies ran serially in rotating +order. Every returned normalized embedding matched the current callable exactly in this run +(cosine 1.0, maximum absolute difference 0.0). + +| Strategy | Median | p95 | +| --- | ---: | ---: | +| CompiledModel callable | 2.204 ms | 3.664 ms | +| CompiledModel callable, shared output | 2.231 ms | 3.688 ms | +| Reused InferRequest | 2.223 ms | 3.703 ms | +| Reused InferRequest, shared output | 2.226 ms | 3.640 ms | + +The median spread was under 0.03 ms and no alternative consistently improved both median +and p95, so keep the existing production call. OpenVINO documents that the callable wrapper +uses a temporary request it can reuse on consecutive calls, and suggests a dedicated +`InferRequest` for workflows that need more control; this simple serialized workload did not +benefit from switching. See [CompiledModel](https://docs.openvino.ai/2026/api/ie_python_api/_autosummary/openvino.CompiledModel.html) +and [InferRequest](https://docs.openvino.ai/2026/api/ie_python_api/_autosummary/openvino.InferRequest.html). + +This is a model-call microbenchmark, not full frame latency or a concurrency test. No runtime +code, model, embedding dimension, or vector format changed. diff --git a/face_api.py b/face_api.py index cb0669d..5ee8711 100644 --- a/face_api.py +++ b/face_api.py @@ -223,7 +223,12 @@ def release_session(session_id: str, session: Session) -> None: async def lifespan(app: FastAPI): runtime = models if runtime is None and cfg.landmarker_path.is_file() and cfg.embedding_model_path.is_file(): - runtime = InferenceModels(str(cfg.landmarker_path), str(cfg.embedding_model_path), metadata) + runtime = InferenceModels( + str(cfg.landmarker_path), + str(cfg.embedding_model_path), + metadata, + cfg.inference_precision_hint, + ) app.state.models = runtime app.state.sessions = sessions app.state.retired_sessions = retired_sessions @@ -417,6 +422,9 @@ async def put_session( } }, responses={ + 200: { + "description": "Per-face results; frames from a replaced or closing session return an empty faces list." + }, 400: {"model": ErrorResponse, "description": "Unsupported or undecodable image."}, 401: {"model": ErrorResponse, "description": "Missing or invalid service bearer token."}, 404: {"model": ErrorResponse, "description": "Recognition session is not active."}, @@ -503,6 +511,9 @@ async def frame( "qrRecommended": track.failures >= 4, } ) + # A streamed upload can resume after PUT/DELETE retired this session. + if session.closing or sessions.get(session_id) is not session: + results = [] timings.outcome = "success" return {"frameId": x_frame_id, "faces": results} except FaceServiceError as exc: diff --git a/face_config.py b/face_config.py index f86e95f..ea50f61 100644 --- a/face_config.py +++ b/face_config.py @@ -36,8 +36,13 @@ class Settings: session_cleanup_interval_seconds: float = float( os.getenv("FACE_SESSION_CLEANUP_INTERVAL_SECONDS", "60") ) + inference_precision_hint: str = field( + default_factory=lambda: os.getenv("FACE_INFERENCE_PRECISION_HINT", "f32").strip().lower() + ) def __post_init__(self) -> None: + if self.inference_precision_hint not in {"f32", "bf16"}: + raise ValueError("FACE_INFERENCE_PRECISION_HINT must be f32 or bf16") if ( not isfinite(self.session_idle_seconds) or not isfinite(self.session_cleanup_interval_seconds) @@ -48,7 +53,17 @@ def __post_init__(self) -> None: @property def model_version(self) -> str: - return os.getenv("FACE_MODEL_VERSION", "face-reidentification-retail-0095@2023.0-fp32") + configured_version = os.getenv("FACE_MODEL_VERSION") + precision = "fp32" if self.inference_precision_hint == "f32" else "bf16" + version = configured_version or f"face-reidentification-retail-0095@2023.0-{precision}" + if precision == "bf16": + if version.endswith("-fp32"): + version = f"{version[:-5]}-bf16" + elif not version.endswith("-bf16"): + version = f"{version}-bf16" + elif version.endswith("-bf16"): + version = f"{version[:-5]}-fp32" + return version @property def ready_for_inference(self) -> bool: diff --git a/face_models.py b/face_models.py index db590da..4201790 100644 --- a/face_models.py +++ b/face_models.py @@ -8,6 +8,27 @@ EMBEDDING_DIMENSION = 256 +def _compile_embedding_model(core, model, precision_hint: str): + if precision_hint == "f32": + return core.compile_model(model, "CPU") + if precision_hint != "bf16": + raise ValueError("inference precision hint must be f32 or bf16") + + capabilities = {str(value).upper() for value in core.get_property("CPU", "OPTIMIZATION_CAPABILITIES")} + if "BF16" not in capabilities: + raise RuntimeError("BF16 inference was requested but is not supported by the CPU") + compiled = core.compile_model( + model, + "CPU", + {"INFERENCE_PRECISION_HINT": "bf16"}, + ) + resolved = str(compiled.get_property("INFERENCE_PRECISION_HINT")).lower() + if "bfloat16" not in resolved: + compiled = None + raise RuntimeError("OpenVINO CPU runtime did not apply the requested BF16 precision") + return compiled + + @dataclass(frozen=True) class ModelMetadata: model_id: str = MODEL_ID @@ -35,10 +56,17 @@ class InferenceModels: only after a face has been detected and aligned from MediaPipe landmarks. """ - def __init__(self, landmarker_path: str, embedding_model_path: str, metadata: ModelMetadata): + def __init__( + self, + landmarker_path: str, + embedding_model_path: str, + metadata: ModelMetadata, + inference_precision_hint: str = "f32", + ): self.metadata = metadata self._landmarker = None self._compiled = None + self.inference_precision_hint = inference_precision_hint try: import mediapipe as mp from mediapipe.tasks import python @@ -61,9 +89,13 @@ def __init__(self, landmarker_path: str, embedding_model_path: str, metadata: Mo core = Core() model = core.read_model(embedding_model_path) - self._compiled = core.compile_model(model, "CPU") + self._compiled = _compile_embedding_model(core, model, inference_precision_hint) except ImportError as exc: + self.close() raise RuntimeError("OpenVINO dependency is not installed") from exc + except BaseException: + self.close() + raise def close(self) -> None: if self._landmarker is not None: diff --git a/infra/face-ai.env.example b/infra/face-ai.env.example index 237c576..5cd53ee 100644 --- a/infra/face-ai.env.example +++ b/infra/face-ai.env.example @@ -2,5 +2,7 @@ PORT=8000 FACE_SERVICE_TOKEN= FACE_MATCH_THRESHOLD= FACE_MATCH_MARGIN= +# Keep f32 by default; use bf16 only after recognition validation and re-enrollment. +FACE_INFERENCE_PRECISION_HINT=f32 FACE_SESSION_IDLE_SECONDS=300 FACE_SESSION_CLEANUP_INTERVAL_SECONDS=60 diff --git a/tests/face_service/test_api.py b/tests/face_service/test_api.py index aca7cc9..0fca7dd 100644 --- a/tests/face_service/test_api.py +++ b/tests/face_service/test_api.py @@ -318,6 +318,68 @@ def test_internal_enrollment_extract_accepts_raw_video_with_service_token(): assert all(len(vector) == 256 for vector in payload["vectors"]) +def test_repeated_enrollment_extraction_preserves_an_existing_recognition_session(): + settings = Settings( + landmarker_path=Path("missing-landmarker.task"), + embedding_model_path=Path("missing-embedding.xml"), + service_token="service-token", + representative_vectors=3, + enrollment_frames=3, + match_threshold=0.5, + match_margin=0.1, + ) + app = create_app(settings, EnrollmentFakeModels()) + headers = {"Authorization": "Bearer service-token"} + existing_vector = [0.0, 1.0] + [0.0] * 254 + session_payload = { + "model": ModelMetadata().__dict__, + "candidates": [{"student_id": "student-synthetic", "vectors": [existing_vector]}], + } + _, image = cv2.imencode(".jpg", np.full((64, 64, 3), 127, dtype=np.uint8)) + video = make_test_mp4(frame_count=3) + with TestClient(app) as client: + response = client.put( + "/internal/v1/face/sessions/reenrollment", headers=headers, json=session_payload + ) + assert response.status_code == 200 + session = app.state.sessions["reenrollment"] + gallery_before = session.gallery.vectors.copy() + for _ in range(2): + frame = client.post( + "/internal/v1/face/sessions/reenrollment/frames", + headers={**headers, "Content-Type": "image/jpeg"}, + content=image.tobytes(), + ) + assert frame.status_code == 200 + track = session.tracker.tracks[0] + assert track.quality_observations == 2 + + for _ in range(2): + extraction = client.post( + "/internal/v1/face/enrollments/extract", + headers={**headers, "Content-Type": "video/mp4"}, + content=video, + ) + assert extraction.status_code == 200 + result = extraction.json() + assert result["acceptedFrames"] == 3 + assert result["vectors"] == [[1.0] + [0.0] * 255] * 3 + assert app.state.sessions["reenrollment"] is session + assert not session.closing and not session.gallery.cleared + np.testing.assert_array_equal(session.gallery.vectors, gallery_before) + assert session.tracker.tracks == [track] + assert track.quality_observations == 2 and track.failures == 0 + + frame = client.post( + "/internal/v1/face/sessions/reenrollment/frames", + headers={**headers, "Content-Type": "image/jpeg"}, + content=image.tobytes(), + ) + assert frame.status_code == 200 + recognition = frame.json()["faces"][0]["recognition"] + assert recognition["status"] == "UNKNOWN" and recognition["studentId"] is None + + @pytest.mark.parametrize( ("method", "path", "request_kwargs"), [ diff --git a/tests/face_service/test_api_latency.py b/tests/face_service/test_api_latency.py index 6c4291b..ff16c15 100644 --- a/tests/face_service/test_api_latency.py +++ b/tests/face_service/test_api_latency.py @@ -85,9 +85,9 @@ def encoded_image(): return encoded.tobytes() -def send_frame(client, image): +def send_frame(client, image, session_id=SESSION): return client.post( - f"/internal/v1/face/sessions/{SESSION}/frames", + f"/internal/v1/face/sessions/{session_id}/frames", headers={"Content-Type": "image/jpeg", "X-Frame-Id": "frame-private"}, content=image, ) @@ -215,6 +215,76 @@ def test_multiple_faces_keep_attempts_and_embedding_gates_isolated(): assert all(not probe.any() for probe in models.probes) +def test_repeated_put_replaces_same_student_synthetic_vectors_and_keeps_other_session(): + models = MetadataModels() + app = create_app(settings(), models) + image = encoded_image() + other_id = "other-session-synthetic" + with TestClient(app, headers=AUTH) as client: + install_session(client) + assert client.put( + f"/internal/v1/face/sessions/{other_id}", json=payload() + ).status_code == 200 + other = app.state.sessions[other_id] + other_vectors = other.gallery.vectors.copy() + for _ in range(2): + assert send_frame(client, image, other_id).status_code == 200 + other_track = other.tracker.tracks[0] + + for _ in range(3): + response = send_frame(client, image) + assert response.json()["faces"][0]["recognition"]["studentId"] == "student-private" + models.vector_indices = (1,) + for _ in range(3): + response = send_frame(client, image) + assert response.json()["faces"][0]["recognition"]["status"] == "UNKNOWN" + original = app.state.sessions[SESSION] + assert original.tracker.tracks[0].failures == 1 + assert original.tracker.tracks[0].cooldown_frames == 5 + + for _ in range(3): + previous = app.state.sessions[SESSION] + previous_vectors = previous.gallery.vectors + response = client.put( + f"/internal/v1/face/sessions/{SESSION}", json=payload(vector_index=1) + ) + assert response.status_code == 200 and response.json() == {"status": "ready"} + current = app.state.sessions[SESSION] + assert current is not previous and not current.closing + assert previous.closing and previous.gallery.cleared + assert not previous_vectors.any() and not previous.gallery.counts.any() + assert not previous.tracker.tracks and id(previous) not in app.state.retired_sessions + assert not current.tracker.tracks and not current.gallery.cleared + np.testing.assert_array_equal( + current.gallery.vectors[0, 0], [0.0, 1.0] + [0.0] * 254 + ) + for expected_count in (1, 2): + response = send_frame(client, image) + assert response.status_code == 200 + face = response.json()["faces"][0] + assert face["recognition"]["status"] == "NOT_ATTEMPTED" + assert face["attempts"] == 0 and not face["qrRecommended"] + track = current.tracker.tracks[0] + assert track.quality_observations == expected_count and track.cooldown_frames == 0 + response = send_frame(client, image) + assert response.status_code == 200 + face = response.json()["faces"][0] + assert face["recognition"]["status"] == "KNOWN" + assert face["recognition"]["studentId"] == "student-private" + assert face["attempts"] == 0 and not face["qrRecommended"] + assert app.state.sessions[other_id] is other + assert not other.closing and not other.gallery.cleared + np.testing.assert_array_equal(other.gallery.vectors, other_vectors) + assert other.tracker.tracks == [other_track] + assert other_track.quality_observations == 2 + + models.vector_indices = (0,) + response = send_frame(client, image, other_id) + assert response.status_code == 200 + recognition = response.json()["faces"][0]["recognition"] + assert recognition["status"] == "KNOWN" and recognition["studentId"] == "student-private" + + @pytest.mark.parametrize("failure", ["detect", "embed", "match"]) def test_inference_errors_clear_image_and_any_returned_probe(monkeypatch, failure): models = MetadataModels() @@ -279,7 +349,7 @@ async def async_client(app): async def async_frame(client, content): return await client.post( f"/internal/v1/face/sessions/{SESSION}/frames", - headers={"Content-Type": "image/jpeg"}, content=content, + headers={"Content-Type": "image/jpeg", "X-Frame-Id": "frame-private"}, content=content, ) @@ -325,7 +395,7 @@ async def streamed_body(): unblock.set() result = await asyncio.wait_for(task, timeout=3) assert result.status_code == 200 - assert result.json()["faces"][0]["recognition"]["studentId"] == "student-private" + assert result.json() == {"frameId": "frame-private", "faces": []} assert old.in_flight == 0 and old.gallery.cleared and not old.gallery.vectors.any() assert not old.tracker.tracks and id(old) not in app.state.retired_sessions if operation == "replace": @@ -478,7 +548,7 @@ async def wait_for_shutdown_retirement(): unblock.set() response = await asyncio.wait_for(upload_task, timeout=3) assert response.status_code == 200 - assert response.json()["faces"][0]["recognition"]["studentId"] == "student-private" + assert response.json() == {"frameId": "frame-private", "faces": []} await asyncio.wait_for(shutdown_task, timeout=3) assert runtime_closed == [True] assert busy.in_flight == 0 and busy.gallery.cleared diff --git a/tests/face_service/test_config.py b/tests/face_service/test_config.py new file mode 100644 index 0000000..f01e2c7 --- /dev/null +++ b/tests/face_service/test_config.py @@ -0,0 +1,43 @@ +import pytest + +from face_config import Settings + + +@pytest.mark.parametrize( + ("precision_hint", "version"), + [ + ("f32", "face-reidentification-retail-0095@2023.0-fp32"), + ("bf16", "face-reidentification-retail-0095@2023.0-bf16"), + ], +) +def test_model_version_identifies_inference_precision(monkeypatch, precision_hint, version): + monkeypatch.delenv("FACE_MODEL_VERSION", raising=False) + + assert Settings(inference_precision_hint=precision_hint).model_version == version + + +def test_face_model_version_override_keeps_fp32_identity(monkeypatch): + monkeypatch.setenv("FACE_MODEL_VERSION", "custom-face-model@1") + + assert Settings(inference_precision_hint="f32").model_version == "custom-face-model@1" + + +def test_bf16_precision_is_added_to_custom_model_version(monkeypatch): + monkeypatch.setenv("FACE_MODEL_VERSION", "custom-face-model@1") + + assert Settings(inference_precision_hint="bf16").model_version == "custom-face-model@1-bf16" + + +def test_inference_precision_hint_reads_environment(monkeypatch): + monkeypatch.setenv("FACE_INFERENCE_PRECISION_HINT", "BF16") + monkeypatch.delenv("FACE_MODEL_VERSION", raising=False) + + settings = Settings() + + assert settings.inference_precision_hint == "bf16" + assert settings.model_version == "face-reidentification-retail-0095@2023.0-bf16" + + +def test_unknown_inference_precision_hint_is_rejected(): + with pytest.raises(ValueError, match="FACE_INFERENCE_PRECISION_HINT"): + Settings(inference_precision_hint="fp16") diff --git a/tests/face_service/test_models.py b/tests/face_service/test_models.py index 7ad0e53..e387740 100644 --- a/tests/face_service/test_models.py +++ b/tests/face_service/test_models.py @@ -91,6 +91,70 @@ def fail(_image): assert captured_rgb and not captured_rgb[0].any() +class FakeOpenVINOCompiled: + def __init__(self, precision): + self.precision = precision + + def get_property(self, name): + assert name == "INFERENCE_PRECISION_HINT" + return self.precision + + +class FakeOpenVINOCore: + def __init__(self, capabilities=("FP32", "BF16"), resolved_precision="bfloat16"): + self.capabilities = capabilities + self.resolved_precision = resolved_precision + self.compile_args = None + + def get_property(self, device, name): + assert device == "CPU" + assert name == "OPTIMIZATION_CAPABILITIES" + return self.capabilities + + def compile_model(self, *args): + self.compile_args = args + return FakeOpenVINOCompiled(self.resolved_precision) + + +def test_default_embedding_compile_keeps_openvino_precision_default(): + core = FakeOpenVINOCore() + model = object() + + compiled = face_models._compile_embedding_model(core, model, "f32") + + assert core.compile_args == (model, "CPU") + assert isinstance(compiled, FakeOpenVINOCompiled) + + +def test_bf16_embedding_compile_requires_and_applies_runtime_support(): + core = FakeOpenVINOCore() + model = object() + + compiled = face_models._compile_embedding_model(core, model, "bf16") + + assert core.compile_args == ( + model, + "CPU", + {"INFERENCE_PRECISION_HINT": "bf16"}, + ) + assert isinstance(compiled, FakeOpenVINOCompiled) + + +def test_bf16_embedding_compile_rejects_unsupported_cpu(): + core = FakeOpenVINOCore(capabilities=("FP32",)) + + with pytest.raises(RuntimeError, match="not supported by the CPU"): + face_models._compile_embedding_model(core, object(), "bf16") + assert core.compile_args is None + + +def test_bf16_embedding_compile_rejects_unapplied_precision_hint(): + core = FakeOpenVINOCore(resolved_precision="float32") + + with pytest.raises(RuntimeError, match="did not apply the requested BF16"): + face_models._compile_embedding_model(core, object(), "bf16") + + @pytest.mark.parametrize("fail", [False, True]) def test_embed_face_uses_original_landmarks_and_clears_aligned_image(monkeypatch, fail): runtime = InferenceModels.__new__(InferenceModels)