feat: replace face pipeline with InsightFace, add content classifier

Face detection/recognition:
- Replace YuNet + SFace with InsightFace buffalo_l (RetinaFace + ArcFace)
- 512-d ArcFace embeddings (was 128-d SFace), migration 0006 resizes column
- Remove YOLO person-bbox workaround — RetinaFace is accurate enough
- Detection threshold 0.65 cleanly separates real faces (0.72+) from
  false positives on dogs/paintings (0.56-0.61)

Content-type classification:
- CLIP zero-shot classifier using native PyTorch text encoder + ONNX
  image encoder for high-quality text-image similarity
- Categories: photograph, screenshot, document, receipt, meme, artwork
- Writes Tag(kind=content_type) per photo via photo_tags
- Margin-based confidence: top-1 vs top-2 score difference
- New ClassifierSettings in config (enabled, min_confidence)
- Wired into vision_fanout pipeline

Tested: 6 real faces from 4 photos (zero false positives), 11/13 photos
classified (8 photograph, 2 artwork, 1 meme).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:49:02 +02:00
parent f48e099bd2
commit fa9b21856f
14 changed files with 331 additions and 73 deletions

View File

@@ -36,6 +36,13 @@ class FaceDetection:
quality: float
@dataclass
class ClassificationResult:
"""A content-type classification."""
label: str
confidence: float
class Embedder(ABC):
"""Generates image and text embeddings (e.g. OpenCLIP ViT-B/32)."""
@@ -74,6 +81,15 @@ class ObjectDetector(ABC):
...
class ContentClassifier(ABC):
"""Classifies images into content types (screenshot, document, etc.)."""
@abstractmethod
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
"""Return content type classifications for an RGB image."""
...
class FaceProcessor(ABC):
"""Detects faces and extracts recognition embeddings (e.g. YuNet + SFace)."""

View File

@@ -21,20 +21,9 @@ logger = logging.getLogger(__name__)
# (relative_path, url, description)
# Models with url=None must be pre-exported via export_models.py.
DOWNLOADS = [
# YuNet — face detection (Apache 2.0, opencv_zoo, ~233 KB)
(
"face/yunet.onnx",
"https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx",
"YuNet face detector",
),
# SFace — face recognition (Apache 2.0, opencv_zoo, ~37 MB)
(
"face/sface.onnx",
"https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx",
"SFace face recognizer",
),
]
# InsightFace (RetinaFace + ArcFace) auto-downloads via the insightface
# package on first use — no manual download entries needed.
DOWNLOADS = []
# Models that need manual export via export_models.py
EXPORTS = [

View File

@@ -0,0 +1,106 @@
"""
CLIP zero-shot content-type classifier.
Uses the native OpenCLIP PyTorch text encoder for high-quality text
embeddings (the ONNX text encoder has degraded quality due to the
eot_indices workaround). Image embeddings use the ONNX visual encoder
which works well.
"""
import logging
import numpy as np
import torch
from app.config import VisionSettings
from app.services.vision.base import ContentClassifier, ClassificationResult
logger = logging.getLogger(__name__)
CATEGORY_PROMPTS = {
"screenshot": [
"a screenshot of a computer screen",
"a screenshot of a phone screen",
"a screen capture of a user interface",
],
"document": [
"a scanned document",
"a photo of a document with printed text",
"a photo of a page of text on paper",
],
"receipt": [
"a photo of a receipt",
"a photo of a bill or invoice",
],
"meme": [
"an internet meme with text overlay",
"a funny image with caption text",
],
"artwork": [
"a painting or drawing",
"a sketch or illustration",
"digital art or graphic design",
],
"photograph": [
"a photograph taken with a camera",
"a real photo of a real scene or person",
"a candid photograph",
],
}
class CLIPContentClassifier(ContentClassifier):
"""Zero-shot content classifier using CLIP text-image similarity.
Uses native PyTorch for text encoding, ONNX for image encoding."""
def __init__(self, settings: VisionSettings):
import open_clip
self._min_confidence = settings.classifier.min_confidence
# Load native model for text encoding only
logger.info("Loading OpenCLIP text encoder for content classification")
model, _, _ = open_clip.create_model_and_transforms(
"ViT-B-32", pretrained="laion2b_s34b_b79k"
)
model.eval()
self._model = model
self._tokenizer = open_clip.get_tokenizer("ViT-B-32")
# Get the ONNX image embedder from the registry
from app.services.vision.registry import registry
self._embedder = registry.get_embedder()
# Pre-compute text embeddings for each category
self._category_embeddings: dict[str, np.ndarray] = {}
for category, prompts in CATEGORY_PROMPTS.items():
tokens = self._tokenizer(prompts)
with torch.no_grad():
text_features = model.encode_text(tokens)
text_features /= text_features.norm(dim=-1, keepdim=True)
avg = text_features.mean(dim=0)
avg /= avg.norm()
self._category_embeddings[category] = avg.numpy().astype(np.float32)
logger.info("Content classifier ready with %d categories", len(self._category_embeddings))
def classify(self, image: np.ndarray) -> list[ClassificationResult]:
img_vec = self._embedder.embed_image(image)
# Cosine similarity against each category
scores = {}
for category, cat_vec in self._category_embeddings.items():
scores[category] = float(np.dot(img_vec, cat_vec))
# Sort by score descending
ranked = sorted(scores.items(), key=lambda x: -x[1])
best_cat, best_score = ranked[0]
second_score = ranked[1][1]
margin = best_score - second_score
# Normalize: 0.01 margin → ~0.5 confidence, 0.03+ → ~1.0
confidence = min(1.0, margin * 30)
if confidence >= self._min_confidence:
return [ClassificationResult(label=best_cat, confidence=confidence)]
return []

View File

@@ -0,0 +1,70 @@
"""
Face detection + recognition using InsightFace (RetinaFace + ArcFace).
Uses the buffalo_l model pack which auto-downloads on first use (~300MB).
Produces 512-d ArcFace embeddings. Non-commercial research license —
fine for homelab self-hosting.
"""
import logging
from pathlib import Path
import numpy as np
from app.config import VisionSettings
from app.services.vision.base import FaceProcessor, FaceDetection
logger = logging.getLogger(__name__)
class InsightFaceProcessor(FaceProcessor):
def __init__(self, settings: VisionSettings):
from insightface.app import FaceAnalysis
model_root = str(Path(settings.models_dir) / "face" / "insightface")
logger.info("Loading InsightFace buffalo_l from %s", model_root)
self._app = FaceAnalysis(
name="buffalo_l",
root=model_root,
providers=["CPUExecutionProvider"],
)
self._app.prepare(ctx_id=-1, det_size=(640, 640))
self._min_det_score = settings.faces.recognition_threshold
def process(self, image: np.ndarray) -> list[FaceDetection]:
orig_h, orig_w = image.shape[:2]
# InsightFace expects BGR
bgr = image[:, :, ::-1].copy()
faces = self._app.get(bgr)
if not faces:
return []
results = []
for face in faces:
if face.det_score < self._min_det_score:
continue
# face.bbox is [x1, y1, x2, y2] in pixel coords
x1, y1, x2, y2 = face.bbox
bbox = [
max(0, float(x1) / orig_w),
max(0, float(y1) / orig_h),
min(1, float(x2) / orig_w),
min(1, float(y2) / orig_h),
]
embedding = face.normed_embedding # already L2-normalized, 512-d
results.append(FaceDetection(
bbox=bbox,
embedding=embedding.astype(np.float32),
quality=float(face.det_score),
))
return results
@property
def embedding_dim(self) -> int:
return 512

View File

@@ -9,7 +9,7 @@ bootstrap_models.py.
import logging
from app.config import VisionSettings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
logger = logging.getLogger(__name__)
@@ -33,5 +33,9 @@ class ONNXBackend:
return YOLOv8Detector(self._settings)
def create_face_processor(self) -> FaceProcessor:
from app.services.vision.faces import YuNetSFaceProcessor
return YuNetSFaceProcessor(self._settings)
from app.services.vision.insightface_processor import InsightFaceProcessor
return InsightFaceProcessor(self._settings)
def create_classifier(self) -> ContentClassifier:
from app.services.vision.classify import CLIPContentClassifier
return CLIPContentClassifier(self._settings)

View File

@@ -15,7 +15,7 @@ import logging
from functools import lru_cache
from app.config import settings
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor
from app.services.vision.base import Embedder, OCREngine, ObjectDetector, FaceProcessor, ContentClassifier
logger = logging.getLogger(__name__)
@@ -46,6 +46,11 @@ class ModelRegistry:
logger.info("Loading face processor (backend=%s)", self._vision.backend)
return self._load_backend().create_face_processor()
@lru_cache(maxsize=1)
def get_classifier(self) -> ContentClassifier:
logger.info("Loading content classifier (backend=%s)", self._vision.backend)
return self._load_backend().create_classifier()
@lru_cache(maxsize=1)
def _load_backend(self):
"""Import and instantiate the configured backend."""
@@ -70,6 +75,8 @@ class ModelRegistry:
self.get_detector()
if self._vision.faces.enabled:
self.get_face_processor()
if self._vision.classifier.enabled:
self.get_classifier()
logger.info("Vision model warmup complete")