Skip to content

Face recognition

Finds faces in an image and turns each one into a comparable vector — enough to count people in a photo, locate the face to crop, or tell whether two photos show the same person.

This is biometric data. Read before storing it.

A face vector identifies a person the way a fingerprint template does. Under Brazil's LGPD it is sensitive personal data (Art. 5, II), and processing it needs consent that is specific and highlighted for that purpose (Art. 11, I) — general terms of service do not cover it.

This module produces the vectors. Storing them is a decision with obligations attached: recorded consent, deletion on request, and never keeping the original image longer than needed. The persistent enrolment layer ships separately, precisely so that part gets reviewed on its own.

Required extra

uv add "tempest-fastapi-sdk[faces]"
Brings onnxruntime, pillow and numpy. No system libraries — unlike [pdf], a slim image is enough here.

Your first recognition

# scripts/faces.py

import asyncio

from tempest_fastapi_sdk.faces import FaceRecognizer, compare_faces


async def main() -> None:
    """Count faces in a photo and compare the first two."""
    recognizer = FaceRecognizer()
    faces = await recognizer.recognize("group.jpg")
    print(f"{len(faces)} faces")
    for face in faces:
        print(f"  {face.box.width:.0f}x{face.box.height:.0f}px  conf={face.confidence:.2f}")
    if len(faces) >= 2:
        print("same person?", compare_faces(faces[0].embedding, faces[1].embedding))


if __name__ == "__main__":
    asyncio.run(main())

Faces come back largest first — the subject of a photo is usually the biggest face in it, so a caller taking faces[0] gets what they meant.

Detecting without touching biometrics

import asyncio

from tempest_fastapi_sdk.faces import FaceRecognizer


async def has_one_face(path: str) -> bool:
    """Validate that an upload contains exactly one face."""
    faces = await FaceRecognizer().detect(path)
    return len(faces) == 1

detect() returns the box, the score and the landmarks — and no vector. For "is there a face here", "how many people" or "where do I crop the thumbnail", it is cheaper and touches no biometric data.

Comparing two photos

import asyncio

from tempest_fastapi_sdk.faces import FaceRecognizer, compare_faces


async def same_person(first: str, second: str) -> bool:
    """Whether two photos show the same person."""
    recognizer = FaceRecognizer()
    left = await recognizer.embed_face(first)
    right = await recognizer.embed_face(second)
    return compare_faces(left, right) >= recognizer.threshold

embed_face() takes the largest face and refuses when there is none, or when it is too small — because at enrolment time a bad vector is not a bad answer, it is a permanently wrong profile.

The measured margin

Run it yourself: uv run python scripts/face_separation.py. The script generates six portraits with sdxl-turbo at fixed seeds (1000 to 1005) and measures both ends — the same face under five transformations, and the 15 pairs of different faces. The faces are generated rather than photographed for two reasons: it ships nobody's real biometrics, and it puts the measurement's input in the repository instead of on somebody's disk.

With the default pack (buffalo_s), n=30 positive comparisons and n=15 negative:

comparison similarity
same person (re-encoded jpeg q40) 0.974-0.990
same person (rescaled 50%) 0.968-0.991
same person (rotated 8°) 0.948-0.984
same person (mirrored) 0.938-0.971
same person (tight 112×112 crop) 0.898-0.974
different people (15 pairs) max 0.285

The default threshold is 0.45, with a 0.614 gap between the worst positive and the best negative. It is not a delicate choice — the opposite of the speaker-diarization case, and worth knowing when carrying intuitions between the two.

This is the easy end of the range

A generated portrait is frontal, evenly lit and unoccluded. A face that is small, turned or badly lit scores lower, and that is exactly where the threshold stops being a free choice — which is what buffalo_l is for.

Raise the threshold to grant access

The measurement is on cooperative, frontal photos. Where recognition unlocks something, the expensive error stops being "did not recognise" and becomes "recognised the wrong person" — and then a stricter threshold trades that for asking the person to try again.

Choosing a model pack

pack size detection same person diff max
buffalo_s (default) 16 MB 15 ms 0.904–0.960 0.225
buffalo_l 191 MB 54 ms 0.920–0.971 0.208

Twelve times smaller and 3.6× faster, for a 0.02 shift in either bound. The large pack earns its size when faces are small, poorly lit or turned away — the cases where the margin matters.

from tempest_fastapi_sdk.faces import FaceRecognizer

recognizer = FaceRecognizer(pack="buffalo_l")

The models are not in the wheel

from tempest_fastapi_sdk.faces import ensure_models

ensure_models()  # honors TEMPEST_FACE_MODEL_DIR

Leaving it to the first request makes one user pay the download inside their timeout.

Why not insightface

It packages exactly this pipeline, and the cost was measured: 558 MB across 24 packages, and the opencv-python it requires links against five GL libraries — so a slim image would need system graphics libraries in order to recognise a face.

Running the same ONNX models directly costs onnxruntime + numpy + pillow, which the SDK already carries for other features, and zero system libraries. The price is the detection decoding and the alignment transform — closed-form geometry rather than a long tail of corrections.

Rejected before that: facenet-pytorch (pins torch<2.3.0, which would cap every consumer), deepface (brings TensorFlow) and face-recognition (brings dlib, which has to compile).

Details that bite

A small face is detected but not embedded. Below 40 px on a side the aligned crop is mostly interpolation, and the vector would describe the upscaling rather than the person. The face comes back with an empty embedding, so a caller can tell "nobody recognisable" from "no face".

A tight crop needs a margin. A 112×112 photo whose face touches the edges returned zero detections; with a 20% border, one. The detector needs context around a face and an already-tight crop has none to give — so the module adds the margin itself.

Alignment is not a refinement. The recognition models were trained with eyes, nose and mouth corners at fixed positions. An unaligned crop does not fail: it loses accuracy silently.

Unloading does not pull the models from under a call. With idle_unload_seconds, unload_if_idle() frees the models only when nothing uses them: during a recognize() seconds_idle reads 0.0 and the answer is False. An unload() mid-call waits for it to finish, and several simultaneous first calls load the models once.

Recap

  • FaceRecognizer.recognize() detects and embeds; detect() only detects and touches no biometrics; embed_face() is the enrolment shape and refuses bad input.
  • Measured margin (scripts/face_separation.py, default pack): 0.898-0.991 same person (n=30) against max 0.285 for different ones (n=15). Default threshold 0.45; raise it to grant access.
  • The 16 MB default pack is a measurement, not an accident.
  • No system libraries, no opencv, no torch.
  • A face vector is sensitive biometric data — storing it has obligations.

Next: PDF generation if recognition feeds a document, or Self-hosted generative AI for speaker diarization, which follows the same design.