2 Commits

Author SHA1 Message Date
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
47 changed files with 1410 additions and 982 deletions

View File

@@ -33,6 +33,8 @@ jobs:
matrix:
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

View File

@@ -54,7 +54,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
python-version: ["3.10", "3.11", "3.13"]
steps:
- name: Checkout code

111
README.md
View File

@@ -3,7 +3,7 @@
<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.11%2B-blue)](https://www.python.org/)
[![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)
[![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)
@@ -33,7 +33,7 @@
- **Gaze Estimation** — Real-time gaze direction with MobileGaze
- **Head Pose Estimation** — 3D head orientation (pitch, yaw, roll) with 6D rotation representation
- **Attribute Analysis** — Age, gender, race (FairFace), and emotion
- **Vector Indexing** — FAISS-backed embedding store for fast multi-identity search
- **Vector Store** — FAISS-backed embedding store for fast multi-identity search
- **Anti-Spoofing** — Face liveness detection with MiniFASNet
- **Face Anonymization** — 5 blur methods for privacy protection
- **Hardware Acceleration** — ARM64 (Apple Silicon), CUDA (NVIDIA), CPU
@@ -61,7 +61,7 @@ git clone https://github.com/yakhyo/uniface.git
cd uniface && pip install -e .
```
**FAISS vector indexing**
**FAISS vector store**
```bash
pip install faiss-cpu # or faiss-gpu for CUDA
@@ -127,14 +127,10 @@ for face in faces:
```python
import cv2
from uniface.analyzer import FaceAnalyzer
from uniface.detection import RetinaFace
from uniface.recognition import ArcFace
from uniface import FaceAnalyzer
detector = RetinaFace()
recognizer = ArcFace()
analyzer = FaceAnalyzer(detector, recognizer=recognizer)
# Zero-config: uses SCRFD (500M) + ArcFace (MobileNet) by default
analyzer = FaceAnalyzer()
image = cv2.imread("photo.jpg")
if image is None:
@@ -146,52 +142,18 @@ for face in faces:
print(face.bbox, face.embedding.shape if face.embedding is not None else None)
```
---
## Execution Providers (ONNX Runtime)
With attributes:
```python
from uniface.detection import RetinaFace
from uniface import FaceAnalyzer, AgeGender
# Force CPU-only inference
detector = RetinaFace(providers=["CPUExecutionProvider"])
analyzer = FaceAnalyzer(attributes=[AgeGender()])
faces = analyzer.analyze(image)
for face in faces:
print(f"{face.sex}, {face.age}y, embedding={face.embedding.shape}")
```
See more in the docs:
https://yakhyo.github.io/uniface/concepts/execution-providers/
---
## Documentation
Full documentation: https://yakhyo.github.io/uniface/
| Resource | Description |
|----------|-------------|
| [Quickstart](https://yakhyo.github.io/uniface/quickstart/) | Get up and running in 5 minutes |
| [Model Zoo](https://yakhyo.github.io/uniface/models/) | All models, benchmarks, and selection guide |
| [API Reference](https://yakhyo.github.io/uniface/modules/detection/) | Detailed module documentation |
| [Tutorials](https://yakhyo.github.io/uniface/recipes/image-pipeline/) | Step-by-step workflow examples |
| [Guides](https://yakhyo.github.io/uniface/concepts/overview/) | Architecture and design principles |
| [Datasets](https://yakhyo.github.io/uniface/datasets/) | Training data and evaluation benchmarks |
---
## Datasets
| Task | Training Dataset | Models |
|------|-----------------|--------|
| Detection | WIDER FACE | RetinaFace, SCRFD, YOLOv5-Face, YOLOv8-Face |
| Recognition | MS1MV2 | MobileFace, SphereFace |
| Recognition | WebFace600K | ArcFace |
| Recognition | WebFace4M / 12M | AdaFace |
| Gaze | Gaze360 | MobileGaze |
| Head Pose | 300W-LP | HeadPose (ResNet, MobileNet) |
| Parsing | CelebAMask-HQ | BiSeNet |
| Attributes | CelebA, FairFace, AffectNet | AgeGender, FairFace, Emotion |
> See [Datasets documentation](https://yakhyo.github.io/uniface/datasets/) for download links, benchmarks, and details.
---
## Jupyter Notebooks
@@ -209,6 +171,53 @@ Full documentation: https://yakhyo.github.io/uniface/
| [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/09_face_segmentation.ipynb) | Face segmentation with XSeg |
| [10_face_vector_store.ipynb](examples/10_face_vector_store.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/10_face_vector_store.ipynb) | FAISS-backed face database |
| [11_head_pose_estimation.ipynb](examples/11_head_pose_estimation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/11_head_pose_estimation.ipynb) | Head pose estimation (pitch, yaw, roll) |
| [12_face_recognition.ipynb](examples/12_face_recognition.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/12_face_recognition.ipynb) | Standalone face recognition pipeline |
---
## Documentation
Full documentation: https://yakhyo.github.io/uniface/
| Resource | Description |
|----------|-------------|
| [Quickstart](https://yakhyo.github.io/uniface/quickstart/) | Get up and running in 5 minutes |
| [Model Zoo](https://yakhyo.github.io/uniface/models/) | All models, benchmarks, and selection guide |
| [API Reference](https://yakhyo.github.io/uniface/modules/detection/) | Detailed module documentation |
| [Tutorials](https://yakhyo.github.io/uniface/recipes/image-pipeline/) | Step-by-step workflow examples |
| [Guides](https://yakhyo.github.io/uniface/concepts/overview/) | Architecture and design principles |
| [Datasets](https://yakhyo.github.io/uniface/datasets/) | Training data and evaluation benchmarks |
---
## Execution Providers (ONNX Runtime)
```python
from uniface.detection import RetinaFace
# Force CPU-only inference
detector = RetinaFace(providers=["CPUExecutionProvider"])
```
See more in the docs:
https://yakhyo.github.io/uniface/concepts/execution-providers/
---
## Datasets
| Task | Training Dataset | Models |
|------|-----------------|--------|
| Detection | WIDER FACE | RetinaFace, SCRFD, YOLOv5-Face, YOLOv8-Face |
| Recognition | MS1MV2 | MobileFace, SphereFace |
| Recognition | WebFace600K | ArcFace |
| Recognition | WebFace4M / 12M | AdaFace |
| Gaze | Gaze360 | MobileGaze |
| Head Pose | 300W-LP | HeadPose (ResNet, MobileNet) |
| Parsing | CelebAMask-HQ | BiSeNet |
| Attributes | CelebA, FairFace, AffectNet | AgeGender, FairFace, Emotion |
> See [Datasets documentation](https://yakhyo.github.io/uniface/datasets/) for download links, benchmarks, and details.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -33,7 +33,7 @@ graph TB
TRK[BYTETracker]
end
subgraph Indexing
subgraph Stores
IDX[FAISS Vector Store]
end
@@ -124,7 +124,7 @@ uniface/
├── headpose/ # Head pose estimation
├── spoofing/ # Anti-spoofing
├── privacy/ # Face anonymization
├── indexing/ # Vector indexing (FAISS)
├── stores/ # Vector stores (FAISS)
├── types.py # Dataclasses (Face, GazeResult, HeadPoseResult, etc.)
├── constants.py # Model weights and URLs
├── model_store.py # Model download and caching

View File

@@ -201,17 +201,11 @@ For drawing detections, filter by confidence:
```python
from uniface.draw import draw_detections
# Only draw high-confidence detections
bboxes = [f.bbox for f in faces if f.confidence > 0.7]
scores = [f.confidence for f in faces if f.confidence > 0.7]
landmarks = [f.landmarks for f in faces if f.confidence > 0.7]
# Only draw high-confidence detections (confidence ≥ vis_threshold)
draw_detections(
image=image,
bboxes=bboxes,
scores=scores,
landmarks=landmarks,
vis_threshold=0.6 # Additional visualization filter
faces=faces,
vis_threshold=0.7,
)
```

View File

@@ -32,7 +32,7 @@ ruff check . --fix
**Guidelines:**
- Line length: 120
- Python 3.11+ type hints
- Python 3.10+ type hints
- Google-style docstrings
---

View File

@@ -13,7 +13,7 @@ template: home.html
<p class="hero-subtitle">All-in-One Open-Source Face Analysis Library</p>
[![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.11%2B-blue)](https://www.python.org/)
[![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)
[![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)

View File

@@ -6,7 +6,7 @@ This guide covers all installation options for UniFace.
## Requirements
- **Python**: 3.11 or higher
- **Python**: 3.10 or higher
- **Operating Systems**: macOS, Linux, Windows
---
@@ -70,16 +70,16 @@ print("Available providers:", ort.get_available_providers())
---
### FAISS Vector Indexing
### FAISS Vector Store
For fast multi-identity face search using a FAISS index:
For fast multi-identity face search using a FAISS vector store:
```bash
pip install faiss-cpu # CPU
pip install faiss-gpu # NVIDIA GPU (CUDA)
```
See the [Indexing module](modules/indexing.md) for usage.
See the [Stores module](modules/stores.md) for usage.
---
@@ -128,7 +128,7 @@ UniFace has minimal dependencies:
| Package | Install extra | Purpose |
|---------|---------------|---------|
| `faiss-cpu` / `faiss-gpu` | `pip install faiss-cpu` | FAISS vector indexing |
| `faiss-cpu` / `faiss-gpu` | `pip install faiss-cpu` | FAISS vector store |
| `onnxruntime-gpu` | `uniface[gpu]` | CUDA acceleration |
| `torch` | `pip install torch` | Emotion model uses TorchScript |
| `torchvision` | `pip install torchvision` | Faster NMS for YOLO detectors |
@@ -159,11 +159,11 @@ print("Installation successful!")
### Import Errors
If you encounter import errors, ensure you're using Python 3.11+:
If you encounter import errors, ensure you're using Python 3.10+:
```bash
python --version
# Should show: Python 3.11.x or higher
# Should show: Python 3.10.x or higher
```
### Model Download Issues

View File

@@ -264,10 +264,8 @@ from uniface.draw import draw_detections
draw_detections(
image=image,
bboxes=[f.bbox for f in faces],
scores=[f.confidence for f in faces],
landmarks=[f.landmarks for f in faces],
vis_threshold=0.6
faces=faces,
vis_threshold=0.6,
)
cv2.imwrite("result.jpg", image)

View File

@@ -1,4 +1,4 @@
# Indexing
# Stores
FAISS-backed vector store for fast similarity search over embeddings.
@@ -12,7 +12,7 @@ FAISS-backed vector store for fast similarity search over embeddings.
## FAISS
```python
from uniface.indexing import FAISS
from uniface.stores import FAISS
```
A thin wrapper around a FAISS `IndexFlatIP` (inner-product) index. Vectors
@@ -134,7 +134,7 @@ loaded = store.load() # True if files exist
import cv2
from uniface.detection import RetinaFace
from uniface.recognition import ArcFace
from uniface.indexing import FAISS
from uniface.stores import FAISS
detector = RetinaFace()
recognizer = ArcFace()

View File

@@ -19,6 +19,7 @@ Run UniFace examples directly in your browser with Google Colab, or download and
| [Face Segmentation](https://github.com/yakhyo/uniface/blob/main/examples/09_face_segmentation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/09_face_segmentation.ipynb) | Face segmentation with XSeg |
| [Face Vector Store](https://github.com/yakhyo/uniface/blob/main/examples/10_face_vector_store.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/10_face_vector_store.ipynb) | FAISS-backed face database |
| [Head Pose Estimation](https://github.com/yakhyo/uniface/blob/main/examples/11_head_pose_estimation.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/11_head_pose_estimation.ipynb) | 3D head orientation estimation |
| [Face Recognition](https://github.com/yakhyo/uniface/blob/main/examples/12_face_recognition.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/12_face_recognition.ipynb) | Standalone face recognition pipeline |
---

View File

@@ -54,19 +54,8 @@ 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=image,
bboxes=bboxes,
scores=scores,
landmarks=landmarks,
vis_threshold=0.6,
)
draw_detections(image=image, faces=faces, vis_threshold=0.6)
# Save result
cv2.imwrite("output.jpg", image)
@@ -372,10 +361,7 @@ while True:
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks)
draw_detections(image=frame, faces=faces)
cv2.imshow("UniFace - Press 'q' to quit", frame)
@@ -507,7 +493,7 @@ from uniface.privacy import BlurFace
from uniface.spoofing import MiniFASNet
from uniface.tracking import BYTETracker
from uniface.analyzer import FaceAnalyzer
from uniface.indexing import FAISS # pip install faiss-cpu
from uniface.stores import FAISS # pip install faiss-cpu
from uniface.draw import draw_detections, draw_tracks
```

View File

@@ -52,7 +52,7 @@ python tools/search.py --reference ref.jpg --source 0 # webcam
## Vector Search (FAISS index)
For identifying faces against a database of many known people, use the
[`FAISS`](../modules/indexing.md) vector store.
[`FAISS`](../modules/stores.md) vector store.
!!! info "Install extra"
`bash
@@ -80,7 +80,7 @@ import cv2
from pathlib import Path
from uniface.detection import RetinaFace
from uniface.recognition import ArcFace
from uniface.indexing import FAISS
from uniface.stores import FAISS
detector = RetinaFace()
recognizer = ArcFace()
@@ -112,7 +112,7 @@ python tools/faiss_search.py build --faces-dir dataset/ --db-path ./my_index
import cv2
from uniface.detection import RetinaFace
from uniface.recognition import ArcFace
from uniface.indexing import FAISS
from uniface.stores import FAISS
detector = RetinaFace()
recognizer = ArcFace()
@@ -143,7 +143,7 @@ python tools/faiss_search.py run --db-path ./my_index --source 0 # webcam
### Manage the index
```python
from uniface.indexing import FAISS
from uniface.stores import FAISS
store = FAISS(db_path="./my_index")
store.load()
@@ -160,7 +160,7 @@ store.save()
## See Also
- [Indexing Module](../modules/indexing.md) - Full `FAISS` API reference
- [Stores Module](../modules/stores.md) - Full `FAISS` API reference
- [Recognition Module](../modules/recognition.md) - Face recognition details
- [Video & Webcam](video-webcam.md) - Real-time processing
- [Concepts: Thresholds](../concepts/thresholds-calibration.md) - Tuning similarity thresholds

View File

@@ -48,12 +48,7 @@ def process_image(image_path):
print(f" Face {i+1}: {attrs.sex}, {attrs.age} years old")
# Visualize
draw_detections(
image=image,
bboxes=[f.bbox for f in faces],
scores=[f.confidence for f in faces],
landmarks=[f.landmarks for f in faces]
)
draw_detections(image=image, faces=faces)
return image, results

View File

@@ -26,12 +26,7 @@ while True:
faces = detector.detect(frame)
draw_detections(
image=frame,
bboxes=[f.bbox for f in faces],
scores=[f.confidence for f in faces],
landmarks=[f.landmarks for f in faces]
)
draw_detections(image=frame, faces=faces)
cv2.imshow("Face Detection", frame)

View File

@@ -51,7 +51,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"3.2.0\n"
"3.3.0\n"
]
}
],
@@ -156,13 +156,8 @@
"faces = detector.detect(image)\n",
"print(f'Detected {len(faces)} face(s)')\n",
"\n",
"# Unpack face data for visualization\n",
"bboxes = [f.bbox for f in faces]\n",
"scores = [f.confidence for f in faces]\n",
"landmarks = [f.landmarks for f in faces]\n",
"\n",
"# Draw detections\n",
"draw_detections(image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=0.6, corner_bbox=True)\n",
"draw_detections(image=image, faces=faces, vis_threshold=0.6, corner_bbox=True)\n",
"\n",
"# Display result\n",
"output_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n",
@@ -210,11 +205,7 @@
"faces = detector.detect(image, max_num=2)\n",
"print(f'Detected {len(faces)} face(s)')\n",
"\n",
"bboxes = [f.bbox for f in faces]\n",
"scores = [f.confidence for f in faces]\n",
"landmarks = [f.landmarks for f in faces]\n",
"\n",
"draw_detections(image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=0.6, corner_bbox=True)\n",
"draw_detections(image=image, faces=faces, vis_threshold=0.6, corner_bbox=True)\n",
"\n",
"output_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n",
"display.display(Image.fromarray(output_image))"
@@ -257,11 +248,7 @@
"faces = detector.detect(image, max_num=5)\n",
"print(f'Detected {len(faces)} face(s)')\n",
"\n",
"bboxes = [f.bbox for f in faces]\n",
"scores = [f.confidence for f in faces]\n",
"landmarks = [f.landmarks for f in faces]\n",
"\n",
"draw_detections(image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=0.6, corner_bbox=True)\n",
"draw_detections(image=image, faces=faces, vis_threshold=0.6, corner_bbox=True)\n",
"\n",
"output_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n",
"display.display(Image.fromarray(output_image))"

View File

@@ -55,7 +55,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"3.2.0\n"
"3.3.0\n"
]
}
],
@@ -139,10 +139,7 @@
"\n",
" # Draw detections\n",
" bbox_image = image.copy()\n",
" bboxes = [f.bbox for f in faces]\n",
" scores = [f.confidence for f in faces]\n",
" landmarks = [f.landmarks for f in faces]\n",
" draw_detections(image=bbox_image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=0.6, corner_bbox=True)\n",
" draw_detections(image=bbox_image, faces=faces, vis_threshold=0.6, corner_bbox=True)\n",
"\n",
" # Align first detected face (returns aligned image and inverse transform matrix)\n",
" first_landmarks = faces[0].landmarks\n",

View File

@@ -44,7 +44,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"3.2.0\n"
"3.3.0\n"
]
}
],

View File

@@ -49,7 +49,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"3.2.0\n"
"3.3.0\n"
]
}
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -51,7 +51,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"UniFace version: 3.2.0\n"
"UniFace version: 3.3.0\n"
]
}
],

File diff suppressed because one or more lines are too long

View File

@@ -53,7 +53,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"UniFace version: 3.2.0\n"
"UniFace version: 3.3.0\n"
]
}
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -154,7 +154,7 @@ nav:
- Head Pose: modules/headpose.md
- Anti-Spoofing: modules/spoofing.md
- Privacy: modules/privacy.md
- Indexing: modules/indexing.md
- Stores: modules/stores.md
- Guides:
- Overview: concepts/overview.md
- Inputs & Outputs: concepts/inputs-outputs.md

View File

@@ -1,6 +1,6 @@
[project]
name = "uniface"
version = "3.2.0"
version = "3.3.0"
description = "UniFace: A Comprehensive Library for Face Detection, Recognition, Tracking, Landmark Analysis, Face Parsing, Gaze Estimation, Age, and Gender Detection"
readme = "README.md"
license = "MIT"
@@ -9,7 +9,7 @@ maintainers = [
{ name = "Yakhyokhuja Valikhujaev", email = "yakhyo9696@gmail.com" },
]
requires-python = ">=3.11,<3.15"
requires-python = ">=3.10,<3.15"
keywords = [
"face-detection",
"face-recognition",
@@ -34,6 +34,7 @@ classifiers = [
"Intended Audience :: Science/Research",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
@@ -44,7 +45,7 @@ dependencies = [
"numpy>=1.21.0",
"opencv-python>=4.5.0",
"onnxruntime>=1.16.0",
"scikit-image>=0.26.0",
"scikit-image>=0.22.0",
"scipy>=1.7.0",
"requests>=2.28.0",
"tqdm>=4.64.0",
@@ -73,7 +74,7 @@ uniface = ["py.typed"]
[tool.ruff]
line-length = 120
target-version = "py311"
target-version = "py310"
exclude = [
".git",
".ruff_cache",

View File

@@ -1,7 +1,7 @@
numpy>=1.21.0
opencv-python>=4.5.0
onnxruntime>=1.16.0
scikit-image>=0.26.0
scikit-image>=0.22.0
scipy>=1.7.0
requests>=2.28.0
tqdm>=4.64.0

View File

@@ -27,12 +27,17 @@ from uniface.draw import draw_detections
from uniface.recognition import ArcFace
def draw_face_info(image, face, face_id):
"""Draw face ID and attributes above bounding box."""
def draw_face_info(image, face):
"""Draw face attributes above bounding box."""
x1, y1, _x2, y2 = map(int, face.bbox)
lines = [f'ID: {face_id}', f'Conf: {face.confidence:.2f}']
if face.age and face.sex:
lines = []
if face.age is not None and face.sex is not None:
lines.append(f'{face.sex}, {face.age}y')
if face.emotion is not None:
lines.append(face.emotion)
if not lines:
return
for i, line in enumerate(lines):
y_pos = y1 - 10 - (len(lines) - 1 - i) * 25
@@ -95,13 +100,10 @@ def process_image(analyzer, image_path: str, save_dir: str = 'outputs', show_sim
status = 'Same' if sim > 0.4 else 'Different'
print(f' Face {i + 1} ↔ Face {j + 1}: {sim:.3f} ({status})')
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, corner_bbox=True)
draw_detections(image=image, faces=faces, corner_bbox=True)
for i, face in enumerate(faces, 1):
draw_face_info(image, face, i)
for face in faces:
draw_face_info(image, face)
os.makedirs(save_dir, exist_ok=True)
output_path = os.path.join(save_dir, f'{Path(image_path).stem}_analysis.jpg')
@@ -137,13 +139,10 @@ def process_video(analyzer, video_path: str, save_dir: str = 'outputs'):
frame_count += 1
faces = analyzer.analyze(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, corner_bbox=True)
draw_detections(image=frame, faces=faces, corner_bbox=True)
for i, face in enumerate(faces, 1):
draw_face_info(frame, face, i)
for face in faces:
draw_face_info(frame, face)
cv2.putText(frame, f'Faces: {len(faces)}', (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
out.write(frame)
@@ -173,13 +172,10 @@ def run_camera(analyzer, camera_id: int = 0):
faces = analyzer.analyze(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, corner_bbox=True)
draw_detections(image=frame, faces=faces, corner_bbox=True)
for i, face in enumerate(faces, 1):
draw_face_info(frame, face, i)
for face in faces:
draw_face_info(frame, face)
cv2.putText(frame, f'Faces: {len(faces)}', (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
cv2.imshow('Face Analyzer', frame)

View File

@@ -43,10 +43,7 @@ def process_image(
from uniface.draw import draw_detections
preview = image.copy()
bboxes = [face.bbox for face in faces]
scores = [face.confidence for face in faces]
landmarks = [face.landmarks for face in faces]
draw_detections(preview, bboxes, scores, landmarks)
draw_detections(image=preview, faces=faces)
cv2.imshow('Detections (Press any key to continue)', preview)
cv2.waitKey(0)

View File

@@ -52,12 +52,7 @@ def process_image(
if not faces:
return
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=image, faces=faces, vis_threshold=threshold, corner_bbox=True)
for i, face in enumerate(faces):
result = age_gender.predict(image, face)
@@ -104,12 +99,7 @@ def process_video(
frame_count += 1
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = age_gender.predict(frame, face)
@@ -143,12 +133,7 @@ def run_camera(detector, age_gender, camera_id: int = 0, threshold: float = 0.6)
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = age_gender.predict(frame, face)

View File

@@ -34,13 +34,7 @@ def process_image(detector, image_path: Path, output_path: Path, threshold: floa
faces = detector.detect(image)
# unpack face 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(
image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=image, faces=faces, vis_threshold=threshold, corner_bbox=True)
cv2.putText(
image,

View File

@@ -35,10 +35,7 @@ def process_image(detector, image_path: str, threshold: float = 0.6, save_dir: s
faces = detector.detect(image)
if faces:
bboxes = [face.bbox for face in faces]
scores = [face.confidence for face in faces]
landmarks = [face.landmarks for face in faces]
draw_detections(image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold)
draw_detections(image=image, faces=faces, vis_threshold=threshold)
os.makedirs(save_dir, exist_ok=True)
output_path = os.path.join(save_dir, f'{os.path.splitext(os.path.basename(image_path))[0]}_out.jpg')
@@ -89,14 +86,9 @@ def process_video(
faces = detector.detect(frame)
total_faces += len(faces)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame,
bboxes=bboxes,
scores=scores,
landmarks=landmarks,
faces=faces,
vis_threshold=threshold,
draw_score=True,
corner_bbox=True,
@@ -141,14 +133,9 @@ def run_camera(detector, camera_id: int = 0, threshold: float = 0.6):
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame,
bboxes=bboxes,
scores=scores,
landmarks=landmarks,
faces=faces,
vis_threshold=threshold,
draw_score=True,
corner_bbox=True,

View File

@@ -52,12 +52,7 @@ def process_image(
if not faces:
return
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=image, faces=faces, vis_threshold=threshold, corner_bbox=True)
for i, face in enumerate(faces):
result = emotion_predictor.predict(image, face)
@@ -104,12 +99,7 @@ def process_video(
frame_count += 1
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = emotion_predictor.predict(frame, face)
@@ -143,12 +133,7 @@ def run_camera(detector, emotion_predictor, camera_id: int = 0, threshold: float
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = emotion_predictor.predict(frame, face)

View File

@@ -52,12 +52,7 @@ def process_image(
if not faces:
return
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=image, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=image, faces=faces, vis_threshold=threshold, corner_bbox=True)
for i, face in enumerate(faces):
result = fairface.predict(image, face)
@@ -104,12 +99,7 @@ def process_video(
frame_count += 1
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = fairface.predict(frame, face)
@@ -143,12 +133,7 @@ def run_camera(detector, fairface, camera_id: int = 0, threshold: float = 0.6):
faces = detector.detect(frame)
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
draw_detections(
image=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, corner_bbox=True
)
draw_detections(image=frame, faces=faces, vis_threshold=threshold, corner_bbox=True)
for face in faces:
result = fairface.predict(frame, face)

View File

@@ -24,7 +24,7 @@ import cv2
from uniface import create_detector, create_recognizer
from uniface.draw import draw_corner_bbox, draw_text_label
from uniface.indexing import FAISS
from uniface.stores import FAISS
def _draw_face(image, bbox, text: str, color: tuple[int, int, int]) -> None:

View File

@@ -30,7 +30,7 @@ from __future__ import annotations
__license__ = 'MIT'
__author__ = 'Yakhyokhuja Valikhujaev'
__version__ = '3.2.0'
__version__ = '3.3.0'
import contextlib
@@ -60,7 +60,7 @@ from .types import AttributeResult, EmotionResult, Face, GazeResult, HeadPoseRes
# Optional: FAISS vector store (requires `pip install faiss-cpu`)
with contextlib.suppress(ImportError):
from .indexing import FAISS
from .stores import FAISS
__all__ = [
# Metadata
@@ -114,7 +114,7 @@ __all__ = [
'BYTETracker',
# Privacy
'BlurFace',
# Indexing (optional)
# Stores (optional)
'FAISS',
# Utilities
'Logger',

View File

@@ -4,6 +4,8 @@
from __future__ import annotations
from typing import Any
import numpy as np
from uniface.attribute.base import Attribute
@@ -14,6 +16,8 @@ from uniface.types import Face
__all__ = ['FaceAnalyzer']
_UNSET: Any = object()
class FaceAnalyzer:
"""Unified face analyzer combining detection, recognition, and attributes.
@@ -27,35 +31,52 @@ class FaceAnalyzer:
via the ``attributes`` list. Each predictor's ``predict(image, face)``
is called once per detected face, enriching the :class:`Face` in-place.
Args:
detector: Face detector instance for detecting faces in images.
recognizer: Optional face recognizer for extracting embeddings.
attributes: Optional list of ``Attribute`` predictors to run on
each detected face (e.g. ``[AgeGender(), FairFace(), Emotion()]``).
When called with no arguments, uses SCRFD (500M) for detection and
ArcFace (MobileNet) for recognition — the smallest and fastest variants.
Example:
>>> from uniface import RetinaFace, ArcFace, AgeGender, FaceAnalyzer
>>> detector = RetinaFace()
>>> recognizer = ArcFace()
>>> analyzer = FaceAnalyzer(detector, recognizer=recognizer, attributes=[AgeGender()])
Args:
detector: Face detector instance. Defaults to ``SCRFD(SCRFD_500M_KPS)``.
recognizer: Face recognizer for extracting embeddings.
Defaults to ``ArcFace(MNET)``. Pass ``None`` to disable recognition.
attributes: Optional list of ``Attribute`` predictors to run on
each detected face (e.g. ``[AgeGender()]``).
Examples:
>>> from uniface import FaceAnalyzer
>>> analyzer = FaceAnalyzer()
>>> faces = analyzer.analyze(image)
>>> from uniface import FaceAnalyzer, AgeGender
>>> analyzer = FaceAnalyzer(attributes=[AgeGender()])
>>> faces = analyzer.analyze(image)
"""
def __init__(
self,
detector: BaseDetector,
recognizer: BaseRecognizer | None = None,
detector: BaseDetector | None = None,
recognizer: BaseRecognizer | None = _UNSET,
attributes: list[Attribute] | None = None,
) -> None:
if detector is None:
from uniface.constants import SCRFDWeights
from uniface.detection import SCRFD
detector = SCRFD(model_name=SCRFDWeights.SCRFD_500M_KPS)
if recognizer is _UNSET:
from uniface.recognition import ArcFace
recognizer = ArcFace()
self.detector = detector
self.recognizer = recognizer
self.attributes: list[Attribute] = attributes or []
Logger.info(f'Initialized FaceAnalyzer with detector={detector.__class__.__name__}')
if recognizer:
Logger.info(f' - Recognition enabled: {recognizer.__class__.__name__}')
Logger.info(f'Recognition enabled: {recognizer.__class__.__name__}')
for attr in self.attributes:
Logger.info(f' - Attribute enabled: {attr.__class__.__name__}')
Logger.info(f'Attribute enabled: {attr.__class__.__name__}')
def analyze(self, image: np.ndarray) -> list[Face]:
"""Analyze faces in an image.
@@ -76,17 +97,17 @@ class FaceAnalyzer:
if self.recognizer is not None:
try:
face.embedding = self.recognizer.get_normalized_embedding(image, face.landmarks)
Logger.debug(f' Face {idx + 1}: Extracted embedding with shape {face.embedding.shape}')
Logger.debug(f'Face {idx + 1}: Extracted embedding with shape {face.embedding.shape}')
except Exception as e:
Logger.warning(f' Face {idx + 1}: Failed to extract embedding: {e}')
Logger.warning(f'Face {idx + 1}: Failed to extract embedding: {e}')
for attr in self.attributes:
attr_name = attr.__class__.__name__
try:
attr.predict(image, face)
Logger.debug(f' Face {idx + 1}: {attr_name} prediction succeeded')
Logger.debug(f'Face {idx + 1}: {attr_name} prediction succeeded')
except Exception as e:
Logger.warning(f' Face {idx + 1}: {attr_name} prediction failed: {e}')
Logger.warning(f'Face {idx + 1}: {attr_name} prediction failed: {e}')
Logger.info(f'Analysis complete: {len(faces)} face(s) processed')
return faces

View File

@@ -232,9 +232,10 @@ def draw_text_label(
def draw_detections(
*,
image: np.ndarray,
bboxes: list[np.ndarray] | list[list[float]],
scores: np.ndarray | list[float],
landmarks: list[np.ndarray] | list[list[list[float]]],
faces: list[Face] | None = None,
bboxes: list[np.ndarray] | list[list[float]] | None = None,
scores: np.ndarray | list[float] | None = None,
landmarks: list[np.ndarray] | list[list[list[float]]] | None = None,
vis_threshold: float = 0.6,
draw_score: bool = False,
corner_bbox: bool = True,
@@ -243,17 +244,31 @@ def draw_detections(
Modifies the image in-place.
Accepts either a list of :class:`Face` objects (preferred) or separate
lists of bboxes, scores, and landmarks for backward compatibility.
Args:
image: Input image to draw on (modified in-place).
faces: List of Face objects from detection. When provided,
``bboxes``, ``scores``, and ``landmarks`` are ignored.
bboxes: List of bounding boxes in xyxy format ``[x1, y1, x2, y2]``.
scores: List of confidence scores.
landmarks: List of landmark sets with shape ``(5, 2)``.
vis_threshold: Confidence threshold for filtering. Defaults to 0.6.
draw_score: Whether to draw confidence scores. Defaults to False.
corner_bbox: Use corner-style bounding boxes. Defaults to True.
"""
# Adaptive line thickness
Examples:
>>> draw_detections(image=image, faces=faces)
>>> draw_detections(image=image, faces=faces, vis_threshold=0.7, draw_score=True)
"""
if faces is not None:
bboxes = [f.bbox for f in faces]
scores = [f.confidence for f in faces]
landmarks = [f.landmarks for f in faces]
elif bboxes is None or scores is None or landmarks is None:
raise ValueError('Provide either faces or all of bboxes, scores, and landmarks')
line_thickness = max(round(sum(image.shape[:2]) / 2 * 0.003), 2)
for i, score in enumerate(scores):
@@ -262,13 +277,11 @@ def draw_detections(
bbox = np.array(bboxes[i], dtype=np.int32)
# Draw bounding box
if corner_bbox:
draw_corner_bbox(image, bbox, color=(0, 255, 0), thickness=line_thickness, proportion=0.2)
else:
cv2.rectangle(image, tuple(bbox[:2]), tuple(bbox[2:]), (0, 255, 0), line_thickness)
# Draw confidence score label
if draw_score:
font_scale = max(0.4, min(0.7, (bbox[3] - bbox[1]) / 200))
draw_text_label(
@@ -281,7 +294,6 @@ def draw_detections(
font_scale=font_scale,
)
# Draw landmarks
landmark_set = np.array(landmarks[i], dtype=np.int32)
for j, point in enumerate(landmark_set):
cv2.circle(image, tuple(point), line_thickness + 1, _LANDMARK_COLORS[j % len(_LANDMARK_COLORS)], -1)

View File

@@ -71,7 +71,13 @@ def estimate_norm(
alignment[:, 0] += diff_x
# Compute the transformation matrix
transform = SimilarityTransform.from_estimate(landmark, alignment)
try:
# scikit-image >= 0.26
transform = SimilarityTransform.from_estimate(landmark, alignment)
except AttributeError:
# scikit-image < 0.26 (e.g. Python 3.10 with older scikit-image)
transform = SimilarityTransform()
transform.estimate(landmark, alignment)
matrix = transform.params[0:2, :]
inverse_matrix = np.linalg.inv(transform.params)[0:2, :]

View File

@@ -1,9 +0,0 @@
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Vector indexing backends for fast similarity search."""
from uniface.indexing.faiss import FAISS
__all__ = ['FAISS']

View File

@@ -0,0 +1,10 @@
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Vector store backends for fast face embedding similarity search."""
from uniface.stores.base import BaseStore
from uniface.stores.faiss import FAISS
__all__ = ['BaseStore', 'FAISS']

69
uniface/stores/base.py Normal file
View File

@@ -0,0 +1,69 @@
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Abstract base class for vector store backends."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import numpy as np
__all__ = ['BaseStore']
Metadata = dict[str, Any]
class BaseStore(ABC):
"""Abstract interface for face embedding vector stores.
All vector store backends (FAISS, Qdrant, etc.) must implement
this interface to ensure consistent usage across the library.
Embeddings are expected to be L2-normalised so that inner product
equals cosine similarity.
"""
@abstractmethod
def add(self, embedding: np.ndarray, metadata: Metadata) -> None:
"""Add a single embedding with associated metadata.
Args:
embedding: L2-normalised embedding vector.
metadata: Arbitrary dict of JSON-serialisable key-value pairs.
"""
@abstractmethod
def search(
self,
embedding: np.ndarray,
threshold: float = 0.4,
) -> tuple[Metadata | None, float]:
"""Find the closest match for a query embedding.
Args:
embedding: L2-normalised query vector.
threshold: Minimum similarity to accept a match.
Returns:
``(metadata, similarity)`` for the best match, or
``(None, similarity)`` when below *threshold* or empty.
"""
@abstractmethod
def remove(self, key: str, value: Any) -> int:
"""Remove all entries where ``metadata[key] == value``.
Args:
key: Metadata key to match against.
value: Value to match.
Returns:
Number of entries removed.
"""
@abstractmethod
def __len__(self) -> int:
"""Return the number of vectors in the store."""

View File

@@ -12,9 +12,9 @@ import numpy as np
from uniface.log import Logger
__all__ = ['FAISS']
from .base import BaseStore, Metadata
Metadata = dict[str, Any]
__all__ = ['FAISS']
def _import_faiss():
@@ -34,7 +34,7 @@ def _import_faiss():
return faiss
class FAISS:
class FAISS(BaseStore):
"""FAISS vector store using IndexFlatIP (inner product).
Vectors must be L2-normalised **before** being added so that inner
@@ -49,7 +49,7 @@ class FAISS:
db_path: Directory for persisting the index and metadata.
Example:
>>> from uniface.indexing import FAISS
>>> from uniface.stores import FAISS
>>> store = FAISS(embedding_size=512, db_path='./my_index')
>>> store.add(embedding, {'person_id': '001', 'name': 'Alice'})
>>> result, score = store.search(query_embedding)