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>
182 lines
5.7 KiB
Python
182 lines
5.7 KiB
Python
"""
|
|
Application configuration using Pydantic Settings
|
|
"""
|
|
from pydantic_settings import BaseSettings
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
class ThumbnailSettings(BaseModel):
|
|
"""Thumbnail generation settings"""
|
|
small: int = 240
|
|
medium: int = 640
|
|
large: int = 1280
|
|
quality: int = 85
|
|
format: str = "webp"
|
|
|
|
class ScannerSettings(BaseModel):
|
|
"""File scanner settings"""
|
|
watch: bool = True
|
|
initial_scan_on_start: bool = True
|
|
batch_size: int = 100
|
|
concurrent_workers: int = 4
|
|
|
|
class PerformanceSettings(BaseModel):
|
|
"""Performance tuning settings"""
|
|
max_concurrent_thumbnails: int = 10
|
|
cache_ttl: int = 3600
|
|
db_pool_size: int = 20
|
|
db_pool_recycle: int = 3600
|
|
|
|
class EmbedderSettings(BaseModel):
|
|
"""CLIP / SigLIP embedding model settings"""
|
|
name: str = "openclip_vitb32"
|
|
batch_size: int = 8
|
|
|
|
class OCRSettings(BaseModel):
|
|
"""PaddleOCR / rapidocr settings"""
|
|
enabled: bool = True
|
|
languages: list[str] = ["en"]
|
|
min_confidence: float = 0.5
|
|
|
|
class DetectorSettings(BaseModel):
|
|
"""YOLOv8n object detection settings"""
|
|
enabled: bool = True
|
|
min_confidence: float = 0.35
|
|
max_detections: int = 50
|
|
|
|
class FacesSettings(BaseModel):
|
|
"""YuNet + SFace face detection/recognition settings"""
|
|
enabled: bool = True
|
|
min_face_size: int = 40
|
|
recognition_threshold: float = 0.65
|
|
cluster_eps: float = 0.5
|
|
|
|
class ClassifierSettings(BaseModel):
|
|
"""CLIP zero-shot content classification settings"""
|
|
enabled: bool = True
|
|
min_confidence: float = 0.3
|
|
|
|
class VisionSettings(BaseModel):
|
|
"""AI vision pipeline settings. Disabled when running on SQLite
|
|
(pgvector is required for embedding storage)."""
|
|
enabled: bool = True
|
|
backend: str = "onnx" # "onnx" | "rocm" (future)
|
|
models_dir: str = "/data/models"
|
|
embedder: EmbedderSettings = EmbedderSettings()
|
|
ocr: OCRSettings = OCRSettings()
|
|
detector: DetectorSettings = DetectorSettings()
|
|
faces: FacesSettings = FacesSettings()
|
|
classifier: ClassifierSettings = ClassifierSettings()
|
|
worker_concurrency: int = 2
|
|
|
|
class MulitaConfig(BaseModel):
|
|
"""Main configuration from YAML file. Source roots and the discard
|
|
workflow are owned by the database now — only operational settings
|
|
live here."""
|
|
thumbnails: ThumbnailSettings = ThumbnailSettings()
|
|
scanner: ScannerSettings = ScannerSettings()
|
|
performance: PerformanceSettings = PerformanceSettings()
|
|
vision: VisionSettings = VisionSettings()
|
|
|
|
class Settings(BaseSettings):
|
|
"""Application settings"""
|
|
# Database — Postgres + pgvector by default. The SQLite escape hatch
|
|
# remains supported via the docker-compose.sqlite.yml override and by
|
|
# setting DATABASE_URL=sqlite+aiosqlite:///... in .env for local dev.
|
|
database_url: str = Field(
|
|
default="postgresql+asyncpg://mulita:mulita@db:5432/mulita",
|
|
env="DATABASE_URL"
|
|
)
|
|
|
|
# Redis
|
|
redis_url: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="REDIS_URL"
|
|
)
|
|
|
|
# Celery
|
|
celery_broker_url: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="CELERY_BROKER_URL"
|
|
)
|
|
celery_result_backend: str = Field(
|
|
default="redis://localhost:6379",
|
|
env="CELERY_RESULT_BACKEND"
|
|
)
|
|
|
|
# Photo directories
|
|
photo_dirs: str = Field(
|
|
default="/photos",
|
|
env="PHOTO_DIRS"
|
|
)
|
|
|
|
# API settings
|
|
api_host: str = Field(default="0.0.0.0", env="API_HOST")
|
|
api_port: int = Field(default=8000, env="API_PORT")
|
|
|
|
# CORS — comma-separated list of allowed origins, or "*" for any.
|
|
# Same-origin requests (the normal case behind nginx / vite proxy)
|
|
# never trip CORS, so this is only for direct browser access from
|
|
# other origins (LAN IP, reverse proxy, dev tools).
|
|
allowed_origins: str = Field(default="*", env="ALLOWED_ORIGINS")
|
|
|
|
# Logging — accepts standard python levels (DEBUG, INFO, WARNING,
|
|
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
|
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
|
|
|
@property
|
|
def cors_origins(self) -> list[str]:
|
|
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
|
- "*" → wildcard (single-element list ["*"])
|
|
- "http://a.com,http://b.com" → split + strip
|
|
Empty entries are dropped.
|
|
"""
|
|
raw = (self.allowed_origins or "").strip()
|
|
if not raw or raw == "*":
|
|
return ["*"]
|
|
return [o.strip() for o in raw.split(",") if o.strip()]
|
|
|
|
# App configuration from YAML
|
|
_config: Optional[MulitaConfig] = None
|
|
|
|
@property
|
|
def config(self) -> MulitaConfig:
|
|
"""Load configuration from YAML file"""
|
|
if self._config is None:
|
|
config_path = Path("/app/config/mulita.yml")
|
|
if not config_path.exists():
|
|
config_path = Path("mulita.yml")
|
|
|
|
if config_path.exists():
|
|
with open(config_path, "r") as f:
|
|
config_data = yaml.safe_load(f)
|
|
self._config = MulitaConfig(**config_data)
|
|
else:
|
|
self._config = MulitaConfig()
|
|
|
|
return self._config
|
|
|
|
@property
|
|
def thumbnails(self) -> ThumbnailSettings:
|
|
return self.config.thumbnails
|
|
|
|
@property
|
|
def scanner(self) -> ScannerSettings:
|
|
return self.config.scanner
|
|
|
|
@property
|
|
def performance(self) -> PerformanceSettings:
|
|
return self.config.performance
|
|
|
|
@property
|
|
def vision(self) -> VisionSettings:
|
|
return self.config.vision
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
case_sensitive = False
|
|
|
|
# Global settings instance
|
|
settings = Settings() |