Request flow
main.cpp
Process entry point (src/main.cpp). Responsibilities, in order:
- Read all configuration from environment variables.
-
Call
InferenceEngine::instance().initialize(cfg)then.warmup(), synchronously, before accepting any traffic. Both are wrapped in a try/catch that logs[main] FATAL: ...and callsreturn 1on failure. This fail-fast startup is deliberate: a container that starts “successfully” but can’t actually load its models is worse than one that crash-loops, because the crash-loop is at least visible to your orchestrator’s health checks. A silently-broken container that returns503forever (or worse, serves garbage) is harder to notice. -
Construct the global
ThreadPool(g_workerPool), sized byFACE_ENGINE_WORKERS. -
Start Drogon: register the listener, set the HTTP I/O thread count, cap
request body size to 12 MB (matches
ImageUtils::kMaxUploadByteswith headroom for multipart boundaries), and run the event loop.
extern ThreadPool &getWorkerPool()
declared in each controller .cpp and defined once in main.cpp — a
deliberately minimal dependency-injection approach for a single-binary
service with one pool.
InferenceEngine
The core inference singleton (src/InferenceEngine.h, src/InferenceEngine.cpp).
Owns both model sessions, but the two are shared very differently across
worker threads:
- The embedder (
Ort::Session) is one instance shared across every worker thread.Ort::Session::Run()is genuinely documented thread-safe for concurrent calls — no pooling needed. - The detector (
cv::FaceDetectorYN) is not shared — each worker thread gets its own instance via athread_localinthreadLocalDetector(). This was a real bug, not a design choice made up front:detectFaces()callssetInputSize()(which mutates state on the object) immediately beforedetect(), so a single shared detector instance lets concurrent requests race on that state. Caught with a concurrent load test — see Models → Concurrency for the full story, including the throughput numbers before/after the fix.
initialize(config)
initialize(config)
Loads the detector via
cv::FaceDetectorYN::create(...) and the
embedder via a raw Ort::Session. Input/output tensor names and shape
are read dynamically from the embedder’s ONNX graph — not
hardcoded — since those were treated as unverified until checked (see
Models). Dynamic input dims (-1) are normalized to
{1, 3, 112, 112}. Throws std::runtime_error on any failure.warmup()
warmup()
Runs one throwaway detect+embed pass on a blank frame so the first
real request doesn’t pay for lazy kernel init / memory allocation.
Called once, right after
initialize(), before the server starts
accepting connections.detectFaces(bgrImage)
detectFaces(bgrImage)
Runs YuNet and returns all detected faces sorted by
score * area
descending, so callers can just take .front() for “the most
prominent face.”Detects on a resolution-capped copy, not the original. YuNet’s
accuracy degrades badly on raw phone-camera resolutions (2300px+
commonly produced zero or badly-mislocated detections in testing) — so
if the longest side exceeds 640px, detection runs on a downscaled copy
and the resulting bbox/landmarks are scaled back up to full-resolution
coordinates before being returned. This was a real bug found during
validation, not a design assumption — see Models for how it
was diagnosed.embedAlignedFace(alignedBgr112)
embedAlignedFace(alignedBgr112)
Takes an already-aligned 112×112 BGR crop, converts to RGB, normalizes
to
(x - 127.5) / 127.5 (standard ArcFace preprocessing), reshapes to
NCHW, runs the embedder, and L2-normalizes the output — so callers
can always use a plain dot product for cosine similarity, and separate
calls stay directly comparable even if the model doesn’t normalize
internally.detectAndEmbed(bgrImage)
detectAndEmbed(bgrImage)
Convenience wrapper: detect → take the best face → align → embed.
Returns
std::nullopt if no face was found. This is what both
controllers actually call.cosineSimilarity(a, b)
cosineSimilarity(a, b)
Static helper. Since embeddings are already L2-normalized, this is
effectively just a dot product (with a defensive norm check /
zero-guard for degenerate inputs).
FaceAligner
src/FaceAligner.h, src/FaceAligner.cpp. Aligns a detected face to a canonical 112×112 crop using a 5-point
similarity transform (rotation + uniform scale + translation — chosen over
a full affine/perspective warp specifically because it preserves face
geometry instead of distorting it).
The reference template is the standard InsightFace/ArcFace 112×112 5-point
layout:
cv::estimateAffinePartial2D computes the transform from the detector’s
raw landmarks to this template. If the landmark configuration is
degenerate (e.g. collinear points — a pathological detector output), it
falls back to a plain bounding-box crop + resize rather than failing the
whole request.
OpenCV 5 moved
estimateAffinePartial2D/boundingRect into a new
geometry module that its own umbrella opencv2/opencv.hpp doesn’t pull
in — FaceAligner.h has a version-gated #if CV_VERSION_MAJOR >= 5
include to handle both OpenCV 4 (Docker’s from-source build) and OpenCV
5 (e.g. Homebrew on macOS) without duplicating logic. See
Deployment for the full story on why Docker builds its
own OpenCV.ImageUtils
src/ImageUtils.h, src/ImageUtils.cpp. Single-purpose: turn raw uploaded bytes into a validated cv::Mat, or
reject with a specific reason. All checks happen before any inference
work, so malformed/abusive uploads fail cheaply.
Decoding uses
cv::imdecode, which inspects the actual byte stream (magic
bytes / codec headers) rather than trusting any client-supplied
Content-Type — so a mislabeled or spoofed file extension can’t sneak a
different format through.
ThreadPool
src/ThreadPool.h. A minimal fixed-size thread pool, header-only. Exists for one reason:
Drogon’s event-loop threads must stay free to service I/O, so all CPU-bound
work (decode, align, inference) is pushed here instead.
enqueue(task)is fire-and-forget — the task itself is responsible for reporting its result (typically by capturing and calling a Drogon responsecallbackfrom inside the lambda, which is safe to call from any thread).- Worker threads catch
...around each task. A job that throws without catching internally would otherwise silently kill a worker thread and never respond to the waiting client — the pool’s own catch-all is a last-resort safety net, not a substitute for jobs handling their own errors. pendingJobs()exposes queue depth if you want to wire up a metric later (see Deployment → not done yet).
Controllers
src/controllers/. Thin HTTP-layer adapters. Each one: parses the multipart body, validates
required fields are present, copies out the file bytes (request objects
don’t outlive the handler call), and enqueues the actual work onto the
shared worker pool. See the API Reference
for full request/response contracts.
Concurrency model
- Drogon’s event loop threads (
HTTP_IO_THREADS) only do connection I/O — accept connections, read/write bytes, parse HTTP framing. They never touch OpenCV or ONNX Runtime. - All decode/align/inference work runs on the separate
ThreadPool(FACE_ENGINE_WORKERS, defaults to core count). Controllers enqueue a job and return immediately; the job calls the response callback itself when done, from the worker thread — Drogon supports this safely. - Detector and embedder are shared singleton sessions across all
worker threads. Both
cv::FaceDetectorYN::detect()andOrt::Session::Run()support concurrent calls, so there’s no per-thread session pooling to reason about.
FACE_ENGINE_WORKERS (bounded by CPU
cores for CPU inference), while HTTP_IO_THREADS can stay small — it’s
rarely the bottleneck for this workload.