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
Bringsonnxruntime, 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.
The models are not in the wheel¶
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.