Skip to content
This repository was archived by the owner on Sep 10, 2026. It is now read-only.
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
3 changes: 1 addition & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,5 @@ AI_PRIVACY_ENABLE_IDENTITY_EXCLUSION=true
AI_PRIVACY_IDENTITY_RECHECK_INTERVAL=12
AI_PRIVACY_IDENTITY_NEGATIVE_RECHECK_INTERVAL=90
AI_PRIVACY_ME_IMAGE_PATH=me.jpeg
AI_PRIVACY_FACE_MODEL_PATH=models/yolov12n-face.pt
AI_PRIVACY_FACE_DETECTION_MODEL_PATH=models/face_detection_yunet_2023mar.onnx
AI_PRIVACY_FACE_MODEL_PATH=models/yolo11s-pose_widerface.pt
AI_PRIVACY_FACE_RECOGNITION_MODEL_PATH=models/face_recognition_sface_2021dec.onnx
77 changes: 57 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,70 @@ Mac 로컬 실행은 위와 같이 CPU 모드를 사용한다. 운영 환경은

## AI privacy runtime

`privacy_blur`는 모든 WebRTC 스트림이 YOLOv12n-Face, YuNet, SFace 모델 세트를
공유하고 각 스트림은 독립적인 얼굴 추적 상태만 유지한다. 공유 워커가 짧은 시간
동안 들어온 프레임을 최대 `AI_PRIVACY_MAX_BATCH_SIZE`개까지 모아 한 번에 추론하며,
대기열은 `AI_PRIVACY_MAX_PENDING_FRAMES`로 제한된다.
`privacy_blur`는 모든 WebRTC 스트림이 하나의 GPU 런타임과 YOLO11s face pose,
SFace 모델 세트를 공유한다. 각 스트림은 독립적인 얼굴 추적 상태와 `client_id`별
참조 얼굴 설정만 따로 가진다. 공유 프레임 워커가 짧은 시간 동안 들어온 프레임을
최대 `AI_PRIVACY_MAX_BATCH_SIZE`개까지 모아 한 번에 추론하며, 대기열은
`AI_PRIVACY_MAX_PENDING_FRAMES`로 제한된다.

처리 순서는 다음과 같다.

1. YOLO FP16 배치로 얼굴을 탐지한다.
2. YOLO가 함께 출력한 5개 랜드마크로 얼굴을 정렬하고 SFace 배치로 식별한다.
랜드마크가 유효하지 않은 얼굴만 YuNet을 폴백으로 사용한다.
3. 참조 얼굴(`AI_PRIVACY_ME_IMAGE_PATH` 또는 `/reference-face` API)을 제외한
얼굴에 확장 타원 마스크와 Gaussian blur를 GPU에서 적용한다.
4. 처리 결과를 원래 WebRTC 트랙으로 반환한다.
1. YOLO FP16 배치로 얼굴과 5개 랜드마크를 탐지한다.
2. 얼굴 추적 상태를 갱신하고, 식별 재확인이 필요한 트랙만 비동기 식별 큐에 넣는다.
랜드마크가 유효하지 않은 얼굴은 본인 제외 후보로 사용하지 않는다.
3. 현재까지 확정된 본인 트랙을 제외한 얼굴에 확장 타원 마스크와 Gaussian blur를
GPU에서 적용한다. 프레임 처리는 SFace 식별 결과를 기다리지 않는다.
4. 별도 식별 워커가 YOLO 랜드마크로 얼굴을 정렬하고 SFace 배치로 참조 얼굴과
비교한다. 결과는 이후 프레임의 해당 트랙 상태에 반영된다.
5. 처리 결과를 원래 WebRTC 트랙으로 반환한다.

`GET /health`에서 장치, ONNX provider, 큐 깊이, 배치 수, 평균 배치 크기와 평균
처리 지연을 확인할 수 있다. 얼굴 대조 수와 지연, YOLO 랜드마크 사용 수 및 YuNet
폴백 수도 별도 지표로 제공한다.
`GET /health`에서 장치, ONNX provider, 프레임 큐와 식별 큐 깊이, 배치 수, 평균
배치 크기와 평균 처리 지연을 확인할 수 있다. 얼굴 대조 수와 지연, YOLO 랜드마크
사용 수, 랜드마크 누락 수, 식별 큐 포화로 드롭된 요청 수도 별도 지표로 제공한다.
`identity_yunet_fallback_faces`는 이전 클라이언트와의 호환성을 위해 남아 있지만,
YuNet fallback은 더 이상 사용하지 않으므로 항상 `0`이다.

한 프레임에서 새로 감지된 모든 얼굴은 ROI Align과 SFace의 단일 CUDA 배치로
즉시 대조한다. 본인 트랙은 `AI_PRIVACY_IDENTITY_RECHECK_INTERVAL`, 비본인 트랙은 더 긴
`AI_PRIVACY_IDENTITY_NEGATIVE_RECHECK_INTERVAL` 주기로 재확인한다.
본인 트랙은 `AI_PRIVACY_IDENTITY_RECHECK_INTERVAL`, 비본인 트랙은 더 긴
`AI_PRIVACY_IDENTITY_NEGATIVE_RECHECK_INTERVAL` 주기로 재확인한다. 새 얼굴이 한 번에
많이 들어오면 현재 프레임은 기존 추적 상태로 바로 처리하고, 식별 워커가 처리한
결과가 다음 프레임들에 반영된다.

### Reference face API

`/reference-face` API는 `client_id`별로 블러 제외 기준 얼굴을 분리한다.
`client_id`는 쿼리, `multipart/form-data` 필드, 또는 `X-Client-ID` 헤더로 전달할 수
있다. 값이 없으면 기존 클라이언트 호환을 위해 `default` 클라이언트를 사용한다.

```text
POST /reference-face
GET /reference-face
DELETE /reference-face
DELETE /reference-face/{face_id}
```

`POST /reference-face`는 JPEG, PNG, WebP 이미지를 최대 20장까지 받는다. 기존 단일
파일 필드 `image`만 보내면 기존 동작처럼 해당 클라이언트의 참조 얼굴을 교체한다.
새 다중 파일 필드 `images`를 보내면 해당 클라이언트에 얼굴을 추가한다. 응답에는
`client_id`, `count`, `faces[].face_id`, `faces[].registered_at`이 포함된다.

`DELETE /reference-face`는 해당 클라이언트의 참조 얼굴 전체를 삭제하고,
`DELETE /reference-face/{face_id}`는 해당 클라이언트에서 지정한 한 장만 삭제한다.
참조 이미지는 `data/reference_faces/{client_id}/{face_id}{ext}` 형식으로 저장된다.
예전 단일 파일 `data/reference_face.jpg`는 `default` 클라이언트 조회 시에만
호환용으로 읽는다.

WebRTC 세션은 `POST /sessions` 요청의 `metadata.client_id`로 참조 얼굴을 선택한다.
브라우저 테스트 클라이언트는 이 값을 자동으로 넣는다. API 클라이언트가 세션을
직접 생성할 때 `metadata.client_id`를 생략하면 `default` 클라이언트의 참조 얼굴을
사용한다.

### NVIDIA RTX 3090 배포

호스트에 NVIDIA 드라이버와 NVIDIA Container Toolkit을 설치한 뒤 `.env.example`을
기준으로 `.env`를 구성한다. 기본 운영값은 `cuda:0`, FP16, 최대 배치 8개이며 참조
얼굴 파일은 실제 서비스 대상 이미지로 교체해야 한다.
기준으로 `.env`를 구성한다. 기본 운영값은 `cuda:0`, FP16, 최대 배치 8개다.
고정 참조 얼굴을 환경 변수로 넣고 싶으면 `AI_PRIVACY_ME_IMAGE_PATH`를 설정할 수
있지만, 일반적인 운영 흐름은 클라이언트가 `/reference-face` API로 자기
`client_id`의 참조 얼굴을 등록하는 방식이다.

```bash
docker build -t innolive-server .
Expand Down Expand Up @@ -88,8 +125,8 @@ https://innolive.duckdns.org/client/
Use **Start WebRTC** to create a session, open the camera, send a WebRTC offer
over `/signaling`, trickle local ICE candidates, and receive the server's
processed video track. The page also exposes health check, session create/list,
session polling, deletion, signaling error probing, connection timing, and media
state diagnostics.
session polling, deletion, signaling error probing, connection timing, media
state diagnostics, and client-scoped reference face registration/deletion.

## WebRTC signaling

Expand Down
183 changes: 149 additions & 34 deletions app/api/routes/reference_face.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
"""촬영 대상자 기준 얼굴 등록·조회·삭제 API."""

from typing import Annotated

from fastapi import APIRouter, UploadFile, status
from fastapi import File, Form, Header, Query
from starlette.concurrency import run_in_threadpool

from app.core.config import get_settings
from app.core.exceptions import BadRequestError
from app.schemas.reference_face import ReferenceFaceStatusResponse
from app.services.ai.identity import (
delete_reference_face,
ReferenceFaceInfo,
ReferenceFaceUpload,
delete_reference_face_by_id,
delete_reference_faces,
get_reference_face_info,
register_reference_face,
normalize_reference_client_id,
register_reference_faces,
)

router = APIRouter(tags=["reference-face"])

_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
_MAX_IMAGE_COUNT = 20
_CONTENT_TYPE_EXTENSIONS = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}


@router.post(
Expand All @@ -26,7 +39,11 @@
description="촬영 대상자의 얼굴 이미지를 업로드하여 블러 제외 대상으로 등록합니다.",
)
async def post_reference_face(
image: UploadFile,
image: Annotated[UploadFile | None, File()] = None,
images: Annotated[list[UploadFile] | None, File()] = None,
form_client_id: Annotated[str | None, Form(alias="client_id")] = None,
query_client_id: Annotated[str | None, Query(alias="client_id")] = None,
x_client_id: Annotated[str | None, Header(alias="X-Client-ID")] = None,
) -> ReferenceFaceStatusResponse:
settings = get_settings()
if not settings.ai_privacy_enabled:
Expand All @@ -35,45 +52,34 @@ async def post_reference_face(
details={"reason": "ai_disabled"},
)

if image.content_type not in _ALLOWED_CONTENT_TYPES:
raise BadRequestError(
"지원하지 않는 이미지 형식입니다. JPEG, PNG, WebP만 허용됩니다.",
details={"content_type": image.content_type},
)
client_id = _resolve_client_id(query_client_id, form_client_id, x_client_id)
uploads = _collect_uploads(image=image, images=images)
reference_uploads = []
for upload in uploads:
reference_uploads.append(await _read_reference_upload(upload))

image_data = await image.read()
if not image_data:
raise BadRequestError(
"빈 이미지 파일은 업로드할 수 없습니다.",
details={"reason": "empty_file"},
)
if len(image_data) > _MAX_IMAGE_SIZE:
raise BadRequestError(
"이미지 크기가 10MB를 초과합니다.",
details={"reason": "file_too_large", "max_bytes": _MAX_IMAGE_SIZE},
)
info = await run_in_threadpool(register_reference_face, image_data)

return ReferenceFaceStatusResponse(
registered=info.registered,
source=info.source,
registered_at=info.registered_at,
info = await run_in_threadpool(
register_reference_faces,
reference_uploads,
client_id=client_id,
replace_existing=image is not None and not images,
)

return _status_response(info)


@router.get(
"/reference-face",
response_model=ReferenceFaceStatusResponse,
summary="기준 얼굴 등록 상태 조회",
description="현재 기준 얼굴의 등록 상태를 조회합니다.",
)
async def get_reference_face() -> ReferenceFaceStatusResponse:
info = get_reference_face_info()
return ReferenceFaceStatusResponse(
registered=info.registered,
source=info.source,
registered_at=info.registered_at,
)
async def get_reference_face(
client_id: Annotated[str | None, Query(alias="client_id")] = None,
x_client_id: Annotated[str | None, Header(alias="X-Client-ID")] = None,
) -> ReferenceFaceStatusResponse:
info = get_reference_face_info(_resolve_client_id(client_id, x_client_id))
return _status_response(info)


@router.delete(
Expand All @@ -82,12 +88,121 @@ async def get_reference_face() -> ReferenceFaceStatusResponse:
summary="기준 얼굴 등록 해제",
description="기준 얼굴 등록을 해제합니다. 모든 얼굴이 블러 처리됩니다.",
)
async def remove_reference_face() -> None:
async def remove_reference_face(
client_id: Annotated[str | None, Query(alias="client_id")] = None,
x_client_id: Annotated[str | None, Header(alias="X-Client-ID")] = None,
) -> None:
settings = get_settings()
if not settings.ai_privacy_enabled:
raise BadRequestError(
"AI 프라이버시 기능이 비활성화 상태입니다.",
details={"reason": "ai_disabled"},
)

await run_in_threadpool(
delete_reference_faces,
client_id=_resolve_client_id(client_id, x_client_id),
)


@router.delete(
"/reference-face/{face_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="개별 기준 얼굴 등록 해제",
description="face_id로 지정한 기준 얼굴 한 장만 블러 제외 대상에서 제거합니다.",
)
async def remove_reference_face_by_id(
face_id: str,
client_id: Annotated[str | None, Query(alias="client_id")] = None,
x_client_id: Annotated[str | None, Header(alias="X-Client-ID")] = None,
) -> None:
settings = get_settings()
if not settings.ai_privacy_enabled:
raise BadRequestError(
"AI 프라이버시 기능이 비활성화 상태입니다.",
details={"reason": "ai_disabled"},
)

await run_in_threadpool(delete_reference_face)
await run_in_threadpool(
delete_reference_face_by_id,
face_id,
client_id=_resolve_client_id(client_id, x_client_id),
)


def _resolve_client_id(*values: str | None) -> str:
for value in values:
if value is not None and value.strip():
return normalize_reference_client_id(value)
return normalize_reference_client_id(None)


def _collect_uploads(
*,
image: UploadFile | None,
images: list[UploadFile] | None,
) -> list[UploadFile]:
uploads = []
if image is not None:
uploads.append(image)
if images:
uploads.extend(images)
if not uploads:
raise BadRequestError(
"업로드할 이미지가 없습니다.",
details={"reason": "empty_upload"},
)
if len(uploads) > _MAX_IMAGE_COUNT:
raise BadRequestError(
"한 번에 등록할 수 있는 이미지는 최대 20장입니다.",
details={"reason": "too_many_files", "max_files": _MAX_IMAGE_COUNT},
)
return uploads


async def _read_reference_upload(upload: UploadFile) -> ReferenceFaceUpload:
if upload.content_type not in _ALLOWED_CONTENT_TYPES:
raise BadRequestError(
"지원하지 않는 이미지 형식입니다. JPEG, PNG, WebP만 허용됩니다.",
details={
"content_type": upload.content_type,
"filename": upload.filename,
},
)

image_data = await upload.read()
if not image_data:
raise BadRequestError(
"빈 이미지 파일은 업로드할 수 없습니다.",
details={"reason": "empty_file", "filename": upload.filename},
)
if len(image_data) > _MAX_IMAGE_SIZE:
raise BadRequestError(
"이미지 크기가 10MB를 초과합니다.",
details={
"reason": "file_too_large",
"max_bytes": _MAX_IMAGE_SIZE,
"filename": upload.filename,
},
)
return ReferenceFaceUpload(
image_data=image_data,
extension=_CONTENT_TYPE_EXTENSIONS[upload.content_type],
)


def _status_response(info: ReferenceFaceInfo) -> ReferenceFaceStatusResponse:
return ReferenceFaceStatusResponse(
registered=info.registered,
source=info.source,
registered_at=info.registered_at,
client_id=info.client_id,
count=info.count,
faces=[
{
"face_id": face.face_id,
"registered_at": face.registered_at,
}
for face in info.faces
],
)
5 changes: 1 addition & 4 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ class Settings(BaseSettings):
ai_privacy_identity_recheck_interval: int = 12
ai_privacy_identity_negative_recheck_interval: int = 90
ai_privacy_me_image_path: str | None = None
ai_privacy_face_model_path: str = "models/yolov12n-face.pt"
ai_privacy_face_detection_model_path: str = (
"models/face_detection_yunet_2023mar.onnx"
)
ai_privacy_face_model_path: str = "models/yolo11s-pose_widerface.pt"
ai_privacy_face_recognition_model_path: str = (
"models/face_recognition_sface_2021dec.onnx"
)
Expand Down
10 changes: 9 additions & 1 deletion app/schemas/reference_face.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
from datetime import datetime
from typing import Literal

from pydantic import BaseModel
from pydantic import BaseModel, Field


class ReferenceFaceImageResponse(BaseModel):
face_id: str
registered_at: datetime


class ReferenceFaceStatusResponse(BaseModel):
registered: bool
source: Literal["api", "env"] | None = None
registered_at: datetime | None = None
client_id: str = "default"
count: int = 0
faces: list[ReferenceFaceImageResponse] = Field(default_factory=list)
Loading
Loading