7 Commits
v3.7.1 ... main

Author SHA1 Message Date
Yakhyokhuja Valikhujaev
df87c6531f chore: Add kaggle notebook update workflow (#137) 2026-08-23 23:56:25 +09:00
github-actions[bot]
ad1a99a169 chore: Release v4.0.0 2026-08-14 14:56:02 +00:00
Yakhyokhuja Valikhujaev
520434be3a fix: Fix demo image size (#135) 2026-08-14 02:22:21 +09:00
Yakhyokhuja Valikhujaev
6e49c78b04 docs: Rebuild demo images and README (#134)
- Replace demo assets with 46 photos and 20 rendered figures.
- Repoint all 15 notebooks at the new sources.
- Lead the README with figures, table and script collapsed.
2026-08-14 02:08:33 +09:00
Yakhyokhuja Valikhujaev
800c960dbe fix: Fix pre and post processing in BlazeFace and CenterFace (#133)
* fix: Keep BlazeFace boxes unclipped for MediaPipe parity

* fix: Match CenterFace preprocessing to upstream

* chore: Release v4.0.0rc2

* docs: Drop unbacked CenterFace rotation warning

* test: Drop MediaPipe reference parity test for BlazeFace

* chore: Release v4.0.0rc3

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-08 00:32:37 +09:00
Yakhyokhuja Valikhujaev
4d6f29a113 feat: Release v4.0.0 (#130)
* docs: Minor info changes

* refactor: Remove deprecated factory functions

Removes the 10 factory functions deprecated in v3.7.0:
create_detector, list_available_detectors, create_recognizer,
create_face_parser, create_gaze_estimator, create_head_pose_estimator,
create_landmarker, create_matting_model, create_spoofer,
create_attribute_predictor.

Instantiate model classes directly instead, e.g.
`from uniface.detection import SCRFD; SCRFD()`.

Also drops the now-unused typing_extensions dependency and updates
docstring examples that referenced the factories.

* refactor: Remove MODEL_URLS and MODEL_SHA256 back-compat mirrors

Use MODEL_REGISTRY instead of the MODEL_URLS and MODEL_SHA256 dicts in
uniface.constants.

* chore: Add .claude directory to gitignore

* ci: Deploy docs via GitHub Pages artifact instead of gh-pages branch

* feat: Add FaceAttribNet face state prediction model

Integrate Qualcomm's FaceAttribNet (Facial-Attribute-Detection) as a new
attribute model predicting five independent binary face states from a
face crop: left/right eye openness, eyeglasses, mask, and sunglasses.

- Add FaceAttribNet class with letterbox preprocessing (normalization is
  baked into the ONNX graph) and multi-label FaceStateResult output
- Register FaceAttribNetWeights with dynamic-batch ONNX weights (opset 17)
- Enrich Face with left_eye_open, right_eye_open, eyeglasses, mask,
  and sunglasses fields
- Rename AttributeResult to DemographyResult and DDAMFNWeights to
  EmotionWeights so 'attribute' unambiguously means the model category
- Add tests and docs

* docs: Add face attributes notebook, demo assets, and README updates

* feat: Add CenterFace face detection model

* docs: Add CONTEXT.md glossary and ADRs for v4 naming decisions

ADR-0001: Face.landmarks keeps its v3 name for the 5-point set; the
future dense-landmark slot will be Face.dense_landmarks.
ADR-0002: Attribute stays the per-face predictor base class name;
FaceAnalyzer(attributes=...) is unchanged.

* refactor: Rename FaceAnalyzer attributes parameter to predictors

The pipeline parameter names the role (per-face predictors), so future
head pose, gaze, spoofing, and quality models can be passed without the
semantic mismatch of being listed as attributes. The Attribute base
class keeps its name per ADR-0002.

BREAKING CHANGE: FaceAnalyzer(attributes=...) is now
FaceAnalyzer(predictors=...); FaceAnalyzer.attributes is now
FaceAnalyzer.predictors.

* docs: Update docs and notebooks for predictors parameter rename

* docs: Remove CONTEXT.md

* docs: Restore README badges

* docs: Remove ADR folder

* refactor: Rename Attribute base class to BaseAttribute

* docs: Replace thick README separators with heading underlines

* docs: Put README tagline in a callout

* fix: Use predictors= in the analyze tool

* refactor: Make supports_landmarks a class attribute on BaseDetector

Replaces the private _supports_landmarks instance flag and its read-only
property with a plain class attribute, mirroring supports_alignment. Every
detector set the flag to True in __init__, so the False default never
survived and the hasattr guard in the property was dead weight.

Also drops a stale Attributes entry in RetinaFace for a field that no
longer exists, and removes comments that restated the call beneath them.

* docs: Normalize docstring markup and document missing raises

Audited every docstring and comment in uniface/ against the detection
package, which is the most internally consistent one.

- Replace 13 Sphinx roles and 159 RST double-backticks with markdown
  backticks; nothing renders docstrings, there is no Sphinx, and mkdocs
  has no mkdocstrings plugin
- Rewrite 59 legacy typing generics (List, Tuple, Optional, Union) to
  PEP 585, each checked against the real signature rather than
  mechanically substituted
- Add Raises sections to download_file, compute_similarity and
  parse_with_inverse, which raised undocumented exceptions
- Drop the RuntimeError Raises block from six abstract _initialize_model
  methods whose body raises NotImplementedError; the contract belongs to
  the implementer and is now stated as prose
- Fold Properties into Attributes with a Read-only prefix, singularize
  Examples, and fix the copyright year in faceattribnet

No executable line changed; the bulk pass rewrote only string and comment
tokens matched by byte offset.

* fix: Raise ValueError instead of asserting in estimate_norm

Both input checks used bare assert statements, which python -O strips.
The docstring promised AssertionError as part of the contract, so under an
optimized build the validation silently disappeared and an unsupported
image_size flowed into the alignment maths instead of failing fast.

Both are now ValueError, and the messages report the offending value.

* docs: Replace RST double-backticks in face_utils

* docs: Document Raises on every model class

The detection package documents ValueError and RuntimeError on the class
docstring; no model class in any other package did, even though they all
call verify_model_weights in __init__ and reach an _initialize_model that
wraps session creation in RuntimeError.

Verified per class before adding: each target reaches both raise sites,
inheriting _initialize_model from its base where it does not define one.

* fix: Replace the last two bare asserts with explicit raises

python -O strips assert statements, so both checks silently disappeared in
optimized builds. BaseRecognizer would then accept a multi-output model and
mis-slice its results, and get_meanface_info would build neighbour tables
from a meanface of the wrong length.

Behaviour is unchanged in normal builds: the recognizer check already sat
inside the try that re-raises as RuntimeError, and the meanface check now
raises the ValueError its docstring already documented.

No bare asserts remain in uniface/.

* style: Put multi-line docstring summaries on the first line

Card 6 of the docstring audit: class docstrings opened two different ways,
45 with a leading newline and 37 on the same line as the quotes.

Settled by the repo's own config rather than by taste. pyproject already
declares pydocstyle convention = google, under which ruff resolves D212
(multi-line-summary-first-line) as the active rule and explicitly ignores
the incompatible D213. Same-line was therefore already the declared
standard, even though the detection package -- the audit's exemplar -- sat
on the other side of the split.

Applied with ruff --fix, then the summaries that ended up glued to their
description got the blank line D205 wants.

* style: Put multi-line docstring summaries on the first line (constants, test_utils)

Same D212/D205 pass as the previous commit, applied to the two files that
were held back from it.

* build: Enforce D205 and D212 in ruff

The docstring audit found the opening style split 45/37 with nothing to
hold it in place: pyproject declared pydocstyle convention = google, but D
was absent from select, so the convention setting was inert.

Selecting the two rules the codebase now satisfies makes the decision
checkable instead of conventional. The rest of D is left off deliberately
-- D100 alone would flag 63 files, since this project's convention is to
have no module docstrings.

* feat: Add BlazeFace detector and MediaPipe Face Mesh landmarker

BlazeFace is MediaPipe's short-range SSD face detector: two anchor grids
over a 128x128 letterboxed image, decoded with MediaPipe's weighted NMS
where overlapping candidates are score-averaged rather than discarded.

It emits 6 MediaPipe keypoints, not the 5-point alignment template, so
BaseDetector gains a supports_alignment flag. It is True everywhere else
and False only here. FaceAnalyzer checks it and disables recognition with
a warning instead of producing broken embeddings, and estimate_norm's
error now names the constraint when a caller tries to align anyway.

FaceMesh predicts 468 dense 3D landmarks from a face crop and is
detector-agnostic: the ROI follows MediaPipe's recipe, a square region at
1.5x the detector box rotated so the eye line is horizontal, built from
whichever two eye points the detector provides. Every face in an image
runs in one session call. roi_from_box and warp_roi are public so
video-mode tracking can be built on top.

Also adds FaceMeshResult, draw_mesh with full and partial tessellations,
weight entries for both models, the facemesh and facestate tools, example
notebook 15, and tests for both models.

Tessellation tables are vendored from PINTO0309/facemesh_onnx_tensorrt
(Apache-2.0); provenance is recorded in docs/license-attribution.md.

* docs: List BlazeFace and Face Mesh in the remaining model indexes

The feature commit updated the detection and landmarks module pages, the
model zoo and the concept pages, but four summary listings still predated
both models:

- README examples table stopped at notebook 14
- README training-dataset table omitted both
- quickstart model summary omitted both
- index.md feature cards described detection as 5-point only, which
  BlazeFace's 6 keypoints contradict

* feat: Add MediaPipe Face Landmarker, a 478-point mesh with irises

Google's Face Landmarker is the successor to Face Mesh: one network predicting
478 landmarks from a 256x256 crop, where the first 468 keep the Face Mesh
topology and ordering and the last ten are the irises. It ships in the Tasks
API bundle; the earlier 478-point attention model is not ported, since it needs
three MediaPipe-only TFLite custom ops that no standard runtime can load.

FaceMesh needs no logic change to serve it. Input size and landmark count were
already read from the ONNX graph, so 192->256 and 468->478 are absorbed at load
time. Verified end to end at 0.007% of inter-ocular distance against MediaPipe's
own pipeline, with the existing ROI recipe unchanged - Google's model card
specifies the same 25% per-side margin the default already used.

Iris membership follows MediaPipe's own convention: the result stays a flat
array and the region lives in named constants next to the topology it indexes,
rather than becoming accessors on FaceMeshResult. draw_mesh renders the irises
distinctly, so the library and tools/facemesh.py agree by construction.

BREAKING CHANGE: FaceMeshWeights.DEFAULT is replaced by V1_468 and V2_478.
Default behaviour is unchanged - the constructor still loads the 468-point
model - but code naming DEFAULT explicitly must be updated. Every other
multi-variant weights enum already names its members descriptively.

* feat: Add Hugging Face mirror fallback for weight downloads

* docs: Document the Hugging Face mirror fallback

* fix: Resolve bugs found in whole-codebase review

* refactor: Publish validate_image and normalize line endings

* fix: Guarantee weighted NMS terminates

* refactor: Make model constructors keyword-only

* refactor: Read input_size from the ONNX model

* fix: Normalize and validate attribute input_size

* refactor: Replace detector **kwargs with explicit options

* fix: Reject unusable images in BlazeFace and FaceMesh

* feat: Make detector capability flags opt-in

* fix: Show every populated face state in Face repr

* docs: Clarify inline comments

* docs: Update docs and notebooks for v4

* refactor: Make remaining public constructors keyword-only

* feat: Export BaseAttribute and document custom predictors

* test: Cover FaceAnalyzer predictors

* fix: Pin the Hugging Face mirror to an immutable revision

* docs: Add changelog with v4 migration guide

* ci: Deploy release docs via the Pages artifact flow

* build: Require setuptools 77 for the SPDX license field

* docs: Fix anchors, dataset table, and tool options

* chore: Release v4.0.0rc1

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-04 02:47:26 +09:00
Yakhyokhuja Valikhujaev
a79d113791 feat: Add RetinaFace with ResNet50 backbone (#128)
* Added RESNET50 for Retinaface

* chore: Change model hosting to uniface github release

---------

Co-authored-by: Bert <maxcooncat75@gmail.com>
2026-07-13 18:03:02 +09:00
225 changed files with 7734 additions and 2819 deletions

29
.github/kaggle/kernels.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
"$comment": [
"'slug' is where the kernel lives on Kaggle right now; 'title' is what it should be called.",
"Kaggle re-slugs a kernel to match its title, so a push whose title differs renames the kernel",
"and its old URL stops resolving. The kernel survives with its upvotes, comments and version",
"history (measured 2026-08-20). After such a push, update the slug here to where it landed;",
"sync.py prints the exact edit, and finds the kernel by title in the meantime.",
"'live': true marks a slug that already exists. sync.py refuses to push one it cannot find",
"under either name, so a mistyped slug fails loudly instead of stranding the original.",
"Titles come from each notebook H1, keeping examples/ the single source of truth."
],
"kernels": [
{"notebook": "01_face_detection.ipynb", "slug": "face-detection-with-uniface", "title": "Face Detection with UniFace", "live": true},
{"notebook": "02_face_alignment.ipynb", "slug": "face-detection-and-alignment-with-uniface", "title": "Face Detection and Alignment with UniFace", "live": true},
{"notebook": "03_face_verification.ipynb", "slug": "face-verification-one-to-one-face-comparison", "title": "Face Verification: One-to-One Face Comparison", "live": true},
{"notebook": "04_face_search.ipynb", "slug": "face-search-one-to-many-face-matching", "title": "Face Search: One-to-Many Face Matching", "live": true},
{"notebook": "05_face_analyzer.ipynb", "slug": "face-analysis-with-uniface", "title": "Face Analysis with UniFace", "live": true},
{"notebook": "06_face_parsing.ipynb", "slug": "face-parsing-with-uniface", "title": "Face Parsing with UniFace", "live": true},
{"notebook": "07_face_anonymization.ipynb", "slug": "face-anonymization-with-uniface", "title": "Face Anonymization with UniFace", "live": true},
{"notebook": "08_gaze_estimation.ipynb", "slug": "gaze-estimation-with-uniface", "title": "Gaze Estimation with UniFace", "live": true},
{"notebook": "09_face_segmentation.ipynb", "slug": "xseg-face-segmentation", "title": "XSeg Face Segmentation", "live": true},
{"notebook": "10_face_vector_store.ipynb", "slug": "face-vector-store-with-faiss", "title": "Face Vector Store with FAISS", "live": true},
{"notebook": "11_head_pose_estimation.ipynb", "slug": "head-pose-estimation-with-uniface", "title": "Head Pose Estimation with UniFace", "live": true},
{"notebook": "12_face_recognition.ipynb", "slug": "face-recognition-retinaface-align-arcface", "title": "Face Recognition: RetinaFace → Align → ArcFace", "live": true},
{"notebook": "13_portrait_matting.ipynb", "slug": "portrait-matting-with-modnet", "title": "Portrait Matting with MODNet", "live": true},
{"notebook": "14_face_attributes.ipynb", "slug": "face-attribute-detection-with-uniface", "title": "Face Attribute Detection with UniFace", "live": true},
{"notebook": "15_face_mesh.ipynb", "slug": "dense-face-mesh-with-uniface", "title": "Dense Face Mesh with UniFace", "live": true}
]
}

513
.github/kaggle/sync.py vendored Normal file
View File

@@ -0,0 +1,513 @@
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Publish the notebooks in ``examples/`` to Kaggle as kernels.
Each notebook maps to one kernel slug in ``kernels.json`` beside this script. Pushing a known slug
adds a *new version* to that kernel rather than creating another one, so its upvotes, comments and
version history carry over; nothing is ever deleted or re-created.
The slug is not fixed, though. Kaggle re-slugs a kernel to match its title, so changing a title
moves the kernel's URL and the old address stops resolving (measured 2026-08-20: retitling
``face-detection-with-uniface-python-library`` moved it to ``face-detection-with-uniface``, which
then 404s, while the kernel itself kept its history). Renaming is therefore done by pushing the OLD
slug with the NEW title, and the manifest holds both: ``slug`` is where the kernel lives now,
``title`` is what it should be called. A mismatch on a live entry means a pending rename and is
reported, not rejected; on a new entry it is simply wrong, because a new kernel lands at
slugify(title) regardless. Keep the old title whenever the current URL matters more than the name.
Kaggle runs the kernel itself once the version lands, so this script pushes and exits without
waiting for the run to finish.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass
import json
import os
from pathlib import Path
import re
import shutil
import subprocess # nosec B404 - only ever runs the Kaggle CLI with literal arguments
import sys
import tempfile
import time
REPO_ROOT = Path(__file__).resolve().parents[2]
MANIFEST = Path(__file__).resolve().parent / 'kernels.json'
EXAMPLES_DIR = REPO_ROOT / 'examples'
# Notebooks reach their assets by cloning the repo, which only happens when this env var is visible.
KAGGLE_ENV_GUARD = 'KAGGLE_KERNEL_RUN_TYPE'
# Each notebook carries an 'Open in Kaggle' badge; this is how one is recognised.
KAGGLE_BADGE = 'kaggle.com/static/images/open-in-kaggle.svg'
# Stands in for the owner when staging without credentials, so a dry run still works offline.
PLACEHOLDER_OWNER = '<KAGGLE_USERNAME>'
# Kaggle refuses a push while five batch sessions are already running; this is how it says so.
SESSION_LIMIT = 'Maximum batch CPU session count'
# Kaggle's own rule for the identifier half of a kernel reference.
SLUG_PATTERN = re.compile(r'[a-z0-9][a-z0-9-]{3,58}[a-z0-9]')
@dataclass(frozen=True)
class RemoteKernel:
"""A kernel as Kaggle currently holds it."""
title: str
votes: int
@property
def vote_count(self) -> str:
"""Return the vote tally as English, e.g. ``"1 vote"`` or ``"5 votes"``."""
return f'{self.votes} vote' if self.votes == 1 else f'{self.votes} votes'
def slugify(title: str) -> str:
"""Reduce a kernel title to the slug Kaggle would derive from it.
This is where a kernel lands after a push, so it doubles as the slug a new kernel is created
at and the slug a renamed one moves to.
Args:
title: Human-readable kernel title.
Returns:
Lowercase dash-separated slug.
"""
return re.sub(r'-+', '-', re.sub(r'[^a-z0-9]+', '-', title.lower())).strip('-')
def resolve_owner(explicit: str | None) -> str | None:
"""Find the Kaggle account that owns the kernels.
Args:
explicit: Owner passed on the command line, or ``None``.
Returns:
Kaggle username, or ``None`` when no credentials are available.
"""
if explicit:
return explicit
if os.environ.get('KAGGLE_USERNAME'):
return os.environ['KAGGLE_USERNAME']
config_dir = Path(os.environ.get('KAGGLE_CONFIG_DIR', Path.home() / '.kaggle'))
config = config_dir / 'kaggle.json'
if config.is_file():
return json.loads(config.read_text(encoding='utf-8')).get('username')
return None
def load_kernels(manifest: Path) -> list[dict]:
"""Read the kernel manifest.
Args:
manifest: Path to ``kernels.json``.
Returns:
List of kernel entries.
"""
return json.loads(manifest.read_text(encoding='utf-8'))['kernels']
def validate(kernels: list[dict], examples_dir: Path) -> list[str]:
"""Check the manifest against the notebooks on disk.
Args:
kernels: Kernel entries from the manifest.
examples_dir: Directory holding the source notebooks.
Returns:
List of error messages; empty when the manifest is consistent.
"""
errors: list[str] = []
seen_slugs: dict[str, str] = {}
for entry in kernels:
notebook = examples_dir / entry['notebook']
if not notebook.is_file():
errors.append(f'{entry["notebook"]}: listed in the manifest but missing from {examples_dir.name}/')
else:
# The notebook's own Kaggle badge has to name the slug the push will land on, or every
# published copy advertises an address that stopped resolving the moment it moved.
landing = slugify(entry['title'])
text = notebook.read_text(encoding='utf-8')
if KAGGLE_BADGE in text and f'/{landing})' not in text:
errors.append(f'{entry["notebook"]}: its Kaggle badge does not point at /{landing}')
if entry['slug'] in seen_slugs:
errors.append(f'{entry["slug"]}: slug reused by {seen_slugs[entry["slug"]]} and {entry["notebook"]}')
seen_slugs[entry['slug']] = entry['notebook']
if not SLUG_PATTERN.fullmatch(entry['slug']):
errors.append(f'{entry["slug"]}: not a valid Kaggle slug (5-60 chars, lowercase letters, digits, dashes)')
if len(entry['title']) < 5:
errors.append(f'{entry["notebook"]}: title "{entry["title"]}" is under Kaggle\'s five-character minimum')
# Kaggle re-slugs a kernel to match its title, so a kernel that does not exist yet will land
# at slugify(title) whatever the manifest claims. On a live kernel the same mismatch is
# meaningful instead of wrong: it is a pending rename, reported by the push rather than
# rejected here, because renaming requires pushing the OLD slug with the NEW title.
if not entry.get('live') and entry['slug'] != slugify(entry['title']):
errors.append(
f'{entry["notebook"]}: a new kernel titled "{entry["title"]}" lands at '
f'"{slugify(entry["title"])}", not "{entry["slug"]}". Match them.'
)
mapped = {entry['notebook'] for entry in kernels}
for notebook in sorted(examples_dir.glob('*.ipynb')):
if notebook.name not in mapped:
errors.append(f'{notebook.name}: no entry in {MANIFEST.name} — add one so it is not silently skipped')
return errors
def check_kaggle_guard(notebook: Path) -> bool:
"""Report whether a notebook clones the repo when it runs on Kaggle.
Args:
notebook: Path to the source notebook.
Returns:
``True`` when the Kaggle branch of the setup cell is present.
"""
return KAGGLE_ENV_GUARD in notebook.read_text(encoding='utf-8')
def build_metadata(entry: dict, owner: str) -> dict:
"""Build the ``kernel-metadata.json`` payload for one notebook.
Args:
entry: Kernel entry from the manifest.
owner: Kaggle username that owns the kernel.
Returns:
Metadata dict ready to be written next to the notebook.
"""
return {
'id': f'{owner}/{entry["slug"]}',
'title': entry['title'],
'code_file': entry['notebook'],
'language': 'python',
'kernel_type': 'notebook',
'is_private': False,
# Notebooks pip-install uniface and download ONNX weights on first use.
'enable_internet': True,
'enable_gpu': entry.get('enable_gpu', False),
'enable_tpu': False,
'dataset_sources': entry.get('dataset_sources', []),
'competition_sources': [],
'kernel_sources': [],
'model_sources': [],
}
def stage(entry: dict, examples_dir: Path, staging_root: Path, owner: str) -> Path:
"""Copy a notebook and its metadata into a directory Kaggle can push.
Args:
entry: Kernel entry from the manifest.
examples_dir: Directory holding the source notebooks.
staging_root: Directory to create the per-kernel folder under.
owner: Kaggle username that owns the kernel.
Returns:
The staged directory.
"""
staged = staging_root / entry['slug']
staged.mkdir(parents=True, exist_ok=True)
shutil.copy2(examples_dir / entry['notebook'], staged / entry['notebook'])
(staged / 'kernel-metadata.json').write_text(
json.dumps(build_metadata(entry, owner), indent=2) + '\n', encoding='utf-8'
)
return staged
def push_kernel(staged: Path, retries: int, wait: int) -> tuple[int, str]:
"""Push one staged kernel, waiting out Kaggle's concurrent-session cap.
Kaggle runs every pushed version and refuses a push once five batch sessions are already
running, so a fifteen-notebook sync cannot be submitted in one burst. Each rejection is
retried rather than reported, since it means "not yet", not "no".
Args:
staged: Directory holding the notebook and its ``kernel-metadata.json``.
retries: How many times to wait for a session slot before giving up.
wait: Seconds to wait between attempts.
Returns:
The final return code and the combined output of the last attempt.
Raises:
FileNotFoundError: If the Kaggle CLI is not on PATH.
"""
for attempt in range(retries + 1):
result = run_kaggle(['kernels', 'push', '-p', str(staged)])
output = (result.stdout + result.stderr).strip()
if SESSION_LIMIT not in output:
return result.returncode, output
if attempt < retries:
print(f' Kaggle is already running its five batch sessions; retrying in {wait}s')
time.sleep(wait)
return result.returncode, output
def run_kaggle(args: list[str]) -> subprocess.CompletedProcess[str]:
"""Run the Kaggle CLI.
Args:
args: Arguments after the ``kaggle`` executable.
Returns:
The completed process, with stdout and stderr captured as text.
Raises:
FileNotFoundError: If the Kaggle CLI is not on PATH.
"""
executable = shutil.which('kaggle')
if executable is None:
raise FileNotFoundError('kaggle')
return subprocess.run([executable, *args], capture_output=True, text=True, check=False) # nosec B603
def fetch_remote(owner: str) -> dict[str, RemoteKernel] | None:
"""Read the account's kernels, keyed by slug.
``kernels status`` cannot tell a missing kernel from a private one — both answer with the same
permission error — so existence is settled from a listing instead. ``--mine`` covers private
kernels the public ``--user`` view omits, and the two are merged.
Args:
owner: Kaggle username to list.
Returns:
Slug to :class:`RemoteKernel`, or ``None`` when the listing could not be read at all.
"""
remote: dict[str, RemoteKernel] = {}
reachable = False
for scope in (['--mine'], ['--user', owner]):
try:
result = run_kaggle(['kernels', 'list', *scope, '--page-size', '100', '-v'])
except FileNotFoundError:
return None
if result.returncode != 0:
continue
reachable = True
# Columns are ref,title,author,lastRunTime,totalVotes; only real rows carry an owner/slug ref.
for row in csv.reader(result.stdout.splitlines()):
if len(row) < 5 or '/' not in row[0]:
continue
row_owner, _, slug = row[0].strip().partition('/')
if row_owner != owner:
continue
remote[slug] = RemoteKernel(title=row[1].strip(), votes=int(row[4]) if row[4].isdigit() else 0)
return remote if reachable else None
def resolve_target(entry: dict, remote: dict[str, RemoteKernel] | None) -> tuple[str | None, RemoteKernel | None]:
"""Work out which slug to push a manifest entry against.
A kernel is looked for under its manifest slug first, then under the slug its title implies.
The second lookup is what makes a rename idempotent: once Kaggle has moved a kernel to match a
new title, the manifest's old slug stops resolving, and only the title-derived one finds it.
Args:
entry: Kernel entry from the manifest.
remote: Kernels currently on the account, or ``None`` when the listing could not be read.
Returns:
The slug to push against and the kernel already there, if any. The slug is ``None`` when a
kernel marked live cannot be found under either name, which means its slug was mistyped.
"""
if remote is None:
return entry['slug'], None
if entry['slug'] in remote:
return entry['slug'], remote[entry['slug']]
renamed = slugify(entry['title'])
if renamed in remote:
return renamed, remote[renamed]
return (None, None) if entry.get('live') else (entry['slug'], None)
def list_remote(owner: str, kernels: list[dict]) -> int:
"""Print the account's kernels and reconcile them against the manifest.
Args:
owner: Kaggle username to list.
kernels: Kernel entries from the manifest.
Returns:
Process exit code.
"""
remote = fetch_remote(owner)
if remote is None:
print(f'Could not list kernels for {owner}. Check the kaggle CLI and its credentials.', file=sys.stderr)
return 1
print(f'Kernels on {owner} ({len(remote)} found):')
for slug, kernel in sorted(remote.items()):
print(f' {kernel.vote_count:>8} {slug} "{kernel.title}"')
print('\nManifest:')
for entry in kernels:
kernel = remote.get(entry['slug'])
state = f'live, {kernel.vote_count}' if kernel else 'new'
print(f' [{state}] {entry["slug"]} <- {entry["notebook"]}')
if kernel and kernel.title != entry['title']:
print(f' retitle "{kernel.title}" -> "{entry["title"]}"; the URL moves to {entry["slug"]}')
if entry.get('live') and not kernel:
print(' ORPHAN RISK: marked live in the manifest but absent from Kaggle.')
unmapped = set(remote) - {entry['slug'] for entry in kernels}
if unmapped:
print('\nOn Kaggle but not in the manifest (pushing would not touch these):')
for slug in sorted(unmapped):
print(f' {slug}')
return 0
def main() -> int:
parser = argparse.ArgumentParser(description='Publish examples/*.ipynb to Kaggle as kernels')
parser.add_argument('--dry-run', action='store_true', help='Stage and validate, push nothing')
parser.add_argument('--only', help='Only sync kernels whose notebook or slug contains this substring')
parser.add_argument('--owner', help='Kaggle username (defaults to KAGGLE_USERNAME or ~/.kaggle/kaggle.json)')
parser.add_argument('--list', action='store_true', help='List the account kernels and reconcile with the manifest')
parser.add_argument('--stage-dir', type=Path, help='Keep the staged kernels here instead of a temp directory')
parser.add_argument(
'--session-wait', type=int, default=60, help='Seconds to wait for a free Kaggle session slot (default: 60)'
)
parser.add_argument(
'--session-retries', type=int, default=30, help='How many times to wait for a slot (default: 30)'
)
args = parser.parse_args()
kernels = load_kernels(MANIFEST)
errors = validate(kernels, EXAMPLES_DIR)
if errors:
print('Manifest is out of sync with examples/:', file=sys.stderr)
for error in errors:
print(f' {error}', file=sys.stderr)
return 1
owner = resolve_owner(args.owner)
if owner is None:
if args.list or not args.dry_run:
print('No Kaggle credentials. Set KAGGLE_USERNAME and KAGGLE_KEY, or pass --owner.', file=sys.stderr)
return 1
owner = PLACEHOLDER_OWNER
print('No Kaggle credentials found; staging with a placeholder owner.\n')
if args.list:
if args.only:
print('--only is ignored by --list, which always shows every kernel.\n')
return list_remote(owner, kernels)
if args.only:
kernels = [e for e in kernels if args.only in e['notebook'] or args.only in e['slug']]
if not kernels:
print(f'No kernel matches --only {args.only!r}', file=sys.stderr)
return 1
for entry in kernels:
if not check_kaggle_guard(EXAMPLES_DIR / entry['notebook']):
print(f'Warning: {entry["notebook"]} has no {KAGGLE_ENV_GUARD} branch; its assets will be missing.')
# One listing settles existence for every kernel, so the loop below makes no extra API calls.
remote = fetch_remote(owner) if owner != PLACEHOLDER_OWNER else None
if remote is None:
print('Could not read the account listing; update-vs-create is unknown for every kernel.\n')
staging_root = args.stage_dir
temp_dir = None
if staging_root is None:
temp_dir = tempfile.TemporaryDirectory(prefix='kaggle-sync-')
staging_root = Path(temp_dir.name)
staging_root.mkdir(parents=True, exist_ok=True)
failed: list[str] = []
renamed: list[tuple[str, str, str]] = []
try:
for entry in kernels:
target, current = resolve_target(entry, remote)
if target is None:
ref = f'{owner}/{entry["slug"]}'
message = f'{ref}: marked live but Kaggle has it under neither that slug nor the one its title implies'
if args.dry_run:
print(f'[dry-run] {ref} (BLOCKED) <- {entry["notebook"]}')
print(f' ORPHAN RISK: {message}.')
continue
print(f'Refusing {message}.', file=sys.stderr)
print(' Restore the original slug, or drop "live" if the kernel is genuinely new.', file=sys.stderr)
failed.append(ref)
continue
ref = f'{owner}/{target}'
moves_to = slugify(entry['title']) if current and current.title != entry['title'] else None
# Push against the slug Kaggle knows; a differing title is what asks it to rename.
staged = stage({**entry, 'slug': target}, EXAMPLES_DIR, staging_root, owner)
if args.dry_run:
if current:
state = f'update existing, {current.vote_count}'
elif remote is not None:
state = 'CREATE NEW'
else:
state = 'unknown'
print(f'[dry-run] {ref} ({state}) <- {entry["notebook"]}')
if moves_to:
print(f' retitle "{current.title}" -> "{entry["title"]}"')
print(f' URL MOVES {target} -> {moves_to}; the old address stops resolving.')
continue
print(f'Pushing {ref} <- {entry["notebook"]}')
try:
returncode, output = push_kernel(staged, args.session_retries, args.session_wait)
except FileNotFoundError:
print('The kaggle CLI is not installed. Run: pip install kaggle', file=sys.stderr)
return 1
if output:
print(' ' + output.replace('\n', '\n '))
if returncode != 0 or 'error' in output.lower():
failed.append(ref)
continue
landed = moves_to or target
print(f' https://www.kaggle.com/code/{owner}/{landed}')
if moves_to:
renamed.append((entry['notebook'], target, moves_to))
finally:
if temp_dir is not None:
temp_dir.cleanup()
elif args.dry_run:
print(f'\nStaged in {staging_root}')
if renamed:
# The manifest now names slugs that no longer exist. resolve_target still finds these
# kernels by title, but leaving the stale slugs in place makes every later run rely on
# that fallback instead of saying plainly where each kernel lives.
print(f'\n{len(renamed)} kernel(s) moved. Update {MANIFEST.name}:')
for notebook, before, after in renamed:
print(f' {notebook}: "slug": "{before}" -> "{after}"')
if failed:
print(f'\n{len(failed)} kernel(s) failed to push:', file=sys.stderr)
for ref in failed:
print(f' {ref}', file=sys.stderr)
return 1
if not args.dry_run:
print(f'\nPushed {len(kernels)} kernel(s). Kaggle runs each new version on its own.')
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -4,11 +4,20 @@ on:
workflow_dispatch:
permissions:
contents: write
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v5
with:
@@ -28,11 +37,14 @@ jobs:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
run: uv run mkdocs build --strict
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Upload site artifact
uses: actions/upload-pages-artifact@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
destination_dir: docs
path: ./site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

65
.github/workflows/kaggle.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Publish Notebooks to Kaggle
# Keeps the Kaggle kernels in step with examples/ so they cannot drift from the
# released API. Runs on a published release, or on demand.
#
# Each push adds a new version to an existing kernel, so its upvotes, comments and
# history carry over. Kaggle runs each new version itself and this job does not wait
# for those runs, but it does pace itself: Kaggle refuses a push while five batch
# sessions are already going, so submitting all fifteen takes a few waves.
on:
release:
types: [published]
workflow_dispatch:
inputs:
mode:
description: 'list = show live kernels, dry-run = stage only, publish = push for real'
type: choice
options: [dry-run, list, publish]
default: dry-run
only:
description: 'Sync only kernels matching this substring (blank = all)'
type: string
default: ''
permissions:
contents: read
concurrency:
group: kaggle-publish
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Kaggle CLI
run: python -m pip install --upgrade pip kaggle
- name: Publish
env:
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
# Read through the environment so a hand-typed value cannot reach the shell as code.
MODE: ${{ inputs.mode }}
ONLY: ${{ inputs.only }}
run: |
set -euo pipefail
cmd=(python .github/kaggle/sync.py)
# A release carries no inputs, so an empty MODE means publish.
case "$MODE" in
list) cmd+=(--list) ;;
dry-run) cmd+=(--dry-run) ;;
esac
if [ -n "$ONLY" ]; then
cmd+=(--only "$ONLY")
fi
"${cmd[@]}"

View File

@@ -196,7 +196,15 @@ jobs:
needs: [validate, publish]
if: needs.validate.outputs.is_prerelease == 'false'
permissions:
contents: write
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
concurrency:
group: pages
cancel-in-progress: false
steps:
- name: Checkout tag
@@ -219,11 +227,14 @@ jobs:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
run: uv run mkdocs build --strict
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Upload site artifact
uses: actions/upload-pages-artifact@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
destination_dir: docs
path: ./site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

4
.gitignore vendored
View File

@@ -1,5 +1,9 @@
tmp_*
.vscode/
.claude
# CLI tools in tools/ write results here by default (--save-dir)
outputs/
# Byte-compiled / optimized / DLL files
__pycache__/

64
CHANGELOG.md Normal file
View File

@@ -0,0 +1,64 @@
# Changelog
Notable changes to UniFace are documented here. Earlier releases are covered by
the autogenerated notes on the [GitHub releases page](https://github.com/yakhyo/uniface/releases).
## 4.0.0 - 2026-08-03
### Breaking changes
- **Factory functions are gone.** `create_detector()` and its siblings no longer
exist. Construct model classes directly.
- **`FaceAnalyzer(attributes=...)` is now `predictors=`.** The parameter accepts
any list of `BaseAttribute` subclasses.
- **`Attribute` is renamed `BaseAttribute`**, matching `BaseDetector`,
`BaseRecognizer`, and the other base classes.
- **Constructors are keyword-only.** Every model class, plus `FaceAnalyzer`,
`BlurFace`, `EllipticalBlur`, `FAISS`, and `BYTETracker`, rejects positional
arguments. `SCRFD(model_name)` raises `TypeError`; write
`SCRFD(model_name=...)`.
- **Detector `**kwargs` replaced with explicit options.** `RetinaFace` takes
`pre_nms_topk`, `post_nms_topk`, and `dynamic_size`; `YOLOv5Face` and
`YOLOv8Face` take `max_det`. Misspelled options now fail instead of being
silently ignored.
- **`input_size` removed from `MobileGaze`, `HeadPose`, `BiSeNet`, and
`Landmark106`.** These models always resized to the size in the ONNX graph;
the parameter had no effect and is gone.
- **Inputs are validated.** Detectors and landmarkers require 3-channel `uint8`
BGR images. Float or grayscale arrays now raise `ValueError` instead of
silently returning no faces.
- **Detector capability flags are opt-in.** `supports_landmarks` and
`supports_alignment` default to `False` on `BaseDetector`. Third-party
detector subclasses that produce 5-point alignment landmarks must now declare
both flags; boxes-only subclasses need no declaration.
### Migrating from 3.x
| 3.x | 4.0 |
| --- | --- |
| `create_detector('retinaface', ...)` | `RetinaFace(...)` |
| `RetinaFace(RetinaFaceWeights.MNET_V2)` | `RetinaFace(model_name=RetinaFaceWeights.MNET_V2)` |
| `FaceAnalyzer(attributes=[AgeGender()])` | `FaceAnalyzer(predictors=[AgeGender()])` |
| `class MyPredictor(Attribute)` | `class MyPredictor(BaseAttribute)` |
| `RetinaFace(**{'pre_nms_topk': 1000})` | `RetinaFace(pre_nms_topk=1000)` |
| `HeadPose(input_size=(224, 224))` | `HeadPose()` |
### Added
- `BlazeFace` detector: MediaPipe short-range face detector with 6 keypoints.
- `CenterFace` detector.
- `FaceMesh` landmarker: MediaPipe dense 3D face mesh, 468 points or 478 with
irises (`FaceMeshWeights.V2_478`).
- `FaceAttribNet` predictor: eye openness, eyeglasses, sunglasses, and mask
probabilities as `FaceStateResult`, enriching `Face` in-place.
- Hugging Face mirror fallback for weight downloads when GitHub Releases is
unreachable, pinned to an immutable revision.
- `uniface.common.validate_image` is public for use in custom models.
### Fixed
- `BlazeFace` weighted NMS could loop forever with `iou_threshold=1.0` or a
zero-area box; it now always terminates.
- `Face.__repr__` no longer assumes all five face-state fields are set together.
- Attribute models normalize `input_size` and warn when it disagrees with the
ONNX metadata; `FaceAttribNet` rejects non-square sizes.

View File

@@ -88,22 +88,22 @@ def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[
Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for all public APIs:
```python
def create_detector(method: str = 'retinaface', **kwargs: Any) -> BaseDetector:
"""Factory function to create face detectors.
def detect(self, image: np.ndarray, **kwargs: Any) -> list[Face]:
"""Detect faces in an image.
Args:
method: Detection method. Options: 'retinaface', 'scrfd', 'yolov5face', 'yolov8face'.
**kwargs: Detector-specific parameters.
image: Input image as numpy array with shape (H, W, C) in BGR format.
**kwargs: Additional detection parameters.
Returns:
Initialized detector instance.
List of detected Face objects.
Raises:
ValueError: If method is not supported.
ValueError: If the image is empty, not 3-channel BGR, or not uint8.
Example:
>>> from uniface import create_detector
>>> detector = create_detector('retinaface', confidence_threshold=0.8)
>>> from uniface import RetinaFace
>>> detector = RetinaFace(confidence_threshold=0.8)
>>> faces = detector.detect(image)
>>> print(f"Found {len(faces)} faces")
"""
@@ -159,7 +159,7 @@ pytest tests/
pytest tests/ -v
# Run specific test file
pytest tests/test_factory.py
pytest tests/test_scrfd.py
# Run with coverage
pytest tests/ --cov=uniface --cov-report=html
@@ -193,6 +193,10 @@ Example notebooks demonstrating library usage:
| Face Segmentation | [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) |
| Face Vector Store | [10_face_vector_store.ipynb](examples/10_face_vector_store.ipynb) |
| Head Pose Estimation | [11_head_pose_estimation.ipynb](examples/11_head_pose_estimation.ipynb) |
| Face Recognition | [12_face_recognition.ipynb](examples/12_face_recognition.ipynb) |
| Portrait Matting | [13_portrait_matting.ipynb](examples/13_portrait_matting.ipynb) |
| Face Attributes | [14_face_attributes.ipynb](examples/14_face_attributes.ipynb) |
| Face Mesh | [15_face_mesh.ipynb](examples/15_face_mesh.ipynb) |
## Release Process

365
README.md
View File

@@ -7,8 +7,8 @@
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Github Build Status](https://github.com/yakhyo/uniface/actions/workflows/ci.yml/badge.svg)](https://github.com/yakhyo/uniface/actions)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/uniface?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=BLUE&left_text=Downloads)](https://pepy.tech/projects/uniface)
[![UniFace Documentation](https://img.shields.io/badge/Docs-UniFace-blue.svg)](https://yakhyo.github.io/uniface/)
[![Kaggle Badge](https://img.shields.io/badge/Notebooks-Kaggle?label=Kaggle&color=blue)](https://www.kaggle.com/yakhyokhuja/code)
[![Hugging Face Spaces](https://img.shields.io/badge/Demo-%F0%9F%A4%97%20Spaces-blue)](https://huggingface.co/spaces/yakhyo/uniface)
</div>
@@ -16,328 +16,171 @@
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/.github/logos/uniface_rounded_q80.webp" width="90%" alt="UniFace - A Unified Face Analysis Library for Python">
</div>
---
<p align="center">
UniFace is a lightweight, production-ready Python library for face detection, recognition,<br>
tracking, landmark analysis, face parsing, gaze estimation, and face attributes.
</p>
**UniFace** is a lightweight, production-ready Python library for face detection, recognition, tracking, landmark analysis, face parsing, gaze estimation, and face attributes.
---
## Features
- **Face Detection** — RetinaFace, SCRFD, YOLOv5-Face, and YOLOv8-Face with 5-point landmarks
- **Face Recognition** — AdaFace, ArcFace, EdgeFace, MobileFace, and SphereFace embeddings
- **Face Tracking** — Multi-object tracking with [BYTETracker](https://github.com/yakhyo/bytetrack-tracker) for persistent IDs across video frames
- **Facial Landmarks** — 106-point (2d106det) and 98 / 68-point (PIPNet) landmark localization (separate from the 5-point detector landmarks)
- **Face Parsing** — BiSeNet semantic segmentation (19 classes), XSeg face masking
- **Portrait Matting** — Trimap-free alpha matte with MODNet (background removal, green screen, compositing)
- **Gaze Estimation** — Real-time gaze direction with MobileGaze
- **Head Pose Estimation** — 3D head orientation (pitch, yaw, roll) with 6D rotation representation
- **Attribute Analysis** — Age, gender, race (FairFace), and emotion
- **Vector Store** — FAISS-backed embedding store for fast multi-identity search
- **Anti-Spoofing** — Face liveness detection with MiniFASNet
- **Face Quality Assessment** — eDifFIQA single-score quality (T/S/M/L, NIST FATE-Quality #1 with the L variant)
- **Face Anonymization** — 5 blur methods for privacy protection
- **Hardware Acceleration** — ARM64 (Apple Silicon), CUDA (NVIDIA), CPU
---
## Visual Examples
<table>
<tr>
<td align="center"><b>Face Detection</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/detection.jpg" width="100%"></td>
<td align="center"><b>Gaze Estimation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/gaze.jpg" width="100%"></td>
</tr>
<tr>
<td align="center"><b>Head Pose Estimation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/headpose.jpg" width="100%"></td>
<td align="center"><b>Age & Gender</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/age_gender.jpg" width="100%"></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Face Verification</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/verification.jpg" width="80%"></td>
</tr>
<tr>
<td align="center"><b>106-Point Landmarks</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/landmarks.jpg" width="70%"></td>
<td align="center"><b>98-Point Landmarks (PIPNet)</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/landmarks_pipnet.jpg" width="70%"></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Face Parsing</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/parsing.jpg" width="80%"></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Face Segmentation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/segmentation.jpg" width="80%"></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Portrait Matting</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/matting.jpg" width="100%"></td>
</tr>
<tr>
<td align="center" colspan="2"><b>Face Anonymization</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/anonymization.jpg" width="100%"></td>
</tr>
</table>
---
## Installation
**CPU / Apple Silicon**
<p align="center">
<a href="https://yakhyo.github.io/uniface/quickstart/"><img src="https://img.shields.io/badge/Get%20Started-1f6feb?style=for-the-badge&logoColor=white" alt="Get Started"></a>
&nbsp;
<a href="https://yakhyo.github.io/uniface/models/"><img src="https://img.shields.io/badge/Model%20Zoo-30363d?style=for-the-badge&logoColor=white" alt="Model Zoo"></a>
&nbsp;
<a href="https://yakhyo.github.io/uniface/notebooks/"><img src="https://img.shields.io/badge/Notebooks-30363d?style=for-the-badge&logo=jupyter&logoColor=white" alt="Notebooks"></a>
&nbsp;
<a href="https://yakhyo.github.io/uniface/"><img src="https://img.shields.io/badge/Full%20Docs-30363d?style=for-the-badge&logoColor=white" alt="Full Docs"></a>
</p>
```bash
pip install uniface[cpu]
pip install "uniface[cpu]" # CPU and Apple Silicon
pip install "uniface[gpu]" # NVIDIA CUDA
pip install --pre "uniface[cpu]" # latest pre-release
```
**GPU support (NVIDIA CUDA)**
<details>
<summary><b>A first script</b></summary>
```bash
pip install uniface[gpu]
```
<br>
> **Why separate extras?** `onnxruntime` and `onnxruntime-gpu` conflict when both are installed — they own the same Python namespace. Installing only the extra you need prevents that conflict entirely.
**From source (latest version)**
```bash
git clone https://github.com/yakhyo/uniface.git
cd uniface && pip install -e ".[cpu]" # or .[gpu] for CUDA
```
**FAISS vector store**
```bash
pip install faiss-cpu # or faiss-gpu for CUDA
```
**Optional dependencies**
- Emotion model uses TorchScript and requires `torch`:
`pip install torch` (choose the correct build for your OS/CUDA)
- YOLOv5-Face and YOLOv8-Face support faster NMS with `torchvision`:
`pip install torch torchvision` then use `nms_mode='torchvision'`
---
## Model Downloads and Cache
Models are downloaded automatically on first use and verified via SHA-256.
Default cache location: `~/.uniface/models`
Override with the programmatic API or environment variable:
```python
from uniface.model_store import get_cache_dir, set_cache_dir
set_cache_dir('/data/models')
print(get_cache_dir()) # /data/models
```
```bash
export UNIFACE_CACHE_DIR=/data/models
```
---
## Quick Example (Detection)
`FaceAnalyzer` runs detection, alignment and recognition in one call. Attribute models are opt-in.
```python
import cv2
from uniface.detection import RetinaFace
from uniface import FaceAnalyzer, FairFace
detector = RetinaFace()
analyzer = FaceAnalyzer(predictors=[FairFace()])
image = cv2.imread("photo.jpg")
if image is None:
raise ValueError("Failed to load image. Check the path to 'photo.jpg'.")
faces = detector.detect(image)
for face in faces:
print(f"Confidence: {face.confidence:.2f}")
print(f"BBox: {face.bbox}")
print(f"Landmarks: {face.landmarks.shape}")
for face in analyzer.analyze(cv2.imread("photo.jpg")):
print(face.bbox, face.sex, face.age_group, face.embedding.shape)
```
---
`bbox`, `confidence`, `landmarks` and `embedding` are always set. Age, sex, race, emotion, quality
and the face states stay `None` until you pass the predictor that fills them.
## Example (Face Analyzer)
</details>
```python
import cv2
from uniface import FaceAnalyzer
<details>
<summary><b>All fifteen tasks, and which model does each</b></summary>
# Zero-config: uses SCRFD (500M) + ArcFace (MobileNet) by default
analyzer = FaceAnalyzer()
<br>
image = cv2.imread("photo.jpg")
if image is None:
raise ValueError("Failed to load image. Check the path to 'photo.jpg'.")
| Task | Models |
| --- | --- |
| Face Detection | RetinaFace, SCRFD, CenterFace, YOLOv5-Face, YOLOv8-Face, BlazeFace |
| Face Recognition | AdaFace, ArcFace, EdgeFace, MobileFace, SphereFace |
| Face Tracking | BYTETracker, persistent IDs across video frames |
| Facial Landmarks | 2d106det (106), PIPNet (98 / 68), Face Mesh (468 / 478, 3D) |
| Face Parsing | BiSeNet (19 classes), XSeg masking |
| Portrait Matting | MODNet, trimap-free |
| Gaze Estimation | MobileGaze (ResNet-18 / 34 / 50, MobileNetV2) |
| Head Pose | 6D rotation representation, pitch / yaw / roll |
| Demographics | AgeGender, FairFace (age group, sex, race) |
| Emotion | AffectNet-7 and AffectNet-8 |
| Face States | FaceAttribNet: eyes, glasses, sunglasses, mask |
| Face Quality | eDifFIQA (T / S / M / L) |
| Anti-Spoofing | MiniFASNet liveness |
| Anonymization | 5 blur methods |
| Vector Store | FAISS-backed embedding search |
faces = analyzer.analyze(image)
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.
for face in faces:
print(face.bbox, face.embedding.shape if face.embedding is not None else None)
```
</details>
With attributes:
<br>
```python
from uniface import FaceAnalyzer, AgeGender
### Find and measure faces
analyzer = FaceAnalyzer(attributes=[AgeGender()])
faces = analyzer.analyze(image)
**Face Detection** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/detection/)
for face in faces:
print(f"{face.sex}, {face.age}y, embedding={face.embedding.shape}")
```
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/detection.jpg" width="100%">
---
**Facial Landmarks** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/landmarks/)
## Example (Portrait Matting)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/landmarks.jpg" width="100%">
```python
import cv2
import numpy as np
from uniface.matting import MODNet
**Face Mesh** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/landmarks/#face-mesh-468-or-478-points-3d)
matting = MODNet()
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_mesh.jpg" width="100%">
image = cv2.imread("portrait.jpg")
matte = matting.predict(image) # (H, W) float32 in [0, 1]
**Face Quality** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/quality/)
# Transparent PNG
rgba = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
rgba[:, :, 3] = (matte * 255).astype(np.uint8)
cv2.imwrite("transparent.png", rgba)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/quality.jpg" width="100%">
# Green screen
matte_3ch = matte[:, :, np.newaxis]
bg = np.full_like(image, (0, 177, 64), dtype=np.uint8)
result = (image * matte_3ch + bg * (1 - matte_3ch)).astype(np.uint8)
cv2.imwrite("green_screen.jpg", result)
```
### Cut faces out
---
**Face Parsing** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/parsing/)
## Jupyter Notebooks
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/parsing.jpg" width="100%">
| Example | Colab | Description |
| -------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | --------------------------------------- |
| [01_face_detection.ipynb](examples/01_face_detection.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/01_face_detection.ipynb) | Face detection and landmarks |
| [02_face_alignment.ipynb](examples/02_face_alignment.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/02_face_alignment.ipynb) | Face alignment for recognition |
| [03_face_verification.ipynb](examples/03_face_verification.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/03_face_verification.ipynb) | Compare faces for identity |
| [04_face_search.ipynb](examples/04_face_search.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/04_face_search.ipynb) | Find a person in group photos |
| [05_face_analyzer.ipynb](examples/05_face_analyzer.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/05_face_analyzer.ipynb) | Unified face analysis |
| [06_face_parsing.ipynb](examples/06_face_parsing.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/06_face_parsing.ipynb) | Semantic face segmentation |
| [07_face_anonymization.ipynb](examples/07_face_anonymization.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/07_face_anonymization.ipynb) | Privacy-preserving blur |
| [08_gaze_estimation.ipynb](examples/08_gaze_estimation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/08_gaze_estimation.ipynb) | Gaze direction estimation |
| [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/09_face_segmentation.ipynb) | Face segmentation with XSeg |
| [10_face_vector_store.ipynb](examples/10_face_vector_store.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/10_face_vector_store.ipynb) | FAISS-backed face database |
| [11_head_pose_estimation.ipynb](examples/11_head_pose_estimation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/11_head_pose_estimation.ipynb) | Head pose estimation (pitch, yaw, roll) |
| [12_face_recognition.ipynb](examples/12_face_recognition.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/12_face_recognition.ipynb) | Standalone face recognition pipeline |
| [13_portrait_matting.ipynb](examples/13_portrait_matting.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/13_portrait_matting.ipynb) | Portrait matting with MODNet |
**Face Segmentation** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/parsing/#xseg)
---
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation.jpg" width="100%">
## Documentation
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation_occluded.jpg" width="100%">
Full documentation: https://yakhyo.github.io/uniface/
**Portrait Matting** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/matting/)
| Resource | Description |
| ------------------------------------------------------------------ | ------------------------------------------- |
| [Quickstart](https://yakhyo.github.io/uniface/quickstart/) | Get up and running in 5 minutes |
| [Model Zoo](https://yakhyo.github.io/uniface/models/) | All models, benchmarks, and selection guide |
| [API Reference](https://yakhyo.github.io/uniface/modules/detection/) | Detailed module documentation |
| [Tutorials](https://yakhyo.github.io/uniface/recipes/image-pipeline/) | Step-by-step workflow examples |
| [Guides](https://yakhyo.github.io/uniface/concepts/overview/) | Architecture and design principles |
| [Datasets](https://yakhyo.github.io/uniface/datasets/) | Training data and evaluation benchmarks |
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting.jpg" width="100%">
---
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting_alt.jpg" width="100%">
## Execution Providers (ONNX Runtime)
### Read where a head is pointing
```python
from uniface.detection import RetinaFace
**Head Pose** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/headpose/)
# Force CPU-only inference
detector = RetinaFace(providers=["CPUExecutionProvider"])
```
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/headpose.jpg" width="100%">
See more in the docs:
https://yakhyo.github.io/uniface/concepts/execution-providers/
**Gaze Estimation** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/gaze/)
---
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/gaze.jpg" width="100%">
## Datasets
### Read a face
| Task | Training Dataset | Models |
| ----------- | --------------------------- | ------------------------------------------- |
| Detection | WIDER FACE | RetinaFace, SCRFD, YOLOv5-Face, YOLOv8-Face |
| Recognition | MS1MV2 | MobileFace, SphereFace |
| Recognition | WebFace600K | ArcFace |
| Recognition | WebFace4M / 12M | AdaFace |
| Recognition | MS1MV2 | EdgeFace |
| Landmarks | WFLW, 300W+CelebA | PIPNet (98 / 68 pts) |
| Gaze | Gaze360 | MobileGaze |
| Head Pose | 300W-LP | HeadPose (ResNet, MobileNet) |
| Parsing | CelebAMask-HQ | BiSeNet |
| Attributes | CelebA, FairFace, AffectNet | AgeGender, FairFace, Emotion |
**Age and Sex** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/)
> See [Datasets documentation](https://yakhyo.github.io/uniface/datasets/) for download links, benchmarks, and details.
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/demography.jpg" width="100%">
---
**Emotion** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/#emotion)
## Licensing and Model Usage
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/emotion.jpg" width="100%">
UniFace is MIT-licensed, but several pretrained models carry their own licenses.
Review: https://yakhyo.github.io/uniface/license-attribution/
**Face States** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/#faceattribnet)
Notable examples:
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states.jpg" width="100%">
- YOLOv5-Face and YOLOv8-Face weights are GPL-3.0
- FairFace weights are CC BY 4.0
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states_alt.jpg" width="100%">
If you plan commercial use, verify model license compatibility.
### Tell a real face from a replay
---
**Anti-Spoofing** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/spoofing/)
## References
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/spoofing.jpg" width="100%">
| Feature | Repository | Training | Description |
| ------------- | ------------------------------------------------------------------------------------- | :------: | ------------------------------------ |
| Detection | [retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch) | ✓ | RetinaFace PyTorch Training & Export |
| Detection | [yolov5-face-onnx-inference](https://github.com/yakhyo/yolov5-face-onnx-inference) | - | YOLOv5-Face ONNX Inference |
| Detection | [yolov8-face-onnx-inference](https://github.com/yakhyo/yolov8-face-onnx-inference) | - | YOLOv8-Face ONNX Inference |
| Tracking | [bytetrack-tracker](https://github.com/yakhyo/bytetrack-tracker) | - | BYTETracker Multi-Object Tracking |
| Recognition | [face-recognition](https://github.com/yakhyo/face-recognition) | ✓ | MobileFace, SphereFace Training |
| Recognition | [edgeface-onnx](https://github.com/yakhyo/edgeface-onnx) | - | EdgeFace ONNX Inference |
| Landmarks | [pipnet-onnx](https://github.com/yakhyo/pipnet-onnx) | - | PIPNet 98 / 68-point ONNX Inference |
| Parsing | [face-parsing](https://github.com/yakhyo/face-parsing) | ✓ | BiSeNet Face Parsing |
| Parsing | [face-segmentation](https://github.com/yakhyo/face-segmentation) | - | XSeg Face Segmentation |
| Gaze | [gaze-estimation](https://github.com/yakhyo/gaze-estimation) | ✓ | MobileGaze Training |
| Head Pose | [head-pose-estimation](https://github.com/yakhyo/head-pose-estimation) | ✓ | Head Pose Training (6DRepNet-style) |
| Matting | [modnet](https://github.com/yakhyo/modnet) | - | MODNet Portrait Matting |
| Anti-Spoofing | [face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing) | - | MiniFASNet Inference |
| Quality | [face-image-quality-assessment](https://github.com/yakhyo/face-image-quality-assessment) | - | eDifFIQA ONNX Inference |
| Attributes | [fairface-onnx](https://github.com/yakhyo/fairface-onnx) | - | FairFace ONNX Inference |
### Match a face, or hide one
*SCRFD and ArcFace models are from [InsightFace](https://github.com/deepinsight/insightface).
**Face Recognition** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/recognition/)
---
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/verification.jpg" width="100%">
## Contributing
**Face Anonymization** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/privacy/)
Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md).
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/anonymization.jpg" width="100%">
## Support
<br>
If you find this project useful, consider giving it a ⭐ on GitHub — it helps others discover it!
<div align="center">
Questions or feedback:
**[Get Started](https://yakhyo.github.io/uniface/quickstart/)** &nbsp;·&nbsp;
[Model Zoo](https://yakhyo.github.io/uniface/models/) &nbsp;·&nbsp;
[Notebooks](https://yakhyo.github.io/uniface/notebooks/) &nbsp;·&nbsp;
[Model licences](https://yakhyo.github.io/uniface/license-attribution/) &nbsp;·&nbsp;
[Contributing](CONTRIBUTING.md) &nbsp;·&nbsp;
[Discord](https://discord.gg/wdzrjr7R5j) &nbsp;·&nbsp;
[Issues](https://github.com/yakhyo/uniface/issues)
- Discord: https://discord.gg/wdzrjr7R5j
- GitHub Issues: https://github.com/yakhyo/uniface/issues
- DeepWiki Q&A: https://deepwiki.com/yakhyo/uniface
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.<br>
UniFace is [MIT](LICENSE); some pretrained weights are not, so check
[licences](https://yakhyo.github.io/uniface/license-attribution/) before shipping commercially.<br>
Not affiliated with [Uniface](https://uniface.com/) by Rocket Software.
## License
This project is licensed under the [MIT License](LICENSE).
> **Disclaimer:** This project is not affiliated with or related to
> [Uniface](https://uniface.com/) by Rocket Software.
</div>

109
assets/demo/README.md Normal file
View File

@@ -0,0 +1,109 @@
# Demo set
Photographs and rendered figures covering every component that a still image can show.
`assets/source/` holds only the photographs the figures read; this folder holds the figures. Rebuild with:
```bash
python3 tools/demo/build_demos.py assets
```
46 source photographs (16 MB), 20 figures (5.9 MB). Every number below is measured by that script,
not quoted from a paper. Rerun it after changing a source and update this file from its output.
## Naming
One pattern for every source: **`<task>_<variant>.jpg`**, task first. A trailing `2` marks a second
set of subjects for the same task (`state_mask` and `state_b_mask` are different people). Verification
uses `verify_<name>_<year>`, because a figure needs several photographs of one person and the
name is what tells you which.
## Source photographs, by task
| Source | Feeds | Notes |
| -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| `detect_group.jpg` | detection, quality | 7 faces |
| `detect_crowd.jpg` | detection_alt | 29 faces, 3746px each |
| `anon_group.jpg` | anonymization | 5 faces |
| `landmarks_face.jpg` | landmarks | |
| `mesh_face.jpg` | face_mesh | |
| `parse_face.jpg` | parsing | 13 of 19 classes present |
| `seg_face.jpg` | segmentation | |
| `seg_occluded.jpg` | segmentation_occluded | headscarf, so only the exposed region masks |
| `matte_face.jpg` | matting | |
| `matte_hair.jpg` | matting_alt | flyaway hair against a plain background |
| `pose_left/center/right.jpg` | headpose | yaw 78° / +9° / +40° |
| `gaze_away/averted/right.jpg` | gaze | yaw 30° / 19° / +23°, so not left/centre/right |
| `age_child/adult/middle/senior.jpg` | demography | 3-9, 20-29, 40-49, 60-69; sorted by prediction, not filename |
| `emotion_*.jpg` (8) | emotion | one per AffectNet-8 class |
| `state_closed/glasses/sunglasses/mask.jpg` | face_states | `emotion_happy` fills the eyes-open slot |
| `state_b_glasses/sunglasses/mask.jpg` | face_states_alt | second set;`age_adult` and `mesh_face` fill the two accessory-free slots |
| `spoof_live/print/screen.jpg` | spoofing | a live capture and two replays of it |
| `verify_now_2010/2014/2024.jpg` | verification | one living subject, three dates, left unnamed |
| `verify_einstein_1921/1947.jpg` | verification_alt | `_1947` is also the `tests/test_blazeface.py` fixture and notebook 04's query, so do not remove it |
| `verify_curie.jpg` | verification_alt | the unpaired negative; year not recorded |
| `verify_bohr_1910/1935.jpg` | verification_alt | second identity, 25 years apart |
Missing names are skipped with a warning rather than failing the run, so a partial set still builds.
## Figures
| File | Model | Measured |
| ------------------------- | -------------------- | ------------------------------------------------------------------------- |
| detection.jpg | SCRFD-10G | 7 faces |
| detection_alt.jpg | SCRFD-10G | 29 faces, 3746px wide, weakest score 0.73 |
| landmarks.jpg | 2d106det, PIPNet | 106 / 98 / 68 points |
| face_mesh.jpg | MediaPipe | 468 and 478 points; landmarks above, 2556-edge tessellation below |
| parsing.jpg | BiSeNet ResNet-34 | 13 of 19 classes present |
| segmentation.jpg | XSeg | input / mask / cut out |
| segmentation_occluded.jpg | XSeg | 8.6% of frame masked |
| matting.jpg | MODNet | input / matte / composite |
| matting_alt.jpg | MODNet | fine hair, plain background |
| headpose.jpg | ResNet-34 | yaw 78° / +9° / +40° |
| gaze.jpg | MobileGaze ResNet-18 | yaw 30° / 19° / +23° |
| demography.jpg | FairFace | 3-9, 20-29, 40-49, 60-69 |
| emotion.jpg | AffectNet-8 | all 8 classes, p 0.750.99 |
| face_states.jpg | FaceAttribNet | glasses 0.74, shades 1.00, mask 1.00 |
| face_states_alt.jpg | FaceAttribNet | glasses 1.00, shades 1.00, mask 1.00 |
| quality.jpg | eDifFIQA(L) | 0.398 … 0.749 across 7 faces |
| spoofing.jpg | MiniFASNet | live Real 1.00; print Fake 0.66, screen Fake 0.99 |
| anonymization.jpg | BlurFace | 4 of 5 methods, 5 faces |
| verification.jpg | AdaFace IR-101 | +0.746 at 4 yr, +0.721 at 10 yr; 0.049 and 0.040 reject |
| verification_alt.jpg | AdaFace IR-101 | Einstein +0.583 at 26 yr, Bohr +0.689 at 25 yr; +0.001 and 0.031 reject |
Not covered: **tracking** needs video, and the **FAISS store** needs a database rather than an
image. Anti-spoofing is covered now, but only because the three frames come from one capture setup:
MiniFASNet judges presentation, so a found photograph is a replay by definition.
## Choices worth keeping
- **Gaze uses ResNet-18.** Against ResNet-34/50 and MobileNetV2 on the same three subjects it was
the only backbone returning a positive yaw on the third face, so the row reads leftward to
rightward instead of all-leftward.
- **Gaze subjects are not left/centre/right.** Measured at 30°, 19° and +23°, the middle face is
still looking left, which is why the filenames say `away` and `averted`.
- **Demography uses FairFace, not AgeGender.** AgeGender put a child at 30 and called an elderly
woman Male; FairFace's buckets order correctly. The figure sorts by predicted bucket, so filename
order does not matter.
- **Head pose follows `tools/headpose.py`**: angles estimated on the unpadded bbox crop, drawn with
`draw_head_pose(draw_type='cube')`.
- **Head pose prints pitch and roll only below 60° of yaw.** Past that this model returns 3582° of
tilt on a level head, so `pose_left` at 78° shows yaw alone.
- **Parsing crops to the face first.** BiSeNet trains on CelebAMask-HQ, which is face-centred crops,
so a full-body portrait leaves the face too small for eyes, brows and lips to resolve.
- **Quality runs on one photograph and shows it.** Pooling faces from several sources made the count
unverifiable, since the reader never saw where they came from.
- **Verification avoids twins.** An identical-twin pair scored above a genuine same-person match,
which reads as a bug rather than a demonstration. Negatives are man-vs-man so the reject is not
trivially separable by sex.
- **One type scale** across the set: footer 20, label 22, value 38, sub 19, legend 26, with a 34px
clear band above every footer.
## Credits
Source photographs come from Pexels, Unsplash, Pixabay and Wikimedia Commons. The three
`spoof_*.jpg` frames come from [yakhyo/face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing).
| File | Source | Author | Licence |
| ------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------ |
| `verify_bohr_1910.jpg` | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Niels_Bohr_-_LOC_-_ggbain_-_35303.jpg) | Bain News Service, via Library of Congress | PD-Bain, no known restrictions |
| `verify_bohr_1935.jpg` | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Niels_Bohr_1935.jpg) | Unknown | PD-anon-70-EU |

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

BIN
assets/demo/demography.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

BIN
assets/demo/detection.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

BIN
assets/demo/emotion.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
assets/demo/face_mesh.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 KiB

BIN
assets/demo/face_states.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
assets/demo/gaze.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
assets/demo/headpose.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

BIN
assets/demo/landmarks.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
assets/demo/matting.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

BIN
assets/demo/matting_alt.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

BIN
assets/demo/parsing.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

BIN
assets/demo/quality.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

BIN
assets/demo/spoofing.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/source/age_adult.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
assets/source/age_child.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

BIN
assets/source/gaze_away.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View File

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

BIN
assets/source/mesh_face.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

BIN
assets/source/pose_left.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

BIN
assets/source/seg_face.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More