diff --git a/MODELS.md b/MODELS.md
index 64cbaa2..101176a 100644
--- a/MODELS.md
+++ b/MODELS.md
@@ -259,9 +259,40 @@ landmarks = landmarker.get_landmarks(image, bbox)
from uniface import AgeGender
predictor = AgeGender()
-gender, age = predictor.predict(image, bbox)
-# Returns: (gender, age_in_years)
-# gender: 0 for Female, 1 for Male
+result = predictor.predict(image, bbox)
+# Returns: AttributeResult with gender, age, sex property
+# result.gender: 0 for Female, 1 for Male
+# result.sex: "Female" or "Male"
+# result.age: age in years
+```
+
+---
+
+### FairFace Attributes
+
+| Model Name | Attributes | Params | Size | Use Case |
+| ----------- | --------------------- | ------ | ----- | --------------------------- |
+| `DEFAULT` | Race, Gender, Age Group | - | 44MB | Balanced demographic prediction |
+
+**Dataset**: Trained on FairFace dataset with balanced demographics
+**Note**: FairFace provides more equitable predictions across different racial and gender groups
+
+**Race Categories (7):** White, Black, Latino Hispanic, East Asian, Southeast Asian, Indian, Middle Eastern
+
+**Age Groups (9):** 0-2, 3-9, 10-19, 20-29, 30-39, 40-49, 50-59, 60-69, 70+
+
+#### Usage
+
+```python
+from uniface import FairFace
+
+predictor = FairFace()
+result = predictor.predict(image, bbox)
+# Returns: AttributeResult with gender, age_group, race, sex property
+# result.gender: 0 for Female, 1 for Male
+# result.sex: "Female" or "Male"
+# result.age_group: "20-29", "30-39", etc.
+# result.race: "East Asian", "White", etc.
```
---
@@ -487,6 +518,7 @@ python scripts/download_model.py --model MNET_V2
- **Gaze Estimation Training**: [yakhyo/gaze-estimation](https://github.com/yakhyo/gaze-estimation) - MobileGaze training code and pretrained weights
- **Face Parsing Training**: [yakhyo/face-parsing](https://github.com/yakhyo/face-parsing) - BiSeNet training code and pretrained weights
- **Face Anti-Spoofing**: [yakhyo/face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing) - MiniFASNet ONNX inference (weights from [minivision-ai/Silent-Face-Anti-Spoofing](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing))
+- **FairFace**: [yakhyo/fairface-onnx](https://github.com/yakhyo/fairface-onnx) - FairFace ONNX inference for race, gender, age prediction
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface) - Model architectures and pretrained weights
### Papers
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 564bef5..aa8e57c 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -199,9 +199,11 @@ faces = detector.detect(image)
# Predict attributes
for i, face in enumerate(faces):
- gender, age = age_gender.predict(image, face.bbox)
- gender_str = 'Female' if gender == 0 else 'Male'
- print(f"Face {i+1}: {gender_str}, {age} years old")
+ result = age_gender.predict(image, face.bbox)
+ print(f"Face {i+1}: {result.sex}, {result.age} years old")
+ # result.gender: 0=Female, 1=Male
+ # result.sex: "Female" or "Male"
+ # result.age: age in years
```
**Output:**
@@ -213,6 +215,45 @@ Face 2: Female, 28 years old
---
+## 5b. FairFace Attributes (2 minutes)
+
+Detect race, gender, and age group with balanced demographics:
+
+```python
+import cv2
+from uniface import RetinaFace, FairFace
+
+# Initialize models
+detector = RetinaFace()
+fairface = FairFace()
+
+# Load image
+image = cv2.imread("photo.jpg")
+faces = detector.detect(image)
+
+# Predict attributes
+for i, face in enumerate(faces):
+ result = fairface.predict(image, face.bbox)
+ print(f"Face {i+1}: {result.sex}, {result.age_group}, {result.race}")
+ # result.gender: 0=Female, 1=Male
+ # result.sex: "Female" or "Male"
+ # result.age_group: "20-29", "30-39", etc.
+ # result.race: "East Asian", "White", etc.
+```
+
+**Output:**
+
+```
+Face 1: Male, 30-39, East Asian
+Face 2: Female, 20-29, White
+```
+
+**Race Categories:** White, Black, Latino Hispanic, East Asian, Southeast Asian, Indian, Middle Eastern
+
+**Age Groups:** 0-2, 3-9, 10-19, 20-29, 30-39, 40-49, 50-59, 60-69, 70+
+
+---
+
## 6. Facial Landmarks (2 minutes)
Detect 106 facial landmarks:
@@ -650,4 +691,5 @@ Explore interactive examples for common tasks:
- **Face Recognition Training**: [yakhyo/face-recognition](https://github.com/yakhyo/face-recognition)
- **Gaze Estimation Training**: [yakhyo/gaze-estimation](https://github.com/yakhyo/gaze-estimation)
- **Face Parsing Training**: [yakhyo/face-parsing](https://github.com/yakhyo/face-parsing)
+- **FairFace**: [yakhyo/fairface-onnx](https://github.com/yakhyo/fairface-onnx) - Race, gender, age prediction
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface)
diff --git a/README.md b/README.md
index 814a7b0..36cc88b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://opensource.org/licenses/MIT)
-[](https://www.python.org/)
+[](https://www.python.org/)
[](https://pypi.org/project/uniface/)
[](https://github.com/yakhyo/uniface/actions)
[](https://pepy.tech/project/uniface)
@@ -26,7 +26,7 @@
- **Face Recognition**: ArcFace, MobileFace, and SphereFace embeddings
- **Face Parsing**: BiSeNet-based semantic segmentation with 19 facial component classes
- **Gaze Estimation**: Real-time gaze direction prediction with MobileGaze
-- **Attribute Analysis**: Age, gender, and emotion detection
+- **Attribute Analysis**: Age, gender, race (FairFace), and emotion detection
- **Anti-Spoofing**: Face liveness detection with MiniFASNet models
- **Face Anonymization**: Privacy-preserving face blurring with 5 methods (pixelate, gaussian, blackout, elliptical, median)
- **Face Alignment**: Precise alignment for downstream tasks
@@ -155,9 +155,28 @@ detector = RetinaFace()
age_gender = AgeGender()
faces = detector.detect(image)
-gender, age = age_gender.predict(image, faces[0].bbox)
-gender_str = 'Female' if gender == 0 else 'Male'
-print(f"{gender_str}, {age} years old")
+result = age_gender.predict(image, faces[0].bbox)
+print(f"{result.sex}, {result.age} years old")
+# result.gender: 0=Female, 1=Male
+# result.sex: "Female" or "Male"
+# result.age: age in years
+```
+
+### FairFace Attributes (Race, Gender, Age Group)
+
+```python
+from uniface import RetinaFace, FairFace
+
+detector = RetinaFace()
+fairface = FairFace()
+
+faces = detector.detect(image)
+result = fairface.predict(image, faces[0].bbox)
+print(f"{result.sex}, {result.age_group}, {result.race}")
+# result.gender: 0=Female, 1=Male
+# result.sex: "Female" or "Male"
+# result.age_group: "20-29", "30-39", etc.
+# result.race: "East Asian", "White", etc.
```
### Gaze Estimation
@@ -372,7 +391,8 @@ faces = detect_faces(image, method='retinaface', conf_thresh=0.8) # methods: re
| Class | Key params (defaults) | Notes |
| --------------- | --------------------------------------------------------------------- | --------------------------------------- |
| `Landmark106` | No required params | 106-point landmarks |
-| `AgeGender` | `model_name=AgeGenderWeights.DEFAULT`; `input_size` auto-detected | Requires bbox; ONNXRuntime |
+| `AgeGender` | `model_name=AgeGenderWeights.DEFAULT`; `input_size` auto-detected | Returns `AttributeResult` with gender, age |
+| `FairFace` | `model_name=FairFaceWeights.DEFAULT`, `input_size=(224, 224)` | Returns `AttributeResult` with gender, age_group, race |
| `Emotion` | `model_weights=DDAMFNWeights.AFFECNET7`, `input_size=(112, 112)` | Requires 5-point landmarks; TorchScript |
**Gaze Estimation**
@@ -655,6 +675,7 @@ uniface/
- **Face Parsing Training**: [yakhyo/face-parsing](https://github.com/yakhyo/face-parsing) - BiSeNet face parsing training code and pretrained weights
- **Gaze Estimation Training**: [yakhyo/gaze-estimation](https://github.com/yakhyo/gaze-estimation) - MobileGaze training code and pretrained weights
- **Face Anti-Spoofing**: [yakhyo/face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing) - MiniFASNet ONNX inference (weights from [minivision-ai/Silent-Face-Anti-Spoofing](https://github.com/minivision-ai/Silent-Face-Anti-Spoofing))
+- **FairFace**: [yakhyo/fairface-onnx](https://github.com/yakhyo/fairface-onnx) - FairFace ONNX inference for race, gender, age prediction
- **InsightFace**: [deepinsight/insightface](https://github.com/deepinsight/insightface) - Model architectures and pretrained weights
## Contributing
diff --git a/scripts/run_age_gender.py b/scripts/run_age_gender.py
index 6a883f3..d2e5583 100644
--- a/scripts/run_age_gender.py
+++ b/scripts/run_age_gender.py
@@ -12,11 +12,10 @@ from uniface import SCRFD, AgeGender, RetinaFace
from uniface.visualization import draw_detections
-def draw_age_gender_label(image, bbox, gender_id: int, age: int):
+def draw_age_gender_label(image, bbox, sex: str, age: int):
"""Draw age/gender label above the bounding box."""
x1, y1 = int(bbox[0]), int(bbox[1])
- gender_str = 'Female' if gender_id == 0 else 'Male'
- text = f'{gender_str}, {age}y'
+ text = f'{sex}, {age}y'
(tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
cv2.rectangle(image, (x1, y1 - th - 10), (x1 + tw + 10, y1), (0, 255, 0), -1)
cv2.putText(image, text, (x1 + 5, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)
@@ -48,10 +47,9 @@ def process_image(
)
for i, face in enumerate(faces):
- gender_id, age = age_gender.predict(image, face['bbox'])
- gender_str = 'Female' if gender_id == 0 else 'Male'
- print(f' Face {i + 1}: {gender_str}, {age} years old')
- draw_age_gender_label(image, face['bbox'], gender_id, age)
+ result = age_gender.predict(image, face['bbox'])
+ print(f' Face {i + 1}: {result.sex}, {result.age} years old')
+ draw_age_gender_label(image, face['bbox'], result.sex, result.age)
os.makedirs(save_dir, exist_ok=True)
output_path = os.path.join(save_dir, f'{Path(image_path).stem}_age_gender.jpg')
@@ -84,8 +82,8 @@ def run_webcam(detector, age_gender, threshold: float = 0.6):
)
for face in faces:
- gender_id, age = age_gender.predict(frame, face['bbox']) # predict per face
- draw_age_gender_label(frame, face['bbox'], gender_id, age)
+ result = age_gender.predict(frame, face['bbox'])
+ draw_age_gender_label(frame, face['bbox'], result.sex, result.age)
cv2.putText(
frame,
diff --git a/scripts/run_fairface.py b/scripts/run_fairface.py
new file mode 100644
index 0000000..319ac28
--- /dev/null
+++ b/scripts/run_fairface.py
@@ -0,0 +1,129 @@
+# FairFace attribute prediction (race, gender, age) on detected faces
+# Usage: python run_fairface.py --image path/to/image.jpg
+# python run_fairface.py --webcam
+
+import argparse
+import os
+from pathlib import Path
+
+import cv2
+
+from uniface import SCRFD, RetinaFace
+from uniface.attribute import FairFace
+from uniface.visualization import draw_detections
+
+
+def draw_fairface_label(image, bbox, sex: str, age_group: str, race: str):
+ """Draw FairFace attributes above the bounding box."""
+ x1, y1 = int(bbox[0]), int(bbox[1])
+ text = f'{sex}, {age_group}, {race}'
+ (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 2)
+ cv2.rectangle(image, (x1, y1 - th - 10), (x1 + tw + 10, y1), (0, 255, 0), -1)
+ cv2.putText(image, text, (x1 + 5, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 2)
+
+
+def process_image(
+ detector,
+ fairface,
+ image_path: str,
+ save_dir: str = 'outputs',
+ threshold: float = 0.6,
+):
+ image = cv2.imread(image_path)
+ if image is None:
+ print(f"Error: Failed to load image from '{image_path}'")
+ return
+
+ faces = detector.detect(image)
+ print(f'Detected {len(faces)} face(s)')
+
+ 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, fancy_bbox=True
+ )
+
+ for i, face in enumerate(faces):
+ result = fairface.predict(image, face.bbox)
+ print(f' Face {i + 1}: {result.sex}, {result.age_group}, {result.race}')
+ draw_fairface_label(image, face.bbox, result.sex, result.age_group, result.race)
+
+ os.makedirs(save_dir, exist_ok=True)
+ output_path = os.path.join(save_dir, f'{Path(image_path).stem}_fairface.jpg')
+ cv2.imwrite(output_path, image)
+ print(f'Output saved: {output_path}')
+
+
+def run_webcam(detector, fairface, threshold: float = 0.6):
+ cap = cv2.VideoCapture(0) # 0 = default webcam
+ if not cap.isOpened():
+ print('Cannot open webcam')
+ return
+
+ print("Press 'q' to quit")
+
+ while True:
+ ret, frame = cap.read()
+ frame = cv2.flip(frame, 1) # mirror for natural interaction
+ if not ret:
+ break
+
+ faces = detector.detect(frame)
+
+ # 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=frame, bboxes=bboxes, scores=scores, landmarks=landmarks, vis_threshold=threshold, fancy_bbox=True
+ )
+
+ for face in faces:
+ result = fairface.predict(frame, face.bbox)
+ draw_fairface_label(frame, face.bbox, result.sex, result.age_group, result.race)
+
+ cv2.putText(
+ frame,
+ f'Faces: {len(faces)}',
+ (10, 30),
+ cv2.FONT_HERSHEY_SIMPLEX,
+ 1,
+ (0, 255, 0),
+ 2,
+ )
+ cv2.imshow('FairFace Detection', frame)
+
+ if cv2.waitKey(1) & 0xFF == ord('q'):
+ break
+
+ cap.release()
+ cv2.destroyAllWindows()
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Run FairFace attribute prediction (race, gender, age)')
+ parser.add_argument('--image', type=str, help='Path to input image')
+ parser.add_argument('--webcam', action='store_true', help='Use webcam')
+ parser.add_argument('--detector', type=str, default='retinaface', choices=['retinaface', 'scrfd'])
+ parser.add_argument('--threshold', type=float, default=0.6, help='Visualization threshold')
+ parser.add_argument('--save_dir', type=str, default='outputs')
+ args = parser.parse_args()
+
+ if not args.image and not args.webcam:
+ parser.error('Either --image or --webcam must be specified')
+
+ detector = RetinaFace() if args.detector == 'retinaface' else SCRFD()
+ fairface = FairFace()
+
+ if args.webcam:
+ run_webcam(detector, fairface, args.threshold)
+ else:
+ process_image(detector, fairface, args.image, args.save_dir, args.threshold)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/test_age_gender.py b/tests/test_age_gender.py
index e899331..cecaaf7 100644
--- a/tests/test_age_gender.py
+++ b/tests/test_age_gender.py
@@ -1,7 +1,7 @@
import numpy as np
import pytest
-from uniface.attribute import AgeGender
+from uniface.attribute import AgeGender, AttributeResult
@pytest.fixture
@@ -24,19 +24,22 @@ def test_model_initialization(age_gender_model):
def test_prediction_output_format(age_gender_model, mock_image, mock_bbox):
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- assert isinstance(gender_id, int), f'Gender ID should be int, got {type(gender_id)}'
- assert isinstance(age, int), f'Age should be int, got {type(age)}'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ assert isinstance(result, AttributeResult), f'Result should be AttributeResult, got {type(result)}'
+ assert isinstance(result.gender, int), f'Gender should be int, got {type(result.gender)}'
+ assert isinstance(result.age, int), f'Age should be int, got {type(result.age)}'
+ assert isinstance(result.sex, str), f'Sex should be str, got {type(result.sex)}'
def test_gender_values(age_gender_model, mock_image, mock_bbox):
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- assert gender_id in [0, 1], f'Gender ID should be 0 (Female) or 1 (Male), got {gender_id}'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ assert result.gender in [0, 1], f'Gender should be 0 (Female) or 1 (Male), got {result.gender}'
+ assert result.sex in ['Female', 'Male'], f'Sex should be Female or Male, got {result.sex}'
def test_age_range(age_gender_model, mock_image, mock_bbox):
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- assert 0 <= age <= 120, f'Age should be between 0 and 120, got {age}'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ assert 0 <= result.age <= 120, f'Age should be between 0 and 120, got {result.age}'
def test_different_bbox_sizes(age_gender_model, mock_image):
@@ -47,9 +50,9 @@ def test_different_bbox_sizes(age_gender_model, mock_image):
]
for bbox in test_bboxes:
- gender_id, age = age_gender_model.predict(mock_image, bbox)
- assert gender_id in [0, 1], f'Failed for bbox {bbox}'
- assert 0 <= age <= 120, f'Age out of range for bbox {bbox}'
+ result = age_gender_model.predict(mock_image, bbox)
+ assert result.gender in [0, 1], f'Failed for bbox {bbox}'
+ assert 0 <= result.age <= 120, f'Age out of range for bbox {bbox}'
def test_different_image_sizes(age_gender_model, mock_bbox):
@@ -57,31 +60,31 @@ def test_different_image_sizes(age_gender_model, mock_bbox):
for size in test_sizes:
mock_image = np.random.randint(0, 255, size, dtype=np.uint8)
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- assert gender_id in [0, 1], f'Failed for image size {size}'
- assert 0 <= age <= 120, f'Age out of range for image size {size}'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ assert result.gender in [0, 1], f'Failed for image size {size}'
+ assert 0 <= result.age <= 120, f'Age out of range for image size {size}'
def test_consistency(age_gender_model, mock_image, mock_bbox):
- gender_id1, age1 = age_gender_model.predict(mock_image, mock_bbox)
- gender_id2, age2 = age_gender_model.predict(mock_image, mock_bbox)
+ result1 = age_gender_model.predict(mock_image, mock_bbox)
+ result2 = age_gender_model.predict(mock_image, mock_bbox)
- assert gender_id1 == gender_id2, 'Same input should produce same gender prediction'
- assert age1 == age2, 'Same input should produce same age prediction'
+ assert result1.gender == result2.gender, 'Same input should produce same gender prediction'
+ assert result1.age == result2.age, 'Same input should produce same age prediction'
def test_bbox_list_format(age_gender_model, mock_image):
bbox_list = [100, 100, 300, 300]
- gender_id, age = age_gender_model.predict(mock_image, bbox_list)
- assert gender_id in [0, 1], 'Should work with bbox as list'
- assert 0 <= age <= 120, 'Age should be in valid range'
+ result = age_gender_model.predict(mock_image, bbox_list)
+ assert result.gender in [0, 1], 'Should work with bbox as list'
+ assert 0 <= result.age <= 120, 'Age should be in valid range'
def test_bbox_array_format(age_gender_model, mock_image):
bbox_array = np.array([100, 100, 300, 300])
- gender_id, age = age_gender_model.predict(mock_image, bbox_array)
- assert gender_id in [0, 1], 'Should work with bbox as numpy array'
- assert 0 <= age <= 120, 'Age should be in valid range'
+ result = age_gender_model.predict(mock_image, bbox_array)
+ assert result.gender in [0, 1], 'Should work with bbox as numpy array'
+ assert 0 <= result.age <= 120, 'Age should be in valid range'
def test_multiple_predictions(age_gender_model, mock_image):
@@ -93,25 +96,37 @@ def test_multiple_predictions(age_gender_model, mock_image):
results = []
for bbox in bboxes:
- gender_id, age = age_gender_model.predict(mock_image, bbox)
- results.append((gender_id, age))
+ result = age_gender_model.predict(mock_image, bbox)
+ results.append(result)
assert len(results) == 3, 'Should have 3 predictions'
- for gender_id, age in results:
- assert gender_id in [0, 1]
- assert 0 <= age <= 120
+ for result in results:
+ assert result.gender in [0, 1]
+ assert 0 <= result.age <= 120
def test_age_is_positive(age_gender_model, mock_image, mock_bbox):
for _ in range(5):
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- assert age >= 0, f'Age should be non-negative, got {age}'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ assert result.age >= 0, f'Age should be non-negative, got {result.age}'
def test_output_format_for_visualization(age_gender_model, mock_image, mock_bbox):
- gender_id, age = age_gender_model.predict(mock_image, mock_bbox)
- gender_str = 'Female' if gender_id == 0 else 'Male'
- text = f'{gender_str}, {age}y'
+ result = age_gender_model.predict(mock_image, mock_bbox)
+ text = f'{result.sex}, {result.age}y'
assert isinstance(text, str), 'Should be able to format as string'
assert 'Male' in text or 'Female' in text, 'Text should contain gender'
assert 'y' in text, "Text should contain 'y' for years"
+
+
+def test_attribute_result_fields(age_gender_model, mock_image, mock_bbox):
+ """Test that AttributeResult has correct fields for AgeGender model."""
+ result = age_gender_model.predict(mock_image, mock_bbox)
+
+ # AgeGender should set gender and age
+ assert result.gender is not None
+ assert result.age is not None
+
+ # AgeGender should NOT set race and age_group (FairFace only)
+ assert result.race is None
+ assert result.age_group is None
diff --git a/uniface/__init__.py b/uniface/__init__.py
index 831400c..3a9652e 100644
--- a/uniface/__init__.py
+++ b/uniface/__init__.py
@@ -22,7 +22,7 @@ from uniface.model_store import verify_model_weights
from uniface.visualization import draw_detections, vis_parsing_maps
from .analyzer import FaceAnalyzer
-from .attribute import AgeGender
+from .attribute import AgeGender, AttributeResult, FairFace
from .face import Face
try:
@@ -76,7 +76,9 @@ __all__ = [
'BiSeNet',
# Attribute models
'AgeGender',
+ 'AttributeResult',
'Emotion',
+ 'FairFace',
# Spoofing models
'MiniFASNet',
# Privacy
diff --git a/uniface/analyzer.py b/uniface/analyzer.py
index 779d2cc..6cfa549 100644
--- a/uniface/analyzer.py
+++ b/uniface/analyzer.py
@@ -7,6 +7,7 @@ from typing import List, Optional
import numpy as np
from uniface.attribute.age_gender import AgeGender
+from uniface.attribute.fairface import FairFace
from uniface.detection.base import BaseDetector
from uniface.face import Face
from uniface.log import Logger
@@ -23,16 +24,20 @@ class FaceAnalyzer:
detector: BaseDetector,
recognizer: Optional[BaseRecognizer] = None,
age_gender: Optional[AgeGender] = None,
+ fairface: Optional[FairFace] = None,
) -> None:
self.detector = detector
self.recognizer = recognizer
self.age_gender = age_gender
+ self.fairface = fairface
Logger.info(f'Initialized FaceAnalyzer with detector={detector.__class__.__name__}')
if recognizer:
Logger.info(f' - Recognition enabled: {recognizer.__class__.__name__}')
if age_gender:
Logger.info(f' - Age/Gender enabled: {age_gender.__class__.__name__}')
+ if fairface:
+ Logger.info(f' - FairFace enabled: {fairface.__class__.__name__}')
def analyze(self, image: np.ndarray) -> List[Face]:
"""Analyze faces in an image."""
@@ -49,11 +54,23 @@ class FaceAnalyzer:
if self.age_gender is not None:
try:
- face.gender, face.age = self.age_gender.predict(image, face.bbox)
- Logger.debug(f' Face {idx + 1}: Age={face.age}, Gender={face.gender}')
+ result = self.age_gender.predict(image, face.bbox)
+ face.gender = result.gender
+ face.age = result.age
+ Logger.debug(f' Face {idx + 1}: Age={face.age}, Gender={face.sex}')
except Exception as e:
Logger.warning(f' Face {idx + 1}: Failed to predict age/gender: {e}')
+ if self.fairface is not None:
+ try:
+ result = self.fairface.predict(image, face.bbox)
+ face.gender = result.gender
+ face.age_group = result.age_group
+ face.race = result.race
+ Logger.debug(f' Face {idx + 1}: AgeGroup={face.age_group}, Gender={face.sex}, Race={face.race}')
+ except Exception as e:
+ Logger.warning(f' Face {idx + 1}: Failed to predict FairFace attributes: {e}')
+
Logger.info(f'Analysis complete: {len(faces)} face(s) processed')
return faces
@@ -63,4 +80,6 @@ class FaceAnalyzer:
parts.append(f'recognizer={self.recognizer.__class__.__name__}')
if self.age_gender:
parts.append(f'age_gender={self.age_gender.__class__.__name__}')
+ if self.fairface:
+ parts.append(f'fairface={self.fairface.__class__.__name__}')
return ', '.join(parts) + ')'
diff --git a/uniface/attribute/__init__.py b/uniface/attribute/__init__.py
index 7111ff2..8c02658 100644
--- a/uniface/attribute/__init__.py
+++ b/uniface/attribute/__init__.py
@@ -7,8 +7,9 @@ from typing import Any, Dict, List, Union
import numpy as np
from uniface.attribute.age_gender import AgeGender
-from uniface.attribute.base import Attribute
-from uniface.constants import AgeGenderWeights, DDAMFNWeights
+from uniface.attribute.base import Attribute, AttributeResult
+from uniface.attribute.fairface import FairFace
+from uniface.constants import AgeGenderWeights, DDAMFNWeights, FairFaceWeights
# Emotion requires PyTorch - make it optional
try:
@@ -20,11 +21,19 @@ except ImportError:
_EMOTION_AVAILABLE = False
# Public API for the attribute module
-__all__ = ['AgeGender', 'Emotion', 'create_attribute_predictor', 'predict_attributes']
+__all__ = [
+ 'AgeGender',
+ 'AttributeResult',
+ 'Emotion',
+ 'FairFace',
+ 'create_attribute_predictor',
+ 'predict_attributes',
+]
# A mapping from model enums to their corresponding attribute classes
_ATTRIBUTE_MODELS = {
**{model: AgeGender for model in AgeGenderWeights},
+ **{model: FairFace for model in FairFaceWeights},
}
# Add Emotion models only if PyTorch is available
@@ -32,7 +41,9 @@ if _EMOTION_AVAILABLE:
_ATTRIBUTE_MODELS.update({model: Emotion for model in DDAMFNWeights})
-def create_attribute_predictor(model_name: Union[AgeGenderWeights, DDAMFNWeights], **kwargs: Any) -> Attribute:
+def create_attribute_predictor(
+ model_name: Union[AgeGenderWeights, DDAMFNWeights, FairFaceWeights], **kwargs: Any
+) -> Attribute:
"""
Factory function to create an attribute predictor instance.
@@ -41,11 +52,13 @@ def create_attribute_predictor(model_name: Union[AgeGenderWeights, DDAMFNWeights
Args:
model_name: The enum corresponding to the desired attribute model
- (e.g., AgeGenderWeights.DEFAULT or DDAMFNWeights.AFFECNET7).
+ (e.g., AgeGenderWeights.DEFAULT, DDAMFNWeights.AFFECNET7,
+ or FairFaceWeights.DEFAULT).
**kwargs: Additional keyword arguments to pass to the model's constructor.
Returns:
- An initialized instance of an Attribute predictor class (e.g., AgeGender).
+ An initialized instance of an Attribute predictor class
+ (e.g., AgeGender, FairFace, or Emotion).
Raises:
ValueError: If the provided model_name is not a supported enum.
@@ -54,7 +67,8 @@ def create_attribute_predictor(model_name: Union[AgeGenderWeights, DDAMFNWeights
if model_class is None:
raise ValueError(
- f'Unsupported attribute model: {model_name}. Please choose from AgeGenderWeights or DDAMFNWeights.'
+ f'Unsupported attribute model: {model_name}. '
+ f'Please choose from AgeGenderWeights, FairFaceWeights, or DDAMFNWeights.'
)
# Pass model_name to the constructor, as some classes might need it
@@ -88,9 +102,16 @@ def predict_attributes(
face['attributes'] = {}
if isinstance(predictor, AgeGender):
- gender_id, age = predictor(image, face['bbox'])
- face['attributes']['gender_id'] = gender_id
- face['attributes']['age'] = age
+ result = predictor(image, face['bbox'])
+ face['attributes']['gender'] = result.gender
+ face['attributes']['sex'] = result.sex
+ face['attributes']['age'] = result.age
+ elif isinstance(predictor, FairFace):
+ result = predictor(image, face['bbox'])
+ face['attributes']['gender'] = result.gender
+ face['attributes']['sex'] = result.sex
+ face['attributes']['age_group'] = result.age_group
+ face['attributes']['race'] = result.race
elif isinstance(predictor, Emotion):
emotion, confidence = predictor(image, face['landmark'])
face['attributes']['emotion'] = emotion
diff --git a/uniface/attribute/age_gender.py b/uniface/attribute/age_gender.py
index d0b0193..cbeded1 100644
--- a/uniface/attribute/age_gender.py
+++ b/uniface/attribute/age_gender.py
@@ -7,7 +7,7 @@ from typing import List, Optional, Tuple, Union
import cv2
import numpy as np
-from uniface.attribute.base import Attribute
+from uniface.attribute.base import Attribute, AttributeResult
from uniface.constants import AgeGenderWeights
from uniface.face_utils import bbox_center_alignment
from uniface.log import Logger
@@ -111,7 +111,7 @@ class AgeGender(Attribute):
)
return blob
- def postprocess(self, prediction: np.ndarray) -> Tuple[int, int]:
+ def postprocess(self, prediction: np.ndarray) -> AttributeResult:
"""
Processes the raw model output to extract gender and age.
@@ -119,16 +119,15 @@ class AgeGender(Attribute):
prediction (np.ndarray): The raw output from the model inference.
Returns:
- Tuple[int, int]: A tuple containing the predicted gender ID (0 for Female, 1 for Male)
- and age (in years).
+ AttributeResult: Result containing gender (0=Female, 1=Male) and age (in years).
"""
# First two values are gender logits
- gender_id = int(np.argmax(prediction[:2]))
+ gender = int(np.argmax(prediction[:2]))
# Third value is normalized age, scaled by 100
age = int(np.round(prediction[2] * 100))
- return gender_id, age
+ return AttributeResult(gender=gender, age=age)
- def predict(self, image: np.ndarray, bbox: Union[List, np.ndarray]) -> Tuple[int, int]:
+ def predict(self, image: np.ndarray, bbox: Union[List, np.ndarray]) -> AttributeResult:
"""
Predicts age and gender for a single face specified by a bounding box.
@@ -137,75 +136,8 @@ class AgeGender(Attribute):
bbox (Union[List, np.ndarray]): The face bounding box coordinates [x1, y1, x2, y2].
Returns:
- Tuple[int, int]: A tuple containing the predicted gender ID (0 for Female, 1 for Male) and age.
+ AttributeResult: Result containing gender (0=Female, 1=Male) and age (in years).
"""
face_blob = self.preprocess(image, bbox)
prediction = self.session.run(self.output_names, {self.input_name: face_blob})[0][0]
- gender_id, age = self.postprocess(prediction)
- return gender_id, age
-
-
-# TODO: below is only for testing, remove it later
-if __name__ == '__main__':
- # To run this script, you need to have uniface.detection installed
- # or available in your path.
- from uniface.constants import RetinaFaceWeights
- from uniface.detection import create_detector
-
- print('Initializing models for live inference...')
- # 1. Initialize the face detector
- # Using a smaller model for faster real-time performance
- detector = create_detector(model_name=RetinaFaceWeights.MNET_V2)
-
- # 2. Initialize the attribute predictor
- age_gender_predictor = AgeGender()
-
- # 3. Start webcam capture
- cap = cv2.VideoCapture(0)
- if not cap.isOpened():
- print('Error: Could not open webcam.')
- exit()
-
- print("Starting webcam feed. Press 'q' to quit.")
- while True:
- ret, frame = cap.read()
- if not ret:
- print('Error: Failed to capture frame.')
- break
-
- # Detect faces in the current frame
- detections = detector.detect(frame)
-
- # For each detected face, predict age and gender
- for detection in detections:
- box = detection['bbox']
- x1, y1, x2, y2 = map(int, box)
-
- # Predict attributes
- gender_id, age = age_gender_predictor.predict(frame, box)
- gender_str = 'Female' if gender_id == 0 else 'Male'
-
- # Prepare text and draw on the frame
- label = f'{gender_str}, {age}'
- cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
- cv2.putText(
- frame,
- label,
- (x1, y1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.8,
- (0, 255, 0),
- 2,
- )
-
- # Display the resulting frame
- cv2.imshow("Age and Gender Inference (Press 'q' to quit)", frame)
-
- # Break the loop if 'q' is pressed
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
-
- # Release resources
- cap.release()
- cv2.destroyAllWindows()
- print('Inference stopped.')
+ return self.postprocess(prediction)
diff --git a/uniface/attribute/base.py b/uniface/attribute/base.py
index b9ba73f..4434b79 100644
--- a/uniface/attribute/base.py
+++ b/uniface/attribute/base.py
@@ -3,10 +3,58 @@
# GitHub: https://github.com/yakhyo
from abc import ABC, abstractmethod
-from typing import Any
+from dataclasses import dataclass
+from typing import Any, Optional
import numpy as np
+__all__ = ['Attribute', 'AttributeResult']
+
+
+@dataclass(slots=True)
+class AttributeResult:
+ """
+ Unified result structure for face attribute prediction.
+
+ This dataclass provides a consistent return type across different attribute
+ prediction models (e.g., AgeGender, FairFace), enabling interoperability
+ and unified handling of results.
+
+ Attributes:
+ gender: Predicted gender (0=Female, 1=Male).
+ age: Exact age in years. Provided by AgeGender model, None for FairFace.
+ age_group: Age range string like "20-29". Provided by FairFace, None for AgeGender.
+ race: Race/ethnicity label. Provided by FairFace only.
+
+ Properties:
+ sex: Gender as a human-readable string ("Female" or "Male").
+
+ Examples:
+ >>> # AgeGender result
+ >>> result = AttributeResult(gender=1, age=25)
+ >>> result.sex
+ 'Male'
+ >>> result.age
+ 25
+
+ >>> # FairFace result
+ >>> result = AttributeResult(gender=0, age_group="20-29", race="East Asian")
+ >>> result.sex
+ 'Female'
+ >>> result.race
+ 'East Asian'
+ """
+
+ gender: int
+ age: Optional[int] = None
+ age_group: Optional[str] = None
+ race: Optional[str] = None
+
+ @property
+ def sex(self) -> str:
+ """Get gender as a string label (Female or Male)."""
+ return 'Female' if self.gender == 0 else 'Male'
+
class Attribute(ABC):
"""
diff --git a/uniface/attribute/emotion.py b/uniface/attribute/emotion.py
index 9daeace..3f1f8ef 100644
--- a/uniface/attribute/emotion.py
+++ b/uniface/attribute/emotion.py
@@ -127,68 +127,3 @@ class Emotion(Attribute):
output = output[0]
return self.postprocess(output)
-
-
-# TODO: below is only for testing, remove it later
-if __name__ == '__main__':
- from uniface.constants import RetinaFaceWeights
- from uniface.detection import create_detector
-
- print('Initializing models for live inference...')
- # 1. Initialize the face detector
- # Using a smaller model for faster real-time performance
- detector = create_detector(model_name=RetinaFaceWeights.MNET_V2)
-
- # 2. Initialize the attribute predictor
- emotion_predictor = Emotion()
-
- # 3. Start webcam capture
- cap = cv2.VideoCapture(0)
- if not cap.isOpened():
- print('Error: Could not open webcam.')
- exit()
-
- print("Starting webcam feed. Press 'q' to quit.")
- while True:
- ret, frame = cap.read()
- if not ret:
- print('Error: Failed to capture frame.')
- break
-
- # Detect faces in the current frame.
- # This method returns a list of dictionaries for each detected face.
- detections = detector.detect(frame)
-
- # For each detected face, predict the emotion
- for detection in detections:
- box = detection['bbox']
- landmark = detection['landmarks']
- x1, y1, x2, y2 = map(int, box)
-
- # Predict attributes using the landmark
- emotion, confidence = emotion_predictor.predict(frame, landmark)
-
- # Prepare text and draw on the frame
- label = f'{emotion} ({confidence:.2f})'
- cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 0, 0), 2)
- cv2.putText(
- frame,
- label,
- (x1, y1 - 10),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.8,
- (255, 0, 0),
- 2,
- )
-
- # Display the resulting frame
- cv2.imshow("Emotion Inference (Press 'q' to quit)", frame)
-
- # Break the loop if 'q' is pressed
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
-
- # Release resources
- cap.release()
- cv2.destroyAllWindows()
- print('Inference stopped.')
diff --git a/uniface/attribute/fairface.py b/uniface/attribute/fairface.py
new file mode 100644
index 0000000..3adbf37
--- /dev/null
+++ b/uniface/attribute/fairface.py
@@ -0,0 +1,193 @@
+# Copyright 2025 Yakhyokhuja Valikhujaev
+# Author: Yakhyokhuja Valikhujaev
+# GitHub: https://github.com/yakhyo
+
+from typing import List, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+
+from uniface.attribute.base import Attribute, AttributeResult
+from uniface.constants import FairFaceWeights
+from uniface.log import Logger
+from uniface.model_store import verify_model_weights
+from uniface.onnx_utils import create_onnx_session
+
+__all__ = ['FairFace', 'RACE_LABELS', 'AGE_LABELS']
+
+# Label definitions
+RACE_LABELS = [
+ 'White',
+ 'Black',
+ 'Latino Hispanic',
+ 'East Asian',
+ 'Southeast Asian',
+ 'Indian',
+ 'Middle Eastern',
+]
+AGE_LABELS = ['0-2', '3-9', '10-19', '20-29', '30-39', '40-49', '50-59', '60-69', '70+']
+
+
+class FairFace(Attribute):
+ """
+ FairFace attribute prediction model using ONNX Runtime.
+
+ This class inherits from the base `Attribute` class and implements the
+ functionality for predicting race (7 categories), gender (2 categories),
+ and age (9 groups) from a face image. It requires a bounding box to locate the face.
+
+ The model is trained on the FairFace dataset which provides balanced demographics
+ for more equitable predictions across different racial and gender groups.
+
+ Args:
+ model_name (FairFaceWeights): The enum specifying the model weights to load.
+ Defaults to `FairFaceWeights.DEFAULT`.
+ input_size (Optional[Tuple[int, int]]): Input size (height, width).
+ If None, defaults to (224, 224). Defaults to None.
+ """
+
+ def __init__(
+ self,
+ model_name: FairFaceWeights = FairFaceWeights.DEFAULT,
+ input_size: Optional[Tuple[int, int]] = None,
+ ) -> None:
+ """
+ Initializes the FairFace prediction model.
+
+ Args:
+ model_name (FairFaceWeights): The enum specifying the model weights to load.
+ input_size (Optional[Tuple[int, int]]): Input size (height, width).
+ If None, defaults to (224, 224).
+ """
+ Logger.info(f'Initializing FairFace with model={model_name.name}')
+ self.model_path = verify_model_weights(model_name)
+ self.input_size = input_size if input_size is not None else (224, 224)
+ self._initialize_model()
+
+ def _initialize_model(self) -> None:
+ """
+ Initializes the ONNX model and creates an inference session.
+ """
+ try:
+ self.session = create_onnx_session(self.model_path)
+ # Get model input details from the loaded model
+ input_meta = self.session.get_inputs()[0]
+ self.input_name = input_meta.name
+ self.output_names = [output.name for output in self.session.get_outputs()]
+ Logger.info(f'Successfully initialized FairFace model with input size {self.input_size}')
+ except Exception as e:
+ Logger.error(
+ f"Failed to load FairFace model from '{self.model_path}'",
+ exc_info=True,
+ )
+ raise RuntimeError(f'Failed to initialize FairFace model: {e}') from e
+
+ def preprocess(self, image: np.ndarray, bbox: Optional[Union[List, np.ndarray]] = None) -> np.ndarray:
+ """
+ Preprocesses the face image for inference.
+
+ Args:
+ image (np.ndarray): The input image in BGR format.
+ bbox (Optional[Union[List, np.ndarray]]): Face bounding box [x1, y1, x2, y2].
+ If None, uses the entire image.
+
+ Returns:
+ np.ndarray: The preprocessed image blob ready for inference.
+ """
+ # Crop face if bbox provided
+ if bbox is not None:
+ bbox = np.asarray(bbox, dtype=int)
+ x1, y1, x2, y2 = bbox[:4]
+
+ # Add padding (25% of face size)
+ w, h = x2 - x1, y2 - y1
+ padding = 0.25
+ x_pad = int(w * padding)
+ y_pad = int(h * padding)
+
+ x1 = max(0, x1 - x_pad)
+ y1 = max(0, y1 - y_pad)
+ x2 = min(image.shape[1], x2 + x_pad)
+ y2 = min(image.shape[0], y2 + y_pad)
+
+ image = image[y1:y2, x1:x2]
+
+ # Resize to input size (width, height for cv2.resize)
+ image = cv2.resize(image, self.input_size[::-1])
+
+ # Convert BGR to RGB
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
+
+ # Normalize with ImageNet mean and std
+ image = image.astype(np.float32) / 255.0
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
+ image = (image - mean) / std
+
+ # Transpose to CHW format and add batch dimension
+ image = np.transpose(image, (2, 0, 1))
+ image = np.expand_dims(image, axis=0)
+
+ return image
+
+ def postprocess(self, prediction: Tuple[np.ndarray, np.ndarray, np.ndarray]) -> AttributeResult:
+ """
+ Processes the raw model output to extract race, gender, and age.
+
+ Args:
+ prediction (Tuple[np.ndarray, np.ndarray, np.ndarray]): Raw outputs from model
+ (race_logits, gender_logits, age_logits).
+
+ Returns:
+ AttributeResult: Result containing gender (0=Female, 1=Male), age_group, and race.
+ """
+ race_logits, gender_logits, age_logits = prediction
+
+ # Apply softmax
+ race_probs = self._softmax(race_logits[0])
+ gender_probs = self._softmax(gender_logits[0])
+ age_probs = self._softmax(age_logits[0])
+
+ # Get predictions
+ race_idx = int(np.argmax(race_probs))
+ raw_gender_idx = int(np.argmax(gender_probs))
+ age_idx = int(np.argmax(age_probs))
+
+ # Normalize gender: model outputs 0=Male, 1=Female → standard 0=Female, 1=Male
+ gender = 1 - raw_gender_idx
+
+ return AttributeResult(
+ gender=gender,
+ age_group=AGE_LABELS[age_idx],
+ race=RACE_LABELS[race_idx],
+ )
+
+ def predict(self, image: np.ndarray, bbox: Optional[Union[List, np.ndarray]] = None) -> AttributeResult:
+ """
+ Predicts race, gender, and age for a face.
+
+ Args:
+ image (np.ndarray): The input image in BGR format.
+ bbox (Optional[Union[List, np.ndarray]]): Face bounding box [x1, y1, x2, y2].
+ If None, uses the entire image.
+
+ Returns:
+ AttributeResult: Result containing:
+ - gender: 0=Female, 1=Male
+ - age_group: Age range string like "20-29"
+ - race: Race/ethnicity label
+ """
+ # Preprocess
+ input_blob = self.preprocess(image, bbox)
+
+ # Inference
+ outputs = self.session.run(self.output_names, {self.input_name: input_blob})
+
+ # Postprocess
+ return self.postprocess(outputs)
+
+ @staticmethod
+ def _softmax(x: np.ndarray) -> np.ndarray:
+ """Compute softmax values for numerical stability."""
+ exp_x = np.exp(x - np.max(x))
+ return exp_x / np.sum(exp_x)
diff --git a/uniface/constants.py b/uniface/constants.py
index 7ad08f6..97af506 100644
--- a/uniface/constants.py
+++ b/uniface/constants.py
@@ -88,6 +88,15 @@ class AgeGenderWeights(str, Enum):
DEFAULT = "age_gender"
+class FairFaceWeights(str, Enum):
+ """
+ FairFace attribute prediction (race, gender, age).
+ Trained on FairFace dataset with balanced demographics.
+ https://github.com/yakhyo/fairface-onnx
+ """
+ DEFAULT = "fairface"
+
+
class LandmarkWeights(str, Enum):
"""
MobileNet 0.5 from Insightface
@@ -164,6 +173,8 @@ MODEL_URLS: Dict[Enum, str] = {
DDAMFNWeights.AFFECNET8: 'https://github.com/yakhyo/uniface/releases/download/weights/affecnet8.script',
# AgeGender
AgeGenderWeights.DEFAULT: 'https://github.com/yakhyo/uniface/releases/download/weights/genderage.onnx',
+ # FairFace
+ FairFaceWeights.DEFAULT: 'https://github.com/yakhyo/fairface-onnx/releases/download/weights/fairface.onnx',
# Landmarks
LandmarkWeights.DEFAULT: 'https://github.com/yakhyo/uniface/releases/download/weights/2d106det.onnx',
# Gaze (MobileGaze)
@@ -211,6 +222,8 @@ MODEL_SHA256: Dict[Enum, str] = {
DDAMFNWeights.AFFECNET8: '8c66963bc71db42796a14dfcbfcd181b268b65a3fc16e87147d6a3a3d7e0f487',
# AgeGender
AgeGenderWeights.DEFAULT: '4fde69b1c810857b88c64a335084f1c3fe8f01246c9a191b48c7bb756d6652fb',
+ # FairFace
+ FairFaceWeights.DEFAULT: '9c8c47d437cd310538d233f2465f9ed0524cb7fb51882a37f74e8bc22437fdbf',
# Landmark
LandmarkWeights.DEFAULT: 'f001b856447c413801ef5c42091ed0cd516fcd21f2d6b79635b1e733a7109dbf',
# MobileGaze (trained on Gaze360)
diff --git a/uniface/detection/retinaface.py b/uniface/detection/retinaface.py
index e18f30a..8dbbe5e 100644
--- a/uniface/detection/retinaface.py
+++ b/uniface/detection/retinaface.py
@@ -313,65 +313,3 @@ class RetinaFace(BaseDetector):
landmarks = landmarks * landmark_scale / resize_factor
return boxes, landmarks
-
-
-# TODO: below is only for testing, remove it later
-def draw_bbox(frame, bbox, score, color=(0, 255, 0), thickness=2):
- x1, y1, x2, y2 = map(int, bbox) # Unpack 4 bbox values
- cv2.rectangle(frame, (x1, y1), (x2, y2), color, thickness)
- cv2.putText(frame, f'{score:.2f}', (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
-
-
-def draw_keypoints(frame, points, color=(0, 0, 255), radius=2):
- for x, y in points.astype(np.int32):
- cv2.circle(frame, (int(x), int(y)), radius, color, -1)
-
-
-if __name__ == '__main__':
- import cv2
-
- detector = RetinaFace(model_name=RetinaFaceWeights.MNET_050)
- print(detector.get_info())
- cap = cv2.VideoCapture(0)
-
- if not cap.isOpened():
- print('Failed to open webcam.')
- exit()
-
- print("Webcam started. Press 'q' to exit.")
-
- while True:
- ret, frame = cap.read()
- if not ret:
- print('Failed to read frame.')
- break
-
- # Get face detections as list of dictionaries
- faces = detector.detect(frame)
-
- # Process each detected face
- for face in faces:
- # Extract bbox and landmarks from Face object
- draw_bbox(frame, face.bbox, face.confidence)
-
- # Draw landmarks if available
- if face.landmarks is not None and len(face.landmarks) > 0:
- draw_keypoints(frame, face.landmarks)
-
- # Display face count
- cv2.putText(
- frame,
- f'Faces: {len(faces)}',
- (10, 30),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.7,
- (255, 255, 255),
- 2,
- )
-
- cv2.imshow('FaceDetection', frame)
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
-
- cap.release()
- cv2.destroyAllWindows()
diff --git a/uniface/detection/scrfd.py b/uniface/detection/scrfd.py
index d750034..f619ac4 100644
--- a/uniface/detection/scrfd.py
+++ b/uniface/detection/scrfd.py
@@ -4,7 +4,6 @@
from typing import Any, List, Literal, Tuple
-import cv2
import numpy as np
from uniface.common import distance2bbox, distance2kps, non_max_suppression, resize_image
@@ -289,63 +288,3 @@ class SCRFD(BaseDetector):
faces.append(face)
return faces
-
-
-# TODO: below is only for testing, remove it later
-def draw_bbox(frame, bbox, score, color=(0, 255, 0), thickness=2):
- x1, y1, x2, y2 = map(int, bbox) # Unpack 4 bbox values
- cv2.rectangle(frame, (x1, y1), (x2, y2), color, thickness)
- cv2.putText(frame, f'{score:.2f}', (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
-
-
-def draw_keypoints(frame, points, color=(0, 0, 255), radius=2):
- for x, y in points.astype(np.int32):
- cv2.circle(frame, (int(x), int(y)), radius, color, -1)
-
-
-if __name__ == '__main__':
- detector = SCRFD(model_name=SCRFDWeights.SCRFD_500M_KPS)
- print(detector.get_info())
- cap = cv2.VideoCapture(0)
-
- if not cap.isOpened():
- print('Failed to open webcam.')
- exit()
-
- print("Webcam started. Press 'q' to exit.")
-
- while True:
- ret, frame = cap.read()
- if not ret:
- print('Failed to read frame.')
- break
-
- # Get face detections as list of dictionaries
- faces = detector.detect(frame)
-
- # Process each detected face
- for face in faces:
- # Extract bbox and landmarks from Face object
- draw_bbox(frame, face.bbox, face.confidence)
-
- # Draw landmarks if available
- if face.landmarks is not None and len(face.landmarks) > 0:
- draw_keypoints(frame, face.landmarks)
-
- # Display face count
- cv2.putText(
- frame,
- f'Faces: {len(faces)}',
- (10, 30),
- cv2.FONT_HERSHEY_SIMPLEX,
- 0.7,
- (255, 255, 255),
- 2,
- )
-
- cv2.imshow('FaceDetection', frame)
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
-
- cap.release()
- cv2.destroyAllWindows()
diff --git a/uniface/face.py b/uniface/face.py
index 6807773..db5c84c 100644
--- a/uniface/face.py
+++ b/uniface/face.py
@@ -2,7 +2,7 @@
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
-from dataclasses import asdict, dataclass
+from dataclasses import dataclass, fields
from typing import Optional
import numpy as np
@@ -12,10 +12,28 @@ from uniface.face_utils import compute_similarity
__all__ = ['Face']
-@dataclass
+@dataclass(slots=True)
class Face:
"""
Detected face with analysis results.
+
+ This dataclass represents a single detected face along with optional
+ analysis results such as embeddings, age, gender, and race predictions.
+
+ Attributes:
+ bbox: Bounding box coordinates [x1, y1, x2, y2].
+ confidence: Detection confidence score.
+ landmarks: Facial landmark coordinates (typically 5 points).
+ embedding: Face embedding vector for recognition (optional).
+ gender: Predicted gender, 0=Female, 1=Male (optional).
+ age: Predicted exact age in years (optional, from AgeGender model).
+ age_group: Predicted age range like "20-29" (optional, from FairFace).
+ race: Predicted race/ethnicity (optional, from FairFace).
+
+ Properties:
+ sex: Gender as a human-readable string ("Female" or "Male").
+ bbox_xyxy: Bounding box in (x1, y1, x2, y2) format.
+ bbox_xywh: Bounding box in (x1, y1, width, height) format.
"""
# Required attributes
@@ -25,8 +43,10 @@ class Face:
# Optional attributes
embedding: Optional[np.ndarray] = None
+ gender: Optional[int] = None
age: Optional[int] = None
- gender: Optional[int] = None # 0 or 1
+ age_group: Optional[str] = None
+ race: Optional[str] = None
def compute_similarity(self, other: 'Face') -> float:
"""Compute cosine similarity with another face."""
@@ -36,10 +56,10 @@ class Face:
def to_dict(self) -> dict:
"""Convert to dictionary."""
- return asdict(self)
+ return {f.name: getattr(self, f.name) for f in fields(self)}
@property
- def sex(self) -> str:
+ def sex(self) -> Optional[str]:
"""Get gender as a string label (Female or Male)."""
if self.gender is None:
return None
@@ -59,8 +79,12 @@ class Face:
parts = [f'Face(confidence={self.confidence:.3f}']
if self.age is not None:
parts.append(f'age={self.age}')
+ if self.age_group is not None:
+ parts.append(f'age_group={self.age_group}')
if self.gender is not None:
parts.append(f'sex={self.sex}')
+ if self.race is not None:
+ parts.append(f'race={self.race}')
if self.embedding is not None:
parts.append(f'embedding_dim={self.embedding.shape[0]}')
return ', '.join(parts) + ')'
diff --git a/uniface/landmark/models.py b/uniface/landmark/models.py
index 381144a..07949e7 100644
--- a/uniface/landmark/models.py
+++ b/uniface/landmark/models.py
@@ -155,58 +155,3 @@ class Landmark106(BaseLandmarker):
raw_predictions = self.session.run(self.output_names, {self.input_names[0]: face_blob})[0][0]
landmarks = self.postprocess(raw_predictions, transform_matrix)
return landmarks
-
-
-# Testing code
-if __name__ == '__main__':
- from uniface.detection import RetinaFace
- from uniface.landmark import Landmark106
-
- face_detector = RetinaFace()
- landmarker = Landmark106()
-
- cap = cv2.VideoCapture(0)
- if not cap.isOpened():
- print('Webcam not available.')
- exit()
-
- print("Press 'q' to quit.")
-
- while True:
- ret, frame = cap.read()
- if not ret:
- print('Frame capture failed.')
- break
-
- # 2. The detect method returns a list of dictionaries
- faces = face_detector.detect(frame)
-
- if not faces:
- cv2.imshow('Facial Landmark Detection', frame)
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
- continue
-
- # 3. Loop through the list of face dictionaries
- for face in faces:
- # Extract the bounding box
- bbox = face['bbox']
-
- # 4. Get landmarks for the current face using its bounding box
- landmarks = landmarker.get_landmarks(frame, bbox)
-
- # --- Drawing Logic ---
- # Draw the landmarks
- for x, y in landmarks.astype(int):
- cv2.circle(frame, (x, y), 2, (0, 255, 0), -1)
-
- # Draw the bounding box
- x1, y1, x2, y2 = map(int, bbox)
- cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 0, 0), 2)
-
- cv2.imshow('Facial Landmark Detection', frame)
- if cv2.waitKey(1) & 0xFF == ord('q'):
- break
-
- cap.release()
- cv2.destroyAllWindows()