신분증 사진을 위한 브라우저 기반 얼굴 감지 + 멀티 프로바이더 AI 작업 오케스트레이션
요약
브라우저 환경에서 신분증 사진 규격에 맞게 얼굴을 감지하고 자동 크롭하는 기술적 패턴을 다룹니다. MediaPipe와 브라우저 API를 활용한 3계층 감지 체인과 멀티 프로바이더 AI 작업 오케스트레이션 시스템 구축 방법을 설명합니다.
핵심 포인트
- MediaPipe, FaceDetector, BlazeFace를 활용한 3계층 얼굴 감지 체인 구현
- 머리카락 부피를 고려한 1.35x 승수 기반의 머리 높이 추정 공식 적용
- 클라이언트 측 실행을 통한 서버 비용 절감 및 즉각적인 사용자 미리보기 제공
- 다양한 백엔드로 AI 워크로드를 라우팅하는 프로바이더 불가지론적 오케스트레이션
브라우저는 컴퓨터 비전 (Computer Vision)을 위한 놀라울 정도로 유능한 플랫폼이 되었습니다. 이 포스트에서는 두 가지 프로덕션 패턴을 살펴보겠습니다. 하나는 다계층 얼굴 감지 (Face Detection)를 갖춘 브라우저 기반 신분증 사진 제작기이며, 다른 하나는 10개 이상의 백엔드에 AI 워크로드를 라우팅하는 프로바이더 불가지론적 (Provider-agnostic) 작업 오케스트레이션 (Task Orchestration) 시스템입니다.
파트 1: 브라우저 기반 신분증 사진 제작기 구축
얼굴 감지 문제
신분증 사진은 머리 높이 비율, 눈 높이 위치, 여백 규칙 등 엄격한 요구 사항이 있습니다. HivisionIDPhotos, dpar39/ppp와 같은 도구와 dreamega.ai, photoaid.com과 같은 상용 서비스들은 모두 동일한 핵심 문제를 해결해야 합니다. 즉, 업로드된 초상화가 주어졌을 때 사양에 맞춰 자동 크롭(Auto-crop)할 수 있을 만큼 얼굴을 정확하게 감지하는 것입니다.
서버 측 도구는 무거운 모델을 사용할 여유가 있습니다 (HivisionIDPhotos는 전용 얼굴 파싱 네트워크와 함께 ONNX 런타임을 사용합니다). 하지만 업로드도 없고, 서버 비용도 없으며, 즉각적인 미리보기가 가능한 **클라이언트 측 (Client-side)**에서 모든 것을 실행하고 싶다면 브라우저 환경은 예측 불가능합니다. 모든 사용자가 Chrome의 실험적인 FaceDetector API를 가지고 있는 것은 아니며, WebGL 지원도 제각각입니다.
우리의 솔루션은 최선의 옵션을 먼저 시도하고 우아하게 폴백(Fallback)하는 **3계층 감지 체인 (Three-tier detection chain)**입니다.
// 감지 체인: MediaPipe (478 landmarks) → Browser FaceDetector → BlazeFace
export async function detectFace(image: HTMLImageElement): Promise<FaceDetectResult> {
if (typeof window !== "undefined" && !window.isSecureContext) {
...
각 계층은 경계 좌표와 선택적 랜드마크(눈, 이마, 턱)가 포함된 FaceBox를 반환합니다. 핵심 통찰은 각 감지기가 서로 다른 랜드마크 풍부도 (Landmark richness)를 제공하므로, 기하학적 추정 (Geometry estimation)이 그에 맞춰 적응한다는 점입니다.
머리 높이 추정: 세 가지 공식
정확한 머리 측정은 매우 중요합니다. 단 몇 픽셀만 어긋나도 사진은 규정(Compliance)을 통과하지 못합니다. 여기서 사용된 방식은 dpar39/ppp의 CrownChinEstimator에서 아이디어를 빌려왔으며, MediaPipe를 우선적으로 사용하는 경로로 확장되었습니다.
export function estimateHeadHeight(face: FaceBox): number {
// 최적: MediaPipe 이마-턱 거리 + 헤어 오프셋 (1.35x 승수)
// HivisionIDPhotos는 알파 채널 윤곽선(alpha-channel contour)을 사용하여 이 문제를 피합니다.
...
1.35x 승수는 이마 위의 머리카락 부피를 고려한 것입니다. MediaPipe 랜드마크(Landmarks)는 머리카락을 감지하지 못하므로, 머리가 잘리는 것을 방지하기 위해 의도적으로 과다 추정(Overestimate)하도록 설정했습니다. IPD(안간거리) 공식은 인체 측정학 연구(Farkas LG, "Anthropometry of the Head and Face")에서 가져왔습니다.
세그멘테이션으로 정교화된 머리 상단 (Segmentation-Refined Head Top)
랜드마크는 머리 상단이 있어야 할 위치를 추정하지만, 세그멘테이션(Segmentation)은 픽셀 단위로 정확한 결과를 제공합니다. 이 지점이 순수하게 얼굴 감지(Face detection)에만 의존하는 passport-photo-online 및 유사 서비스와 우리의 방식이 갈라지는 부분입니다. 두 방식을 결합하면 머리카락 부피가 큰 사람들에게 눈에 띄게 더 나은 결과를 제공합니다.
export function refineWithSegmentation(
geom: HeadGeometry,
segBounds: { topY: number; bottomY: number } | null
...
주의해야 할 중요한 점은, 세그멘테이션의 bottomY는 턱이 아니라 신체(Body) 하단(어깨, 몸통)이라는 것입니다. 이를 그대로 사용하면 머리 측정값이 과도하게 늘어납니다. 우리는 세그멘테이션에서는 topY(머리카락 경계)만 가져오고, 턱 위치는 얼굴 랜드마크의 값을 유지합니다.
자동 맞춤 알고리즘 (The Auto-Fit Algorithm)
정확한 머리 기하학(Head geometry)이 확보되면, 자동 크롭(Auto-cropping)은 세 단계의 과정이 됩니다.
export function computeAutoFitCropState({ imageWidth, imageHeight, face, spec, canvasWidth, canvasHeight, segBounds }) {
const rules = getNormalizedHeadRules(spec);
const head = refineWithSegmentation(estimateHeadGeometry(face), segBounds);
...
가드 패스(Guard pass)는 예외 상황을 처리합니다: 정수리(Crown)가 상단에 너무 가깝거나, 턱이 프레임 아래에 있거나, 혹은 둘 다 넘치는 경우(이 경우 프레임에 맞게 크기를 축소합니다).
색상 오염 제거를 통한 배경 제거 (Background Removal with Color Decontamination)
배경을 교체할 때, 가장자리 픽셀에 원래 배경의 색상이 번지는 현상이 발생합니다. 우리는 remove.bg의 오픈 소스 대안 모델인 ISNet 엔진을 사용하는 @imgly/background-removal을 사용한 후, 색상 오염 제거 (Color Decontamination)를 적용합니다:
// 반투명한 가장자리 픽셀의 오염을 제거합니다
for (let i = 0; i < rPx.length; i += 4) {
let a = rPx[i + 3] / 255;
...
이 수학적 계산은 알파 합성 (Alpha Compositing) 공식을 역산합니다. 합성된 색상이 C = α·F + (1-α)·B라면, 실제 전경(Foreground)은 F = (C - (1-α)·B) / α가 됩니다. 알파 스퀴즈 (Alpha squeeze, [0.15, 1] → [0, 1])는 머리카락 주변의 부드러운 헤일로(Halo) 현상을 제거하는 침식 필터 (Erosion filter) 역할을 합니다.
파트 2: 멀티 프로바이더 AI를 위한 작업 시스템 아키텍처 (Task System Architecture for Multi-Provider AI)
Fal, WaveSpeed, Volcengine, ChatFire, Google, OpenAI 등 여러 서비스로 요청을 라우팅할 때는 다음과 같은 시스템이 필요합니다:
- 단일 사용자 요청을 프로바이더별 하위 작업 (Subtasks)으로 분할
- 비동기 (Webhook) 및 동기 (Sync) 실행 모드 처리
- 실행 전 크레딧 (Credits) 계산 및 실패 시 환불 처리
- 오류 발생 시 대체 프로바이더로 폴백 (Fallback)
Task → SubTask 모델
모든 사용자 요청은 하나 이상의 **하위 작업 (SubTasks)**을 가진 하나의 **작업 (Task)**이 됩니다. 각 하위 작업은 특정 프로바이더를 대상으로 합니다:
export async function createOrUpdateTask(params: CreateOrUpdateTaskParams): Promise<TaskExecutionData> {
const credits = preCalculatedCredits ??
(await calculateTaskCredits({ taskType, metadata, request, systemRequest })).totalCredits;
...
**드래프트 패턴 (Draft pattern)**이 주목할 만합니다. 작업은 (미리보기/확인을 위해) 드래프트 상태로 생성될 수 있으며, 이후 중복 제출을 방지하는 상태 가드 (Status guard)를 통해 원자적(Atomically)으로 PENDING 상태로 승격됩니다.
레지스트리 패턴을 이용한 프로바이더 라우팅 (Provider Routing with Registry Pattern)
하위 작업 생성을 위해 거대한 if-else 체인을 사용하는 대신, 프로바이더 레지스트리 (Provider registry)를 사용합니다:
const subTaskGeneratorRegistry: Partial<Record<Provider, SubTaskGenerator>> = {
[Provider.fal]: generateFalSubTasks,
[Provider.kie_ai]: generateKieAiSubTasks,
...
각 생성기(generator)는 통합된 요청 형식을 프로바이더별(provider-specific) API 호출로 변환하는 방법을 알고 있습니다. 새로운 프로바이더를 추가한다는 것은 하나의 모듈을 작성하고 이를 등록하는 것을 의미합니다. 이는 Replicate가 모델 라우팅(model routing)을 위해 사용하는 것과 동일한 패턴이며, 이를 단일 코드베이스로 축소하여 구현한 것입니다.
실행: 비동기(Async) vs 동기(Sync) vs 스트리밍(Streaming)
러너(runner)는 세 가지 모드를 지원합니다. 비동기(Async) 모드는 서브태스크(subtasks)를 실행하고 웹훅(webhooks)을 기다립니다:
export async function runTaskByProvider(task: TaskExecutionData): Promise<void> {
for (const subTask of task.subTasks) {
const provider = await getTaskProvider({ metadata, taskType, request, systemRequest });
...
**폴백 메커니즘 (fallback mechanism)**이 핵심입니다. 프로바이더가 실패할 때, 시스템은 투명하게 대체 프로바이더를 대상으로 하는 새로운 서브태스크를 생성할 수 있으므로 사용자는 에러를 전혀 보지 못하게 됩니다.
스트리밍(Streaming) 모드는 텍스트 생성 태스크에 SSE를 사용합니다:
const streamResult = await streamTextFromTemplate({ templateSlug, input, stream: true });
const { readable, writable } = new TransformStream();
...
크레딧 시스템: 예약(Reserve) → 확정/취소(Confirm/Cancel)
크레딧은 2단계 커밋(two-phase commit) 패턴을 따릅니다:
- 예약 (Reserve): 태스크가 생성될 때 크레딧을 예약합니다.
- 확정 (Confirm): 성공 시 예약을 확정합니다 (크레딧이 영구적으로 차감됨).
- 취소 (Cancel): 실패 시 예약을 취소합니다 (크레딧이 반환됨).
export function calculateRefundCredits(task: TaskWithDetailItem): number {
const refundCredits = task.subTasks.reduce((total, subTask) => {
if ([TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.ABORTED].includes(subTask.status)) {
...
Math.min 상한선은 안전장치 역할을 합니다. 폴백(fallback) 서브태스크가 생성되었지만 원래 서브태스크의 크레딧이 아직 0으로 처리되지 않았을 때, 단순 합산(naive summing)을 하면 과다 환불이 발생할 수 있기 때문입니다.
핵심 요약 (Key Takeaways)
-
점진적 향상 (Progressive enhancement)은 ML에서도 유효합니다: 3단계 얼굴 감지 (Face detection) 체인을 통해, 수동적인 기능 플래그 (Feature-flag) 관리 없이도 모든 사용자가 자신의 브라우저가 지원하는 최상의 경험을 누릴 수 있습니다.
-
감지 (Detection)와 추정 (Estimation)을 분리하십시오: 얼굴 감지 (Face detection)는 좌표를 제공하며, 머리 기하학 추정 (Head geometry estimation)은 이를 해석합니다. 이 둘을 분리해 두면 레이아웃 로직을 건드리지 않고도 더 나은 모델로 교체할 수 있습니다.
-
레지스트리 패턴 (Registry pattern)은 확장성이 뛰어납니다: 10개 이상의 AI 프로바이더 (Provider)를 통합할 때는
switch문보다 타입화된 생성기 (Typed generators)의 레지스트리를 사용하는 것이 훨씬 유리합니다. 각 프로바이더는 격리되어 있으며 테스트가 가능합니다. -
2단계 크레딧 커밋 (Two-phase credit commits)은 문제를 방지합니다: 폴백 (Fallback) 기능이 있는 비동기 시스템에서 단순한 '시작 시 차감' 모델을 사용하면, 과다 청구(작업 실패 시)되거나 과소 청구(경쟁 상태 발생 시)될 수 있습니다. 예약/확인 (Reserve/confirm) 방식은 추가적인 복잡성을 감수할 만큼의 가치가 있습니다.
-
세그멘테이션 (Segmentation)은 랜드마크 (Landmarks)를 대체하는 것이 아니라 보완합니다: 세그멘테이션은 그에 적합한 용도(머리카락 경계, 배경 분리)로 사용하고, 랜드마크는 그에 적합한 용도(얼굴 특징점 위치)로 사용하십시오. 두 방식의 강점을 결합하면 어느 하나만 사용할 때보다 더 나은 결과를 얻을 수 있습니다.
유사한 브라우저 기반 컴퓨터 비전 (CV) 도구나 멀티 프로바이더 오케스트레이션 (Multi-provider orchestration) 시스템을 구축하고 계신 분들께 도움이 되기를 바랍니다. 댓글로 질문해 주시면 기꺼이 답변해 드리겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기