Estimates a person's apparent age from a photo. Faces are found and aligned with MediaPipe landmarks, and a pretrained CNN predicts a probability distribution over ages 0-100. The reported age is the expected value of that distribution.
The pipeline on official NASA astronaut portraits (public domain): 478 face landmarks, five of them for the alignment, the aligned crop the network sees, and its probability for every age from 0 to 100, next to the person's actual age when the photo was taken.flowchart LR
A[photo] --> B[MediaPipe face landmarks]
B --> C[similarity alignment<br/>5-point template]
C --> D[ConvNeXt-T / ResNet-50<br/>ImageNet or DINOv3 weights]
D --> E[softmax over ages 0..100]
E --> F[expected age]
Highlights
- Same alignment for training and inference. Five landmarks (iris centres, nose tip, mouth corners) are mapped onto a fixed template with a least-squares similarity transform (Umeyama). Every face therefore has the same scale, position and roll before the network sees it.
- Three interchangeable heads: plain L1 regression, label-distribution learning (DLDL-v2) and the mean-variance loss. All three run from one config switch, so they can be compared in an ablation.
- Evaluation broken down by group. Besides MAE and CS@5, errors are reported per age group, gender and ethnicity, because a single MAE can hide large gaps between groups.
- Ready for a Slurm cluster. Job scripts are generated for a Slurm GPU cluster. Training saves a checkpoint when Slurm warns about the time limit, and a resubmitted job resumes where it stopped.
- Deployment: ONNX export, checked numerically against PyTorch, and a CLI for images, videos and webcams.
UTKFace, random 80/10/10 split stratified by age, test set, flip TTA:
| Model | Head | MAE ↓ | CS@5 ↑ | MAE in the unified benchmark ↓ |
|---|---|---|---|---|
| ResNet-50 | regression (L1) | 4.83 | 0.635 | 4.53 |
| ResNet-50 | mean-variance | 4.69 | 0.645 | 4.57 |
| ResNet-50 | DLDL | 4.74 | 0.659 | 4.60 (DLDL-v2) |
| ConvNeXt-T (ImageNet-22k) | DLDL | 4.40 | 0.678 | |
| ConvNeXt-T (DINOv3), main model | DLDL | 4.25 | 0.687 |
The last column comes from Paplhám and Franc (CVPR 2024, Table 6): the same heads on ResNet-50, ImageNet initialisation, trained and tested on UTKFace with one protocol. Their splits, 256 px input and training schedule differ from this repo, so the comparison checks that the reimplementation is in the right range rather than that it matches to the decimal. That paper's main finding is worth knowing: with a fixed protocol, the choice of head changes MAE by at most a few tenths of a year. Pretraining matters about as much (4.38 with IMDB-WIKI pretraining vs. 4.75 from ImageNet, both cross-entropy), and input resolution far more (5.83 at 64 px vs. 4.38 at 256 px).
2371 test faces. The ResNet-50 heads land within 0.1-0.3 years of the unified benchmark, and, as that paper found, the head matters little (4.69-4.83). The backbone matters more: ConvNeXt-T is 0.3-0.4 years better than ResNet-50, and the self-supervised DINOv3 weights another 0.15 on top. DINOv3 also had the best validation MAE (4.24 against 4.38), which is how it became the main model, before the test set was scored.
Error of the main model by group (test set, MAE in years):
| Age 0-12 | 13-19 | 20-34 | 35-49 | 50-64 | 65+ |
|---|---|---|---|---|---|
| 1.34 | 2.94 | 3.44 | 5.45 | 6.53 | 7.93 |
| Female | Male | Asian | Black | Indian | White | Other |
|---|---|---|---|---|---|---|
| 3.99 | 4.50 | 3.16 | 4.52 | 4.21 | 4.63 | 3.55 |
Errors grow with age: a year of difference shows more on a child's face than on a 70-year-old's,
and UTKFace has fewer older faces to learn from. The spread across groups is smaller than the
spread across ages. The full breakdown is written to eval_test.json next to each checkpoint.
pip install -e ".[train]"
bash scripts/download_models.sh # MediaPipe landmarker + pretrained weights
bash scripts/download_utkface.sh # needs a Kaggle API token, see the script
age-align --src data/utkface/raw --out data/utkface/aligned --model models/face_landmarker.task
age-train --config configs/convnext_tiny_dinov3_dldl.yaml --set data.root=data/utkface/aligned
age-eval --checkpoint runs/convnext_tiny_dinov3_dldl/best.pt --split test
age-predict --checkpoint runs/convnext_tiny_dinov3_dldl/best.pt --source path/to/photos/
python -m age_estimation.export --checkpoint runs/convnext_tiny_dinov3_dldl/best.ptAny config value can be overridden from the command line, for example
--set model.head=regression --set optim.lr=1e-3.
bash scripts/setup_cluster.sh # once, on a login node: venv + install
bash scripts/download_models.sh # login node has internet; compute nodes may not
python3 slurm/generate.py --list # jobs defined in slurm/cluster.toml
python3 slurm/generate.py align --submit # CPU job
python3 slurm/generate.py train-convnext --submit
python3 slurm/generate.py ablation-heads --submit # array job, one task per head
python3 slurm/generate.py ablation-backbone --submit # ConvNeXt-T with DINOv3 weightsslurm/generate.py renders slurm/cluster.toml into sbatch scripts. It sets the account, partition,
GPU constraint, environment and log paths, and handles array arguments. Put your Slurm account in
slurm/cluster.local.toml (not committed) before submitting.
configs/ experiment configs (YAML, overridable with --set)
scripts/ data/model download, cluster environment setup
slurm/ cluster.toml (cluster + jobs) and the sbatch generator
src/age_estimation/
align.py MediaPipe landmarks -> similarity transform -> aligned crop
data/ UTKFace parsing, stratified split, dataset and augmentation
models.py timm backbone + regression / distribution head
losses.py L1, DLDL (KL + expectation L1), mean-variance
train.py training loop: AMP (bf16/fp16), cosine LR, resumable checkpoints
evaluate.py test metrics with per-group breakdown
export.py ONNX export + ONNX Runtime parity check
predict.py images / video / webcam inference
tests/ unit tests and an end-to-end smoke test (train -> eval -> ONNX)
- Why a distribution head? Neighbouring ages look almost the same. A Gaussian label distribution (σ = 2 years) encodes that a 31-year-old is "nearly right" for a 30-year-old, which gives a smoother training signal than one-hot classification. It is also more robust to label noise than plain regression.
- Alignment context. The template is scaled by
face_scale = 0.8, so the crop keeps the forehead, hairline and chin, which carry age cues that a tight ArcFace crop cuts away. - Which pretraining? The same ConvNeXt-T with supervised ImageNet-22k weights and distilled from Meta's self-supervised DINOv3 (2025), the newest ConvNeXt weights in timm. DINOv3 won on validation and is the main model. Architecture, input size and schedule stay the same; the self-supervised backbone is fine-tuned gently, at a tenth of the ImageNet model's backbone learning rate (2e-5). DINOv3 weights come under Meta's DINOv3 licence.
- Mixed precision uses bf16 on A100/H100 and fp16 with loss scaling on V100. Both are picked automatically.
The model estimates apparent age as labelled in UTKFace, not real age. UTKFace is unbalanced across ages and ethnicities, so the per-group breakdown should be read before trusting the model on any particular population. UTKFace is licensed for non-commercial research only.
- Zhang, Song, Qi. Age Progression/Regression by Conditional Adversarial Autoencoder (UTKFace), CVPR 2017.
- Gao et al. Age Estimation Using Expectation of Label Distribution Learning (DLDL-v2), IJCAI 2018.
- Pan et al. Mean-Variance Loss for Deep Age Estimation from a Face, CVPR 2018.
- Paplhám, Franc. A Call to Reflect on Evaluation Practices for Age Estimation: Comparative Analysis of the State-of-the-Art and a Unified Benchmark, CVPR 2024.
- Siméoni et al. DINOv3, arXiv:2508.10104, 2025.
- Umeyama. Least-squares estimation of transformation parameters between two point patterns, TPAMI 1991.
