Request flow

Every piece of that pipeline below the controller is what the rest of this page documents.

main.cpp

Process entry point (src/main.cpp). Responsibilities, in order:
  1. Read all configuration from environment variables.
  2. Call InferenceEngine::instance().initialize(cfg) then .warmup(), synchronously, before accepting any traffic. Both are wrapped in a try/catch that logs [main] FATAL: ... and calls return 1 on 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 returns 503 forever (or worse, serves garbage) is harder to notice.
  3. Construct the global ThreadPool (g_workerPool), sized by FACE_ENGINE_WORKERS.
  4. Start Drogon: register the listener, set the HTTP I/O thread count, cap request body size to 12 MB (matches ImageUtils::kMaxUploadBytes with headroom for multipart boundaries), and run the event loop.
Controllers reach the worker pool via an 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 a thread_local in threadLocalDetector(). This was a real bug, not a design choice made up front: detectFaces() calls setInputSize() (which mutates state on the object) immediately before detect(), 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.
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.
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.
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.
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.
Convenience wrapper: detect → take the best face → align → embed. Returns std::nullopt if no face was found. This is what both controllers actually call.
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 response callback from 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() and Ort::Session::Run() support concurrent calls, so there’s no per-thread session pooling to reason about.
This means throughput scales with 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.