Compare commits
65 Commits
v2.0.2
...
chore/mino
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805e0f7d60 | ||
|
|
ad1a99a169 | ||
|
|
520434be3a | ||
|
|
6e49c78b04 | ||
|
|
800c960dbe | ||
|
|
4d6f29a113 | ||
|
|
a79d113791 | ||
|
|
d1df387274 | ||
|
|
1c74e5884f | ||
|
|
9484e9e6e1 | ||
|
|
b12c929f9d | ||
|
|
50c67507e3 | ||
|
|
1584a9d857 | ||
|
|
446e3629fb | ||
|
|
5af87dcd56 | ||
|
|
7882ec5cb4 | ||
|
|
d51d030545 | ||
|
|
5a767847da | ||
|
|
4a22f903f0 | ||
|
|
43a46e11df | ||
|
|
025b93ab8b | ||
|
|
8bf87d958f | ||
|
|
b813dc2ee7 | ||
|
|
73fc291930 | ||
|
|
400bb72217 | ||
|
|
a0a12d5eca | ||
|
|
a34f376da0 | ||
|
|
2b29706615 | ||
|
|
f6d3cf33f0 | ||
|
|
0eb042425c | ||
|
|
35c0b6d539 | ||
|
|
13c4ac83d8 | ||
|
|
6ce397b811 | ||
|
|
9bf54f5f78 | ||
|
|
c87ec1ad0f | ||
|
|
9e56a86963 | ||
|
|
426bd71505 | ||
|
|
ede8b27091 | ||
|
|
02c77ce5db | ||
|
|
d70d6a254f | ||
|
|
7d37633b1a | ||
|
|
bc413df4a8 | ||
|
|
8db0577991 | ||
|
|
3682a2124f | ||
|
|
2ef6a1ebe8 | ||
|
|
78a2dba7c7 | ||
|
|
87e496d1f5 | ||
|
|
5604ebf4f1 | ||
|
|
971775b2e8 | ||
|
|
c520ea2df2 | ||
|
|
2a8cb54d31 | ||
|
|
331f46be7c | ||
|
|
9991fae62a | ||
|
|
b74ab95d39 | ||
|
|
d2b0303bfe | ||
|
|
5f74487eb3 | ||
|
|
f897482d26 | ||
|
|
f3d81eb201 | ||
|
|
ea0b56f7e0 | ||
|
|
edbab5f7bf | ||
|
|
cd8077e460 | ||
|
|
452b3381a2 | ||
|
|
07c8bd7b24 | ||
|
|
68179d1e2d | ||
|
|
99b35dddb4 |
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())
|
||||
BIN
.github/logos/gaze_crop.png
vendored
|
Before Width: | Height: | Size: 716 KiB |
BIN
.github/logos/gaze_org.png
vendored
|
Before Width: | Height: | Size: 673 KiB |
BIN
.github/logos/logo_preview.jpg
vendored
|
Before Width: | Height: | Size: 826 KiB |
BIN
.github/logos/logo_readme.png
vendored
|
Before Width: | Height: | Size: 563 KiB |
BIN
.github/logos/logo_web.webp
vendored
|
Before Width: | Height: | Size: 33 KiB |
BIN
.github/logos/uniface.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
.github/logos/uniface_rounded_q80.webp
vendored
Normal file
|
After Width: | Height: | Size: 62 KiB |
49
.github/workflows/ci.yml
vendored
@@ -4,11 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -19,10 +17,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: "3.11"
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
|
||||
test:
|
||||
@@ -33,33 +31,44 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.11", "3.13"]
|
||||
include:
|
||||
# Full Python range on Linux (fastest runner)
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.10"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.11"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.12"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.13"
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.14"
|
||||
- os: macos-latest
|
||||
python-version: "3.13"
|
||||
- os: windows-latest
|
||||
python-version: "3.13"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install .[dev]
|
||||
run: uv sync --locked --extra cpu --extra dev
|
||||
|
||||
- name: Check ONNX Runtime providers
|
||||
run: |
|
||||
python -c "import onnxruntime as ort; print('Available providers:', ort.get_available_providers())"
|
||||
run: uv run python -c "import onnxruntime as ort; print('Available providers:', ort.get_available_providers())"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short
|
||||
run: uv run pytest -v --tb=short
|
||||
|
||||
- name: Test package imports
|
||||
run: python -c "import uniface; print(f'uniface {uniface.__version__} loaded with {len(uniface.__all__)} exports')"
|
||||
run: uv run python -c "import uniface; print(f'uniface {uniface.__version__} loaded with {len(uniface.__all__)} exports')"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -68,10 +77,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "pip"
|
||||
|
||||
44
.github/workflows/docs.yml
vendored
@@ -1,38 +1,50 @@
|
||||
name: Deploy docs
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
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@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history for git-committers and git-revision-date plugins
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install mkdocs-material pymdown-extensions mkdocs-git-committers-plugin-2 mkdocs-git-revision-date-localized-plugin
|
||||
run: uv sync --locked --extra docs
|
||||
|
||||
- name: Build docs
|
||||
env:
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
|
||||
run: mkdocs build --strict
|
||||
run: uv run mkdocs build --strict
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./site
|
||||
destination_dir: docs
|
||||
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[@]}"
|
||||
240
.github/workflows/pipeline.yml
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
name: Release Pipeline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version (e.g. 3.6.0, 3.6.0b1, 3.6.0rc1)'
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: pipeline
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
is_prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Validate version (PEP 440)
|
||||
run: |
|
||||
python - <<'EOF'
|
||||
import re, sys
|
||||
v = "${{ inputs.version }}"
|
||||
if not re.fullmatch(r'\d+\.\d+\.\d+((a|b|rc)\d+|\.dev\d+)?', v):
|
||||
print(f"Invalid version: {v}")
|
||||
print("Expected forms: 3.6.0, 3.6.0a1, 3.6.0b1, 3.6.0rc1, 3.6.0.dev1")
|
||||
sys.exit(1)
|
||||
EOF
|
||||
|
||||
- name: Check tag does not exist
|
||||
run: |
|
||||
if git rev-parse "v${{ inputs.version }}" >/dev/null 2>&1; then
|
||||
echo "Tag v${{ inputs.version }} already exists."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Detect pre-release
|
||||
id: prerelease
|
||||
run: |
|
||||
if [[ "${{ inputs.version }}" =~ (a|b|rc|\.dev)[0-9]+ ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: validate
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra cpu --extra dev
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest -v --tb=short
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Update pyproject.toml
|
||||
run: |
|
||||
python - <<'EOF'
|
||||
import re, pathlib
|
||||
p = pathlib.Path('pyproject.toml')
|
||||
text = p.read_text()
|
||||
new = re.sub(r'^version\s*=\s*".*"', f'version = "${{ inputs.version }}"', text, count=1, flags=re.M)
|
||||
if new == text:
|
||||
raise SystemExit("Failed to update version in pyproject.toml")
|
||||
p.write_text(new)
|
||||
EOF
|
||||
|
||||
- name: Update uniface/__init__.py
|
||||
run: |
|
||||
python - <<'EOF'
|
||||
import re, pathlib
|
||||
p = pathlib.Path('uniface/__init__.py')
|
||||
text = p.read_text()
|
||||
new = re.sub(r"^__version__\s*=\s*'.*'", f"__version__ = '${{ inputs.version }}'", text, count=1, flags=re.M)
|
||||
if new == text:
|
||||
raise SystemExit("Failed to update __version__ in uniface/__init__.py")
|
||||
p.write_text(new)
|
||||
EOF
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Refresh uv.lock with new project version
|
||||
run: uv lock --upgrade-package uniface
|
||||
|
||||
- name: Commit, tag, push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add pyproject.toml uniface/__init__.py uv.lock
|
||||
git commit -m "chore: Release v${{ inputs.version }}"
|
||||
git tag "v${{ inputs.version }}"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
git push origin "v${{ inputs.version }}"
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [validate, release]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/uniface/
|
||||
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: v${{ inputs.version }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Check package
|
||||
run: twine check dist/*
|
||||
|
||||
- name: Publish to PyPI
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: twine upload dist/*
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.version }}
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ needs.validate.outputs.is_prerelease }}
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [validate, publish]
|
||||
if: needs.validate.outputs.is_prerelease == 'false'
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: v${{ inputs.version }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra docs
|
||||
|
||||
- name: Build docs
|
||||
env:
|
||||
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.MKDOCS_GIT_COMMITTERS_APIKEY }}
|
||||
run: uv run mkdocs build --strict
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: ./site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
119
.github/workflows/publish.yml
vendored
@@ -1,119 +0,0 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*" # Trigger only on version tags like v0.1.9
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
tag_version: ${{ steps.get_version.outputs.tag_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Get version from tag and pyproject.toml
|
||||
id: get_version
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "tag_version=$TAG_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
PYPROJECT_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
||||
echo "version=$PYPROJECT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Tag version: v$TAG_VERSION"
|
||||
echo "pyproject.toml version: $PYPROJECT_VERSION"
|
||||
|
||||
- name: Verify version match
|
||||
run: |
|
||||
if [ "${{ steps.get_version.outputs.tag_version }}" != "${{ steps.get_version.outputs.version }}" ]; then
|
||||
echo "Error: Tag version (${{ steps.get_version.outputs.tag_version }}) does not match pyproject.toml version (${{ steps.get_version.outputs.version }})"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validation passed: ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: validate
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.13"]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install .[dev]
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [validate, test]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/uniface/
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Check package
|
||||
run: twine check dist/*
|
||||
|
||||
- name: Publish to PyPI
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: twine upload dist/*
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
||||
tmp_*
|
||||
.vscode/
|
||||
.claude
|
||||
|
||||
# CLI tools in tools/ write results here by default (--save-dir)
|
||||
outputs/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -18,6 +18,13 @@ repos:
|
||||
- id: debug-statements
|
||||
- id: check-ast
|
||||
|
||||
# Strip Jupyter notebook outputs
|
||||
- repo: https://github.com/kynan/nbstripout
|
||||
rev: 0.9.1
|
||||
hooks:
|
||||
- id: nbstripout
|
||||
files: ^examples/
|
||||
|
||||
# Ruff - Fast Python linter and formatter
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
@@ -35,6 +42,12 @@ repos:
|
||||
additional_dependencies: ['bandit[toml]']
|
||||
exclude: ^tests/
|
||||
|
||||
# Keep uv.lock in sync with pyproject.toml
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.9.0
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
# Configuration
|
||||
ci:
|
||||
autofix_commit_msg: 'style: auto-fix by pre-commit hooks'
|
||||
|
||||
6
AGENTS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Cursor agent instructions — shared with CLAUDE.md -->
|
||||
<!-- See CLAUDE.md for full project instructions for AI coding agents. -->
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
Please read and follow all instructions in [CLAUDE.md](./CLAUDE.md).
|
||||
64
CHANGELOG.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to UniFace are documented here. Earlier releases are covered by
|
||||
the autogenerated notes on the [GitHub releases page](https://github.com/yakhyo/uniface/releases).
|
||||
|
||||
## 4.0.0 - 2026-08-03
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- **Factory functions are gone.** `create_detector()` and its siblings no longer
|
||||
exist. Construct model classes directly.
|
||||
- **`FaceAnalyzer(attributes=...)` is now `predictors=`.** The parameter accepts
|
||||
any list of `BaseAttribute` subclasses.
|
||||
- **`Attribute` is renamed `BaseAttribute`**, matching `BaseDetector`,
|
||||
`BaseRecognizer`, and the other base classes.
|
||||
- **Constructors are keyword-only.** Every model class, plus `FaceAnalyzer`,
|
||||
`BlurFace`, `EllipticalBlur`, `FAISS`, and `BYTETracker`, rejects positional
|
||||
arguments. `SCRFD(model_name)` raises `TypeError`; write
|
||||
`SCRFD(model_name=...)`.
|
||||
- **Detector `**kwargs` replaced with explicit options.** `RetinaFace` takes
|
||||
`pre_nms_topk`, `post_nms_topk`, and `dynamic_size`; `YOLOv5Face` and
|
||||
`YOLOv8Face` take `max_det`. Misspelled options now fail instead of being
|
||||
silently ignored.
|
||||
- **`input_size` removed from `MobileGaze`, `HeadPose`, `BiSeNet`, and
|
||||
`Landmark106`.** These models always resized to the size in the ONNX graph;
|
||||
the parameter had no effect and is gone.
|
||||
- **Inputs are validated.** Detectors and landmarkers require 3-channel `uint8`
|
||||
BGR images. Float or grayscale arrays now raise `ValueError` instead of
|
||||
silently returning no faces.
|
||||
- **Detector capability flags are opt-in.** `supports_landmarks` and
|
||||
`supports_alignment` default to `False` on `BaseDetector`. Third-party
|
||||
detector subclasses that produce 5-point alignment landmarks must now declare
|
||||
both flags; boxes-only subclasses need no declaration.
|
||||
|
||||
### Migrating from 3.x
|
||||
|
||||
| 3.x | 4.0 |
|
||||
| --- | --- |
|
||||
| `create_detector('retinaface', ...)` | `RetinaFace(...)` |
|
||||
| `RetinaFace(RetinaFaceWeights.MNET_V2)` | `RetinaFace(model_name=RetinaFaceWeights.MNET_V2)` |
|
||||
| `FaceAnalyzer(attributes=[AgeGender()])` | `FaceAnalyzer(predictors=[AgeGender()])` |
|
||||
| `class MyPredictor(Attribute)` | `class MyPredictor(BaseAttribute)` |
|
||||
| `RetinaFace(**{'pre_nms_topk': 1000})` | `RetinaFace(pre_nms_topk=1000)` |
|
||||
| `HeadPose(input_size=(224, 224))` | `HeadPose()` |
|
||||
|
||||
### Added
|
||||
|
||||
- `BlazeFace` detector: MediaPipe short-range face detector with 6 keypoints.
|
||||
- `CenterFace` detector.
|
||||
- `FaceMesh` landmarker: MediaPipe dense 3D face mesh, 468 points or 478 with
|
||||
irises (`FaceMeshWeights.V2_478`).
|
||||
- `FaceAttribNet` predictor: eye openness, eyeglasses, sunglasses, and mask
|
||||
probabilities as `FaceStateResult`, enriching `Face` in-place.
|
||||
- Hugging Face mirror fallback for weight downloads when GitHub Releases is
|
||||
unreachable, pinned to an immutable revision.
|
||||
- `uniface.common.validate_image` is public for use in custom models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `BlazeFace` weighted NMS could loop forever with `iou_threshold=1.0` or a
|
||||
zero-area box; it now always terminates.
|
||||
- `Face.__repr__` no longer assumes all five face-state fields are set together.
|
||||
- Attribute models normalize `input_size` and warn when it disagrees with the
|
||||
ONNX metadata; `FaceAttribNet` rejects non-square sizes.
|
||||
81
CLAUDE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Project instructions for AI coding agents.
|
||||
|
||||
## Project Overview
|
||||
|
||||
UniFace is a Python library for face detection, recognition, tracking, landmark analysis, face parsing, gaze estimation, age/gender detection. It uses ONNX Runtime for inference.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Python 3.10+ with type hints
|
||||
- Line length: 120
|
||||
- Single quotes for strings, double quotes for docstrings
|
||||
- Google-style docstrings
|
||||
- Formatter/linter: Ruff (config in `pyproject.toml`)
|
||||
- Run `ruff format .` and `ruff check . --fix` before committing
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/) with a **capitalized** description:
|
||||
|
||||
```
|
||||
<type>: <Capitalized short description>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`
|
||||
|
||||
Examples:
|
||||
- `feat: Add gaze estimation model`
|
||||
- `fix: Correct bounding box scaling for non-square images`
|
||||
- `ci: Add nbstripout pre-commit hook`
|
||||
- `docs: Update installation instructions`
|
||||
- `refactor: Unify attribute/detector base classes`
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pytest -v --tb=short
|
||||
```
|
||||
|
||||
Tests live in `tests/`. Run the full suite before submitting changes.
|
||||
|
||||
## Pre-commit
|
||||
|
||||
Pre-commit hooks handle formatting, linting, security checks, and notebook output stripping. Always run:
|
||||
|
||||
```bash
|
||||
pre-commit install
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
uniface/ # Main package
|
||||
detection/ # Face detection models (SCRFD, RetinaFace, YOLOv5, YOLOv8)
|
||||
recognition/ # Face recognition/verification (AdaFace, ArcFace, EdgeFace, MobileFace, SphereFace)
|
||||
landmark/ # Facial landmark models
|
||||
tracking/ # Object tracking (ByteTrack)
|
||||
parsing/ # Face parsing/segmentation (BiSeNet, XSeg)
|
||||
gaze/ # Gaze estimation
|
||||
headpose/ # Head pose estimation
|
||||
attribute/ # Age, gender, emotion detection
|
||||
spoofing/ # Anti-spoofing (MiniFASNet)
|
||||
privacy/ # Face anonymization
|
||||
stores/ # Vector stores (FAISS)
|
||||
constants.py # Model weight URLs and checksums
|
||||
model_store.py # Model download/cache management
|
||||
analyzer.py # High-level FaceAnalyzer API
|
||||
types.py # Shared type definitions
|
||||
tests/ # Unit tests
|
||||
examples/ # Jupyter notebooks (outputs are auto-stripped)
|
||||
docs/ # MkDocs documentation
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- New models: add class in submodule, register weights in `constants.py`, export in `__init__.py`
|
||||
- Dependencies: managed in `pyproject.toml`
|
||||
- All ONNX models are downloaded on demand with SHA256 verification
|
||||
- Do not commit notebook outputs; `nbstripout` pre-commit hook handles this
|
||||
106
CONTRIBUTING.md
@@ -21,25 +21,31 @@ Thank you for considering contributing to UniFace! We welcome contributions of a
|
||||
|
||||
## Development Setup
|
||||
|
||||
We use [uv](https://docs.astral.sh/uv/) for reproducible dev installs. The committed `uv.lock` pins every transitive dependency so contributors and CI resolve to identical versions.
|
||||
|
||||
```bash
|
||||
# Install uv (https://docs.astral.sh/uv/getting-started/installation/)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
git clone https://github.com/yakhyo/uniface.git
|
||||
cd uniface
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Sync runtime + cpu + dev extras from uv.lock (use --extra gpu instead of cpu for CUDA)
|
||||
uv sync --extra cpu --extra dev
|
||||
```
|
||||
|
||||
`uv sync` creates a project-local `.venv/` and installs everything pinned in `uv.lock`. Run commands with `uv run <cmd>` (e.g. `uv run pytest`), or activate the venv with `source .venv/bin/activate`.
|
||||
|
||||
### Setting Up Pre-commit Hooks
|
||||
|
||||
We use [pre-commit](https://pre-commit.com/) to ensure code quality and consistency. Install and configure it:
|
||||
We use [pre-commit](https://pre-commit.com/) to ensure code quality and consistency. `pre-commit` is included in the `[dev]` extra, so it's already installed after `uv sync`.
|
||||
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Install the git hooks
|
||||
pre-commit install
|
||||
uv run pre-commit install
|
||||
|
||||
# (Optional) Run against all files
|
||||
pre-commit run --all-files
|
||||
uv run pre-commit run --all-files
|
||||
```
|
||||
|
||||
Once installed, pre-commit will automatically run on every commit to check:
|
||||
@@ -59,12 +65,12 @@ This project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formattin
|
||||
#### General Rules
|
||||
|
||||
- **Line length:** 120 characters maximum
|
||||
- **Python version:** 3.11+ (use modern syntax)
|
||||
- **Python version:** 3.10+ (use modern syntax)
|
||||
- **Quote style:** Single quotes for strings, double quotes for docstrings
|
||||
|
||||
#### Type Hints
|
||||
|
||||
Use modern Python 3.11+ type hints (PEP 585 and PEP 604):
|
||||
Use modern Python 3.10+ type hints (PEP 585 and PEP 604):
|
||||
|
||||
```python
|
||||
# Preferred (modern)
|
||||
@@ -82,23 +88,23 @@ 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 detect_faces(image: np.ndarray, threshold: float = 0.5) -> list[Face]:
|
||||
def detect(self, image: np.ndarray, **kwargs: Any) -> list[Face]:
|
||||
"""Detect faces in an image.
|
||||
|
||||
Args:
|
||||
image: Input image as a numpy array with shape (H, W, C) in BGR format.
|
||||
threshold: Confidence threshold for filtering detections. Defaults to 0.5.
|
||||
image: Input image as numpy array with shape (H, W, C) in BGR format.
|
||||
**kwargs: Additional detection parameters.
|
||||
|
||||
Returns:
|
||||
List of Face objects containing bounding boxes, confidence scores,
|
||||
and facial landmarks.
|
||||
List of detected Face objects.
|
||||
|
||||
Raises:
|
||||
ValueError: If the input image has invalid dimensions.
|
||||
ValueError: If the image is empty, not 3-channel BGR, or not uint8.
|
||||
|
||||
Example:
|
||||
>>> from uniface import detect_faces
|
||||
>>> faces = detect_faces(image, threshold=0.8)
|
||||
>>> from uniface import RetinaFace
|
||||
>>> detector = RetinaFace(confidence_threshold=0.8)
|
||||
>>> faces = detector.detect(image)
|
||||
>>> print(f"Found {len(faces)} faces")
|
||||
"""
|
||||
```
|
||||
@@ -153,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
|
||||
@@ -174,16 +180,62 @@ When adding a new model or feature:
|
||||
|
||||
Example notebooks demonstrating library usage:
|
||||
|
||||
| Example | Notebook |
|
||||
|---------|----------|
|
||||
| Face Detection | [01_face_detection.ipynb](examples/01_face_detection.ipynb) |
|
||||
| Face Alignment | [02_face_alignment.ipynb](examples/02_face_alignment.ipynb) |
|
||||
| Face Verification | [03_face_verification.ipynb](examples/03_face_verification.ipynb) |
|
||||
| Face Search | [04_face_search.ipynb](examples/04_face_search.ipynb) |
|
||||
| Face Analyzer | [05_face_analyzer.ipynb](examples/05_face_analyzer.ipynb) |
|
||||
| Face Parsing | [06_face_parsing.ipynb](examples/06_face_parsing.ipynb) |
|
||||
| Example | Notebook |
|
||||
| ------------------ | ------------------------------------------------------------------- |
|
||||
| Face Detection | [01_face_detection.ipynb](examples/01_face_detection.ipynb) |
|
||||
| Face Alignment | [02_face_alignment.ipynb](examples/02_face_alignment.ipynb) |
|
||||
| Face Verification | [03_face_verification.ipynb](examples/03_face_verification.ipynb) |
|
||||
| Face Search | [04_face_search.ipynb](examples/04_face_search.ipynb) |
|
||||
| Face Analyzer | [05_face_analyzer.ipynb](examples/05_face_analyzer.ipynb) |
|
||||
| Face Parsing | [06_face_parsing.ipynb](examples/06_face_parsing.ipynb) |
|
||||
| Face Anonymization | [07_face_anonymization.ipynb](examples/07_face_anonymization.ipynb) |
|
||||
| Gaze Estimation | [08_gaze_estimation.ipynb](examples/08_gaze_estimation.ipynb) |
|
||||
| Gaze Estimation | [08_gaze_estimation.ipynb](examples/08_gaze_estimation.ipynb) |
|
||||
| Face Segmentation | [09_face_segmentation.ipynb](examples/09_face_segmentation.ipynb) |
|
||||
| Face Vector Store | [10_face_vector_store.ipynb](examples/10_face_vector_store.ipynb) |
|
||||
| Head Pose Estimation | [11_head_pose_estimation.ipynb](examples/11_head_pose_estimation.ipynb) |
|
||||
| Face Recognition | [12_face_recognition.ipynb](examples/12_face_recognition.ipynb) |
|
||||
| Portrait Matting | [13_portrait_matting.ipynb](examples/13_portrait_matting.ipynb) |
|
||||
| Face Attributes | [14_face_attributes.ipynb](examples/14_face_attributes.ipynb) |
|
||||
| Face Mesh | [15_face_mesh.ipynb](examples/15_face_mesh.ipynb) |
|
||||
|
||||
## Release Process
|
||||
|
||||
Releases are fully automated via GitHub Actions. Only maintainers with branch-protection bypass privileges on `main` can trigger a release.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
1. Go to **Actions → Release Pipeline → Run workflow** on GitHub.
|
||||
2. Enter the version following [PEP 440](https://peps.python.org/pep-0440/):
|
||||
- Stable: `0.7.0`, `1.0.0`
|
||||
- Pre-release: `0.7.0rc1`, `0.7.0b1`, `0.7.0a1`, `0.7.0.dev1`
|
||||
3. Click **Run workflow**.
|
||||
|
||||
### What happens automatically
|
||||
|
||||
The `Release Pipeline` workflow runs all stages in sequence:
|
||||
|
||||
1. **Validate** — checks the version string against PEP 440 and confirms the tag does not already exist.
|
||||
2. **Test** — runs the test suite on Python 3.10–3.14.
|
||||
3. **Release** — updates `pyproject.toml` and `uniface/__init__.py`, commits `chore: Release vX.Y.Z` to `main`, creates and pushes tag `vX.Y.Z`.
|
||||
4. **Publish** — builds the package, uploads to PyPI, and creates a GitHub Release (flagged as pre-release for `a`/`b`/`rc`/`.dev` versions).
|
||||
5. **Deploy docs** — runs only for **stable** versions. Pre-releases do not update the live documentation site.
|
||||
|
||||
### Verifying a release
|
||||
|
||||
- PyPI: <https://pypi.org/project/uniface/>
|
||||
- GitHub Releases: <https://github.com/yakhyo/uniface/releases>
|
||||
- Docs (stable only): <https://yakhyo.github.io/uniface/>
|
||||
|
||||
### Installing a pre-release
|
||||
|
||||
End users can opt in to pre-releases with the `--pre` flag:
|
||||
|
||||
```bash
|
||||
pip install uniface --pre # latest pre-release
|
||||
pip install uniface==0.7.0rc1 # specific pre-release
|
||||
```
|
||||
|
||||
Without `--pre`, `pip install uniface` always resolves to the latest stable version.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
261
README.md
@@ -1,125 +1,186 @@
|
||||
# UniFace: All-in-One Face Analysis Library
|
||||
<h1 align="center">UniFace: A Unified Face Analysis Library for Python</h1>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://pypi.org/project/uniface/)
|
||||
[](https://www.python.org/)
|
||||
[](https://pypi.org/project/uniface/)
|
||||
[](https://www.python.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/yakhyo/uniface/actions)
|
||||
[](https://pepy.tech/project/uniface)
|
||||
[](https://yakhyo.github.io/uniface/)
|
||||
[](https://github.com/yakhyo/uniface/actions)
|
||||
[](https://pepy.tech/projects/uniface)
|
||||
[](https://www.kaggle.com/yakhyokhuja/code)
|
||||
[](https://huggingface.co/spaces/yakhyo/uniface)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/logos/logo_web.webp" width=80%>
|
||||
<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>
|
||||
|
||||
**UniFace** is a lightweight, production-ready face analysis library built on ONNX Runtime. It provides high-performance face detection, recognition, landmark detection, face parsing, gaze estimation, and attribute analysis with hardware acceleration support across platforms.
|
||||
<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>
|
||||
|
||||
> 💬 **Have questions?** [Chat with this codebase on DeepWiki](https://deepwiki.com/yakhyo/uniface) - AI-powered docs that let you ask anything about UniFace.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Face Detection** — RetinaFace, SCRFD, and YOLOv5-Face with 5-point landmarks
|
||||
- **Face Recognition** — ArcFace, MobileFace, and SphereFace embeddings
|
||||
- **Facial Landmarks** — 106-point landmark localization
|
||||
- **Face Parsing** — BiSeNet semantic segmentation (19 classes)
|
||||
- **Gaze Estimation** — Real-time gaze direction with MobileGaze
|
||||
- **Attribute Analysis** — Age, gender, race (FairFace), and emotion
|
||||
- **Anti-Spoofing** — Face liveness detection with MiniFASNet
|
||||
- **Face Anonymization** — 5 blur methods for privacy protection
|
||||
- **Hardware Acceleration** — ARM64 (Apple Silicon), CUDA (NVIDIA), CPU
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
<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
|
||||
# Standard installation
|
||||
pip install uniface
|
||||
|
||||
# GPU support (CUDA)
|
||||
pip install uniface[gpu]
|
||||
|
||||
# From source
|
||||
git clone https://github.com/yakhyo/uniface.git
|
||||
cd uniface && pip install -e .
|
||||
pip install "uniface[cpu]" # CPU and Apple Silicon
|
||||
pip install "uniface[gpu]" # NVIDIA CUDA
|
||||
pip install --pre "uniface[cpu]" # latest pre-release
|
||||
```
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary><b>A first script</b></summary>
|
||||
|
||||
## Quick Example
|
||||
<br>
|
||||
|
||||
`FaceAnalyzer` runs detection, alignment and recognition in one call. Attribute models are opt-in.
|
||||
|
||||
```python
|
||||
import cv2
|
||||
from uniface import RetinaFace
|
||||
from uniface import FaceAnalyzer, FairFace
|
||||
|
||||
# Initialize detector (models auto-download on first use)
|
||||
detector = RetinaFace()
|
||||
analyzer = FaceAnalyzer(predictors=[FairFace()])
|
||||
|
||||
# Detect faces
|
||||
image = cv2.imread("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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>All fifteen tasks, and which model does each</b></summary>
|
||||
|
||||
<br>
|
||||
|
||||
| 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 |
|
||||
|
||||
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.
|
||||
|
||||
</details>
|
||||
|
||||
<br>
|
||||
|
||||
### Find and measure faces
|
||||
|
||||
**Face Detection** · [docs](https://yakhyo.github.io/uniface/modules/detection/)
|
||||
|
||||
<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/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/landmarks.jpg" width="100%">
|
||||
|
||||
**Face Mesh** · [docs](https://yakhyo.github.io/uniface/modules/landmarks/#face-mesh-468-or-478-points-3d)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_mesh.jpg" width="100%">
|
||||
|
||||
**Face Quality** · [docs](https://yakhyo.github.io/uniface/modules/quality/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/quality.jpg" width="100%">
|
||||
|
||||
### Cut faces out
|
||||
|
||||
**Face Parsing** · [docs](https://yakhyo.github.io/uniface/modules/parsing/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/parsing.jpg" width="100%">
|
||||
|
||||
**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%">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/segmentation_occluded.jpg" width="100%">
|
||||
|
||||
**Portrait Matting** · [docs](https://yakhyo.github.io/uniface/modules/matting/)
|
||||
|
||||
<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%">
|
||||
|
||||
### Read where a head is pointing
|
||||
|
||||
**Head Pose** · [docs](https://yakhyo.github.io/uniface/modules/headpose/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/headpose.jpg" width="100%">
|
||||
|
||||
**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%">
|
||||
|
||||
### Read a face
|
||||
|
||||
**Age and Sex** · [docs](https://yakhyo.github.io/uniface/modules/attributes/)
|
||||
|
||||
<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)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/emotion.jpg" width="100%">
|
||||
|
||||
**Face States** · [docs](https://yakhyo.github.io/uniface/modules/attributes/#faceattribnet)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states.jpg" width="100%">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/face_states_alt.jpg" width="100%">
|
||||
|
||||
### Tell a real face from a replay
|
||||
|
||||
**Anti-Spoofing** · [docs](https://yakhyo.github.io/uniface/modules/spoofing/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/spoofing.jpg" width="100%">
|
||||
|
||||
### Match a face, or hide one
|
||||
|
||||
**Face Recognition** · [docs](https://yakhyo.github.io/uniface/modules/recognition/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/verification.jpg" width="100%">
|
||||
|
||||
**Face Anonymization** · [docs](https://yakhyo.github.io/uniface/modules/privacy/)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/yakhyo/uniface/main/assets/demo/anonymization.jpg" width="100%">
|
||||
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<img src="assets/test_result.png">
|
||||
|
||||
**[Get Started](https://yakhyo.github.io/uniface/quickstart/)** ·
|
||||
[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)
|
||||
|
||||
Runs on CPU, Apple Silicon and CUDA. Weights download on first use, verified by SHA-256.<br>
|
||||
UniFace is [MIT](LICENSE); some pretrained weights are not, so check
|
||||
[licences](https://yakhyo.github.io/uniface/license-attribution/) before shipping commercially.<br>
|
||||
Not affiliated with [Uniface](https://uniface.com/) by Rocket Software.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
📚 **Full documentation**: [yakhyo.github.io/uniface](https://yakhyo.github.io/uniface/)
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| [Quickstart](https://yakhyo.github.io/uniface/quickstart/) | Get up and running in 5 minutes |
|
||||
| [Model Zoo](https://yakhyo.github.io/uniface/models/) | All models, benchmarks, and selection guide |
|
||||
| [API Reference](https://yakhyo.github.io/uniface/modules/detection/) | Detailed module documentation |
|
||||
| [Tutorials](https://yakhyo.github.io/uniface/recipes/image-pipeline/) | Step-by-step workflow examples |
|
||||
| [Guides](https://yakhyo.github.io/uniface/concepts/overview/) | Architecture and design principles |
|
||||
|
||||
### Jupyter Notebooks
|
||||
|
||||
| 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) | All-in-one 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 |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [yakhyo/retinaface-pytorch](https://github.com/yakhyo/retinaface-pytorch) — RetinaFace training
|
||||
- [yakhyo/yolov5-face-onnx-inference](https://github.com/yakhyo/yolov5-face-onnx-inference) — YOLOv5-Face ONNX
|
||||
- [yakhyo/face-recognition](https://github.com/yakhyo/face-recognition) — ArcFace, MobileFace, SphereFace
|
||||
- [yakhyo/face-parsing](https://github.com/yakhyo/face-parsing) — BiSeNet face parsing
|
||||
- [yakhyo/gaze-estimation](https://github.com/yakhyo/gaze-estimation) — MobileGaze training
|
||||
- [yakhyo/face-anti-spoofing](https://github.com/yakhyo/face-anti-spoofing) — MiniFASNet inference
|
||||
- [yakhyo/fairface-onnx](https://github.com/yakhyo/fairface-onnx) — FairFace attributes
|
||||
- [deepinsight/insightface](https://github.com/deepinsight/insightface) — Model architectures
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
|
Before Width: | Height: | Size: 996 KiB |
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: 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 |
BIN
assets/source/matte_hair.jpg
Normal file
|
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 |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -88,12 +88,22 @@ landmarks = face.landmarks # Shape: (5, 2)
|
||||
3 ● ● 4
|
||||
```
|
||||
|
||||
This ordering is the *alignment template* (`uniface.face_utils.reference_alignment`)
|
||||
that recognition, quality scoring, and XSeg parsing all require.
|
||||
|
||||
!!! warning "BlazeFace uses a different layout"
|
||||
`BlazeFace` returns 6 keypoints — right eye, left eye, nose tip, mouth **center**,
|
||||
right ear tragion, left ear tragion — named from the subject's perspective, so rows
|
||||
0/1 are still the viewer-left and viewer-right eye. With no mouth corners they
|
||||
cannot be fitted to the template above, which is why `BlazeFace.supports_alignment`
|
||||
is `False`.
|
||||
|
||||
### 106-Point Landmarks
|
||||
|
||||
Returned by `Landmark106`:
|
||||
|
||||
```python
|
||||
from uniface import Landmark106
|
||||
from uniface.landmark import Landmark106
|
||||
|
||||
landmarker = Landmark106()
|
||||
landmarks = landmarker.get_landmarks(image, face.bbox)
|
||||
@@ -110,6 +120,48 @@ landmarks = landmarker.get_landmarks(image, face.bbox)
|
||||
| 63-86 | Eyes | 24 |
|
||||
| 87-105 | Mouth | 19 |
|
||||
|
||||
### 98 / 68-Point Landmarks (PIPNet)
|
||||
|
||||
Returned by `PIPNet`. The variant determines the layout:
|
||||
|
||||
```python
|
||||
from uniface.constants import PIPNetWeights
|
||||
from uniface.landmark import PIPNet
|
||||
|
||||
# 98-point WFLW layout (default)
|
||||
landmarks = PIPNet().get_landmarks(image, face.bbox)
|
||||
# Shape: (98, 2)
|
||||
|
||||
# 68-point 300W layout
|
||||
landmarks = PIPNet(model_name=PIPNetWeights.DW300_CELEBA_68).get_landmarks(image, face.bbox)
|
||||
# Shape: (68, 2)
|
||||
```
|
||||
|
||||
The 98-point output follows the standard [WFLW](https://wywu.github.io/projects/LAB/WFLW.html) layout
|
||||
(33 face-contour points, eyebrow/eye/nose/mouth groups). The 68-point output follows the standard
|
||||
[300W / iBUG](https://ibug.doc.ic.ac.uk/resources/300-W/) layout. Coordinates are in original-image
|
||||
pixel space, identical in convention to `Landmark106`.
|
||||
|
||||
### 468-Point Landmarks (Face Mesh)
|
||||
|
||||
`FaceMesh` is the only model in UniFace that returns **three** coordinates per point:
|
||||
|
||||
```python
|
||||
from uniface import FaceMesh
|
||||
|
||||
results = FaceMesh().predict(image, faces)
|
||||
results[0].landmarks # Shape: (468, 3)
|
||||
```
|
||||
|
||||
| Axis | Meaning |
|
||||
|------|---------|
|
||||
| `x`, `y` | Original-image pixel coordinates, same convention as every other landmarker |
|
||||
| `z` | **Relative** depth, on the same pixel scale as `x`/`y`. Smaller is closer to the camera |
|
||||
|
||||
`z` has no absolute origin — it is only meaningful *within* one face, for comparing which
|
||||
features sit nearer the camera. It is not a distance measurement and is not comparable
|
||||
between faces or between images. Use `results[0].points_2d` to drop it.
|
||||
|
||||
---
|
||||
|
||||
## Face Crop
|
||||
@@ -174,11 +226,11 @@ yaw = -90° ────┼──── yaw = +90°
|
||||
Face alignment uses 5-point landmarks to normalize face orientation:
|
||||
|
||||
```python
|
||||
from uniface import face_alignment
|
||||
from uniface.face_utils import face_alignment
|
||||
|
||||
# Align face to standard template
|
||||
aligned_face = face_alignment(image, face.landmarks)
|
||||
# Output: 112x112 aligned face image
|
||||
aligned_face, _ = face_alignment(image, face.landmarks)
|
||||
# Output: (112x112 aligned face image, inverse transform matrix)
|
||||
```
|
||||
|
||||
The alignment transforms faces to a canonical pose for better recognition accuracy.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Execution Providers
|
||||
|
||||
UniFace uses ONNX Runtime for model inference, which supports multiple hardware acceleration backends.
|
||||
UniFace automatically runs each model on the best available hardware — Apple Silicon (CoreML), NVIDIA GPU (CUDA), or CPU. Under the hood this is handled by ONNX Runtime execution providers.
|
||||
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ UniFace uses ONNX Runtime for model inference, which supports multiple hardware
|
||||
UniFace automatically selects the optimal execution provider based on available hardware:
|
||||
|
||||
```python
|
||||
from uniface import RetinaFace
|
||||
from uniface.detection import RetinaFace
|
||||
|
||||
# Automatically uses best available provider
|
||||
detector = RetinaFace()
|
||||
@@ -17,12 +17,44 @@ detector = RetinaFace()
|
||||
|
||||
**Priority order:**
|
||||
|
||||
1. **CUDAExecutionProvider** - NVIDIA GPU
|
||||
2. **CoreMLExecutionProvider** - Apple Silicon
|
||||
1. **CoreMLExecutionProvider** - Apple Silicon
|
||||
2. **CUDAExecutionProvider** - NVIDIA GPU
|
||||
3. **CPUExecutionProvider** - Fallback
|
||||
|
||||
---
|
||||
|
||||
## Explicit Provider Selection
|
||||
|
||||
You can specify which execution provider to use by passing the `providers` parameter:
|
||||
|
||||
```python
|
||||
from uniface.detection import RetinaFace
|
||||
from uniface.recognition import ArcFace
|
||||
|
||||
# Force CPU execution (even if GPU is available)
|
||||
detector = RetinaFace(providers=['CPUExecutionProvider'])
|
||||
recognizer = ArcFace(providers=['CPUExecutionProvider'])
|
||||
|
||||
# Use CUDA with CPU fallback
|
||||
detector = RetinaFace(providers=['CUDAExecutionProvider', 'CPUExecutionProvider'])
|
||||
```
|
||||
|
||||
All **ONNX-based** model classes accept the `providers` parameter:
|
||||
|
||||
- Detection: `RetinaFace`, `SCRFD`, `YOLOv5Face`, `YOLOv8Face`
|
||||
- Recognition: `ArcFace`, `AdaFace`, `MobileFace`, `SphereFace`
|
||||
- Landmarks: `Landmark106`, `PIPNet`
|
||||
- Gaze: `MobileGaze`
|
||||
- Parsing: `BiSeNet`, `XSeg`
|
||||
- Attributes: `AgeGender`, `FairFace`
|
||||
- Anti-Spoofing: `MiniFASNet`
|
||||
|
||||
!!! note "Non-ONNX components"
|
||||
- **Emotion** uses TorchScript and selects its device automatically (`mps` / `cuda` / `cpu`). It does **not** accept the `providers` parameter.
|
||||
- **BlurFace** is a pure OpenCV utility and does not load any model.
|
||||
|
||||
---
|
||||
|
||||
## Check Available Providers
|
||||
|
||||
```python
|
||||
@@ -61,7 +93,7 @@ print("Available providers:", providers)
|
||||
No additional setup required. ARM64 optimizations are built into `onnxruntime`:
|
||||
|
||||
```bash
|
||||
pip install uniface
|
||||
pip install "uniface[cpu]"
|
||||
```
|
||||
|
||||
Verify ARM64:
|
||||
@@ -78,10 +110,10 @@ python -c "import platform; print(platform.machine())"
|
||||
|
||||
### NVIDIA GPU (CUDA)
|
||||
|
||||
Install with GPU support:
|
||||
Install with GPU support (this installs `onnxruntime-gpu`, which already includes CPU fallback):
|
||||
|
||||
```bash
|
||||
pip install uniface[gpu]
|
||||
pip install "uniface[gpu]"
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
@@ -108,7 +140,7 @@ else:
|
||||
CPU execution is always available:
|
||||
|
||||
```bash
|
||||
pip install uniface
|
||||
pip install "uniface[cpu]"
|
||||
```
|
||||
|
||||
Works on all platforms without additional configuration.
|
||||
@@ -139,7 +171,7 @@ print(f"Available: {providers}")
|
||||
For batch processing or real-time applications, GPU acceleration provides significant speedups:
|
||||
|
||||
```bash
|
||||
pip install uniface[gpu]
|
||||
pip install "uniface[gpu]"
|
||||
```
|
||||
|
||||
### 2. Optimize Input Size
|
||||
@@ -147,7 +179,7 @@ pip install uniface[gpu]
|
||||
Smaller input sizes are faster but may reduce accuracy:
|
||||
|
||||
```python
|
||||
from uniface import RetinaFace
|
||||
from uniface.detection import RetinaFace
|
||||
|
||||
# Faster, lower accuracy
|
||||
detector = RetinaFace(input_size=(320, 320))
|
||||
@@ -183,8 +215,8 @@ for image_path in image_paths:
|
||||
|
||||
3. Reinstall with GPU support:
|
||||
```bash
|
||||
pip uninstall onnxruntime onnxruntime-gpu
|
||||
pip install uniface[gpu]
|
||||
pip uninstall onnxruntime onnxruntime-gpu -y
|
||||
pip install "uniface[gpu]"
|
||||
```
|
||||
|
||||
### Slow Performance on Mac
|
||||
|
||||