diff --git a/.env b/.env deleted file mode 100755 index 769ebf2805af57a8e66acf4a1d25a4e2a6be556d..0000000000000000000000000000000000000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL=file:/home/z/my-project/db/custom.db diff --git a/download/face-intel/.env.example b/.env.example similarity index 80% rename from download/face-intel/.env.example rename to .env.example index d3f6eff54f84334380f078a633dccb6d6faafa00..ef735f50480763ec765ccce65b3d6f10ddebb67e 100755 --- a/download/face-intel/.env.example +++ b/.env.example @@ -27,6 +27,16 @@ FI_ENABLE_SERPAPI=false FI_ENABLE_YANDEX=false FI_ENABLE_TINEYE=false +# --- Reverse face search (PimEyes-style) --- +FI_ENABLE_FACE_INDEX=true +FI_FACE_INDEX_PATH=data/face_index.db +FI_FACE_INDEX_TOP_K=10 +FI_FACE_INDEX_THRESHOLD=0.5 +FI_FACE_INDEX_MAX_FACES=100000 +FI_FACE_INDEX_CRAWLER_USER_AGENT=FaceIntel/1.0 +FI_FACE_INDEX_CRAWLER_SEED_WIKIPEDIA=false +FI_FACE_INDEX_CRAWLER_SEED_IMDB=false + # --- API keys (only needed if corresponding enable_* is true) --- FI_SERPAPI_KEY= FI_BING_API_KEY= diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 2d7753f43dfa3bd4ceec0bbc22cdd0071fef632c..0ead9c27dcc379816e061e48205264f2d1c2f88e --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,88 @@ -skills/ -node_modules/ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.pydevproject + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Environment / secrets +.env +.env.local +.env.*.local +*.pem +*.key +secrets/ + +# Runtime data +data/ +!data/.gitkeep + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# Models (downloaded at runtime — too large for git) +data/models/*.onnx +data/models/*.caffemodel +data/models/*.prototxt +data/models/*.npy +data/models/*.pt +data/models/*.bin + +# Gallery (user-uploaded face embeddings) +data/gallery/* + +# Uploads +data/uploads/* +data/jobs/* + +# Test artifacts +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Jupyter +.ipynb_checkpoints/ +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..029f4b25981aa3f8c21e12c94a229803715a9875 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.11-slim + +WORKDIR /app + +# System deps for OpenCV + lxml + sqlite-vec +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ libgl1 libglib2.0-0 libxml2-dev libxslt1-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy app +COPY . . + +# Ensure data dir exists +RUN mkdir -p data/models data/gallery data/uploads data/jobs + +# Non-root user +RUN useradd -m -u 1000 app && chown -R app:app /app +USER app + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..a8b05f8ae68d3db3eeb0a99fd5b5f11f2e13291a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marwan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000000000000000000000000000000000000..5b655873b49b1b99ca9103364a06be9428945b56 --- /dev/null +++ b/README.md @@ -0,0 +1,272 @@ +# Face Intel + +**Modular image intelligence platform — OSINT, face recognition, forensic metadata, object intelligence, location estimation, AI-image detection, and a true reverse face search engine.** + +Face Intel aggregates **16+ providers** across 11 capability buckets in parallel — face detection, recognition, reverse search, forensics, OCR, metadata, NSFW detection, scene recognition, AI-image detection, and object intelligence — with an evidence-first, explainable confidence model. + +Runs anywhere Python 3.11+ runs. Default install is lightweight enough for free-tier VPS / Railway / Hugging Face Spaces / Termux. Heavy ML deps (ONNX, dlib, Selenium) are opt-in. + +--- + +## Why Face Intel? + +| Tool | What it does | Pricing | Face Intel advantage | +|---|---|---|---| +| **PimEyes** | Reverse face search | $30–300/mo | Open source, self-hosted, multi-provider | +| **FaceCheck.ID** | Reverse face search | $20–100/mo | Free tier, evidence-first | +| **TinEye** | Reverse image search | $200/mo API | Multi-engine (Google/Bing/Yandex/TinEye/Reddit) | +| **DeepFace** (library) | Face recognition | Free | Face Intel wraps + extends with forensics + OSINT + AI-detection | +| **Maltego** transforms | Image OSINT | $999/yr | Argus-style integration potential | + +Face Intel collapses 16 providers into a single API call: face detection, recognition, reverse image search, EXIF/GPS, ELA forensics, OCR, NSFW detection, AI-image detection, scene recognition, barcode/QR, and image quality analysis. + +--- + +## Key Features + +### 🎯 Reverse Face Search (NEW) +True PimEyes-style "find this face elsewhere" — not just reverse image search. + +- **Face enrollment API** — index faces with metadata (name, source URL, age, etc.) +- **Face search API** — upload a face photo, get top-k matches from the index +- **sqlite-vec backend** — zero external dependencies, persists to single file +- **512-d ArcFace embeddings** — 99.7% accuracy on LFW +- **Optional crawler** — seed the index with public Wikipedia/IMDB headshots + +### 🧩 16 Providers Across 11 Capabilities + +| Category | Providers | +|---|---| +| **Detection** | Haar Cascade, OpenCV DNN | +| **Recognition** | InsightFace (ArcFace 512-d via ONNX) | +| **Reverse search** | SerpAPI, Social Lookup (Google/Bing/Yandex/TinEye/Reddit URL constructor), **Face Index (NEW)** | +| **Forensics** | Image Integrity, Duplicate Detector (pHash+dHash), ELA (Error Level Analysis), Image Similarity | +| **Metadata** | EXIF extraction | +| **OCR** | RapidOCR (multilingual) | +| **Object detection** | YOLOv8 | +| **Object intelligence** | Barcode, QR Code | +| **Scene recognition** | Places365 | +| **NSFW** | NudeNet | +| **AI image detection** | LAID (detect AI-generated images) | +| **Embeddings** | MobileNet | +| **Image analysis** | Image Quality (brightness/contrast/sharpness/noise), Image Properties | + +--- + +## Quick Start + +### Local + +```bash +git clone https://github.com/marwangpt237/face-intel.git +cd face-intel +pip install -r requirements.txt +cp .env.example .env +# edit .env to enable/disable providers +python app.py +# or: uvicorn app:app --reload +``` + +Open: +- http://localhost:8000/docs — Swagger UI +- http://localhost:8000/health — health check +- http://localhost:8000/providers — provider list +- http://localhost:8000/stats — metrics + +### Docker + +```bash +docker build -t face-intel . +docker run -p 8000:8000 -v $(pwd)/data:/app/data face-intel +``` + +### Deploy to free-tier clouds + +| Cloud | Free tier | Steps | +|---|---|---| +| **Hugging Face Spaces** | 16GB RAM, unlimited | New Space → SDK: Docker → upload files → live at `https://{user}-face-intel.hf.space` | +| **Render** | 750 hrs/mo | New → Web Service → connect repo → Build: `pip install -r requirements.txt` → Start: `uvicorn app:app --host 0.0.0.0 --port $PORT` | +| **Fly.io** | 3 shared-cpu VMs | `flyctl launch` (set `internal_port = 8000` in `fly.toml`) → `flyctl deploy` | +| **Koyeb** | 1 free web service | `koyeb service create face-intel --git github.com/marwangpt237/face-intel --ports 8000:http` | +| **Railway** | $5 free credit/mo | New project → Deploy from GitHub → auto-detects Dockerfile | + +--- + +## API Endpoints + +### Core + +| Method | Path | Description | +|---|---|---| +| `POST` | `/faces/detect` | Detect faces in an image | +| `POST` | `/faces/recognize` | Recognize faces against the gallery | +| `POST` | `/search/reverse` | Reverse image search (Google Lens / TinEye URLs) | +| `POST` | `/search/scrape` | Scrape images from a URL | +| `POST` | `/jobs` | Full-pipeline job | +| `GET` | `/providers` | List all providers | +| `GET` | `/stats` | Metrics snapshot | +| `GET` | `/health/providers` | Per-provider health | + +### Reverse Face Search (NEW) + +| Method | Path | Description | +|---|---|---| +| `POST` | `/faces/enroll` | Enroll a face into the searchable index | +| `POST` | `/faces/search` | Search the index for matching faces | +| `GET` | `/faces/list` | Paginated list of enrolled faces | +| `GET` | `/faces/{face_id}` | Get details of a specific enrolled face | +| `DELETE` | `/faces/{face_id}` | Remove a face from the index | +| `POST` | `/faces/crawl` | Trigger the public-faces crawler (Wikipedia/IMDB seed) | +| `GET` | `/faces/stats` | Index statistics (count, last update, etc.) | + +#### Example: Enroll a face + +```bash +curl -X POST http://localhost:8000/faces/enroll \ + -F "image=@photo.jpg" \ + -F "name=John Doe" \ + -F "source_url=https://example.com/profile" \ + -F "metadata={\"age\":30,\"location\":\"US\"}" +``` + +Response: +```json +{ + "face_id": "f3a2b1c8-...", + "status": "enrolled", + "embedding_dim": 512, + "thumbnail_path": "data/thumbnails/f3a2b1c8.jpg" +} +``` + +#### Example: Search for a face + +```bash +curl -X POST http://localhost:8000/faces/search \ + -F "image=@query.jpg" \ + -F "top_k=5" \ + -F "threshold=0.5" +``` + +Response: +```json +{ + "query_face_detected": true, + "num_matches": 3, + "matches": [ + { + "face_id": "f3a2b1c8-...", + "name": "John Doe", + "source_url": "https://example.com/profile", + "similarity": 0.87, + "metadata": {"age": 30, "location": "US"} + } + ] +} +``` + +--- + +## Architecture + +Face Intel is built on **11 strict one-way dependency layers** with dependency injection throughout. No global state. Every provider is isolated — failures don't cascade. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ FACE INTEL │ +├──────────────────────────────────────────────────────────────────┤ +│ api/ FastAPI routes + middleware + DI container │ +│ services/ High-level orchestration services │ +│ orchestrator/ Async fan-out, retry, circuit breaker, cache │ +│ pipeline/ Image preprocessing + feature extraction │ +│ providers/ 16 providers across 11 capabilities │ +│ cores/ Shared modules (face, vision, onnx, embedding) │ +│ storage/ Database, cache, artifacts, reference store │ +│ config/ Pydantic settings (FI_* env vars) │ +│ models/ Pydantic schemas for requests/responses │ +│ metrics/ Prometheus-style collector │ +│ utils/ Logging, audit, helpers │ +└──────────────────────────────────────────────────────────────────┘ +``` + +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full layer dependency graph, execution flow, lifecycle diagrams, and DI container docs. + +--- + +## Adding a New Provider + +1. Implement `providers//.py` (subclass `BaseProvider`) +2. Add one entry to `PROVIDER_MANIFEST` in `providers/registry.py` +3. Add an `enable_: bool = False` flag to `config/settings.py` + +That's it — the orchestrator, services, API, and UI pick it up automatically. See [`docs/PROVIDERS.md`](docs/PROVIDERS.md) for the complete step-by-step guide. + +--- + +## Configuration + +Every config is via `FI_*` env vars. See [`.env.example`](.env.example) for the full list, or [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for type, default, and description of each. + +Key ones: + +| Var | Default | Description | +|---|---|---| +| `FI_HOST` | `0.0.0.0` | Bind host | +| `FI_PORT` | `8000` | Bind port | +| `FI_ENABLE_INSIGHTFACE` | `false` | Enable ArcFace recognition (requires ONNX) | +| `FI_ENABLE_FACE_INDEX` | `true` | Enable reverse face search | +| `FI_FACE_INDEX_PATH` | `data/face_index.db` | Path to sqlite-vec index | +| `FI_FACE_INDEX_TOP_K` | `10` | Default top_k for search | +| `FI_RATE_LIMIT_PER_MINUTE` | `30` | API rate limit | + +--- + +## Engineering Principles + +- **Modular** — every provider is isolated; failures don't cascade +- **Extensible** — new providers require zero orchestrator/API changes +- **Production-ready** — circuit breakers, retries, caching, rate limiting, audit log +- **Evidence-first** — raw provider responses preserved verbatim +- **Explainable** — confidence scores decomposed into weighted sub-scores +- **Clean separation** — 11 layers, strict one-way dependency direction +- **DI throughout** — no global stateful objects; everything injectable + +--- + +## Documentation + +| Document | Audience | What's in it | +|---|---|---| +| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | All engineers | Layer dependency graph, execution flow, lifecycle diagrams, DI container, structured logging, metrics subsystem | +| [`docs/PROVIDERS.md`](docs/PROVIDERS.md) | Provider authors | Provider Protocol, capability enum, step-by-step guide to adding a new provider, manifest format, health check & error handling patterns, testing pattern | +| [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) | API consumers | Every endpoint with method, path, request/response schemas, status codes, `curl` examples | +| [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) | Operators | Every `FI_*` env var with type, default, and description | +| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | SRE / DevOps | Local dev setup, Docker deployment, Kubernetes manifests, reverse proxy config, storage layout, load balancer health probes | +| [`docs/TESTING.md`](docs/TESTING.md) | Contributors | Test structure (unit/provider/integration), how to run tests, how to write new tests, fixtures, coverage goals, CI integration | +| [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | On-call | Common errors and fixes: provider not_configured, circuit breaker open, model download failures, dlib compilation, Selenium/Chrome issues, cache issues, performance tuning | +| [`docs/ECOSYSTEM_RESEARCH.md`](docs/ECOSYSTEM_RESEARCH.md) | Architects | Open-source ecosystem survey of 42 projects across 18 capability categories, with capability matrix, priority ranking, integration roadmap, effort estimates, and example API designs | +| [`docs/OPTIMIZATION_REPORT.md`](docs/OPTIMIZATION_REPORT.md) | All engineers | Consolidation report: shared `cores/` modules, dependency minimization, vendor-only-what's-necessary strategy, and savings estimates (89% storage reduction, 70% startup improvement) | +| [`docs/research/`](docs/research/) | Researchers | 25+ JSON files of curated links across face recognition, forensics, OCR, NSFW, deepfake detection, AI image detection, lightweight models, and more | + +--- + +## ⚠️ Privacy & Legal + +Face recognition is regulated in some jurisdictions (EU GDPR, Illinois BIPA, China PIPL). Operators are responsible for: + +- Obtaining consent before enrolling faces into the index +- Providing a deletion mechanism (the `DELETE /faces/{face_id}` endpoint) +- Not storing uploaded query images longer than necessary +- Displaying a clear privacy policy and ToS + +The default deployment **does not crawl or index** any faces automatically. The crawler is opt-in via `POST /faces/crawl` and only seeds from public-domain sources (Wikipedia profile photos). Social media scraping is intentionally NOT supported due to legal risk. + +--- + +## License + +MIT — see [LICENSE](LICENSE). + +## Acknowledgments + +Built on the shoulders of giants: InsightFace, DeepFace, OpenCV, RapidOCR, YOLOv8, NudeNet, Places365, LAID, and many other open-source projects documented in [`docs/ECOSYSTEM_RESEARCH.md`](docs/ECOSYSTEM_RESEARCH.md). diff --git a/download/face-intel/api/__init__.py b/api/__init__.py similarity index 100% rename from download/face-intel/api/__init__.py rename to api/__init__.py diff --git a/download/face-intel/api/container.py b/api/container.py similarity index 93% rename from download/face-intel/api/container.py rename to api/container.py index 2294ab21b0700bfb508c499c5717377d03ace886..839e42699a3d0cdd73b2b596dbd19af7f62bb923 100755 --- a/download/face-intel/api/container.py +++ b/api/container.py @@ -28,6 +28,7 @@ from services.analysis_service import AnalysisService from services.cache_service import CacheService from services.detection_service import DetectionService from services.export_service import ExportService +from services.face_index_service import FaceIndexService from services.face_intelligence_service import FaceIntelligenceService from services.forensic_metadata_service import ForensicMetadataService from services.health_service import HealthService @@ -42,6 +43,7 @@ from services.search_service import SearchService from storage.artifacts import ArtifactStore from storage.cache import Cache from storage.database import Database +from storage.face_index import FaceIndex from storage.reference_store import ReferenceStore @@ -78,6 +80,8 @@ class ServiceContainer: cache_service: CacheService health_service: HealthService export_service: ExportService + face_index: FaceIndex + face_index_service: FaceIndexService def build_container(settings: Settings | None = None) -> ServiceContainer: @@ -201,6 +205,17 @@ def build_container(settings: Settings | None = None) -> ServiceContainer: ) export_service = ExportService(database=database) + # Reverse face search index (sqlite-vec backed) + face_index = FaceIndex(path=settings.face_index_path) + face_index_service = FaceIndexService( + settings=settings, + validator=validator, + preprocessor=preprocessor, + hasher=hasher, + feature_extractor=feature_extractor, + face_index=face_index, + ) + return ServiceContainer( settings=settings, registry=registry, @@ -232,4 +247,6 @@ def build_container(settings: Settings | None = None) -> ServiceContainer: cache_service=cache_service, health_service=health_service, export_service=export_service, + face_index=face_index, + face_index_service=face_index_service, ) diff --git a/download/face-intel/api/deps.py b/api/deps.py similarity index 95% rename from download/face-intel/api/deps.py rename to api/deps.py index 48873e8d298da4147a20bd1077eb0a3ec6563133..2e873380a13fb47537aa64ec17153d14fbc20aff 100755 --- a/download/face-intel/api/deps.py +++ b/api/deps.py @@ -49,6 +49,10 @@ def get_face_intelligence_service(request: Request): return get_container(request).face_intelligence_service +def get_face_index_service(request: Request): + return get_container(request).face_index_service + + def get_forensic_metadata_service(request: Request): return get_container(request).forensic_metadata_service diff --git a/download/face-intel/api/main.py b/api/main.py similarity index 100% rename from download/face-intel/api/main.py rename to api/main.py diff --git a/download/face-intel/api/middleware.py b/api/middleware.py similarity index 100% rename from download/face-intel/api/middleware.py rename to api/middleware.py diff --git a/download/face-intel/api/routes/__init__.py b/api/routes/__init__.py similarity index 100% rename from download/face-intel/api/routes/__init__.py rename to api/routes/__init__.py diff --git a/download/face-intel/api/routes/analysis.py b/api/routes/analysis.py similarity index 100% rename from download/face-intel/api/routes/analysis.py rename to api/routes/analysis.py diff --git a/download/face-intel/api/routes/cache.py b/api/routes/cache.py similarity index 100% rename from download/face-intel/api/routes/cache.py rename to api/routes/cache.py diff --git a/download/face-intel/api/routes/export.py b/api/routes/export.py similarity index 100% rename from download/face-intel/api/routes/export.py rename to api/routes/export.py diff --git a/api/routes/faces.py b/api/routes/faces.py new file mode 100755 index 0000000000000000000000000000000000000000..c0cd218873d3948dfa90584ce1fed56689f1a107 --- /dev/null +++ b/api/routes/faces.py @@ -0,0 +1,209 @@ +"""Face routes — detect / recognize / intelligence / index endpoints.""" + +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from pydantic import BaseModel + +from api.deps import ( + get_detection_service, + get_recognition_service, + get_face_intelligence_service, + get_face_index_service, +) +from models.jobs import JobKind, JobRequest +from services.detection_service import DetectionService +from services.recognition_service import RecognitionService +from services.face_intelligence_service import FaceIntelligenceService +from services.face_index_service import FaceIndexService + +router = APIRouter() + + +class FaceRequest(BaseModel): + image_url: Optional[str] = None + image_base64: Optional[str] = None + providers: List[str] = [] + + +@router.post("/detect") +async def detect_faces(req: FaceRequest, svc: DetectionService = Depends(get_detection_service)): + job_req = JobRequest( + kind=JobKind.DETECTION, + image_url=req.image_url, + image_base64=req.image_base64, + providers=req.providers, + ) + return await svc.detect(job_req) + + +@router.post("/recognize") +async def recognize_faces(req: FaceRequest, svc: RecognitionService = Depends(get_recognition_service)): + job_req = JobRequest( + kind=JobKind.RECOGNITION, + image_url=req.image_url, + image_base64=req.image_base64, + providers=req.providers, + ) + return await svc.recognize(job_req) + + +@router.post("/intelligence") +async def face_intelligence( + req: FaceRequest, + svc: FaceIntelligenceService = Depends(get_face_intelligence_service), +): + """Run face intelligence: quality, blur, pose, best-face, clustering.""" + job_req = JobRequest( + kind=JobKind.DETECTION, + image_url=req.image_url, + image_base64=req.image_base64, + providers=req.providers, + ) + return await svc.analyze(job_req) + + +@router.get("/gallery") +async def list_gallery(svc: RecognitionService = Depends(get_recognition_service)): + return {"persons": svc.list_known_persons()} + + +@router.delete("/gallery/{name}") +async def remove_from_gallery(name: str, svc: RecognitionService = Depends(get_recognition_service)): + return svc.remove_known_person(name) + + +# --------------------------------------------------------------------------- # +# Reverse Face Search (PimEyes-style indexed search) +# --------------------------------------------------------------------------- # + +class EnrollURLRequest(BaseModel): + image_url: str + name: Optional[str] = None + source_url: Optional[str] = None + metadata: Optional[dict] = None + + +class SearchURLRequest(BaseModel): + image_url: str + top_k: Optional[int] = None + threshold: Optional[float] = None + + +@router.post("/enroll") +async def enroll_face( + image: UploadFile = File(...), + name: Optional[str] = Form(None), + source_url: Optional[str] = Form(None), + metadata: Optional[str] = Form(None), + svc: FaceIndexService = Depends(get_face_index_service), +): + """ + Enroll a face into the searchable reverse-search index. + + Upload an image (multipart/form-data) with optional name, source_url, + and metadata (JSON string). The largest face in the image will be + detected, embedded (512-d ArcFace), and added to the index. + """ + import json + image_bytes = await image.read() + md = json.loads(metadata) if metadata else None + return await svc.enroll( + image_bytes=image_bytes, + name=name, + source_url=source_url, + metadata=md, + ) + + +@router.post("/enroll/url") +async def enroll_face_url( + req: EnrollURLRequest, + svc: FaceIndexService = Depends(get_face_index_service), +): + """Enroll a face from a URL (alternative to multipart upload).""" + return await svc.enroll( + image_url=req.image_url, + name=req.name, + source_url=req.source_url, + metadata=req.metadata, + ) + + +@router.post("/search") +async def search_faces( + image: UploadFile = File(...), + top_k: Optional[int] = Form(None), + threshold: Optional[float] = Form(None), + svc: FaceIndexService = Depends(get_face_index_service), +): + """ + Search the index for faces matching the uploaded image. + + Returns up to `top_k` matches with similarity >= `threshold`. + Each match includes the face_id, name, source_url, metadata, + and similarity score (0-1, higher is better). + """ + image_bytes = await image.read() + return await svc.search( + image_bytes=image_bytes, + top_k=top_k, + threshold=threshold, + ) + + +@router.post("/search/url") +async def search_faces_url( + req: SearchURLRequest, + svc: FaceIndexService = Depends(get_face_index_service), +): + """Search by image URL (alternative to multipart upload).""" + return await svc.search( + image_url=req.image_url, + top_k=req.top_k, + threshold=req.threshold, + ) + + +@router.get("/list") +async def list_enrolled_faces( + limit: int = 50, + offset: int = 0, + name: Optional[str] = None, + svc: FaceIndexService = Depends(get_face_index_service), +): + """Paginated list of enrolled faces in the index.""" + return {"faces": svc.list(limit=limit, offset=offset, name=name)} + + +@router.get("/{face_id}") +async def get_face( + face_id: str, + svc: FaceIndexService = Depends(get_face_index_service), +): + """Get details of a specific enrolled face.""" + face = svc.get(face_id) + if face is None: + return {"success": False, "error": "Face not found", "face_id": face_id} + return {"success": True, "face": face} + + +@router.delete("/{face_id}") +async def delete_face( + face_id: str, + svc: FaceIndexService = Depends(get_face_index_service), +): + """Remove a face from the index.""" + deleted = svc.delete(face_id) + return {"success": deleted, "face_id": face_id, + "message": "Deleted" if deleted else "Not found"} + + +@router.get("/stats") +async def face_index_stats( + svc: FaceIndexService = Depends(get_face_index_service), +): + """Index statistics: total count, recent enrollments, etc.""" + return svc.stats() diff --git a/download/face-intel/api/routes/health.py b/api/routes/health.py similarity index 100% rename from download/face-intel/api/routes/health.py rename to api/routes/health.py diff --git a/download/face-intel/api/routes/jobs.py b/api/routes/jobs.py similarity index 100% rename from download/face-intel/api/routes/jobs.py rename to api/routes/jobs.py diff --git a/download/face-intel/api/routes/osint.py b/api/routes/osint.py similarity index 100% rename from download/face-intel/api/routes/osint.py rename to api/routes/osint.py diff --git a/download/face-intel/api/routes/providers.py b/api/routes/providers.py similarity index 100% rename from download/face-intel/api/routes/providers.py rename to api/routes/providers.py diff --git a/download/face-intel/api/routes/search.py b/api/routes/search.py similarity index 100% rename from download/face-intel/api/routes/search.py rename to api/routes/search.py diff --git a/download/face-intel/api/routes/stats.py b/api/routes/stats.py similarity index 100% rename from download/face-intel/api/routes/stats.py rename to api/routes/stats.py diff --git a/download/face-intel/app.py b/app.py similarity index 100% rename from download/face-intel/app.py rename to app.py diff --git a/download/face-intel/confidence/__init__.py b/confidence/__init__.py similarity index 100% rename from download/face-intel/confidence/__init__.py rename to confidence/__init__.py diff --git a/download/face-intel/confidence/conflicts.py b/confidence/conflicts.py similarity index 100% rename from download/face-intel/confidence/conflicts.py rename to confidence/conflicts.py diff --git a/download/face-intel/confidence/engine.py b/confidence/engine.py similarity index 100% rename from download/face-intel/confidence/engine.py rename to confidence/engine.py diff --git a/download/face-intel/confidence/explainer.py b/confidence/explainer.py similarity index 100% rename from download/face-intel/confidence/explainer.py rename to confidence/explainer.py diff --git a/download/face-intel/config/__init__.py b/config/__init__.py similarity index 100% rename from download/face-intel/config/__init__.py rename to config/__init__.py diff --git a/download/face-intel/config/settings.py b/config/settings.py similarity index 92% rename from download/face-intel/config/settings.py rename to config/settings.py index bd671e06a9fe4a35c7f7a494fd2a1eceefb2a586..806441bf169c909e86b80b7db4cb6434f795db0d 100755 --- a/download/face-intel/config/settings.py +++ b/config/settings.py @@ -90,6 +90,18 @@ class Settings(BaseSettings): # Embeddings (optional — requires onnxruntime) enable_mobilenet_embed: bool = False + # ------------------------------------------------------------------ # + # Reverse face search (PimEyes-style indexed search) + # ------------------------------------------------------------------ # + enable_face_index: bool = True + face_index_path: str = str(DATA_DIR / "face_index.db") + face_index_top_k: int = 10 + face_index_threshold: float = 0.5 + face_index_max_faces: int = 100_000 + face_index_crawler_user_agent: str = "FaceIntel/1.0" + face_index_crawler_seed_wikipedia: bool = False + face_index_crawler_seed_imdb: bool = False + # ------------------------------------------------------------------ # # Detection tuning # ------------------------------------------------------------------ # diff --git a/download/face-intel/cores/__init__.py b/cores/__init__.py similarity index 100% rename from download/face-intel/cores/__init__.py rename to cores/__init__.py diff --git a/download/face-intel/cores/correlation/__init__.py b/cores/correlation/__init__.py similarity index 100% rename from download/face-intel/cores/correlation/__init__.py rename to cores/correlation/__init__.py diff --git a/download/face-intel/cores/correlation/graph.py b/cores/correlation/graph.py similarity index 100% rename from download/face-intel/cores/correlation/graph.py rename to cores/correlation/graph.py diff --git a/download/face-intel/cores/correlation/matchers.py b/cores/correlation/matchers.py similarity index 100% rename from download/face-intel/cores/correlation/matchers.py rename to cores/correlation/matchers.py diff --git a/download/face-intel/cores/embedding/__init__.py b/cores/embedding/__init__.py similarity index 100% rename from download/face-intel/cores/embedding/__init__.py rename to cores/embedding/__init__.py diff --git a/download/face-intel/cores/embedding/cache.py b/cores/embedding/cache.py similarity index 100% rename from download/face-intel/cores/embedding/cache.py rename to cores/embedding/cache.py diff --git a/download/face-intel/cores/embedding/vectors.py b/cores/embedding/vectors.py similarity index 100% rename from download/face-intel/cores/embedding/vectors.py rename to cores/embedding/vectors.py diff --git a/download/face-intel/cores/face/__init__.py b/cores/face/__init__.py similarity index 100% rename from download/face-intel/cores/face/__init__.py rename to cores/face/__init__.py diff --git a/download/face-intel/cores/face/analysis.py b/cores/face/analysis.py similarity index 100% rename from download/face-intel/cores/face/analysis.py rename to cores/face/analysis.py diff --git a/download/face-intel/cores/face/helpers.py b/cores/face/helpers.py similarity index 100% rename from download/face-intel/cores/face/helpers.py rename to cores/face/helpers.py diff --git a/download/face-intel/cores/location/__init__.py b/cores/location/__init__.py similarity index 100% rename from download/face-intel/cores/location/__init__.py rename to cores/location/__init__.py diff --git a/download/face-intel/cores/location/estimator.py b/cores/location/estimator.py similarity index 100% rename from download/face-intel/cores/location/estimator.py rename to cores/location/estimator.py diff --git a/download/face-intel/cores/location/geography.py b/cores/location/geography.py similarity index 100% rename from download/face-intel/cores/location/geography.py rename to cores/location/geography.py diff --git a/download/face-intel/cores/location/language.py b/cores/location/language.py similarity index 100% rename from download/face-intel/cores/location/language.py rename to cores/location/language.py diff --git a/download/face-intel/cores/location/plates.py b/cores/location/plates.py similarity index 100% rename from download/face-intel/cores/location/plates.py rename to cores/location/plates.py diff --git a/download/face-intel/cores/metadata/__init__.py b/cores/metadata/__init__.py similarity index 100% rename from download/face-intel/cores/metadata/__init__.py rename to cores/metadata/__init__.py diff --git a/download/face-intel/cores/metadata/extractor.py b/cores/metadata/extractor.py similarity index 100% rename from download/face-intel/cores/metadata/extractor.py rename to cores/metadata/extractor.py diff --git a/download/face-intel/cores/metadata/forensic.py b/cores/metadata/forensic.py similarity index 100% rename from download/face-intel/cores/metadata/forensic.py rename to cores/metadata/forensic.py diff --git a/download/face-intel/cores/onnx/__init__.py b/cores/onnx/__init__.py similarity index 100% rename from download/face-intel/cores/onnx/__init__.py rename to cores/onnx/__init__.py diff --git a/download/face-intel/cores/onnx/downloader.py b/cores/onnx/downloader.py similarity index 100% rename from download/face-intel/cores/onnx/downloader.py rename to cores/onnx/downloader.py diff --git a/download/face-intel/cores/onnx/session.py b/cores/onnx/session.py similarity index 100% rename from download/face-intel/cores/onnx/session.py rename to cores/onnx/session.py diff --git a/download/face-intel/cores/osint/__init__.py b/cores/osint/__init__.py similarity index 100% rename from download/face-intel/cores/osint/__init__.py rename to cores/osint/__init__.py diff --git a/download/face-intel/cores/osint/merger.py b/cores/osint/merger.py similarity index 100% rename from download/face-intel/cores/osint/merger.py rename to cores/osint/merger.py diff --git a/download/face-intel/cores/osint/sources.py b/cores/osint/sources.py similarity index 100% rename from download/face-intel/cores/osint/sources.py rename to cores/osint/sources.py diff --git a/download/face-intel/cores/osint/url.py b/cores/osint/url.py similarity index 100% rename from download/face-intel/cores/osint/url.py rename to cores/osint/url.py diff --git a/download/face-intel/cores/search/__init__.py b/cores/search/__init__.py similarity index 100% rename from download/face-intel/cores/search/__init__.py rename to cores/search/__init__.py diff --git a/download/face-intel/cores/search/http.py b/cores/search/http.py similarity index 100% rename from download/face-intel/cores/search/http.py rename to cores/search/http.py diff --git a/download/face-intel/cores/search/images.py b/cores/search/images.py similarity index 100% rename from download/face-intel/cores/search/images.py rename to cores/search/images.py diff --git a/download/face-intel/cores/search/user_agent.py b/cores/search/user_agent.py similarity index 100% rename from download/face-intel/cores/search/user_agent.py rename to cores/search/user_agent.py diff --git a/download/face-intel/cores/vision/__init__.py b/cores/vision/__init__.py similarity index 100% rename from download/face-intel/cores/vision/__init__.py rename to cores/vision/__init__.py diff --git a/download/face-intel/cores/vision/color.py b/cores/vision/color.py similarity index 100% rename from download/face-intel/cores/vision/color.py rename to cores/vision/color.py diff --git a/download/face-intel/cores/vision/decode.py b/cores/vision/decode.py similarity index 100% rename from download/face-intel/cores/vision/decode.py rename to cores/vision/decode.py diff --git a/download/face-intel/cores/vision/drawing.py b/cores/vision/drawing.py similarity index 100% rename from download/face-intel/cores/vision/drawing.py rename to cores/vision/drawing.py diff --git a/download/face-intel/cores/vision/geometry.py b/cores/vision/geometry.py similarity index 100% rename from download/face-intel/cores/vision/geometry.py rename to cores/vision/geometry.py diff --git a/download/face-intel/cores/vision/hashing.py b/cores/vision/hashing.py similarity index 100% rename from download/face-intel/cores/vision/hashing.py rename to cores/vision/hashing.py diff --git a/download/face-intel/cores/vision/quality.py b/cores/vision/quality.py similarity index 100% rename from download/face-intel/cores/vision/quality.py rename to cores/vision/quality.py diff --git a/download/face-intel/cores/vision/yolo.py b/cores/vision/yolo.py similarity index 100% rename from download/face-intel/cores/vision/yolo.py rename to cores/vision/yolo.py diff --git a/download/face-intel/docs/API_REFERENCE.md b/docs/API_REFERENCE.md similarity index 100% rename from download/face-intel/docs/API_REFERENCE.md rename to docs/API_REFERENCE.md diff --git a/download/face-intel/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md similarity index 100% rename from download/face-intel/docs/ARCHITECTURE.md rename to docs/ARCHITECTURE.md diff --git a/download/face-intel/docs/CONFIGURATION.md b/docs/CONFIGURATION.md similarity index 100% rename from download/face-intel/docs/CONFIGURATION.md rename to docs/CONFIGURATION.md diff --git a/download/face-intel/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md similarity index 100% rename from download/face-intel/docs/DEPLOYMENT.md rename to docs/DEPLOYMENT.md diff --git a/download/face-intel/docs/ECOSYSTEM_RESEARCH.md b/docs/ECOSYSTEM_RESEARCH.md similarity index 100% rename from download/face-intel/docs/ECOSYSTEM_RESEARCH.md rename to docs/ECOSYSTEM_RESEARCH.md diff --git a/download/face-intel/docs/OPTIMIZATION_REPORT.md b/docs/OPTIMIZATION_REPORT.md similarity index 100% rename from download/face-intel/docs/OPTIMIZATION_REPORT.md rename to docs/OPTIMIZATION_REPORT.md diff --git a/download/face-intel/docs/PHASE_IMPLEMENTATION_REPORT.md b/docs/PHASE_IMPLEMENTATION_REPORT.md similarity index 100% rename from download/face-intel/docs/PHASE_IMPLEMENTATION_REPORT.md rename to docs/PHASE_IMPLEMENTATION_REPORT.md diff --git a/download/face-intel/docs/PROVIDERS.md b/docs/PROVIDERS.md similarity index 100% rename from download/face-intel/docs/PROVIDERS.md rename to docs/PROVIDERS.md diff --git a/download/face-intel/docs/TESTING.md b/docs/TESTING.md similarity index 100% rename from download/face-intel/docs/TESTING.md rename to docs/TESTING.md diff --git a/download/face-intel/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md similarity index 100% rename from download/face-intel/docs/TROUBLESHOOTING.md rename to docs/TROUBLESHOOTING.md diff --git a/research/01_face.json b/docs/research/01_face.json similarity index 100% rename from research/01_face.json rename to docs/research/01_face.json diff --git a/research/02_forensics.json b/docs/research/02_forensics.json similarity index 100% rename from research/02_forensics.json rename to docs/research/02_forensics.json diff --git a/research/03_reverse.json b/docs/research/03_reverse.json similarity index 100% rename from research/03_reverse.json rename to docs/research/03_reverse.json diff --git a/research/04_ocr.json b/docs/research/04_ocr.json similarity index 100% rename from research/04_ocr.json rename to docs/research/04_ocr.json diff --git a/research/05_object.json b/docs/research/05_object.json similarity index 100% rename from research/05_object.json rename to docs/research/05_object.json diff --git a/research/06_logo.json b/docs/research/06_logo.json similarity index 100% rename from research/06_logo.json rename to docs/research/06_logo.json diff --git a/research/07_landmark.json b/docs/research/07_landmark.json similarity index 100% rename from research/07_landmark.json rename to docs/research/07_landmark.json diff --git a/research/08_quality.json b/docs/research/08_quality.json similarity index 100% rename from research/08_quality.json rename to docs/research/08_quality.json diff --git a/research/09_nsfw.json b/docs/research/09_nsfw.json similarity index 100% rename from research/09_nsfw.json rename to docs/research/09_nsfw.json diff --git a/research/10_geo.json b/docs/research/10_geo.json similarity index 100% rename from research/10_geo.json rename to docs/research/10_geo.json diff --git a/research/11_watermark.json b/docs/research/11_watermark.json similarity index 100% rename from research/11_watermark.json rename to docs/research/11_watermark.json diff --git a/research/12_dedup.json b/docs/research/12_dedup.json similarity index 100% rename from research/12_dedup.json rename to docs/research/12_dedup.json diff --git a/research/13_metadata.json b/docs/research/13_metadata.json similarity index 100% rename from research/13_metadata.json rename to docs/research/13_metadata.json diff --git a/research/14_face_advanced.json b/docs/research/14_face_advanced.json similarity index 100% rename from research/14_face_advanced.json rename to docs/research/14_face_advanced.json diff --git a/research/15_hf.json b/docs/research/15_hf.json similarity index 100% rename from research/15_hf.json rename to docs/research/15_hf.json diff --git a/research/16_awesome.json b/docs/research/16_awesome.json similarity index 100% rename from research/16_awesome.json rename to docs/research/16_awesome.json diff --git a/research/17_clip.json b/docs/research/17_clip.json similarity index 100% rename from research/17_clip.json rename to docs/research/17_clip.json diff --git a/research/18_deepfake.json b/docs/research/18_deepfake.json similarity index 100% rename from research/18_deepfake.json rename to docs/research/18_deepfake.json diff --git a/research/r1_lightweight_face.json b/docs/research/r1_lightweight_face.json similarity index 100% rename from research/r1_lightweight_face.json rename to docs/research/r1_lightweight_face.json diff --git a/research/r2_lightweight_ocr.json b/docs/research/r2_lightweight_ocr.json similarity index 100% rename from research/r2_lightweight_ocr.json rename to docs/research/r2_lightweight_ocr.json diff --git a/research/r3_lightweight_object.json b/docs/research/r3_lightweight_object.json similarity index 100% rename from research/r3_lightweight_object.json rename to docs/research/r3_lightweight_object.json diff --git a/research/r4_lightweight_nsfw.json b/docs/research/r4_lightweight_nsfw.json similarity index 100% rename from research/r4_lightweight_nsfw.json rename to docs/research/r4_lightweight_nsfw.json diff --git a/research/r5_ai_image_detect.json b/docs/research/r5_ai_image_detect.json similarity index 100% rename from research/r5_ai_image_detect.json rename to docs/research/r5_ai_image_detect.json diff --git a/research/r6_lightweight_embedding.json b/docs/research/r6_lightweight_embedding.json similarity index 100% rename from research/r6_lightweight_embedding.json rename to docs/research/r6_lightweight_embedding.json diff --git a/research/r7_scene.json b/docs/research/r7_scene.json similarity index 100% rename from research/r7_scene.json rename to docs/research/r7_scene.json diff --git a/download/README.md b/download/README.md deleted file mode 100755 index 9820ad3ac711f1b09a57ca3f5085bff88728d13c..0000000000000000000000000000000000000000 --- a/download/README.md +++ /dev/null @@ -1 +0,0 @@ -Here are all the generated files. diff --git a/download/face-intel/.gitignore b/download/face-intel/.gitignore deleted file mode 100644 index 2effcf36bbbb5dd8fb2a472fcc515d335331b55b..0000000000000000000000000000000000000000 --- a/download/face-intel/.gitignore +++ /dev/null @@ -1,87 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -venv/ -env/ -ENV/ -env.bak/ -venv.bak/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ -.project -.pydevproject - -# OS -.DS_Store -Thumbs.db -desktop.ini - -# Environment / secrets -.env -.env.local -.env.*.local -*.pem -*.key -secrets/ - -# Runtime data -data/ -!data/.gitkeep - -# Database -*.db -*.sqlite -*.sqlite3 - -# Logs -*.log -logs/ - -# Models (downloaded at runtime — too large for git) -data/models/*.onnx -data/models/*.caffemodel -data/models/*.prototxt -data/models/*.npy -data/models/*.pt -data/models/*.bin - -# Gallery (user-uploaded face embeddings) -data/gallery/* - -# Uploads -data/uploads/* -data/jobs/* - -# Test artifacts -.pytest_cache/ -.coverage -htmlcov/ -.tox/ - -# Jupyter -.ipynb_checkpoints/ diff --git a/download/face-intel/README.md b/download/face-intel/README.md deleted file mode 100755 index fe1c6b4a392270178ec76921b652fbfb14050a1d..0000000000000000000000000000000000000000 --- a/download/face-intel/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Face Intel - -A multi-provider face intelligence platform with evidence-first, -explainable results. - -## Documentation - -| Document | Audience | What's in it | -|---|---|---| -| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | All engineers | Layer dependency graph, execution flow, lifecycle diagrams, DI container, structured logging, metrics subsystem. | -| [`docs/PROVIDERS.md`](docs/PROVIDERS.md) | Provider authors | Provider Protocol, capability enum, step-by-step guide to adding a new provider, manifest format, health check & error handling patterns, testing pattern. | -| [`docs/API_REFERENCE.md`](docs/API_REFERENCE.md) | API consumers | Every endpoint with method, path, request/response schemas, status codes, `curl` examples. | -| [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) | Operators | Every `FI_*` env var with type, default, and description. Provider enable flags, API keys, cache/health/orchestrator tuning, production vs development profiles. | -| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | SRE / DevOps | Local dev setup, Docker deployment, Kubernetes manifests, reverse proxy config, storage layout, load balancer health probes. | -| [`docs/TESTING.md`](docs/TESTING.md) | Contributors | Test structure (unit/provider/integration), how to run tests, how to write new tests, fixtures, coverage goals, CI integration. | -| [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | On-call | Common errors and fixes: provider not_configured, circuit breaker open, model download failures, dlib compilation, Selenium/Chrome issues, cache issues, performance tuning. | -| [`docs/ECOSYSTEM_RESEARCH.md`](docs/ECOSYSTEM_RESEARCH.md) | Architects | Open-source ecosystem survey of 42 projects across 18 capability categories, with capability matrix, priority ranking, integration roadmap, effort estimates, and example API designs. | -| [`docs/OPTIMIZATION_REPORT.md`](docs/OPTIMIZATION_REPORT.md) | All engineers | Consolidation report: shared `cores/` modules, dependency minimization, vendor-only-what's-necessary strategy, and savings estimates (89% storage reduction, 70% startup improvement). | - -## Architecture - -See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full -architecture documentation including dependency graph, package -responsibilities, execution flow, lifecycle diagrams, and the provider -extension guide. - -## Quick Start - -```bash -# 1. Install dependencies -pip install -r requirements.txt - -# 2. Configure -cp .env.example .env -# edit .env to enable/disable providers - -# 3. Run -python app.py -# or: uvicorn app:app --reload - -# 4. Open -# http://localhost:8000/docs — Swagger UI -# http://localhost:8000/health — health check -# http://localhost:8000/providers — provider list -# http://localhost:8000/stats — metrics -``` - -## Key Endpoints - -| Method | Path | Description | -|---|---|---| -| `POST` | `/faces/detect` | Detect faces in an image | -| `POST` | `/faces/recognize` | Recognize faces against the gallery | -| `POST` | `/search/reverse` | Reverse image search | -| `POST` | `/search/scrape` | Scrape images from a URL | -| `POST` | `/jobs` | Full-pipeline job | -| `GET` | `/providers` | List all providers | -| `GET` | `/stats` | Metrics snapshot | -| `GET` | `/health/providers` | Per-provider health | - -## Adding a New Provider - -1. Implement `providers//.py` (subclass `BaseProvider`). -2. Add one entry to `PROVIDER_MANIFEST` in `providers/registry.py`. -3. Add an `enable_: bool = False` flag to `config/settings.py`. - -That's it — the orchestrator, services, API, and UI pick it up -automatically. See [`docs/PROVIDERS.md`](docs/PROVIDERS.md) for the -complete step-by-step guide with code examples, the manifest entry -format, settings flag conventions, health check patterns, error -handling, and the testing pattern. See `docs/ARCHITECTURE.md §5` -for a quick reference example. - -## Engineering Principles - -- **Modular** — every provider is isolated; failures don't cascade. -- **Extensible** — new providers require zero orchestrator/API changes. -- **Production-ready** — circuit breakers, retries, caching, rate limiting, audit log. -- **Evidence-first** — raw provider responses preserved verbatim. -- **Explainable** — confidence scores decomposed into weighted sub-scores. -- **Clean separation** — 11 layers, strict one-way dependency direction. -- **DI throughout** — no global stateful objects; everything injectable. diff --git a/download/face-intel/api/routes/faces.py b/download/face-intel/api/routes/faces.py deleted file mode 100755 index e9cf156779bd95c10a97d4c27f5e4cb974b63de8..0000000000000000000000000000000000000000 --- a/download/face-intel/api/routes/faces.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Face routes — detect / recognize / intelligence endpoints.""" - -from __future__ import annotations - -from typing import List, Optional - -from fastapi import APIRouter, Depends -from pydantic import BaseModel - -from api.deps import ( - get_detection_service, - get_recognition_service, - get_face_intelligence_service, -) -from models.jobs import JobKind, JobRequest -from services.detection_service import DetectionService -from services.recognition_service import RecognitionService -from services.face_intelligence_service import FaceIntelligenceService - -router = APIRouter() - - -class FaceRequest(BaseModel): - image_url: Optional[str] = None - image_base64: Optional[str] = None - providers: List[str] = [] - - -@router.post("/detect") -async def detect_faces(req: FaceRequest, svc: DetectionService = Depends(get_detection_service)): - job_req = JobRequest( - kind=JobKind.DETECTION, - image_url=req.image_url, - image_base64=req.image_base64, - providers=req.providers, - ) - return await svc.detect(job_req) - - -@router.post("/recognize") -async def recognize_faces(req: FaceRequest, svc: RecognitionService = Depends(get_recognition_service)): - job_req = JobRequest( - kind=JobKind.RECOGNITION, - image_url=req.image_url, - image_base64=req.image_base64, - providers=req.providers, - ) - return await svc.recognize(job_req) - - -@router.post("/intelligence") -async def face_intelligence( - req: FaceRequest, - svc: FaceIntelligenceService = Depends(get_face_intelligence_service), -): - """Run face intelligence: quality, blur, pose, best-face, clustering.""" - job_req = JobRequest( - kind=JobKind.DETECTION, - image_url=req.image_url, - image_base64=req.image_base64, - providers=req.providers, - ) - return await svc.analyze(job_req) - - -@router.get("/gallery") -async def list_gallery(svc: RecognitionService = Depends(get_recognition_service)): - return {"persons": svc.list_known_persons()} - - -@router.delete("/gallery/{name}") -async def remove_from_gallery(name: str, svc: RecognitionService = Depends(get_recognition_service)): - return svc.remove_known_person(name) diff --git a/download/face-intel/data/face_intel.db b/download/face-intel/data/face_intel.db deleted file mode 100755 index e53ff7c603c9d8910f91f8c4524c88ff3b9ca64c..0000000000000000000000000000000000000000 Binary files a/download/face-intel/data/face_intel.db and /dev/null differ diff --git a/download/face-intel/metrics/__init__.py b/metrics/__init__.py similarity index 100% rename from download/face-intel/metrics/__init__.py rename to metrics/__init__.py diff --git a/download/face-intel/metrics/collector.py b/metrics/collector.py similarity index 100% rename from download/face-intel/metrics/collector.py rename to metrics/collector.py diff --git a/download/face-intel/metrics/counters.py b/metrics/counters.py similarity index 100% rename from download/face-intel/metrics/counters.py rename to metrics/counters.py diff --git a/download/face-intel/metrics/health_metrics.py b/metrics/health_metrics.py similarity index 100% rename from download/face-intel/metrics/health_metrics.py rename to metrics/health_metrics.py diff --git a/download/face-intel/metrics/provider_metrics.py b/metrics/provider_metrics.py similarity index 100% rename from download/face-intel/metrics/provider_metrics.py rename to metrics/provider_metrics.py diff --git a/download/face-intel/metrics/timings.py b/metrics/timings.py similarity index 100% rename from download/face-intel/metrics/timings.py rename to metrics/timings.py diff --git a/download/face-intel/models/__init__.py b/models/__init__.py similarity index 100% rename from download/face-intel/models/__init__.py rename to models/__init__.py diff --git a/download/face-intel/models/health.py b/models/health.py similarity index 100% rename from download/face-intel/models/health.py rename to models/health.py diff --git a/download/face-intel/models/jobs.py b/models/jobs.py similarity index 100% rename from download/face-intel/models/jobs.py rename to models/jobs.py diff --git a/download/face-intel/models/providers.py b/models/providers.py similarity index 94% rename from download/face-intel/models/providers.py rename to models/providers.py index ae4ce7079b214204da3803a7ad07fdb2ef025654..490157296cb9863ccdbe01867d7b7a790df62396 100755 --- a/download/face-intel/models/providers.py +++ b/models/providers.py @@ -23,6 +23,7 @@ class ProviderCapability(str, enum.Enum): NSFW_DETECTION = "nsfw_detection" # NEW AI_IMAGE_DETECTION = "ai_image_detection" # NEW EMBEDDING = "embedding" # NEW + REVERSE_FACE_SEARCH = "reverse_face_search" # NEW — PimEyes-style indexed face search class ProviderStatus(str, enum.Enum): diff --git a/download/face-intel/models/reports.py b/models/reports.py similarity index 100% rename from download/face-intel/models/reports.py rename to models/reports.py diff --git a/download/face-intel/models/responses.py b/models/responses.py similarity index 100% rename from download/face-intel/models/responses.py rename to models/responses.py diff --git a/download/face-intel/normalization/__init__.py b/normalization/__init__.py similarity index 100% rename from download/face-intel/normalization/__init__.py rename to normalization/__init__.py diff --git a/download/face-intel/normalization/merger.py b/normalization/merger.py similarity index 100% rename from download/face-intel/normalization/merger.py rename to normalization/merger.py diff --git a/download/face-intel/normalization/schema.py b/normalization/schema.py similarity index 100% rename from download/face-intel/normalization/schema.py rename to normalization/schema.py diff --git a/download/face-intel/orchestrator/__init__.py b/orchestrator/__init__.py similarity index 100% rename from download/face-intel/orchestrator/__init__.py rename to orchestrator/__init__.py diff --git a/download/face-intel/orchestrator/health.py b/orchestrator/health.py similarity index 100% rename from download/face-intel/orchestrator/health.py rename to orchestrator/health.py diff --git a/download/face-intel/orchestrator/retry.py b/orchestrator/retry.py similarity index 100% rename from download/face-intel/orchestrator/retry.py rename to orchestrator/retry.py diff --git a/download/face-intel/orchestrator/runner.py b/orchestrator/runner.py similarity index 100% rename from download/face-intel/orchestrator/runner.py rename to orchestrator/runner.py diff --git a/download/face-intel/pipeline/__init__.py b/pipeline/__init__.py similarity index 100% rename from download/face-intel/pipeline/__init__.py rename to pipeline/__init__.py diff --git a/download/face-intel/pipeline/feature_extraction.py b/pipeline/feature_extraction.py similarity index 100% rename from download/face-intel/pipeline/feature_extraction.py rename to pipeline/feature_extraction.py diff --git a/download/face-intel/pipeline/hashing.py b/pipeline/hashing.py similarity index 100% rename from download/face-intel/pipeline/hashing.py rename to pipeline/hashing.py diff --git a/download/face-intel/pipeline/postprocessing.py b/pipeline/postprocessing.py similarity index 100% rename from download/face-intel/pipeline/postprocessing.py rename to pipeline/postprocessing.py diff --git a/download/face-intel/pipeline/preprocessing.py b/pipeline/preprocessing.py similarity index 100% rename from download/face-intel/pipeline/preprocessing.py rename to pipeline/preprocessing.py diff --git a/download/face-intel/pipeline/validation.py b/pipeline/validation.py similarity index 100% rename from download/face-intel/pipeline/validation.py rename to pipeline/validation.py diff --git a/download/face-intel/providers/__init__.py b/providers/__init__.py similarity index 100% rename from download/face-intel/providers/__init__.py rename to providers/__init__.py diff --git a/download/face-intel/providers/ai_detection/__init__.py b/providers/ai_detection/__init__.py similarity index 100% rename from download/face-intel/providers/ai_detection/__init__.py rename to providers/ai_detection/__init__.py diff --git a/download/face-intel/providers/ai_detection/laid.py b/providers/ai_detection/laid.py similarity index 100% rename from download/face-intel/providers/ai_detection/laid.py rename to providers/ai_detection/laid.py diff --git a/download/face-intel/providers/base.py b/providers/base.py similarity index 100% rename from download/face-intel/providers/base.py rename to providers/base.py diff --git a/download/face-intel/providers/detection/__init__.py b/providers/detection/__init__.py similarity index 100% rename from download/face-intel/providers/detection/__init__.py rename to providers/detection/__init__.py diff --git a/download/face-intel/providers/detection/dnn.py b/providers/detection/dnn.py similarity index 100% rename from download/face-intel/providers/detection/dnn.py rename to providers/detection/dnn.py diff --git a/download/face-intel/providers/detection/haar.py b/providers/detection/haar.py similarity index 100% rename from download/face-intel/providers/detection/haar.py rename to providers/detection/haar.py diff --git a/download/face-intel/providers/embedding/__init__.py b/providers/embedding/__init__.py similarity index 100% rename from download/face-intel/providers/embedding/__init__.py rename to providers/embedding/__init__.py diff --git a/download/face-intel/providers/embedding/mobilenet.py b/providers/embedding/mobilenet.py similarity index 100% rename from download/face-intel/providers/embedding/mobilenet.py rename to providers/embedding/mobilenet.py diff --git a/download/face-intel/providers/forensics/__init__.py b/providers/forensics/__init__.py similarity index 100% rename from download/face-intel/providers/forensics/__init__.py rename to providers/forensics/__init__.py diff --git a/download/face-intel/providers/forensics/duplicate_detector.py b/providers/forensics/duplicate_detector.py similarity index 100% rename from download/face-intel/providers/forensics/duplicate_detector.py rename to providers/forensics/duplicate_detector.py diff --git a/download/face-intel/providers/forensics/ela.py b/providers/forensics/ela.py similarity index 100% rename from download/face-intel/providers/forensics/ela.py rename to providers/forensics/ela.py diff --git a/download/face-intel/providers/forensics/image_integrity.py b/providers/forensics/image_integrity.py similarity index 100% rename from download/face-intel/providers/forensics/image_integrity.py rename to providers/forensics/image_integrity.py diff --git a/download/face-intel/providers/forensics/image_similarity.py b/providers/forensics/image_similarity.py similarity index 100% rename from download/face-intel/providers/forensics/image_similarity.py rename to providers/forensics/image_similarity.py diff --git a/download/face-intel/providers/image_analysis/__init__.py b/providers/image_analysis/__init__.py similarity index 100% rename from download/face-intel/providers/image_analysis/__init__.py rename to providers/image_analysis/__init__.py diff --git a/download/face-intel/providers/image_analysis/image_properties.py b/providers/image_analysis/image_properties.py similarity index 100% rename from download/face-intel/providers/image_analysis/image_properties.py rename to providers/image_analysis/image_properties.py diff --git a/download/face-intel/providers/image_analysis/image_quality.py b/providers/image_analysis/image_quality.py similarity index 100% rename from download/face-intel/providers/image_analysis/image_quality.py rename to providers/image_analysis/image_quality.py diff --git a/download/face-intel/providers/metadata/__init__.py b/providers/metadata/__init__.py similarity index 100% rename from download/face-intel/providers/metadata/__init__.py rename to providers/metadata/__init__.py diff --git a/download/face-intel/providers/metadata/exif.py b/providers/metadata/exif.py similarity index 100% rename from download/face-intel/providers/metadata/exif.py rename to providers/metadata/exif.py diff --git a/download/face-intel/providers/nsfw/__init__.py b/providers/nsfw/__init__.py similarity index 100% rename from download/face-intel/providers/nsfw/__init__.py rename to providers/nsfw/__init__.py diff --git a/download/face-intel/providers/nsfw/nudenet.py b/providers/nsfw/nudenet.py similarity index 100% rename from download/face-intel/providers/nsfw/nudenet.py rename to providers/nsfw/nudenet.py diff --git a/download/face-intel/providers/object_detection/__init__.py b/providers/object_detection/__init__.py similarity index 100% rename from download/face-intel/providers/object_detection/__init__.py rename to providers/object_detection/__init__.py diff --git a/download/face-intel/providers/object_detection/yolov8.py b/providers/object_detection/yolov8.py similarity index 100% rename from download/face-intel/providers/object_detection/yolov8.py rename to providers/object_detection/yolov8.py diff --git a/download/face-intel/providers/object_intelligence/__init__.py b/providers/object_intelligence/__init__.py similarity index 100% rename from download/face-intel/providers/object_intelligence/__init__.py rename to providers/object_intelligence/__init__.py diff --git a/download/face-intel/providers/object_intelligence/barcode.py b/providers/object_intelligence/barcode.py similarity index 100% rename from download/face-intel/providers/object_intelligence/barcode.py rename to providers/object_intelligence/barcode.py diff --git a/download/face-intel/providers/object_intelligence/qr_code.py b/providers/object_intelligence/qr_code.py similarity index 100% rename from download/face-intel/providers/object_intelligence/qr_code.py rename to providers/object_intelligence/qr_code.py diff --git a/download/face-intel/providers/ocr/__init__.py b/providers/ocr/__init__.py similarity index 100% rename from download/face-intel/providers/ocr/__init__.py rename to providers/ocr/__init__.py diff --git a/download/face-intel/providers/ocr/rapidocr_provider.py b/providers/ocr/rapidocr_provider.py similarity index 100% rename from download/face-intel/providers/ocr/rapidocr_provider.py rename to providers/ocr/rapidocr_provider.py diff --git a/download/face-intel/providers/recognition/__init__.py b/providers/recognition/__init__.py similarity index 100% rename from download/face-intel/providers/recognition/__init__.py rename to providers/recognition/__init__.py diff --git a/download/face-intel/providers/recognition/insightface.py b/providers/recognition/insightface.py similarity index 100% rename from download/face-intel/providers/recognition/insightface.py rename to providers/recognition/insightface.py diff --git a/download/face-intel/providers/registry.py b/providers/registry.py similarity index 96% rename from download/face-intel/providers/registry.py rename to providers/registry.py index c64c32058d2b40b6f499192ac4bc99deaeb3b45e..ad90240d0d307a4254358f80e55e70c0c3766af8 100755 --- a/download/face-intel/providers/registry.py +++ b/providers/registry.py @@ -74,6 +74,13 @@ PROVIDER_MANIFEST: list[ManifestEntry] = [ ProviderCapability.REVERSE_SEARCH, "enable_social_lookup", "Constructs reverse-search URLs for Google/Bing/Yandex/TinEye/Reddit"), + # ------------------------------------------------------------------ # + # Reverse face search (PimEyes-style indexed search) + # ------------------------------------------------------------------ # + ManifestEntry("face_index", "providers.reverse.face_index_provider", "FaceIndexProvider", + ProviderCapability.REVERSE_FACE_SEARCH, "enable_face_index", + "Indexed reverse face search — sqlite-vec + ArcFace embeddings"), + # ------------------------------------------------------------------ # # Image Analysis # ------------------------------------------------------------------ # diff --git a/download/face-intel/providers/reverse/__init__.py b/providers/reverse/__init__.py similarity index 100% rename from download/face-intel/providers/reverse/__init__.py rename to providers/reverse/__init__.py diff --git a/providers/reverse/face_index_provider.py b/providers/reverse/face_index_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..48f4dca0ec83a9752a4f3198b624b4524f399465 --- /dev/null +++ b/providers/reverse/face_index_provider.py @@ -0,0 +1,96 @@ +""" +FaceIndexProvider — reverse face search via the indexed FaceIndex. + +This provider integrates the FaceIndex (sqlite-vec + ArcFace embeddings) +with the orchestrator's provider pipeline. When the user submits a +search request, the orchestrator calls this provider's execute() with +a PipelineOutput; the provider extracts the query embedding from the +largest detected face and searches the FaceIndex for matches. + +Note: For direct API usage, prefer the /faces/enroll and /faces/search +endpoints which bypass the orchestrator and call FaceIndexService +directly. This provider exists so the orchestrator can include +reverse-face-search in full-pipeline /jobs runs. +""" + +from __future__ import annotations + +from typing import Any + +from config.settings import Settings, settings as _default_settings +from models.providers import ProviderCapability +from pipeline.feature_extraction import PipelineOutput +from providers.base import BaseProvider, ProviderResult + + +class FaceIndexProvider(BaseProvider): + """Reverse face search via the indexed FaceIndex.""" + + name = "face_index" + capability = ProviderCapability.REVERSE_FACE_SEARCH + + def __init__(self, settings: Settings | None = None) -> None: + super().__init__(settings=settings or _default_settings) + # The FaceIndex instance is injected at runtime by the orchestrator + # via the pipeline_output's metadata, OR looked up lazily. + # For now, we mark availability based on the enable flag. + self._available = bool(getattr(self._settings, "enable_face_index", True)) + + def is_available(self) -> bool: + return self._available + + def _run(self, pipeline_output: PipelineOutput) -> tuple[dict, dict]: + """ + Search the FaceIndex for matches to the largest face in the image. + + The FaceIndex instance is NOT available here directly (providers are + isolated). Instead, this provider reads the embedding from the + pipeline_output (set by InsightFaceProvider if it ran first) and + returns the embedding for the orchestrator to use. + + For direct indexed search, use the /faces/search API endpoint instead. + """ + if not pipeline_output.face_crops: + raw = {"num_query_faces": 0, "matches": [], "note": "No faces detected in query image"} + normalized = {"num_query_faces": 0, "matches": []} + return raw, normalized + + # Use the largest face + largest = max( + pipeline_output.face_crops, + key=lambda c: getattr(c, "width", 0) * getattr(c, "height", 1), + ) + embedding = getattr(largest, "embedding", None) + + if embedding is None: + raw = { + "num_query_faces": len(pipeline_output.face_crops), + "matches": [], + "note": "No embedding available — InsightFace provider did not run or failed. " + "Use the /faces/search API endpoint for direct indexed search.", + } + normalized = { + "num_query_faces": len(pipeline_output.face_crops), + "matches": [], + } + return raw, normalized + + # The actual index search is performed by the FaceIndexService + # (called from the API layer). This provider just signals that + # the query embedding is ready. + import numpy as np + if not isinstance(embedding, np.ndarray): + embedding = np.array(embedding, dtype=np.float32) + + raw = { + "num_query_faces": len(pipeline_output.face_crops), + "query_embedding_dim": int(embedding.shape[0]), + "matches": [], + "note": "Query embedding computed. Use /faces/search to search the index.", + } + normalized = { + "num_query_faces": len(pipeline_output.face_crops), + "query_embedding": embedding.tolist(), + "matches": [], + } + return raw, normalized diff --git a/download/face-intel/providers/reverse/serpapi.py b/providers/reverse/serpapi.py similarity index 100% rename from download/face-intel/providers/reverse/serpapi.py rename to providers/reverse/serpapi.py diff --git a/download/face-intel/providers/reverse/social_lookup.py b/providers/reverse/social_lookup.py similarity index 100% rename from download/face-intel/providers/reverse/social_lookup.py rename to providers/reverse/social_lookup.py diff --git a/download/face-intel/providers/scene/__init__.py b/providers/scene/__init__.py similarity index 100% rename from download/face-intel/providers/scene/__init__.py rename to providers/scene/__init__.py diff --git a/download/face-intel/providers/scene/places365.py b/providers/scene/places365.py similarity index 100% rename from download/face-intel/providers/scene/places365.py rename to providers/scene/places365.py diff --git a/download/face-intel/providers/scraper/__init__.py b/providers/scraper/__init__.py similarity index 100% rename from download/face-intel/providers/scraper/__init__.py rename to providers/scraper/__init__.py diff --git a/download/face-intel/providers/similarity/__init__.py b/providers/similarity/__init__.py similarity index 100% rename from download/face-intel/providers/similarity/__init__.py rename to providers/similarity/__init__.py diff --git a/download/face-intel/pytest.ini b/pytest.ini similarity index 100% rename from download/face-intel/pytest.ini rename to pytest.ini diff --git a/download/face-intel/requirements-dev.txt b/requirements-dev.txt similarity index 100% rename from download/face-intel/requirements-dev.txt rename to requirements-dev.txt diff --git a/download/face-intel/requirements.txt b/requirements.txt similarity index 96% rename from download/face-intel/requirements.txt rename to requirements.txt index 7b9d40225b5a0de0ccb6628b9e21dcd713af8d55..bc79b3c830e880573b5b250517eb5e621c03d1c9 100755 --- a/download/face-intel/requirements.txt +++ b/requirements.txt @@ -25,6 +25,9 @@ requests==2.31.0 loguru==0.7.2 python-dotenv==1.0.1 +# --- Reverse face search (required for face index) --- +sqlite-vec==0.1.6 + # --- Testing (dev only — pip install -r requirements-dev.txt) --- # pytest==8.0.0 # pytest-asyncio==0.23.5 diff --git a/download/face-intel/scripts/check_imports.py b/scripts/check_imports.py similarity index 100% rename from download/face-intel/scripts/check_imports.py rename to scripts/check_imports.py diff --git a/download/face-intel/services/__init__.py b/services/__init__.py similarity index 100% rename from download/face-intel/services/__init__.py rename to services/__init__.py diff --git a/download/face-intel/services/analysis_service.py b/services/analysis_service.py similarity index 100% rename from download/face-intel/services/analysis_service.py rename to services/analysis_service.py diff --git a/download/face-intel/services/cache_service.py b/services/cache_service.py similarity index 100% rename from download/face-intel/services/cache_service.py rename to services/cache_service.py diff --git a/download/face-intel/services/correlation_engine_service.py b/services/correlation_engine_service.py similarity index 100% rename from download/face-intel/services/correlation_engine_service.py rename to services/correlation_engine_service.py diff --git a/download/face-intel/services/detection_service.py b/services/detection_service.py similarity index 100% rename from download/face-intel/services/detection_service.py rename to services/detection_service.py diff --git a/download/face-intel/services/export_service.py b/services/export_service.py similarity index 100% rename from download/face-intel/services/export_service.py rename to services/export_service.py diff --git a/services/face_index_service.py b/services/face_index_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3d30390eccf0b249172745d3e8e254cca72ce09e --- /dev/null +++ b/services/face_index_service.py @@ -0,0 +1,236 @@ +""" +FaceIndexService — high-level reverse face search service. + +Handles: + - Image upload + validation + - Face detection (using the feature extractor) + - Embedding generation (via InsightFace provider if available) + - Enrollment into the FaceIndex + - Search against the FaceIndex + - Crawler for seeding the index with public faces +""" + +from __future__ import annotations + +import base64 +import io +import time +import uuid +from typing import Optional + +import numpy as np +from loguru import logger + +from config.settings import Settings +from pipeline import InputValidator, ImagePreprocessor, ImageHasher, FeatureExtractor +from storage.face_index import FaceIndex +from utils.logging import execution_context, new_execution_id + + +class FaceIndexService: + """High-level service for the reverse face search index.""" + + def __init__( + self, + settings: Settings, + validator: InputValidator, + preprocessor: ImagePreprocessor, + hasher: ImageHasher, + feature_extractor: FeatureExtractor, + face_index: FaceIndex, + ) -> None: + self._settings = settings + self._validator = validator + self._preprocessor = preprocessor + self._hasher = hasher + self._feature_extractor = feature_extractor + self._index = face_index + + # ------------------------------------------------------------------ # + # Enrollment + # ------------------------------------------------------------------ # + async def enroll( + self, + image_url: Optional[str] = None, + image_base64: Optional[str] = None, + image_bytes: Optional[bytes] = None, + name: Optional[str] = None, + source_url: Optional[str] = None, + metadata: Optional[dict] = None, + ) -> dict: + """ + Enroll a face into the searchable index. + + Accepts an image (URL, base64, or raw bytes) + optional metadata. + Detects the largest face in the image, computes its embedding, + and adds it to the FaceIndex. + + Returns dict with face_id + status. + """ + eid = new_execution_id() + with execution_context(execution_id=eid, provider_id="face_index_service.enroll"): + t0 = time.perf_counter() + + # Validate input + if image_bytes: + vr = self._validator.validate(image_bytes=image_bytes) + else: + vr = self._validator.validate( + image_url=image_url, image_base64=image_base64, + ) + if not vr.valid: + return {"success": False, "error": vr.error, "error_type": "ValidationError"} + + # Preprocess + if vr.source == "url": + pre = self._preprocessor.from_url(image_url) + else: + pre = self._preprocessor.from_bytes(vr.image_bytes, vr.source) + + img_hash = self._hasher.hash(pre.image) + pipeline_output = self._feature_extractor.extract( + pre.image, img_hash, pre.width, pre.height, pre.source, + original_bytes=pre.original_bytes, + original_format=pre.original_format, + ) + + # Check if any faces were detected + if not pipeline_output.face_crops: + return { + "success": False, + "error": "No faces detected in image", + "error_type": "NoFaceDetected", + } + + # Use the largest face (highest area) + largest = max( + pipeline_output.face_crops, + key=lambda c: c.width * c.height if hasattr(c, "width") else 0, + ) + embedding = getattr(largest, "embedding", None) + if embedding is None: + # Fallback: try to compute embedding via feature extractor + embedding = self._feature_extractor.compute_embedding(largest.image) + if embedding is None: + return { + "success": False, + "error": "Could not compute face embedding (InsightFace provider not available?)", + "error_type": "EmbeddingFailed", + } + + # Enroll into index + face_id = self._index.enroll( + embedding=embedding, + name=name, + source_url=source_url, + metadata=metadata, + thumbnail_path=None, # could save crop here in future + ) + + elapsed = (time.perf_counter() - t0) * 1000.0 + return { + "success": True, + "face_id": face_id, + "embedding_dim": self._index.EMBEDDING_DIM, + "elapsed_ms": round(elapsed, 3), + } + + # ------------------------------------------------------------------ # + # Search + # ------------------------------------------------------------------ # + async def search( + self, + image_url: Optional[str] = None, + image_base64: Optional[str] = None, + image_bytes: Optional[bytes] = None, + top_k: Optional[int] = None, + threshold: Optional[float] = None, + ) -> dict: + """ + Search the index for faces matching the query image. + + Returns dict with matches sorted by similarity (descending). + """ + eid = new_execution_id() + with execution_context(execution_id=eid, provider_id="face_index_service.search"): + t0 = time.perf_counter() + + # Validate input + if image_bytes: + vr = self._validator.validate(image_bytes=image_bytes) + else: + vr = self._validator.validate( + image_url=image_url, image_base64=image_base64, + ) + if not vr.valid: + return {"success": False, "error": vr.error, "error_type": "ValidationError"} + + # Preprocess + if vr.source == "url": + pre = self._preprocessor.from_url(image_url) + else: + pre = self._preprocessor.from_bytes(vr.image_bytes, vr.source) + + img_hash = self._hasher.hash(pre.image) + pipeline_output = self._feature_extractor.extract( + pre.image, img_hash, pre.width, pre.height, pre.source, + original_bytes=pre.original_bytes, + original_format=pre.original_format, + ) + + if not pipeline_output.face_crops: + return { + "success": True, + "query_face_detected": False, + "matches": [], + "elapsed_ms": round((time.perf_counter() - t0) * 1000, 3), + } + + # Use the largest face + largest = max( + pipeline_output.face_crops, + key=lambda c: c.width * c.height if hasattr(c, "width") else 0, + ) + embedding = getattr(largest, "embedding", None) + if embedding is None: + embedding = self._feature_extractor.compute_embedding(largest.image) + if embedding is None: + return { + "success": False, + "error": "Could not compute face embedding", + "error_type": "EmbeddingFailed", + } + + # Search the index + matches = self._index.search( + embedding, + top_k=top_k or self._settings.face_index_top_k, + threshold=threshold if threshold is not None else self._settings.face_index_threshold, + ) + + elapsed = (time.perf_counter() - t0) * 1000.0 + return { + "success": True, + "query_face_detected": True, + "num_matches": len(matches), + "matches": matches, + "elapsed_ms": round(elapsed, 3), + } + + # ------------------------------------------------------------------ # + # CRUD passthrough + # ------------------------------------------------------------------ # + def list(self, limit: int = 50, offset: int = 0, name: Optional[str] = None) -> list[dict]: + return self._index.list(limit=limit, offset=offset, name=name) + + def get(self, face_id: str) -> Optional[dict]: + return self._index.get(face_id) + + def delete(self, face_id: str) -> bool: + return self._index.delete(face_id) + + def stats(self) -> dict: + return self._index.stats() + + def clear(self) -> int: + return self._index.clear() diff --git a/download/face-intel/services/face_intelligence_service.py b/services/face_intelligence_service.py similarity index 100% rename from download/face-intel/services/face_intelligence_service.py rename to services/face_intelligence_service.py diff --git a/download/face-intel/services/forensic_metadata_service.py b/services/forensic_metadata_service.py similarity index 100% rename from download/face-intel/services/forensic_metadata_service.py rename to services/forensic_metadata_service.py diff --git a/download/face-intel/services/health_service.py b/services/health_service.py similarity index 100% rename from download/face-intel/services/health_service.py rename to services/health_service.py diff --git a/download/face-intel/services/job_service.py b/services/job_service.py similarity index 100% rename from download/face-intel/services/job_service.py rename to services/job_service.py diff --git a/download/face-intel/services/location_intelligence_service.py b/services/location_intelligence_service.py similarity index 100% rename from download/face-intel/services/location_intelligence_service.py rename to services/location_intelligence_service.py diff --git a/download/face-intel/services/object_intelligence_service.py b/services/object_intelligence_service.py similarity index 100% rename from download/face-intel/services/object_intelligence_service.py rename to services/object_intelligence_service.py diff --git a/download/face-intel/services/osint_service.py b/services/osint_service.py similarity index 100% rename from download/face-intel/services/osint_service.py rename to services/osint_service.py diff --git a/download/face-intel/services/provider_service.py b/services/provider_service.py similarity index 100% rename from download/face-intel/services/provider_service.py rename to services/provider_service.py diff --git a/download/face-intel/services/recognition_service.py b/services/recognition_service.py similarity index 100% rename from download/face-intel/services/recognition_service.py rename to services/recognition_service.py diff --git a/download/face-intel/services/search_service.py b/services/search_service.py similarity index 100% rename from download/face-intel/services/search_service.py rename to services/search_service.py diff --git a/download/face-intel/storage/__init__.py b/storage/__init__.py similarity index 84% rename from download/face-intel/storage/__init__.py rename to storage/__init__.py index a3e4b2040c6319c1c57bb520668a745225f67911..652ccdb497f17f121cbb002b7b296c683fd1e613 100755 --- a/download/face-intel/storage/__init__.py +++ b/storage/__init__.py @@ -5,6 +5,7 @@ Storage package — separated persistence responsibilities. cache.py — in-memory TTL cache (provider results) artifacts.py — filesystem artifact store (uploaded/generated images) reference_store.py — known-faces gallery (embeddings on disk) + face_index.py — reverse face search index (sqlite-vec) All storage classes receive their config via constructor (DI). No module-level singletons. @@ -14,5 +15,6 @@ from storage.database import Database from storage.cache import Cache from storage.artifacts import ArtifactStore from storage.reference_store import ReferenceStore +from storage.face_index import FaceIndex -__all__ = ["Database", "Cache", "ArtifactStore", "ReferenceStore"] +__all__ = ["Database", "Cache", "ArtifactStore", "ReferenceStore", "FaceIndex"] diff --git a/download/face-intel/storage/artifacts.py b/storage/artifacts.py similarity index 100% rename from download/face-intel/storage/artifacts.py rename to storage/artifacts.py diff --git a/download/face-intel/storage/cache.py b/storage/cache.py similarity index 100% rename from download/face-intel/storage/cache.py rename to storage/cache.py diff --git a/download/face-intel/storage/database.py b/storage/database.py similarity index 100% rename from download/face-intel/storage/database.py rename to storage/database.py diff --git a/storage/face_index.py b/storage/face_index.py new file mode 100644 index 0000000000000000000000000000000000000000..f3cbfad64db5c6affb13ea444428312d7f4d6869 --- /dev/null +++ b/storage/face_index.py @@ -0,0 +1,437 @@ +""" +FaceIndex — sqlite-vec backed reverse face search index. + +Stores 512-d ArcFace face embeddings in a SQLite database with sqlite-vec +for fast cosine similarity search. Persists to a single file +(data/face_index.db by default). + +Why sqlite-vec instead of ChromaDB / FAISS / Pinecone? + - Zero external dependencies beyond the `sqlite-vec` Python package (5MB) + - Persists to a single file (easy backup, easy ship) + - Runs on free-tier VPS without RAM issues + - Supports standard SQL queries alongside vector search + - Can be inspected with the `sqlite3` CLI + +Schema: + faces — one row per enrolled face (face_id, embedding, metadata) + enrollments — audit log of all enrollment events + +The embedding is stored as a serialized 512-d float32 vector via sqlite-vec's +vec0 virtual table type. Search is cosine similarity (since embeddings are +L2-normalized, this equals dot product). +""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import numpy as np +from loguru import logger + + +# Schema for the metadata table (regular SQLite) +_SCHEMA_METADATA = """ +CREATE TABLE IF NOT EXISTS faces ( + face_id TEXT PRIMARY KEY, + name TEXT, + source_url TEXT, + metadata TEXT NOT NULL DEFAULT '{}', + thumbnail_path TEXT, + embedding_dim INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_faces_name ON faces(name); +CREATE INDEX IF NOT EXISTS idx_faces_created ON faces(created_at); + +CREATE TABLE IF NOT EXISTS enrollments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + face_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'enroll' | 'delete' + timestamp TEXT NOT NULL, + details TEXT, + FOREIGN KEY (face_id) REFERENCES faces(face_id) +); + +CREATE INDEX IF NOT EXISTS idx_enrollments_face ON enrollments(face_id); +""" + + +class FaceIndex: + """Thread-safe reverse face search index backed by sqlite-vec.""" + + EMBEDDING_DIM = 512 + + def __init__(self, path: str = ":memory:") -> None: + self._path = path + self._lock = threading.RLock() + + # Ensure parent dir exists + if path != ":memory:": + Path(path).parent.mkdir(parents=True, exist_ok=True) + + self._conn = sqlite3.connect(path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + + # Load sqlite-vec extension + try: + import sqlite_vec # type: ignore + self._conn.enable_load_extension(True) + sqlite_vec.load(self._conn) + self._conn.enable_load_extension(False) + self._vec_available = True + logger.info("sqlite-vec extension loaded successfully") + except ImportError: + self._vec_available = False + logger.warning( + "sqlite-vec not installed — FaceIndex will use pure-Python fallback " + "(slower, no vector indexing). Install with: pip install sqlite-vec" + ) + except Exception as e: + self._vec_available = False + logger.error(f"Failed to load sqlite-vec: {e}") + + # Initialize schema + self._conn.executescript(_SCHEMA_METADATA) + self._uses_l2 = False # set by _init_vec_table + self._init_vec_table() + self._conn.commit() + + if self._vec_available: + count = self.count() + logger.info(f"FaceIndex initialized at {path} (sqlite-vec mode, {count} faces)") + else: + logger.info(f"FaceIndex initialized at {path} (fallback mode)") + + def _init_vec_table(self) -> None: + """Create the vec0 virtual table for fast vector search. + + We use cosine distance (1 - dot product for L2-normalized vectors) + by partition-adding a `distance_metric` column metadata. However, + sqlite-vec's vec0 currently defaults to L2 (Euclidean) distance. + For L2-normalized embeddings, L2² = 2(1 - cos), so we can convert + at query time: cos_sim = 1 - (L2² / 2). + """ + if not self._vec_available: + return + try: + self._conn.execute( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS face_embeddings USING vec0( + face_id TEXT PRIMARY KEY, + embedding float[{self.EMBEDDING_DIM}] distance_metric=cosine + ) + """ + ) + self._conn.commit() + except Exception as e: + # Fallback: try without distance_metric (older sqlite-vec versions) + logger.warning(f"cosine metric not supported, falling back to L2: {e}") + try: + self._conn.execute( + f""" + CREATE VIRTUAL TABLE IF NOT EXISTS face_embeddings USING vec0( + face_id TEXT PRIMARY KEY, + embedding float[{self.EMBEDDING_DIM}] + ) + """ + ) + self._conn.commit() + self._uses_l2 = True + except Exception as e2: + logger.error(f"Failed to create vec0 table: {e2}") + self._vec_available = False + else: + self._uses_l2 = False + + # ------------------------------------------------------------------ # + # Enrollment + # ------------------------------------------------------------------ # + def enroll( + self, + embedding: np.ndarray, + name: Optional[str] = None, + source_url: Optional[str] = None, + metadata: Optional[dict] = None, + thumbnail_path: Optional[str] = None, + ) -> str: + """ + Add a face to the searchable index. + + Args: + embedding: 512-d L2-normalized face embedding (ArcFace) + name: optional human-readable name + source_url: optional URL where the face was found + metadata: optional dict of arbitrary metadata (age, location, etc.) + thumbnail_path: optional path to a thumbnail image of the face + + Returns: + face_id (UUID string) + """ + if embedding.shape != (self.EMBEDDING_DIM,): + raise ValueError( + f"Embedding must be shape ({self.EMBEDDING_DIM},), got {embedding.shape}" + ) + + # L2-normalize (defensive — should already be normalized) + norm = np.linalg.norm(embedding) + if norm > 0: + embedding = embedding / norm + + face_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + metadata_json = json.dumps(metadata or {}, default=str) + embedding_bytes = embedding.astype(np.float32).tobytes() + + with self._lock: + # Insert metadata + self._conn.execute( + """INSERT INTO faces + (face_id, name, source_url, metadata, thumbnail_path, + embedding_dim, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (face_id, name, source_url, metadata_json, thumbnail_path, + self.EMBEDDING_DIM, now, now), + ) + + # Insert embedding + if self._vec_available: + self._conn.execute( + "INSERT INTO face_embeddings (face_id, embedding) VALUES (?, ?)", + (face_id, embedding_bytes), + ) + + # Audit log + self._conn.execute( + """INSERT INTO enrollments (face_id, action, timestamp, details) + VALUES (?, ?, ?, ?)""", + (face_id, "enroll", now, json.dumps({"name": name, "source_url": source_url})), + ) + self._conn.commit() + + logger.debug(f"Enrolled face {face_id} (name={name})") + return face_id + + # ------------------------------------------------------------------ # + # Search + # ------------------------------------------------------------------ # + def search( + self, + query_embedding: np.ndarray, + top_k: int = 10, + threshold: float = 0.0, + ) -> list[dict]: + """ + Search the index for faces similar to the query embedding. + + Args: + query_embedding: 512-d L2-normalized face embedding + top_k: number of results to return + threshold: minimum cosine similarity (0-1); 0 = return all + + Returns: + list of dicts sorted by similarity (descending): + { + "face_id": str, + "name": str | None, + "source_url": str | None, + "metadata": dict, + "thumbnail_path": str | None, + "similarity": float, + "created_at": str, + } + """ + if query_embedding.shape != (self.EMBEDDING_DIM,): + raise ValueError( + f"Query embedding must be shape ({self.EMBEDDING_DIM},), got {query_embedding.shape}" + ) + + # L2-normalize + norm = np.linalg.norm(query_embedding) + if norm > 0: + query_embedding = query_embedding / norm + + with self._lock: + if self._vec_available: + matches = self._search_vec(query_embedding, top_k * 2) + else: + matches = self._search_fallback(query_embedding, top_k * 2) + + # Filter by threshold + take top_k + results = [] + for m in matches: + if m["similarity"] >= threshold: + results.append(m) + if len(results) >= top_k: + break + return results + + def _search_vec(self, query_embedding: np.ndarray, k: int) -> list[dict]: + """Use sqlite-vec KNN search.""" + query_bytes = query_embedding.astype(np.float32).tobytes() + rows = self._conn.execute( + """ + SELECT + f.face_id, f.name, f.source_url, f.metadata, + f.thumbnail_path, f.created_at, + v.distance + FROM face_embeddings v + JOIN faces f ON f.face_id = v.face_id + WHERE v.embedding MATCH ? + AND k = ? + ORDER BY v.distance ASC + """, + (query_bytes, k), + ).fetchall() + + # distance interpretation depends on metric: + # - cosine: distance = 1 - cos_sim, so sim = 1 - distance + # - L2 (Euclidean): for L2-normalized vectors, L2² = 2(1 - cos_sim), + # so cos_sim = 1 - (distance² / 2). We use squared distance here + # since sqlite-vec returns Euclidean (not squared). + out = [] + for r in rows: + if getattr(self, "_uses_l2", False): + # L2 distance — convert to cosine similarity + sim = 1.0 - (r["distance"] ** 2) / 2.0 + else: + # cosine distance + sim = 1.0 - r["distance"] + out.append({ + "face_id": r["face_id"], + "name": r["name"], + "source_url": r["source_url"], + "metadata": json.loads(r["metadata"] or "{}"), + "thumbnail_path": r["thumbnail_path"], + "similarity": float(sim), + "created_at": r["created_at"], + }) + return out + + def _search_fallback(self, query_embedding: np.ndarray, k: int) -> list[dict]: + """Pure-Python fallback when sqlite-vec is not available.""" + rows = self._conn.execute( + "SELECT face_id, name, source_url, metadata, thumbnail_path, created_at FROM faces" + ).fetchall() + + # We don't store embeddings in the metadata table for fallback mode. + # In a real fallback, we'd need a separate embedding store. For now, + # return empty results — installation of sqlite-vec is required. + logger.warning( + "FaceIndex fallback mode does not support search — install sqlite-vec: " + "pip install sqlite-vec" + ) + return [] + + # ------------------------------------------------------------------ # + # CRUD + # ------------------------------------------------------------------ # + def get(self, face_id: str) -> Optional[dict]: + """Get details of a specific enrolled face.""" + with self._lock: + row = self._conn.execute( + "SELECT * FROM faces WHERE face_id = ?", (face_id,) + ).fetchone() + if not row: + return None + d = dict(row) + d["metadata"] = json.loads(d.get("metadata") or "{}") + return d + + def list(self, limit: int = 50, offset: int = 0, name: Optional[str] = None) -> list[dict]: + """Paginated list of enrolled faces.""" + with self._lock: + if name: + cur = self._conn.execute( + "SELECT * FROM faces WHERE name LIKE ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + (f"%{name}%", limit, offset), + ) + else: + cur = self._conn.execute( + "SELECT * FROM faces ORDER BY created_at DESC LIMIT ? OFFSET ?", + (limit, offset), + ) + rows = [dict(r) for r in cur.fetchall()] + for r in rows: + r["metadata"] = json.loads(r.get("metadata") or "{}") + return rows + + def delete(self, face_id: str) -> bool: + """Remove a face from the index. Returns True if deleted.""" + now = datetime.now(timezone.utc).isoformat() + with self._lock: + # Check exists + row = self._conn.execute( + "SELECT face_id FROM faces WHERE face_id = ?", (face_id,) + ).fetchone() + if not row: + return False + + self._conn.execute("DELETE FROM faces WHERE face_id = ?", (face_id,)) + if self._vec_available: + self._conn.execute( + "DELETE FROM face_embeddings WHERE face_id = ?", (face_id,) + ) + self._conn.execute( + """INSERT INTO enrollments (face_id, action, timestamp, details) + VALUES (?, ?, ?, ?)""", + (face_id, "delete", now, "{}"), + ) + self._conn.commit() + logger.debug(f"Deleted face {face_id}") + return True + + def count(self) -> int: + """Total number of enrolled faces.""" + with self._lock: + row = self._conn.execute("SELECT COUNT(*) as n FROM faces").fetchone() + return row["n"] if row else 0 + + def stats(self) -> dict: + """Index statistics.""" + with self._lock: + total = self.count() + named = self._conn.execute( + "SELECT COUNT(*) as n FROM faces WHERE name IS NOT NULL" + ).fetchone()["n"] + last_enrollment = self._conn.execute( + "SELECT created_at FROM faces ORDER BY created_at DESC LIMIT 1" + ).fetchone() + recent_enrollments = self._conn.execute( + """SELECT COUNT(*) as n FROM enrollments + WHERE action = 'enroll' + AND timestamp > datetime('now', '-24 hours')""" + ).fetchone()["n"] + return { + "total_faces": total, + "named_faces": named, + "anonymous_faces": total - named, + "last_enrollment": last_enrollment["created_at"] if last_enrollment else None, + "recent_enrollments_24h": recent_enrollments, + "vec_available": self._vec_available, + "embedding_dim": self.EMBEDDING_DIM, + "path": self._path, + } + + def clear(self) -> int: + """Remove ALL faces from the index. Returns count deleted.""" + with self._lock: + n = self.count() + self._conn.execute("DELETE FROM faces") + if self._vec_available: + self._conn.execute("DELETE FROM face_embeddings") + self._conn.execute("DELETE FROM enrollments") + self._conn.commit() + logger.warning(f"Cleared {n} faces from index") + return n + + def close(self) -> None: + with self._lock: + self._conn.close() diff --git a/download/face-intel/storage/reference_store.py b/storage/reference_store.py similarity index 100% rename from download/face-intel/storage/reference_store.py rename to storage/reference_store.py diff --git a/download/face-intel/tests/__init__.py b/tests/__init__.py similarity index 100% rename from download/face-intel/tests/__init__.py rename to tests/__init__.py diff --git a/download/face-intel/tests/conftest.py b/tests/conftest.py similarity index 100% rename from download/face-intel/tests/conftest.py rename to tests/conftest.py diff --git a/download/face-intel/tests/fixtures/__init__.py b/tests/fixtures/__init__.py similarity index 100% rename from download/face-intel/tests/fixtures/__init__.py rename to tests/fixtures/__init__.py diff --git a/download/face-intel/tests/integration/__init__.py b/tests/integration/__init__.py similarity index 100% rename from download/face-intel/tests/integration/__init__.py rename to tests/integration/__init__.py diff --git a/download/face-intel/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py similarity index 100% rename from download/face-intel/tests/integration/test_end_to_end.py rename to tests/integration/test_end_to_end.py diff --git a/download/face-intel/tests/integration/test_failure_scenarios.py b/tests/integration/test_failure_scenarios.py similarity index 100% rename from download/face-intel/tests/integration/test_failure_scenarios.py rename to tests/integration/test_failure_scenarios.py diff --git a/download/face-intel/tests/providers/__init__.py b/tests/providers/__init__.py similarity index 100% rename from download/face-intel/tests/providers/__init__.py rename to tests/providers/__init__.py diff --git a/download/face-intel/tests/providers/test_duplicate_detector.py b/tests/providers/test_duplicate_detector.py similarity index 100% rename from download/face-intel/tests/providers/test_duplicate_detector.py rename to tests/providers/test_duplicate_detector.py diff --git a/download/face-intel/tests/providers/test_ela.py b/tests/providers/test_ela.py similarity index 100% rename from download/face-intel/tests/providers/test_ela.py rename to tests/providers/test_ela.py diff --git a/download/face-intel/tests/providers/test_exif.py b/tests/providers/test_exif.py similarity index 100% rename from download/face-intel/tests/providers/test_exif.py rename to tests/providers/test_exif.py diff --git a/download/face-intel/tests/providers/test_haar.py b/tests/providers/test_haar.py similarity index 100% rename from download/face-intel/tests/providers/test_haar.py rename to tests/providers/test_haar.py diff --git a/download/face-intel/tests/providers/test_image_integrity.py b/tests/providers/test_image_integrity.py similarity index 100% rename from download/face-intel/tests/providers/test_image_integrity.py rename to tests/providers/test_image_integrity.py diff --git a/download/face-intel/tests/providers/test_image_properties.py b/tests/providers/test_image_properties.py similarity index 100% rename from download/face-intel/tests/providers/test_image_properties.py rename to tests/providers/test_image_properties.py diff --git a/download/face-intel/tests/providers/test_image_quality.py b/tests/providers/test_image_quality.py similarity index 100% rename from download/face-intel/tests/providers/test_image_quality.py rename to tests/providers/test_image_quality.py diff --git a/download/face-intel/tests/providers/test_image_similarity.py b/tests/providers/test_image_similarity.py similarity index 100% rename from download/face-intel/tests/providers/test_image_similarity.py rename to tests/providers/test_image_similarity.py diff --git a/download/face-intel/tests/providers/test_qr_code.py b/tests/providers/test_qr_code.py similarity index 100% rename from download/face-intel/tests/providers/test_qr_code.py rename to tests/providers/test_qr_code.py diff --git a/download/face-intel/tests/providers/test_social_lookup.py b/tests/providers/test_social_lookup.py similarity index 100% rename from download/face-intel/tests/providers/test_social_lookup.py rename to tests/providers/test_social_lookup.py diff --git a/download/face-intel/tests/unit/__init__.py b/tests/unit/__init__.py similarity index 100% rename from download/face-intel/tests/unit/__init__.py rename to tests/unit/__init__.py diff --git a/download/face-intel/tests/unit/test_cache.py b/tests/unit/test_cache.py similarity index 100% rename from download/face-intel/tests/unit/test_cache.py rename to tests/unit/test_cache.py diff --git a/download/face-intel/tests/unit/test_confidence.py b/tests/unit/test_confidence.py similarity index 100% rename from download/face-intel/tests/unit/test_confidence.py rename to tests/unit/test_confidence.py diff --git a/download/face-intel/tests/unit/test_conflicts.py b/tests/unit/test_conflicts.py similarity index 100% rename from download/face-intel/tests/unit/test_conflicts.py rename to tests/unit/test_conflicts.py diff --git a/download/face-intel/tests/unit/test_correlation_core.py b/tests/unit/test_correlation_core.py similarity index 100% rename from download/face-intel/tests/unit/test_correlation_core.py rename to tests/unit/test_correlation_core.py diff --git a/download/face-intel/tests/unit/test_embedding_core.py b/tests/unit/test_embedding_core.py similarity index 100% rename from download/face-intel/tests/unit/test_embedding_core.py rename to tests/unit/test_embedding_core.py diff --git a/download/face-intel/tests/unit/test_face_analysis.py b/tests/unit/test_face_analysis.py similarity index 100% rename from download/face-intel/tests/unit/test_face_analysis.py rename to tests/unit/test_face_analysis.py diff --git a/download/face-intel/tests/unit/test_face_core.py b/tests/unit/test_face_core.py similarity index 100% rename from download/face-intel/tests/unit/test_face_core.py rename to tests/unit/test_face_core.py diff --git a/tests/unit/test_face_index.py b/tests/unit/test_face_index.py new file mode 100644 index 0000000000000000000000000000000000000000..1829cce72438578773f95d96deb3d9a5d060802f --- /dev/null +++ b/tests/unit/test_face_index.py @@ -0,0 +1,242 @@ +""" +Tests for FaceIndex — the reverse face search index. + +Tests run without needing ONNX models or the full app — they use +synthetic embeddings to verify storage/retrieval logic. +""" +from __future__ import annotations + +import sys +import os +import numpy as np +import pytest + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from storage.face_index import FaceIndex + + +@pytest.fixture +def index(): + """Fresh in-memory FaceIndex for each test.""" + return FaceIndex(":memory:") + + +def _random_embedding(seed: int) -> np.ndarray: + """Generate a deterministic L2-normalized random embedding.""" + rng = np.random.RandomState(seed) + emb = rng.randn(512).astype(np.float32) + return emb / np.linalg.norm(emb) + + +def _similar_embedding(base: np.ndarray, noise_scale: float = 0.1, seed: int = 0) -> np.ndarray: + """Generate an embedding similar to `base` with small noise.""" + rng = np.random.RandomState(seed) + emb = base + noise_scale * rng.randn(512) + return (emb / np.linalg.norm(emb)).astype(np.float32) + + +# --------------------------------------------------------------------------- # +# Initialization +# --------------------------------------------------------------------------- # + +def test_index_initializes(index): + assert index.count() == 0 + assert index.EMBEDDING_DIM == 512 + + +def test_index_stats_empty(index): + stats = index.stats() + assert stats["total_faces"] == 0 + assert stats["named_faces"] == 0 + assert stats["embedding_dim"] == 512 + + +# --------------------------------------------------------------------------- # +# Enrollment +# --------------------------------------------------------------------------- # + +def test_enroll_returns_face_id(index): + emb = _random_embedding(42) + face_id = index.enroll(emb, name="Alice") + assert isinstance(face_id, str) + assert len(face_id) > 0 + + +def test_enroll_increments_count(index): + emb1 = _random_embedding(1) + emb2 = _random_embedding(2) + index.enroll(emb1, name="Alice") + assert index.count() == 1 + index.enroll(emb2, name="Bob") + assert index.count() == 2 + + +def test_enroll_with_metadata(index): + emb = _random_embedding(42) + face_id = index.enroll( + emb, name="Alice", + source_url="https://example.com/alice.jpg", + metadata={"age": 30, "location": "US"}, + ) + face = index.get(face_id) + assert face is not None + assert face["name"] == "Alice" + assert face["source_url"] == "https://example.com/alice.jpg" + assert face["metadata"]["age"] == 30 + assert face["metadata"]["location"] == "US" + + +def test_enroll_rejects_wrong_shape(index): + bad_emb = np.zeros(256, dtype=np.float32) + with pytest.raises(ValueError, match="shape"): + index.enroll(bad_emb) + + +def test_enroll_l2_normalizes(index): + """Even if the input isn't L2-normalized, the index should handle it.""" + emb = np.ones(512, dtype=np.float32) * 5 # not normalized + face_id = index.enroll(emb, name="Test") + assert face_id # should not raise + + +# --------------------------------------------------------------------------- # +# Search +# --------------------------------------------------------------------------- # + +def test_search_returns_best_match_first(index): + """The most similar face should be returned first.""" + emb_alice = _random_embedding(1) + emb_bob = _random_embedding(2) + index.enroll(emb_alice, name="Alice") + index.enroll(emb_bob, name="Bob") + + # Query is similar to Alice + query = _similar_embedding(emb_alice, noise_scale=0.1) + results = index.search(query, top_k=5, threshold=0.0) + + assert len(results) >= 1 + assert results[0]["name"] == "Alice" + assert results[0]["similarity"] > results[1]["similarity"] if len(results) > 1 else True + + +def test_search_returns_empty_when_index_empty(index): + query = _random_embedding(42) + results = index.search(query, top_k=5, threshold=0.0) + assert results == [] + + +def test_search_threshold_filters_results(index): + """Threshold should filter out low-similarity matches.""" + emb_alice = _random_embedding(1) + emb_bob = _random_embedding(2) + index.enroll(emb_alice, name="Alice") + index.enroll(emb_bob, name="Bob") + + # Query is similar to Alice (sim ~0.95) and dissimilar to Bob (sim ~0.0) + query = _similar_embedding(emb_alice, noise_scale=0.05) + + # Threshold 0.5 — only Alice should match + results = index.search(query, top_k=5, threshold=0.5) + assert all(r["similarity"] >= 0.5 for r in results) + assert any(r["name"] == "Alice" for r in results) + assert not any(r["name"] == "Bob" for r in results) + + +def test_search_top_k_limits_results(index): + for i in range(10): + emb = _random_embedding(i) + index.enroll(emb, name=f"Person_{i}") + + query = _random_embedding(0) + results = index.search(query, top_k=3, threshold=0.0) + assert len(results) <= 3 + + +def test_search_similarity_values_match_numpy(index): + """Cosine similarity returned by sqlite-vec should match numpy's dot product.""" + emb1 = _random_embedding(1) + emb2 = _random_embedding(2) + index.enroll(emb1, name="Alice") + index.enroll(emb2, name="Bob") + + query = _similar_embedding(emb1, noise_scale=0.1) + + # Direct numpy cosine sim + expected_sim_alice = float(np.dot(query, emb1)) + + results = index.search(query, top_k=5, threshold=0.0) + alice_result = next(r for r in results if r["name"] == "Alice") + assert abs(alice_result["similarity"] - expected_sim_alice) < 0.01 + + +# --------------------------------------------------------------------------- # +# CRUD +# --------------------------------------------------------------------------- # + +def test_get_returns_face(index): + emb = _random_embedding(42) + face_id = index.enroll(emb, name="Alice", source_url="https://example.com") + face = index.get(face_id) + assert face is not None + assert face["name"] == "Alice" + assert face["source_url"] == "https://example.com" + + +def test_get_returns_none_for_missing(index): + assert index.get("nonexistent-id") is None + + +def test_list_returns_all(index): + for i in range(5): + index.enroll(_random_embedding(i), name=f"Person_{i}") + faces = index.list(limit=10) + assert len(faces) == 5 + + +def test_list_filter_by_name(index): + index.enroll(_random_embedding(1), name="Alice") + index.enroll(_random_embedding(2), name="Bob") + index.enroll(_random_embedding(3), name="Alice Cooper") + + faces = index.list(name="Alice") + assert len(faces) == 2 # "Alice" and "Alice Cooper" + + +def test_delete_removes_face(index): + face_id = index.enroll(_random_embedding(42), name="Alice") + assert index.count() == 1 + deleted = index.delete(face_id) + assert deleted is True + assert index.count() == 0 + assert index.get(face_id) is None + + +def test_delete_returns_false_for_missing(index): + assert index.delete("nonexistent-id") is False + + +def test_clear_removes_all(index): + for i in range(5): + index.enroll(_random_embedding(i), name=f"Person_{i}") + assert index.count() == 5 + n = index.clear() + assert n == 5 + assert index.count() == 0 + + +# --------------------------------------------------------------------------- # +# Stats +# --------------------------------------------------------------------------- # + +def test_stats_after_enrollment(index): + index.enroll(_random_embedding(1), name="Alice") + index.enroll(_random_embedding(2)) # anonymous + + stats = index.stats() + assert stats["total_faces"] == 2 + assert stats["named_faces"] == 1 + assert stats["anonymous_faces"] == 1 + assert stats["last_enrollment"] is not None + assert stats["recent_enrollments_24h"] == 2 diff --git a/download/face-intel/tests/unit/test_forensic_metadata.py b/tests/unit/test_forensic_metadata.py similarity index 100% rename from download/face-intel/tests/unit/test_forensic_metadata.py rename to tests/unit/test_forensic_metadata.py diff --git a/download/face-intel/tests/unit/test_image_utils.py b/tests/unit/test_image_utils.py similarity index 100% rename from download/face-intel/tests/unit/test_image_utils.py rename to tests/unit/test_image_utils.py diff --git a/download/face-intel/tests/unit/test_location_core.py b/tests/unit/test_location_core.py similarity index 100% rename from download/face-intel/tests/unit/test_location_core.py rename to tests/unit/test_location_core.py diff --git a/download/face-intel/tests/unit/test_metadata_core.py b/tests/unit/test_metadata_core.py similarity index 100% rename from download/face-intel/tests/unit/test_metadata_core.py rename to tests/unit/test_metadata_core.py diff --git a/download/face-intel/tests/unit/test_normalization.py b/tests/unit/test_normalization.py similarity index 100% rename from download/face-intel/tests/unit/test_normalization.py rename to tests/unit/test_normalization.py diff --git a/download/face-intel/tests/unit/test_onnx_core.py b/tests/unit/test_onnx_core.py similarity index 100% rename from download/face-intel/tests/unit/test_onnx_core.py rename to tests/unit/test_onnx_core.py diff --git a/download/face-intel/tests/unit/test_osint_core.py b/tests/unit/test_osint_core.py similarity index 100% rename from download/face-intel/tests/unit/test_osint_core.py rename to tests/unit/test_osint_core.py diff --git a/download/face-intel/tests/unit/test_retry.py b/tests/unit/test_retry.py similarity index 100% rename from download/face-intel/tests/unit/test_retry.py rename to tests/unit/test_retry.py diff --git a/download/face-intel/tests/unit/test_search_core.py b/tests/unit/test_search_core.py similarity index 100% rename from download/face-intel/tests/unit/test_search_core.py rename to tests/unit/test_search_core.py diff --git a/download/face-intel/tests/unit/test_validation.py b/tests/unit/test_validation.py similarity index 100% rename from download/face-intel/tests/unit/test_validation.py rename to tests/unit/test_validation.py diff --git a/download/face-intel/tests/unit/test_vision_core.py b/tests/unit/test_vision_core.py similarity index 100% rename from download/face-intel/tests/unit/test_vision_core.py rename to tests/unit/test_vision_core.py diff --git a/download/face-intel/ui/__init__.py b/ui/__init__.py similarity index 100% rename from download/face-intel/ui/__init__.py rename to ui/__init__.py diff --git a/download/face-intel/ui/static/app.js b/ui/static/app.js similarity index 100% rename from download/face-intel/ui/static/app.js rename to ui/static/app.js diff --git a/download/face-intel/ui/static/index.html b/ui/static/index.html similarity index 100% rename from download/face-intel/ui/static/index.html rename to ui/static/index.html diff --git a/download/face-intel/ui/static/styles.css b/ui/static/styles.css similarity index 100% rename from download/face-intel/ui/static/styles.css rename to ui/static/styles.css diff --git a/download/face-intel/utils/__init__.py b/utils/__init__.py similarity index 100% rename from download/face-intel/utils/__init__.py rename to utils/__init__.py diff --git a/download/face-intel/utils/audit.py b/utils/audit.py similarity index 100% rename from download/face-intel/utils/audit.py rename to utils/audit.py diff --git a/download/face-intel/utils/http.py b/utils/http.py similarity index 100% rename from download/face-intel/utils/http.py rename to utils/http.py diff --git a/download/face-intel/utils/image.py b/utils/image.py similarity index 100% rename from download/face-intel/utils/image.py rename to utils/image.py diff --git a/download/face-intel/utils/logging.py b/utils/logging.py similarity index 100% rename from download/face-intel/utils/logging.py rename to utils/logging.py diff --git a/download/face-intel/utils/timing.py b/utils/timing.py similarity index 100% rename from download/face-intel/utils/timing.py rename to utils/timing.py