Z User commited on
Commit
aac350d
·
1 Parent(s): bbc3fdf

5de94558-bd63-4dac-82f0-38a08c4a5f29

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. download/face-intel/.env.example +54 -0
  2. download/face-intel/README.md +64 -0
  3. download/face-intel/api/__init__.py +6 -0
  4. download/face-intel/api/container.py +171 -0
  5. download/face-intel/api/deps.py +57 -0
  6. download/face-intel/api/main.py +80 -0
  7. download/face-intel/api/middleware.py +56 -0
  8. download/face-intel/api/routes/__init__.py +7 -0
  9. download/face-intel/api/routes/cache.py +23 -0
  10. download/face-intel/api/routes/export.py +20 -0
  11. download/face-intel/api/routes/faces.py +53 -0
  12. download/face-intel/api/routes/health.py +32 -0
  13. download/face-intel/api/routes/jobs.py +45 -0
  14. download/face-intel/api/routes/providers.py +24 -0
  15. download/face-intel/api/routes/search.py +45 -0
  16. download/face-intel/api/routes/stats.py +15 -0
  17. download/face-intel/app.py +28 -0
  18. download/face-intel/confidence/__init__.py +10 -0
  19. download/face-intel/confidence/conflicts.py +75 -0
  20. download/face-intel/confidence/engine.py +183 -0
  21. download/face-intel/confidence/explainer.py +42 -0
  22. download/face-intel/config/__init__.py +5 -1
  23. download/face-intel/config/settings.py +29 -12
  24. download/face-intel/docs/ARCHITECTURE.md +609 -0
  25. download/face-intel/metrics/__init__.py +22 -0
  26. download/face-intel/metrics/collector.py +40 -0
  27. download/face-intel/metrics/counters.py +33 -0
  28. download/face-intel/metrics/health_metrics.py +89 -0
  29. download/face-intel/metrics/provider_metrics.py +86 -0
  30. download/face-intel/metrics/timings.py +46 -0
  31. download/face-intel/models/__init__.py +59 -0
  32. download/face-intel/models/health.py +38 -0
  33. download/face-intel/models/jobs.py +75 -0
  34. download/face-intel/models/providers.py +51 -0
  35. download/face-intel/models/reports.py +79 -0
  36. download/face-intel/models/responses.py +36 -0
  37. download/face-intel/normalization/__init__.py +23 -0
  38. download/face-intel/normalization/merger.py +202 -0
  39. download/face-intel/normalization/schema.py +53 -0
  40. download/face-intel/orchestrator/__init__.py +13 -0
  41. download/face-intel/orchestrator/health.py +37 -0
  42. download/face-intel/orchestrator/retry.py +89 -0
  43. download/face-intel/orchestrator/runner.py +199 -0
  44. download/face-intel/pipeline/__init__.py +36 -0
  45. download/face-intel/pipeline/feature_extraction.py +105 -0
  46. download/face-intel/pipeline/hashing.py +24 -0
  47. download/face-intel/pipeline/postprocessing.py +55 -0
  48. download/face-intel/pipeline/preprocessing.py +69 -0
  49. download/face-intel/pipeline/validation.py +79 -0
  50. download/face-intel/providers/__init__.py +24 -122
download/face-intel/.env.example ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Face Intel — environment configuration
2
+ # Copy to `.env` and adjust as needed.
3
+ # Every variable has the FI_ prefix.
4
+
5
+ # --- Core ---
6
+ FI_ENVIRONMENT=development
7
+ FI_HOST=0.0.0.0
8
+ FI_PORT=8000
9
+ FI_DEBUG=false
10
+ FI_LOG_LEVEL=INFO
11
+ FI_LOG_JSON=false
12
+
13
+ # --- Provider enable flags ---
14
+ FI_ENABLE_HAAR=true
15
+ FI_ENABLE_DNN=true
16
+ FI_ENABLE_MTCNN=true
17
+ FI_ENABLE_RETINAFACE=false
18
+ FI_ENABLE_FACE_RECOGNITION=true
19
+ FI_ENABLE_DEEPFACE=false
20
+ FI_ENABLE_INSIGHTFACE=false
21
+ FI_ENABLE_BEAUTIFULSOUP_SCRAPER=true
22
+ FI_ENABLE_SELENIUM_SCRAPER=true
23
+ FI_ENABLE_BING_SCRAPER=false
24
+ FI_ENABLE_DUCKDUCKGO_SCRAPER=true
25
+ FI_ENABLE_GOOGLE_LENS=true
26
+ FI_ENABLE_SERPAPI=false
27
+ FI_ENABLE_YANDEX=false
28
+ FI_ENABLE_TINEYE=false
29
+
30
+ # --- API keys (only needed if corresponding enable_* is true) ---
31
+ FI_SERPAPI_KEY=
32
+ FI_BING_API_KEY=
33
+ FI_TINEYE_PUBLIC_KEY=
34
+ FI_TINEYE_PRIVATE_KEY=
35
+
36
+ # --- Orchestrator ---
37
+ FI_ORCHESTRATOR_TIMEOUT_SECONDS=90
38
+ FI_ORCHESTRATOR_MAX_CONCURRENCY=8
39
+ FI_RETRY_MAX_ATTEMPTS=3
40
+ FI_RETRY_INITIAL_BACKOFF_SECONDS=0.5
41
+ FI_RETRY_MAX_BACKOFF_SECONDS=8
42
+
43
+ # --- Cache ---
44
+ FI_CACHE_ENABLED=true
45
+ FI_CACHE_TTL_SECONDS=3600
46
+ FI_CACHE_MAX_ENTRIES=1000
47
+
48
+ # --- Health / circuit breaker ---
49
+ FI_HEALTH_CHECK_INTERVAL_SECONDS=60
50
+ FI_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5
51
+ FI_CIRCUIT_BREAKER_RECOVERY_SECONDS=120
52
+
53
+ # --- API ---
54
+ FI_RATE_LIMIT_PER_MINUTE=30
download/face-intel/README.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Face Intel
2
+
3
+ A multi-provider face intelligence platform with evidence-first,
4
+ explainable results.
5
+
6
+ ## Architecture
7
+
8
+ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full
9
+ architecture documentation including dependency graph, package
10
+ responsibilities, execution flow, lifecycle diagrams, and the provider
11
+ extension guide.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # 1. Install dependencies
17
+ pip install -r requirements.txt
18
+
19
+ # 2. Configure
20
+ cp .env.example .env
21
+ # edit .env to enable/disable providers
22
+
23
+ # 3. Run
24
+ python app.py
25
+ # or: uvicorn app:app --reload
26
+
27
+ # 4. Open
28
+ # http://localhost:8000/docs — Swagger UI
29
+ # http://localhost:8000/health — health check
30
+ # http://localhost:8000/providers — provider list
31
+ # http://localhost:8000/stats — metrics
32
+ ```
33
+
34
+ ## Key Endpoints
35
+
36
+ | Method | Path | Description |
37
+ |---|---|---|
38
+ | `POST` | `/faces/detect` | Detect faces in an image |
39
+ | `POST` | `/faces/recognize` | Recognize faces against the gallery |
40
+ | `POST` | `/search/reverse` | Reverse image search |
41
+ | `POST` | `/search/scrape` | Scrape images from a URL |
42
+ | `POST` | `/jobs` | Full-pipeline job |
43
+ | `GET` | `/providers` | List all providers |
44
+ | `GET` | `/stats` | Metrics snapshot |
45
+ | `GET` | `/health/providers` | Per-provider health |
46
+
47
+ ## Adding a New Provider
48
+
49
+ 1. Implement `providers/<category>/<name>.py` (subclass `BaseProvider`).
50
+ 2. Add one entry to `PROVIDER_MANIFEST` in `providers/registry.py`.
51
+ 3. Add an `enable_<name>: bool = False` flag to `config/settings.py`.
52
+
53
+ That's it — the orchestrator, services, API, and UI pick it up
54
+ automatically. See `docs/ARCHITECTURE.md §5` for a complete example.
55
+
56
+ ## Engineering Principles
57
+
58
+ - **Modular** — every provider is isolated; failures don't cascade.
59
+ - **Extensible** — new providers require zero orchestrator/API changes.
60
+ - **Production-ready** — circuit breakers, retries, caching, rate limiting, audit log.
61
+ - **Evidence-first** — raw provider responses preserved verbatim.
62
+ - **Explainable** — confidence scores decomposed into weighted sub-scores.
63
+ - **Clean separation** — 11 layers, strict one-way dependency direction.
64
+ - **DI throughout** — no global stateful objects; everything injectable.
download/face-intel/api/__init__.py CHANGED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """API package — FastAPI app factory + DI wiring."""
2
+
3
+ from api.main import create_app
4
+ from api.container import ServiceContainer
5
+
6
+ __all__ = ["create_app", "ServiceContainer"]
download/face-intel/api/container.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DI container — constructs every dependency ONCE at app startup and
3
+ wires services together. This is the composition root.
4
+
5
+ No globals: the container is created in `create_app()` and stored on
6
+ `app.state.container`. Route handlers retrieve it via the `get_container`
7
+ FastAPI dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from config.settings import Settings, DATA_DIR, GALLERY_DIR, UPLOADS_DIR, JOBS_DIR
15
+ from confidence.engine import ConfidenceEngine
16
+ from confidence.conflicts import ConflictDetector
17
+ from metrics.collector import MetricsCollector
18
+ from normalization.merger import ReportMerger
19
+ from orchestrator.health import HealthMonitor
20
+ from orchestrator.runner import Orchestrator
21
+ from pipeline import (
22
+ InputValidator,
23
+ ImagePreprocessor,
24
+ ImageHasher,
25
+ FeatureExtractor,
26
+ )
27
+ from providers.registry import ProviderRegistry
28
+ from services.cache_service import CacheService
29
+ from services.detection_service import DetectionService
30
+ from services.export_service import ExportService
31
+ from services.health_service import HealthService
32
+ from services.job_service import JobService
33
+ from services.provider_service import ProviderService
34
+ from services.recognition_service import RecognitionService
35
+ from services.search_service import SearchService
36
+ from storage.artifacts import ArtifactStore
37
+ from storage.cache import Cache
38
+ from storage.database import Database
39
+ from storage.reference_store import ReferenceStore
40
+
41
+
42
+ @dataclass
43
+ class ServiceContainer:
44
+ """Holds every wired service + infrastructure object."""
45
+ settings: Settings
46
+ registry: ProviderRegistry
47
+ cache: Cache
48
+ database: Database
49
+ artifacts: ArtifactStore
50
+ reference_store: ReferenceStore
51
+ metrics: MetricsCollector
52
+ health_monitor: HealthMonitor
53
+ orchestrator: Orchestrator
54
+ confidence_engine: ConfidenceEngine
55
+ conflict_detector: ConflictDetector
56
+ validator: InputValidator
57
+ preprocessor: ImagePreprocessor
58
+ hasher: ImageHasher
59
+ feature_extractor: FeatureExtractor
60
+ detection_service: DetectionService
61
+ recognition_service: RecognitionService
62
+ search_service: SearchService
63
+ job_service: JobService
64
+ provider_service: ProviderService
65
+ cache_service: CacheService
66
+ health_service: HealthService
67
+ export_service: ExportService
68
+
69
+
70
+ def build_container(settings: Settings | None = None) -> ServiceContainer:
71
+ """Construct the entire dependency graph. Called once at startup."""
72
+ settings = settings or Settings()
73
+
74
+ # Infrastructure
75
+ registry = ProviderRegistry(settings)
76
+ registry.discover()
77
+
78
+ cache = Cache(
79
+ ttl_seconds=settings.cache_ttl_seconds,
80
+ max_entries=settings.cache_max_entries,
81
+ )
82
+ database = Database(path=settings.db_path)
83
+ artifacts = ArtifactStore(root=UPLOADS_DIR)
84
+ reference_store = ReferenceStore(root=GALLERY_DIR)
85
+
86
+ metrics = MetricsCollector(
87
+ failure_threshold=settings.circuit_breaker_failure_threshold,
88
+ recovery_seconds=settings.circuit_breaker_recovery_seconds,
89
+ )
90
+ health_monitor = HealthMonitor(metrics=metrics.health)
91
+
92
+ orchestrator = Orchestrator(
93
+ registry=registry,
94
+ cache=cache,
95
+ metrics=metrics,
96
+ health=health_monitor,
97
+ settings=settings,
98
+ )
99
+
100
+ confidence_engine = ConfidenceEngine()
101
+ conflict_detector = ConflictDetector()
102
+
103
+ # Pipeline
104
+ validator = InputValidator()
105
+ preprocessor = ImagePreprocessor(max_dim=1024)
106
+ hasher = ImageHasher()
107
+ # Use the first available detection provider as the default feature extractor
108
+ from models.providers import ProviderCapability
109
+ detectors = registry.list_by_capability(ProviderCapability.DETECTION)
110
+ default_detector = detectors[0] if detectors else None
111
+ feature_extractor = FeatureExtractor(detector=default_detector)
112
+
113
+ # Services
114
+ detection_service = DetectionService(
115
+ registry=registry, orchestrator=orchestrator, cache=cache, metrics=metrics,
116
+ validator=validator, preprocessor=preprocessor, hasher=hasher,
117
+ feature_extractor=feature_extractor,
118
+ confidence_engine=confidence_engine, conflict_detector=conflict_detector,
119
+ )
120
+ recognition_service = RecognitionService(
121
+ orchestrator=orchestrator, metrics=metrics,
122
+ validator=validator, preprocessor=preprocessor, hasher=hasher,
123
+ feature_extractor=feature_extractor, reference_store=reference_store,
124
+ confidence_engine=confidence_engine, conflict_detector=conflict_detector,
125
+ )
126
+ search_service = SearchService(
127
+ orchestrator=orchestrator, metrics=metrics,
128
+ validator=validator, preprocessor=preprocessor, hasher=hasher,
129
+ feature_extractor=feature_extractor,
130
+ confidence_engine=confidence_engine, conflict_detector=conflict_detector,
131
+ )
132
+ job_service = JobService(
133
+ detection=detection_service,
134
+ recognition=recognition_service,
135
+ search=search_service,
136
+ database=database,
137
+ metrics=metrics,
138
+ )
139
+ provider_service = ProviderService(registry=registry)
140
+ cache_service = CacheService(cache=cache)
141
+ health_service = HealthService(
142
+ registry=registry, health_monitor=health_monitor, metrics=metrics,
143
+ version=settings.app_version,
144
+ )
145
+ export_service = ExportService(database=database)
146
+
147
+ return ServiceContainer(
148
+ settings=settings,
149
+ registry=registry,
150
+ cache=cache,
151
+ database=database,
152
+ artifacts=artifacts,
153
+ reference_store=reference_store,
154
+ metrics=metrics,
155
+ health_monitor=health_monitor,
156
+ orchestrator=orchestrator,
157
+ confidence_engine=confidence_engine,
158
+ conflict_detector=conflict_detector,
159
+ validator=validator,
160
+ preprocessor=preprocessor,
161
+ hasher=hasher,
162
+ feature_extractor=feature_extractor,
163
+ detection_service=detection_service,
164
+ recognition_service=recognition_service,
165
+ search_service=search_service,
166
+ job_service=job_service,
167
+ provider_service=provider_service,
168
+ cache_service=cache_service,
169
+ health_service=health_service,
170
+ export_service=export_service,
171
+ )
download/face-intel/api/deps.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependency injection helpers.
3
+
4
+ Routes use these to pull services off the request-scoped container.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from fastapi import Request
10
+
11
+ from api.container import ServiceContainer
12
+
13
+
14
+ def get_container(request: Request) -> ServiceContainer:
15
+ """Returns the app-wide ServiceContainer."""
16
+ return request.app.state.container
17
+
18
+
19
+ # Per-service dependency callables (use as `Depends(get_detection_service)`)
20
+ def get_settings(request: Request):
21
+ return get_container(request).settings
22
+
23
+
24
+ def get_detection_service(request: Request):
25
+ return get_container(request).detection_service
26
+
27
+
28
+ def get_recognition_service(request: Request):
29
+ return get_container(request).recognition_service
30
+
31
+
32
+ def get_search_service(request: Request):
33
+ return get_container(request).search_service
34
+
35
+
36
+ def get_job_service(request: Request):
37
+ return get_container(request).job_service
38
+
39
+
40
+ def get_provider_service(request: Request):
41
+ return get_container(request).provider_service
42
+
43
+
44
+ def get_cache_service(request: Request):
45
+ return get_container(request).cache_service
46
+
47
+
48
+ def get_health_service(request: Request):
49
+ return get_container(request).health_service
50
+
51
+
52
+ def get_export_service(request: Request):
53
+ return get_container(request).export_service
54
+
55
+
56
+ def get_metrics(request: Request):
57
+ return get_container(request).metrics
download/face-intel/api/main.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI app factory.
3
+
4
+ Constructs the ServiceContainer (composition root), mounts middleware,
5
+ registers routes, and serves the UI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+
13
+ from fastapi import FastAPI
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.staticfiles import StaticFiles
16
+ from loguru import logger
17
+
18
+ from api.container import build_container
19
+ from api.middleware import RequestContextMiddleware, RateLimitMiddleware
20
+ from api.routes import (
21
+ health, stats, providers, cache, jobs, faces, search, export,
22
+ )
23
+ from config.settings import Settings, UI_DIR
24
+ from utils.logging import setup_logging
25
+
26
+
27
+ def create_app(settings: Settings | None = None) -> FastAPI:
28
+ """Application factory — composes the entire dependency graph."""
29
+ settings = settings or Settings()
30
+ setup_logging(settings)
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(app: FastAPI):
34
+ logger.info(f"Starting {settings.app_name} v{settings.app_version}")
35
+ container = build_container(settings)
36
+ app.state.container = container
37
+ app.state.settings = settings
38
+ logger.info(f"Registered providers: {container.registry.list_names()}")
39
+ yield
40
+ logger.info("Shutting down")
41
+ container.database.close()
42
+
43
+ app = FastAPI(
44
+ title=settings.app_name,
45
+ version=settings.app_version,
46
+ description="Multi-provider face intelligence platform",
47
+ lifespan=lifespan,
48
+ )
49
+
50
+ # Middleware
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=settings.cors_origins,
54
+ allow_credentials=True,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+ app.add_middleware(RequestContextMiddleware)
59
+ app.add_middleware(RateLimitMiddleware, requests_per_minute=settings.rate_limit_per_minute)
60
+
61
+ # Routers
62
+ app.include_router(health.router, prefix="/health", tags=["health"])
63
+ app.include_router(stats.router, prefix="/stats", tags=["stats"])
64
+ app.include_router(providers.router, prefix="/providers", tags=["providers"])
65
+ app.include_router(cache.router, prefix="/cache", tags=["cache"])
66
+ app.include_router(jobs.router, prefix="/jobs", tags=["jobs"])
67
+ app.include_router(faces.router, prefix="/faces", tags=["faces"])
68
+ app.include_router(search.router, prefix="/search", tags=["search"])
69
+ app.include_router(export.router, prefix="/export", tags=["export"])
70
+
71
+ # UI
72
+ if UI_DIR.exists():
73
+ app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
74
+ else:
75
+ @app.get("/")
76
+ async def root():
77
+ return {"app": settings.app_name, "version": settings.app_version,
78
+ "docs": "/docs"}
79
+
80
+ return app
download/face-intel/api/middleware.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API middleware — CORS, request-id, simple rate limiting.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from collections import defaultdict
9
+
10
+ from fastapi import Request, Response
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+
13
+
14
+ class RequestContextMiddleware(BaseHTTPMiddleware):
15
+ """Adds a request-id to every request + measures duration."""
16
+
17
+ async def dispatch(self, request: Request, call_next):
18
+ import uuid
19
+ request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:12]
20
+ request.state.request_id = request_id
21
+ t0 = time.perf_counter()
22
+ response: Response = await call_next(request)
23
+ elapsed_ms = (time.perf_counter() - t0) * 1000.0
24
+ response.headers["X-Request-ID"] = request_id
25
+ response.headers["X-Response-Time-ms"] = f"{elapsed_ms:.2f}"
26
+ return response
27
+
28
+
29
+ class RateLimitMiddleware(BaseHTTPMiddleware):
30
+ """Simple in-memory per-IP rate limiter.
31
+
32
+ For production, replace with a Redis-backed limiter.
33
+ """
34
+
35
+ def __init__(self, app, requests_per_minute: int = 30):
36
+ super().__init__(app)
37
+ self._limit = requests_per_minute
38
+ self._buckets: dict[str, list[float]] = defaultdict(list)
39
+
40
+ async def dispatch(self, request: Request, call_next):
41
+ # Skip health checks
42
+ if request.url.path.startswith("/health"):
43
+ return await call_next(request)
44
+ client = request.client.host if request.client else "unknown"
45
+ now = time.time()
46
+ window = 60.0
47
+ recent = [t for t in self._buckets[client] if now - t < window]
48
+ if len(recent) >= self._limit:
49
+ return Response(
50
+ content='{"error":"rate limit exceeded"}',
51
+ status_code=429,
52
+ media_type="application/json",
53
+ )
54
+ recent.append(now)
55
+ self._buckets[client] = recent
56
+ return await call_next(request)
download/face-intel/api/routes/__init__.py CHANGED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Routes package — one router per resource."""
2
+
3
+ from api.routes import (
4
+ health, stats, providers, cache, jobs, faces, search, export,
5
+ )
6
+
7
+ __all__ = ["health", "stats", "providers", "cache", "jobs", "faces", "search", "export"]
download/face-intel/api/routes/cache.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache routes — inspection + invalidation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from api.deps import get_cache_service
8
+ from services.cache_service import CacheService
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("")
14
+ @router.get("/")
15
+ async def cache_stats(svc: CacheService = Depends(get_cache_service)):
16
+ return svc.stats()
17
+
18
+
19
+ @router.delete("")
20
+ @router.delete("/")
21
+ async def cache_clear(svc: CacheService = Depends(get_cache_service)):
22
+ n = svc.clear()
23
+ return {"cleared": n}
download/face-intel/api/routes/export.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export routes — download job results as JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import PlainTextResponse
7
+
8
+ from api.deps import get_export_service
9
+ from services.export_service import ExportService
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/{job_id}")
15
+ async def export_job(job_id: str, svc: ExportService = Depends(get_export_service)):
16
+ data = svc.export_job_json_str(job_id)
17
+ if data is None:
18
+ raise HTTPException(status_code=404, detail="Job not found")
19
+ return PlainTextResponse(content=data, media_type="application/json",
20
+ headers={"Content-Disposition": f"attachment; filename={job_id}.json"})
download/face-intel/api/routes/faces.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Face routes — detect / recognize endpoints (convenience wrappers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from pydantic import BaseModel
9
+
10
+ from api.deps import get_detection_service, get_recognition_service
11
+ from models.jobs import JobKind, JobRequest
12
+ from services.detection_service import DetectionService
13
+ from services.recognition_service import RecognitionService
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ class FaceRequest(BaseModel):
19
+ image_url: Optional[str] = None
20
+ image_base64: Optional[str] = None
21
+ providers: List[str] = []
22
+
23
+
24
+ @router.post("/detect")
25
+ async def detect_faces(req: FaceRequest, svc: DetectionService = Depends(get_detection_service)):
26
+ job_req = JobRequest(
27
+ kind=JobKind.DETECTION,
28
+ image_url=req.image_url,
29
+ image_base64=req.image_base64,
30
+ providers=req.providers,
31
+ )
32
+ return await svc.detect(job_req)
33
+
34
+
35
+ @router.post("/recognize")
36
+ async def recognize_faces(req: FaceRequest, svc: RecognitionService = Depends(get_recognition_service)):
37
+ job_req = JobRequest(
38
+ kind=JobKind.RECOGNITION,
39
+ image_url=req.image_url,
40
+ image_base64=req.image_base64,
41
+ providers=req.providers,
42
+ )
43
+ return await svc.recognize(job_req)
44
+
45
+
46
+ @router.get("/gallery")
47
+ async def list_gallery(svc: RecognitionService = Depends(get_recognition_service)):
48
+ return {"persons": svc.list_known_persons()}
49
+
50
+
51
+ @router.delete("/gallery/{name}")
52
+ async def remove_from_gallery(name: str, svc: RecognitionService = Depends(get_recognition_service)):
53
+ return svc.remove_known_person(name)
download/face-intel/api/routes/health.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from api.deps import get_health_service
8
+ from services.health_service import HealthService
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("")
14
+ @router.get("/")
15
+ async def health_root():
16
+ return {"status": "ok"}
17
+
18
+
19
+ @router.get("/providers")
20
+ async def health_providers(health: HealthService = Depends(get_health_service)):
21
+ return health.snapshot().model_dump()
22
+
23
+
24
+ @router.get("/live")
25
+ async def liveness():
26
+ return {"status": "alive"}
27
+
28
+
29
+ @router.get("/ready")
30
+ async def readiness():
31
+ """Minimal readiness check. Real checks would verify DB + cache."""
32
+ return {"status": "ready"}
download/face-intel/api/routes/jobs.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job routes — create / inspect / list jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
+
9
+ from api.deps import get_job_service
10
+ from models.jobs import JobKind, JobRequest
11
+ from services.job_service import JobService
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ @router.post("")
17
+ @router.post("/")
18
+ async def create_job(request: JobRequest, svc: JobService = Depends(get_job_service)):
19
+ return await svc.create_and_run(request)
20
+
21
+
22
+ @router.get("")
23
+ @router.get("/")
24
+ async def list_jobs(
25
+ limit: int = Query(50, ge=1, le=500),
26
+ status: Optional[str] = None,
27
+ svc: JobService = Depends(get_job_service),
28
+ ):
29
+ return {"jobs": svc.list_jobs(limit=limit, status=status)}
30
+
31
+
32
+ @router.get("/{job_id}")
33
+ async def get_job(job_id: str, svc: JobService = Depends(get_job_service)):
34
+ job = svc.get_job(job_id)
35
+ if not job:
36
+ raise HTTPException(status_code=404, detail="Job not found")
37
+ return job
38
+
39
+
40
+ @router.get("/{job_id}/result")
41
+ async def get_job_result(job_id: str, svc: JobService = Depends(get_job_service)):
42
+ result = svc.get_result(job_id)
43
+ if not result:
44
+ raise HTTPException(status_code=404, detail="Result not found")
45
+ return result
download/face-intel/api/routes/providers.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider routes — list/get provider info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+
7
+ from api.deps import get_provider_service
8
+ from services.provider_service import ProviderService
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("")
14
+ @router.get("/")
15
+ async def list_providers(svc: ProviderService = Depends(get_provider_service)):
16
+ return {"providers": svc.list_providers(), "errors": svc.manifest_errors()}
17
+
18
+
19
+ @router.get("/{name}")
20
+ async def get_provider(name: str, svc: ProviderService = Depends(get_provider_service)):
21
+ info = svc.get_provider(name)
22
+ if not info:
23
+ raise HTTPException(status_code=404, detail=f"Provider '{name}' not found")
24
+ return info
download/face-intel/api/routes/search.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search routes — scrape + reverse-image-search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from pydantic import BaseModel
9
+
10
+ from api.deps import get_search_service
11
+ from models.jobs import JobKind, JobRequest
12
+ from services.search_service import SearchService
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ class SearchRequest(BaseModel):
18
+ image_url: Optional[str] = None
19
+ image_base64: Optional[str] = None
20
+ providers: List[str] = []
21
+ scrape_url: Optional[str] = None
22
+
23
+
24
+ @router.post("/reverse")
25
+ async def reverse_search(req: SearchRequest, svc: SearchService = Depends(get_search_service)):
26
+ job_req = JobRequest(
27
+ kind=JobKind.SEARCH,
28
+ image_url=req.image_url,
29
+ image_base64=req.image_base64,
30
+ providers=req.providers,
31
+ options={"scrape_url": req.scrape_url} if req.scrape_url else {},
32
+ )
33
+ return await svc.search(job_req)
34
+
35
+
36
+ @router.post("/scrape")
37
+ async def scrape(req: SearchRequest, svc: SearchService = Depends(get_search_service)):
38
+ job_req = JobRequest(
39
+ kind=JobKind.SEARCH,
40
+ image_url=req.image_url,
41
+ image_base64=req.image_base64,
42
+ providers=req.providers,
43
+ options={"scrape_url": req.scrape_url} if req.scrape_url else {},
44
+ )
45
+ return await svc.search(job_req)
download/face-intel/api/routes/stats.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stats routes — exposes metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from api.deps import get_metrics
8
+
9
+ router = APIRouter()
10
+
11
+
12
+ @router.get("")
13
+ @router.get("/")
14
+ async def stats_root(metrics=Depends(get_metrics)):
15
+ return metrics.snapshot()
download/face-intel/app.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Application entry point.
3
+
4
+ Run with:
5
+ uvicorn app:app --host 0.0.0.0 --port 8000 --reload
6
+
7
+ or directly:
8
+ python app.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uvicorn
14
+
15
+ from api.main import create_app
16
+ from config.settings import settings
17
+
18
+ app = create_app(settings)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ uvicorn.run(
23
+ "app:app",
24
+ host=settings.host,
25
+ port=settings.port,
26
+ reload=settings.debug,
27
+ log_level=settings.log_level.lower(),
28
+ )
download/face-intel/confidence/__init__.py CHANGED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Confidence package — explainable scoring + cross-provider conflict
3
+ detection.
4
+ """
5
+
6
+ from confidence.engine import ConfidenceEngine
7
+ from confidence.explainer import Explainer
8
+ from confidence.conflicts import ConflictDetector
9
+
10
+ __all__ = ["ConfidenceEngine", "Explainer", "ConflictDetector"]
download/face-intel/confidence/conflicts.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Conflict detector — finds cross-provider disagreements.
3
+
4
+ Currently detects:
5
+ - face_count_mismatch (detectors disagree on number of faces)
6
+ - match_disagreement (recognizers disagree on best match for same face)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List, Protocol
12
+
13
+ from models.reports import ConflictReport
14
+ from providers.base import ProviderResult
15
+
16
+
17
+ # Duck-typed protocols (confidence must not import from normalization)
18
+ class _BoxLike(Protocol):
19
+ detector: str
20
+ confidence: float
21
+
22
+ class _MatchLike(Protocol):
23
+ query_face_index: int
24
+ best_match: str | None
25
+ recognizer: str
26
+
27
+
28
+ class ConflictDetector:
29
+ """Detects cross-provider disagreements."""
30
+
31
+ def detect(
32
+ self,
33
+ results: Dict[str, ProviderResult],
34
+ boxes: List[_BoxLike],
35
+ matches: List[_MatchLike],
36
+ ) -> List[ConflictReport]:
37
+ conflicts: List[ConflictReport] = []
38
+
39
+ # Face count mismatch
40
+ detector_counts: Dict[str, int] = {}
41
+ for r in results.values():
42
+ if r.success and r.capability.value == "detection":
43
+ detector_counts[r.provider] = r.normalized.get("num_faces", 0)
44
+ if detector_counts:
45
+ counts = list(detector_counts.values())
46
+ if max(counts) != min(counts):
47
+ conflicts.append(ConflictReport(
48
+ kind="face_count_mismatch",
49
+ providers=list(detector_counts.keys()),
50
+ description=(
51
+ f"Detectors disagree on face count: {detector_counts}"
52
+ ),
53
+ severity="warning",
54
+ ))
55
+
56
+ # Match disagreement (same face index, different best match)
57
+ by_face: Dict[int, List[NormalizedMatch]] = {}
58
+ for m in matches:
59
+ by_face.setdefault(m.query_face_index, []).append(m)
60
+ for face_idx, face_matches in by_face.items():
61
+ if len(face_matches) < 2:
62
+ continue
63
+ best_matches = {m.best_match for m in face_matches if m.best_match}
64
+ if len(best_matches) > 1:
65
+ conflicts.append(ConflictReport(
66
+ kind="match_disagreement",
67
+ providers=[m.recognizer for m in face_matches],
68
+ description=(
69
+ f"Recognizers disagree on best match for face #{face_idx}: "
70
+ f"{[(m.recognizer, m.best_match) for m in face_matches]}"
71
+ ),
72
+ severity="warning",
73
+ ))
74
+
75
+ return conflicts
download/face-intel/confidence/engine.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Confidence engine — decomposes every score into weighted sub-scores
3
+ with a human-readable explanation.
4
+
5
+ Sub-scores:
6
+ - source_reliability (provider's track record)
7
+ - cross_provider_consensus (how many providers agree)
8
+ - detection_confidence (raw provider confidence)
9
+ - latency_penalty (slower = lower confidence)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, List, Protocol
15
+
16
+ from models.reports import (
17
+ ConfidenceScore,
18
+ ConflictReport,
19
+ FaceDetection,
20
+ FaceMatch,
21
+ )
22
+
23
+ # NOTE: confidence/ must NOT import from normalization/ (dependency direction
24
+ # is normalization -> confidence). We accept duck-typed objects that expose
25
+ # the attributes we need (.detector, .confidence, .query_face_index,
26
+ # .best_match, .distance, .recognizer) instead of importing the DTOs.
27
+
28
+
29
+ class _BoxLike(Protocol):
30
+ detector: str
31
+ confidence: float
32
+
33
+
34
+ class _MatchLike(Protocol):
35
+ query_face_index: int
36
+ best_match: str | None
37
+ distance: float
38
+ recognizer: str
39
+
40
+
41
+ WEIGHTS = {
42
+ "source_reliability": 0.25,
43
+ "cross_provider_consensus": 0.30,
44
+ "detection_confidence": 0.35,
45
+ "latency_penalty": 0.10,
46
+ }
47
+
48
+ # Default reliability per provider (can be overridden by metrics history)
49
+ DEFAULT_RELIABILITY = {
50
+ "haar": 0.65,
51
+ "dnn": 0.85,
52
+ "mtcnn": 0.92,
53
+ "retinaface": 0.95,
54
+ "face_recognition": 0.90,
55
+ "deepface": 0.88,
56
+ "insightface": 0.93,
57
+ "beautifulsoup": 0.75,
58
+ "selenium": 0.70,
59
+ "bing": 0.85,
60
+ "duckduckgo": 0.65,
61
+ "google_lens": 0.55, # brittle
62
+ "serpapi": 0.90,
63
+ "yandex": 0.55,
64
+ "tineye": 0.88,
65
+ }
66
+
67
+
68
+ class ConfidenceEngine:
69
+ """Computes weighted confidence scores."""
70
+
71
+ def __init__(self, reliability_overrides: dict | None = None) -> None:
72
+ self._reliability = {**DEFAULT_RELIABILITY, **(reliability_overrides or {})}
73
+
74
+ # ------------------------------------------------------------------ #
75
+ # Detection confidence
76
+ # ------------------------------------------------------------------ #
77
+ def score_detection(
78
+ self,
79
+ nbox: _BoxLike,
80
+ all_results: dict[str, Any],
81
+ ) -> ConfidenceScore:
82
+ provider = nbox.detector
83
+ source_rel = self._reliability.get(provider, 0.7)
84
+
85
+ # Cross-provider consensus: how many detectors found a similar box?
86
+ num_detectors = sum(
87
+ 1 for r in all_results.values()
88
+ if r.success and r.capability.value == "detection"
89
+ )
90
+ consensus = min(1.0, num_detectors / 3.0) # 3 detectors = full consensus
91
+
92
+ det_conf = nbox.confidence
93
+
94
+ # Latency penalty: <100ms = 1.0, >2000ms = 0.5
95
+ provider_result = all_results.get(provider)
96
+ latency = provider_result.elapsed_ms if provider_result else 500.0
97
+ latency_score = max(0.5, 1.0 - (latency / 4000.0))
98
+
99
+ components = {
100
+ "source_reliability": round(source_rel, 4),
101
+ "cross_provider_consensus": round(consensus, 4),
102
+ "detection_confidence": round(det_conf, 4),
103
+ "latency_penalty": round(latency_score, 4),
104
+ }
105
+ overall = sum(
106
+ components[k] * WEIGHTS[k] for k in WEIGHTS
107
+ )
108
+ explanation = (
109
+ f"{provider} detected a face with raw confidence {det_conf:.2f}. "
110
+ f"{num_detectors} detector(s) ran in this job "
111
+ f"(consensus={consensus:.2f}). "
112
+ f"Provider reliability={source_rel:.2f}, "
113
+ f"latency={latency:.0f}ms (score={latency_score:.2f})."
114
+ )
115
+ return ConfidenceScore(
116
+ overall=round(overall, 4),
117
+ components=components,
118
+ explanation=explanation,
119
+ method="weighted_average",
120
+ )
121
+
122
+ # ------------------------------------------------------------------ #
123
+ # Recognition match confidence
124
+ # ------------------------------------------------------------------ #
125
+ def score_match(self, nm: _MatchLike) -> ConfidenceScore:
126
+ provider = nm.recognizer
127
+ source_rel = self._reliability.get(provider, 0.7)
128
+ # Distance → confidence: distance 0 = 1.0, distance 1.0 = 0.0
129
+ match_conf = max(0.0, 1.0 - nm.distance)
130
+ components = {
131
+ "source_reliability": round(source_rel, 4),
132
+ "cross_provider_consensus": 0.5, # placeholder; enriched when multiple recognizers ran
133
+ "detection_confidence": round(match_conf, 4),
134
+ "latency_penalty": 0.9,
135
+ }
136
+ overall = sum(components[k] * WEIGHTS[k] for k in WEIGHTS)
137
+ explanation = (
138
+ f"{provider} matched face #{nm.query_face_index} "
139
+ f"to '{nm.best_match}' with distance {nm.distance:.3f} "
140
+ f"(match confidence={match_conf:.2f})."
141
+ )
142
+ return ConfidenceScore(
143
+ overall=round(overall, 4),
144
+ components=components,
145
+ explanation=explanation,
146
+ method="weighted_average",
147
+ )
148
+
149
+ # ------------------------------------------------------------------ #
150
+ # Overall report confidence
151
+ # ------------------------------------------------------------------ #
152
+ def score_overall(
153
+ self,
154
+ report_detections: List[FaceDetection],
155
+ matches: List[FaceMatch],
156
+ conflicts: List[ConflictReport],
157
+ ) -> ConfidenceScore:
158
+ if not report_detections:
159
+ return ConfidenceScore(
160
+ overall=0.0,
161
+ components={},
162
+ explanation="No faces detected.",
163
+ )
164
+ detection_avg = sum(d.confidence.overall for d in report_detections) / len(report_detections)
165
+ match_avg = (
166
+ sum(m.confidence.overall for m in matches) / len(matches)
167
+ if matches else 0.0
168
+ )
169
+ conflict_penalty = max(0.0, 1.0 - (0.15 * len(conflicts)))
170
+ overall = detection_avg * 0.5 + match_avg * 0.3 + conflict_penalty * 0.2
171
+ return ConfidenceScore(
172
+ overall=round(overall, 4),
173
+ components={
174
+ "detection_avg": round(detection_avg, 4),
175
+ "match_avg": round(match_avg, 4),
176
+ "conflict_penalty": round(conflict_penalty, 4),
177
+ },
178
+ explanation=(
179
+ f"Report confidence from {len(report_detections)} detection(s) "
180
+ f"and {len(matches)} match(es) with {len(conflicts)} conflict(s)."
181
+ ),
182
+ method="weighted_average",
183
+ )
download/face-intel/confidence/explainer.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Explainer — turns a ConfidenceScore into a multi-line human-readable
3
+ string for display in the UI.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from models.reports import ConfidenceScore, UnifiedFaceReport
9
+
10
+
11
+ class Explainer:
12
+ """Renders confidence + report as human-readable text."""
13
+
14
+ @staticmethod
15
+ def explain_score(score: ConfidenceScore) -> str:
16
+ lines = [f"Overall confidence: {score.overall:.2%} ({score.method})"]
17
+ if score.explanation:
18
+ lines.append(f" {score.explanation}")
19
+ if score.components:
20
+ lines.append(" Components:")
21
+ for k, v in score.components.items():
22
+ lines.append(f" - {k}: {v:.3f}")
23
+ return "\n".join(lines)
24
+
25
+ @staticmethod
26
+ def explain_report(report: UnifiedFaceReport) -> str:
27
+ m = report.metadata
28
+ lines = [
29
+ f"Job {m.job_id} — {m.total_elapsed_ms:.0f}ms total",
30
+ f" Providers invoked: {', '.join(m.providers_invoked) or 'none'}",
31
+ f" Succeeded: {', '.join(m.providers_succeeded) or 'none'}",
32
+ f" Failed: {', '.join(m.providers_failed) or 'none'}",
33
+ f" Detections: {len(report.detections)}",
34
+ f" Matches: {len(report.matches)}",
35
+ f" Scraped images: {len(report.scraped_images)}",
36
+ f" Reverse matches: {len(report.reverse_matches)}",
37
+ f" Conflicts: {len(report.conflicts)}",
38
+ ]
39
+ if report.overall_confidence:
40
+ lines.append("")
41
+ lines.append(Explainer.explain_score(report.overall_confidence))
42
+ return "\n".join(lines)
download/face-intel/config/__init__.py CHANGED
@@ -1,7 +1,9 @@
1
- """Re-export the singleton settings for convenient imports."""
2
 
3
  from config.settings import (
 
4
  settings,
 
5
  BASE_DIR,
6
  DATA_DIR,
7
  MODELS_DIR,
@@ -12,7 +14,9 @@ from config.settings import (
12
  )
13
 
14
  __all__ = [
 
15
  "settings",
 
16
  "BASE_DIR",
17
  "DATA_DIR",
18
  "MODELS_DIR",
 
1
+ """Config package re-exports settings + path constants."""
2
 
3
  from config.settings import (
4
+ Settings,
5
  settings,
6
+ make_settings,
7
  BASE_DIR,
8
  DATA_DIR,
9
  MODELS_DIR,
 
14
  )
15
 
16
  __all__ = [
17
+ "Settings",
18
  "settings",
19
+ "make_settings",
20
  "BASE_DIR",
21
  "DATA_DIR",
22
  "MODELS_DIR",
download/face-intel/config/settings.py CHANGED
@@ -2,8 +2,13 @@
2
  Centralized configuration for Face Intel.
3
 
4
  All settings are environment-driven via pydantic-settings so the same
5
- code runs in dev, test, and production without code changes. See
6
- `.env.example` for every supported variable.
 
 
 
 
 
7
  """
8
 
9
  from __future__ import annotations
@@ -43,7 +48,7 @@ class Settings(BaseSettings):
43
  # ------------------------------------------------------------------ #
44
  app_name: str = "Face Intel"
45
  app_version: str = "1.0.0"
46
- environment: str = "development" # development | staging | production
47
  host: str = "0.0.0.0"
48
  port: int = 8000
49
  debug: bool = False
@@ -54,7 +59,7 @@ class Settings(BaseSettings):
54
  enable_haar: bool = True
55
  enable_dnn: bool = True
56
  enable_mtcnn: bool = True
57
- enable_retinaface: bool = False # requires insightface
58
  enable_face_recognition: bool = True
59
  enable_deepface: bool = False
60
  enable_insightface: bool = False
@@ -79,10 +84,10 @@ class Settings(BaseSettings):
79
  # Recognition tuning
80
  # ------------------------------------------------------------------ #
81
  face_recognition_tolerance: float = 0.6
82
- face_recognition_model: str = "hog" # hog | cnn
83
  deepface_backend: str = "arcface"
84
  insightface_model_pack: str = "buffalo_l"
85
- recognition_match_threshold: float = 0.5 # cosine similarity cutoff
86
 
87
  # ------------------------------------------------------------------ #
88
  # Scraping
@@ -114,9 +119,6 @@ class Settings(BaseSettings):
114
  retry_max_attempts: int = 3
115
  retry_initial_backoff_seconds: float = 0.5
116
  retry_max_backoff_seconds: float = 8.0
117
- retry_retriable_exceptions: List[str] = Field(
118
- default_factory=lambda: ["TimeoutError", "ConnectionError"]
119
- )
120
 
121
  # ------------------------------------------------------------------ #
122
  # Cache
@@ -143,9 +145,7 @@ class Settings(BaseSettings):
143
  # API
144
  # ------------------------------------------------------------------ #
145
  rate_limit_per_minute: int = 30
146
- cors_origins: List[str] = Field(
147
- default_factory=lambda: ["*"]
148
- )
149
  require_consent_header: bool = True
150
  consent_header_name: str = "X-Consent-Statement"
151
 
@@ -156,4 +156,21 @@ class Settings(BaseSettings):
156
  log_json: bool = False
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  settings = Settings()
 
2
  Centralized configuration for Face Intel.
3
 
4
  All settings are environment-driven via pydantic-settings so the same
5
+ code runs in dev, test, and production without code changes.
6
+
7
+ After the refactor, `settings` is still importable for backward
8
+ compatibility with provider modules that read tuning knobs, BUT every
9
+ stateful service (storage, cache, orchestrator, services) receives its
10
+ dependencies through constructor injection — never by importing this
11
+ module directly.
12
  """
13
 
14
  from __future__ import annotations
 
48
  # ------------------------------------------------------------------ #
49
  app_name: str = "Face Intel"
50
  app_version: str = "1.0.0"
51
+ environment: str = "development"
52
  host: str = "0.0.0.0"
53
  port: int = 8000
54
  debug: bool = False
 
59
  enable_haar: bool = True
60
  enable_dnn: bool = True
61
  enable_mtcnn: bool = True
62
+ enable_retinaface: bool = False
63
  enable_face_recognition: bool = True
64
  enable_deepface: bool = False
65
  enable_insightface: bool = False
 
84
  # Recognition tuning
85
  # ------------------------------------------------------------------ #
86
  face_recognition_tolerance: float = 0.6
87
+ face_recognition_model: str = "hog"
88
  deepface_backend: str = "arcface"
89
  insightface_model_pack: str = "buffalo_l"
90
+ recognition_match_threshold: float = 0.5
91
 
92
  # ------------------------------------------------------------------ #
93
  # Scraping
 
119
  retry_max_attempts: int = 3
120
  retry_initial_backoff_seconds: float = 0.5
121
  retry_max_backoff_seconds: float = 8.0
 
 
 
122
 
123
  # ------------------------------------------------------------------ #
124
  # Cache
 
145
  # API
146
  # ------------------------------------------------------------------ #
147
  rate_limit_per_minute: int = 30
148
+ cors_origins: List[str] = Field(default_factory=lambda: ["*"])
 
 
149
  require_consent_header: bool = True
150
  consent_header_name: str = "X-Consent-Statement"
151
 
 
156
  log_json: bool = False
157
 
158
 
159
+ def make_settings(**overrides) -> Settings:
160
+ """Factory used by the DI container to build a Settings instance.
161
+
162
+ Tests can override individual fields without touching env vars:
163
+ make_settings(environment="test", cache_enabled=False)
164
+ """
165
+ if overrides:
166
+ # Build a fresh instance with overrides merged
167
+ base = Settings()
168
+ data = base.model_dump()
169
+ data.update(overrides)
170
+ return Settings(**data)
171
+ return Settings()
172
+
173
+
174
+ # Default singleton — fine for read-only config consumption by providers.
175
+ # Stateful services MUST be injected, not import this directly.
176
  settings = Settings()
download/face-intel/docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Face Intel — Architecture
2
+
3
+ > Refactored architecture (v2). Introduces a services layer, a pipeline
4
+ > package, a provider registry, a metrics subsystem, separated storage,
5
+ > shared domain models, strict one-way dependency direction, comprehensive
6
+ > dependency injection, and structured execution-context logging.
7
+
8
+ ---
9
+
10
+ ## 1. Dependency Graph (strict one-way)
11
+
12
+ ```
13
+ ┌──────────┐
14
+ │ API │ FastAPI routes + middleware
15
+ └────┬─────┘
16
+
17
+ ┌────▼─────┐
18
+ │ Services │ business logic (8 services)
19
+ └────┬─────┘
20
+
21
+ ┌──────────────┼──────────────┐
22
+ │ │ │
23
+ ┌────▼─────┐ ┌────▼─────┐ ┌─────▼──────┐
24
+ │ Orchest. │ │ Confid. │ │ Normaliz. │
25
+ └────┬─────┘ └────┬─────┘ └─────┬──────┘
26
+ │ │ │
27
+ └─────────────┼──────────────┘
28
+
29
+ ┌────▼─────┐
30
+ │ Pipeline │ validation → preprocess → hash → feature_extract
31
+ └────┬─────┘
32
+
33
+ ┌────▼─────┐
34
+ │ Providers│ detection / recognition / scraper / reverse
35
+ └────┬─────┘
36
+
37
+ ┌────▼─────┐
38
+ │ Storage │ database / cache / artifacts / reference_store
39
+ └────┬─────┘
40
+
41
+ ┌────▼─────┐
42
+ │ Utils │ image / http / audit / timing / logging
43
+ └────┬─────┘
44
+
45
+ ┌────▼─────┐
46
+ │ Models │ shared domain DTOs (pure pydantic, no deps)
47
+ └──────────┘
48
+ ```
49
+
50
+ **Rule:** arrows point downward. A layer may import only from layers
51
+ below it. `models/` is at the bottom and depends on nothing but pydantic
52
+ + stdlib. No circular imports — verified by `scripts/check_imports.py`.
53
+
54
+ **Cross-cutting:** `config/` (settings) is read by every layer but is a
55
+ value object, not stateful. `metrics/` is consumed by orchestrator +
56
+ services + API but owns no upstream dependencies.
57
+
58
+ ---
59
+
60
+ ## 2. Package Responsibilities
61
+
62
+ | Package | Responsibility | Key Exports |
63
+ |---|---|---|
64
+ | `config/` | Environment-driven settings (Pydantic BaseSettings) | `Settings`, `make_settings()` |
65
+ | `models/` | Shared domain DTOs (pure pydantic, no logic) | `Job`, `JobRequest`, `UnifiedFaceReport`, `ProviderInfo`, `HealthSnapshot`, `APIResponse` |
66
+ | `utils/` | Stateless helpers: image I/O, HTTP session, audit log, timing, structured logging | `bytes_to_numpy`, `shared_session`, `audit_log`, `execution_context`, `setup_logging` |
67
+ | `storage/` | Persistence: SQLite (jobs), TTL cache, file artifacts, reference gallery | `Database`, `Cache`, `ArtifactStore`, `ReferenceStore` |
68
+ | `metrics/` | Per-provider latency/success/retry counters, timings, health, cache hit ratio | `MetricsCollector` (facade) |
69
+ | `providers/` | Provider Protocol + BaseProvider + Registry + 4 capability folders | `Provider`, `BaseProvider`, `ProviderRegistry`, `PROVIDER_MANIFEST` |
70
+ | `pipeline/` | Local pre-orchestrator stages: validate → preprocess → hash → feature-extract → postprocess | `InputValidator`, `ImagePreprocessor`, `ImageHasher`, `FeatureExtractor`, `PipelineOutput` |
71
+ | `orchestrator/` | Async fan-out, retry, circuit breaker | `Orchestrator`, `RetryPolicy`, `HealthMonitor` |
72
+ | `normalization/` | Merge multi-provider results into unified report; internal DTOs only | `ReportMerger`, `NormalizedBox`, `NormalizedMatch` |
73
+ | `confidence/` | Explainable scoring + cross-provider conflict detection | `ConfidenceEngine`, `ConflictDetector`, `Explainer` |
74
+ | `services/` | Business logic; one service per concern | `DetectionService`, `RecognitionService`, `SearchService`, `JobService`, `ProviderService`, `CacheService`, `HealthService`, `ExportService` |
75
+ | `api/` | FastAPI app, DI container, middleware, route handlers | `create_app()`, `ServiceContainer`, `build_container()` |
76
+ | `ui/` | Static SPA (served by FastAPI) | — |
77
+ | `tests/` | Unit + provider + integration test suites | — |
78
+
79
+ ---
80
+
81
+ ## 3. Execution Flow
82
+
83
+ ```
84
+ HTTP request
85
+
86
+
87
+ FastAPI middleware: request-id → rate-limit → CORS
88
+
89
+
90
+ Route handler (api/routes/<resource>.py)
91
+ │ depends( get_<service> ) → pulls service off app.state.container
92
+
93
+ Service (services/<name>_service.py)
94
+ │ 1. InputValidator.validate(image_url|base64|bytes)
95
+ │ 2. ImagePreprocessor.from_url|from_bytes → PreprocessedImage
96
+ │ 3. ImageHasher.hash → cache key
97
+ │ 4. FeatureExtractor.extract → PipelineOutput
98
+ │ (image + hash + face_crops + optional gallery/scrape_url)
99
+ │ 5. Orchestrator.run(pipeline_output, capabilities)
100
+ │ 6. ReportMerger.merge(results) → UnifiedFaceReport
101
+ │ 7. return {report, elapsed_ms}
102
+
103
+ Orchestrator (orchestrator/runner.py)
104
+ │ for each provider matching capabilities:
105
+ │ - skip if circuit breaker open
106
+ │ - check cache; hit → return cached ProviderResult
107
+ │ - else: asyncio.to_thread(provider.execute(pipeline_output))
108
+ │ - apply RetryPolicy (exponential backoff + jitter)
109
+ │ - record metrics (latency, success/failure, retries)
110
+ │ - cache successful results
111
+
112
+ Provider (providers/<category>/<name>.py)
113
+ │ receives PipelineOutput
114
+ │ extracts .image / .face_crops / .gallery / .scrape_url
115
+ │ runs its model / HTTP call
116
+ │ returns ProviderResult(raw=verbatim, normalized={...}, elapsed_ms, success)
117
+
118
+ Normalization (normalization/merger.py)
119
+ │ collects NormalizedBox / NormalizedMatch / NormalizedScrapeImage / NormalizedReverseMatch
120
+ │ preserves every ProviderResult as Evidence
121
+ │ delegates scoring to ConfidenceEngine
122
+ │ delegates conflicts to ConflictDetector
123
+ │ returns UnifiedFaceReport
124
+
125
+ Response → JSON to client
126
+ ```
127
+
128
+ ---
129
+
130
+ ## 4. Lifecycle Diagrams
131
+
132
+ ### 4.1 Application Startup
133
+
134
+ ```
135
+ create_app(settings)
136
+
137
+ ├─ setup_logging(settings) # loguru sinks + execution-context format
138
+
139
+ ├─ lifespan:
140
+ │ build_container(settings) # composition root (DI)
141
+ │ │
142
+ │ ├─ ProviderRegistry(settings)
143
+ │ │ .discover() # imports each provider module, instantiates, registers
144
+ │ │ # missing optional deps → NOT_CONFIGURED (graceful)
145
+ │ │
146
+ │ ├─ Cache(ttl, max_entries)
147
+ │ ├─ Database(path) # SQLite, schema migrated
148
+ │ ├─ ArtifactStore(root=uploads/)
149
+ │ ├─ ReferenceStore(root=gallery/)
150
+ │ │
151
+ │ ├─ MetricsCollector(failure_threshold, recovery_seconds)
152
+ │ ├─ HealthMonitor(metrics.health)
153
+ │ │
154
+ │ ├─ Orchestrator(registry, cache, metrics, health, settings, retry_policy)
155
+ │ │
156
+ │ ├─ ConfidenceEngine()
157
+ │ ├─ ConflictDetector()
158
+ │ │
159
+ │ ├─ InputValidator()
160
+ │ ├─ ImagePreprocessor(max_dim=1024)
161
+ │ ├─ ImageHasher()
162
+ │ ├─ FeatureExtractor(detector=first_detection_provider)
163
+ │ │
164
+ │ ├─ DetectionService(...)
165
+ │ ├─ RecognitionService(...)
166
+ │ ├─ SearchService(...)
167
+ │ ├─ JobService(detection, recognition, search, database, metrics)
168
+ │ ├─ ProviderService(registry)
169
+ │ ├─ CacheService(cache)
170
+ │ ├─ HealthService(registry, health, metrics)
171
+ │ └─ ExportService(database)
172
+
173
+ ├─ app.state.container = container
174
+
175
+ └─ mount routes + UI
176
+ ```
177
+
178
+ ### 4.2 Request Lifecycle
179
+
180
+ ```
181
+ 1. HTTP request hits FastAPI
182
+ 2. RequestContextMiddleware → assigns X-Request-ID, starts timer
183
+ 3. RateLimitMiddleware → enforces per-IP limit (sliding 60s window)
184
+ 4. Route handler → Depends(get_<service>) pulls from container
185
+ 5. Service → runs pipeline → orchestrator → normalization → confidence
186
+ 6. Orchestrator → per-provider: cache check → retry-wrapped invoke → metrics
187
+ 7. Provider → executes within execution_context(provider_id=...)
188
+ 8. Result flows back up: ProviderResult → UnifiedFaceReport → JSON response
189
+ 9. Response headers → X-Request-ID, X-Response-Time-ms
190
+ ```
191
+
192
+ ### 4.3 Circuit Breaker Lifecycle
193
+
194
+ ```
195
+ provider.invoke() succeeds
196
+
197
+
198
+ HealthMetrics.record_success(name)
199
+ │ → consecutive_failures = 0
200
+ │ → avg_latency_ms = exp-avg
201
+ │ → if circuit_open: close it
202
+
203
+ circuit stays CLOSED
204
+
205
+
206
+ provider.invoke() fails
207
+
208
+
209
+ HealthMetrics.record_failure(name)
210
+ │ → consecutive_failures += 1
211
+ │ → if consecutive_failures >= threshold AND not circuit_open:
212
+ │ circuit_open = True
213
+ │ circuit_opened_at = now()
214
+
215
+ circuit OPEN → orchestrator skips this provider
216
+ until recovery_seconds elapsed
217
+
218
+
219
+ next invoke attempt after recovery_seconds
220
+
221
+
222
+ HealthMetrics.is_circuit_open(name)
223
+ │ → if circuit_open AND now - opened_at > recovery_seconds:
224
+ │ circuit_open = False (half-open)
225
+ │ return False (allow attempt)
226
+
227
+ provider gets one trial invoke
228
+ success → circuit stays closed
229
+ failure → circuit re-opens
230
+ ```
231
+
232
+ ---
233
+
234
+ ## 5. Extension Guide — Adding a New Provider
235
+
236
+ Adding a provider requires **two changes** and **zero orchestrator/API edits**.
237
+
238
+ ### Step 1 — Implement the provider
239
+
240
+ Create `providers/<category>/<your_provider>.py`:
241
+
242
+ ```python
243
+ from __future__ import annotations
244
+ from config.settings import Settings, settings as _default
245
+ from pipeline.feature_extraction import PipelineOutput
246
+ from providers.base import BaseProvider, ProviderCapability, ProviderResult
247
+
248
+
249
+ class YourProvider(BaseProvider):
250
+ name = "your_provider"
251
+ capability = ProviderCapability.DETECTION # or RECOGNITION / SCRAPING / REVERSE_SEARCH
252
+
253
+ def __init__(self, settings: Settings | None = None) -> None:
254
+ super().__init__(settings=settings or _default)
255
+ # ... initialize your model / client
256
+
257
+ def is_available(self) -> bool:
258
+ # Return False if optional deps or API keys are missing.
259
+ return True
260
+
261
+ def _run(self, pipeline_output: PipelineOutput) -> tuple[dict, dict]:
262
+ img = pipeline_output.image # extract what you need
263
+ # ... do your work
264
+ raw = {"...": ...} # verbatim response (preserved as evidence)
265
+ normalized = {
266
+ "boxes": [{"x": 0, "y": 0, "w": 0, "h": 0}],
267
+ "num_faces": 1,
268
+ "confidences": [0.95],
269
+ "landmarks": None,
270
+ }
271
+ return raw, normalized
272
+ ```
273
+
274
+ ### Step 2 — Add one manifest entry
275
+
276
+ Edit `providers/registry.py`:
277
+
278
+ ```python
279
+ PROVIDER_MANIFEST: list[ManifestEntry] = [
280
+ # ... existing entries ...
281
+ ManifestEntry(
282
+ name="your_provider",
283
+ module_path="providers.detection.your_provider",
284
+ class_name="YourProvider",
285
+ capability=ProviderCapability.DETECTION,
286
+ enable_flag="enable_your_provider",
287
+ description="Your provider — one-line description",
288
+ optional_dependency=True, # set True if it imports an optional package
289
+ ),
290
+ ]
291
+ ```
292
+
293
+ ### Step 3 — Add the enable flag
294
+
295
+ Edit `config/settings.py`:
296
+
297
+ ```python
298
+ enable_your_provider: bool = False
299
+ # ... any tuning knobs your provider needs, e.g.:
300
+ your_provider_threshold: float = 0.7
301
+ ```
302
+
303
+ ### Step 4 — Done
304
+
305
+ - The registry auto-discovers your provider on startup.
306
+ - The orchestrator invokes it for matching capability queries.
307
+ - `/providers` lists it automatically.
308
+ - `/stats` tracks its latency, success rate, retries.
309
+ - The circuit breaker protects against its failures.
310
+ - The cache dedupes identical invocations.
311
+
312
+ **No other file needs to change.** This is the core extensibility guarantee.
313
+
314
+ ---
315
+
316
+ ## 6. Dependency Injection
317
+
318
+ ### Principle
319
+
320
+ > No global objects for stateful services. Inject services, storage,
321
+ > metrics, cache, and orchestrator through constructors.
322
+
323
+ ### Composition Root
324
+
325
+ `api/container.py::build_container()` is the **only** place where
326
+ services are constructed. It runs once at app startup and stores the
327
+ `ServiceContainer` on `app.state.container`.
328
+
329
+ ### How Routes Receive Services
330
+
331
+ ```python
332
+ # api/routes/faces.py
333
+ from fastapi import Depends
334
+ from api.deps import get_detection_service
335
+
336
+ @router.post("/detect")
337
+ async def detect_faces(req: FaceRequest,
338
+ svc: DetectionService = Depends(get_detection_service)):
339
+ return await svc.detect(job_req)
340
+ ```
341
+
342
+ `get_detection_service` is a thin wrapper:
343
+
344
+ ```python
345
+ # api/deps.py
346
+ def get_detection_service(request: Request):
347
+ return get_container(request).detection_service
348
+ ```
349
+
350
+ ### Why This Matters
351
+
352
+ - **Testability:** tests construct a container with `:memory:` DB, disabled
353
+ network providers, and assert against the same code path as production.
354
+ - **No hidden state:** every dependency is visible in the constructor
355
+ signature — no surprise module-level singletons.
356
+ - **Easy overrides:** swap any component (cache, DB, registry) by
357
+ constructing a custom container in tests or for feature flags.
358
+
359
+ ---
360
+
361
+ ## 7. Structured Logging
362
+
363
+ Every log line produced inside an execution carries:
364
+
365
+ ```
366
+ 2026-07-10 14:30:00.123 | INFO | eid=abc123def456 | pid=haar | retry=0 | status=started | invoking haar
367
+ ```
368
+
369
+ | Field | Source | Meaning |
370
+ |---|---|---|
371
+ | `eid` | `execution_context(execution_id=...)` | Unique id for the top-level job |
372
+ | `pid` | `execution_context(provider_id=...)` | Provider name (or `-` for service-level logs) |
373
+ | `retry` | `execution_context(retry_count=...)` | Retry attempt number (0 = first try) |
374
+ | `status` | updated via the context dict | `started` → `running` → `success` / `failed` / `retried` |
375
+
376
+ ### Usage
377
+
378
+ ```python
379
+ from utils.logging import execution_context, new_execution_id
380
+
381
+ async def my_handler():
382
+ eid = new_execution_id()
383
+ with execution_context(execution_id=eid, provider_id="my_service"):
384
+ logger.info("started") # → eid=…, pid=my_service, status=started
385
+ # ... do work ...
386
+ logger.info("completed") # → same eid + pid
387
+ ```
388
+
389
+ ### JSON Mode
390
+
391
+ Set `FI_LOG_JSON=true` to emit newline-delimited JSON for log
392
+ aggregators (Loki, Datadog, CloudWatch):
393
+
394
+ ```json
395
+ {"timestamp":"2026-07-10T14:30:00.123Z","level":"INFO","execution_id":"abc123def456","provider_id":"haar","retry_count":0,"status":"started","message":"invoking haar"}
396
+ ```
397
+
398
+ ---
399
+
400
+ ## 8. Metrics Subsystem
401
+
402
+ `metrics/` tracks six categories consumed by `/stats` and the UI:
403
+
404
+ | Subsystem | What it tracks | Where it's recorded |
405
+ |---|---|---|
406
+ | `ProviderMetrics` | per-provider invocations, successes, failures, retries, latency samples | orchestrator after each provider call |
407
+ | `TimingCollector` | per-operation duration histograms (p50, p95) | services record `job.detection`, `job.recognition`, `job.search` |
408
+ | `CounterRegistry` | global counters (cache.hits, cache.misses, retries.<provider>, failures.<provider>, jobs.<kind>.completed) | orchestrator + services |
409
+ | `HealthMetrics` | per-provider consecutive failures, avg latency, circuit-breaker state | orchestrator via HealthMonitor |
410
+
411
+ `MetricsCollector` is a facade that owns all four — injected as a single
412
+ object so services don't depend on four separate classes.
413
+
414
+ `GET /stats` returns the full snapshot:
415
+
416
+ ```json
417
+ {
418
+ "providers": [{"name": "haar", "invocations": 42, "successes": 41, "failures": 1, "retries": 0, "avg_latency_ms": 6.2, "p95_latency_ms": 12.4, "success_rate": 0.976}],
419
+ "timings": {"job.detection": {"count": 42, "avg_ms": 14.3, "p50_ms": 12.0, "p95_ms": 28.1}},
420
+ "counters": {"cache.hits": 18, "cache.misses": 24, "jobs.detection.completed": 42},
421
+ "health": [{"name": "haar", "consecutive_failures": 0, "avg_latency_ms": 6.2, "circuit_open": false}]
422
+ }
423
+ ```
424
+
425
+ ---
426
+
427
+ ## 9. REST API Surface
428
+
429
+ | Method | Path | Description |
430
+ |---|---|---|
431
+ | `GET` | `/health` | Liveness probe |
432
+ | `GET` | `/health/live` | Liveness |
433
+ | `GET` | `/health/ready` | Readiness |
434
+ | `GET` | `/health/providers` | Per-provider health snapshot |
435
+ | `GET` | `/stats` | Full metrics snapshot |
436
+ | `GET` | `/providers` | List all providers + manifest errors |
437
+ | `GET` | `/providers/{name}` | Single provider info |
438
+ | `GET` | `/cache` | Cache stats (entries, hit ratio, evictions) |
439
+ | `DELETE` | `/cache` | Clear cache |
440
+ | `POST` | `/jobs` | Create + run a job (detection / recognition / search / full_pipeline) |
441
+ | `GET` | `/jobs` | List recent jobs |
442
+ | `GET` | `/jobs/{id}` | Job metadata |
443
+ | `GET` | `/jobs/{id}/result` | Job result (UnifiedFaceReport) |
444
+ | `POST` | `/faces/detect` | Convenience: detect-only |
445
+ | `POST` | `/faces/recognize` | Convenience: recognize-only |
446
+ | `GET` | `/faces/gallery` | List known persons |
447
+ | `DELETE` | `/faces/gallery/{name}` | Remove a known person |
448
+ | `POST` | `/search/reverse` | Reverse image search |
449
+ | `POST` | `/search/scrape` | Scrape images from a URL |
450
+ | `GET` | `/export/{job_id}` | Download job result as JSON |
451
+ | `GET` | `/docs` | OpenAPI Swagger UI |
452
+
453
+ ---
454
+
455
+ ## 10. File Tree (refactored)
456
+
457
+ ```
458
+ face-intel/
459
+ ├── app.py # entry point: uvicorn app:app
460
+ ├── requirements.txt
461
+ ├── .env.example
462
+ ├── README.md
463
+
464
+ ├── config/
465
+ │ ├── __init__.py
466
+ │ └── settings.py # Settings + make_settings() factory
467
+
468
+ ├── models/ # NEW: shared domain DTOs
469
+ │ ├── __init__.py
470
+ │ ├── jobs.py # Job, JobRequest, JobStatus, JobResult, JobKind
471
+ │ ├── providers.py # ProviderCapability, ProviderStatus, ProviderInfo, ProviderConfig
472
+ │ ├── reports.py # UnifiedFaceReport, FaceDetection, FaceMatch, Evidence, ConfidenceScore, ConflictReport
473
+ │ ├── responses.py # APIResponse, PaginatedResponse, ErrorResponse, HealthResponse
474
+ │ └── health.py # HealthSnapshot, ProviderHealthSnapshot, SystemHealthSnapshot
475
+
476
+ ├── utils/ # lowest layer
477
+ │ ├── __init__.py
478
+ │ ├── image.py # bytes↔numpy, BBox, crop, hash, draw
479
+ │ ├── http.py # shared requests.Session with retries
480
+ │ ├── audit.py # append-only JSONL audit log
481
+ │ ├── timing.py # @contextmanager timed()
482
+ │ └── logging.py # NEW: loguru + execution_context()
483
+
484
+ ├── storage/ # separated responsibilities
485
+ │ ├── __init__.py
486
+ │ ├── database.py # SQLite (jobs, results)
487
+ │ ├── cache.py # in-memory TTL + LRU
488
+ │ ├── artifacts.py # filesystem: uploads/, generated/
489
+ │ └── reference_store.py # known-faces gallery (.npy + manifest.json)
490
+
491
+ ├── metrics/ # NEW
492
+ │ ├── __init__.py
493
+ │ ├── provider_metrics.py # per-provider latency/success/retry
494
+ │ ├── timings.py # operation-level p50/p95
495
+ │ ├── counters.py # named integer counters
496
+ │ ├── health_metrics.py # circuit breaker state
497
+ │ └── collector.py # MetricsCollector facade
498
+
499
+ ├── providers/
500
+ │ ├── __init__.py # re-exports (no globals)
501
+ │ ├── base.py # Provider Protocol, BaseProvider, ProviderResult
502
+ │ ├── registry.py # NEW: ProviderRegistry class + PROVIDER_MANIFEST
503
+ │ ├── detection/
504
+ │ │ ├── __init__.py
505
+ │ │ └── haar.py # implemented (DI-updated)
506
+ │ ├── recognition/ # stubs (to be implemented Phase 3)
507
+ │ ├── scraper/
508
+ │ └── reverse/
509
+
510
+ ├── pipeline/ # NEW
511
+ │ ├── __init__.py
512
+ │ ├── validation.py # InputValidator
513
+ │ ├── preprocessing.py # ImagePreprocessor → PreprocessedImage
514
+ │ ├── hashing.py # ImageHasher (SHA-256 cache key)
515
+ │ ├── feature_extraction.py # FeatureExtractor → PipelineOutput
516
+ │ └── postprocessing.py # dedupe, clamp, filter
517
+
518
+ ├── orchestrator/
519
+ │ ├── __init__.py
520
+ │ ├── runner.py # Orchestrator (async fan-out, DI-injected)
521
+ │ ├── retry.py # RetryPolicy, with_retry_sync/async
522
+ │ └── health.py # HealthMonitor (circuit breaker gate)
523
+
524
+ ├── normalization/
525
+ │ ├── __init__.py
526
+ │ ├── schema.py # internal DTOs (NormalizedBox, NormalizedMatch, ...)
527
+ │ └── merger.py # ReportMerger (provider results → UnifiedFaceReport)
528
+
529
+ ├── confidence/
530
+ │ ├── __init__.py
531
+ │ ├── engine.py # ConfidenceEngine (weighted sub-scores)
532
+ │ ├── explainer.py # human-readable explanations
533
+ │ └── conflicts.py # ConflictDetector (cross-provider disagreements)
534
+
535
+ ├── services/ # NEW
536
+ │ ├── __init__.py
537
+ │ ├── detection_service.py
538
+ │ ├── recognition_service.py
539
+ │ ├── search_service.py
540
+ │ ├── job_service.py # full-pipeline + persistence
541
+ │ ├── provider_service.py
542
+ │ ├── cache_service.py
543
+ │ ├── health_service.py
544
+ │ └── export_service.py
545
+
546
+ ├── api/
547
+ │ ├── __init__.py
548
+ │ ├── main.py # create_app() factory
549
+ │ ├── container.py # build_container() — composition root
550
+ │ ├── deps.py # FastAPI Depends() callables
551
+ │ ├── middleware.py # request-id, rate limit
552
+ │ └── routes/
553
+ │ ├── __init__.py
554
+ │ ├── health.py
555
+ │ ├── stats.py
556
+ │ ├── providers.py
557
+ │ ├── cache.py
558
+ │ ├── jobs.py
559
+ │ ├── faces.py
560
+ │ ├── search.py
561
+ │ └── export.py
562
+
563
+ ├── ui/
564
+ │ └── static/ # served by FastAPI (to be built Phase 8)
565
+
566
+ ├── tests/ # to be built Phase 9
567
+ │ ├── unit/
568
+ │ ├── providers/
569
+ │ └── integration/
570
+
571
+ └── docs/
572
+ └── ARCHITECTURE.md # this file
573
+ ```
574
+
575
+ ---
576
+
577
+ ## 11. Verification
578
+
579
+ The refactor has been smoke-tested end-to-end:
580
+
581
+ ```
582
+ ✓ All 11 layers import cleanly — no circular dependencies
583
+ ✓ DI container builds with all 15 manifest entries registered
584
+ ✓ FastAPI app boots with 32 routes
585
+ ✓ End-to-end detection job runs: pipeline → orchestrator → haar → normalization → confidence → report
586
+ ✓ Haar provider correctly receives PipelineOutput (not raw numpy)
587
+ ✓ Evidence preserved with raw + normalized + elapsed_ms + success
588
+ ✓ Confidence engine produces explainable sub-scores
589
+ ✓ Metrics subsystem records invocations, successes, timings
590
+ ✓ Circuit-breaker state queryable via /health/providers
591
+ ```
592
+
593
+ ---
594
+
595
+ ## 12. What's Next
596
+
597
+ The refactor establishes the production-grade skeleton. The remaining
598
+ work from the original 10-phase plan:
599
+
600
+ - **Phase 3 (continued):** implement the 14 remaining provider modules
601
+ (dnn, mtcnn, retinaface, face_recognition, deepface, insightface,
602
+ beautifulsoup, selenium, bing, duckduckgo, google_lens, serpapi,
603
+ yandex, tineye). Each follows the `haar.py` pattern — ~50 LOC.
604
+ - **Phase 8:** build the UI SPA in `ui/static/`.
605
+ - **Phase 9:** write the test suites in `tests/`.
606
+ - **Phase 10:** provider-specific docs, deployment, benchmarks.
607
+
608
+ The architecture will not need to change for any of these — only
609
+ additions within existing packages.
download/face-intel/metrics/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Metrics subsystem — tracks provider latency, success/failure counts,
3
+ retries, cache hit ratio, and execution duration.
4
+
5
+ All metrics are in-process and thread-safe. Designed to be scraped
6
+ by the /stats endpoint. No external metrics backend (Prometheus etc.)
7
+ is required, but the data model is compatible with one.
8
+ """
9
+
10
+ from metrics.provider_metrics import ProviderMetrics
11
+ from metrics.timings import TimingCollector
12
+ from metrics.counters import CounterRegistry
13
+ from metrics.health_metrics import HealthMetrics
14
+ from metrics.collector import MetricsCollector
15
+
16
+ __all__ = [
17
+ "ProviderMetrics",
18
+ "TimingCollector",
19
+ "CounterRegistry",
20
+ "HealthMetrics",
21
+ "MetricsCollector",
22
+ ]
download/face-intel/metrics/collector.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MetricsCollector — aggregates the four metric subsystems into one
3
+ injectable facade so services don't depend on four separate objects.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from metrics.provider_metrics import ProviderMetrics
9
+ from metrics.timings import TimingCollector
10
+ from metrics.counters import CounterRegistry
11
+ from metrics.health_metrics import HealthMetrics
12
+
13
+
14
+ class MetricsCollector:
15
+ """Facade that owns all metric subsystems."""
16
+
17
+ def __init__(self,
18
+ failure_threshold: int = 5,
19
+ recovery_seconds: int = 120) -> None:
20
+ self.providers = ProviderMetrics()
21
+ self.timings = TimingCollector()
22
+ self.counters = CounterRegistry()
23
+ self.health = HealthMetrics(
24
+ failure_threshold=failure_threshold,
25
+ recovery_seconds=recovery_seconds,
26
+ )
27
+
28
+ def snapshot(self) -> dict:
29
+ return {
30
+ "providers": self.providers.snapshot(),
31
+ "timings": self.timings.snapshot(),
32
+ "counters": self.counters.snapshot(),
33
+ "health": self.health.snapshot(),
34
+ }
35
+
36
+ def reset(self) -> None:
37
+ self.providers.reset()
38
+ self.timings.reset()
39
+ self.counters.reset()
40
+ self.health.reset()
download/face-intel/metrics/counters.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Global monotonic counters — cache hits/misses, requests, errors.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ from collections import defaultdict
9
+ from typing import Dict
10
+
11
+
12
+ class CounterRegistry:
13
+ """Named integer counters."""
14
+
15
+ def __init__(self) -> None:
16
+ self._lock = threading.RLock()
17
+ self._counters: Dict[str, int] = defaultdict(int)
18
+
19
+ def inc(self, name: str, amount: int = 1) -> None:
20
+ with self._lock:
21
+ self._counters[name] += amount
22
+
23
+ def get(self, name: str) -> int:
24
+ with self._lock:
25
+ return self._counters.get(name, 0)
26
+
27
+ def snapshot(self) -> Dict[str, int]:
28
+ with self._lock:
29
+ return dict(self._counters)
30
+
31
+ def reset(self) -> None:
32
+ with self._lock:
33
+ self._counters.clear()
download/face-intel/metrics/health_metrics.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Health metrics — feeds the circuit breaker and the /health endpoint.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timezone
11
+ from typing import Dict, Optional
12
+
13
+
14
+ @dataclass
15
+ class HealthRecord:
16
+ name: str
17
+ consecutive_failures: int = 0
18
+ last_success: Optional[float] = None # epoch
19
+ last_failure: Optional[float] = None
20
+ avg_latency_ms: float = 0.0
21
+ circuit_open: bool = False
22
+ circuit_opened_at: Optional[float] = None
23
+
24
+
25
+ class HealthMetrics:
26
+ """Per-provider health record + circuit breaker state."""
27
+
28
+ def __init__(self, failure_threshold: int = 5,
29
+ recovery_seconds: int = 120) -> None:
30
+ self._lock = threading.RLock()
31
+ self._records: Dict[str, HealthRecord] = {}
32
+ self._failure_threshold = failure_threshold
33
+ self._recovery_seconds = recovery_seconds
34
+
35
+ def _get(self, name: str) -> HealthRecord:
36
+ if name not in self._records:
37
+ self._records[name] = HealthRecord(name=name)
38
+ return self._records[name]
39
+
40
+ def record_success(self, name: str, latency_ms: float) -> None:
41
+ with self._lock:
42
+ r = self._get(name)
43
+ r.consecutive_failures = 0
44
+ r.last_success = time.time()
45
+ # rolling avg
46
+ r.avg_latency_ms = 0.9 * r.avg_latency_ms + 0.1 * latency_ms if r.avg_latency_ms else latency_ms
47
+ # auto-close circuit on success
48
+ if r.circuit_open:
49
+ r.circuit_open = False
50
+ r.circuit_opened_at = None
51
+
52
+ def record_failure(self, name: str) -> None:
53
+ with self._lock:
54
+ r = self._get(name)
55
+ r.consecutive_failures += 1
56
+ r.last_failure = time.time()
57
+ if r.consecutive_failures >= self._failure_threshold and not r.circuit_open:
58
+ r.circuit_open = True
59
+ r.circuit_opened_at = time.time()
60
+
61
+ def is_circuit_open(self, name: str) -> bool:
62
+ with self._lock:
63
+ r = self._get(name)
64
+ if not r.circuit_open:
65
+ return False
66
+ # half-open after recovery window
67
+ if r.circuit_opened_at and (time.time() - r.circuit_opened_at) > self._recovery_seconds:
68
+ r.circuit_open = False
69
+ r.circuit_opened_at = None
70
+ return False
71
+ return True
72
+
73
+ def snapshot(self) -> list[dict]:
74
+ with self._lock:
75
+ out = []
76
+ for name, r in self._records.items():
77
+ out.append({
78
+ "name": name,
79
+ "consecutive_failures": r.consecutive_failures,
80
+ "last_success": datetime.fromtimestamp(r.last_success, tz=timezone.utc).isoformat() if r.last_success else None,
81
+ "last_failure": datetime.fromtimestamp(r.last_failure, tz=timezone.utc).isoformat() if r.last_failure else None,
82
+ "avg_latency_ms": round(r.avg_latency_ms, 3),
83
+ "circuit_open": r.circuit_open,
84
+ })
85
+ return out
86
+
87
+ def reset(self) -> None:
88
+ with self._lock:
89
+ self._records.clear()
download/face-intel/metrics/provider_metrics.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Per-provider metrics: latency histogram (simplified), success/failure
3
+ counts, retry counts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import threading
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List
12
+
13
+
14
+ @dataclass
15
+ class ProviderStat:
16
+ name: str
17
+ invocations: int = 0
18
+ successes: int = 0
19
+ failures: int = 0
20
+ retries: int = 0
21
+ latencies_ms: List[float] = field(default_factory=list)
22
+ last_latency_ms: float = 0.0
23
+ last_error: str = ""
24
+
25
+ def to_dict(self) -> dict:
26
+ total = self.successes + self.failures
27
+ avg = sum(self.latencies_ms) / len(self.latencies_ms) if self.latencies_ms else 0.0
28
+ p95 = sorted(self.latencies_ms)[int(len(self.latencies_ms) * 0.95)] if self.latencies_ms else 0.0
29
+ return {
30
+ "name": self.name,
31
+ "invocations": self.invocations,
32
+ "successes": self.successes,
33
+ "failures": self.failures,
34
+ "retries": self.retries,
35
+ "success_rate": round(self.successes / total, 4) if total else 0.0,
36
+ "avg_latency_ms": round(avg, 3),
37
+ "p95_latency_ms": round(p95, 3),
38
+ "last_latency_ms": round(self.last_latency_ms, 3),
39
+ "last_error": self.last_error,
40
+ }
41
+
42
+
43
+ class ProviderMetrics:
44
+ """Thread-safe per-provider metrics collector."""
45
+
46
+ MAX_LATENCY_SAMPLES = 500 # rolling window
47
+
48
+ def __init__(self) -> None:
49
+ self._lock = threading.RLock()
50
+ self._stats: Dict[str, ProviderStat] = defaultdict(lambda: None)
51
+
52
+ def _get(self, name: str) -> ProviderStat:
53
+ if self._stats[name] is None:
54
+ self._stats[name] = ProviderStat(name=name)
55
+ return self._stats[name]
56
+
57
+ def record_invocation(self, name: str) -> None:
58
+ with self._lock:
59
+ self._get(name).invocations += 1
60
+
61
+ def record_success(self, name: str, latency_ms: float) -> None:
62
+ with self._lock:
63
+ s = self._get(name)
64
+ s.successes += 1
65
+ s.last_latency_ms = latency_ms
66
+ s.latencies_ms.append(latency_ms)
67
+ if len(s.latencies_ms) > self.MAX_LATENCY_SAMPLES:
68
+ s.latencies_ms = s.latencies_ms[-self.MAX_LATENCY_SAMPLES:]
69
+
70
+ def record_failure(self, name: str, error: str = "") -> None:
71
+ with self._lock:
72
+ s = self._get(name)
73
+ s.failures += 1
74
+ s.last_error = error
75
+
76
+ def record_retry(self, name: str) -> None:
77
+ with self._lock:
78
+ self._get(name).retries += 1
79
+
80
+ def snapshot(self) -> List[dict]:
81
+ with self._lock:
82
+ return [self._get(name).to_dict() for name in sorted(self._stats.keys())]
83
+
84
+ def reset(self) -> None:
85
+ with self._lock:
86
+ self._stats.clear()
download/face-intel/metrics/timings.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Execution timing collector — tracks end-to-end job durations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ from collections import defaultdict
9
+ from typing import Dict, List
10
+
11
+
12
+ class TimingCollector:
13
+ """Tracks durations grouped by operation label."""
14
+
15
+ MAX_SAMPLES = 500
16
+
17
+ def __init__(self) -> None:
18
+ self._lock = threading.RLock()
19
+ self._samples: Dict[str, List[float]] = defaultdict(list)
20
+
21
+ def record(self, label: str, duration_ms: float) -> None:
22
+ with self._lock:
23
+ self._samples[label].append(duration_ms)
24
+ if len(self._samples[label]) > self.MAX_SAMPLES:
25
+ self._samples[label] = self._samples[label][-self.MAX_SAMPLES:]
26
+
27
+ def snapshot(self) -> dict:
28
+ with self._lock:
29
+ out = {}
30
+ for label, samples in self._samples.items():
31
+ if not samples:
32
+ continue
33
+ s = sorted(samples)
34
+ out[label] = {
35
+ "count": len(samples),
36
+ "avg_ms": round(sum(samples) / len(samples), 3),
37
+ "p50_ms": round(s[len(s) // 2], 3),
38
+ "p95_ms": round(s[int(len(s) * 0.95)], 3),
39
+ "min_ms": round(s[0], 3),
40
+ "max_ms": round(s[-1], 3),
41
+ }
42
+ return out
43
+
44
+ def reset(self) -> None:
45
+ with self._lock:
46
+ self._samples.clear()
download/face-intel/models/__init__.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared domain models.
3
+
4
+ These are the cross-layer data contracts. Only normalization-specific
5
+ DTOs live in `normalization/schema.py`; everything else that crosses a
6
+ layer boundary is defined here.
7
+
8
+ Dependency rule: models/ imports only from pydantic + stdlib. It never
9
+ imports from utils, storage, providers, pipeline, orchestrator, services,
10
+ or api.
11
+ """
12
+
13
+ from models.jobs import (
14
+ Job,
15
+ JobRequest,
16
+ JobStatus,
17
+ JobResult,
18
+ JobKind,
19
+ )
20
+ from models.providers import (
21
+ ProviderCapability,
22
+ ProviderStatus,
23
+ ProviderInfo,
24
+ ProviderConfig,
25
+ )
26
+ from models.reports import (
27
+ UnifiedFaceReport,
28
+ FaceDetection,
29
+ FaceMatch,
30
+ Evidence,
31
+ ConfidenceScore,
32
+ ConflictReport,
33
+ ReportMetadata,
34
+ )
35
+ from models.responses import (
36
+ APIResponse,
37
+ PaginatedResponse,
38
+ ErrorResponse,
39
+ HealthResponse,
40
+ )
41
+ from models.health import (
42
+ HealthSnapshot,
43
+ ProviderHealthSnapshot,
44
+ SystemHealthSnapshot,
45
+ )
46
+
47
+ __all__ = [
48
+ # jobs
49
+ "Job", "JobRequest", "JobStatus", "JobResult", "JobKind",
50
+ # providers
51
+ "ProviderCapability", "ProviderStatus", "ProviderInfo", "ProviderConfig",
52
+ # reports
53
+ "UnifiedFaceReport", "FaceDetection", "FaceMatch", "Evidence",
54
+ "ConfidenceScore", "ConflictReport", "ReportMetadata",
55
+ # responses
56
+ "APIResponse", "PaginatedResponse", "ErrorResponse", "HealthResponse",
57
+ # health
58
+ "HealthSnapshot", "ProviderHealthSnapshot", "SystemHealthSnapshot",
59
+ ]
download/face-intel/models/health.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health-related domain models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Dict, List, Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class ProviderHealthSnapshot(BaseModel):
12
+ """Per-provider health snapshot used by the circuit breaker + API."""
13
+ name: str
14
+ healthy: bool
15
+ consecutive_failures: int = 0
16
+ last_success: Optional[datetime] = None
17
+ last_failure: Optional[datetime] = None
18
+ last_check: Optional[datetime] = None
19
+ avg_latency_ms: float = 0.0
20
+ circuit_open: bool = False
21
+ circuit_opened_at: Optional[datetime] = None
22
+ metadata: dict = Field(default_factory=dict)
23
+
24
+
25
+ class SystemHealthSnapshot(BaseModel):
26
+ """Aggregate system health."""
27
+ status: str = "healthy" # healthy | degraded | unhealthy
28
+ version: str = "1.0.0"
29
+ uptime_seconds: float = 0.0
30
+ providers: List[ProviderHealthSnapshot] = Field(default_factory=list)
31
+ cache: dict = Field(default_factory=dict)
32
+ metrics: dict = Field(default_factory=dict)
33
+
34
+
35
+ class HealthSnapshot(BaseModel):
36
+ """Convenience alias."""
37
+ system: SystemHealthSnapshot
38
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
download/face-intel/models/jobs.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job-related domain models — used by services, storage, and API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from typing import Any, List, Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class JobKind(str, enum.Enum):
14
+ DETECTION = "detection"
15
+ RECOGNITION = "recognition"
16
+ SEARCH = "search" # scrape + reverse image search
17
+ FULL_PIPELINE = "full_pipeline" # detect + recognize + search
18
+
19
+
20
+ class JobStatus(str, enum.Enum):
21
+ PENDING = "pending"
22
+ RUNNING = "running"
23
+ COMPLETED = "completed"
24
+ FAILED = "failed"
25
+ CANCELLED = "cancelled"
26
+
27
+
28
+ class JobRequest(BaseModel):
29
+ """Inbound request to create a job."""
30
+ kind: JobKind
31
+ image_url: Optional[str] = None
32
+ image_base64: Optional[str] = None
33
+ providers: List[str] = Field(
34
+ default_factory=list,
35
+ description="Optional whitelist of provider names. Empty = use all enabled.",
36
+ )
37
+ options: dict = Field(default_factory=dict)
38
+
39
+ def has_image_input(self) -> bool:
40
+ return bool(self.image_url or self.image_base64)
41
+
42
+
43
+ class Job(BaseModel):
44
+ """Persisted job record."""
45
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
46
+ kind: JobKind
47
+ status: JobStatus = JobStatus.PENDING
48
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
49
+ started_at: Optional[datetime] = None
50
+ completed_at: Optional[datetime] = None
51
+ request: JobRequest
52
+ image_hash: Optional[str] = None
53
+ error: Optional[str] = None
54
+
55
+ def mark_running(self) -> None:
56
+ self.status = JobStatus.RUNNING
57
+ self.started_at = datetime.now(timezone.utc)
58
+
59
+ def mark_completed(self) -> None:
60
+ self.status = JobStatus.COMPLETED
61
+ self.completed_at = datetime.now(timezone.utc)
62
+
63
+ def mark_failed(self, error: str) -> None:
64
+ self.status = JobStatus.FAILED
65
+ self.completed_at = datetime.now(timezone.utc)
66
+ self.error = error
67
+
68
+
69
+ class JobResult(BaseModel):
70
+ """The output of a completed job — wraps a UnifiedFaceReport."""
71
+ job_id: str
72
+ status: JobStatus
73
+ report: Optional[Any] = None # UnifiedFaceReport (avoid circular import)
74
+ error: Optional[str] = None
75
+ elapsed_ms: float = 0.0
download/face-intel/models/providers.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider-related domain models.
2
+
3
+ Note: ProviderCapability and ProviderStatus are also re-exported from
4
+ providers/base.py for legacy compatibility, but the canonical home is
5
+ here in models/ (per refactor requirement 6).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import enum
11
+ from typing import Any, Optional
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class ProviderCapability(str, enum.Enum):
17
+ DETECTION = "detection"
18
+ RECOGNITION = "recognition"
19
+ SCRAPING = "scraping"
20
+ REVERSE_SEARCH = "reverse_search"
21
+
22
+
23
+ class ProviderStatus(str, enum.Enum):
24
+ HEALTHY = "healthy"
25
+ DEGRADED = "degraded"
26
+ UNHEALTHY = "unhealthy"
27
+ DISABLED = "disabled"
28
+ NOT_CONFIGURED = "not_configured"
29
+
30
+
31
+ class ProviderInfo(BaseModel):
32
+ """Public-facing description of a registered provider."""
33
+ name: str
34
+ capability: ProviderCapability
35
+ status: ProviderStatus
36
+ available: bool
37
+ description: str = ""
38
+ version: str = ""
39
+ metadata: dict = Field(default_factory=dict)
40
+
41
+
42
+ class ProviderConfig(BaseModel):
43
+ """Configuration entry used by the registry manifest."""
44
+ name: str
45
+ module_path: str
46
+ class_name: str
47
+ capability: ProviderCapability
48
+ enable_flag: str
49
+ description: str = ""
50
+ requires_api_key: bool = False
51
+ optional_dependency: bool = False
download/face-intel/models/reports.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Report domain models — the unified output of a face-intelligence job."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class Evidence(BaseModel):
13
+ """Preserved raw provider output — never discarded."""
14
+ provider: str
15
+ capability: str
16
+ raw: Any = None # verbatim provider response
17
+ normalized: dict = Field(default_factory=dict)
18
+ elapsed_ms: float = 0.0
19
+ success: bool = True
20
+ error: Optional[str] = None
21
+ error_type: Optional[str] = None
22
+ metadata: dict = Field(default_factory=dict)
23
+
24
+
25
+ class ConfidenceScore(BaseModel):
26
+ """Explainable confidence — decomposed into weighted sub-scores."""
27
+ overall: float # 0.0 – 1.0
28
+ components: Dict[str, float] = Field(default_factory=dict)
29
+ explanation: str = ""
30
+ method: str = "weighted_average"
31
+
32
+
33
+ class FaceDetection(BaseModel):
34
+ """One detected face, with cross-provider consensus."""
35
+ box: Dict[str, int] # {x, y, w, h}
36
+ confidence: ConfidenceScore
37
+ landmarks: Optional[Dict[str, List[int]]] = None
38
+ detected_by: List[str] = Field(default_factory=list)
39
+ embedding: Optional[List[float]] = None
40
+ embedding_provider: Optional[str] = None
41
+
42
+
43
+ class FaceMatch(BaseModel):
44
+ """A recognition match against the reference gallery."""
45
+ query_face_index: int
46
+ best_match: Optional[str] = None
47
+ confidence: ConfidenceScore
48
+ distances: Dict[str, float] = Field(default_factory=dict)
49
+
50
+
51
+ class ConflictReport(BaseModel):
52
+ """Cross-provider disagreement."""
53
+ kind: str # e.g. "face_count_mismatch"
54
+ providers: List[str]
55
+ description: str
56
+ severity: str = "info" # info | warning | error
57
+
58
+
59
+ class ReportMetadata(BaseModel):
60
+ """Job/report metadata."""
61
+ job_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
62
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
63
+ image_hash: Optional[str] = None
64
+ total_elapsed_ms: float = 0.0
65
+ providers_invoked: List[str] = Field(default_factory=list)
66
+ providers_succeeded: List[str] = Field(default_factory=list)
67
+ providers_failed: List[str] = Field(default_factory=list)
68
+
69
+
70
+ class UnifiedFaceReport(BaseModel):
71
+ """The final unified report consumed by API + UI."""
72
+ metadata: ReportMetadata
73
+ detections: List[FaceDetection] = Field(default_factory=list)
74
+ matches: List[FaceMatch] = Field(default_factory=list)
75
+ scraped_images: List[dict] = Field(default_factory=list)
76
+ reverse_matches: List[dict] = Field(default_factory=list)
77
+ evidence: List[Evidence] = Field(default_factory=list)
78
+ conflicts: List[ConflictReport] = Field(default_factory=list)
79
+ overall_confidence: Optional[ConfidenceScore] = None
download/face-intel/models/responses.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API response wrapper models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class APIResponse(BaseModel):
11
+ """Standard envelope for successful responses."""
12
+ success: bool = True
13
+ data: Any
14
+ message: str = ""
15
+
16
+
17
+ class PaginatedResponse(BaseModel):
18
+ success: bool = True
19
+ data: List[Any]
20
+ total: int
21
+ page: int = 1
22
+ page_size: int = 50
23
+
24
+
25
+ class ErrorResponse(BaseModel):
26
+ success: bool = False
27
+ error: str
28
+ error_type: str = ""
29
+ details: Optional[dict] = None
30
+
31
+
32
+ class HealthResponse(BaseModel):
33
+ status: str
34
+ version: str
35
+ providers: List[dict]
36
+ uptime_seconds: float
download/face-intel/normalization/__init__.py CHANGED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Normalization package — merges multi-provider ProviderResults into a
3
+ single UnifiedFaceReport, preserving every Evidence record.
4
+
5
+ Internal-only DTOs (NormalizedBox, NormalizedMatch, etc.) live here.
6
+ Cross-layer models live in models/.
7
+ """
8
+
9
+ from normalization.schema import (
10
+ NormalizedBox,
11
+ NormalizedMatch,
12
+ NormalizedScrapeImage,
13
+ NormalizedReverseMatch,
14
+ )
15
+ from normalization.merger import ReportMerger
16
+
17
+ __all__ = [
18
+ "NormalizedBox",
19
+ "NormalizedMatch",
20
+ "NormalizedScrapeImage",
21
+ "NormalizedReverseMatch",
22
+ "ReportMerger",
23
+ ]
download/face-intel/normalization/merger.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Report merger — turns a dict of {provider_name: ProviderResult} into a
3
+ UnifiedFaceReport.
4
+
5
+ The merger:
6
+ 1. Extracts NormalizedBox list from each detection provider.
7
+ 2. Extracts NormalizedMatch list from each recognition provider.
8
+ 3. Extracts NormalizedScrapeImage list from each scraper.
9
+ 4. Extracts NormalizedReverseMatch list from each reverse-search.
10
+ 5. Preserves every ProviderResult as Evidence (raw + normalized).
11
+ 6. Delegates confidence scoring to confidence.engine.
12
+ 7. Delegates conflict detection to confidence.conflicts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Dict, List
18
+
19
+ from confidence.engine import ConfidenceEngine
20
+ from confidence.conflicts import ConflictDetector
21
+ from models.reports import (
22
+ UnifiedFaceReport,
23
+ FaceDetection,
24
+ FaceMatch,
25
+ Evidence,
26
+ ReportMetadata,
27
+ )
28
+ from normalization.schema import (
29
+ NormalizedBox,
30
+ NormalizedMatch,
31
+ NormalizedScrapeImage,
32
+ NormalizedReverseMatch,
33
+ )
34
+ from pipeline.postprocessing import ResultPostprocessor
35
+ from providers.base import ProviderResult
36
+
37
+
38
+ class ReportMerger:
39
+ """Merges provider results into a UnifiedFaceReport."""
40
+
41
+ def __init__(
42
+ self,
43
+ confidence_engine: ConfidenceEngine,
44
+ conflict_detector: ConflictDetector,
45
+ ) -> None:
46
+ self._confidence = confidence_engine
47
+ self._conflicts = conflict_detector
48
+
49
+ def merge(
50
+ self,
51
+ results: Dict[str, ProviderResult],
52
+ image_hash: str,
53
+ job_id: str,
54
+ total_elapsed_ms: float,
55
+ ) -> UnifiedFaceReport:
56
+ boxes = self._collect_boxes(results)
57
+ matches = self._collect_matches(results)
58
+ scraped = self._collect_scraped(results)
59
+ reverse_matches = self._collect_reverse(results)
60
+ evidence = self._build_evidence(results)
61
+
62
+ # Build FaceDetection objects with confidence
63
+ detections: List[FaceDetection] = []
64
+ for i, nbox in enumerate(boxes):
65
+ score = self._confidence.score_detection(nbox, results)
66
+ detections.append(FaceDetection(
67
+ box={"x": nbox.x, "y": nbox.y, "w": nbox.w, "h": nbox.h},
68
+ confidence=score,
69
+ landmarks=nbox.landmarks,
70
+ detected_by=[nbox.detector],
71
+ ))
72
+
73
+ # Build FaceMatch objects
74
+ face_matches: List[FaceMatch] = []
75
+ for nm in matches:
76
+ score = self._confidence.score_match(nm)
77
+ face_matches.append(FaceMatch(
78
+ query_face_index=nm.query_face_index,
79
+ best_match=nm.best_match,
80
+ confidence=score,
81
+ distances=nm.distances,
82
+ ))
83
+
84
+ # Detect cross-provider conflicts
85
+ conflicts = self._conflicts.detect(results, boxes, matches)
86
+
87
+ # Metadata
88
+ succeeded = [r.provider for r in results.values() if r.success]
89
+ failed = [r.provider for r in results.values() if not r.success]
90
+ metadata = ReportMetadata(
91
+ job_id=job_id,
92
+ image_hash=image_hash,
93
+ total_elapsed_ms=total_elapsed_ms,
94
+ providers_invoked=list(results.keys()),
95
+ providers_succeeded=succeeded,
96
+ providers_failed=failed,
97
+ )
98
+
99
+ report = UnifiedFaceReport(
100
+ metadata=metadata,
101
+ detections=detections,
102
+ matches=face_matches,
103
+ scraped_images=[s.__dict__ for s in scraped],
104
+ reverse_matches=[r.__dict__ for r in reverse_matches],
105
+ evidence=evidence,
106
+ conflicts=conflicts,
107
+ overall_confidence=self._confidence.score_overall(report_detections=detections,
108
+ matches=face_matches,
109
+ conflicts=conflicts),
110
+ )
111
+ # Patch: rebuild metadata on report (already set above)
112
+ report.metadata = metadata
113
+ return report
114
+
115
+ # ------------------------------------------------------------------ #
116
+ # Collectors
117
+ # ------------------------------------------------------------------ #
118
+ def _collect_boxes(self, results: Dict[str, ProviderResult]) -> List[NormalizedBox]:
119
+ out: List[NormalizedBox] = []
120
+ for r in results.values():
121
+ if not r.success:
122
+ continue
123
+ norm = r.normalized
124
+ if r.capability.value != "detection":
125
+ continue
126
+ boxes = norm.get("boxes", [])
127
+ confs = norm.get("confidences", [1.0] * len(boxes))
128
+ lms = norm.get("landmarks")
129
+ for i, b in enumerate(boxes):
130
+ lm = lms[i] if isinstance(lms, list) and i < len(lms) else (lms if isinstance(lms, dict) else None)
131
+ out.append(NormalizedBox(
132
+ x=b["x"], y=b["y"], w=b["w"], h=b["h"],
133
+ confidence=float(confs[i]) if i < len(confs) else 1.0,
134
+ detector=r.provider,
135
+ landmarks=lm,
136
+ ))
137
+ return out
138
+
139
+ def _collect_matches(self, results: Dict[str, ProviderResult]) -> List[NormalizedMatch]:
140
+ out: List[NormalizedMatch] = []
141
+ for r in results.values():
142
+ if not r.success or r.capability.value != "recognition":
143
+ continue
144
+ for m in r.normalized.get("matches", []):
145
+ out.append(NormalizedMatch(
146
+ query_face_index=m.get("query_face_index", 0),
147
+ best_match=m.get("best_match"),
148
+ distance=m.get("distance", 1.0),
149
+ distances=m.get("distances", {}),
150
+ recognizer=r.provider,
151
+ ))
152
+ return out
153
+
154
+ def _collect_scraped(self, results: Dict[str, ProviderResult]) -> List[NormalizedScrapeImage]:
155
+ out: List[NormalizedScrapeImage] = []
156
+ for r in results.values():
157
+ if not r.success or r.capability.value != "scraping":
158
+ continue
159
+ for img in r.normalized.get("images", []):
160
+ out.append(NormalizedScrapeImage(
161
+ url=img.get("url", ""),
162
+ alt=img.get("alt", ""),
163
+ source_page=img.get("source_page", ""),
164
+ scraper=r.provider,
165
+ width=img.get("width"),
166
+ height=img.get("height"),
167
+ ))
168
+ # Dedupe
169
+ deduped = ResultPostprocessor.dedupe_images([s.__dict__ for s in out])
170
+ return [NormalizedScrapeImage(**d) for d in deduped]
171
+
172
+ def _collect_reverse(self, results: Dict[str, ProviderResult]) -> List[NormalizedReverseMatch]:
173
+ out: List[NormalizedReverseMatch] = []
174
+ for r in results.values():
175
+ if not r.success or r.capability.value != "reverse_search":
176
+ continue
177
+ for m in r.normalized.get("results", []):
178
+ out.append(NormalizedReverseMatch(
179
+ image_url=m.get("image_url", ""),
180
+ source_page=m.get("source_page", ""),
181
+ title=m.get("title", ""),
182
+ snippet=m.get("snippet", ""),
183
+ thumbnail=m.get("thumbnail", ""),
184
+ provider=r.provider,
185
+ ))
186
+ return out
187
+
188
+ def _build_evidence(self, results: Dict[str, ProviderResult]) -> List[Evidence]:
189
+ out: List[Evidence] = []
190
+ for r in results.values():
191
+ out.append(Evidence(
192
+ provider=r.provider,
193
+ capability=r.capability.value,
194
+ raw=r.raw,
195
+ normalized=r.normalized,
196
+ elapsed_ms=r.elapsed_ms,
197
+ success=r.success,
198
+ error=r.error,
199
+ error_type=r.error_type,
200
+ metadata=r.metadata,
201
+ ))
202
+ return out
download/face-intel/normalization/schema.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Normalization-internal DTOs.
3
+
4
+ These are intermediate objects used by the merger before being promoted
5
+ to the cross-layer models in models/reports.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Optional
12
+
13
+
14
+ @dataclass
15
+ class NormalizedBox:
16
+ """One face box from one detector."""
17
+ x: int
18
+ y: int
19
+ w: int
20
+ h: int
21
+ confidence: float
22
+ detector: str
23
+ landmarks: Optional[dict] = None
24
+
25
+
26
+ @dataclass
27
+ class NormalizedMatch:
28
+ """One recognition match against the gallery."""
29
+ query_face_index: int
30
+ best_match: Optional[str]
31
+ distance: float
32
+ distances: Dict[str, float]
33
+ recognizer: str
34
+
35
+
36
+ @dataclass
37
+ class NormalizedScrapeImage:
38
+ url: str
39
+ alt: str
40
+ source_page: str
41
+ scraper: str
42
+ width: Optional[int] = None
43
+ height: Optional[int] = None
44
+
45
+
46
+ @dataclass
47
+ class NormalizedReverseMatch:
48
+ image_url: str
49
+ source_page: str
50
+ title: str
51
+ snippet: str
52
+ thumbnail: str
53
+ provider: str
download/face-intel/orchestrator/__init__.py CHANGED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestrator package — async fan-out across providers.
3
+
4
+ After the refactor, the orchestrator receives its dependencies
5
+ (registry, cache, metrics, settings) through its constructor. No
6
+ globals, no module-level state.
7
+ """
8
+
9
+ from orchestrator.runner import Orchestrator
10
+ from orchestrator.retry import RetryPolicy, with_retry_sync, with_retry_async
11
+ from orchestrator.health import HealthMonitor
12
+
13
+ __all__ = ["Orchestrator", "RetryPolicy", "with_retry_sync", "with_retry_async", "HealthMonitor"]
download/face-intel/orchestrator/health.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Health monitor — wraps HealthMetrics + scheduled checks.
3
+
4
+ For now this is a thin wrapper; future work can add a background task
5
+ that pings providers periodically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List
11
+
12
+ from metrics.health_metrics import HealthMetrics
13
+ from providers.base import Provider
14
+
15
+
16
+ class HealthMonitor:
17
+ """Reads/writes per-provider health and exposes a circuit-breaker gate."""
18
+
19
+ def __init__(self, metrics: HealthMetrics) -> None:
20
+ self._metrics = metrics
21
+
22
+ def record_success(self, provider_name: str, latency_ms: float) -> None:
23
+ self._metrics.record_success(provider_name, latency_ms)
24
+
25
+ def record_failure(self, provider_name: str) -> None:
26
+ self._metrics.record_failure(provider_name)
27
+
28
+ def is_available(self, provider_name: str) -> bool:
29
+ """Returns False if the circuit breaker is open for this provider."""
30
+ return not self._metrics.is_circuit_open(provider_name)
31
+
32
+ def filter_healthy(self, providers: List[Provider]) -> List[Provider]:
33
+ """Drop providers whose circuit is open."""
34
+ return [p for p in providers if self.is_available(p.name)]
35
+
36
+ def snapshot(self) -> list[dict]:
37
+ return self._metrics.snapshot()
download/face-intel/orchestrator/retry.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retry policy with exponential backoff + jitter.
3
+
4
+ Used by the orchestrator to wrap provider invocations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import random
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Awaitable, Callable, Tuple, Type
14
+
15
+ from loguru import logger
16
+
17
+
18
+ @dataclass
19
+ class RetryPolicy:
20
+ """Exponential backoff with full jitter."""
21
+ max_attempts: int = 3
22
+ initial_backoff_seconds: float = 0.5
23
+ max_backoff_seconds: float = 8.0
24
+ retriable_exceptions: Tuple[Type[BaseException], ...] = (
25
+ TimeoutError, ConnectionError, OSError,
26
+ )
27
+
28
+ def backoff(self, attempt: int) -> float:
29
+ """Compute backoff for the given attempt (1-indexed)."""
30
+ delay = self.initial_backoff_seconds * (2 ** (attempt - 1))
31
+ delay = min(delay, self.max_backoff_seconds)
32
+ # full jitter
33
+ return random.uniform(0, delay)
34
+
35
+
36
+ def with_retry_sync(
37
+ fn: Callable,
38
+ policy: RetryPolicy,
39
+ label: str = "",
40
+ on_retry: Callable[[int, Exception], None] | None = None,
41
+ ):
42
+ """Synchronous retry wrapper."""
43
+ attempt = 0
44
+ last_exc: Exception | None = None
45
+ while attempt < policy.max_attempts:
46
+ try:
47
+ attempt += 1
48
+ return fn()
49
+ except policy.retriable_exceptions as e:
50
+ last_exc = e
51
+ if attempt >= policy.max_attempts:
52
+ break
53
+ delay = policy.backoff(attempt)
54
+ if on_retry:
55
+ on_retry(attempt, e)
56
+ logger.warning(
57
+ f"[retry] {label} attempt {attempt}/{policy.max_attempts} "
58
+ f"failed: {e}; sleeping {delay:.2f}s"
59
+ )
60
+ time.sleep(delay)
61
+ raise last_exc # type: ignore
62
+
63
+
64
+ async def with_retry_async(
65
+ fn: Callable[[], Awaitable],
66
+ policy: RetryPolicy,
67
+ label: str = "",
68
+ on_retry: Callable[[int, Exception], None] | None = None,
69
+ ):
70
+ """Asynchronous retry wrapper."""
71
+ attempt = 0
72
+ last_exc: Exception | None = None
73
+ while attempt < policy.max_attempts:
74
+ try:
75
+ attempt += 1
76
+ return await fn()
77
+ except policy.retriable_exceptions as e:
78
+ last_exc = e
79
+ if attempt >= policy.max_attempts:
80
+ break
81
+ delay = policy.backoff(attempt)
82
+ if on_retry:
83
+ on_retry(attempt, e)
84
+ logger.warning(
85
+ f"[retry] {label} attempt {attempt}/{policy.max_attempts} "
86
+ f"failed: {e}; sleeping {delay:.2f}s"
87
+ )
88
+ await asyncio.sleep(delay)
89
+ raise last_exc # type: ignore
download/face-intel/orchestrator/runner.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestrator — async fan-out across providers.
3
+
4
+ Responsibilities:
5
+ - Receive a PipelineOutput (never raw user input).
6
+ - For each target provider:
7
+ * Skip if circuit breaker is open.
8
+ * Check cache; if hit, return cached ProviderResult.
9
+ * Otherwise invoke provider (sync → asyncio.to_thread).
10
+ * Apply retry policy.
11
+ * Record metrics (latency, success/failure, retry count).
12
+ * Cache successful results.
13
+ - Return a dict mapping provider name → ProviderResult.
14
+
15
+ All dependencies (registry, cache, metrics, health, retry policy) are
16
+ injected via the constructor.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from typing import Awaitable, Callable, Dict, Iterable, Optional
23
+
24
+ from loguru import logger
25
+
26
+ from config.settings import Settings
27
+ from metrics.collector import MetricsCollector
28
+ from orchestrator.health import HealthMonitor
29
+ from orchestrator.retry import RetryPolicy, with_retry_sync
30
+ from pipeline.feature_extraction import PipelineOutput
31
+ from providers.base import Provider, ProviderCapability, ProviderResult
32
+ from providers.registry import ProviderRegistry
33
+ from storage.cache import Cache
34
+ from utils.logging import execution_context, new_execution_id
35
+
36
+
37
+ class Orchestrator:
38
+ """Async fan-out across providers. Injectable."""
39
+
40
+ def __init__(
41
+ self,
42
+ registry: ProviderRegistry,
43
+ cache: Cache,
44
+ metrics: MetricsCollector,
45
+ health: HealthMonitor,
46
+ settings: Settings,
47
+ retry_policy: Optional[RetryPolicy] = None,
48
+ ) -> None:
49
+ self._registry = registry
50
+ self._cache = cache
51
+ self._metrics = metrics
52
+ self._health = health
53
+ self._settings = settings
54
+ self._retry = retry_policy or RetryPolicy(
55
+ max_attempts=settings.retry_max_attempts,
56
+ initial_backoff_seconds=settings.retry_initial_backoff_seconds,
57
+ max_backoff_seconds=settings.retry_max_backoff_seconds,
58
+ )
59
+
60
+ # ------------------------------------------------------------------ #
61
+ # Public API
62
+ # ------------------------------------------------------------------ #
63
+ async def run(
64
+ self,
65
+ pipeline_output: PipelineOutput,
66
+ capabilities: Iterable[ProviderCapability],
67
+ provider_whitelist: Optional[list[str]] = None,
68
+ execution_id: Optional[str] = None,
69
+ ) -> Dict[str, ProviderResult]:
70
+ """Fan out across all providers matching the given capabilities.
71
+
72
+ Args:
73
+ pipeline_output: normalized image + face crops.
74
+ capabilities: which capability buckets to invoke.
75
+ provider_whitelist: optional list of provider names; if set,
76
+ only those providers are invoked.
77
+ execution_id: optional trace id for structured logging.
78
+
79
+ Returns:
80
+ dict mapping provider name → ProviderResult.
81
+ """
82
+ eid = execution_id or new_execution_id()
83
+ targets: list[Provider] = []
84
+ for cap in capabilities:
85
+ for p in self._registry.list_by_capability(cap):
86
+ if provider_whitelist and p.name not in provider_whitelist:
87
+ continue
88
+ if not self._health.is_available(p.name):
89
+ logger.info(f"[orchestrator] skipping {p.name}: circuit open")
90
+ continue
91
+ targets.append(p)
92
+
93
+ if not targets:
94
+ logger.warning("[orchestrator] no providers to invoke")
95
+ return {}
96
+
97
+ semaphore = asyncio.Semaphore(self._settings.orchestrator_max_concurrency)
98
+
99
+ async def _wrapped(provider: Provider) -> tuple[str, ProviderResult]:
100
+ async with semaphore:
101
+ return provider.name, await self._invoke_one(provider, pipeline_output, eid)
102
+
103
+ tasks = [_wrapped(p) for p in targets]
104
+ gathered = await asyncio.gather(*tasks, return_exceptions=False)
105
+ return dict(gathered)
106
+
107
+ # ------------------------------------------------------------------ #
108
+ # Per-provider invocation (with cache + retry + metrics)
109
+ # ------------------------------------------------------------------ #
110
+ async def _invoke_one(
111
+ self,
112
+ provider: Provider,
113
+ pipeline_output: PipelineOutput,
114
+ execution_id: str,
115
+ ) -> ProviderResult:
116
+ cache_key = self._cache_key(provider, pipeline_output)
117
+
118
+ # Cache hit?
119
+ if self._settings.cache_enabled:
120
+ cached = self._cache.get(cache_key)
121
+ if cached is not None:
122
+ self._metrics.counters.inc("cache.hits")
123
+ logger.debug(f"[orchestrator] cache hit for {provider.name}")
124
+ cached.metadata["cache_hit"] = True
125
+ return cached
126
+ self._metrics.counters.inc("cache.misses")
127
+
128
+ # Invoke with retry
129
+ retry_count = 0
130
+
131
+ def _on_retry(attempt: int, exc: Exception) -> None:
132
+ nonlocal retry_count
133
+ retry_count = attempt
134
+ self._metrics.providers.record_retry(provider.name)
135
+ self._metrics.counters.inc(f"retries.{provider.name}")
136
+
137
+ with execution_context(execution_id=execution_id,
138
+ provider_id=provider.name,
139
+ retry_count=retry_count):
140
+ logger.info(f"[orchestrator] invoking {provider.name}")
141
+
142
+ def _call_sync() -> ProviderResult:
143
+ return with_retry_sync(
144
+ lambda: provider.execute(pipeline_output),
145
+ self._retry,
146
+ label=provider.name,
147
+ on_retry=_on_retry,
148
+ )
149
+
150
+ try:
151
+ result = await asyncio.wait_for(
152
+ asyncio.to_thread(_call_sync),
153
+ timeout=self._settings.orchestrator_timeout_seconds,
154
+ )
155
+ except asyncio.TimeoutError:
156
+ result = ProviderResult(
157
+ provider=provider.name,
158
+ capability=provider.capability,
159
+ success=False,
160
+ elapsed_ms=self._settings.orchestrator_timeout_seconds * 1000,
161
+ error="Orchestrator timeout",
162
+ error_type="TimeoutError",
163
+ retry_count=retry_count,
164
+ )
165
+ except Exception as e:
166
+ result = ProviderResult(
167
+ provider=provider.name,
168
+ capability=provider.capability,
169
+ success=False,
170
+ elapsed_ms=0.0,
171
+ error=str(e),
172
+ error_type=type(e).__name__,
173
+ retry_count=retry_count,
174
+ )
175
+
176
+ result.retry_count = retry_count
177
+
178
+ # Record metrics
179
+ self._metrics.providers.record_invocation(provider.name)
180
+ if result.success:
181
+ self._metrics.providers.record_success(provider.name, result.elapsed_ms)
182
+ self._health.record_success(provider.name, result.elapsed_ms)
183
+ self._metrics.timings.record(f"provider.{provider.name}", result.elapsed_ms)
184
+ # Cache
185
+ if self._settings.cache_enabled:
186
+ self._cache.set(cache_key, result)
187
+ else:
188
+ self._metrics.providers.record_failure(provider.name, result.error or "")
189
+ self._health.record_failure(provider.name)
190
+ self._metrics.counters.inc(f"failures.{provider.name}")
191
+
192
+ return result
193
+
194
+ # ------------------------------------------------------------------ #
195
+ # Cache key
196
+ # ------------------------------------------------------------------ #
197
+ @staticmethod
198
+ def _cache_key(provider: Provider, pipeline_output: PipelineOutput) -> str:
199
+ return f"{provider.name}:{pipeline_output.image_hash}"
download/face-intel/pipeline/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pipeline package — local processing stages that run BEFORE the
3
+ orchestrator fans out to providers.
4
+
5
+ Flow:
6
+ raw user input (bytes / URL / base64)
7
+
8
+ validation.py — InputValidator (reject bad input)
9
+
10
+ preprocessing.py — ImagePreprocessor (decode, resize, normalize)
11
+
12
+ hashing.py — ImageHasher (SHA-256 cache key)
13
+
14
+ feature_extraction.py — FeatureExtractor (face crops, default-detector boxes)
15
+
16
+ PipelineOutput (consumed by orchestrator)
17
+
18
+ The orchestrator receives a PipelineOutput, never raw user input.
19
+ """
20
+
21
+ from pipeline.validation import InputValidator, ValidationResult
22
+ from pipeline.preprocessing import ImagePreprocessor, PreprocessedImage
23
+ from pipeline.hashing import ImageHasher
24
+ from pipeline.feature_extraction import FeatureExtractor, PipelineOutput
25
+ from pipeline.postprocessing import ResultPostprocessor
26
+
27
+ __all__ = [
28
+ "InputValidator",
29
+ "ValidationResult",
30
+ "ImagePreprocessor",
31
+ "PreprocessedImage",
32
+ "ImageHasher",
33
+ "FeatureExtractor",
34
+ "PipelineOutput",
35
+ "ResultPostprocessor",
36
+ ]
download/face-intel/pipeline/feature_extraction.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature extraction — uses a default detection provider to pre-locate
3
+ faces and produce crops before the orchestrator fans out.
4
+
5
+ The orchestrator receives a PipelineOutput that already contains:
6
+ - preprocessed image
7
+ - image hash (cache key)
8
+ - face crops
9
+ - bounding boxes
10
+
11
+ This means recognition / reverse-search providers don't each have to
12
+ re-detect faces independently.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import List, Optional
19
+
20
+ import numpy as np
21
+ from loguru import logger
22
+
23
+ from providers.base import Provider, ProviderResult
24
+ from utils.image import BBox, crop_face
25
+
26
+
27
+ @dataclass
28
+ class FaceCrop:
29
+ """One pre-extracted face crop."""
30
+ image: np.ndarray
31
+ box: dict
32
+ confidence: float = 1.0
33
+ detector: str = ""
34
+
35
+
36
+ @dataclass
37
+ class PipelineOutput:
38
+ """The normalized payload the orchestrator consumes.
39
+
40
+ Optional fields (`gallery`, `scrape_url`) may be attached by services
41
+ that need to pass extra context to specific providers. Recognition
42
+ providers read `gallery`; scraper providers read `scrape_url`.
43
+ """
44
+ image: np.ndarray
45
+ image_hash: str
46
+ width: int
47
+ height: int
48
+ source: str
49
+ face_crops: List[FaceCrop] = field(default_factory=list)
50
+ primary_detector: str = ""
51
+ # Optional context attached by services (kept here so the orchestrator
52
+ # passes a single object through to every provider).
53
+ gallery: Optional[dict] = None
54
+ scrape_url: Optional[str] = None
55
+
56
+ @property
57
+ def num_faces(self) -> int:
58
+ return len(self.face_crops)
59
+
60
+
61
+ class FeatureExtractor:
62
+ """Uses a detection provider to pre-extract face crops."""
63
+
64
+ def __init__(self, detector: Optional[Provider] = None) -> None:
65
+ self._detector = detector
66
+
67
+ def set_detector(self, provider: Provider) -> None:
68
+ self._detector = provider
69
+
70
+ def extract(self, image: np.ndarray, image_hash: str,
71
+ width: int, height: int, source: str) -> PipelineOutput:
72
+ crops: List[FaceCrop] = []
73
+ detector_name = ""
74
+
75
+ if self._detector is not None and self._detector.is_available():
76
+ try:
77
+ result: ProviderResult = self._detector.execute(image)
78
+ if result.success and result.normalized.get("boxes"):
79
+ detector_name = result.provider
80
+ boxes = result.normalized["boxes"]
81
+ confs = result.normalized.get("confidences", [1.0] * len(boxes))
82
+ for box_dict, conf in zip(boxes, confs):
83
+ bbox = BBox(box_dict["x"], box_dict["y"],
84
+ box_dict["w"], box_dict["h"])
85
+ crop = crop_face(image, bbox, margin=0.2)
86
+ crops.append(FaceCrop(
87
+ image=crop,
88
+ box=box_dict,
89
+ confidence=float(conf),
90
+ detector=detector_name,
91
+ ))
92
+ except Exception as e:
93
+ logger.warning(f"Feature extraction failed: {e}")
94
+ else:
95
+ logger.debug("No detector available; pipeline output will have 0 face crops.")
96
+
97
+ return PipelineOutput(
98
+ image=image,
99
+ image_hash=image_hash,
100
+ width=width,
101
+ height=height,
102
+ source=source,
103
+ face_crops=crops,
104
+ primary_detector=detector_name,
105
+ )
download/face-intel/pipeline/hashing.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image hashing — produces a stable cache key for a preprocessed image.
3
+
4
+ The hash is computed on the JPEG-encoded bytes (quality 90) so that
5
+ visually identical inputs collapse to the same key.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+
12
+ import cv2
13
+ import numpy as np
14
+
15
+
16
+ class ImageHasher:
17
+ """SHA-256 over normalized JPEG bytes."""
18
+
19
+ @staticmethod
20
+ def hash(img: np.ndarray, quality: int = 90) -> str:
21
+ ok, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality])
22
+ if not ok:
23
+ raise ValueError("Could not encode image for hashing.")
24
+ return hashlib.sha256(buffer.tobytes()).hexdigest()
download/face-intel/pipeline/postprocessing.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Post-processing — applies final cleanup to provider results before
3
+ they enter normalization.
4
+
5
+ Examples:
6
+ - de-duplicate scraped image URLs
7
+ - clamp bounding boxes to image bounds
8
+ - strip PII from raw responses (placeholder for future policy hooks)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, List
14
+
15
+ from loguru import logger
16
+
17
+
18
+ class ResultPostprocessor:
19
+ """Cleans provider outputs before normalization."""
20
+
21
+ @staticmethod
22
+ def dedupe_images(images: List[dict]) -> List[dict]:
23
+ """Remove duplicate image URLs."""
24
+ seen: set[str] = set()
25
+ out: List[dict] = []
26
+ for img in images:
27
+ url = img.get("url") or img.get("image_url")
28
+ if not url or url in seen:
29
+ continue
30
+ seen.add(url)
31
+ out.append(img)
32
+ return out
33
+
34
+ @staticmethod
35
+ def clamp_boxes(boxes: List[dict], width: int, height: int) -> List[dict]:
36
+ """Clamp bounding boxes to image bounds."""
37
+ out: List[dict] = []
38
+ for b in boxes:
39
+ x = max(0, min(b["x"], width - 1))
40
+ y = max(0, min(b["y"], height - 1))
41
+ x2 = max(0, min(b["x"] + b["w"], width))
42
+ y2 = max(0, min(b["y"] + b["h"], height))
43
+ out.append({"x": x, "y": y, "w": max(0, x2 - x), "h": max(0, y2 - y)})
44
+ return out
45
+
46
+ @staticmethod
47
+ def filter_low_confidence(boxes: List[dict], confs: List[float],
48
+ threshold: float = 0.5) -> tuple[List[dict], List[float]]:
49
+ """Drop detections below confidence threshold."""
50
+ out_boxes, out_confs = [], []
51
+ for b, c in zip(boxes, confs):
52
+ if c >= threshold:
53
+ out_boxes.append(b)
54
+ out_confs.append(c)
55
+ return out_boxes, out_confs
download/face-intel/pipeline/preprocessing.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Preprocessing — decode, resize, color-convert.
3
+
4
+ Takes raw bytes (or URL) and produces a normalized BGR numpy array
5
+ suitable for every downstream provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ from loguru import logger
15
+
16
+ from utils.image import bytes_to_numpy, url_to_numpy, resize_with_aspect
17
+
18
+
19
+ @dataclass
20
+ class PreprocessedImage:
21
+ """Output of preprocessing — the canonical image object passed downstream."""
22
+ image: np.ndarray # BGR uint8 HxWx3
23
+ width: int
24
+ height: int
25
+ channels: int = 3
26
+ source: str = "" # "url" | "base64" | "bytes"
27
+ resized: bool = False # True if downscaled to fit max_dim
28
+
29
+
30
+ class ImagePreprocessor:
31
+ """Decodes + resizes inbound images."""
32
+
33
+ def __init__(self, max_dim: int = 1024) -> None:
34
+ self._max_dim = max_dim
35
+
36
+ def from_bytes(self, data: bytes, source: str = "bytes") -> PreprocessedImage:
37
+ img = bytes_to_numpy(data)
38
+ return self._finalize(img, source)
39
+
40
+ def from_url(self, url: str, timeout: int = 15) -> PreprocessedImage:
41
+ img = url_to_numpy(url, timeout=timeout)
42
+ return self._finalize(img, "url")
43
+
44
+ def from_numpy(self, img: np.ndarray, source: str = "in_memory") -> PreprocessedImage:
45
+ return self._finalize(img, source)
46
+
47
+ def _finalize(self, img: np.ndarray, source: str) -> PreprocessedImage:
48
+ # Ensure 3 channels
49
+ if img.ndim == 2:
50
+ img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) if (cv2 := __import__("cv2")) else img
51
+ elif img.shape[2] == 4:
52
+ import cv2
53
+ img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
54
+
55
+ resized = False
56
+ h, w = img.shape[:2]
57
+ if max(h, w) > self._max_dim:
58
+ img = resize_with_aspect(img, max_dim=self._max_dim)
59
+ resized = True
60
+
61
+ h, w = img.shape[:2]
62
+ return PreprocessedImage(
63
+ image=img,
64
+ width=w,
65
+ height=h,
66
+ channels=img.shape[2] if img.ndim == 3 else 1,
67
+ source=source,
68
+ resized=resized,
69
+ )
download/face-intel/pipeline/validation.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Input validation — rejects malformed requests before any heavy work.
3
+
4
+ Validates:
5
+ - At least one of (image_url, image_base64, image_bytes) is present
6
+ - image_url is a well-formed http(s) URL
7
+ - image_base64 is decodable
8
+ - Image size within configured limits
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from dataclasses import dataclass
15
+ from typing import Optional
16
+ from urllib.parse import urlparse
17
+
18
+ from loguru import logger
19
+
20
+
21
+ @dataclass
22
+ class ValidationResult:
23
+ valid: bool
24
+ error: Optional[str] = None
25
+ image_bytes: Optional[bytes] = None
26
+ source: str = "" # "url" | "base64" | "bytes"
27
+
28
+
29
+ class InputValidator:
30
+ """Validates inbound image input."""
31
+
32
+ def __init__(self, max_bytes: int = 20 * 1024 * 1024) -> None:
33
+ """max_bytes defaults to 20 MB."""
34
+ self._max_bytes = max_bytes
35
+
36
+ def validate(
37
+ self,
38
+ image_url: Optional[str] = None,
39
+ image_base64: Optional[str] = None,
40
+ image_bytes: Optional[bytes] = None,
41
+ ) -> ValidationResult:
42
+ # At least one input
43
+ if not any([image_url, image_base64, image_bytes]):
44
+ return ValidationResult(False, error="No image input provided.")
45
+
46
+ # URL validation
47
+ if image_url:
48
+ parsed = urlparse(image_url)
49
+ if parsed.scheme not in ("http", "https"):
50
+ return ValidationResult(False, error=f"Unsupported URL scheme: {parsed.scheme}")
51
+ if not parsed.netloc:
52
+ return ValidationResult(False, error="URL missing host.")
53
+ return ValidationResult(True, source="url")
54
+
55
+ # Base64 validation
56
+ if image_base64:
57
+ try:
58
+ # Strip data URI prefix if present
59
+ raw = image_base64.split(",", 1)[-1]
60
+ decoded = base64.b64decode(raw, validate=True)
61
+ except Exception as e:
62
+ return ValidationResult(False, error=f"Invalid base64: {e}")
63
+ if len(decoded) > self._max_bytes:
64
+ return ValidationResult(
65
+ False,
66
+ error=f"Decoded image exceeds {self._max_bytes} bytes",
67
+ )
68
+ return ValidationResult(True, image_bytes=decoded, source="base64")
69
+
70
+ # Raw bytes
71
+ if image_bytes:
72
+ if len(image_bytes) > self._max_bytes:
73
+ return ValidationResult(
74
+ False,
75
+ error=f"Image exceeds {self._max_bytes} bytes",
76
+ )
77
+ return ValidationResult(True, image_bytes=image_bytes, source="bytes")
78
+
79
+ return ValidationResult(False, error="Unreachable.")
download/face-intel/providers/__init__.py CHANGED
@@ -1,132 +1,34 @@
1
  """
2
- Provider package — auto-registers every enabled provider.
3
-
4
- Importing this module triggers construction + registration of every
5
- provider whose `enable_*` flag is true in settings. Optional providers
6
- (importable only if their optional dep is installed) are wrapped in
7
- try/except so a missing dep degrades gracefully to NOT_CONFIGURED.
 
 
 
 
 
 
8
  """
9
 
10
- from __future__ import annotations
11
-
12
- from loguru import logger
13
-
14
- from config import settings
15
  from providers.base import (
16
  Provider,
17
- ProviderCapability,
18
- ProviderStatus,
19
- register_provider,
20
- list_providers,
21
- get_provider,
22
- clear_registry,
 
23
  )
24
-
25
-
26
- def _safe_register(module_path: str, class_name: str, enable_flag: bool) -> None:
27
- """Import + register a provider; swallow ImportError if optional dep missing."""
28
- if not enable_flag:
29
- return
30
- try:
31
- import importlib
32
- module = importlib.import_module(module_path)
33
- cls = getattr(module, class_name)
34
- instance = cls()
35
- register_provider(instance)
36
- logger.info(f"Registered provider: {instance.name} ({instance.capability.value})")
37
- except ImportError as e:
38
- logger.warning(
39
- f"Provider {class_name} from {module_path} skipped (missing optional dep): {e}"
40
- )
41
- except Exception as e:
42
- logger.error(f"Failed to register {class_name}: {e}")
43
-
44
-
45
- def initialize_providers() -> None:
46
- """Construct and register every enabled provider. Call once at startup."""
47
- # Detection
48
- _safe_register("providers.detection.haar", "HaarDetector", settings.enable_haar)
49
- _safe_register("providers.detection.dnn", "DNNDetector", settings.enable_dnn)
50
- _safe_register("providers.detection.mtcnn", "MTCNNDetector", settings.enable_mtcnn)
51
- _safe_register("providers.detection.retinaface", "RetinaFaceDetector", settings.enable_retinaface)
52
-
53
- # Recognition
54
- _safe_register(
55
- "providers.recognition.face_recognition_provider", "FaceRecognitionProvider",
56
- settings.enable_face_recognition,
57
- )
58
- _safe_register(
59
- "providers.recognition.deepface_provider", "DeepFaceProvider",
60
- settings.enable_deepface,
61
- )
62
- _safe_register(
63
- "providers.recognition.insightface_provider", "InsightFaceProvider",
64
- settings.enable_insightface,
65
- )
66
-
67
- # Scraping
68
- _safe_register(
69
- "providers.scraper.beautifulsoup_scraper", "BeautifulSoupScraper",
70
- settings.enable_beautifulsoup_scraper,
71
- )
72
- _safe_register(
73
- "providers.scraper.selenium_scraper", "SeleniumScraper",
74
- settings.enable_selenium_scraper,
75
- )
76
- _safe_register(
77
- "providers.scraper.bing_scraper", "BingImageScraper",
78
- settings.enable_bing_scraper,
79
- )
80
- _safe_register(
81
- "providers.scraper.duckduckgo_scraper", "DuckDuckGoScraper",
82
- settings.enable_duckduckgo_scraper,
83
- )
84
-
85
- # Reverse image search
86
- _safe_register(
87
- "providers.reverse.google_lens", "GoogleLensProvider",
88
- settings.enable_google_lens,
89
- )
90
- _safe_register(
91
- "providers.reverse.serpapi", "SerpAPIProvider",
92
- settings.enable_serpapi,
93
- )
94
- _safe_register(
95
- "providers.reverse.yandex", "YandexReverseProvider",
96
- settings.enable_yandex,
97
- )
98
- _safe_register(
99
- "providers.reverse.tineye", "TinEyeProvider",
100
- settings.enable_tineye,
101
- )
102
-
103
-
104
- def provider_statuses() -> list[dict]:
105
- """Snapshot of every provider's availability + status."""
106
- out: list[dict] = []
107
- for p in list_providers():
108
- try:
109
- available = p.is_available()
110
- except Exception:
111
- available = False
112
- status = ProviderStatus.HEALTHY if available else ProviderStatus.NOT_CONFIGURED
113
- out.append({
114
- "name": p.name,
115
- "capability": p.capability.value,
116
- "status": status.value,
117
- "available": available,
118
- })
119
- return out
120
-
121
 
122
  __all__ = [
123
  "Provider",
124
- "ProviderCapability",
125
- "ProviderStatus",
126
- "initialize_providers",
127
- "list_providers",
128
- "get_provider",
129
- "clear_registry",
130
- "register_provider",
131
- "provider_statuses",
132
  ]
 
1
  """
2
+ Providers package.
3
+
4
+ After the refactor:
5
+ - base.py defines the Protocol + BaseProvider + ProviderResult
6
+ - registry.py defines ProviderRegistry (injectable, no globals)
7
+ - detection/ detection providers
8
+ - recognition/ recognition providers
9
+ - scraper/ web-scraping providers
10
+ - reverse/ reverse-image-search providers
11
+
12
+ There is NO module-level singleton. Construct a ProviderRegistry via
13
+ the DI container in api/deps.py.
14
  """
15
 
 
 
 
 
 
16
  from providers.base import (
17
  Provider,
18
+ BaseProvider,
19
+ ProviderResult,
20
+ )
21
+ from providers.registry import (
22
+ ProviderRegistry,
23
+ PROVIDER_MANIFEST,
24
+ ManifestEntry,
25
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  __all__ = [
28
  "Provider",
29
+ "BaseProvider",
30
+ "ProviderResult",
31
+ "ProviderRegistry",
32
+ "PROVIDER_MANIFEST",
33
+ "ManifestEntry",
 
 
 
34
  ]