Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df87c6531f | ||
|
|
ad1a99a169 | ||
|
|
520434be3a | ||
|
|
6e49c78b04 | ||
|
|
800c960dbe | ||
|
|
4d6f29a113 | ||
|
|
a79d113791 |
29
.github/kaggle/kernels.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$comment": [
|
||||
"'slug' is where the kernel lives on Kaggle right now; 'title' is what it should be called.",
|
||||
"Kaggle re-slugs a kernel to match its title, so a push whose title differs renames the kernel",
|
||||
"and its old URL stops resolving. The kernel survives with its upvotes, comments and version",
|
||||
"history (measured 2026-08-20). After such a push, update the slug here to where it landed;",
|
||||
"sync.py prints the exact edit, and finds the kernel by title in the meantime.",
|
||||
"'live': true marks a slug that already exists. sync.py refuses to push one it cannot find",
|
||||
"under either name, so a mistyped slug fails loudly instead of stranding the original.",
|
||||
"Titles come from each notebook H1, keeping examples/ the single source of truth."
|
||||
],
|
||||
"kernels": [
|
||||
{"notebook": "01_face_detection.ipynb", "slug": "face-detection-with-uniface", "title": "Face Detection with UniFace", "live": true},
|
||||
{"notebook": "02_face_alignment.ipynb", "slug": "face-detection-and-alignment-with-uniface", "title": "Face Detection and Alignment with UniFace", "live": true},
|
||||
{"notebook": "03_face_verification.ipynb", "slug": "face-verification-one-to-one-face-comparison", "title": "Face Verification: One-to-One Face Comparison", "live": true},
|
||||
{"notebook": "04_face_search.ipynb", "slug": "face-search-one-to-many-face-matching", "title": "Face Search: One-to-Many Face Matching", "live": true},
|
||||
{"notebook": "05_face_analyzer.ipynb", "slug": "face-analysis-with-uniface", "title": "Face Analysis with UniFace", "live": true},
|
||||
{"notebook": "06_face_parsing.ipynb", "slug": "face-parsing-with-uniface", "title": "Face Parsing with UniFace", "live": true},
|
||||
{"notebook": "07_face_anonymization.ipynb", "slug": "face-anonymization-with-uniface", "title": "Face Anonymization with UniFace", "live": true},
|
||||
{"notebook": "08_gaze_estimation.ipynb", "slug": "gaze-estimation-with-uniface", "title": "Gaze Estimation with UniFace", "live": true},
|
||||
{"notebook": "09_face_segmentation.ipynb", "slug": "xseg-face-segmentation", "title": "XSeg Face Segmentation", "live": true},
|
||||
{"notebook": "10_face_vector_store.ipynb", "slug": "face-vector-store-with-faiss", "title": "Face Vector Store with FAISS", "live": true},
|
||||
{"notebook": "11_head_pose_estimation.ipynb", "slug": "head-pose-estimation-with-uniface", "title": "Head Pose Estimation with UniFace", "live": true},
|
||||
{"notebook": "12_face_recognition.ipynb", "slug": "face-recognition-retinaface-align-arcface", "title": "Face Recognition: RetinaFace → Align → ArcFace", "live": true},
|
||||
{"notebook": "13_portrait_matting.ipynb", "slug": "portrait-matting-with-modnet", "title": "Portrait Matting with MODNet", "live": true},
|
||||
{"notebook": "14_face_attributes.ipynb", "slug": "face-attribute-detection-with-uniface", "title": "Face Attribute Detection with UniFace", "live": true},
|
||||
{"notebook": "15_face_mesh.ipynb", "slug": "dense-face-mesh-with-uniface", "title": "Dense Face Mesh with UniFace", "live": true}
|
||||
]
|
||||
}
|
||||
513
.github/kaggle/sync.py
vendored
Normal file
@@ -0,0 +1,513 @@
|
||||
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
|
||||
# Author: Yakhyokhuja Valikhujaev
|
||||
# GitHub: https://github.com/yakhyo
|
||||
|
||||
"""Publish the notebooks in ``examples/`` to Kaggle as kernels.
|
||||
|
||||
Each notebook maps to one kernel slug in ``kernels.json`` beside this script. Pushing a known slug
|
||||
adds a *new version* to that kernel rather than creating another one, so its upvotes, comments and
|
||||
version history carry over; nothing is ever deleted or re-created.
|
||||
|
||||
The slug is not fixed, though. Kaggle re-slugs a kernel to match its title, so changing a title
|
||||
moves the kernel's URL and the old address stops resolving (measured 2026-08-20: retitling
|
||||
``face-detection-with-uniface-python-library`` moved it to ``face-detection-with-uniface``, which
|
||||
then 404s, while the kernel itself kept its history). Renaming is therefore done by pushing the OLD
|
||||
slug with the NEW title, and the manifest holds both: ``slug`` is where the kernel lives now,
|
||||
``title`` is what it should be called. A mismatch on a live entry means a pending rename and is
|
||||
reported, not rejected; on a new entry it is simply wrong, because a new kernel lands at
|
||||
slugify(title) regardless. Keep the old title whenever the current URL matters more than the name.
|
||||
|
||||
Kaggle runs the kernel itself once the version lands, so this script pushes and exits without
|
||||
waiting for the run to finish.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404 - only ever runs the Kaggle CLI with literal arguments
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST = Path(__file__).resolve().parent / 'kernels.json'
|
||||
EXAMPLES_DIR = REPO_ROOT / 'examples'
|
||||
|
||||
# Notebooks reach their assets by cloning the repo, which only happens when this env var is visible.
|
||||
KAGGLE_ENV_GUARD = 'KAGGLE_KERNEL_RUN_TYPE'
|
||||
|
||||
# Each notebook carries an 'Open in Kaggle' badge; this is how one is recognised.
|
||||
KAGGLE_BADGE = 'kaggle.com/static/images/open-in-kaggle.svg'
|
||||
|
||||
# Stands in for the owner when staging without credentials, so a dry run still works offline.
|
||||
PLACEHOLDER_OWNER = '<KAGGLE_USERNAME>'
|
||||
|
||||
# Kaggle refuses a push while five batch sessions are already running; this is how it says so.
|
||||
SESSION_LIMIT = 'Maximum batch CPU session count'
|
||||
|
||||
# Kaggle's own rule for the identifier half of a kernel reference.
|
||||
SLUG_PATTERN = re.compile(r'[a-z0-9][a-z0-9-]{3,58}[a-z0-9]')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteKernel:
|
||||
"""A kernel as Kaggle currently holds it."""
|
||||
|
||||
title: str
|
||||
votes: int
|
||||
|
||||
@property
|
||||
def vote_count(self) -> str:
|
||||
"""Return the vote tally as English, e.g. ``"1 vote"`` or ``"5 votes"``."""
|
||||
return f'{self.votes} vote' if self.votes == 1 else f'{self.votes} votes'
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
"""Reduce a kernel title to the slug Kaggle would derive from it.
|
||||
|
||||
This is where a kernel lands after a push, so it doubles as the slug a new kernel is created
|
||||
at and the slug a renamed one moves to.
|
||||
|
||||
Args:
|
||||
title: Human-readable kernel title.
|
||||
|
||||
Returns:
|
||||
Lowercase dash-separated slug.
|
||||
"""
|
||||
return re.sub(r'-+', '-', re.sub(r'[^a-z0-9]+', '-', title.lower())).strip('-')
|
||||
|
||||
|
||||
def resolve_owner(explicit: str | None) -> str | None:
|
||||
"""Find the Kaggle account that owns the kernels.
|
||||
|
||||
Args:
|
||||
explicit: Owner passed on the command line, or ``None``.
|
||||
|
||||
Returns:
|
||||
Kaggle username, or ``None`` when no credentials are available.
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
if os.environ.get('KAGGLE_USERNAME'):
|
||||
return os.environ['KAGGLE_USERNAME']
|
||||
|
||||
config_dir = Path(os.environ.get('KAGGLE_CONFIG_DIR', Path.home() / '.kaggle'))
|
||||
config = config_dir / 'kaggle.json'
|
||||
if config.is_file():
|
||||
return json.loads(config.read_text(encoding='utf-8')).get('username')
|
||||
return None
|
||||
|
||||
|
||||
def load_kernels(manifest: Path) -> list[dict]:
|
||||
"""Read the kernel manifest.
|
||||
|
||||
Args:
|
||||
manifest: Path to ``kernels.json``.
|
||||
|
||||
Returns:
|
||||
List of kernel entries.
|
||||
"""
|
||||
return json.loads(manifest.read_text(encoding='utf-8'))['kernels']
|
||||
|
||||
|
||||
def validate(kernels: list[dict], examples_dir: Path) -> list[str]:
|
||||
"""Check the manifest against the notebooks on disk.
|
||||
|
||||
Args:
|
||||
kernels: Kernel entries from the manifest.
|
||||
examples_dir: Directory holding the source notebooks.
|
||||
|
||||
Returns:
|
||||
List of error messages; empty when the manifest is consistent.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
seen_slugs: dict[str, str] = {}
|
||||
|
||||
for entry in kernels:
|
||||
notebook = examples_dir / entry['notebook']
|
||||
if not notebook.is_file():
|
||||
errors.append(f'{entry["notebook"]}: listed in the manifest but missing from {examples_dir.name}/')
|
||||
else:
|
||||
# The notebook's own Kaggle badge has to name the slug the push will land on, or every
|
||||
# published copy advertises an address that stopped resolving the moment it moved.
|
||||
landing = slugify(entry['title'])
|
||||
text = notebook.read_text(encoding='utf-8')
|
||||
if KAGGLE_BADGE in text and f'/{landing})' not in text:
|
||||
errors.append(f'{entry["notebook"]}: its Kaggle badge does not point at /{landing}')
|
||||
if entry['slug'] in seen_slugs:
|
||||
errors.append(f'{entry["slug"]}: slug reused by {seen_slugs[entry["slug"]]} and {entry["notebook"]}')
|
||||
seen_slugs[entry['slug']] = entry['notebook']
|
||||
if not SLUG_PATTERN.fullmatch(entry['slug']):
|
||||
errors.append(f'{entry["slug"]}: not a valid Kaggle slug (5-60 chars, lowercase letters, digits, dashes)')
|
||||
if len(entry['title']) < 5:
|
||||
errors.append(f'{entry["notebook"]}: title "{entry["title"]}" is under Kaggle\'s five-character minimum')
|
||||
# Kaggle re-slugs a kernel to match its title, so a kernel that does not exist yet will land
|
||||
# at slugify(title) whatever the manifest claims. On a live kernel the same mismatch is
|
||||
# meaningful instead of wrong: it is a pending rename, reported by the push rather than
|
||||
# rejected here, because renaming requires pushing the OLD slug with the NEW title.
|
||||
if not entry.get('live') and entry['slug'] != slugify(entry['title']):
|
||||
errors.append(
|
||||
f'{entry["notebook"]}: a new kernel titled "{entry["title"]}" lands at '
|
||||
f'"{slugify(entry["title"])}", not "{entry["slug"]}". Match them.'
|
||||
)
|
||||
|
||||
mapped = {entry['notebook'] for entry in kernels}
|
||||
for notebook in sorted(examples_dir.glob('*.ipynb')):
|
||||
if notebook.name not in mapped:
|
||||
errors.append(f'{notebook.name}: no entry in {MANIFEST.name} — add one so it is not silently skipped')
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def check_kaggle_guard(notebook: Path) -> bool:
|
||||
"""Report whether a notebook clones the repo when it runs on Kaggle.
|
||||
|
||||
Args:
|
||||
notebook: Path to the source notebook.
|
||||
|
||||
Returns:
|
||||
``True`` when the Kaggle branch of the setup cell is present.
|
||||
"""
|
||||
return KAGGLE_ENV_GUARD in notebook.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def build_metadata(entry: dict, owner: str) -> dict:
|
||||
"""Build the ``kernel-metadata.json`` payload for one notebook.
|
||||
|
||||
Args:
|
||||
entry: Kernel entry from the manifest.
|
||||
owner: Kaggle username that owns the kernel.
|
||||
|
||||
Returns:
|
||||
Metadata dict ready to be written next to the notebook.
|
||||
"""
|
||||
return {
|
||||
'id': f'{owner}/{entry["slug"]}',
|
||||
'title': entry['title'],
|
||||
'code_file': entry['notebook'],
|
||||
'language': 'python',
|
||||
'kernel_type': 'notebook',
|
||||
'is_private': False,
|
||||
# Notebooks pip-install uniface and download ONNX weights on first use.
|
||||
'enable_internet': True,
|
||||
'enable_gpu': entry.get('enable_gpu', False),
|
||||
'enable_tpu': False,
|
||||
'dataset_sources': entry.get('dataset_sources', []),
|
||||
'competition_sources': [],
|
||||
'kernel_sources': [],
|
||||
'model_sources': [],
|
||||
}
|
||||
|
||||
|
||||
def stage(entry: dict, examples_dir: Path, staging_root: Path, owner: str) -> Path:
|
||||
"""Copy a notebook and its metadata into a directory Kaggle can push.
|
||||
|
||||
Args:
|
||||
entry: Kernel entry from the manifest.
|
||||
examples_dir: Directory holding the source notebooks.
|
||||
staging_root: Directory to create the per-kernel folder under.
|
||||
owner: Kaggle username that owns the kernel.
|
||||
|
||||
Returns:
|
||||
The staged directory.
|
||||
"""
|
||||
staged = staging_root / entry['slug']
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(examples_dir / entry['notebook'], staged / entry['notebook'])
|
||||
(staged / 'kernel-metadata.json').write_text(
|
||||
json.dumps(build_metadata(entry, owner), indent=2) + '\n', encoding='utf-8'
|
||||
)
|
||||
return staged
|
||||
|
||||
|
||||
def push_kernel(staged: Path, retries: int, wait: int) -> tuple[int, str]:
|
||||
"""Push one staged kernel, waiting out Kaggle's concurrent-session cap.
|
||||
|
||||
Kaggle runs every pushed version and refuses a push once five batch sessions are already
|
||||
running, so a fifteen-notebook sync cannot be submitted in one burst. Each rejection is
|
||||
retried rather than reported, since it means "not yet", not "no".
|
||||
|
||||
Args:
|
||||
staged: Directory holding the notebook and its ``kernel-metadata.json``.
|
||||
retries: How many times to wait for a session slot before giving up.
|
||||
wait: Seconds to wait between attempts.
|
||||
|
||||
Returns:
|
||||
The final return code and the combined output of the last attempt.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the Kaggle CLI is not on PATH.
|
||||
"""
|
||||
for attempt in range(retries + 1):
|
||||
result = run_kaggle(['kernels', 'push', '-p', str(staged)])
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
if SESSION_LIMIT not in output:
|
||||
return result.returncode, output
|
||||
if attempt < retries:
|
||||
print(f' Kaggle is already running its five batch sessions; retrying in {wait}s')
|
||||
time.sleep(wait)
|
||||
return result.returncode, output
|
||||
|
||||
|
||||
def run_kaggle(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the Kaggle CLI.
|
||||
|
||||
Args:
|
||||
args: Arguments after the ``kaggle`` executable.
|
||||
|
||||
Returns:
|
||||
The completed process, with stdout and stderr captured as text.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the Kaggle CLI is not on PATH.
|
||||
"""
|
||||
executable = shutil.which('kaggle')
|
||||
if executable is None:
|
||||
raise FileNotFoundError('kaggle')
|
||||
return subprocess.run([executable, *args], capture_output=True, text=True, check=False) # nosec B603
|
||||
|
||||
|
||||
def fetch_remote(owner: str) -> dict[str, RemoteKernel] | None:
|
||||
"""Read the account's kernels, keyed by slug.
|
||||
|
||||
``kernels status`` cannot tell a missing kernel from a private one — both answer with the same
|
||||
permission error — so existence is settled from a listing instead. ``--mine`` covers private
|
||||
kernels the public ``--user`` view omits, and the two are merged.
|
||||
|
||||
Args:
|
||||
owner: Kaggle username to list.
|
||||
|
||||
Returns:
|
||||
Slug to :class:`RemoteKernel`, or ``None`` when the listing could not be read at all.
|
||||
"""
|
||||
remote: dict[str, RemoteKernel] = {}
|
||||
reachable = False
|
||||
|
||||
for scope in (['--mine'], ['--user', owner]):
|
||||
try:
|
||||
result = run_kaggle(['kernels', 'list', *scope, '--page-size', '100', '-v'])
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
continue
|
||||
reachable = True
|
||||
# Columns are ref,title,author,lastRunTime,totalVotes; only real rows carry an owner/slug ref.
|
||||
for row in csv.reader(result.stdout.splitlines()):
|
||||
if len(row) < 5 or '/' not in row[0]:
|
||||
continue
|
||||
row_owner, _, slug = row[0].strip().partition('/')
|
||||
if row_owner != owner:
|
||||
continue
|
||||
remote[slug] = RemoteKernel(title=row[1].strip(), votes=int(row[4]) if row[4].isdigit() else 0)
|
||||
|
||||
return remote if reachable else None
|
||||
|
||||
|
||||
def resolve_target(entry: dict, remote: dict[str, RemoteKernel] | None) -> tuple[str | None, RemoteKernel | None]:
|
||||
"""Work out which slug to push a manifest entry against.
|
||||
|
||||
A kernel is looked for under its manifest slug first, then under the slug its title implies.
|
||||
The second lookup is what makes a rename idempotent: once Kaggle has moved a kernel to match a
|
||||
new title, the manifest's old slug stops resolving, and only the title-derived one finds it.
|
||||
|
||||
Args:
|
||||
entry: Kernel entry from the manifest.
|
||||
remote: Kernels currently on the account, or ``None`` when the listing could not be read.
|
||||
|
||||
Returns:
|
||||
The slug to push against and the kernel already there, if any. The slug is ``None`` when a
|
||||
kernel marked live cannot be found under either name, which means its slug was mistyped.
|
||||
"""
|
||||
if remote is None:
|
||||
return entry['slug'], None
|
||||
if entry['slug'] in remote:
|
||||
return entry['slug'], remote[entry['slug']]
|
||||
renamed = slugify(entry['title'])
|
||||
if renamed in remote:
|
||||
return renamed, remote[renamed]
|
||||
return (None, None) if entry.get('live') else (entry['slug'], None)
|
||||
|
||||
|
||||
def list_remote(owner: str, kernels: list[dict]) -> int:
|
||||
"""Print the account's kernels and reconcile them against the manifest.
|
||||
|
||||
Args:
|
||||
owner: Kaggle username to list.
|
||||
kernels: Kernel entries from the manifest.
|
||||
|
||||
Returns:
|
||||
Process exit code.
|
||||
"""
|
||||
remote = fetch_remote(owner)
|
||||
if remote is None:
|
||||
print(f'Could not list kernels for {owner}. Check the kaggle CLI and its credentials.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f'Kernels on {owner} ({len(remote)} found):')
|
||||
for slug, kernel in sorted(remote.items()):
|
||||
print(f' {kernel.vote_count:>8} {slug} "{kernel.title}"')
|
||||
|
||||
print('\nManifest:')
|
||||
for entry in kernels:
|
||||
kernel = remote.get(entry['slug'])
|
||||
state = f'live, {kernel.vote_count}' if kernel else 'new'
|
||||
print(f' [{state}] {entry["slug"]} <- {entry["notebook"]}')
|
||||
if kernel and kernel.title != entry['title']:
|
||||
print(f' retitle "{kernel.title}" -> "{entry["title"]}"; the URL moves to {entry["slug"]}')
|
||||
if entry.get('live') and not kernel:
|
||||
print(' ORPHAN RISK: marked live in the manifest but absent from Kaggle.')
|
||||
|
||||
unmapped = set(remote) - {entry['slug'] for entry in kernels}
|
||||
if unmapped:
|
||||
print('\nOn Kaggle but not in the manifest (pushing would not touch these):')
|
||||
for slug in sorted(unmapped):
|
||||
print(f' {slug}')
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='Publish examples/*.ipynb to Kaggle as kernels')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Stage and validate, push nothing')
|
||||
parser.add_argument('--only', help='Only sync kernels whose notebook or slug contains this substring')
|
||||
parser.add_argument('--owner', help='Kaggle username (defaults to KAGGLE_USERNAME or ~/.kaggle/kaggle.json)')
|
||||
parser.add_argument('--list', action='store_true', help='List the account kernels and reconcile with the manifest')
|
||||
parser.add_argument('--stage-dir', type=Path, help='Keep the staged kernels here instead of a temp directory')
|
||||
parser.add_argument(
|
||||
'--session-wait', type=int, default=60, help='Seconds to wait for a free Kaggle session slot (default: 60)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--session-retries', type=int, default=30, help='How many times to wait for a slot (default: 30)'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
kernels = load_kernels(MANIFEST)
|
||||
|
||||
errors = validate(kernels, EXAMPLES_DIR)
|
||||
if errors:
|
||||
print('Manifest is out of sync with examples/:', file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f' {error}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
owner = resolve_owner(args.owner)
|
||||
if owner is None:
|
||||
if args.list or not args.dry_run:
|
||||
print('No Kaggle credentials. Set KAGGLE_USERNAME and KAGGLE_KEY, or pass --owner.', file=sys.stderr)
|
||||
return 1
|
||||
owner = PLACEHOLDER_OWNER
|
||||
print('No Kaggle credentials found; staging with a placeholder owner.\n')
|
||||
|
||||
if args.list:
|
||||
if args.only:
|
||||
print('--only is ignored by --list, which always shows every kernel.\n')
|
||||
return list_remote(owner, kernels)
|
||||
|
||||
if args.only:
|
||||
kernels = [e for e in kernels if args.only in e['notebook'] or args.only in e['slug']]
|
||||
if not kernels:
|
||||
print(f'No kernel matches --only {args.only!r}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for entry in kernels:
|
||||
if not check_kaggle_guard(EXAMPLES_DIR / entry['notebook']):
|
||||
print(f'Warning: {entry["notebook"]} has no {KAGGLE_ENV_GUARD} branch; its assets will be missing.')
|
||||
|
||||
# One listing settles existence for every kernel, so the loop below makes no extra API calls.
|
||||
remote = fetch_remote(owner) if owner != PLACEHOLDER_OWNER else None
|
||||
if remote is None:
|
||||
print('Could not read the account listing; update-vs-create is unknown for every kernel.\n')
|
||||
|
||||
staging_root = args.stage_dir
|
||||
temp_dir = None
|
||||
if staging_root is None:
|
||||
temp_dir = tempfile.TemporaryDirectory(prefix='kaggle-sync-')
|
||||
staging_root = Path(temp_dir.name)
|
||||
staging_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed: list[str] = []
|
||||
renamed: list[tuple[str, str, str]] = []
|
||||
try:
|
||||
for entry in kernels:
|
||||
target, current = resolve_target(entry, remote)
|
||||
|
||||
if target is None:
|
||||
ref = f'{owner}/{entry["slug"]}'
|
||||
message = f'{ref}: marked live but Kaggle has it under neither that slug nor the one its title implies'
|
||||
if args.dry_run:
|
||||
print(f'[dry-run] {ref} (BLOCKED) <- {entry["notebook"]}')
|
||||
print(f' ORPHAN RISK: {message}.')
|
||||
continue
|
||||
print(f'Refusing {message}.', file=sys.stderr)
|
||||
print(' Restore the original slug, or drop "live" if the kernel is genuinely new.', file=sys.stderr)
|
||||
failed.append(ref)
|
||||
continue
|
||||
|
||||
ref = f'{owner}/{target}'
|
||||
moves_to = slugify(entry['title']) if current and current.title != entry['title'] else None
|
||||
# Push against the slug Kaggle knows; a differing title is what asks it to rename.
|
||||
staged = stage({**entry, 'slug': target}, EXAMPLES_DIR, staging_root, owner)
|
||||
|
||||
if args.dry_run:
|
||||
if current:
|
||||
state = f'update existing, {current.vote_count}'
|
||||
elif remote is not None:
|
||||
state = 'CREATE NEW'
|
||||
else:
|
||||
state = 'unknown'
|
||||
print(f'[dry-run] {ref} ({state}) <- {entry["notebook"]}')
|
||||
if moves_to:
|
||||
print(f' retitle "{current.title}" -> "{entry["title"]}"')
|
||||
print(f' URL MOVES {target} -> {moves_to}; the old address stops resolving.')
|
||||
continue
|
||||
|
||||
print(f'Pushing {ref} <- {entry["notebook"]}')
|
||||
try:
|
||||
returncode, output = push_kernel(staged, args.session_retries, args.session_wait)
|
||||
except FileNotFoundError:
|
||||
print('The kaggle CLI is not installed. Run: pip install kaggle', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if output:
|
||||
print(' ' + output.replace('\n', '\n '))
|
||||
if returncode != 0 or 'error' in output.lower():
|
||||
failed.append(ref)
|
||||
continue
|
||||
|
||||
landed = moves_to or target
|
||||
print(f' https://www.kaggle.com/code/{owner}/{landed}')
|
||||
if moves_to:
|
||||
renamed.append((entry['notebook'], target, moves_to))
|
||||
finally:
|
||||
if temp_dir is not None:
|
||||
temp_dir.cleanup()
|
||||
elif args.dry_run:
|
||||
print(f'\nStaged in {staging_root}')
|
||||
|
||||
if renamed:
|
||||
# The manifest now names slugs that no longer exist. resolve_target still finds these
|
||||
# kernels by title, but leaving the stale slugs in place makes every later run rely on
|
||||
# that fallback instead of saying plainly where each kernel lives.
|
||||
print(f'\n{len(renamed)} kernel(s) moved. Update {MANIFEST.name}:')
|
||||
for notebook, before, after in renamed:
|
||||
print(f' {notebook}: "slug": "{before}" -> "{after}"')
|
||||
|
||||
if failed:
|
||||
print(f'\n{len(failed)} kernel(s) failed to push:', file=sys.stderr)
|
||||
for ref in failed:
|
||||
print(f' {ref}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not args.dry_run:
|
||||
print(f'\nPushed {len(kernels)} kernel(s). Kaggle runs each new version on its own.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
28
.github/workflows/docs.yml
vendored
@@ -4,11 +4,20 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -28,11 +37,14 @@ jobs:
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
|
||||
run: uv run mkdocs build --strict
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
destination_dir: docs
|
||||
path: ./site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
65
.github/workflows/kaggle.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Publish Notebooks to Kaggle
|
||||
|
||||
# Keeps the Kaggle kernels in step with examples/ so they cannot drift from the
|
||||
# released API. Runs on a published release, or on demand.
|
||||
#
|
||||
# Each push adds a new version to an existing kernel, so its upvotes, comments and
|
||||
# history carry over. Kaggle runs each new version itself and this job does not wait
|
||||
# for those runs, but it does pace itself: Kaggle refuses a push while five batch
|
||||
# sessions are already going, so submitting all fifteen takes a few waves.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'list = show live kernels, dry-run = stage only, publish = push for real'
|
||||
type: choice
|
||||
options: [dry-run, list, publish]
|
||||
default: dry-run
|
||||
only:
|
||||
description: 'Sync only kernels matching this substring (blank = all)'
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: kaggle-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Kaggle CLI
|
||||
run: python -m pip install --upgrade pip kaggle
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
KAGGLE_USERNAME: ${{ secrets.KAGGLE_USERNAME }}
|
||||
KAGGLE_KEY: ${{ secrets.KAGGLE_KEY }}
|
||||
# Read through the environment so a hand-typed value cannot reach the shell as code.
|
||||
MODE: ${{ inputs.mode }}
|
||||
ONLY: ${{ inputs.only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cmd=(python .github/kaggle/sync.py)
|
||||
# A release carries no inputs, so an empty MODE means publish.
|
||||
case "$MODE" in
|
||||
list) cmd+=(--list) ;;
|
||||
dry-run) cmd+=(--dry-run) ;;
|
||||
esac
|
||||
if [ -n "$ONLY" ]; then
|
||||
cmd+=(--only "$ONLY")
|
||||
fi
|
||||
"${cmd[@]}"
|
||||
27
.github/workflows/pipeline.yml
vendored
@@ -196,7 +196,15 @@ jobs:
|
||||
needs: [validate, publish]
|
||||
if: needs.validate.outputs.is_prerelease == 'false'
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
@@ -219,11 +227,14 @@ jobs:
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
|
||||
run: uv run mkdocs build --strict
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
destination_dir: docs
|
||||
path: ./site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
4
.gitignore
vendored
@@ -1,5 +1,9 @@
|
||||
tmp_*
|
||||
.vscode/
|
||||
.claude
|
||||
|
||||
# CLI tools in tools/ write results here by default (--save-dir)
|
||||
outputs/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
64
CHANGELOG.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to UniFace are documented here. Earlier releases are covered by
|
||||
the autogenerated notes on the [GitHub releases page](https://github.com/yakhyo/uniface/releases).
|
||||
|
||||
## 4.0.0 - 2026-08-03
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **Factory functions are gone.** `create_detector()` and its siblings no longer
|
||||
exist. Construct model classes directly.
|
||||
- **`FaceAnalyzer(attributes=...)` is now `predictors=`.** The parameter accepts
|
||||
any list of `BaseAttribute` subclasses.
|
||||
- **`Attribute` is renamed `BaseAttribute`**, matching `BaseDetector`,
|
||||
`BaseRecognizer`, and the other base classes.
|
||||
- **Constructors are keyword-only.** Every model class, plus `FaceAnalyzer`,
|
||||
`BlurFace`, `EllipticalBlur`, `FAISS`, and `BYTETracker`, rejects positional
|
||||
arguments. `SCRFD(model_name)` raises `TypeError`; write
|
||||
`SCRFD(model_name=...)`.
|
||||
- **Detector `**kwargs` replaced with explicit options.** `RetinaFace` takes
|
||||
`pre_nms_topk`, `post_nms_topk`, and `dynamic_size`; `YOLOv5Face` and
|
||||
`YOLOv8Face` take `max_det`. Misspelled options now fail instead of being
|
||||
silently ignored.
|
||||
- **`input_size` removed from `MobileGaze`, `HeadPose`, `BiSeNet`, and
|
||||
`Landmark106`.** These models always resized to the size in the ONNX graph;
|
||||
the parameter had no effect and is gone.
|
||||
- **Inputs are validated.** Detectors and landmarkers require 3-channel `uint8`
|
||||
BGR images. Float or grayscale arrays now raise `ValueError` instead of
|
||||
silently returning no faces.
|
||||
- **Detector capability flags are opt-in.** `supports_landmarks` and
|
||||
`supports_alignment` default to `False` on `BaseDetector`. Third-party
|
||||
detector subclasses that produce 5-point alignment landmarks must now declare
|
||||
both flags; boxes-only subclasses need no declaration.
|
||||
|
||||
### Migrating from 3.x
|
||||
|
||||
| 3.x | 4.0 |
|
||||
| --- | --- |
|
||||
| `create_detector('retinaface', ...)` | `RetinaFace(...)` |
|
||||
| `RetinaFace(RetinaFaceWeights.MNET_V2)` | `RetinaFace(model_name=RetinaFaceWeights.MNET_V2)` |
|
||||
| `FaceAnalyzer(attributes=[AgeGender()])` | `FaceAnalyzer(predictors=[AgeGender()])` |
|
||||
| `class MyPredictor(Attribute)` | `class MyPredictor(BaseAttribute)` |
|
||||
| `RetinaFace(**{'pre_nms_topk': 1000})` | `RetinaFace(pre_nms_topk=1000)` |
|
||||
| `HeadPose(input_size=(224, 224))` | `HeadPose()` |
|
||||
|
||||
### Added
|
||||
|
||||
- `BlazeFace` detector: MediaPipe short-range face detector with 6 keypoints.
|
||||
- `CenterFace` detector.
|
||||
- `FaceMesh` landmarker: MediaPipe dense 3D face mesh, 468 points or 478 with
|
||||
irises (`FaceMeshWeights.V2_478`).
|
||||
- `FaceAttribNet` predictor: eye openness, eyeglasses, sunglasses, and mask
|
||||
probabilities as `FaceStateResult`, enriching `Face` in-place.
|
||||
- Hugging Face mirror fallback for weight downloads when GitHub Releases is
|
||||
unreachable, pinned to an immutable revision.
|
||||
- `uniface.common.validate_image` is public for use in custom models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `BlazeFace` weighted NMS could loop forever with `iou_threshold=1.0` or a
|
||||
zero-area box; it now always terminates.
|
||||
- `Face.__repr__` no longer assumes all five face-state fields are set together.
|
||||
- Attribute models normalize `input_size` and warn when it disagrees with the
|
||||
ONNX metadata; `FaceAttribNet` rejects non-square sizes.
|
||||
@@ -88,22 +88,22 @@ def process(items: List[str], config: Optional[Dict[str, int]] = None) -> Tuple[
|
||||
Use [Google-style docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) for all public APIs:
|
||||
|
||||
```python
|
||||
def create_detector(method: str = 'retinaface', **kwargs: Any) -> BaseDetector:
|
||||
"""Factory function to create face detectors.
|
||||
def detect(self, image: np.ndarray, **kwargs: Any) -> list[Face]:
|
||||
"""Detect faces in an image.
|
||||
|
||||
Args:
|
||||
method: Detection method. Options: 'retinaface', 'scrfd', 'yolov5face', 'yolov8face'.
|
||||
**kwargs: Detector-specific parameters.
|
||||
image: Input image as numpy array with shape (H, W, C) in BGR format.
|
||||
**kwargs: Additional detection parameters.
|
||||
|
||||
Returns:
|
||||
Initialized detector instance.
|
||||
List of detected Face objects.
|
||||
|
||||
Raises:
|
||||
ValueError: If method is not supported.
|
||||
ValueError: If the image is empty, not 3-channel BGR, or not uint8.
|
||||
|
||||
Example:
|
||||
>>> from uniface import create_detector
|
||||
>>> detector = create_detector('retinaface', confidence_threshold=0.8)
|
||||
>>> from uniface import RetinaFace
|
||||
>>> detector = RetinaFace(confidence_threshold=0.8)
|
||||
>>> faces = detector.detect(image)
|
||||
>>> print(f"Found {len(faces)} faces")
|
||||
"""
|
||||
@@ -159,7 +159,7 @@ pytest tests/
|
||||
pytest tests/ -v
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_factory.py
|
||||
pytest tests/test_scrfd.py
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=uniface --cov-report=html
|
||||
@@ -193,6 +193,10 @@ Example notebooks demonstrating library usage:
|
||||
| Face Segmentation | [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) |
|
||||
| Face Vector Store | [10_face_vector_store.ipynb](examples/10_face_vector_store.ipynb) |
|
||||
| Head Pose Estimation | [11_head_pose_estimation.ipynb](examples/11_head_pose_estimation.ipynb) |
|
||||
| Face Recognition | [12_face_recognition.ipynb](examples/12_face_recognition.ipynb) |
|
||||
| Portrait Matting | [13_portrait_matting.ipynb](examples/13_portrait_matting.ipynb) |
|
||||
| Face Attributes | [14_face_attributes.ipynb](examples/14_face_attributes.ipynb) |
|
||||
| Face Mesh | [15_face_mesh.ipynb](examples/15_face_mesh.ipynb) |
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
365
README.md
@@ -7,8 +7,8 @@
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/yakhyo/uniface/actions)
|
||||
[](https://pepy.tech/projects/uniface)
|
||||
[](https://yakhyo.github.io/uniface/)
|
||||
[](https://www.kaggle.com/yakhyokhuja/code)
|
||||
[](https://huggingface.co/spaces/yakhyo/uniface)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -16,328 +16,171 @@
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/.github/logos/uniface_rounded_q80.webp" width="90%" alt="UniFace - A Unified Face Analysis Library for Python">
|
||||
</div>
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
UniFace is a lightweight, production-ready Python library for face detection, recognition,<br>
|
||||
tracking, landmark analysis, face parsing, gaze estimation, and face attributes.
|
||||
</p>
|
||||
|
||||
**UniFace** is a lightweight, production-ready Python library for face detection, recognition, tracking, landmark analysis, face parsing, gaze estimation, and face attributes.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Face Detection** — RetinaFace, SCRFD, YOLOv5-Face, and YOLOv8-Face with 5-point landmarks
|
||||
- **Face Recognition** — AdaFace, ArcFace, EdgeFace, MobileFace, and SphereFace embeddings
|
||||
- **Face Tracking** — Multi-object tracking with [BYTETracker](https://github.com/yakhyo/bytetrack-tracker) for persistent IDs across video frames
|
||||
- **Facial Landmarks** — 106-point (2d106det) and 98 / 68-point (PIPNet) landmark localization (separate from the 5-point detector landmarks)
|
||||
- **Face Parsing** — BiSeNet semantic segmentation (19 classes), XSeg face masking
|
||||
- **Portrait Matting** — Trimap-free alpha matte with MODNet (background removal, green screen, compositing)
|
||||
- **Gaze Estimation** — Real-time gaze direction with MobileGaze
|
||||
- **Head Pose Estimation** — 3D head orientation (pitch, yaw, roll) with 6D rotation representation
|
||||
- **Attribute Analysis** — Age, gender, race (FairFace), and emotion
|
||||
- **Vector Store** — FAISS-backed embedding store for fast multi-identity search
|
||||
- **Anti-Spoofing** — Face liveness detection with MiniFASNet
|
||||
- **Face Quality Assessment** — eDifFIQA single-score quality (T/S/M/L, NIST FATE-Quality #1 with the L variant)
|
||||
- **Face Anonymization** — 5 blur methods for privacy protection
|
||||
- **Hardware Acceleration** — ARM64 (Apple Silicon), CUDA (NVIDIA), CPU
|
||||
|
||||
---
|
||||
|
||||
## Visual Examples
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><b>Face Detection</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/detection.jpg" width="100%"></td>
|
||||
<td align="center"><b>Gaze Estimation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/gaze.jpg" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>Head Pose Estimation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/headpose.jpg" width="100%"></td>
|
||||
<td align="center"><b>Age & Gender</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/age_gender.jpg" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>Face Verification</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/verification.jpg" width="80%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>106-Point Landmarks</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/landmarks.jpg" width="70%"></td>
|
||||
<td align="center"><b>98-Point Landmarks (PIPNet)</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/landmarks_pipnet.jpg" width="70%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>Face Parsing</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/parsing.jpg" width="80%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>Face Segmentation</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/segmentation.jpg" width="80%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>Portrait Matting</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/matting.jpg" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>Face Anonymization</b><br><img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demos/anonymization.jpg" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**CPU / Apple Silicon**
|
||||
<p align="center">
|
||||
<a href="https://yakhyo.github.io/uniface/quickstart/"><img src="https://img.shields.io/badge/Get%20Started-1f6feb?style=for-the-badge&logoColor=white" alt="Get Started"></a>
|
||||
|
||||
<a href="https://yakhyo.github.io/uniface/models/"><img src="https://img.shields.io/badge/Model%20Zoo-30363d?style=for-the-badge&logoColor=white" alt="Model Zoo"></a>
|
||||
|
||||
<a href="https://yakhyo.github.io/uniface/notebooks/"><img src="https://img.shields.io/badge/Notebooks-30363d?style=for-the-badge&logo=jupyter&logoColor=white" alt="Notebooks"></a>
|
||||
|
||||
<a href="https://yakhyo.github.io/uniface/"><img src="https://img.shields.io/badge/Full%20Docs-30363d?style=for-the-badge&logoColor=white" alt="Full Docs"></a>
|
||||
</p>
|
||||
|
||||
```bash
|
||||
pip install uniface[cpu]
|
||||
pip install "uniface[cpu]" # CPU and Apple Silicon
|
||||
pip install "uniface[gpu]" # NVIDIA CUDA
|
||||
pip install --pre "uniface[cpu]" # latest pre-release
|
||||
```
|
||||
|
||||
**GPU support (NVIDIA CUDA)**
|
||||
<details>
|
||||
<summary><b>A first script</b></summary>
|
||||
|
||||
```bash
|
||||
pip install uniface[gpu]
|
||||
```
|
||||
<br>
|
||||
|
||||
> **Why separate extras?** `onnxruntime` and `onnxruntime-gpu` conflict when both are installed — they own the same Python namespace. Installing only the extra you need prevents that conflict entirely.
|
||||
|
||||
**From source (latest version)**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yakhyo/uniface.git
|
||||
cd uniface && pip install -e ".[cpu]" # or .[gpu] for CUDA
|
||||
```
|
||||
|
||||
**FAISS vector store**
|
||||
|
||||
```bash
|
||||
pip install faiss-cpu # or faiss-gpu for CUDA
|
||||
```
|
||||
|
||||
**Optional dependencies**
|
||||
|
||||
- Emotion model uses TorchScript and requires `torch`:
|
||||
`pip install torch` (choose the correct build for your OS/CUDA)
|
||||
- YOLOv5-Face and YOLOv8-Face support faster NMS with `torchvision`:
|
||||
`pip install torch torchvision` then use `nms_mode='torchvision'`
|
||||
|
||||
---
|
||||
|
||||
## Model Downloads and Cache
|
||||
|
||||
Models are downloaded automatically on first use and verified via SHA-256.
|
||||
|
||||
Default cache location: `~/.uniface/models`
|
||||
|
||||
Override with the programmatic API or environment variable:
|
||||
|
||||
```python
|
||||
from uniface.model_store import get_cache_dir, set_cache_dir
|
||||
|
||||
set_cache_dir('/data/models')
|
||||
print(get_cache_dir()) # /data/models
|
||||
```
|
||||
|
||||
```bash
|
||||
export UNIFACE_CACHE_DIR=/data/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Example (Detection)
|
||||
`FaceAnalyzer` runs detection, alignment and recognition in one call. Attribute models are opt-in.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
from uniface.detection import RetinaFace
|
||||
from uniface import FaceAnalyzer, FairFace
|
||||
|
||||
detector = RetinaFace()
|
||||
analyzer = FaceAnalyzer(predictors=[FairFace()])
|
||||
|
||||
image = cv2.imread("photo.jpg")
|
||||
if image is None:
|
||||
raise ValueError("Failed to load image. Check the path to 'photo.jpg'.")
|
||||
|
||||
faces = detector.detect(image)
|
||||
|
||||
for face in faces:
|
||||
print(f"Confidence: {face.confidence:.2f}")
|
||||
print(f"BBox: {face.bbox}")
|
||||
print(f"Landmarks: {face.landmarks.shape}")
|
||||
for face in analyzer.analyze(cv2.imread("photo.jpg")):
|
||||
print(face.bbox, face.sex, face.age_group, face.embedding.shape)
|
||||
```
|
||||
|
||||
---
|
||||
`bbox`, `confidence`, `landmarks` and `embedding` are always set. Age, sex, race, emotion, quality
|
||||
and the face states stay `None` until you pass the predictor that fills them.
|
||||
|
||||
## Example (Face Analyzer)
|
||||
</details>
|
||||
|
||||
```python
|
||||
import cv2
|
||||
from uniface import FaceAnalyzer
|
||||
<details>
|
||||
<summary><b>All fifteen tasks, and which model does each</b></summary>
|
||||
|
||||
# Zero-config: uses SCRFD (500M) + ArcFace (MobileNet) by default
|
||||
analyzer = FaceAnalyzer()
|
||||
<br>
|
||||
|
||||
image = cv2.imread("photo.jpg")
|
||||
if image is None:
|
||||
raise ValueError("Failed to load image. Check the path to 'photo.jpg'.")
|
||||
| Task | Models |
|
||||
| --- | --- |
|
||||
| Face Detection | RetinaFace, SCRFD, CenterFace, YOLOv5-Face, YOLOv8-Face, BlazeFace |
|
||||
| Face Recognition | AdaFace, ArcFace, EdgeFace, MobileFace, SphereFace |
|
||||
| Face Tracking | BYTETracker, persistent IDs across video frames |
|
||||
| Facial Landmarks | 2d106det (106), PIPNet (98 / 68), Face Mesh (468 / 478, 3D) |
|
||||
| Face Parsing | BiSeNet (19 classes), XSeg masking |
|
||||
| Portrait Matting | MODNet, trimap-free |
|
||||
| Gaze Estimation | MobileGaze (ResNet-18 / 34 / 50, MobileNetV2) |
|
||||
| Head Pose | 6D rotation representation, pitch / yaw / roll |
|
||||
| Demographics | AgeGender, FairFace (age group, sex, race) |
|
||||
| Emotion | AffectNet-7 and AffectNet-8 |
|
||||
| Face States | FaceAttribNet: eyes, glasses, sunglasses, mask |
|
||||
| Face Quality | eDifFIQA (T / S / M / L) |
|
||||
| Anti-Spoofing | MiniFASNet liveness |
|
||||
| Anonymization | 5 blur methods |
|
||||
| Vector Store | FAISS-backed embedding search |
|
||||
|
||||
faces = analyzer.analyze(image)
|
||||
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.
|
||||
|
||||
for face in faces:
|
||||
print(face.bbox, face.embedding.shape if face.embedding is not None else None)
|
||||
```
|
||||
</details>
|
||||
|
||||
With attributes:
|
||||
<br>
|
||||
|
||||
```python
|
||||
from uniface import FaceAnalyzer, AgeGender
|
||||
### Find and measure faces
|
||||
|
||||
analyzer = FaceAnalyzer(attributes=[AgeGender()])
|
||||
faces = analyzer.analyze(image)
|
||||
**Face Detection** · [docs](https://yakhyo.github.io/uniface/modules/detection/)
|
||||
|
||||
for face in faces:
|
||||
print(f"{face.sex}, {face.age}y, embedding={face.embedding.shape}")
|
||||
```
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/detection.jpg" width="100%">
|
||||
|
||||
---
|
||||
**Facial Landmarks** · [docs](https://yakhyo.github.io/uniface/modules/landmarks/)
|
||||
|
||||
## Example (Portrait Matting)
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/landmarks.jpg" width="100%">
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import numpy as np
|
||||
from uniface.matting import MODNet
|
||||
**Face Mesh** · [docs](https://yakhyo.github.io/uniface/modules/landmarks/#face-mesh-468-or-478-points-3d)
|
||||
|
||||
matting = MODNet()
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_mesh.jpg" width="100%">
|
||||
|
||||
image = cv2.imread("portrait.jpg")
|
||||
matte = matting.predict(image) # (H, W) float32 in [0, 1]
|
||||
**Face Quality** · [docs](https://yakhyo.github.io/uniface/modules/quality/)
|
||||
|
||||
# Transparent PNG
|
||||
rgba = cv2.cvtColor(image, cv2.COLOR_BGR2BGRA)
|
||||
rgba[:, :, 3] = (matte * 255).astype(np.uint8)
|
||||
cv2.imwrite("transparent.png", rgba)
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/quality.jpg" width="100%">
|
||||
|
||||
# Green screen
|
||||
matte_3ch = matte[:, :, np.newaxis]
|
||||
bg = np.full_like(image, (0, 177, 64), dtype=np.uint8)
|
||||
result = (image * matte_3ch + bg * (1 - matte_3ch)).astype(np.uint8)
|
||||
cv2.imwrite("green_screen.jpg", result)
|
||||
```
|
||||
### Cut faces out
|
||||
|
||||
---
|
||||
**Face Parsing** · [docs](https://yakhyo.github.io/uniface/modules/parsing/)
|
||||
|
||||
## Jupyter Notebooks
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/parsing.jpg" width="100%">
|
||||
|
||||
| Example | Colab | Description |
|
||||
| -------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | --------------------------------------- |
|
||||
| [01_face_detection.ipynb](examples/01_face_detection.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/01_face_detection.ipynb) | Face detection and landmarks |
|
||||
| [02_face_alignment.ipynb](examples/02_face_alignment.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/02_face_alignment.ipynb) | Face alignment for recognition |
|
||||
| [03_face_verification.ipynb](examples/03_face_verification.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/03_face_verification.ipynb) | Compare faces for identity |
|
||||
| [04_face_search.ipynb](examples/04_face_search.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/04_face_search.ipynb) | Find a person in group photos |
|
||||
| [05_face_analyzer.ipynb](examples/05_face_analyzer.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/05_face_analyzer.ipynb) | Unified face analysis |
|
||||
| [06_face_parsing.ipynb](examples/06_face_parsing.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/06_face_parsing.ipynb) | Semantic face segmentation |
|
||||
| [07_face_anonymization.ipynb](examples/07_face_anonymization.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/07_face_anonymization.ipynb) | Privacy-preserving blur |
|
||||
| [08_gaze_estimation.ipynb](examples/08_gaze_estimation.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/08_gaze_estimation.ipynb) | Gaze direction estimation |
|
||||
| [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) | [](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) | [](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) | [](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) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/12_face_recognition.ipynb) | Standalone face recognition pipeline |
|
||||
| [13_portrait_matting.ipynb](examples/13_portrait_matting.ipynb) | [](https://colab.research.google.com/github/yakhyo/uniface/blob/main/examples/13_portrait_matting.ipynb) | Portrait matting with MODNet |
|
||||
**Face Segmentation** · [docs](https://yakhyo.github.io/uniface/modules/parsing/#xseg)
|
||||
|
||||
---
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation.jpg" width="100%">
|
||||
|
||||
## Documentation
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation_occluded.jpg" width="100%">
|
||||
|
||||
Full documentation: https://yakhyo.github.io/uniface/
|
||||
**Portrait Matting** · [docs](https://yakhyo.github.io/uniface/modules/matting/)
|
||||
|
||||
| Resource | Description |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| [Quickstart](https://yakhyo.github.io/uniface/quickstart/) | Get up and running in 5 minutes |
|
||||
| [Model Zoo](https://yakhyo.github.io/uniface/models/) | All models, benchmarks, and selection guide |
|
||||
| [API Reference](https://yakhyo.github.io/uniface/modules/detection/) | Detailed module documentation |
|
||||
| [Tutorials](https://yakhyo.github.io/uniface/recipes/image-pipeline/) | Step-by-step workflow examples |
|
||||
| [Guides](https://yakhyo.github.io/uniface/concepts/overview/) | Architecture and design principles |
|
||||
| [Datasets](https://yakhyo.github.io/uniface/datasets/) | Training data and evaluation benchmarks |
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting.jpg" width="100%">
|
||||
|
||||
---
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/matting_alt.jpg" width="100%">
|
||||
|
||||
## Execution Providers (ONNX Runtime)
|
||||
### Read where a head is pointing
|
||||
|
||||
```python
|
||||
from uniface.detection import RetinaFace
|
||||
**Head Pose** · [docs](https://yakhyo.github.io/uniface/modules/headpose/)
|
||||
|
||||
# Force CPU-only inference
|
||||
detector = RetinaFace(providers=["CPUExecutionProvider"])
|
||||
```
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/headpose.jpg" width="100%">
|
||||
|
||||
See more in the docs:
|
||||
https://yakhyo.github.io/uniface/concepts/execution-providers/
|
||||
**Gaze Estimation** · [docs](https://yakhyo.github.io/uniface/modules/gaze/)
|
||||
|
||||
---
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/gaze.jpg" width="100%">
|
||||
|
||||
## Datasets
|
||||
### Read a face
|
||||
|
||||
| Task | Training Dataset | Models |
|
||||
| ----------- | --------------------------- | ------------------------------------------- |
|
||||
| Detection | WIDER FACE | RetinaFace, SCRFD, YOLOv5-Face, YOLOv8-Face |
|
||||
| Recognition | MS1MV2 | MobileFace, SphereFace |
|
||||
| Recognition | WebFace600K | ArcFace |
|
||||
| Recognition | WebFace4M / 12M | AdaFace |
|
||||
| Recognition | MS1MV2 | EdgeFace |
|
||||
| Landmarks | WFLW, 300W+CelebA | PIPNet (98 / 68 pts) |
|
||||
| Gaze | Gaze360 | MobileGaze |
|
||||
| Head Pose | 300W-LP | HeadPose (ResNet, MobileNet) |
|
||||
| Parsing | CelebAMask-HQ | BiSeNet |
|
||||
| Attributes | CelebA, FairFace, AffectNet | AgeGender, FairFace, Emotion |
|
||||
**Age and Sex** · [docs](https://yakhyo.github.io/uniface/modules/attributes/)
|
||||
|
||||
> See [Datasets documentation](https://yakhyo.github.io/uniface/datasets/) for download links, benchmarks, and details.
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/demography.jpg" width="100%">
|
||||
|
||||
---
|
||||
**Emotion** · [docs](https://yakhyo.github.io/uniface/modules/attributes/#emotion)
|
||||
|
||||
## Licensing and Model Usage
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/emotion.jpg" width="100%">
|
||||
|
||||
UniFace is MIT-licensed, but several pretrained models carry their own licenses.
|
||||
Review: https://yakhyo.github.io/uniface/license-attribution/
|
||||
**Face States** · [docs](https://yakhyo.github.io/uniface/modules/attributes/#faceattribnet)
|
||||
|
||||
Notable examples:
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states.jpg" width="100%">
|
||||
|
||||
- YOLOv5-Face and YOLOv8-Face weights are GPL-3.0
|
||||
- FairFace weights are CC BY 4.0
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states_alt.jpg" width="100%">
|
||||
|
||||
If you plan commercial use, verify model license compatibility.
|
||||
### Tell a real face from a replay
|
||||
|
||||
---
|
||||
**Anti-Spoofing** · [docs](https://yakhyo.github.io/uniface/modules/spoofing/)
|
||||
|
||||
## References
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/spoofing.jpg" width="100%">
|
||||
|
||||
| Feature | Repository | Training | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------- | :------: | ------------------------------------ |
|
||||
| Detection | [retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch) | ✓ | RetinaFace PyTorch Training & Export |
|
||||
| Detection | [yolov5-face-onnx-inference](https://github.com/yakhyo/yolov5-face-onnx-inference) | - | YOLOv5-Face ONNX Inference |
|
||||
| Detection | [yolov8-face-onnx-inference](https://github.com/yakhyo/yolov8-face-onnx-inference) | - | YOLOv8-Face ONNX Inference |
|
||||
| Tracking | [bytetrack-tracker](https://github.com/yakhyo/bytetrack-tracker) | - | BYTETracker Multi-Object Tracking |
|
||||
| Recognition | [face-recognition](https://github.com/yakhyo/face-recognition) | ✓ | MobileFace, SphereFace Training |
|
||||
| Recognition | [edgeface-onnx](https://github.com/yakhyo/edgeface-onnx) | - | EdgeFace ONNX Inference |
|
||||
| Landmarks | [pipnet-onnx](https://github.com/yakhyo/pipnet-onnx) | - | PIPNet 98 / 68-point ONNX Inference |
|
||||
| Parsing | [face-parsing](https://github.com/yakhyo/face-parsing) | ✓ | BiSeNet Face Parsing |
|
||||
| Parsing | [face-segmentation](https://github.com/yakhyo/face-segmentation) | - | XSeg Face Segmentation |
|
||||
| Gaze | [gaze-estimation](https://github.com/yakhyo/gaze-estimation) | ✓ | MobileGaze Training |
|
||||
| Head Pose | [head-pose-estimation](https://github.com/yakhyo/head-pose-estimation) | ✓ | Head Pose Training (6DRepNet-style) |
|
||||
| Matting | [modnet](https://github.com/yakhyo/modnet) | - | MODNet Portrait Matting |
|
||||
| Anti-Spoofing | [face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing) | - | MiniFASNet Inference |
|
||||
| Quality | [face-image-quality-assessment](https://github.com/yakhyo/face-image-quality-assessment) | - | eDifFIQA ONNX Inference |
|
||||
| Attributes | [fairface-onnx](https://github.com/yakhyo/fairface-onnx) | - | FairFace ONNX Inference |
|
||||
### Match a face, or hide one
|
||||
|
||||
*SCRFD and ArcFace models are from [InsightFace](https://github.com/deepinsight/insightface).
|
||||
**Face Recognition** · [docs](https://yakhyo.github.io/uniface/modules/recognition/)
|
||||
|
||||
---
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/verification.jpg" width="100%">
|
||||
|
||||
## Contributing
|
||||
**Face Anonymization** · [docs](https://yakhyo.github.io/uniface/modules/privacy/)
|
||||
|
||||
Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/anonymization.jpg" width="100%">
|
||||
|
||||
## Support
|
||||
<br>
|
||||
|
||||
If you find this project useful, consider giving it a ⭐ on GitHub — it helps others discover it!
|
||||
<div align="center">
|
||||
|
||||
Questions or feedback:
|
||||
**[Get Started](https://yakhyo.github.io/uniface/quickstart/)** ·
|
||||
[Model Zoo](https://yakhyo.github.io/uniface/models/) ·
|
||||
[Notebooks](https://yakhyo.github.io/uniface/notebooks/) ·
|
||||
[Model licences](https://yakhyo.github.io/uniface/license-attribution/) ·
|
||||
[Contributing](CONTRIBUTING.md) ·
|
||||
[Discord](https://discord.gg/wdzrjr7R5j) ·
|
||||
[Issues](https://github.com/yakhyo/uniface/issues)
|
||||
|
||||
- Discord: https://discord.gg/wdzrjr7R5j
|
||||
- GitHub Issues: https://github.com/yakhyo/uniface/issues
|
||||
- DeepWiki Q&A: https://deepwiki.com/yakhyo/uniface
|
||||
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.<br>
|
||||
UniFace is [MIT](LICENSE); some pretrained weights are not, so check
|
||||
[licences](https://yakhyo.github.io/uniface/license-attribution/) before shipping commercially.<br>
|
||||
Not affiliated with [Uniface](https://uniface.com/) by Rocket Software.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
> **Disclaimer:** This project is not affiliated with or related to
|
||||
> [Uniface](https://uniface.com/) by Rocket Software.
|
||||
</div>
|
||||
|
||||
109
assets/demo/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Demo set
|
||||
|
||||
Photographs and rendered figures covering every component that a still image can show.
|
||||
`assets/source/` holds only the photographs the figures read; this folder holds the figures. Rebuild with:
|
||||
|
||||
```bash
|
||||
python3 tools/demo/build_demos.py assets
|
||||
```
|
||||
|
||||
46 source photographs (16 MB), 20 figures (5.9 MB). Every number below is measured by that script,
|
||||
not quoted from a paper. Rerun it after changing a source and update this file from its output.
|
||||
|
||||
## Naming
|
||||
|
||||
One pattern for every source: **`<task>_<variant>.jpg`**, task first. A trailing `2` marks a second
|
||||
set of subjects for the same task (`state_mask` and `state_b_mask` are different people). Verification
|
||||
uses `verify_<name>_<year>`, because a figure needs several photographs of one person and the
|
||||
name is what tells you which.
|
||||
|
||||
## Source photographs, by task
|
||||
|
||||
| Source | Feeds | Notes |
|
||||
| -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `detect_group.jpg` | detection, quality | 7 faces |
|
||||
| `detect_crowd.jpg` | detection_alt | 29 faces, 37–46px each |
|
||||
| `anon_group.jpg` | anonymization | 5 faces |
|
||||
| `landmarks_face.jpg` | landmarks | |
|
||||
| `mesh_face.jpg` | face_mesh | |
|
||||
| `parse_face.jpg` | parsing | 13 of 19 classes present |
|
||||
| `seg_face.jpg` | segmentation | |
|
||||
| `seg_occluded.jpg` | segmentation_occluded | headscarf, so only the exposed region masks |
|
||||
| `matte_face.jpg` | matting | |
|
||||
| `matte_hair.jpg` | matting_alt | flyaway hair against a plain background |
|
||||
| `pose_left/center/right.jpg` | headpose | yaw −78° / +9° / +40° |
|
||||
| `gaze_away/averted/right.jpg` | gaze | yaw −30° / −19° / +23°, so not left/centre/right |
|
||||
| `age_child/adult/middle/senior.jpg` | demography | 3-9, 20-29, 40-49, 60-69; sorted by prediction, not filename |
|
||||
| `emotion_*.jpg` (8) | emotion | one per AffectNet-8 class |
|
||||
| `state_closed/glasses/sunglasses/mask.jpg` | face_states | `emotion_happy` fills the eyes-open slot |
|
||||
| `state_b_glasses/sunglasses/mask.jpg` | face_states_alt | second set;`age_adult` and `mesh_face` fill the two accessory-free slots |
|
||||
| `spoof_live/print/screen.jpg` | spoofing | a live capture and two replays of it |
|
||||
| `verify_now_2010/2014/2024.jpg` | verification | one living subject, three dates, left unnamed |
|
||||
| `verify_einstein_1921/1947.jpg` | verification_alt | `_1947` is also the `tests/test_blazeface.py` fixture and notebook 04's query, so do not remove it |
|
||||
| `verify_curie.jpg` | verification_alt | the unpaired negative; year not recorded |
|
||||
| `verify_bohr_1910/1935.jpg` | verification_alt | second identity, 25 years apart |
|
||||
|
||||
Missing names are skipped with a warning rather than failing the run, so a partial set still builds.
|
||||
|
||||
## Figures
|
||||
|
||||
| File | Model | Measured |
|
||||
| ------------------------- | -------------------- | ------------------------------------------------------------------------- |
|
||||
| detection.jpg | SCRFD-10G | 7 faces |
|
||||
| detection_alt.jpg | SCRFD-10G | 29 faces, 37–46px wide, weakest score 0.73 |
|
||||
| landmarks.jpg | 2d106det, PIPNet | 106 / 98 / 68 points |
|
||||
| face_mesh.jpg | MediaPipe | 468 and 478 points; landmarks above, 2556-edge tessellation below |
|
||||
| parsing.jpg | BiSeNet ResNet-34 | 13 of 19 classes present |
|
||||
| segmentation.jpg | XSeg | input / mask / cut out |
|
||||
| segmentation_occluded.jpg | XSeg | 8.6% of frame masked |
|
||||
| matting.jpg | MODNet | input / matte / composite |
|
||||
| matting_alt.jpg | MODNet | fine hair, plain background |
|
||||
| headpose.jpg | ResNet-34 | yaw −78° / +9° / +40° |
|
||||
| gaze.jpg | MobileGaze ResNet-18 | yaw −30° / −19° / +23° |
|
||||
| demography.jpg | FairFace | 3-9, 20-29, 40-49, 60-69 |
|
||||
| emotion.jpg | AffectNet-8 | all 8 classes, p 0.75–0.99 |
|
||||
| face_states.jpg | FaceAttribNet | glasses 0.74, shades 1.00, mask 1.00 |
|
||||
| face_states_alt.jpg | FaceAttribNet | glasses 1.00, shades 1.00, mask 1.00 |
|
||||
| quality.jpg | eDifFIQA(L) | 0.398 … 0.749 across 7 faces |
|
||||
| spoofing.jpg | MiniFASNet | live Real 1.00; print Fake 0.66, screen Fake 0.99 |
|
||||
| anonymization.jpg | BlurFace | 4 of 5 methods, 5 faces |
|
||||
| verification.jpg | AdaFace IR-101 | +0.746 at 4 yr, +0.721 at 10 yr; −0.049 and −0.040 reject |
|
||||
| verification_alt.jpg | AdaFace IR-101 | Einstein +0.583 at 26 yr, Bohr +0.689 at 25 yr; +0.001 and −0.031 reject |
|
||||
|
||||
Not covered: **tracking** needs video, and the **FAISS store** needs a database rather than an
|
||||
image. Anti-spoofing is covered now, but only because the three frames come from one capture setup:
|
||||
MiniFASNet judges presentation, so a found photograph is a replay by definition.
|
||||
|
||||
## Choices worth keeping
|
||||
|
||||
- **Gaze uses ResNet-18.** Against ResNet-34/50 and MobileNetV2 on the same three subjects it was
|
||||
the only backbone returning a positive yaw on the third face, so the row reads leftward to
|
||||
rightward instead of all-leftward.
|
||||
- **Gaze subjects are not left/centre/right.** Measured at −30°, −19° and +23°, the middle face is
|
||||
still looking left, which is why the filenames say `away` and `averted`.
|
||||
- **Demography uses FairFace, not AgeGender.** AgeGender put a child at 30 and called an elderly
|
||||
woman Male; FairFace's buckets order correctly. The figure sorts by predicted bucket, so filename
|
||||
order does not matter.
|
||||
- **Head pose follows `tools/headpose.py`**: angles estimated on the unpadded bbox crop, drawn with
|
||||
`draw_head_pose(draw_type='cube')`.
|
||||
- **Head pose prints pitch and roll only below 60° of yaw.** Past that this model returns 35–82° of
|
||||
tilt on a level head, so `pose_left` at −78° shows yaw alone.
|
||||
- **Parsing crops to the face first.** BiSeNet trains on CelebAMask-HQ, which is face-centred crops,
|
||||
so a full-body portrait leaves the face too small for eyes, brows and lips to resolve.
|
||||
- **Quality runs on one photograph and shows it.** Pooling faces from several sources made the count
|
||||
unverifiable, since the reader never saw where they came from.
|
||||
- **Verification avoids twins.** An identical-twin pair scored above a genuine same-person match,
|
||||
which reads as a bug rather than a demonstration. Negatives are man-vs-man so the reject is not
|
||||
trivially separable by sex.
|
||||
- **One type scale** across the set: footer 20, label 22, value 38, sub 19, legend 26, with a 34px
|
||||
clear band above every footer.
|
||||
|
||||
## Credits
|
||||
|
||||
Source photographs come from Pexels, Unsplash, Pixabay and Wikimedia Commons. The three
|
||||
`spoof_*.jpg` frames come from [yakhyo/face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing).
|
||||
|
||||
| File | Source | Author | Licence |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------ |
|
||||
| `verify_bohr_1910.jpg` | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Niels_Bohr_-_LOC_-_ggbain_-_35303.jpg) | Bain News Service, via Library of Congress | PD-Bain, no known restrictions |
|
||||
| `verify_bohr_1935.jpg` | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Niels_Bohr_1935.jpg) | Unknown | PD-anon-70-EU |
|
||||
BIN
assets/demo/anonymization.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
assets/demo/demography.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
assets/demo/detection.jpg
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
assets/demo/detection_alt.jpg
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
assets/demo/emotion.jpg
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
assets/demo/face_mesh.jpg
Normal file
|
After Width: | Height: | Size: 865 KiB |
BIN
assets/demo/face_states.jpg
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
assets/demo/face_states_alt.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
assets/demo/gaze.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
assets/demo/headpose.jpg
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
assets/demo/landmarks.jpg
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
assets/demo/matting.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
assets/demo/matting_alt.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
assets/demo/parsing.jpg
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
assets/demo/quality.jpg
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
assets/demo/segmentation.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
assets/demo/segmentation_occluded.jpg
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
assets/demo/spoofing.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
assets/demo/verification.jpg
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
assets/demo/verification_alt.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 341 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 938 KiB |
|
Before Width: | Height: | Size: 712 KiB |
|
Before Width: | Height: | Size: 851 KiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
BIN
assets/source/age_adult.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/source/age_child.jpg
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
assets/source/age_middle.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
assets/source/age_senior.jpg
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
assets/source/anon_group.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
assets/source/detect_crowd.jpg
Normal file
|
After Width: | Height: | Size: 632 KiB |
BIN
assets/source/detect_group.jpg
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
assets/source/emotion_angry.jpg
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
assets/source/emotion_contempt.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
assets/source/emotion_disgust.jpg
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
assets/source/emotion_fear.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
assets/source/emotion_happy.jpg
Normal file
|
After Width: | Height: | Size: 709 KiB |
BIN
assets/source/emotion_neutral.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
assets/source/emotion_sad.jpg
Normal file
|
After Width: | Height: | Size: 485 KiB |
BIN
assets/source/emotion_surprise.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
assets/source/gaze_averted.jpg
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
assets/source/gaze_away.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
assets/source/gaze_right.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
assets/source/landmarks_face.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
assets/source/matte_face.jpg
Normal file
|
After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 208 KiB |
BIN
assets/source/mesh_face.jpg
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
assets/source/parse_face.jpg
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
assets/source/pose_center.jpg
Normal file
|
After Width: | Height: | Size: 672 KiB |
BIN
assets/source/pose_left.jpg
Normal file
|
After Width: | Height: | Size: 382 KiB |
BIN
assets/source/pose_right.jpg
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
assets/source/seg_face.jpg
Normal file
|
After Width: | Height: | Size: 339 KiB |
BIN
assets/source/seg_occluded.jpg
Normal file
|
After Width: | Height: | Size: 809 KiB |
BIN
assets/source/spoof_live.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
assets/source/spoof_print.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
assets/source/spoof_screen.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
assets/source/state_b_glasses.jpg
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
assets/source/state_b_mask.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
assets/source/state_b_sunglasses.jpg
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
assets/source/state_closed.jpg
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
assets/source/state_glasses.jpg
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
assets/source/state_mask.jpg
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
assets/source/state_sunglasses.jpg
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
assets/source/verify_bohr_1910.jpg
Normal file
|
After Width: | Height: | Size: 731 KiB |
BIN
assets/source/verify_bohr_1935.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/source/verify_curie.jpg
Normal file
|
After Width: | Height: | Size: 582 KiB |
BIN
assets/source/verify_einstein_1921.jpg
Normal file
|
After Width: | Height: | Size: 827 KiB |
BIN
assets/source/verify_einstein_1947.jpg
Normal file
|
After Width: | Height: | Size: 836 KiB |
BIN
assets/source/verify_now_2010.jpg
Normal file
|
After Width: | Height: | Size: 459 KiB |
BIN
assets/source/verify_now_2014.jpg
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
assets/source/verify_now_2024.jpg
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
assets/test.jpg
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |