107 Commits

Author SHA1 Message Date
yakhyo
805e0f7d60 chore: Add kaggle notebook update workflow 2026-08-20 16:45:55 +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
github-actions[bot]
d1df387274 chore: Release v3.7.1 2026-06-16 13:42:27 +00:00
Yakhyokhuja Valikhujaev
1c74e5884f chore: Clean up redundant files and update README (#126) 2026-06-15 00:53:47 +09:00
Yakhyokhuja Valikhujaev
9484e9e6e1 fix: Fix download file function with writing to tempfile for downloading file (#125) 2026-06-14 23:29:56 +09:00
Yakhyokhuja Valikhujaev
b12c929f9d docs: Fix old documentation issues (#124) 2026-06-14 22:17:15 +09:00
github-actions[bot]
50c67507e3 chore: Release v3.7.0 2026-05-27 12:21:16 +00:00
github-actions[bot]
1584a9d857 chore: Release v3.7.0rc1 2026-05-26 14:36:21 +00:00
Yakhyokhuja Valikhujaev
446e3629fb feat: Add face image quality assessment (#122)
* feat: Add face image quality assessment functionality

* chore: Add hover animation for landing page components
2026-05-26 23:29:27 +09:00
Yakhyokhuja Valikhujaev
5af87dcd56 feat: Deprecate factory function and pre-commit update (#121)
* refactor: Align BGR contract for vis_parsing_maps and tighten public docstrings

* refactor: Deprecate warning for factory functions

* Update uv.lock

* feat: Catch changes to pyproject in pre-commit to keep  uv.lock updated
2026-05-25 00:44:27 +09:00
Yakhyokhuja Valikhujaev
7882ec5cb4 chore: Update docstrings and comments (#119) 2026-05-11 01:07:14 +09:00
dependabot[bot]
d51d030545 chore(deps): bump gitpython from 3.1.49 to 3.1.50 (#118)
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.49 to 3.1.50.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.50
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 19:16:08 +09:00
github-actions[bot]
5a767847da chore: Release v3.6.0 2026-05-08 12:25:34 +00:00
github-actions[bot]
4a22f903f0 chore: Release v3.6.0rc2 2026-05-08 12:20:29 +00:00
Yakhyokhuja Valikhujaev
43a46e11df ci: Refresh uv.lock during release pipeline (#117) 2026-05-08 21:13:44 +09:00
github-actions[bot]
025b93ab8b chore: Release v3.6.0rc1 2026-05-08 03:27:30 +00:00
Yakhyokhuja Valikhujaev
8bf87d958f feat: Add PIPNet for facial landmarks detection (#116)
* docs: Add PipNet model documentation

* feat: Add PipNet for face landmark detection
2026-05-08 12:25:00 +09:00
Yakhyokhuja Valikhujaev
b813dc2ee7 ref: Update package mngt and optimize the vector store functions (#115)
* ref: Update download and hash chunk sizes to speed up

* build: Adopt uv with uv.lock and drop requirements.txt

* ref: Centralize softmax helper and minor cleanups
2026-05-06 01:47:27 +09:00
Yakhyokhuja Valikhujaev
73fc291930 ci: Resolve deprecation warnings in pipeline (#114)
* ci: Resolve deprecation warnings in pipeline
2026-04-28 00:52:46 +09:00
github-actions[bot]
400bb72217 chore: Release v3.5.3 2026-04-27 15:24:18 +00:00
Yakhyokhuja Valikhujaev
a0a12d5eca fix: Fix pypi publish re-run issue (#113) 2026-04-28 00:22:12 +09:00
github-actions[bot]
a34f376da0 chore: Release v3.5.2 2026-04-27 15:04:20 +00:00
Yakhyokhuja Valikhujaev
2b29706615 ci: Add end-to-end deployment pipeline and fix docs auto-trigger (#112) 2026-04-27 23:59:09 +09:00
github-actions[bot]
f6d3cf33f0 chore: Release v3.5.1 2026-04-27 11:53:21 +00:00
Yakhyokhuja Valikhujaev
0eb042425c chore: Minor changes to workflow names and docs (#111) 2026-04-27 20:51:50 +09:00
github-actions[bot]
35c0b6d539 chore: Release v3.5.1rc1 2026-04-25 15:17:54 +00:00
Yakhyokhuja Valikhujaev
13c4ac83d8 feat: Update the release workflow and package installation command (#110)
* fix: Fix installation conflict between onnxruntime and onnxruntime-gpu

* fix: Fix CI, notebooks, type hints, and packaging issues found in audit

* feat: Add new release config

* ci: Automate release pipeline and document release process
2026-04-25 23:59:00 +09:00
Yakhyokhuja Valikhujaev
6ce397b811 feat: Add MODNet portrait matting (#108)
* feat: Add MODNet portrait matting

* docs: Update docs and example of portrait matting

* fix: Fix linting issue
2026-04-11 23:30:32 +09:00
Yakhyokhuja Valikhujaev
9bf54f5f78 feat: Add EdgeFace recognition model (#105)
* refactor: Split recognition models into separate files

* feat: Add EdgeFace recognition model

* release: Bump version to v3.4.0
2026-04-04 20:11:28 +09:00
Yakhyokhuja Valikhujaev
c87ec1ad0f docs: Add example images and update MkDocs files (#104)
* chore: Add example inference results

* docs: Update MkDocs and README files
2026-04-04 18:28:27 +09:00
Yakhyokhuja Valikhujaev
9e56a86963 chore: Update docs and clean up notebook outputs before before commit (#102)
* chore: Add links for repo and docs on example notebooks

* ref: Compress jupyter notebook sizes

* ci: Add nbstripout pre-commit hook for notebook output stripping

* docs: Add coding agent docs and commit message tag
2026-04-03 10:10:51 +09:00
Yakhyokhuja Valikhujaev
426bd71505 release: Release UniFace v3.3.0 - Python 3.10 support, stores refactor, docs and examples refresh (#101)
* docs: Update docs and examples

* chore: Update tools folder testing for development

* feat: Update indexing to stores and drawing logic

* chore: Update the release version to 3.3.0

* feat: Add python 3.10 support

* build: Add python support for worklows and publishing

* chore: Update all example notebooks
2026-03-28 22:30:56 +09:00
LiberiFatali
ede8b27091 chore: Add example notebook for face recognition (#100) 2026-03-28 05:27:27 +09:00
Yakhyokhuja Valikhujaev
02c77ce5db feat: Add head pose estimation model (#99)
* feat: Add Head Pose Estimation  with 6 different models

* chore: Update jupyter notebook examples

* docs: Update head pose estimation related docs
2026-03-26 22:57:05 +09:00
Yakhyokhuja Valikhujaev
d70d6a254f ref: Unify attribute/detector base classes and fix tools reliability (#98)
* refactor: unify attribute API, deduplicate detectors, and fix embedding shape

* refactor: unify attribute API and deduplicate detector code

* chore: Update docs page build on tags and frame validation before flip
2026-03-25 23:43:56 +09:00
Yakhyokhuja Valikhujaev
7d37633b1a chore: drop Python 3.10 support, bump scikit-image to >=0.26.0 (#96) 2026-03-19 10:04:52 +09:00
Yakhyokhuja Valikhujaev
bc413df4a8 docs: Add release changelog markdown file (#92) 2026-03-19 09:46:16 +09:00
Marc-Antoine BERTIN
8db0577991 feat: Add Python 3.14 support (#95)
- Relax requires-python upper bound from <3.14 to <3.15
- Add Python 3.14 classifier to pyproject.toml
- Add Python 3.14 to CI test matrix (ubuntu-latest)
- Fix SimilarityTransform.estimate() deprecation warning (scikit-image >=0.26)
  by switching to SimilarityTransform.from_estimate() class constructor

All 147 tests pass on Python 3.14.3 with no warnings.

Co-authored-by: marc-antoine <marcantoine.bertin@storyzy.com>
2026-03-19 09:41:44 +09:00
Yakhyokhuja Valikhujaev
3682a2124f release: Release UniFace version v3.1.0 (#91)
* release: Release UniFace version v3.1.0

* docs: Change classifiers to stable from beta
2026-03-11 12:21:33 +09:00
Yakhyokhuja Valikhujaev
2ef6a1ebe8 refactor: Use dataclass-based model info in model management (#90)
- Refactor model management section: Using data classes for more robust model management.
2026-03-11 12:05:43 +09:00
Yakhyokhuja Valikhujaev
78a2dba7c7 feat: Add FAISS vectore database for fast face search (#88) 2026-03-05 22:46:03 +09:00
Yakhyokhuja Valikhujaev
87e496d1f5 feat: Add FAISS vector DB support for fast search (#86)
* feat: Add FAISS: VectorDB for face embedding search

* docs: Update Documentation
2026-03-03 12:12:05 +09:00
Yakhyokhuja Valikhujaev
5604ebf4f1 docs: Add datasets information in the docs (#85) 2026-02-18 16:02:37 +09:00
Yakhyokhuja Valikhujaev
971775b2e8 feat: Update API format and gaze estimation models (#82)
* docs: Update documentation

* fix: Update several missing docs and tests

* docs: Clean up and remove redundants

* fix: Fix the gaze output formula and change the output order

* chore: Update model weights for gaze estimation

* release: Update release version to v3.0.0
2026-02-14 23:54:51 +09:00
Yakhyokhuja Valikhujaev
c520ea2df2 faet: Add ByteTrack - Multi-Object Tracking by Associating Every Detection Box (#81)
* feat: Add BYTETrack for face/person tracking

* docs: Update documentation

* ref: Update tools folder file naming and imports

* docs: Update jupyter notebook examples

* ref: Rename the file and remove duplicate codes

* docs: Update README.md

* chore: Update description in mkdocs, add keywords for face tracking

* docs: Add announcement section

* feat: Remove expand bbox for tracking and update docs
2026-02-12 00:20:23 +09:00
Yakhyokhuja Valikhujaev
2a8cb54d31 feat: Add get and set for cache dir (#80) 2026-02-09 23:32:02 +09:00
Yakhyokhuja Valikhujaev
331f46be7c release: Update release version and docs (#79) 2026-02-05 21:45:28 +09:00
Yakhyokhuja Valikhujaev
9991fae62a docs: Update UniFace library documentation and README.md (#78)
* docs: Update wrong/missing references

* docs: Update README.md
2026-02-04 20:45:02 +09:00
Yakhyokhuja Valikhujaev
b74ab95d39 docs: Update UniFace github image (#75) 2026-01-25 17:07:40 +09:00
Yakhyokhuja Valikhujaev
d2b0303bfe docs: Add additional badges to README.md (#74)
* Update badges in README.md
* Update ci.yml
2026-01-24 22:25:09 +09:00
Yakhyokhuja Valikhujaev
5f74487eb3 feat: Add XSeg for Face Segmentation (#72)
* feat: Add XSeg for Face Segmentation DeepFaceLab

* docs: Update model inference related reference

* chore: Update jupyter notebook example for face segmentation
2026-01-22 22:33:31 +09:00
Yakhyokhuja Valikhujaev
f897482d26 release: Release UniFace v2.2.1 (#69) 2026-01-18 22:38:15 +09:00
Yakhyokhuja Valikhujaev
f3d81eb201 feat: Add providers for chosing inference backend (#68)
* feat: Add providers for chosing inference backend

* docs: Update Python version
2026-01-18 22:29:15 +09:00
Yakhyokhuja Valikhujaev
ea0b56f7e0 fix: Add cache dir check (#67) 2026-01-15 18:07:45 +09:00
Yakhyokhuja Valikhujaev
edbab5f7bf fix: use Python 3.11 in validate job for tomllib support (#65) 2026-01-07 00:29:48 +09:00
Yakhyokhuja Valikhujaev
cd8077e460 feat: Update release to v2.2.0 (#64) 2026-01-07 00:16:29 +09:00
Yakhyokhuja Valikhujaev
452b3381a2 Update badge links in README.md (#63) 2026-01-06 23:32:36 +09:00
Yakhyokhuja Valikhujaev
07c8bd7b24 feat: Add YOLOv8 Face Detection model support (#62)
* docs: Update UniFace documentation

* feat: Add YOLOv8 face detection model
2026-01-03 19:08:41 +09:00
Yakhyokhuja Valikhujaev
68179d1e2d feat: Add AdaFace: Quality Adaptive Margin for Face Recognition (#61)
* feat: Add AdaFace model

* release: Update release version to v2.1.0
2026-01-02 00:23:24 +09:00
Yakhyokhuja Valikhujaev
99b35dddb4 chore: Add google analytics (#57) 2025-12-31 19:45:49 +09:00
Yakhyokhuja Valikhujaev
3b6d0a35a9 release: Fix/deprecated warnings and release version change (#56)
* docs: Update deprecated warnings

* release: Update release version to v2.0.2
2025-12-31 19:29:29 +09:00
Yakhyokhuja Valikhujaev
0bd808bcef release: Update release version to v2.0.1 (#55) 2025-12-31 19:07:40 +09:00
Yakhyokhuja Valikhujaev
9edf8b6b3d docs: Add Google Colab and Jypter notebooks reference (#53) 2025-12-31 18:41:23 +09:00
Yakhyokhuja Valikhujaev
efb40f2e91 feat: Upgrade docs and Add google colab support (#52)
* docs: Add announcement section

* docs: Add landing page and improve the docs

* docs: Update docs

* docs: Update documentation

* chore: Update all examples and add google colab support

* docs: Update README.md
2025-12-31 18:07:04 +09:00
Yakhyokhuja Valikhujaev
376e7bc488 docs: Add mkdocs material theme for documentation (#51)
* docs: Add mkdocs material theme for documentation

* chore: Add custom folder for rendering
2025-12-30 19:29:39 +09:00
Yakhyokhuja Valikhujaev
cbcd89b167 feat: Common result dataclasses and refactoring several methods. (#50)
* chore: Rename scripts to tools folder and unify argument parser

* refactor: Centralize dataclasses in types.py and add __call__ to all models

- Move Face and result dataclasses to uniface/types.py
- Add GazeResult, SpoofingResult, EmotionResult (frozen=True)
- Add __call__ to BaseDetector, BaseRecognizer, BaseLandmarker
- Add __repr__ to all dataclasses
- Replace print() with Logger in onnx_utils.py
- Update tools and docs to use new dataclass return types
- Add test_types.py with comprehensive dataclass testschore: Rename files under tools folder and unitify argument parser for them
2025-12-30 17:05:24 +09:00
Yakhyokhuja Valikhujaev
50226041c9 refactor: Standardize naming conventions (#47)
* refactor: Standardize naming conventions

* chore: Update the version and re-run experiments

* chore: Improve code quality tooling and documentation

- Add pre-commit job to CI workflow for automated linting on PRs
- Update uniface/__init__.py with copyright header, module docstring,
  and logically grouped exports
- Revise CONTRIBUTING.md to reflect pre-commit handles all formatting
- Remove redundant ruff check from CI (now handled by pre-commit)
- Update build job Python version to 3.11 (matches requires-python)
2025-12-30 00:20:34 +09:00
Yakhyokhuja Valikhujaev
64ad0d2f53 feat: Add FairFace model and AttributeResults return type (#46)
* feat: Add FairFace model and unified AttributeResult return type
- Update FaceAnalyzer to support FairFace
- Update documentation (README.md, QUICKSTART.md, MODELS.md)

* docs: Change python3.10 to python3.11 in python badge

* chore: Remove unused import

* fix: Fix test for age gender to reflect AttributeResult type
2025-12-28 21:07:36 +09:00
Yakhyokhuja Valikhujaev
7c98a60d26 fix: Python 3.10 does not support tomlib (#43) 2025-12-24 00:51:36 +09:00
Yakhyokhuja Valikhujaev
d97a3b2cb2 Merge pull request #42 from yakhyo/feat/standardize-outputs
feat: Standardize detection output and several other updates
2025-12-24 00:38:32 +09:00
yakhyo
2200ba063c docs: Update related docs and ruff formatting 2025-12-24 00:34:24 +09:00
yakhyo
9bcbfa65c2 feat: Update detection module output to datalasses 2025-12-24 00:00:00 +09:00
yakhyo
96306a0910 feat: Update github actions 2025-12-23 23:59:15 +09:00
Yakhyokhuja Valikhujaev
3389aa3e4c feat: Add MiniFasNet for Face Anti Spoofing (#41) 2025-12-20 22:34:47 +09:00
Yakhyokhuja Valikhujaev
b282e6ccc1 docs: Update related docs to face anonymization (#40) 2025-12-20 21:27:26 +09:00
Yakhyokhuja Valikhujaev
d085c6a822 feat: Add face blurring for privacy (#39)
* feat: Add face blurring for privacy

* chore: Revert back the version
2025-12-20 20:57:42 +09:00
yakhyo
13b518e96d chore: Upgrade version to v1.5.3 2025-12-15 15:09:54 +09:00
yakhyo
1b877bc9fc fix: Fix the version 2025-12-15 14:53:36 +09:00
Yakhyokhuja Valikhujaev
bb1d209f3b feat: Add BiSeNet face parsing model (#36)
* Add BiSeNet face parsing implementation

* Add parsing model weights configuration

* Export BiSeNet in main package

* Add face parsing tests

* Add face parsing examples and script

* Bump version to 1.5.0

* Update documentation for face parsing

* Fix face parsing notebook to use lips instead of mouth

* chore: Update the face parsing example

* fix: Fix model argument to use Enum

* ref: Move vis_parsing_map function into visualization.py

* docs: Update README.md
2025-12-15 14:50:15 +09:00
Yakhyokhuja Valikhujaev
54b769c0f1 feat: Add Face Parsing model BiSeNet model trained on CelebMask dataset (#35)
* Add BiSeNet face parsing implementation

* Add parsing model weights configuration

* Export BiSeNet in main package

* Add face parsing tests

* Add face parsing examples and script

* Bump version to 1.5.0

* Update documentation for face parsing

* Fix face parsing notebook to use lips instead of mouth

* chore: Update the face parsing example

* fix: Fix model argument to use Enum

* ref: Move vis_parsing_map function into visualization.py

* docs: Update README.md
2025-12-14 21:13:53 +09:00
Yakhyokhuja Valikhujaev
4d1921e531 feat: Add 2D Gaze estimation models (#34)
* feat: Add Gaze Estimation, update docs and Add example notebook, inference code

* docs: Update README.md
2025-12-14 14:07:46 +09:00
yakhyo
da8a5cf35b feat: Add yolov5n, update docs and ruff code format 2025-12-11 01:02:18 +09:00
Yakhyokhuja Valikhujaev
3982d677a9 fix: Fix type conversion and remove redundant type conversion (#29)
* ref: Remove type conversion and update face class

* fix: change the type to float32

* chore: Update all examples, testing with latest version

* docs: Update docs reflecting the recent changes
2025-12-10 00:18:11 +09:00
Yakhyokhuja Valikhujaev
f4458f0550 Revise model configurations in README.md
Updated model names and confidence thresholds for SCRFD and YOLOv5Face in the README.
2025-12-08 10:07:30 +09:00
Yakhyokhuja Valikhujaev
637316f077 feat: Update examples and some minor changes to UniFace API (#28)
* chore: Style changes and create jupyter notebook template

* docs: Update docstring for detection

* feat: Keyword only for common parameters: model_name, conf_thresh, nms_thresh, input_size

* chore: Update drawing and let the conf text optional for drawing

* feat: add fancy bbox draw

* docs: Add examples of using UniFace

* feat: Add version to all examples
2025-12-07 19:51:08 +09:00
Yakhyokhuja Valikhujaev
6b1d2a1ce6 feat: Add YOLOv5 face detection support (#26)
* feat: Add YOLOv5 face detection model

* docs: Update docs, add new model information

* feat: Add YOLOv5 face detection model

* test: Add testing and running
2025-12-03 23:35:56 +09:00
Yakhyokhuja Valikhujaev
a5e97ac484 Update README.md 2025-12-01 13:19:25 +09:00
Yakhyokhuja Valikhujaev
0c93598007 feat: Enhace emotion inference speed on ARM and add FaceAnalyzer, Face classes for ease of use. (#25)
* feat: Update linting and type annotations, return types in detect

* feat: add face analyzer and face classes

* chore: Update the format and clean up some docstrings

* docs: Update usage documentation

* feat: Change AgeGender model output to 0, 1 instead of string (Female, Male)

* test: Update testing code

* feat: Add Apple silicon backend for torchscript inference

* feat: Add face analyzer example and add run emotion for testing
2025-11-30 20:32:07 +09:00
Yakhyokhuja Valikhujaev
779952e3f8 Merge pull request #23 from yakhyo/test-files-update
feat: Some minor changes to code style and warning supression
2025-11-26 00:16:49 +09:00
yakhyo
39b50b62bd chore: Update the version 2025-11-26 00:15:45 +09:00
yakhyo
db7532ecf1 feat: Supress the warning and give info about onnx backend 2025-11-26 00:06:39 +09:00
yakhyo
4b8dc2c0f9 feat: Update jupyter notebooks to match the latest version of UniFace 2025-11-26 00:06:13 +09:00
yakhyo
0a2a10e165 docs: Update README.md 2025-11-26 00:05:40 +09:00
yakhyo
84cda5f56c chore: Code style formatting changes 2025-11-26 00:05:24 +09:00
yakhyo
0771a7959a chore: Code style formatting changes 2025-11-25 23:45:50 +09:00
yakhyo
15947eb605 chore: Change import order and style changes by Ruff 2025-11-25 23:35:00 +09:00
yakhyo
1ccc4f6b77 chore: Update print 2025-11-25 23:28:42 +09:00
yakhyo
189755a1a6 ref: Update some refactoring files for testing 2025-11-25 23:19:45 +09:00
Yakhyokhuja Valikhujaev
11363fe0a8 Merge pull request #18 from yakhyo/feat-20251115
ref: Add comprehensive test suite and enhance model functionality
2025-11-15 21:32:10 +09:00
yakhyo
fe3e70a352 release: Update release version to v1.1.0 2025-11-15 21:31:56 +09:00
yakhyo
8e218321a4 fix: Fix test issue where landmark variable name wrongly used 2025-11-15 21:28:21 +09:00
yakhyo
2c78f39e5d ref: Add comprehensive test suite and enhance model functionality
- Add new test files for age_gender, factory, landmark, recognition, scrfd, and utils
- Add new scripts for age_gender, landmarks, and video detection
- Update documentation in README.md, MODELS.md, QUICKSTART.md
- Improve model constants and face utilities
- Update detection models (retinaface, scrfd) with enhanced functionality
- Update project configuration in pyproject.toml
2025-11-15 21:09:37 +09:00
278 changed files with 33873 additions and 4572 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())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

BIN
.github/logos/uniface.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
.github/logos/uniface_rounded_q80.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -4,66 +4,86 @@ on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- uses: pre-commit/action@v3.0.1
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 15
needs: lint
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
include:
# Full Python range on Linux (fastest runner)
- os: ubuntu-latest
python-version: "3.10"
- os: ubuntu-latest
python-version: "3.11"
- os: ubuntu-latest
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
- os: ubuntu-latest
python-version: "3.14"
- os: macos-latest
python-version: "3.13"
- os: windows-latest
python-version: "3.13"
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[dev]
run: uv sync --locked --extra cpu --extra dev
- name: Check ONNX Runtime providers
run: |
python -c "import onnxruntime as ort; print('Available providers:', ort.get_available_providers())"
- name: Lint with ruff (if available)
run: |
pip install ruff || true
ruff check . --exit-zero || true
continue-on-error: true
run: uv run python -c "import onnxruntime as ort; print('Available providers:', ort.get_available_providers())"
- name: Run tests
run: pytest -v --tb=short
run: uv run pytest -v --tb=short
- name: Test package imports
run: |
python -c "from uniface import RetinaFace, ArcFace, Landmark106, AgeGender; print('All imports successful')"
run: uv run python -c "import uniface; print(f'uniface {uniface.__version__} loaded with {len(uniface.__all__)} exports')"
build:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: 'pip'
python-version: "3.11"
cache: "pip"
- name: Install build tools
run: |
@@ -84,4 +104,3 @@ jobs:
name: dist-python-${{ github.sha }}
path: dist/
retention-days: 7

50
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Deploy Documentation
on:
workflow_dispatch:
permissions:
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:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: "3.11"
- name: Install dependencies
run: uv sync --locked --extra docs
- name: Build docs
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
run: uv run mkdocs build --strict
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Upload site artifact
uses: actions/upload-pages-artifact@v4
with:
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[@]}"

240
.github/workflows/pipeline.yml vendored Normal file
View File

@@ -0,0 +1,240 @@
name: Release Pipeline
on:
workflow_dispatch:
inputs:
version:
description: 'Version (e.g. 3.6.0, 3.6.0b1, 3.6.0rc1)'
required: true
concurrency:
group: pipeline
cancel-in-progress: false
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
is_prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Validate version (PEP 440)
run: |
python - <<'EOF'
import re, sys
v = "${{ inputs.version }}"
if not re.fullmatch(r'\d+\.\d+\.\d+((a|b|rc)\d+|\.dev\d+)?', v):
print(f"Invalid version: {v}")
print("Expected forms: 3.6.0, 3.6.0a1, 3.6.0b1, 3.6.0rc1, 3.6.0.dev1")
sys.exit(1)
EOF
- name: Check tag does not exist
run: |
if git rev-parse "v${{ inputs.version }}" >/dev/null 2>&1; then
echo "Tag v${{ inputs.version }} already exists."
exit 1
fi
- name: Detect pre-release
id: prerelease
run: |
if [[ "${{ inputs.version }}" =~ (a|b|rc|\.dev)[0-9]+ ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
test:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: uv sync --locked --extra cpu --extra dev
- name: Run tests
run: uv run pytest -v --tb=short
release:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: test
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Update pyproject.toml
run: |
python - <<'EOF'
import re, pathlib
p = pathlib.Path('pyproject.toml')
text = p.read_text()
new = re.sub(r'^version\s*=\s*".*"', f'version = "${{ inputs.version }}"', text, count=1, flags=re.M)
if new == text:
raise SystemExit("Failed to update version in pyproject.toml")
p.write_text(new)
EOF
- name: Update uniface/__init__.py
run: |
python - <<'EOF'
import re, pathlib
p = pathlib.Path('uniface/__init__.py')
text = p.read_text()
new = re.sub(r"^__version__\s*=\s*'.*'", f"__version__ = '${{ inputs.version }}'", text, count=1, flags=re.M)
if new == text:
raise SystemExit("Failed to update __version__ in uniface/__init__.py")
p.write_text(new)
EOF
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: "3.11"
- name: Refresh uv.lock with new project version
run: uv lock --upgrade-package uniface
- name: Commit, tag, push
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml uniface/__init__.py uv.lock
git commit -m "chore: Release v${{ inputs.version }}"
git tag "v${{ inputs.version }}"
git push origin HEAD:${{ github.ref_name }}
git push origin "v${{ inputs.version }}"
publish:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [validate, release]
permissions:
contents: write
id-token: write
environment:
name: pypi
url: https://pypi.org/project/uniface/
steps:
- name: Checkout tag
uses: actions/checkout@v5
with:
ref: v${{ inputs.version }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: 'pip'
- name: Install build tools
run: |
python -m pip install --upgrade pip
python -m pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ inputs.version }}
files: dist/*
generate_release_notes: true
prerelease: ${{ needs.validate.outputs.is_prerelease }}
docs:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [validate, publish]
if: needs.validate.outputs.is_prerelease == 'false'
permissions:
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
uses: actions/checkout@v5
with:
ref: v${{ inputs.version }}
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: "3.11"
- name: Install dependencies
run: uv sync --locked --extra docs
- name: Build docs
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
run: uv run mkdocs build --strict
- name: Configure GitHub Pages
uses: actions/configure-pages@v5
- name: Upload site artifact
uses: actions/upload-pages-artifact@v4
with:
path: ./site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -1,108 +0,0 @@
name: Publish to PyPI
on:
push:
tags:
- "v*.*.*" # Trigger only on version tags like v0.1.9
jobs:
validate:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
tag_version: ${{ steps.get_version.outputs.tag_version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get version from tag and pyproject.toml
id: get_version
run: |
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "tag_version=$TAG_VERSION" >> $GITHUB_OUTPUT
PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml)
echo "version=$PYPROJECT_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: v$TAG_VERSION"
echo "pyproject.toml version: $PYPROJECT_VERSION"
- name: Verify version match
run: |
if [ "${{ steps.get_version.outputs.tag_version }}" != "${{ steps.get_version.outputs.version }}" ]; then
echo "Error: Tag version (${{ steps.get_version.outputs.tag_version }}) does not match pyproject.toml version (${{ steps.get_version.outputs.version }})"
exit 1
fi
echo "Version validation passed: ${{ steps.get_version.outputs.version }}"
test:
runs-on: ubuntu-latest
needs: validate
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[dev]
- name: Run tests
run: pytest -v
publish:
runs-on: ubuntu-latest
needs: [validate, test]
permissions:
contents: write
id-token: write
environment:
name: pypi
url: https://pypi.org/project/uniface/
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: 'pip'
- name: Install build tools
run: |
python -m pip install --upgrade pip
python -m pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: dist/*
generate_release_notes: true

5
.gitignore vendored
View File

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

54
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,54 @@
# Pre-commit configuration for UniFace
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
exclude: ^mkdocs.yml$
- id: check-toml
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: debug-statements
- id: check-ast
# Strip Jupyter notebook outputs
- repo: https://github.com/kynan/nbstripout
rev: 0.9.1
hooks:
- id: nbstripout
files: ^examples/
# Ruff - Fast Python linter and formatter
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.10
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix]
- id: ruff-format
# Security checks
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
args: [-c, pyproject.toml]
additional_dependencies: ['bandit[toml]']
exclude: ^tests/
# Keep uv.lock in sync with pyproject.toml
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.0
hooks:
- id: uv-lock
# Configuration
ci:
autofix_commit_msg: 'style: auto-fix by pre-commit hooks'
autoupdate_commit_msg: 'chore: update pre-commit hooks'

6
AGENTS.md Normal file
View File

@@ -0,0 +1,6 @@
<!-- Cursor agent instructions — shared with CLAUDE.md -->
<!-- See CLAUDE.md for full project instructions for AI coding agents. -->
# AGENTS.md
Please read and follow all instructions in [CLAUDE.md](./CLAUDE.md).

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.

81
CLAUDE.md Normal file
View File

@@ -0,0 +1,81 @@
# CLAUDE.md
Project instructions for AI coding agents.
## Project Overview
UniFace is a Python library for face detection, recognition, tracking, landmark analysis, face parsing, gaze estimation, age/gender detection. It uses ONNX Runtime for inference.
## Code Style
- Python 3.10+ with type hints
- Line length: 120
- Single quotes for strings, double quotes for docstrings
- Google-style docstrings
- Formatter/linter: Ruff (config in `pyproject.toml`)
- Run `ruff format .` and `ruff check . --fix` before committing
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/) with a **capitalized** description:
```
<type>: <Capitalized short description>
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`
Examples:
- `feat: Add gaze estimation model`
- `fix: Correct bounding box scaling for non-square images`
- `ci: Add nbstripout pre-commit hook`
- `docs: Update installation instructions`
- `refactor: Unify attribute/detector base classes`
## Testing
```bash
pytest -v --tb=short
```
Tests live in `tests/`. Run the full suite before submitting changes.
## Pre-commit
Pre-commit hooks handle formatting, linting, security checks, and notebook output stripping. Always run:
```bash
pre-commit install
pre-commit run --all-files
```
## Project Structure
```
uniface/ # Main package
detection/ # Face detection models (SCRFD, RetinaFace, YOLOv5, YOLOv8)
recognition/ # Face recognition/verification (AdaFace, ArcFace, EdgeFace, MobileFace, SphereFace)
landmark/ # Facial landmark models
tracking/ # Object tracking (ByteTrack)
parsing/ # Face parsing/segmentation (BiSeNet, XSeg)
gaze/ # Gaze estimation
headpose/ # Head pose estimation
attribute/ # Age, gender, emotion detection
spoofing/ # Anti-spoofing (MiniFASNet)
privacy/ # Face anonymization
stores/ # Vector stores (FAISS)
constants.py # Model weight URLs and checksums
model_store.py # Model download/cache management
analyzer.py # High-level FaceAnalyzer API
types.py # Shared type definitions
tests/ # Unit tests
examples/ # Jupyter notebooks (outputs are auto-stripped)
docs/ # MkDocs documentation
```
## Key Conventions
- New models: add class in submodule, register weights in `constants.py`, export in `__init__.py`
- Dependencies: managed in `pyproject.toml`
- All ONNX models are downloaded on demand with SHA256 verification
- Do not commit notebook outputs; `nbstripout` pre-commit hook handles this

242
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,242 @@
# Contributing to UniFace
Thank you for considering contributing to UniFace! We welcome contributions of all kinds.
## How to Contribute
### Reporting Issues
- Use GitHub Issues to report bugs or suggest features
- Include clear descriptions and reproducible examples
- Check existing issues before creating new ones
### Pull Requests
1. Fork the repository
2. Create a new branch for your feature
3. Write clear, documented code with type hints
4. Add tests for new functionality
5. Ensure all tests pass and pre-commit hooks are satisfied
6. Submit a pull request with a clear description
## Development Setup
We use [uv](https://docs.astral.sh/uv/) for reproducible dev installs. The committed `uv.lock` pins every transitive dependency so contributors and CI resolve to identical versions.
```bash
# Install uv (https://docs.astral.sh/uv/getting-started/installation/)
curl -LsSf https://astral.sh/uv/install.sh | sh
git clone https://github.com/yakhyo/uniface.git
cd uniface
# Sync runtime + cpu + dev extras from uv.lock (use --extra gpu instead of cpu for CUDA)
uv sync --extra cpu --extra dev
```
`uv sync` creates a project-local `.venv/` and installs everything pinned in `uv.lock`. Run commands with `uv run <cmd>` (e.g. `uv run pytest`), or activate the venv with `source .venv/bin/activate`.
### Setting Up Pre-commit Hooks
We use [pre-commit](https://pre-commit.com/) to ensure code quality and consistency. `pre-commit` is included in the `[dev]` extra, so it's already installed after `uv sync`.
```bash
# Install the git hooks
uv run pre-commit install
# (Optional) Run against all files
uv run pre-commit run --all-files
```
Once installed, pre-commit will automatically run on every commit to check:
- Code formatting and linting (Ruff)
- Security issues (Bandit)
- General file hygiene (trailing whitespace, YAML/TOML validity, etc.)
**Note:** All PRs are automatically checked by CI. The merge button will only be available after all checks pass.
## Code Style
This project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting, following modern Python best practices. Pre-commit handles all formatting automatically.
### Style Guidelines
#### General Rules
- **Line length:** 120 characters maximum
- **Python version:** 3.10+ (use modern syntax)
- **Quote style:** Single quotes for strings, double quotes for docstrings
#### Type Hints
Use modern Python 3.10+ type hints (PEP 585 and PEP 604):
```python
# Preferred (modern)
def process(items: list[str], config: dict[str, int] | None = None) -> tuple[int, str]:
...
# Avoid (legacy)
from typing import List, Dict, Optional, Tuple
def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[int, str]:
...
```
#### Docstrings
Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for all public APIs:
```python
def detect(self, image: np.ndarray, **kwargs: Any) -> list[Face]:
"""Detect faces in an image.
Args:
image: Input image as numpy array with shape (H, W, C) in BGR format.
**kwargs: Additional detection parameters.
Returns:
List of detected Face objects.
Raises:
ValueError: If the image is empty, not 3-channel BGR, or not uint8.
Example:
>>> from uniface import RetinaFace
>>> detector = RetinaFace(confidence_threshold=0.8)
>>> faces = detector.detect(image)
>>> print(f"Found {len(faces)} faces")
"""
```
#### Import Order
Imports are automatically sorted by Ruff with the following order:
1. **Future** imports (`from __future__ import annotations`)
2. **Standard library** (`os`, `sys`, `typing`, etc.)
3. **Third-party** (`numpy`, `cv2`, `onnxruntime`, etc.)
4. **First-party** (`uniface.*`)
5. **Local** (relative imports like `.base`, `.models`)
```python
from __future__ import annotations
import os
from typing import Any
import cv2
import numpy as np
from uniface.constants import RetinaFaceWeights
from uniface.log import Logger
from .base import BaseDetector
```
#### Code Comments
- Add comments for complex logic, magic numbers, and non-obvious behavior
- Avoid comments that merely restate the code
- Use `# TODO:` with issue links for planned improvements
```python
# RetinaFace FPN strides and corresponding anchor sizes per level
steps = [8, 16, 32]
min_sizes = [[16, 32], [64, 128], [256, 512]]
# Add small epsilon to prevent division by zero
similarity = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-5)
```
## Running Tests
```bash
# Run all tests
pytest tests/
# Run with verbose output
pytest tests/ -v
# Run specific test file
pytest tests/test_scrfd.py
# Run with coverage
pytest tests/ --cov=uniface --cov-report=html
```
## Adding New Features
When adding a new model or feature:
1. **Create the model class** in the appropriate submodule (e.g., `uniface/detection/`)
2. **Add weight constants** to `uniface/constants.py` with URLs and SHA256 hashes
3. **Export in `__init__.py`** files at both module and package levels
4. **Write tests** in `tests/` directory
5. **Add example usage** in `tools/` or update existing notebooks
6. **Update documentation** if needed
## Examples
Example notebooks demonstrating library usage:
| Example | Notebook |
| ------------------ | ------------------------------------------------------------------- |
| Face Detection | [01_face_detection.ipynb](examples/01_face_detection.ipynb) |
| Face Alignment | [02_face_alignment.ipynb](examples/02_face_alignment.ipynb) |
| Face Verification | [03_face_verification.ipynb](examples/03_face_verification.ipynb) |
| Face Search | [04_face_search.ipynb](examples/04_face_search.ipynb) |
| Face Analyzer | [05_face_analyzer.ipynb](examples/05_face_analyzer.ipynb) |
| Face Parsing | [06_face_parsing.ipynb](examples/06_face_parsing.ipynb) |
| Face Anonymization | [07_face_anonymization.ipynb](examples/07_face_anonymization.ipynb) |
| Gaze Estimation | [08_gaze_estimation.ipynb](examples/08_gaze_estimation.ipynb) |
| 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
Releases are fully automated via GitHub Actions. Only maintainers with branch-protection bypass privileges on `main` can trigger a release.
### Cutting a release
1. Go to **Actions → Release Pipeline → Run workflow** on GitHub.
2. Enter the version following [PEP 440](https://peps.python.org/pep-0440/):
- Stable: `0.7.0`, `1.0.0`
- Pre-release: `0.7.0rc1`, `0.7.0b1`, `0.7.0a1`, `0.7.0.dev1`
3. Click **Run workflow**.
### What happens automatically
The `Release Pipeline` workflow runs all stages in sequence:
1. **Validate** — checks the version string against PEP 440 and confirms the tag does not already exist.
2. **Test** — runs the test suite on Python 3.103.14.
3. **Release** — updates `pyproject.toml` and `uniface/__init__.py`, commits `chore: Release vX.Y.Z` to `main`, creates and pushes tag `vX.Y.Z`.
4. **Publish** — builds the package, uploads to PyPI, and creates a GitHub Release (flagged as pre-release for `a`/`b`/`rc`/`.dev` versions).
5. **Deploy docs** — runs only for **stable** versions. Pre-releases do not update the live documentation site.
### Verifying a release
- PyPI: <https://pypi.org/project/uniface/>
- GitHub Releases: <https://github.com/yakhyo/uniface/releases>
- Docs (stable only): <https://yakhyo.github.io/uniface/>
### Installing a pre-release
End users can opt in to pre-releases with the `--pre` flag:
```bash
pip install uniface --pre # latest pre-release
pip install uniface==0.7.0rc1 # specific pre-release
```
Without `--pre`, `pip install uniface` always resolves to the latest stable version.
## Questions?
Open an issue or start a discussion on GitHub.

395
MODELS.md
View File

@@ -1,395 +0,0 @@
# UniFace Model Zoo
Complete guide to all available models, their performance characteristics, and selection criteria.
---
## Face Detection Models
### RetinaFace Family
RetinaFace models are trained on the WIDER FACE dataset and provide excellent accuracy-speed tradeoffs.
| Model Name | Params | Size | Easy | Medium | Hard | Use Case |
|---------------------|--------|--------|--------|--------|--------|----------------------------|
| `MNET_025` | 0.4M | 1.7MB | 88.48% | 87.02% | 80.61% | Mobile/Edge devices |
| `MNET_050` | 1.0M | 2.6MB | 89.42% | 87.97% | 82.40% | Mobile/Edge devices |
| `MNET_V1` | 3.5M | 3.8MB | 90.59% | 89.14% | 84.13% | Balanced mobile |
| `MNET_V2` ⭐ | 3.2M | 3.5MB | 91.70% | 91.03% | 86.60% | **Recommended default** |
| `RESNET18` | 11.7M | 27MB | 92.50% | 91.02% | 86.63% | Server/High accuracy |
| `RESNET34` | 24.8M | 56MB | 94.16% | 93.12% | 88.90% | Maximum accuracy |
**Accuracy**: WIDER FACE validation set (Easy/Medium/Hard subsets) - from [RetinaFace paper](https://arxiv.org/abs/1905.00641)
**Speed**: Benchmark on your own hardware using `scripts/run_detection.py --iterations 100`
#### Usage
```python
from uniface import RetinaFace
from uniface.constants import RetinaFaceWeights
# Default (recommended)
detector = RetinaFace() # Uses MNET_V2
# Specific model
detector = RetinaFace(
model_name=RetinaFaceWeights.MNET_025, # Fastest
conf_thresh=0.5,
nms_thresh=0.4,
input_size=(640, 640)
)
```
---
### SCRFD Family
SCRFD (Sample and Computation Redistribution for Efficient Face Detection) models offer state-of-the-art speed-accuracy tradeoffs.
| Model Name | Params | Size | Easy | Medium | Hard | Use Case |
|-----------------|--------|-------|--------|--------|--------|----------------------------|
| `SCRFD_500M` | 0.6M | 2.5MB | 90.57% | 88.12% | 68.51% | Real-time applications |
| `SCRFD_10G` ⭐ | 4.2M | 17MB | 95.16% | 93.87% | 83.05% | **High accuracy + speed** |
**Accuracy**: WIDER FACE validation set - from [SCRFD paper](https://arxiv.org/abs/2105.04714)
**Speed**: Benchmark on your own hardware using `scripts/run_detection.py --iterations 100`
#### Usage
```python
from uniface import SCRFD
from uniface.constants import SCRFDWeights
# Fast real-time detection
detector = SCRFD(
model_name=SCRFDWeights.SCRFD_500M_KPS,
conf_thresh=0.5,
input_size=(640, 640)
)
# High accuracy
detector = SCRFD(
model_name=SCRFDWeights.SCRFD_10G_KPS,
conf_thresh=0.5
)
```
---
## Face Recognition Models
### ArcFace
State-of-the-art face recognition using additive angular margin loss.
| Model Name | Backbone | Params | Size | Use Case |
|-------------|-------------|--------|-------|----------------------------|
| `MNET` ⭐ | MobileNet | 2.0M | 8MB | **Balanced (recommended)** |
| `RESNET` | ResNet50 | 43.6M | 166MB | Maximum accuracy |
**Dataset**: Trained on MS1M-V2 (5.8M images, 85K identities)
**Accuracy**: Benchmark on your own dataset or use standard face verification benchmarks
#### Usage
```python
from uniface import ArcFace
from uniface.constants import ArcFaceWeights
# Default (MobileNet backbone)
recognizer = ArcFace()
# High accuracy (ResNet50 backbone)
recognizer = ArcFace(model_name=ArcFaceWeights.RESNET)
# Extract embedding
embedding = recognizer.get_normalized_embedding(image, landmarks)
# Returns: (1, 512) normalized embedding vector
```
---
### MobileFace
Lightweight face recognition optimized for mobile devices.
| Model Name | Backbone | Params | Size | Use Case |
|-----------------|-----------------|--------|------|--------------------|
| `MNET_025` | MobileNetV1 0.25| 0.2M | 1MB | Ultra-lightweight |
| `MNET_V2` ⭐ | MobileNetV2 | 1.0M | 4MB | **Mobile/Edge** |
| `MNET_V3_SMALL` | MobileNetV3-S | 0.8M | 3MB | Mobile optimized |
| `MNET_V3_LARGE` | MobileNetV3-L | 2.5M | 10MB | Balanced mobile |
**Note**: These models are lightweight alternatives to ArcFace for resource-constrained environments
#### Usage
```python
from uniface import MobileFace
from uniface.constants import MobileFaceWeights
# Lightweight
recognizer = MobileFace(model_name=MobileFaceWeights.MNET_V2)
```
---
### SphereFace
Face recognition using angular softmax loss.
| Model Name | Backbone | Params | Size | Use Case |
|-------------|----------|--------|------|----------------------|
| `SPHERE20` | Sphere20 | 13.0M | 50MB | Research/Comparison |
| `SPHERE36` | Sphere36 | 24.2M | 92MB | Research/Comparison |
**Note**: SphereFace uses angular softmax loss, an earlier approach before ArcFace
#### Usage
```python
from uniface import SphereFace
from uniface.constants import SphereFaceWeights
recognizer = SphereFace(model_name=SphereFaceWeights.SPHERE20)
```
---
## Facial Landmark Models
### 106-Point Landmark Detection
High-precision facial landmark localization.
| Model Name | Points | Params | Size | Use Case |
|------------|--------|--------|------|-----------------------------|
| `2D106` | 106 | 3.7M | 14MB | Face alignment, analysis |
**Note**: Provides 106 facial keypoints for detailed face analysis and alignment
#### Usage
```python
from uniface import Landmark106
landmarker = Landmark106()
landmarks = landmarker.get_landmarks(image, bbox)
# Returns: (106, 2) array of (x, y) coordinates
```
**Landmark Groups:**
- Face contour: 0-32 (33 points)
- Eyebrows: 33-50 (18 points)
- Nose: 51-62 (12 points)
- Eyes: 63-86 (24 points)
- Mouth: 87-105 (19 points)
---
## Attribute Analysis Models
### Age & Gender Detection
| Model Name | Attributes | Params | Size | Use Case |
|------------|-------------|--------|------|-------------------|
| `DEFAULT` | Age, Gender | 2.1M | 8MB | General purpose |
**Dataset**: Trained on CelebA
**Note**: Accuracy varies by demographic and image quality. Test on your specific use case.
#### Usage
```python
from uniface import AgeGender
predictor = AgeGender()
gender, age = predictor.predict(image, bbox)
# Returns: ("Male"/"Female", age_in_years)
```
---
### Emotion Detection
| Model Name | Classes | Params | Size | Use Case |
|--------------|---------|--------|------|-----------------------|
| `AFFECNET7` | 7 | 0.5M | 2MB | 7-class emotion |
| `AFFECNET8` | 8 | 0.5M | 2MB | 8-class emotion |
**Classes (7)**: Neutral, Happy, Sad, Surprise, Fear, Disgust, Anger
**Classes (8)**: Above + Contempt
**Dataset**: Trained on AffectNet
**Note**: Emotion detection accuracy depends heavily on facial expression clarity and cultural context
#### Usage
```python
from uniface import Emotion
from uniface.constants import DDAMFNWeights
predictor = Emotion(model_name=DDAMFNWeights.AFFECNET7)
emotion, confidence = predictor.predict(image, landmarks)
```
---
## Model Selection Guide
### By Use Case
#### Mobile/Edge Devices
- **Detection**: `RetinaFace(MNET_025)` or `SCRFD(SCRFD_500M)`
- **Recognition**: `MobileFace(MNET_V2)`
- **Priority**: Speed, small model size
#### Real-Time Applications (Webcam, Video)
- **Detection**: `RetinaFace(MNET_V2)` or `SCRFD(SCRFD_500M)`
- **Recognition**: `ArcFace(MNET)`
- **Priority**: Speed-accuracy balance
#### High-Accuracy Applications (Security, Verification)
- **Detection**: `SCRFD(SCRFD_10G)` or `RetinaFace(RESNET34)`
- **Recognition**: `ArcFace(RESNET)`
- **Priority**: Maximum accuracy
#### Server/Cloud Deployment
- **Detection**: `SCRFD(SCRFD_10G)`
- **Recognition**: `ArcFace(RESNET)`
- **Priority**: Accuracy, batch processing
---
### By Hardware
#### Apple Silicon (M1/M2/M3/M4)
**Recommended**: All models work well with CoreML acceleration
```bash
pip install uniface[silicon]
```
**Recommended models**:
- **Fast**: `SCRFD(SCRFD_500M)` - Lightweight, real-time capable
- **Balanced**: `RetinaFace(MNET_V2)` - Good accuracy/speed tradeoff
- **Accurate**: `SCRFD(SCRFD_10G)` - High accuracy
**Benchmark on your M4**: `python scripts/run_detection.py --iterations 100`
#### NVIDIA GPU (CUDA)
**Recommended**: Larger models for maximum throughput
```bash
pip install uniface[gpu]
```
**Recommended models**:
- **Fast**: `SCRFD(SCRFD_500M)` - Maximum throughput
- **Balanced**: `SCRFD(SCRFD_10G)` - Best overall
- **Accurate**: `RetinaFace(RESNET34)` - Highest accuracy
#### CPU Only
**Recommended**: Lightweight models
**Recommended models**:
- **Fast**: `RetinaFace(MNET_025)` - Smallest, fastest
- **Balanced**: `RetinaFace(MNET_V2)` - Recommended default
- **Accurate**: `SCRFD(SCRFD_10G)` - Best accuracy on CPU
**Note**: FPS values vary significantly based on image size, number of faces, and hardware. Always benchmark on your specific setup.
---
## Benchmark Details
### How to Benchmark
Run benchmarks on your own hardware:
```bash
# Detection speed
python scripts/run_detection.py --image assets/test.jpg --iterations 100
# Compare models
python scripts/run_detection.py --image assets/test.jpg --method retinaface --iterations 100
python scripts/run_detection.py --image assets/test.jpg --method scrfd --iterations 100
```
### Accuracy Metrics Explained
- **WIDER FACE**: Standard face detection benchmark with three difficulty levels
- **Easy**: Large faces (>50px), clear backgrounds
- **Medium**: Medium-sized faces (30-50px), moderate occlusion
- **Hard**: Small faces (<30px), heavy occlusion, blur
*Accuracy values are from the original papers - see references below*
- **Model Size**: ONNX model file size (affects download time and memory)
- **Params**: Number of model parameters (affects inference speed)
### Important Notes
1. **Speed varies by**:
- Image resolution
- Number of faces in image
- Hardware (CPU/GPU/CoreML)
- Batch size
- Operating system
2. **Accuracy varies by**:
- Image quality
- Lighting conditions
- Face pose and occlusion
- Demographic factors
3. **Always benchmark on your specific use case** before choosing a model
---
## Model Updates
Models are automatically downloaded and cached on first use. Cache location: `~/.uniface/models/`
### Manual Model Management
```python
from uniface.model_store import verify_model_weights
from uniface.constants import RetinaFaceWeights
# Download specific model
model_path = verify_model_weights(
RetinaFaceWeights.MNET_V2,
root='./custom_cache'
)
# Models are verified with SHA-256 checksums
```
### Download All Models
```bash
# Using the provided script
python scripts/download_model.py
# Download specific model
python scripts/download_model.py --model MNET_V2
```
---
## References
### Model Training & Architectures
- **RetinaFace Training**: [yakhyo/retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch) - PyTorch implementation and training code
- **Face Recognition Training**: [yakhyo/face-recognition](https://github.com/yakhyo/face-recognition) - ArcFace, MobileFace, SphereFace training code
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface) - Model architectures and pretrained weights
### Papers
- **RetinaFace**: [Single-Shot Multi-Level Face Localisation in the Wild](https://arxiv.org/abs/1905.00641)
- **SCRFD**: [Sample and Computation Redistribution for Efficient Face Detection](https://arxiv.org/abs/2105.04714)
- **ArcFace**: [Additive Angular Margin Loss for Deep Face Recognition](https://arxiv.org/abs/1801.07698)
- **SphereFace**: [Deep Hypersphere Embedding for Face Recognition](https://arxiv.org/abs/1704.08063)

View File

@@ -1,355 +0,0 @@
# UniFace Quick Start Guide
Get up and running with UniFace in 5 minutes! This guide covers the most common use cases.
---
## Installation
```bash
# macOS (Apple Silicon)
pip install uniface[silicon]
# Linux/Windows with NVIDIA GPU
pip install uniface[gpu]
# CPU-only (all platforms)
pip install uniface
```
---
## 1. Face Detection (30 seconds)
Detect faces in an image:
```python
import cv2
from uniface import RetinaFace
# Load image
image = cv2.imread("photo.jpg")
# Initialize detector (models auto-download on first use)
detector = RetinaFace()
# Detect faces
faces = detector.detect(image)
# Print results
for i, face in enumerate(faces):
print(f"Face {i+1}:")
print(f" Confidence: {face['confidence']:.2f}")
print(f" BBox: {face['bbox']}")
print(f" Landmarks: {len(face['landmarks'])} points")
```
**Output:**
```
Face 1:
Confidence: 0.99
BBox: [120.5, 85.3, 245.8, 210.6]
Landmarks: 5 points
```
---
## 2. Visualize Detections (1 minute)
Draw bounding boxes and landmarks:
```python
import cv2
from uniface import RetinaFace
from uniface.visualization import draw_detections
# Detect faces
detector = RetinaFace()
image = cv2.imread("photo.jpg")
faces = detector.detect(image)
# Extract visualization data
bboxes = [f['bbox'] for f in faces]
scores = [f['confidence'] for f in faces]
landmarks = [f['landmarks'] for f in faces]
# Draw on image
draw_detections(image, bboxes, scores, landmarks, vis_threshold=0.6)
# Save result
cv2.imwrite("output.jpg", image)
print("Saved output.jpg")
```
---
## 3. Face Recognition (2 minutes)
Compare two faces:
```python
import cv2
import numpy as np
from uniface import RetinaFace, ArcFace
# Initialize models
detector = RetinaFace()
recognizer = ArcFace()
# Load two images
image1 = cv2.imread("person1.jpg")
image2 = cv2.imread("person2.jpg")
# Detect faces
faces1 = detector.detect(image1)
faces2 = detector.detect(image2)
if faces1 and faces2:
# Extract embeddings
emb1 = recognizer.get_normalized_embedding(image1, faces1[0]['landmarks'])
emb2 = recognizer.get_normalized_embedding(image2, faces2[0]['landmarks'])
# Compute similarity (cosine similarity)
similarity = np.dot(emb1, emb2.T)[0][0]
# Interpret result
if similarity > 0.6:
print(f"✅ Same person (similarity: {similarity:.3f})")
else:
print(f"❌ Different people (similarity: {similarity:.3f})")
else:
print("No faces detected")
```
**Similarity thresholds:**
- `> 0.6`: Same person (high confidence)
- `0.4 - 0.6`: Uncertain (manual review)
- `< 0.4`: Different people
---
## 4. Webcam Demo (2 minutes)
Real-time face detection:
```python
import cv2
from uniface import RetinaFace
from uniface.visualization import draw_detections
detector = RetinaFace()
cap = cv2.VideoCapture(0)
print("Press 'q' to quit")
while True:
ret, frame = cap.read()
if not ret:
break
# Detect faces
faces = detector.detect(frame)
# Draw results
bboxes = [f['bbox'] for f in faces]
scores = [f['confidence'] for f in faces]
landmarks = [f['landmarks'] for f in faces]
draw_detections(frame, bboxes, scores, landmarks)
# Show frame
cv2.imshow("UniFace - Press 'q' to quit", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
```
---
## 5. Age & Gender Detection (2 minutes)
Detect age and gender:
```python
import cv2
from uniface import RetinaFace, AgeGender
# Initialize models
detector = RetinaFace()
age_gender = AgeGender()
# Load image
image = cv2.imread("photo.jpg")
faces = detector.detect(image)
# Predict attributes
for i, face in enumerate(faces):
gender, age = age_gender.predict(image, face['bbox'])
print(f"Face {i+1}: {gender}, {age} years old")
```
**Output:**
```
Face 1: Male, 32 years old
Face 2: Female, 28 years old
```
---
## 6. Facial Landmarks (2 minutes)
Detect 106 facial landmarks:
```python
import cv2
from uniface import RetinaFace, Landmark106
# Initialize models
detector = RetinaFace()
landmarker = Landmark106()
# Detect face and landmarks
image = cv2.imread("photo.jpg")
faces = detector.detect(image)
if faces:
landmarks = landmarker.get_landmarks(image, faces[0]['bbox'])
print(f"Detected {len(landmarks)} landmarks")
# Draw landmarks
for x, y in landmarks.astype(int):
cv2.circle(image, (x, y), 2, (0, 255, 0), -1)
cv2.imwrite("landmarks.jpg", image)
```
---
## 7. Batch Processing (3 minutes)
Process multiple images:
```python
import cv2
from pathlib import Path
from uniface import RetinaFace
detector = RetinaFace()
# Process all images in a folder
image_dir = Path("images/")
output_dir = Path("output/")
output_dir.mkdir(exist_ok=True)
for image_path in image_dir.glob("*.jpg"):
print(f"Processing {image_path.name}...")
image = cv2.imread(str(image_path))
faces = detector.detect(image)
print(f" Found {len(faces)} face(s)")
# Save results
output_path = output_dir / image_path.name
# ... draw and save ...
print("Done!")
```
---
## 8. Model Selection
Choose the right model for your use case:
```python
from uniface import create_detector
from uniface.constants import RetinaFaceWeights, SCRFDWeights
# Fast detection (mobile/edge devices)
detector = create_detector(
'retinaface',
model_name=RetinaFaceWeights.MNET_025,
conf_thresh=0.7
)
# Balanced (recommended)
detector = create_detector(
'retinaface',
model_name=RetinaFaceWeights.MNET_V2
)
# High accuracy (server/GPU)
detector = create_detector(
'scrfd',
model_name=SCRFDWeights.SCRFD_10G_KPS,
conf_thresh=0.5
)
```
---
## Common Issues
### 1. Models Not Downloading
```python
# Manually download a model
from uniface.model_store import verify_model_weights
from uniface.constants import RetinaFaceWeights
model_path = verify_model_weights(RetinaFaceWeights.MNET_V2)
print(f"Model downloaded to: {model_path}")
```
### 2. Check Hardware Acceleration
```python
import onnxruntime as ort
print("Available providers:", ort.get_available_providers())
# macOS M-series should show: ['CoreMLExecutionProvider', ...]
# NVIDIA GPU should show: ['CUDAExecutionProvider', ...]
```
### 3. Slow Performance on Mac
Make sure you installed with CoreML support:
```bash
pip install uniface[silicon]
```
### 4. Import Errors
```python
# ✅ Correct imports
from uniface import RetinaFace, ArcFace, Landmark106
from uniface.detection import create_detector
# ❌ Wrong imports
from uniface import retinaface # Module, not class
```
---
## Next Steps
- **Detailed Examples**: Check the [examples/](examples/) folder for Jupyter notebooks
- **Model Benchmarks**: See [MODELS.md](MODELS.md) for performance comparisons
- **Full Documentation**: Read [README.md](README.md) for complete API reference
---
## References
- **RetinaFace Training**: [yakhyo/retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch)
- **Face Recognition Training**: [yakhyo/face-recognition](https://github.com/yakhyo/face-recognition)
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface)
---
Happy coding! 🚀

513
README.md
View File

@@ -1,447 +1,186 @@
# UniFace: All-in-One Face Analysis Library
<h1 align="center">UniFace: A Unified Face Analysis Library for Python</h1>
<div align="center">
[![PyPI Version](https://img.shields.io/pypi/v/uniface.svg?label=Version)](https://pypi.org/project/uniface/)
[![Python Version](https://img.shields.io/badge/Python-3.10%2B-blue)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
![Python](https://img.shields.io/badge/Python-3.10%2B-blue)
[![PyPI Version](https://img.shields.io/pypi/v/uniface.svg)](https://pypi.org/project/uniface/)
[![CI](https://github.com/yakhyo/uniface/actions/workflows/ci.yml/badge.svg)](https://github.com/yakhyo/uniface/actions)
[![Downloads](https://pepy.tech/badge/uniface)](https://pepy.tech/project/uniface)
[![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)
[![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 align="center">
<img src=".github/logos/logo_web.webp" width=75%>
</div>
**UniFace** is a lightweight, production-ready face analysis library built on ONNX Runtime. It provides high-performance face detection, recognition, landmark detection, and attribute analysis with hardware acceleration support across platforms.
<div align="center">
<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>
## Features
- **High-Speed Face Detection**: ONNX-optimized RetinaFace and SCRFD models
- **Facial Landmark Detection**: Accurate 106-point landmark localization
- **Face Recognition**: ArcFace, MobileFace, and SphereFace embeddings
- **Attribute Analysis**: Age, gender, and emotion detection
- **Face Alignment**: Precise alignment for downstream tasks
- **Hardware Acceleration**: CoreML (Apple Silicon), CUDA (NVIDIA), CPU fallback
- **Simple API**: Intuitive factory functions and clean interfaces
- **Production-Ready**: Type hints, comprehensive logging, PEP8 compliant
---
## Installation
### Quick Install (All Platforms)
<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
pip install "uniface[cpu]" # CPU and Apple Silicon
pip install "uniface[gpu]" # NVIDIA CUDA
pip install --pre "uniface[cpu]" # latest pre-release
```
### Platform-Specific Installation
<details>
<summary><b>A first script</b></summary>
#### macOS (Apple Silicon - M1/M2/M3/M4)
<br>
For optimal performance with **CoreML acceleration** (3-5x faster):
```bash
# Standard installation (CPU only)
pip install uniface
# With CoreML acceleration (recommended for M-series chips)
pip install uniface[silicon]
```
**Verify CoreML is available:**
```python
import onnxruntime as ort
print(ort.get_available_providers())
# Should show: ['CoreMLExecutionProvider', 'CPUExecutionProvider']
```
#### Linux/Windows with NVIDIA GPU
```bash
# With CUDA acceleration
pip install uniface[gpu]
```
**Requirements:**
- CUDA 11.x or 12.x
- cuDNN 8.x
- See [ONNX Runtime GPU requirements](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)
#### CPU-Only (All Platforms)
```bash
pip install uniface
```
### Install from Source
```bash
git clone https://github.com/yakhyo/uniface.git
cd uniface
pip install -e .
```
---
## Quick Start
### Face Detection
`FaceAnalyzer` runs detection, alignment and recognition in one call. Attribute models are opt-in.
```python
import cv2
from uniface import RetinaFace
from uniface import FaceAnalyzer, FairFace
# Initialize detector
detector = RetinaFace()
analyzer = FaceAnalyzer(predictors=[FairFace()])
# Load image
image = cv2.imread("image.jpg")
# Detect faces
faces = detector.detect(image)
# Process results
for face in faces:
bbox = face['bbox'] # [x1, y1, x2, y2]
confidence = face['confidence']
landmarks = face['landmarks'] # 5-point landmarks
print(f"Face detected with confidence: {confidence:.2f}")
for face in analyzer.analyze(cv2.imread("photo.jpg")):
print(face.bbox, face.sex, face.age_group, face.embedding.shape)
```
### Face Recognition
`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.
```python
from uniface import ArcFace, RetinaFace
from uniface import compute_similarity
</details>
# Initialize models
detector = RetinaFace()
recognizer = ArcFace()
<details>
<summary><b>All fifteen tasks, and which model does each</b></summary>
# Detect and extract embeddings
faces1 = detector.detect(image1)
faces2 = detector.detect(image2)
<br>
embedding1 = recognizer.get_normalized_embedding(image1, faces1[0]['landmarks'])
embedding2 = recognizer.get_normalized_embedding(image2, faces2[0]['landmarks'])
| 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 |
# Compare faces
similarity = compute_similarity(embedding1, embedding2)
print(f"Similarity: {similarity:.4f}")
```
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.
### Facial Landmarks
</details>
```python
from uniface import RetinaFace, Landmark106
<br>
detector = RetinaFace()
landmarker = Landmark106()
### Find and measure faces
faces = detector.detect(image)
landmarks = landmarker.get_landmarks(image, faces[0]['bbox'])
# Returns 106 (x, y) landmark points
```
**Face Detection** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/detection/)
### Age & Gender Detection
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/detection.jpg" width="100%">
```python
from uniface import RetinaFace, AgeGender
**Facial Landmarks** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/landmarks/)
detector = RetinaFace()
age_gender = AgeGender()
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/landmarks.jpg" width="100%">
faces = detector.detect(image)
gender, age = age_gender.predict(image, faces[0]['bbox'])
print(f"{gender}, {age} years old")
```
**Face Mesh** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/landmarks/#face-mesh-468-or-478-points-3d)
---
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_mesh.jpg" width="100%">
## Documentation
**Face Quality** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/quality/)
- [**QUICKSTART.md**](QUICKSTART.md) - 5-minute getting started guide
- [**MODELS.md**](MODELS.md) - Model zoo, benchmarks, and selection guide
- [**Examples**](examples/) - Jupyter notebooks with detailed examples
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/quality.jpg" width="100%">
---
### Cut faces out
## API Overview
**Face Parsing** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/parsing/)
### Factory Functions (Recommended)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/parsing.jpg" width="100%">
```python
from uniface import create_detector, create_recognizer, create_landmarker
**Face Segmentation** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/parsing/#xseg)
# Create detector with default settings
detector = create_detector('retinaface')
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation.jpg" width="100%">
# Create with custom config
detector = create_detector(
'scrfd',
model_name='scrfd_10g_kps',
conf_thresh=0.8,
input_size=(640, 640)
)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation_occluded.jpg" width="100%">
# Recognition and landmarks
recognizer = create_recognizer('arcface')
landmarker = create_landmarker('2d106det')
```
**Portrait Matting** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/matting/)
### Direct Model Instantiation
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting.jpg" width="100%">
```python
from uniface import RetinaFace, SCRFD, ArcFace, MobileFace
from uniface.constants import RetinaFaceWeights
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting_alt.jpg" width="100%">
# Detection
detector = RetinaFace(
model_name=RetinaFaceWeights.MNET_V2,
conf_thresh=0.5,
nms_thresh=0.4
)
### Read where a head is pointing
# Recognition
recognizer = ArcFace() # Uses default weights
recognizer = MobileFace() # Lightweight alternative
```
**Head Pose** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/headpose/)
### High-Level Detection API
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/headpose.jpg" width="100%">
```python
from uniface import detect_faces
**Gaze Estimation** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/gaze/)
# One-line face detection
faces = detect_faces(image, method='retinaface', conf_thresh=0.8)
```
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/gaze.jpg" width="100%">
---
### Read a face
## Model Performance
**Age and Sex** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/)
### Face Detection (WIDER FACE Dataset)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/demography.jpg" width="100%">
| Model | Easy | Medium | Hard | Use Case |
|--------------------|--------|--------|--------|-------------------------|
| retinaface_mnet025 | 88.48% | 87.02% | 80.61% | Mobile/Edge devices |
| retinaface_mnet_v2 | 91.70% | 91.03% | 86.60% | Balanced (recommended) |
| retinaface_r34 | 94.16% | 93.12% | 88.90% | High accuracy |
| scrfd_500m | 90.57% | 88.12% | 68.51% | Real-time applications |
| scrfd_10g | 95.16% | 93.87% | 83.05% | Best accuracy/speed |
**Emotion** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/#emotion)
*Accuracy values from original papers: [RetinaFace](https://arxiv.org/abs/1905.00641), [SCRFD](https://arxiv.org/abs/2105.04714)*
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/emotion.jpg" width="100%">
**Benchmark on your hardware:**
```bash
python scripts/run_detection.py --image assets/test.jpg --iterations 100
```
**Face States** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/attributes/#faceattribnet)
See [MODELS.md](MODELS.md) for detailed model information and selection guide.
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states.jpg" width="100%">
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states_alt.jpg" width="100%">
### Tell a real face from a replay
**Anti-Spoofing** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/spoofing/)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/spoofing.jpg" width="100%">
### Match a face, or hide one
**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%">
**Face Anonymization** &nbsp;·&nbsp; [docs](https://yakhyo.github.io/uniface/modules/privacy/)
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/anonymization.jpg" width="100%">
<br>
<div align="center">
<img src="assets/test_result.png">
**[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)
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.
</div>
---
## Examples
### Webcam Face Detection
```python
import cv2
from uniface import RetinaFace
from uniface.visualization import draw_detections
detector = RetinaFace()
cap = cv2.VideoCapture(0)
while True:
ret, frame = cap.read()
if not ret:
break
faces = detector.detect(frame)
# Extract data for visualization
bboxes = [f['bbox'] for f in faces]
scores = [f['confidence'] for f in faces]
landmarks = [f['landmarks'] for f in faces]
draw_detections(frame, bboxes, scores, landmarks, vis_threshold=0.6)
cv2.imshow("Face Detection", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
```
### Face Search System
```python
import numpy as np
from uniface import RetinaFace, ArcFace
detector = RetinaFace()
recognizer = ArcFace()
# Build face database
database = {}
for person_id, image_path in person_images.items():
image = cv2.imread(image_path)
faces = detector.detect(image)
if faces:
embedding = recognizer.get_normalized_embedding(
image, faces[0]['landmarks']
)
database[person_id] = embedding
# Search for a face
query_image = cv2.imread("query.jpg")
query_faces = detector.detect(query_image)
if query_faces:
query_embedding = recognizer.get_normalized_embedding(
query_image, query_faces[0]['landmarks']
)
# Find best match
best_match = None
best_similarity = -1
for person_id, db_embedding in database.items():
similarity = np.dot(query_embedding, db_embedding.T)[0][0]
if similarity > best_similarity:
best_similarity = similarity
best_match = person_id
print(f"Best match: {best_match} (similarity: {best_similarity:.4f})")
```
More examples in the [examples/](examples/) directory.
---
## Advanced Configuration
### Custom ONNX Runtime Providers
```python
from uniface.onnx_utils import get_available_providers, create_onnx_session
# Check available providers
providers = get_available_providers()
print(f"Available: {providers}")
# Force CPU-only execution
from uniface import RetinaFace
detector = RetinaFace()
# Internally uses create_onnx_session() which auto-selects best provider
```
### Model Download and Caching
Models are automatically downloaded on first use and cached in `~/.uniface/models/`.
```python
from uniface.model_store import verify_model_weights
from uniface.constants import RetinaFaceWeights
# Manually download and verify a model
model_path = verify_model_weights(
RetinaFaceWeights.MNET_V2,
root='./custom_models' # Custom cache directory
)
```
### Logging Configuration
```python
from uniface import Logger
import logging
# Set logging level
Logger.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR
# Disable logging
Logger.setLevel(logging.CRITICAL)
```
---
## Testing
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=uniface --cov-report=html
# Run specific test file
pytest tests/test_retinaface.py -v
```
---
## Development
### Setup Development Environment
```bash
git clone https://github.com/yakhyo/uniface.git
cd uniface
# Install in editable mode with dev dependencies
pip install -e ".[dev]"
# Run tests
pytest
# Format code
black uniface/
isort uniface/
```
### Project Structure
```
uniface/
├── uniface/
│ ├── detection/ # Face detection models
│ ├── recognition/ # Face recognition models
│ ├── landmark/ # Landmark detection
│ ├── attribute/ # Age, gender, emotion
│ ├── onnx_utils.py # ONNX Runtime utilities
│ ├── model_store.py # Model download & caching
│ └── visualization.py # Drawing utilities
├── tests/ # Unit tests
├── examples/ # Example notebooks
└── scripts/ # Utility scripts
```
---
## References
### Model Training & Architectures
- **RetinaFace Training**: [yakhyo/retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch) - PyTorch implementation and training code
- **Face Recognition Training**: [yakhyo/face-recognition](https://github.com/yakhyo/face-recognition) - ArcFace, MobileFace, SphereFace training code
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface) - Model architectures and pretrained weights
### Papers
- **RetinaFace**: [Single-Shot Multi-Level Face Localisation in the Wild](https://arxiv.org/abs/1905.00641)
- **SCRFD**: [Sample and Computation Redistribution for Efficient Face Detection](https://arxiv.org/abs/2105.04714)
- **ArcFace**: [Additive Angular Margin Loss for Deep Face Recognition](https://arxiv.org/abs/1801.07698)
---
## Contributing
Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/yakhyo/uniface).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 996 KiB

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

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

Binary file not shown.

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,243 @@
# Coordinate Systems
This page explains the coordinate formats used in UniFace.
---
## Image Coordinates
All coordinates use **pixel-based, top-left origin**:
```
(0, 0) ────────────────► x (width)
│ Image
y (height)
```
---
## Bounding Box Format
Bounding boxes use `[x1, y1, x2, y2]` format (top-left and bottom-right corners):
```
(x1, y1) ─────────────────┐
│ │
│ Face │
│ │
└─────────────────────┘ (x2, y2)
```
### Accessing Coordinates
```python
face = faces[0]
# Direct access
x1, y1, x2, y2 = face.bbox
# As properties
bbox_xyxy = face.bbox_xyxy # [x1, y1, x2, y2]
bbox_xywh = face.bbox_xywh # [x1, y1, width, height]
```
### Conversion
```python
import numpy as np
# xyxy → xywh
def xyxy_to_xywh(bbox):
x1, y1, x2, y2 = bbox
return np.array([x1, y1, x2 - x1, y2 - y1])
# xywh → xyxy
def xywh_to_xyxy(bbox):
x, y, w, h = bbox
return np.array([x, y, x + w, y + h])
```
---
## Landmarks
### 5-Point Landmarks (Detection)
Returned by all detection models:
```python
landmarks = face.landmarks # Shape: (5, 2)
```
| Index | Point |
|-------|-------|
| 0 | Left Eye |
| 1 | Right Eye |
| 2 | Nose Tip |
| 3 | Left Mouth Corner |
| 4 | Right Mouth Corner |
```
0 ● ● 1
● 2
3 ● ● 4
```
This ordering is the *alignment template* (`uniface.face_utils.reference_alignment`)
that recognition, quality scoring, and XSeg parsing all require.
!!! warning "BlazeFace uses a different layout"
`BlazeFace` returns 6 keypoints — right eye, left eye, nose tip, mouth **center**,
right ear tragion, left ear tragion — named from the subject's perspective, so rows
0/1 are still the viewer-left and viewer-right eye. With no mouth corners they
cannot be fitted to the template above, which is why `BlazeFace.supports_alignment`
is `False`.
### 106-Point Landmarks
Returned by `Landmark106`:
```python
from uniface.landmark import Landmark106
landmarker = Landmark106()
landmarks = landmarker.get_landmarks(image, face.bbox)
# Shape: (106, 2)
```
**Landmark Groups:**
| Range | Group | Points |
|-------|-------|--------|
| 0-32 | Face Contour | 33 |
| 33-50 | Eyebrows | 18 |
| 51-62 | Nose | 12 |
| 63-86 | Eyes | 24 |
| 87-105 | Mouth | 19 |
### 98 / 68-Point Landmarks (PIPNet)
Returned by `PIPNet`. The variant determines the layout:
```python
from uniface.constants import PIPNetWeights
from uniface.landmark import PIPNet
# 98-point WFLW layout (default)
landmarks = PIPNet().get_landmarks(image, face.bbox)
# Shape: (98, 2)
# 68-point 300W layout
landmarks = PIPNet(model_name=PIPNetWeights.DW300_CELEBA_68).get_landmarks(image, face.bbox)
# Shape: (68, 2)
```
The 98-point output follows the standard [WFLW](https://wywu.github.io/projects/LAB/WFLW.html) layout
(33 face-contour points, eyebrow/eye/nose/mouth groups). The 68-point output follows the standard
[300W / iBUG](https://ibug.doc.ic.ac.uk/resources/300-W/) layout. Coordinates are in original-image
pixel space, identical in convention to `Landmark106`.
### 468-Point Landmarks (Face Mesh)
`FaceMesh` is the only model in UniFace that returns **three** coordinates per point:
```python
from uniface import FaceMesh
results = FaceMesh().predict(image, faces)
results[0].landmarks # Shape: (468, 3)
```
| Axis | Meaning |
|------|---------|
| `x`, `y` | Original-image pixel coordinates, same convention as every other landmarker |
| `z` | **Relative** depth, on the same pixel scale as `x`/`y`. Smaller is closer to the camera |
`z` has no absolute origin — it is only meaningful *within* one face, for comparing which
features sit nearer the camera. It is not a distance measurement and is not comparable
between faces or between images. Use `results[0].points_2d` to drop it.
---
## Face Crop
To crop a face from an image:
```python
def crop_face(image, bbox, margin=0):
"""Crop face with optional margin."""
h, w = image.shape[:2]
x1, y1, x2, y2 = map(int, bbox)
# Add margin
if margin > 0:
bw, bh = x2 - x1, y2 - y1
x1 = max(0, x1 - int(bw * margin))
y1 = max(0, y1 - int(bh * margin))
x2 = min(w, x2 + int(bw * margin))
y2 = min(h, y2 + int(bh * margin))
return image[y1:y2, x1:x2]
# Usage
face_crop = crop_face(image, face.bbox, margin=0.1)
```
---
## Gaze Angles
Gaze estimation returns pitch and yaw in **radians**:
```python
result = gaze_estimator.estimate(face_crop)
# Angles in radians
pitch = result.pitch # Vertical: + = up, - = down
yaw = result.yaw # Horizontal: + = right, - = left
# Convert to degrees
import numpy as np
pitch_deg = np.degrees(pitch)
yaw_deg = np.degrees(yaw)
```
**Angle Reference:**
```
pitch = +90° (up)
yaw = -90° ────┼──── yaw = +90°
(left) │ (right)
pitch = -90° (down)
```
---
## Face Alignment
Face alignment uses 5-point landmarks to normalize face orientation:
```python
from uniface.face_utils import face_alignment
# Align face to standard template
aligned_face, _ = face_alignment(image, face.landmarks)
# Output: (112x112 aligned face image, inverse transform matrix)
```
The alignment transforms faces to a canonical pose for better recognition accuracy.
---
## Next Steps
- [Inputs & Outputs](inputs-outputs.md) - Data types reference
- [Recognition Module](../modules/recognition.md) - Face recognition details

View File

@@ -0,0 +1,236 @@
# Execution Providers
UniFace automatically runs each model on the best available hardware — Apple Silicon (CoreML), NVIDIA GPU (CUDA), or CPU. Under the hood this is handled by ONNX Runtime execution providers.
---
## Automatic Provider Selection
UniFace automatically selects the optimal execution provider based on available hardware:
```python
from uniface.detection import RetinaFace
# Automatically uses best available provider
detector = RetinaFace()
```
**Priority order:**
1. **CoreMLExecutionProvider** - Apple Silicon
2. **CUDAExecutionProvider** - NVIDIA GPU
3. **CPUExecutionProvider** - Fallback
---
## Explicit Provider Selection
You can specify which execution provider to use by passing the `providers` parameter:
```python
from uniface.detection import RetinaFace
from uniface.recognition import ArcFace
# Force CPU execution (even if GPU is available)
detector = RetinaFace(providers=['CPUExecutionProvider'])
recognizer = ArcFace(providers=['CPUExecutionProvider'])
# Use CUDA with CPU fallback
detector = RetinaFace(providers=['CUDAExecutionProvider', 'CPUExecutionProvider'])
```
All **ONNX-based** model classes accept the `providers` parameter:
- Detection: `RetinaFace`, `SCRFD`, `YOLOv5Face`, `YOLOv8Face`
- Recognition: `ArcFace`, `AdaFace`, `MobileFace`, `SphereFace`
- Landmarks: `Landmark106`, `PIPNet`
- Gaze: `MobileGaze`
- Parsing: `BiSeNet`, `XSeg`
- Attributes: `AgeGender`, `FairFace`
- Anti-Spoofing: `MiniFASNet`
!!! note "Non-ONNX components"
- **Emotion** uses TorchScript and selects its device automatically (`mps` / `cuda` / `cpu`). It does **not** accept the `providers` parameter.
- **BlurFace** is a pure OpenCV utility and does not load any model.
---
## Check Available Providers
```python
import onnxruntime as ort
providers = ort.get_available_providers()
print("Available providers:", providers)
```
**Example outputs:**
=== "macOS (Apple Silicon)"
```
['CoreMLExecutionProvider', 'CPUExecutionProvider']
```
=== "Linux (NVIDIA GPU)"
```
['CUDAExecutionProvider', 'CPUExecutionProvider']
```
=== "Windows (CPU)"
```
['CPUExecutionProvider']
```
---
## Platform-Specific Setup
### Apple Silicon (M1/M2/M3/M4)
No additional setup required. ARM64 optimizations are built into `onnxruntime`:
```bash
pip install "uniface[cpu]"
```
Verify ARM64:
```bash
python -c "import platform; print(platform.machine())"
# Should show: arm64
```
!!! tip "Performance"
Apple Silicon Macs use CoreML acceleration automatically, providing excellent performance for face analysis tasks.
---
### NVIDIA GPU (CUDA)
Install with GPU support (this installs `onnxruntime-gpu`, which already includes CPU fallback):
```bash
pip install "uniface[gpu]"
```
**Requirements:**
- CUDA 11.x or 12.x
- cuDNN 8.x
- Compatible NVIDIA driver
Verify CUDA:
```python
import onnxruntime as ort
if 'CUDAExecutionProvider' in ort.get_available_providers():
print("CUDA is available!")
else:
print("CUDA not available, using CPU")
```
---
### CPU Fallback
CPU execution is always available:
```bash
pip install "uniface[cpu]"
```
Works on all platforms without additional configuration.
---
## Internal API
For advanced use cases, you can access the provider utilities:
```python
from uniface.onnx_utils import get_available_providers, create_onnx_session
# Check available providers
providers = get_available_providers()
print(f"Available: {providers}")
# Models use create_onnx_session() internally
# which auto-selects the best provider
```
---
## Performance Tips
### 1. Use GPU When Available
For batch processing or real-time applications, GPU acceleration provides significant speedups:
```bash
pip install "uniface[gpu]"
```
### 2. Optimize Input Size
Smaller input sizes are faster but may reduce accuracy:
```python
from uniface.detection import RetinaFace
# Faster, lower accuracy
detector = RetinaFace(input_size=(320, 320))
# Balanced (default)
detector = RetinaFace(input_size=(640, 640))
```
### 3. Batch Processing
Process multiple images to maximize GPU utilization:
```python
# Process images in batch (GPU-efficient)
for image_path in image_paths:
image = cv2.imread(image_path)
faces = detector.detect(image)
# ...
```
---
## Troubleshooting
### CUDA Not Detected
1. Verify CUDA installation:
```bash
nvidia-smi
```
2. Check CUDA version compatibility with ONNX Runtime
3. Reinstall with GPU support:
```bash
pip uninstall onnxruntime onnxruntime-gpu -y
pip install "uniface[gpu]"
```
### Slow Performance on Mac
Verify you're using ARM64 Python (not Rosetta):
```bash
python -c "import platform; print(platform.machine())"
# Should show: arm64 (not x86_64)
```
---
## Next Steps
- [Model Cache & Offline](model-cache-offline.md) - Model management
- [Thresholds & Calibration](thresholds-calibration.md) - Tuning parameters

View File

@@ -0,0 +1,317 @@
# Inputs & Outputs
This page describes the data types used throughout UniFace.
---
## Input: Images
All models accept NumPy arrays in **BGR format** (OpenCV default):
```python
import cv2
# Load image (BGR format)
image = cv2.imread("photo.jpg")
print(f"Shape: {image.shape}") # (H, W, 3)
print(f"Dtype: {image.dtype}") # uint8
```
!!! warning "Color Format"
UniFace expects **BGR** format (OpenCV default). If using PIL or other libraries, convert first:
```python
from PIL import Image
import numpy as np
pil_image = Image.open("photo.jpg")
bgr_image = np.array(pil_image)[:, :, ::-1] # RGB → BGR
```
!!! warning "Dtype"
Images must be `uint8` in `[0, 255]`, three-channel. A float `[0, 1]` array or a
grayscale frame raises `ValueError` rather than returning quietly wrong results:
```python
detector.detect(image.astype(np.float32) / 255.0)
# ValueError: Expected dtype uint8, got float32. Scale to [0, 255] and cast with .astype(np.uint8).
detector.detect(cv2.cvtColor(image, cv2.COLOR_BGR2GRAY))
# ValueError: Expected a BGR image of shape (H, W, 3), got (480, 640). Convert with cv2.cvtColor.
```
Normalization is each model's job — pass the raw `cv2.imread` array through.
---
## Output: Face Dataclass
Detection returns a list of `Face` objects:
```python
from dataclasses import dataclass
import numpy as np
@dataclass
class Face:
# Required (from detection)
bbox: np.ndarray # [x1, y1, x2, y2]
confidence: float # 0.0 to 1.0
landmarks: np.ndarray # (5, 2) from detectors — except BlazeFace, which returns (6, 2).
# Dense landmarkers return (106, 2), (98, 2), (68, 2), (468, 3), or (478, 3).
# Optional (enriched by analyzers)
embedding: np.ndarray | None = None
gender: int | None = None # 0=Female, 1=Male
age: int | None = None # Years
age_group: str | None = None # "20-29", etc.
race: str | None = None # "East Asian", etc.
emotion: str | None = None # "Happy", etc.
emotion_confidence: float | None = None
left_eye_open: float | None = None # [0, 1] probability, from FaceAttribNet
right_eye_open: float | None = None # [0, 1] probability, from FaceAttribNet
eyeglasses: float | None = None # [0, 1] probability, from FaceAttribNet
mask: float | None = None # [0, 1] probability, from FaceAttribNet
sunglasses: float | None = None # [0, 1] probability, from FaceAttribNet
quality: float | None = None # [0, 1] quality score from eDifFIQA
track_id: int | None = None # Persistent ID from tracker
```
### Properties
```python
face = faces[0]
# Bounding box formats
face.bbox_xyxy # [x1, y1, x2, y2] - same as bbox
face.bbox_xywh # [x1, y1, width, height]
# Gender as string
face.sex # "Female" or "Male" (None if not predicted)
```
### Methods
```python
# Compute similarity with another face
similarity = face1.compute_similarity(face2)
# Convert to dictionary
face_dict = face.to_dict()
# Convert to JSON string
face_json = face.to_json(indent=2)
```
---
## Result Types
### GazeResult
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class GazeResult:
pitch: float # Vertical angle (radians), + = up
yaw: float # Horizontal angle (radians), + = right
```
**Usage:**
```python
import numpy as np
result = gaze_estimator.estimate(face_crop)
print(f"Pitch: {np.degrees(result.pitch):.1f}°")
print(f"Yaw: {np.degrees(result.yaw):.1f}°")
```
---
### HeadPoseResult
```python
@dataclass(frozen=True)
class HeadPoseResult:
pitch: float # Rotation around X-axis (degrees), + = looking down
yaw: float # Rotation around Y-axis (degrees), + = looking right
roll: float # Rotation around Z-axis (degrees), + = tilting clockwise
```
**Usage:**
```python
result = head_pose.estimate(face_crop)
print(f"Pitch: {result.pitch:.1f}°")
print(f"Yaw: {result.yaw:.1f}°")
print(f"Roll: {result.roll:.1f}°")
```
---
### SpoofingResult
```python
@dataclass(frozen=True)
class SpoofingResult:
is_real: bool # True = real, False = fake
confidence: float # 0.0 to 1.0
```
**Usage:**
```python
result = spoofer.predict(image, face.bbox)
label = "Real" if result.is_real else "Fake"
print(f"{label}: {result.confidence:.1%}")
```
---
### DemographyResult
```python
@dataclass(frozen=True)
class DemographyResult:
gender: int # 0=Female, 1=Male
age: int | None # Years (AgeGender model)
age_group: str | None # "20-29" (FairFace model)
race: str | None # Race label (FairFace model)
@property
def sex(self) -> str:
return "Female" if self.gender == 0 else "Male"
```
**Usage:**
```python
# AgeGender model
result = age_gender.predict(image, face)
print(f"{result.sex}, {result.age} years old")
# FairFace model
result = fairface.predict(image, face)
print(f"{result.sex}, {result.age_group}, {result.race}")
```
---
### EmotionResult
```python
@dataclass(frozen=True)
class EmotionResult:
emotion: str # "Happy", "Sad", etc.
confidence: float # 0.0 to 1.0
```
---
### QualityResult
```python
@dataclass(frozen=True)
class QualityResult:
score: float # 0.0 to 1.0, higher = better quality
```
---
### FaceStateResult
```python
@dataclass(frozen=True)
class FaceStateResult:
left_eye_open: float # Probability the left eye is open
right_eye_open: float # Probability the right eye is open
eyeglasses: float # Probability eyeglasses are present
mask: float # Probability a face mask is present
sunglasses: float # Probability sunglasses are present
```
The five values come from independent binary heads: they do not sum to 1 and several can
be high at once. Threshold each attribute separately; never `argmax`.
**Usage:**
```python
result = face_attrib_net.predict(image, face)
print(result.as_dict()) # {'left_eye_open': 0.98, ...}
print(result.labels(0.5)) # ['left_eye_open', 'right_eye_open', 'eyeglasses']
```
---
### FaceMeshResult
```python
@dataclass(frozen=True)
class FaceMeshResult:
landmarks: np.ndarray # (468, 3) or (478, 3); x, y in image pixels, z is relative depth
score: float # Face presence, 0.0 to 1.0
@property
def points_2d(self) -> np.ndarray:
return self.landmarks[:, :2] # (N, 2), depth dropped
```
`score` saturates near 1.0 for anything plausible. It confirms the model ran; it is not a
discriminative confidence, so do not threshold on it.
---
## Embeddings
Face recognition models return normalized 512-dimensional embeddings:
```python
embedding = recognizer.get_normalized_embedding(image, landmarks)
print(f"Shape: {embedding.shape}") # (512,)
print(f"Norm: {np.linalg.norm(embedding):.4f}") # ~1.0
```
### Similarity Computation
```python
from uniface.face_utils import compute_similarity
similarity = compute_similarity(embedding1, embedding2)
# Returns: float between -1 and 1 (cosine similarity)
```
---
## Parsing Masks
Face parsing returns a segmentation mask:
```python
mask = parser.parse(face_image)
print(f"Shape: {mask.shape}") # (H, W)
print(f"Classes: {np.unique(mask)}") # [0, 1, 2, ...]
```
**19 Classes:**
| ID | Class | ID | Class |
|----|-------|----|-------|
| 0 | Background | 10 | Nose |
| 1 | Skin | 11 | Mouth |
| 2 | Left Eyebrow | 12 | Upper Lip |
| 3 | Right Eyebrow | 13 | Lower Lip |
| 4 | Left Eye | 14 | Neck |
| 5 | Right Eye | 15 | Necklace |
| 6 | Eyeglasses | 16 | Cloth |
| 7 | Left Ear | 17 | Hair |
| 8 | Right Ear | 18 | Hat |
| 9 | Earring | | |
---
## Next Steps
- [Coordinate Systems](coordinate-systems.md) - Bbox and landmark formats
- [Thresholds & Calibration](thresholds-calibration.md) - Tuning confidence thresholds

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