"""
Face-detection backends for the anonymization plugin. All three expose the
same interface — ``detect(frame_bgr) -> list[(x1, y1, x2, y2)]`` in pixel
coordinates — so run_face_mask.py doesn't need to know which one is in use.
Each backend already filters by its own confidence threshold internally
before returning, so the box tuples don't carry a confidence score.
Backends (selectable in the UI, "mediapipe" is the default):
mediapipe — MediaPipe Tasks FaceLandmarker, same model family already used
by python/pose/gaze_estimator.py for real-time gaze (but that
module lives in a different, independent uv project — python/
vs. analysis/ — so this backend owns its own model download
rather than importing across the project boundary). Best
recall; multi-face.
yolov8 — Ultralytics YOLO with a face-detection checkpoint. There is no
Ultralytics-official face weights file (unlike yolov8n-pose.pt),
so this downloads a named community checkpoint on first use.
Pass --model /path/to/local.pt to supply your own instead.
opencv — cv2.FaceDetectorYN (YuNet), OpenCV's own currently-maintained
ONNX face detector (official opencv/opencv_zoo model). No new
ML framework dependency.
Each backend caches its downloaded model file(s) under models/ next to this
file (gitignored, same treatment as auto-downloaded yolov8n-pose.pt).
"""
from __future__ import annotations
import hashlib
import urllib.request
from pathlib import Path
from typing import Protocol
import cv2
import numpy as np
Box = tuple[float, float, float, float] # x1, y1, x2, y2
_MODELS_DIR = Path(__file__).parent / "models"
[docs]
class FaceDetector(Protocol):
"""Structural interface every face-detection backend satisfies.
All three backends (:class:`MediaPipeFaceDetector`,
:class:`YoloFaceDetector`, :class:`OpenCVDnnFaceDetector`) implement
this without inheriting from it — a plain ``duck-typing`` Protocol so
``run_face_mask.py`` doesn't need to know which one is in use.
"""
[docs]
def detect(self, frame_bgr: np.ndarray) -> list[Box]:
"""Detect faces in one BGR frame.
Parameters
----------
frame_bgr : numpy.ndarray
BGR frame, as returned by ``cv2.imread``/``cv2.VideoCapture``.
Returns
-------
list of Box
One ``(x1, y1, x2, y2)`` pixel box per detected face, already
filtered by this backend's own confidence threshold (box
tuples carry no separate confidence score).
"""
...
# ── MediaPipe FaceLandmarker ─────────────────────────────────────────────────
_MEDIAPIPE_MODEL_URL = (
"https://storage.googleapis.com/mediapipe-models/"
"face_landmarker/face_landmarker/float16/1/face_landmarker.task"
)
# ── YOLOv8-face (ultralytics) ────────────────────────────────────────────────
# Community-maintained checkpoints (not hosted/affiliated with Ultralytics) —
# see https://github.com/akanametov/yolo-face. Only used when --model isn't
# given a local path explicitly. Release tag confirmed current 2026-07-28 —
# the upstream repo previously tagged these "v0.0.0" (now gone entirely,
# returns a 404) and re-released the same filenames under "1.0.0".
_YOLO_FACE_URLS = {
"yolov8n-face.pt": "https://github.com/akanametov/yolo-face/releases/download/1.0.0/yolov8n-face.pt",
"yolov8m-face.pt": "https://github.com/akanametov/yolo-face/releases/download/1.0.0/yolov8m-face.pt",
}
[docs]
class YoloFaceDetector:
"""Ultralytics YOLO face detector, using a community face checkpoint.
There is no Ultralytics-official face-detection weights file (unlike
``yolov8n-pose.pt``), so this downloads a named community checkpoint
on first use (see the module docstring) unless ``model`` points at a
local file.
Parameters
----------
model : str or None, default None
A local checkpoint path, a known community checkpoint name (see
``_YOLO_FACE_URLS``), or ``None`` for the default
``"yolov8n-face.pt"``.
conf_threshold : float, default 0.5
Minimum detection confidence to keep a face.
device : str or None, default None
Inference device (e.g. ``"cpu"``, ``"cuda:0"``); ``None`` lets
ultralytics choose.
"""
def __init__(
self, model: str | None = None, conf_threshold: float = 0.5, device: str | None = None
) -> None:
from ultralytics import YOLO
model_name = model or "yolov8n-face.pt"
model_path: str
if Path(model_name).is_file():
model_path = model_name
elif model_name in _YOLO_FACE_URLS:
model_path = str(
_ensure_download(_MODELS_DIR / model_name, _YOLO_FACE_URLS[model_name])
)
else:
# Let ultralytics try its own resolution (e.g. an official name).
model_path = model_name
self._conf = conf_threshold
self._device = device
self._model = YOLO(model_path)
[docs]
def detect(self, frame_bgr: np.ndarray) -> list[Box]:
"""See :meth:`FaceDetector.detect`."""
results = self._model(frame_bgr, conf=self._conf, device=self._device, verbose=False)
boxes: list[Box] = []
for res in results:
if res.boxes is None:
continue
for b in res.boxes.xyxy.cpu().numpy():
boxes.append((float(b[0]), float(b[1]), float(b[2]), float(b[3])))
return boxes
# ── OpenCV DNN (YuNet ONNX face detector) ────────────────────────────────────
_YUNET_URL = (
"https://media.githubusercontent.com/media/opencv/opencv_zoo/main/"
"models/face_detection_yunet/face_detection_yunet_2023mar.onnx"
)
# Verified 2026-07-28 (sha256 matches the file's own GitHub-reported ETag).
# media.githubusercontent.com is required, NOT raw.githubusercontent.com —
# same Git-LFS-pointer-stub trap already documented in
# analysis/expression/ferplus.py for the FER+ model: the raw URL serves a
# 131-byte pointer instead of the real 232,589-byte binary.
_YUNET_SHA256 = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"
[docs]
class OpenCVDnnFaceDetector:
"""``cv2.FaceDetectorYN`` (YuNet) ONNX face detector.
Replaces an earlier Caffe-based res10 SSD detector, which broke outright
once OpenCV 5.0 removed ``cv2.dnn.readNetFromCaffe`` (and every other
non-ONNX/TensorFlow DNN importer) — this project's
``opencv-python>=4.8`` constraint (``analysis/pyproject.toml``) has no
upper bound, so a fresh ``uv sync`` now always resolves to 5.x. YuNet is
OpenCV's own currently-maintained face-detection model
(``opencv/opencv_zoo``), ONNX-based (works with any OpenCV DNN build),
and a real accuracy upgrade over the retired res10 model, not just a
compatibility shim. No new ML framework dependency beyond
``opencv-python``. Downloads the official OpenCV-hosted, sha256-verified
ONNX model to ``models/`` on first use.
Parameters
----------
conf_threshold : float, default 0.5
Minimum detection confidence to keep a face.
"""
def __init__(self, conf_threshold: float = 0.5) -> None:
model_path = _ensure_download_verified(
_MODELS_DIR / "face_detection_yunet_2023mar.onnx", _YUNET_URL, _YUNET_SHA256
)
# (320, 320) is just a placeholder construction-time size — detect()
# below calls setInputSize() with each frame's real dimensions
# before every inference, since YuNet's input size must match the
# frame (unlike blobFromImage-based detectors, which resize
# internally).
self._detector = cv2.FaceDetectorYN_create(
str(model_path), "", (320, 320), score_threshold=conf_threshold
)
self._last_size: tuple[int, int] | None = None
[docs]
def detect(self, frame_bgr: np.ndarray) -> list[Box]:
"""See :meth:`FaceDetector.detect`."""
h, w = frame_bgr.shape[:2]
if self._last_size != (w, h):
self._detector.setInputSize((w, h))
self._last_size = (w, h)
_, faces = self._detector.detect(frame_bgr)
boxes: list[Box] = []
if faces is not None:
for face in faces:
x, y, bw, bh = face[:4]
boxes.append((float(x), float(y), float(x + bw), float(y + bh)))
return boxes
# ── Shared model-download helper ─────────────────────────────────────────────
def _ensure_download(dest: Path, url: str) -> Path:
"""Download ``url`` to ``dest`` if not already cached there.
Parameters
----------
dest : pathlib.Path
Destination file path; parent directories are created as needed.
url : str
Source URL.
Returns
-------
pathlib.Path
``dest``, unchanged — returned for convenient call-site chaining.
"""
if not dest.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"[facemask] Downloading {dest.name} …", flush=True)
urllib.request.urlretrieve(url, dest)
print(f"[facemask] Downloaded to {dest}", flush=True)
return dest
def _ensure_download_verified(dest: Path, url: str, expected_sha256: str) -> Path:
"""Like :func:`_ensure_download`, but sha256-verifies the file (own or
cached) against ``expected_sha256`` — see
``analysis/expression/ferplus.py``'s identical helper, whose docstring
explains why: a truncated/corrupted download must never be silently
cached and reused, since it could produce garbage detections with no
visible symptom.
Parameters
----------
dest : pathlib.Path
Destination file path; parent directories are created as needed.
url : str
Source URL.
expected_sha256 : str
Expected hex-digest sha256 of the downloaded file.
Returns
-------
pathlib.Path
``dest``, unchanged.
Raises
------
RuntimeError
If the downloaded file's sha256 doesn't match ``expected_sha256``
— the bad download is deleted rather than cached.
"""
if dest.exists() and _sha256_of(dest) == expected_sha256:
return dest
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"[facemask] Downloading {dest.name} …", flush=True)
urllib.request.urlretrieve(url, dest)
actual = _sha256_of(dest)
if actual != expected_sha256:
dest.unlink(missing_ok=True)
raise RuntimeError(
f"Downloaded {dest.name} but its sha256 ({actual}) doesn't match the "
f"expected {expected_sha256} — the download may have been truncated, "
f"corrupted, or (if the model is ever re-exported upstream) the pinned "
f"hash in detectors.py needs updating. Deleted the bad download; not "
f"caching a file that might produce garbage detections."
)
print(f"[facemask] Downloaded to {dest}", flush=True)
return dest
def _sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
# ── Factory ───────────────────────────────────────────────────────────────────
[docs]
def make_detector(
backend: str, model: str | None, conf_threshold: float, device: str | None = None
) -> FaceDetector:
"""Build a face-detection backend by name.
Parameters
----------
backend : {"mediapipe", "yolov8", "opencv"}
Which backend to construct.
model : str or None
Forwarded to :class:`YoloFaceDetector` as its ``model`` argument;
ignored by the other two backends.
conf_threshold : float
Minimum detection confidence to keep a face.
device : str or None, default None
Forwarded to :class:`YoloFaceDetector` as its ``device`` argument;
ignored by the other two backends.
Returns
-------
FaceDetector
A constructed, ready-to-use detector.
Raises
------
ValueError
If ``backend`` isn't one of the three known names.
"""
if backend == "mediapipe":
return MediaPipeFaceDetector(conf_threshold=conf_threshold)
if backend == "yolov8":
return YoloFaceDetector(model=model, conf_threshold=conf_threshold, device=device)
if backend == "opencv":
return OpenCVDnnFaceDetector(conf_threshold=conf_threshold)
raise ValueError(f"Unknown face-detection backend: {backend!r}")