ref: Update package mngt and optimize the vector store functions (#115)

* ref: Update download and hash chunk sizes to speed up

* build: Adopt uv with uv.lock and drop requirements.txt

* ref: Centralize softmax helper and minor cleanups
This commit is contained in:
Yakhyokhuja Valikhujaev
2026-05-06 01:47:27 +09:00
committed by GitHub
parent 73fc291930
commit b813dc2ee7
21 changed files with 1894 additions and 190 deletions

View File

@@ -52,26 +52,23 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
- 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 ".[cpu,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

View File

@@ -14,19 +14,19 @@ jobs:
with:
fetch-depth: 0
- uses: actions/setup-python@v6
- 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: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4

View File

@@ -68,19 +68,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
- 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 ".[cpu,dev]"
run: uv sync --locked --extra cpu --extra dev
- name: Run tests
run: pytest -v --tb=short
run: uv run pytest -v --tb=short
release:
runs-on: ubuntu-latest
@@ -198,20 +196,19 @@ jobs:
ref: v${{ inputs.version }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
- 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: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4

View File

@@ -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:

View File

@@ -6,16 +6,20 @@ Thank you for contributing to UniFace!
## Quick Start
We use [uv](https://docs.astral.sh/uv/) for reproducible dev installs (lockfile-pinned).
```bash
# Install uv first: https://docs.astral.sh/uv/getting-started/installation/
# Clone
git clone https://github.com/yakhyo/uniface.git
cd uniface
# Install dev dependencies
pip install -e ".[dev]"
# Install runtime + cpu + dev extras from uv.lock (--extra gpu for CUDA)
uv sync --extra cpu --extra dev
# Run tests
pytest
uv run pytest
```
---
@@ -39,10 +43,11 @@ ruff check . --fix
## Pre-commit Hooks
`pre-commit` is included in the `[dev]` extra, so `uv sync` already installs it.
```bash
pip install pre-commit
pre-commit install
pre-commit run --all-files
uv run pre-commit install
uv run pre-commit run --all-files
```
---

View File

@@ -51,14 +51,20 @@ dependencies = [
]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.16.0"]
gpu = ["onnxruntime-gpu>=1.16.0"]
cpu = [
"onnxruntime>=1.16.0; python_version >= '3.11'",
"onnxruntime>=1.16.0,<1.24; python_version < '3.11'",
]
gpu = [
"onnxruntime-gpu>=1.16.0; python_version >= '3.11'",
"onnxruntime-gpu>=1.16.0,<1.24; python_version < '3.11'",
]
dev = ["pytest>=7.0.0", "ruff>=0.4.0", "pre-commit>=3.0.0"]
docs = [
"mkdocs-material>=9.0",
"pymdown-extensions>=10.0",
"mkdocs-git-committers-plugin-2>=1.0",
"mkdocs-git-revision-date-localized-plugin>=2.0",
"mkdocs-material",
"pymdown-extensions",
"mkdocs-git-committers-plugin-2",
"mkdocs-git-revision-date-localized-plugin",
]
[project.urls]

View File

@@ -1,9 +0,0 @@
numpy>=1.21.0
opencv-python>=4.5.0
scikit-image>=0.22.0
scipy>=1.7.0
requests>=2.28.0
tqdm>=4.64.0
# Install ONE of the following (not both):
# onnxruntime>=1.16.0 # CPU / Apple Silicon → pip install uniface[cpu]
# onnxruntime-gpu>=1.16.0 # NVIDIA CUDA → pip install uniface[gpu]

View File

@@ -1,61 +0,0 @@
# Copyright 2025-2026 Yakhyokhuja Valikhujaev
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
from __future__ import annotations
import numpy as np
from uniface.draw import draw_gaze
def _compute_gaze_delta(bbox: np.ndarray, pitch: float, yaw: float) -> tuple[int, int]:
"""Replicate draw_gaze dx/dy math for verification."""
x_min, _, x_max, _ = map(int, bbox[:4])
length = x_max - x_min
dx = int(-length * np.sin(yaw) * np.cos(pitch))
dy = int(-length * np.sin(pitch))
return dx, dy
def test_draw_gaze_yaw_only_moves_horizontally():
"""Yaw-only input (pitch=0) should produce horizontal displacement only."""
image = np.zeros((200, 200, 3), dtype=np.uint8)
bbox = np.array([50, 50, 150, 150], dtype=np.float32)
yaw = 0.5
pitch = 0.0
dx, dy = _compute_gaze_delta(bbox, pitch, yaw)
assert dx != 0, 'Yaw-only should produce horizontal displacement'
assert dy == 0, 'Yaw-only should produce zero vertical displacement'
# Should not raise
draw_gaze(image, bbox, pitch, yaw, draw_bbox=False, draw_angles=False)
def test_draw_gaze_pitch_only_moves_vertically():
"""Pitch-only input (yaw=0) should produce vertical displacement only."""
image = np.zeros((200, 200, 3), dtype=np.uint8)
bbox = np.array([50, 50, 150, 150], dtype=np.float32)
yaw = 0.0
pitch = 0.5
dx, dy = _compute_gaze_delta(bbox, pitch, yaw)
assert dx == 0, 'Pitch-only should produce zero horizontal displacement'
assert dy != 0, 'Pitch-only should produce vertical displacement'
# Should not raise
draw_gaze(image, bbox, pitch, yaw, draw_bbox=False, draw_angles=False)
def test_draw_gaze_modifies_image():
"""draw_gaze should modify the image in place."""
image = np.zeros((200, 200, 3), dtype=np.uint8)
bbox = np.array([50, 50, 150, 150], dtype=np.float32)
original = image.copy()
draw_gaze(image, bbox, 0.3, 0.3)
assert not np.array_equal(image, original), 'draw_gaze should modify the image'

View File

@@ -198,7 +198,10 @@ def main():
parser_arg.add_argument('--source', type=str, required=True, help='Image/video path or camera ID (0, 1, ...)')
parser_arg.add_argument('--save-dir', type=str, default='outputs', help='Output directory')
parser_arg.add_argument(
'--model', type=str, default=ParsingWeights.RESNET18, choices=[ParsingWeights.RESNET18, ParsingWeights.RESNET34]
'--model',
type=ParsingWeights,
default=ParsingWeights.RESNET18,
choices=list(ParsingWeights),
)
parser_arg.add_argument(
'--expand-ratio',

View File

@@ -113,9 +113,10 @@ class FaceAnalyzer:
return faces
def __repr__(self) -> str:
parts = [f'FaceAnalyzer(detector={self.detector.__class__.__name__}']
parts = [f'detector={self.detector.__class__.__name__}']
if self.recognizer:
parts.append(f'recognizer={self.recognizer.__class__.__name__}')
for attr in self.attributes:
parts.append(f'{attr.__class__.__name__}')
return ', '.join(parts) + ')'
if self.attributes:
attr_names = ', '.join(attr.__class__.__name__ for attr in self.attributes)
parts.append(f'attributes=[{attr_names}]')
return f'FaceAnalyzer({", ".join(parts)})'

View File

@@ -7,6 +7,7 @@ import cv2
import numpy as np
from uniface.attribute.base import Attribute
from uniface.common import softmax
from uniface.constants import FairFaceWeights
from uniface.log import Logger
from uniface.model_store import verify_model_weights
@@ -150,9 +151,9 @@ class FairFace(Attribute):
race_logits, gender_logits, age_logits = prediction
# Apply softmax
race_probs = self._softmax(race_logits[0])
gender_probs = self._softmax(gender_logits[0])
age_probs = self._softmax(age_logits[0])
race_probs = softmax(race_logits[0])
gender_probs = softmax(gender_logits[0])
age_probs = softmax(age_logits[0])
# Get predictions
race_idx = int(np.argmax(race_probs))
@@ -186,9 +187,3 @@ class FairFace(Attribute):
face.age_group = result.age_group
face.race = result.race
return result
@staticmethod
def _softmax(x: np.ndarray) -> np.ndarray:
"""Compute softmax values for numerical stability."""
exp_x = np.exp(x - np.max(x))
return exp_x / np.sum(exp_x)

View File

@@ -19,6 +19,7 @@ __all__ = [
'letterbox_resize',
'non_max_suppression',
'resize_image',
'softmax',
'xyxy_to_cxcywh',
]
@@ -63,6 +64,21 @@ def resize_image(
return image, resize_factor
def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
"""Compute the numerically stable softmax of an array along ``axis``.
Args:
x: Input array.
axis: Axis along which softmax is computed. Defaults to the last axis.
Returns:
Array of the same shape as *x* with values in ``[0, 1]`` summing to 1
along *axis*.
"""
exp_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
return exp_x / np.sum(exp_x, axis=axis, keepdims=True)
def xyxy_to_cxcywh(bboxes: np.ndarray) -> np.ndarray:
"""Convert bounding boxes from ``[x1, y1, x2, y2]`` to ``[cx, cy, w, h]``.

View File

@@ -469,4 +469,5 @@ MODEL_REGISTRY: dict[Enum, ModelInfo] = {
MODEL_URLS: dict[Enum, str] = {k: v.url for k, v in MODEL_REGISTRY.items()}
MODEL_SHA256: dict[Enum, str] = {k: v.sha256 for k, v in MODEL_REGISTRY.items()}
CHUNK_SIZE = 8192
DOWNLOAD_CHUNK_SIZE = 256 * 1024 # 256 KiB
HASH_CHUNK_SIZE = 1024 * 1024 # 1 MiB

View File

@@ -13,7 +13,7 @@ from typing import Any, Literal
import numpy as np
from uniface.common import letterbox_resize, non_max_suppression
from uniface.common import letterbox_resize, non_max_suppression, softmax
from uniface.constants import YOLOv8FaceWeights
from uniface.log import Logger
from uniface.model_store import verify_model_weights
@@ -171,12 +171,6 @@ class YOLOv8Face(BaseDetector):
"""
return self.session.run(self.output_names, {self.input_names: input_tensor})
@staticmethod
def _softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
"""Compute softmax values for array x along specified axis."""
exp_x = np.exp(x - np.max(x, axis=axis, keepdims=True))
return exp_x / np.sum(exp_x, axis=axis, keepdims=True)
def postprocess(
self,
predictions: list[np.ndarray],
@@ -224,7 +218,7 @@ class YOLOv8Face(BaseDetector):
# Decode bounding boxes from DFL
bbox_pred = bbox_pred.reshape(-1, 4, 16)
bbox_dist = self._softmax(bbox_pred, axis=-1) @ np.arange(16)
bbox_dist = softmax(bbox_pred, axis=-1) @ np.arange(16)
# Convert distances to xyxy format
x1 = (grid_x - bbox_dist[:, 0]) * stride

View File

@@ -677,14 +677,10 @@ def vis_parsing_maps(
segmentation_mask = segmentation_mask.copy().astype(np.uint8)
# Create a color mask
segmentation_mask_color = np.zeros((segmentation_mask.shape[0], segmentation_mask.shape[1], 3))
num_classes = np.max(segmentation_mask)
for class_index in range(1, num_classes + 1):
class_pixels = np.where(segmentation_mask == class_index)
segmentation_mask_color[class_pixels[0], class_pixels[1], :] = FACE_PARSING_COLORS[class_index]
segmentation_mask_color = segmentation_mask_color.astype(np.uint8)
max_class = int(segmentation_mask.max())
palette = np.zeros((max(max_class + 1, len(FACE_PARSING_COLORS)), 3), dtype=np.uint8)
palette[: len(FACE_PARSING_COLORS)] = FACE_PARSING_COLORS
segmentation_mask_color = palette[segmentation_mask]
# Convert image to BGR format for blending
bgr_image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)

View File

@@ -6,6 +6,7 @@
import cv2
import numpy as np
from uniface.common import softmax
from uniface.constants import GazeWeights
from uniface.log import Logger
from uniface.model_store import verify_model_weights
@@ -142,11 +143,6 @@ class MobileGaze(BaseGazeEstimator):
return image
def _softmax(self, x: np.ndarray) -> np.ndarray:
"""Apply softmax along axis 1."""
e_x = np.exp(x - np.max(x, axis=1, keepdims=True))
return e_x / e_x.sum(axis=1, keepdims=True)
def postprocess(self, outputs: tuple[np.ndarray, np.ndarray]) -> GazeResult:
"""
Postprocess raw model outputs into gaze angles.
@@ -164,8 +160,8 @@ class MobileGaze(BaseGazeEstimator):
yaw_logits, pitch_logits = outputs
# Convert logits to probabilities
yaw_probs = self._softmax(yaw_logits)
pitch_probs = self._softmax(pitch_logits)
yaw_probs = softmax(yaw_logits)
pitch_probs = softmax(pitch_logits)
# Compute expected bin index (soft-argmax)
yaw_deg = np.sum(yaw_probs * self._idx_tensor, axis=1) * self._binwidth - self._angle_offset

View File

@@ -2,12 +2,6 @@
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Logging utilities for UniFace.
This module provides a centralized logger for the UniFace library,
allowing users to enable verbose logging when debugging or developing.
"""
from __future__ import annotations
import logging

View File

@@ -2,12 +2,6 @@
# Author: Yakhyokhuja Valikhujaev
# GitHub: https://github.com/yakhyo
"""Model weight management for UniFace.
This module handles downloading, caching, and verifying model weights
using SHA-256 checksums for integrity validation.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -159,7 +153,7 @@ def download_file(url: str, dest_path: str, timeout: int = 60, max_retries: int
unit_divisor=1024,
) as progress,
):
for chunk in response.iter_content(chunk_size=const.CHUNK_SIZE):
for chunk in response.iter_content(chunk_size=const.DOWNLOAD_CHUNK_SIZE):
if chunk:
file.write(chunk)
progress.update(len(chunk))
@@ -178,7 +172,7 @@ def verify_file_hash(file_path: str, expected_hash: str) -> bool:
"""Compute the SHA-256 hash of the file and compare it with the expected hash."""
file_hash = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(const.CHUNK_SIZE), b''):
for chunk in iter(lambda: f.read(const.HASH_CHUNK_SIZE), b''):
file_hash.update(chunk)
actual_hash = file_hash.hexdigest()
if actual_hash != expected_hash:
@@ -187,7 +181,7 @@ def verify_file_hash(file_path: str, expected_hash: str) -> bool:
def download_models(
model_names: list[Enum], max_workers: int = 4, timeout: int = 60, max_retries: int = 3
model_names: list[Enum], max_workers: int | None = None, timeout: int = 60, max_retries: int = 3
) -> dict[Enum, str]:
"""Download and verify multiple models concurrently.
@@ -214,6 +208,17 @@ def download_models(
results: dict[Enum, str] = {}
errors: list[str] = []
if isinstance(max_workers, bool) or not isinstance(max_workers, int | None):
raise TypeError(f'max_workers must be int or None, got {type(max_workers).__name__}')
if max_workers is None or max_workers < 1:
if max_workers < 1:
Logger.info(f'max_workers must be >= 1, got {max_workers}; falling back to auto mode')
max_workers = min(os.cpu_count(), 8) # at most 8
if max_workers < 1:
raise ValueError(f'max_workers must be >= 1, got {max_workers}')
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_model = {
executor.submit(verify_model_weights, name, timeout=timeout, max_retries=max_retries): name

View File

@@ -6,6 +6,7 @@
import cv2
import numpy as np
from uniface.common import softmax
from uniface.constants import MiniFASNetWeights
from uniface.log import Logger
from uniface.model_store import verify_model_weights
@@ -179,11 +180,6 @@ class MiniFASNet(BaseSpoofer):
return face
def _softmax(self, x: np.ndarray) -> np.ndarray:
"""Apply softmax to logits along axis 1."""
e_x = np.exp(x - np.max(x, axis=1, keepdims=True))
return e_x / e_x.sum(axis=1, keepdims=True)
def postprocess(self, outputs: np.ndarray) -> SpoofingResult:
"""
Postprocess raw model outputs into prediction result.
@@ -197,7 +193,7 @@ class MiniFASNet(BaseSpoofer):
Returns:
SpoofingResult: Result containing is_real flag and confidence score.
"""
probs = self._softmax(outputs)
probs = softmax(outputs)
label_idx = int(np.argmax(probs))
confidence = float(probs[0, label_idx])

View File

@@ -115,7 +115,7 @@ class FAISS(BaseStore):
return None, similarity
def remove(self, key: str, value: Any) -> int:
"""Remove all entries where ``metadata[key] == value`` and rebuild.
"""Remove all entries where ``metadata[key] == value``.
Args:
key: Metadata key to match against.
@@ -126,22 +126,19 @@ class FAISS(BaseStore):
"""
faiss = _import_faiss()
keep = [i for i, m in enumerate(self.metadata) if m.get(key) != value]
removed = len(self.metadata) - len(keep)
if removed == 0:
to_remove = [i for i, m in enumerate(self.metadata) if m.get(key) == value]
if not to_remove:
return 0
if keep:
vectors = np.empty((len(keep), self.embedding_size), dtype=np.float32)
for dst, src in enumerate(keep):
self.index.reconstruct(src, vectors[dst])
new_index = faiss.IndexFlatIP(self.embedding_size)
new_index.add(vectors)
else:
new_index = faiss.IndexFlatIP(self.embedding_size)
ids = np.array(to_remove, dtype=np.int64)
self.index.remove_ids(faiss.IDSelectorBatch(ids))
self.index = new_index
self.metadata = [self.metadata[i] for i in keep]
# IndexFlatIP.remove_ids preserves the relative order of survivors,
# so deleting the same positions from metadata keeps them aligned.
drop = set(to_remove)
self.metadata = [m for i, m in enumerate(self.metadata) if i not in drop]
removed = len(to_remove)
Logger.info('Removed %d entries where %s=%s (%d remaining)', removed, key, value, self.index.ntotal)
return removed

1769
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff