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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ COPY --from=builder --chown=app:app /opt/models /app/models
COPY --from=builder --chown=app:app /build/models/download-manifest.json /app/models/download-manifest.json
COPY --chown=app:app \
face_api.py \
face_execution.py \
face_config.py \
face_models.py \
face_pipeline.py \
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const response = await fetch(`${apiBaseUrl}/api/v1/face/enrollments`, {
| --- | --- | --- |
| `FACE_SERVICE_TOKEN` | FastAPI와 Spring의 서버 secret | Spring→FastAPI 서비스 인증. 두 곳에 같은 값을 설정 |
| `FACE_MATCH_THRESHOLD`, `FACE_MATCH_MARGIN` | FastAPI 환경 변수 | 인식 판정 기준. 실제 측정된 값을 사용 |
| `FACE_SUCCESS_RECHECK_SECONDS` | FastAPI 환경 변수 | 성공한 얼굴 트랙 재인식 유예 시간. 기본 1초, `0`이면 비활성화 |
| `FACE_AI_BASE_URL` | Spring 환경 변수 | FastAPI의 내부 주소 |
| `PORT` | FastAPI 실행 환경 | 수신 포트. 기본값 `8000` |
| `PUBLIC_ORIGIN` | Spring 환경 변수 | 로그인 완료 후 브라우저가 돌아갈 프론트 주소 |
Expand All @@ -191,14 +192,23 @@ const response = await fetch(`${apiBaseUrl}/api/v1/face/enrollments`, {
## 인식 지연 진단

인식 세션은 후보 행렬을 최초 PUT에서 준비하고, 기존 3품질 프레임·쿨다운 조건을 통과한 얼굴에만
임베딩을 계산합니다. 모델과 인식 임계값은 그대로 사용합니다. `face_api` logger를 DEBUG로 설정하면
단계별 시간과 처리 개수만 기록합니다.
임베딩을 계산합니다. 성공한 트랙은 최대 1초까지 반복 임베딩을 건너뛰며, 얼굴 이탈·저품질·위치/매칭
모호성에는 즉시 재검사를 시작합니다. 이 동안에도 전체 후보 벡터와 인식 임계값은 그대로 유지합니다.
동기 추론은 단일 작업자에서 실행하며 등록 영상은 프레임 단위로 추론 작업에 교차 제출됩니다.
`face_api` logger를 DEBUG로 설정하면 단계별 시간과 처리 개수만 기록합니다.

```powershell
python benchmarks/face_matching.py --students 1 50 200
```

To measure the effect of suppressing repeated embeddings for a recently recognized
track, run `python benchmarks/face_recheck_benchmark.py`. This uses synthetic data;
the [measurement record](docs/reviews/face-recheck-benchmark-2026-10-13.md) reports
latency and embedding counts, not real-face accuracy.

이 벤치마크는 합성 데이터의 후보 비교만 측정합니다. 전체 카메라 지연이나 인식 정확도를 대신하지 않습니다.
검출 비교는 선택된 승인 영상을 `benchmarks/face_detector_comparison.py`로 측정합니다. 합성 도형은
실행·시간 측정에만 쓰며 얼굴 정확도 근거가 아닙니다.
Spring의 시작 시각 기준 200ms 제한에는 V17 마이그레이션이 필요합니다.
[구현·검증 결과와 배포 순서](docs/reviews/face-latency-2026-10-07.md)를 확인하세요.

Expand Down
466 changes: 466 additions & 0 deletions benchmarks/face_detector_comparison.py

Large diffs are not rendered by default.

201 changes: 201 additions & 0 deletions benchmarks/face_recheck_benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Compare repeated successful recognition with success recheck suppression.

Only a procedural non-photographic frame is used. The exact embedding generated
from that frame seeds one synthetic gallery entry so both lanes recognize it.
The benchmark measures the existing API and prints only aggregate timings/counts.

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

from __future__ import annotations

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

import cv2
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_api_synthetic import parse_timing_records, summarize, synthetic_candidates # noqa: E402
from face_detection_modes import positive_int, 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
from face_pipeline import clear_observations, quality_issues # noqa: E402


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--requests", type=positive_int, default=100, help="measured requests per lane")
parser.add_argument("--warmup", type=positive_int, default=20)
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=20261013)
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)
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
app = create_app(settings=settings)
lanes = (
("recheck_disabled", "synthetic-recheck-disabled"),
("recheck_enabled", "synthetic-recheck-enabled"),
)
headers = {"Authorization": f"Bearer {token}"}
frame = synthetic_frame(0)
encoded = None
probe = None
observations = []
try:
with TestClient(app) as client:
runtime = app.state.models
observations = runtime.detect_faces(frame)
eligible = [item for item in observations if not quality_issues(item)]
if not eligible:
raise RuntimeError("the procedural frame did not produce a quality face")
probe = runtime.embed_face(frame, eligible[0])
# CandidateGallery scores the mean of its best three templates.
candidate_vectors[0, :3] = probe
candidates[0]["vectors"] = candidate_vectors[0].tolist()
payload = {
"model": {
"model_id": MODEL_ID,
"version": metadata.version,
"dimension": EMBEDDING_DIMENSION,
"normalization": metadata.normalization,
},
"candidates": candidates,
}
for _, session_id in lanes:
response = client.put(
f"/internal/v1/face/sessions/{session_id}", headers=headers, json=payload
)
response.raise_for_status()
app.state.sessions[lanes[0][1]].tracker.success_recheck_seconds = 0
app.state.sessions[lanes[1][1]].tracker.success_recheck_seconds = 1

success_counts = dict.fromkeys((name for name, _ in lanes), 0)
measured_statuses = {name: {} for name, _ in lanes}
for index in range(args.warmup + args.requests):
for name, session_id in lanes:
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()
faces = response.json()["faces"]
if not faces:
raise RuntimeError("the procedural face disappeared during the benchmark")
status = faces[0]["recognition"]["status"]
if status == "KNOWN":
success_counts[name] += 1
if index >= args.warmup:
measured_statuses[name][status] = measured_statuses[name].get(status, 0) + 1
encoded.fill(0)
for _, session_id in lanes:
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()
frame.fill(0)
if encoded is not None:
encoded.fill(0)
if probe is not None:
probe.fill(0)
clear_observations(observations)
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())
expected = 2 * (args.warmup + args.requests)
if len(records) != expected:
raise RuntimeError(f"expected {expected} timing records, received {len(records)}")
measured = {name: [] for name, _ in lanes}
for index, record in enumerate(records):
lane_index = index % len(lanes)
pair_index = index // len(lanes)
if pair_index >= args.warmup:
measured[lanes[lane_index][0]].append(record)

results = {}
for name, _ in lanes:
lane_records = measured[name]
stages = {}
for stage in ("admission", "queue", "decode", "detect", "embed", "match", "total"):
values = [float(record[stage]) for record in lane_records if stage in record]
if values:
stages[stage] = {**summarize(values), "samples": len(values)}
results[name] = {
"stages": stages,
"embedding_attempts": sum(int(record["embeddings"]) for record in lane_records),
"recognized_attempts_including_warmup": success_counts[name],
"measured_recognition_statuses": measured_statuses[name],
}

baseline = results["recheck_disabled"]
optimized = results["recheck_enabled"]
if optimized["embedding_attempts"] >= baseline["embedding_attempts"]:
raise RuntimeError("success recheck did not reduce embedding attempts")
if optimized["recognized_attempts_including_warmup"] == 0:
raise RuntimeError("known-face recognition was not exercised")
base_stages = baseline["stages"]
optimized_stages = optimized["stages"]
comparison = {
"embedding_attempt_reduction_percent": 100 * (
baseline["embedding_attempts"] - optimized["embedding_attempts"]
) / baseline["embedding_attempts"],
"total_median_reduction_percent": 100 * (
base_stages["total"]["median_ms"] - optimized_stages["total"]["median_ms"]
) / base_stages["total"]["median_ms"],
"total_p95_reduction_percent": 100 * (
base_stages["total"]["p95_ms"] - optimized_stages["total"]["p95_ms"]
) / base_stages["total"]["p95_ms"],
}

print(json.dumps({
"scope": "synthetic_known_face_recheck_api_comparison",
"input_kind": "procedural_non_photographic_frame_with_matching_model_embedding",
"requests_per_lane": args.requests,
"warmup_per_lane": args.warmup,
"synthetic_students": args.students,
"synthetic_vectors_per_student": args.vectors,
"lanes": results,
"comparison_percent_reduction": comparison,
"note": "Measures repeated-known-face compute savings, not real face accuracy or field throughput.",
}, indent=2, allow_nan=False))


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ services:
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_SUCCESS_RECHECK_SECONDS: ${FACE_SUCCESS_RECHECK_SECONDS:-1}
FACE_SESSION_IDLE_SECONDS: 300
FACE_SESSION_CLEANUP_INTERVAL_SECONDS: 60
32 changes: 32 additions & 0 deletions docs/reviews/face-recheck-benchmark-2026-10-13.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Repeated known-face benchmark — 2026-10-13

[`face_recheck_benchmark.py`](../../benchmarks/face_recheck_benchmark.py) compares the
real frame API with success rechecks disabled and enabled. It uses a procedural,
non-photographic frame and seeds the synthetic gallery with the embedding generated
from that same frame. Both lanes run interleaved in one process with the same 200
students and 20 templates per student.

Reproduce with:

```powershell
.\.venv\Scripts\python.exe benchmarks\face_recheck_benchmark.py --requests 120 --warmup 20 --students 200 --vectors 20
```

Two runs each used 20 warmups and 120 measured requests per lane:

| Run | Recheck | Total median | Total p95 | Embedding attempts |
| ---: | --- | ---: | ---: | ---: |
| 1 | Disabled | 12.131 ms | 16.720 ms | 40 |
| 1 | 1 second | 11.925 ms | 14.541 ms | 3 |
| 2 | Disabled | 11.861 ms | 16.248 ms | 40 |
| 2 | 1 second | 11.798 ms | 13.841 ms | 3 |

The feature reduced embedding and gallery-match attempts by 92.5%. Total median
latency changed by less than 2% across these runs, while p95 fell by 13.0% and
14.8%. Face detection remained about 10.7 ms per frame and dominated total time.

This measures repeated computation only. The synthetic frame is not representative
of real faces and cannot establish recognition accuracy or field throughput. During
the recheck window the API reports `NOT_ATTEMPTED` with no student ID; it does not
replay the previous `KNOWN` result. Real labeled validation is needed before drawing
accuracy conclusions or making any detector-resolution change.
Loading
Loading