Z User commited on
Commit ·
bbc3fdf
1
Parent(s): 25ee7a9
78fbc14f-966c-4b10-99c2-e2d762855856
Browse files- download/face-intel/api/__init__.py +0 -0
- download/face-intel/api/routes/__init__.py +0 -0
- download/face-intel/confidence/__init__.py +0 -0
- download/face-intel/config/__init__.py +23 -0
- download/face-intel/config/settings.py +159 -0
- download/face-intel/normalization/__init__.py +0 -0
- download/face-intel/orchestrator/__init__.py +0 -0
- download/face-intel/providers/__init__.py +132 -0
- download/face-intel/providers/base.py +195 -0
- download/face-intel/providers/detection/__init__.py +0 -0
- download/face-intel/providers/detection/haar.py +57 -0
- download/face-intel/providers/recognition/__init__.py +0 -0
- download/face-intel/providers/reverse/__init__.py +0 -0
- download/face-intel/providers/scraper/__init__.py +0 -0
- download/face-intel/storage/__init__.py +0 -0
- download/face-intel/ui/__init__.py +0 -0
- download/face-intel/utils/__init__.py +0 -0
- download/face-intel/utils/audit.py +41 -0
- download/face-intel/utils/http.py +47 -0
- download/face-intel/utils/image.py +168 -0
- download/face-intel/utils/timing.py +20 -0
- download/face-intelligence-api/config.py +0 -87
- download/face-intelligence-api/modules/__init__.py +0 -1
- download/face-intelligence-api/modules/face_detection.py +0 -261
- download/face-intelligence-api/modules/face_recognition.py +0 -255
- download/face-intelligence-api/modules/reverse_search.py +0 -283
- download/face-intelligence-api/modules/web_scraper.py +0 -235
- download/face-intelligence-api/requirements.txt +0 -37
- download/face-intelligence-api/utils/__init__.py +0 -1
- download/face-intelligence-api/utils/image_utils.py +0 -94
download/face-intel/api/__init__.py
ADDED
|
File without changes
|
download/face-intel/api/routes/__init__.py
ADDED
|
File without changes
|
download/face-intel/confidence/__init__.py
ADDED
|
File without changes
|
download/face-intel/config/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 8 |
+
GALLERY_DIR,
|
| 9 |
+
UPLOADS_DIR,
|
| 10 |
+
JOBS_DIR,
|
| 11 |
+
UI_DIR,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"settings",
|
| 16 |
+
"BASE_DIR",
|
| 17 |
+
"DATA_DIR",
|
| 18 |
+
"MODELS_DIR",
|
| 19 |
+
"GALLERY_DIR",
|
| 20 |
+
"UPLOADS_DIR",
|
| 21 |
+
"JOBS_DIR",
|
| 22 |
+
"UI_DIR",
|
| 23 |
+
]
|
download/face-intel/config/settings.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 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
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
from pydantic import Field
|
| 15 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 19 |
+
DATA_DIR = BASE_DIR / "data"
|
| 20 |
+
MODELS_DIR = DATA_DIR / "models"
|
| 21 |
+
GALLERY_DIR = DATA_DIR / "gallery"
|
| 22 |
+
UPLOADS_DIR = DATA_DIR / "uploads"
|
| 23 |
+
JOBS_DIR = DATA_DIR / "jobs"
|
| 24 |
+
UI_DIR = BASE_DIR / "ui" / "static"
|
| 25 |
+
|
| 26 |
+
# Ensure runtime dirs exist (idempotent)
|
| 27 |
+
for _d in (DATA_DIR, MODELS_DIR, GALLERY_DIR, UPLOADS_DIR, JOBS_DIR):
|
| 28 |
+
_d.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Settings(BaseSettings):
|
| 32 |
+
"""Environment-driven settings. All fields map to FI_* env vars."""
|
| 33 |
+
|
| 34 |
+
model_config = SettingsConfigDict(
|
| 35 |
+
env_file=".env",
|
| 36 |
+
env_prefix="FI_",
|
| 37 |
+
case_sensitive=False,
|
| 38 |
+
extra="ignore",
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# ------------------------------------------------------------------ #
|
| 42 |
+
# Core
|
| 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
|
| 50 |
+
|
| 51 |
+
# ------------------------------------------------------------------ #
|
| 52 |
+
# Provider enable flags
|
| 53 |
+
# ------------------------------------------------------------------ #
|
| 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
|
| 61 |
+
enable_beautifulsoup_scraper: bool = True
|
| 62 |
+
enable_selenium_scraper: bool = True
|
| 63 |
+
enable_bing_scraper: bool = False
|
| 64 |
+
enable_duckduckgo_scraper: bool = True
|
| 65 |
+
enable_google_lens: bool = True
|
| 66 |
+
enable_serpapi: bool = False
|
| 67 |
+
enable_yandex: bool = False
|
| 68 |
+
enable_tineye: bool = False
|
| 69 |
+
|
| 70 |
+
# ------------------------------------------------------------------ #
|
| 71 |
+
# Detection tuning
|
| 72 |
+
# ------------------------------------------------------------------ #
|
| 73 |
+
dnn_confidence_threshold: float = 0.7
|
| 74 |
+
mtcnn_min_face_size: int = 20
|
| 75 |
+
haar_scale_factor: float = 1.1
|
| 76 |
+
haar_min_neighbors: int = 5
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------------ #
|
| 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
|
| 89 |
+
# ------------------------------------------------------------------ #
|
| 90 |
+
scrape_timeout: int = 30
|
| 91 |
+
scrape_max_images: int = 50
|
| 92 |
+
selenium_headless: bool = True
|
| 93 |
+
selenium_implicit_wait: int = 10
|
| 94 |
+
selenium_scroll_iterations: int = 5
|
| 95 |
+
user_agent: str = (
|
| 96 |
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
| 97 |
+
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------ #
|
| 101 |
+
# Reverse image search
|
| 102 |
+
# ------------------------------------------------------------------ #
|
| 103 |
+
reverse_search_max_results: int = 20
|
| 104 |
+
serpapi_key: str = ""
|
| 105 |
+
bing_api_key: str = ""
|
| 106 |
+
tineye_public_key: str = ""
|
| 107 |
+
tineye_private_key: str = ""
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------ #
|
| 110 |
+
# Orchestrator
|
| 111 |
+
# ------------------------------------------------------------------ #
|
| 112 |
+
orchestrator_timeout_seconds: float = 90.0
|
| 113 |
+
orchestrator_max_concurrency: int = 8
|
| 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
|
| 123 |
+
# ------------------------------------------------------------------ #
|
| 124 |
+
cache_enabled: bool = True
|
| 125 |
+
cache_ttl_seconds: int = 3600
|
| 126 |
+
cache_max_entries: int = 1000
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------ #
|
| 129 |
+
# Health
|
| 130 |
+
# ------------------------------------------------------------------ #
|
| 131 |
+
health_check_interval_seconds: int = 60
|
| 132 |
+
circuit_breaker_failure_threshold: int = 5
|
| 133 |
+
circuit_breaker_recovery_seconds: int = 120
|
| 134 |
+
|
| 135 |
+
# ------------------------------------------------------------------ #
|
| 136 |
+
# Storage
|
| 137 |
+
# ------------------------------------------------------------------ #
|
| 138 |
+
db_path: str = str(DATA_DIR / "face_intel.db")
|
| 139 |
+
audit_log_path: str = str(DATA_DIR / "audit.log")
|
| 140 |
+
job_retention_days: int = 7
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------------ #
|
| 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 |
+
|
| 152 |
+
# ------------------------------------------------------------------ #
|
| 153 |
+
# Logging
|
| 154 |
+
# ------------------------------------------------------------------ #
|
| 155 |
+
log_level: str = "INFO"
|
| 156 |
+
log_json: bool = False
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
settings = Settings()
|
download/face-intel/normalization/__init__.py
ADDED
|
File without changes
|
download/face-intel/orchestrator/__init__.py
ADDED
|
File without changes
|
download/face-intel/providers/__init__.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
]
|
download/face-intel/providers/base.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Provider Framework — the contract every provider must satisfy.
|
| 3 |
+
|
| 4 |
+
Key design decisions (matching Phone Intel methodology):
|
| 5 |
+
|
| 6 |
+
1. **Provider Protocol, not ABC** — structural typing, no inheritance
|
| 7 |
+
ceremony. A provider is any object with the right methods.
|
| 8 |
+
|
| 9 |
+
2. **Completely isolated** — a provider only imports its own deps,
|
| 10 |
+
never imports from siblings. A failing optional dep (e.g.
|
| 11 |
+
`insightface` missing) must not affect other providers.
|
| 12 |
+
|
| 13 |
+
3. **ProviderResult** carries the raw response (preserved verbatim),
|
| 14 |
+
the normalized fields, the latency, and any error. Nothing is
|
| 15 |
+
discarded.
|
| 16 |
+
|
| 17 |
+
4. **ProviderHealth** is tracked by the orchestrator's circuit
|
| 18 |
+
breaker — a provider that fails N times in a row is shorted
|
| 19 |
+
until recovery.
|
| 20 |
+
|
| 21 |
+
5. **Capability flags** declare what a provider can do so the
|
| 22 |
+
orchestrator can route queries correctly (e.g. detection vs.
|
| 23 |
+
recognition vs. scraping vs. reverse-search).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import enum
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import Any, Optional, Protocol, runtime_checkable
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# --------------------------------------------------------------------------- #
|
| 35 |
+
# Capability & status enums
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
class ProviderCapability(str, enum.Enum):
|
| 38 |
+
DETECTION = "detection"
|
| 39 |
+
RECOGNITION = "recognition"
|
| 40 |
+
SCRAPING = "scraping"
|
| 41 |
+
REVERSE_SEARCH = "reverse_search"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ProviderStatus(str, enum.Enum):
|
| 45 |
+
HEALTHY = "healthy"
|
| 46 |
+
DEGRADED = "degraded" # working but slow / intermittent
|
| 47 |
+
UNHEALTHY = "unhealthy" # circuit breaker open
|
| 48 |
+
DISABLED = "disabled" # disabled by config
|
| 49 |
+
NOT_CONFIGURED = "not_configured" # missing API key / optional dep
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --------------------------------------------------------------------------- #
|
| 53 |
+
# Result objects
|
| 54 |
+
# --------------------------------------------------------------------------- #
|
| 55 |
+
@dataclass
|
| 56 |
+
class ProviderResult:
|
| 57 |
+
"""
|
| 58 |
+
The contract returned by every provider.
|
| 59 |
+
|
| 60 |
+
`raw` is preserved verbatim for evidence/audit.
|
| 61 |
+
`normalized` is provider-specific cleaned data (boxes, encodings,
|
| 62 |
+
image URLs, etc.) — see normalization/schema.py for the post-merge
|
| 63 |
+
unified schema.
|
| 64 |
+
"""
|
| 65 |
+
provider: str
|
| 66 |
+
capability: ProviderCapability
|
| 67 |
+
success: bool
|
| 68 |
+
elapsed_ms: float
|
| 69 |
+
raw: Any = None # untouched provider response
|
| 70 |
+
normalized: dict = field(default_factory=dict)
|
| 71 |
+
error: Optional[str] = None
|
| 72 |
+
error_type: Optional[str] = None
|
| 73 |
+
metadata: dict = field(default_factory=dict) # rate-limit info, warnings, etc.
|
| 74 |
+
|
| 75 |
+
def to_dict(self) -> dict:
|
| 76 |
+
return {
|
| 77 |
+
"provider": self.provider,
|
| 78 |
+
"capability": self.capability.value,
|
| 79 |
+
"success": self.success,
|
| 80 |
+
"elapsed_ms": self.elapsed_ms,
|
| 81 |
+
"raw": self.raw,
|
| 82 |
+
"normalized": self.normalized,
|
| 83 |
+
"error": self.error,
|
| 84 |
+
"error_type": self.error_type,
|
| 85 |
+
"metadata": self.metadata,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# --------------------------------------------------------------------------- #
|
| 90 |
+
# Provider Protocol
|
| 91 |
+
# --------------------------------------------------------------------------- #
|
| 92 |
+
@runtime_checkable
|
| 93 |
+
class Provider(Protocol):
|
| 94 |
+
"""
|
| 95 |
+
Structural contract for every provider.
|
| 96 |
+
|
| 97 |
+
A provider class must define:
|
| 98 |
+
- name: str — unique slug
|
| 99 |
+
- capability: ProviderCapability
|
| 100 |
+
- is_available() -> bool — quick config/dep check
|
| 101 |
+
- execute(input_data) -> ProviderResult
|
| 102 |
+
|
| 103 |
+
Providers may be sync or async. The orchestrator wraps sync calls
|
| 104 |
+
in `asyncio.to_thread`.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
name: str
|
| 108 |
+
capability: ProviderCapability
|
| 109 |
+
|
| 110 |
+
def is_available(self) -> bool: ...
|
| 111 |
+
|
| 112 |
+
def execute(self, input_data: Any) -> ProviderResult: ...
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --------------------------------------------------------------------------- #
|
| 116 |
+
# Base helper class (optional convenience — providers may subclass
|
| 117 |
+
# this OR implement the Protocol directly)
|
| 118 |
+
# --------------------------------------------------------------------------- #
|
| 119 |
+
class BaseProvider:
|
| 120 |
+
"""Convenience base with timing + error handling boilerplate."""
|
| 121 |
+
|
| 122 |
+
name: str = "base"
|
| 123 |
+
capability: ProviderCapability = ProviderCapability.DETECTION
|
| 124 |
+
|
| 125 |
+
def is_available(self) -> bool:
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
def execute(self, input_data: Any) -> ProviderResult: # pragma: no cover
|
| 129 |
+
raise NotImplementedError
|
| 130 |
+
|
| 131 |
+
# ------------------------------------------------------------------ #
|
| 132 |
+
# Helpers used by subclasses
|
| 133 |
+
# ------------------------------------------------------------------ #
|
| 134 |
+
def _run(self, input_data: Any) -> tuple[Any, dict]:
|
| 135 |
+
"""Subclass implements: returns (raw_response, normalized_dict)."""
|
| 136 |
+
raise NotImplementedError
|
| 137 |
+
|
| 138 |
+
def _safe_execute(self, input_data: Any) -> ProviderResult:
|
| 139 |
+
"""Wrap _run with timing + exception capture."""
|
| 140 |
+
t0 = time.perf_counter()
|
| 141 |
+
try:
|
| 142 |
+
if not self.is_available():
|
| 143 |
+
return ProviderResult(
|
| 144 |
+
provider=self.name,
|
| 145 |
+
capability=self.capability,
|
| 146 |
+
success=False,
|
| 147 |
+
elapsed_ms=0.0,
|
| 148 |
+
error="Provider not available (disabled or missing dependencies)",
|
| 149 |
+
error_type="NotConfigured",
|
| 150 |
+
)
|
| 151 |
+
raw, normalized = self._run(input_data)
|
| 152 |
+
elapsed = (time.perf_counter() - t0) * 1000.0
|
| 153 |
+
return ProviderResult(
|
| 154 |
+
provider=self.name,
|
| 155 |
+
capability=self.capability,
|
| 156 |
+
success=True,
|
| 157 |
+
elapsed_ms=round(elapsed, 3),
|
| 158 |
+
raw=raw,
|
| 159 |
+
normalized=normalized,
|
| 160 |
+
)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
elapsed = (time.perf_counter() - t0) * 1000.0
|
| 163 |
+
return ProviderResult(
|
| 164 |
+
provider=self.name,
|
| 165 |
+
capability=self.capability,
|
| 166 |
+
success=False,
|
| 167 |
+
elapsed_ms=round(elapsed, 3),
|
| 168 |
+
error=str(e),
|
| 169 |
+
error_type=type(e).__name__,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# --------------------------------------------------------------------------- #
|
| 174 |
+
# Provider registry
|
| 175 |
+
# --------------------------------------------------------------------------- #
|
| 176 |
+
_REGISTRY: dict[str, Provider] = {}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def register_provider(provider: Provider) -> Provider:
|
| 180 |
+
"""Register a provider instance. Idempotent."""
|
| 181 |
+
_REGISTRY[provider.name] = provider
|
| 182 |
+
return provider
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def get_provider(name: str) -> Optional[Provider]:
|
| 186 |
+
return _REGISTRY.get(name)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def list_providers() -> list[Provider]:
|
| 190 |
+
return list(_REGISTRY.values())
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def clear_registry() -> None:
|
| 194 |
+
"""Used by tests."""
|
| 195 |
+
_REGISTRY.clear()
|
download/face-intel/providers/detection/__init__.py
ADDED
|
File without changes
|
download/face-intel/providers/detection/haar.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OpenCV Haar Cascade face detector.
|
| 3 |
+
|
| 4 |
+
Verified facts (Phase 2):
|
| 5 |
+
- Cascade file ships with OpenCV at `cv2.data.haarcascades`.
|
| 6 |
+
- Returns rectangles only (no confidence, no landmarks).
|
| 7 |
+
- Fast (~5–15 ms CPU), poor on profile faces.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from config import settings
|
| 16 |
+
from providers.base import BaseProvider, ProviderCapability, ProviderResult
|
| 17 |
+
from utils.image import BBox
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class HaarDetector(BaseProvider):
|
| 21 |
+
name = "haar"
|
| 22 |
+
capability = ProviderCapability.DETECTION
|
| 23 |
+
|
| 24 |
+
def __init__(self) -> None:
|
| 25 |
+
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
| 26 |
+
self._cascade = cv2.CascadeClassifier(cascade_path)
|
| 27 |
+
if self._cascade.empty():
|
| 28 |
+
raise RuntimeError("Failed to load Haar cascade classifier.")
|
| 29 |
+
|
| 30 |
+
def is_available(self) -> bool:
|
| 31 |
+
return not self._cascade.empty()
|
| 32 |
+
|
| 33 |
+
def _run(self, img: np.ndarray) -> tuple[dict, dict]:
|
| 34 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 35 |
+
gray = cv2.equalizeHist(gray)
|
| 36 |
+
rects = self._cascade.detectMultiScale(
|
| 37 |
+
gray,
|
| 38 |
+
scaleFactor=settings.haar_scale_factor,
|
| 39 |
+
minNeighbors=settings.haar_min_neighbors,
|
| 40 |
+
minSize=(30, 30),
|
| 41 |
+
flags=cv2.CASCADE_SCALE_IMAGE,
|
| 42 |
+
)
|
| 43 |
+
boxes = [
|
| 44 |
+
BBox(int(x), int(y), int(w), int(h)).to_dict()
|
| 45 |
+
for (x, y, w, h) in rects
|
| 46 |
+
]
|
| 47 |
+
raw = {
|
| 48 |
+
"rectangles": [[int(x), int(y), int(w), int(h)] for (x, y, w, h) in rects],
|
| 49 |
+
"num_faces": len(rects),
|
| 50 |
+
}
|
| 51 |
+
normalized = {
|
| 52 |
+
"boxes": boxes,
|
| 53 |
+
"num_faces": len(boxes),
|
| 54 |
+
"landmarks": None, # Haar gives no landmarks
|
| 55 |
+
"confidences": [1.0] * len(boxes), # Haar gives no score
|
| 56 |
+
}
|
| 57 |
+
return raw, normalized
|
download/face-intel/providers/recognition/__init__.py
ADDED
|
File without changes
|
download/face-intel/providers/reverse/__init__.py
ADDED
|
File without changes
|
download/face-intel/providers/scraper/__init__.py
ADDED
|
File without changes
|
download/face-intel/storage/__init__.py
ADDED
|
File without changes
|
download/face-intel/ui/__init__.py
ADDED
|
File without changes
|
download/face-intel/utils/__init__.py
ADDED
|
File without changes
|
download/face-intel/utils/audit.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Append-only audit log for sensitive operations (identity resolution,
|
| 3 |
+
reverse image search, gallery mutations).
|
| 4 |
+
|
| 5 |
+
Format: one JSON object per line (JSONL).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from config import settings
|
| 16 |
+
from loguru import logger
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def audit_log(
|
| 20 |
+
action: str,
|
| 21 |
+
actor: str = "anonymous",
|
| 22 |
+
target: str | None = None,
|
| 23 |
+
outcome: str = "success",
|
| 24 |
+
details: dict[str, Any] | None = None,
|
| 25 |
+
) -> None:
|
| 26 |
+
"""Append a structured audit entry."""
|
| 27 |
+
entry = {
|
| 28 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 29 |
+
"action": action,
|
| 30 |
+
"actor": actor,
|
| 31 |
+
"target": target,
|
| 32 |
+
"outcome": outcome,
|
| 33 |
+
"details": details or {},
|
| 34 |
+
}
|
| 35 |
+
path = Path(settings.audit_log_path)
|
| 36 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
try:
|
| 38 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 39 |
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
| 40 |
+
except OSError as e:
|
| 41 |
+
logger.warning(f"Audit log write failed: {e}")
|
download/face-intel/utils/http.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared HTTP session and helpers for HTTP-based providers.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
from requests.adapters import HTTPAdapter
|
| 9 |
+
from urllib3.util.retry import Retry
|
| 10 |
+
|
| 11 |
+
from config import settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def make_session(
|
| 15 |
+
pool_connections: int = 10,
|
| 16 |
+
pool_maxsize: int = 10,
|
| 17 |
+
retries: int = 2,
|
| 18 |
+
backoff: float = 0.3,
|
| 19 |
+
) -> requests.Session:
|
| 20 |
+
"""Build a requests.Session with sensible connection pooling and retry."""
|
| 21 |
+
session = requests.Session()
|
| 22 |
+
session.headers.update({"User-Agent": settings.user_agent})
|
| 23 |
+
retry = Retry(
|
| 24 |
+
total=retries,
|
| 25 |
+
backoff_factor=backoff,
|
| 26 |
+
status_forcelist=[502, 503, 504],
|
| 27 |
+
allowed_methods=["GET", "POST", "HEAD"],
|
| 28 |
+
)
|
| 29 |
+
adapter = HTTPAdapter(
|
| 30 |
+
pool_connections=pool_connections,
|
| 31 |
+
pool_maxsize=pool_maxsize,
|
| 32 |
+
max_retries=retry,
|
| 33 |
+
)
|
| 34 |
+
session.mount("http://", adapter)
|
| 35 |
+
session.mount("https://", adapter)
|
| 36 |
+
return session
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Module-level shared session (lazy-initialized)
|
| 40 |
+
_session: requests.Session | None = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def shared_session() -> requests.Session:
|
| 44 |
+
global _session
|
| 45 |
+
if _session is None:
|
| 46 |
+
_session = make_session()
|
| 47 |
+
return _session
|
download/face-intel/utils/image.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image utility helpers used across every provider.
|
| 3 |
+
|
| 4 |
+
All providers receive and return BGR numpy arrays (OpenCV convention).
|
| 5 |
+
These helpers handle the boundary conversions: bytes <-> numpy,
|
| 6 |
+
base64 <-> numpy, URL -> numpy, image hashing, cropping, resizing,
|
| 7 |
+
and side-by-side annotation.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import hashlib
|
| 14 |
+
import io
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional, Tuple
|
| 18 |
+
|
| 19 |
+
import cv2
|
| 20 |
+
import numpy as np
|
| 21 |
+
from PIL import Image
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# --------------------------------------------------------------------------- #
|
| 25 |
+
# Decode / encode
|
| 26 |
+
# --------------------------------------------------------------------------- #
|
| 27 |
+
def bytes_to_numpy(image_bytes: bytes) -> np.ndarray:
|
| 28 |
+
"""Decode raw image bytes into an OpenCV BGR numpy array."""
|
| 29 |
+
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 30 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 31 |
+
if img is None:
|
| 32 |
+
raise ValueError("Could not decode image bytes. Unsupported format or corrupted data.")
|
| 33 |
+
return img
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def base64_to_numpy(b64_string: str) -> np.ndarray:
|
| 37 |
+
"""Decode a base64-encoded image string into a BGR numpy array."""
|
| 38 |
+
if "," in b64_string:
|
| 39 |
+
b64_string = b64_string.split(",", 1)[1]
|
| 40 |
+
raw = base64.b64decode(b64_string)
|
| 41 |
+
return bytes_to_numpy(raw)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def numpy_to_base64(img: np.ndarray, fmt: str = ".jpg", quality: int = 85) -> str:
|
| 45 |
+
"""Encode a BGR numpy array as a base64 string."""
|
| 46 |
+
params = []
|
| 47 |
+
if fmt.lower() in (".jpg", ".jpeg"):
|
| 48 |
+
params = [cv2.IMWRITE_JPEG_QUALITY, quality]
|
| 49 |
+
ok, buffer = cv2.imencode(fmt, img, params)
|
| 50 |
+
if not ok:
|
| 51 |
+
raise ValueError("Could not encode image.")
|
| 52 |
+
return base64.b64encode(buffer).decode("utf-8")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def save_image(img: np.ndarray, path: Path, fmt: str = ".jpg") -> Path:
|
| 56 |
+
"""Save an image to disk. Returns the path."""
|
| 57 |
+
path = Path(path)
|
| 58 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
cv2.imwrite(str(path), img)
|
| 60 |
+
return path
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# --------------------------------------------------------------------------- #
|
| 64 |
+
# Hashing (used as cache key)
|
| 65 |
+
# --------------------------------------------------------------------------- #
|
| 66 |
+
def image_hash(img: np.ndarray) -> str:
|
| 67 |
+
"""SHA-256 hash of the JPEG-encoded image — stable cache key."""
|
| 68 |
+
ok, buffer = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
| 69 |
+
if not ok:
|
| 70 |
+
raise ValueError("Could not encode image for hashing.")
|
| 71 |
+
return hashlib.sha256(buffer.tobytes()).hexdigest()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def bytes_hash(data: bytes) -> str:
|
| 75 |
+
"""SHA-256 hash of raw bytes."""
|
| 76 |
+
return hashlib.sha256(data).hexdigest()
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# --------------------------------------------------------------------------- #
|
| 80 |
+
# Geometry
|
| 81 |
+
# --------------------------------------------------------------------------- #
|
| 82 |
+
@dataclass
|
| 83 |
+
class BBox:
|
| 84 |
+
"""Axis-aligned bounding box."""
|
| 85 |
+
x: int
|
| 86 |
+
y: int
|
| 87 |
+
w: int
|
| 88 |
+
h: int
|
| 89 |
+
|
| 90 |
+
def to_dict(self) -> dict:
|
| 91 |
+
return {"x": self.x, "y": self.y, "w": self.w, "h": self.h}
|
| 92 |
+
|
| 93 |
+
@property
|
| 94 |
+
def area(self) -> int:
|
| 95 |
+
return self.w * self.h
|
| 96 |
+
|
| 97 |
+
def to_face_recognition_tuple(self) -> Tuple[int, int, int, int]:
|
| 98 |
+
"""Convert to (top, right, bottom, left) tuple used by face_recognition."""
|
| 99 |
+
return (self.y, self.x + self.w, self.y + self.h, self.x)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def crop_face(img: np.ndarray, bbox: BBox, margin: float = 0.2) -> np.ndarray:
|
| 103 |
+
"""Crop a face region with optional fractional margin."""
|
| 104 |
+
x0 = max(0, bbox.x - int(bbox.w * margin))
|
| 105 |
+
y0 = max(0, bbox.y - int(bbox.h * margin))
|
| 106 |
+
x1 = min(img.shape[1], bbox.x + bbox.w + int(bbox.w * margin))
|
| 107 |
+
y1 = min(img.shape[0], bbox.y + bbox.h + int(bbox.h * margin))
|
| 108 |
+
return img[y0:y1, x0:x1]
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def resize_with_aspect(img: np.ndarray, max_dim: int = 1024) -> np.ndarray:
|
| 112 |
+
"""Resize so the longest side is at most max_dim, preserving aspect."""
|
| 113 |
+
h, w = img.shape[:2]
|
| 114 |
+
if max(h, w) <= max_dim:
|
| 115 |
+
return img
|
| 116 |
+
scale = max_dim / max(h, w)
|
| 117 |
+
return cv2.resize(img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# --------------------------------------------------------------------------- #
|
| 121 |
+
# Network I/O
|
| 122 |
+
# --------------------------------------------------------------------------- #
|
| 123 |
+
def url_to_numpy(url: str, timeout: int = 15) -> np.ndarray:
|
| 124 |
+
"""Download an image from a URL and return it as a BGR numpy array."""
|
| 125 |
+
import requests
|
| 126 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 127 |
+
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 128 |
+
resp.raise_for_status()
|
| 129 |
+
return bytes_to_numpy(resp.content)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def url_to_bytes(url: str, timeout: int = 15) -> bytes:
|
| 133 |
+
"""Download a URL and return raw bytes."""
|
| 134 |
+
import requests
|
| 135 |
+
headers = {"User-Agent": "Mozilla/5.0"}
|
| 136 |
+
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 137 |
+
resp.raise_for_status()
|
| 138 |
+
return resp.content
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def validate_image_file(path: Path) -> bool:
|
| 142 |
+
"""Validate that a file is a readable image (PIL verify)."""
|
| 143 |
+
try:
|
| 144 |
+
with Image.open(path) as im:
|
| 145 |
+
im.verify()
|
| 146 |
+
return True
|
| 147 |
+
except Exception:
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# --------------------------------------------------------------------------- #
|
| 152 |
+
# Drawing helpers (for UI annotations)
|
| 153 |
+
# --------------------------------------------------------------------------- #
|
| 154 |
+
def draw_boxes(
|
| 155 |
+
img: np.ndarray,
|
| 156 |
+
boxes: list,
|
| 157 |
+
color: Tuple[int, int, int] = (0, 255, 0),
|
| 158 |
+
thickness: int = 2,
|
| 159 |
+
) -> np.ndarray:
|
| 160 |
+
"""Draw a list of BBox (or dict) onto a copy of the image."""
|
| 161 |
+
out = img.copy()
|
| 162 |
+
for b in boxes:
|
| 163 |
+
if isinstance(b, dict):
|
| 164 |
+
b = BBox(b["x"], b["y"], b["w"], b["h"])
|
| 165 |
+
elif not isinstance(b, BBox):
|
| 166 |
+
b = BBox(*b)
|
| 167 |
+
cv2.rectangle(out, (b.x, b.y), (b.x + b.w, b.y + b.h), color, thickness)
|
| 168 |
+
return out
|
download/face-intel/utils/timing.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Timing utility — context manager and decorator for measuring latency.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
from typing import Iterator
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@contextmanager
|
| 13 |
+
def timed(label: str = "") -> Iterator[dict]:
|
| 14 |
+
"""Context manager that yields a dict and fills it with elapsed ms."""
|
| 15 |
+
info: dict = {"label": label, "elapsed_ms": 0.0}
|
| 16 |
+
t0 = time.perf_counter()
|
| 17 |
+
try:
|
| 18 |
+
yield info
|
| 19 |
+
finally:
|
| 20 |
+
info["elapsed_ms"] = round((time.perf_counter() - t0) * 1000.0, 3)
|
download/face-intelligence-api/config.py
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Configuration module for the Face Intelligence API.
|
| 3 |
-
Centralizes all settings and provides ethical-use enforcement flags.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from pydantic_settings import BaseSettings
|
| 8 |
-
from pydantic import Field
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
BASE_DIR = Path(__file__).resolve().parent
|
| 12 |
-
DATA_DIR = BASE_DIR / "data"
|
| 13 |
-
KNOWN_FACES_DIR = DATA_DIR / "known_faces"
|
| 14 |
-
TEMP_DIR = DATA_DIR / "temp"
|
| 15 |
-
UPLOADS_DIR = DATA_DIR / "uploads"
|
| 16 |
-
MODELS_DIR = DATA_DIR / "models"
|
| 17 |
-
|
| 18 |
-
for _d in (KNOWN_FACES_DIR, TEMP_DIR, UPLOADS_DIR, MODELS_DIR):
|
| 19 |
-
_d.mkdir(parents=True, exist_ok=True)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
class Settings(BaseSettings):
|
| 23 |
-
"""Runtime settings, overridable via environment variables or .env file."""
|
| 24 |
-
|
| 25 |
-
# --- API ---
|
| 26 |
-
app_name: str = "Face Intelligence API"
|
| 27 |
-
app_version: str = "1.0.0"
|
| 28 |
-
host: str = "0.0.0.0"
|
| 29 |
-
port: int = 8000
|
| 30 |
-
debug: bool = False
|
| 31 |
-
|
| 32 |
-
# --- Face Detection ---
|
| 33 |
-
# Options: "haar", "dnn", "mtcnn"
|
| 34 |
-
default_detector: str = "dnn"
|
| 35 |
-
# DNN model files (downloaded automatically on first run)
|
| 36 |
-
dnn_prototxt_url: str = (
|
| 37 |
-
"https://raw.githubusercontent.com/opencv/opencv_3rdparty"
|
| 38 |
-
"/dnn_samples_face_detector_20170830/deploy.prototxt"
|
| 39 |
-
)
|
| 40 |
-
dnn_model_url: str = (
|
| 41 |
-
"https://raw.githubusercontent.com/opencv/opencv_3rdparty"
|
| 42 |
-
"/dnn_samples_face_detector_20170830/res10_300x300_ssd_iter_140000.caffemodel"
|
| 43 |
-
)
|
| 44 |
-
dnn_confidence_threshold: float = 0.7
|
| 45 |
-
|
| 46 |
-
# Haar cascade file (ships with OpenCV)
|
| 47 |
-
haar_cascade_path: str = str(
|
| 48 |
-
Path(cv2.__file__).parent / "data" / "haarcascade_frontalface_default.xml"
|
| 49 |
-
) if (cv2 := __import__("cv2", globals(), locals(), [], 0)) else "haarcascade_frontalface_default.xml"
|
| 50 |
-
|
| 51 |
-
# --- Face Recognition ---
|
| 52 |
-
face_recognition_tolerance: float = 0.6 # lower = stricter
|
| 53 |
-
face_recognition_model: str = "hog" # "hog" (fast) or "cnn" (accurate, GPU)
|
| 54 |
-
|
| 55 |
-
# --- Web Scraping ---
|
| 56 |
-
scrape_timeout: int = 30
|
| 57 |
-
scrape_max_images: int = 50
|
| 58 |
-
selenium_headless: bool = True
|
| 59 |
-
selenium_implicit_wait: int = 10
|
| 60 |
-
user_agent: str = (
|
| 61 |
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
| 62 |
-
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
|
| 63 |
-
)
|
| 64 |
-
|
| 65 |
-
# --- Reverse Image Search ---
|
| 66 |
-
# Method: "google_lens" (default, free, via Selenium) or "serpapi" (paid)
|
| 67 |
-
reverse_search_method: str = "google_lens"
|
| 68 |
-
serpapi_key: str = ""
|
| 69 |
-
reverse_search_max_results: int = 20
|
| 70 |
-
|
| 71 |
-
# --- Ethical Guardrails (DO NOT disable in production) ---
|
| 72 |
-
# Require an explicit authorization token for identity-resolution endpoints
|
| 73 |
-
require_authorization: bool = True
|
| 74 |
-
authorization_token: str = "CHANGE_ME_IN_PRODUCTION"
|
| 75 |
-
# Require consent acknowledgment header for online lookup endpoints
|
| 76 |
-
require_consent_header: bool = True
|
| 77 |
-
# Rate limiting (requests per minute per IP)
|
| 78 |
-
rate_limit_per_minute: int = 20
|
| 79 |
-
# Log all identity-resolution operations for audit
|
| 80 |
-
audit_log_path: str = str(DATA_DIR / "audit.log")
|
| 81 |
-
|
| 82 |
-
class Config:
|
| 83 |
-
env_file = ".env"
|
| 84 |
-
env_prefix = "FI_"
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
settings = Settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/modules/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Modules package."""
|
|
|
|
|
|
download/face-intelligence-api/modules/face_detection.py
DELETED
|
@@ -1,261 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Face Detection Module
|
| 3 |
-
=====================
|
| 4 |
-
Provides three interchangeable face-detection backends:
|
| 5 |
-
|
| 6 |
-
1. Haar Cascade - fastest, least accurate (ships with OpenCV)
|
| 7 |
-
2. DNN (Caffe) - good accuracy/speed tradeoff (default)
|
| 8 |
-
3. MTCNN - high accuracy, slower, detects landmarks
|
| 9 |
-
|
| 10 |
-
All detectors return a normalized list of FaceBox dataclasses so the
|
| 11 |
-
rest of the pipeline is detector-agnostic.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import urllib.request
|
| 17 |
-
from dataclasses import dataclass, asdict
|
| 18 |
-
from pathlib import Path
|
| 19 |
-
from typing import List, Optional
|
| 20 |
-
|
| 21 |
-
import cv2
|
| 22 |
-
import numpy as np
|
| 23 |
-
|
| 24 |
-
from config import settings, MODELS_DIR
|
| 25 |
-
from utils.image_utils import crop_face
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
# --------------------------------------------------------------------------- #
|
| 29 |
-
# Data model
|
| 30 |
-
# --------------------------------------------------------------------------- #
|
| 31 |
-
@dataclass
|
| 32 |
-
class FaceBox:
|
| 33 |
-
"""A single detected face."""
|
| 34 |
-
x: int
|
| 35 |
-
y: int
|
| 36 |
-
w: int
|
| 37 |
-
h: int
|
| 38 |
-
confidence: float
|
| 39 |
-
detector: str
|
| 40 |
-
landmarks: Optional[dict] = None # eyes, nose, mouth corners (MTCNN only)
|
| 41 |
-
|
| 42 |
-
def to_dict(self) -> dict:
|
| 43 |
-
return asdict(self)
|
| 44 |
-
|
| 45 |
-
@property
|
| 46 |
-
def area(self) -> int:
|
| 47 |
-
return self.w * self.h
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
# --------------------------------------------------------------------------- #
|
| 51 |
-
# Base detector
|
| 52 |
-
# --------------------------------------------------------------------------- #
|
| 53 |
-
class BaseDetector:
|
| 54 |
-
name: str = "base"
|
| 55 |
-
|
| 56 |
-
def detect(self, img: np.ndarray) -> List[FaceBox]:
|
| 57 |
-
raise NotImplementedError
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# --------------------------------------------------------------------------- #
|
| 61 |
-
# 1. Haar Cascade Detector
|
| 62 |
-
# --------------------------------------------------------------------------- #
|
| 63 |
-
class HaarCascadeDetector(BaseDetector):
|
| 64 |
-
"""Classic Viola-Jones cascade. Very fast, struggles with angles/lighting."""
|
| 65 |
-
|
| 66 |
-
name = "haar"
|
| 67 |
-
|
| 68 |
-
def __init__(self):
|
| 69 |
-
cascade_path = settings.haar_cascade_path
|
| 70 |
-
if not Path(cascade_path).exists():
|
| 71 |
-
# Fallback: try OpenCV's bundled path
|
| 72 |
-
cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
|
| 73 |
-
self.cascade = cv2.CascadeClassifier(cascade_path)
|
| 74 |
-
if self.cascade.empty():
|
| 75 |
-
raise RuntimeError("Failed to load Haar cascade classifier.")
|
| 76 |
-
|
| 77 |
-
def detect(self, img: np.ndarray) -> List[FaceBox]:
|
| 78 |
-
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 79 |
-
gray = cv2.equalizeHist(gray)
|
| 80 |
-
rects = self.cascade.detectMultiScale(
|
| 81 |
-
gray,
|
| 82 |
-
scaleFactor=1.1,
|
| 83 |
-
minNeighbors=5,
|
| 84 |
-
minSize=(30, 30),
|
| 85 |
-
flags=cv2.CASCADE_SCALE_IMAGE,
|
| 86 |
-
)
|
| 87 |
-
boxes: List[FaceBox] = []
|
| 88 |
-
for (x, y, w, h) in rects:
|
| 89 |
-
boxes.append(FaceBox(int(x), int(y), int(w), int(h), 1.0, self.name))
|
| 90 |
-
return boxes
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
# --------------------------------------------------------------------------- #
|
| 94 |
-
# 2. DNN (Caffe SSD) Detector
|
| 95 |
-
# --------------------------------------------------------------------------- #
|
| 96 |
-
class DNNDetector(BaseDetector):
|
| 97 |
-
"""OpenCV's DNN module with the SSD Caffe face model. Recommended default."""
|
| 98 |
-
|
| 99 |
-
name = "dnn"
|
| 100 |
-
|
| 101 |
-
PROTOTXT = MODELS_DIR / "deploy.prototxt"
|
| 102 |
-
CAFFEMODEL = MODELS_DIR / "res10_300x300_ssd_iter_140000.caffemodel"
|
| 103 |
-
|
| 104 |
-
def __init__(self):
|
| 105 |
-
self._download_models()
|
| 106 |
-
self.net = cv2.dnn.readNetFromCaffe(str(self.PROTOTXT), str(self.CAFFEMODEL))
|
| 107 |
-
# Use GPU if available
|
| 108 |
-
try:
|
| 109 |
-
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
|
| 110 |
-
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)
|
| 111 |
-
except Exception:
|
| 112 |
-
pass # CPU fallback
|
| 113 |
-
|
| 114 |
-
def _download_models(self):
|
| 115 |
-
if not self.PROTOTXT.exists():
|
| 116 |
-
print("[DNN] Downloading prototxt...")
|
| 117 |
-
urllib.request.urlretrieve(settings.dnn_prototxt_url, self.PROTOTXT)
|
| 118 |
-
if not self.CAFFEMODEL.exists():
|
| 119 |
-
print("[DNN] Downloading caffemodel (this may take a moment)...")
|
| 120 |
-
urllib.request.urlretrieve(settings.dnn_model_url, self.CAFFEMODEL)
|
| 121 |
-
|
| 122 |
-
def detect(self, img: np.ndarray) -> List[FaceBox]:
|
| 123 |
-
h, w = img.shape[:2]
|
| 124 |
-
blob = cv2.dnn.blobFromImage(
|
| 125 |
-
cv2.resize(img, (300, 300)),
|
| 126 |
-
1.0,
|
| 127 |
-
(300, 300),
|
| 128 |
-
(104.0, 177.0, 123.0),
|
| 129 |
-
)
|
| 130 |
-
self.net.setInput(blob)
|
| 131 |
-
detections = self.net.forward()
|
| 132 |
-
|
| 133 |
-
boxes: List[FaceBox] = []
|
| 134 |
-
for i in range(detections.shape[2]):
|
| 135 |
-
confidence = detections[0, 0, i, 2]
|
| 136 |
-
if confidence < settings.dnn_confidence_threshold:
|
| 137 |
-
continue
|
| 138 |
-
x1 = int(detections[0, 0, i, 3] * w)
|
| 139 |
-
y1 = int(detections[0, 0, i, 4] * h)
|
| 140 |
-
x2 = int(detections[0, 0, i, 5] * w)
|
| 141 |
-
y2 = int(detections[0, 0, i, 6] * h)
|
| 142 |
-
boxes.append(
|
| 143 |
-
FaceBox(
|
| 144 |
-
x=x1, y=y1, w=x2 - x1, h=y2 - y1,
|
| 145 |
-
confidence=float(confidence), detector=self.name,
|
| 146 |
-
)
|
| 147 |
-
)
|
| 148 |
-
return boxes
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
# --------------------------------------------------------------------------- #
|
| 152 |
-
# 3. MTCNN Detector
|
| 153 |
-
# --------------------------------------------------------------------------- #
|
| 154 |
-
class MTCNNDetector(BaseDetector):
|
| 155 |
-
"""MTCNN: high accuracy, also returns 5 facial landmarks."""
|
| 156 |
-
|
| 157 |
-
name = "mtcnn"
|
| 158 |
-
|
| 159 |
-
def __init__(self):
|
| 160 |
-
try:
|
| 161 |
-
from mtcnn.mtcnn import MTCNN
|
| 162 |
-
except ImportError as e:
|
| 163 |
-
raise ImportError(
|
| 164 |
-
"MTCNN requires the 'mtcnn' and 'tensorflow' packages. "
|
| 165 |
-
"Install with: pip install mtcnn tensorflow"
|
| 166 |
-
) from e
|
| 167 |
-
self.detector = MTCNN()
|
| 168 |
-
|
| 169 |
-
def detect(self, img: np.ndarray) -> List[FaceBox]:
|
| 170 |
-
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 171 |
-
results = self.detector.detect_faces(rgb)
|
| 172 |
-
boxes: List[FaceBox] = []
|
| 173 |
-
for r in results:
|
| 174 |
-
x, y, w, h = r["box"]
|
| 175 |
-
boxes.append(
|
| 176 |
-
FaceBox(
|
| 177 |
-
x=int(x), y=int(y), w=int(w), h=int(h),
|
| 178 |
-
confidence=float(r["confidence"]), detector=self.name,
|
| 179 |
-
landmarks={
|
| 180 |
-
"left_eye": r.get("keypoints", {}).get("left_eye"),
|
| 181 |
-
"right_eye": r.get("keypoints", {}).get("right_eye"),
|
| 182 |
-
"nose": r.get("keypoints", {}).get("nose"),
|
| 183 |
-
"mouth_left": r.get("keypoints", {}).get("mouth_left"),
|
| 184 |
-
"mouth_right": r.get("keypoints", {}).get("mouth_right"),
|
| 185 |
-
},
|
| 186 |
-
)
|
| 187 |
-
)
|
| 188 |
-
return boxes
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
# --------------------------------------------------------------------------- #
|
| 192 |
-
# Factory + high-level API
|
| 193 |
-
# --------------------------------------------------------------------------- #
|
| 194 |
-
_DETECTORS: dict = {}
|
| 195 |
-
|
| 196 |
-
def get_detector(name: Optional[str] = None) -> BaseDetector:
|
| 197 |
-
"""Get a cached detector instance by name."""
|
| 198 |
-
name = name or settings.default_detector
|
| 199 |
-
if name not in _DETECTORS:
|
| 200 |
-
if name == "haar":
|
| 201 |
-
_DETECTORS[name] = HaarCascadeDetector()
|
| 202 |
-
elif name == "dnn":
|
| 203 |
-
_DETECTORS[name] = DNNDetector()
|
| 204 |
-
elif name == "mtcnn":
|
| 205 |
-
_DETECTORS[name] = MTCNNDetector()
|
| 206 |
-
else:
|
| 207 |
-
raise ValueError(f"Unknown detector: {name}. Use 'haar', 'dnn', or 'mtcnn'.")
|
| 208 |
-
return _DETECTORS[name]
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def detect_faces(
|
| 212 |
-
img: np.ndarray,
|
| 213 |
-
detector_name: Optional[str] = None,
|
| 214 |
-
draw: bool = False,
|
| 215 |
-
) -> dict:
|
| 216 |
-
"""Detect faces and optionally return an annotated image.
|
| 217 |
-
|
| 218 |
-
Returns:
|
| 219 |
-
{
|
| 220 |
-
"detector": str,
|
| 221 |
-
"num_faces": int,
|
| 222 |
-
"faces": List[dict],
|
| 223 |
-
"annotated_image_base64": str | None,
|
| 224 |
-
}
|
| 225 |
-
"""
|
| 226 |
-
detector = get_detector(detector_name)
|
| 227 |
-
boxes = detector.detect(img)
|
| 228 |
-
|
| 229 |
-
annotated_b64 = None
|
| 230 |
-
if draw:
|
| 231 |
-
annotated = img.copy()
|
| 232 |
-
for box in boxes:
|
| 233 |
-
cv2.rectangle(
|
| 234 |
-
annotated, (box.x, box.y), (box.x + box.w, box.y + box.h),
|
| 235 |
-
(0, 255, 0), 2,
|
| 236 |
-
)
|
| 237 |
-
label = f"{box.detector} {box.confidence:.2f}"
|
| 238 |
-
cv2.putText(
|
| 239 |
-
annotated, label, (box.x, max(box.y - 8, 12)),
|
| 240 |
-
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1,
|
| 241 |
-
)
|
| 242 |
-
if box.landmarks:
|
| 243 |
-
for pt in box.landmarks.values():
|
| 244 |
-
if pt:
|
| 245 |
-
cv2.circle(annotated, pt, 3, (0, 0, 255), -1)
|
| 246 |
-
from utils.image_utils import numpy_to_base64
|
| 247 |
-
annotated_b64 = numpy_to_base64(annotated)
|
| 248 |
-
|
| 249 |
-
return {
|
| 250 |
-
"detector": detector.name,
|
| 251 |
-
"num_faces": len(boxes),
|
| 252 |
-
"faces": [b.to_dict() for b in boxes],
|
| 253 |
-
"annotated_image_base64": annotated_b64,
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def extract_face_crops(img: np.ndarray, detector_name: Optional[str] = None) -> List[np.ndarray]:
|
| 258 |
-
"""Return a list of cropped face images (with 20% margin)."""
|
| 259 |
-
detector = get_detector(detector_name)
|
| 260 |
-
boxes = detector.detect(img)
|
| 261 |
-
return [crop_face(img, (b.x, b.y, b.w, b.h)) for b in boxes]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/modules/face_recognition.py
DELETED
|
@@ -1,255 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Face Recognition Module
|
| 3 |
-
=======================
|
| 4 |
-
Wraps the `face_recognition` library to provide:
|
| 5 |
-
|
| 6 |
-
- Face encoding (128-d vector per face)
|
| 7 |
-
- Known-faces database management (register / list / delete)
|
| 8 |
-
- Identity matching against the database
|
| 9 |
-
- Batch comparison utilities
|
| 10 |
-
|
| 11 |
-
The known-faces database is persisted as a JSON file mapping
|
| 12 |
-
person_name -> list of encodings. Each registered person may have
|
| 13 |
-
multiple reference photos for robustness.
|
| 14 |
-
"""
|
| 15 |
-
|
| 16 |
-
from __future__ import annotations
|
| 17 |
-
|
| 18 |
-
import json
|
| 19 |
-
from dataclasses import dataclass, asdict
|
| 20 |
-
from pathlib import Path
|
| 21 |
-
from typing import List, Optional, Dict, Any
|
| 22 |
-
|
| 23 |
-
import numpy as np
|
| 24 |
-
|
| 25 |
-
from config import settings, KNOWN_FACES_DIR, DATA_DIR
|
| 26 |
-
from modules.face_detection import get_detector, FaceBox
|
| 27 |
-
from utils.image_utils import crop_face
|
| 28 |
-
|
| 29 |
-
DB_PATH = DATA_DIR / "face_database.json"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
# --------------------------------------------------------------------------- #
|
| 33 |
-
# Data model
|
| 34 |
-
# --------------------------------------------------------------------------- #
|
| 35 |
-
@dataclass
|
| 36 |
-
class RecognitionResult:
|
| 37 |
-
"""Result of recognizing a single face."""
|
| 38 |
-
location: dict # bounding box
|
| 39 |
-
encoding: List[float] # 128-d embedding (for client-side use if needed)
|
| 40 |
-
best_match: Optional[str] # person name or None
|
| 41 |
-
confidence: float # 1 - (distance / tolerance) clipped to [0,1]
|
| 42 |
-
all_distances: Dict[str, float] # person_name -> min distance
|
| 43 |
-
|
| 44 |
-
def to_dict(self) -> dict:
|
| 45 |
-
return asdict(self)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
# --------------------------------------------------------------------------- #
|
| 49 |
-
# Database
|
| 50 |
-
# --------------------------------------------------------------------------- #
|
| 51 |
-
class FaceDatabase:
|
| 52 |
-
"""JSON-backed known-faces database."""
|
| 53 |
-
|
| 54 |
-
def __init__(self, path: Path = DB_PATH):
|
| 55 |
-
self.path = path
|
| 56 |
-
self.data: Dict[str, List[List[float]]] = {}
|
| 57 |
-
self._load()
|
| 58 |
-
|
| 59 |
-
def _load(self):
|
| 60 |
-
if self.path.exists():
|
| 61 |
-
with open(self.path, "r") as f:
|
| 62 |
-
self.data = json.load(f)
|
| 63 |
-
else:
|
| 64 |
-
self.data = {}
|
| 65 |
-
|
| 66 |
-
def _save(self):
|
| 67 |
-
with open(self.path, "w") as f:
|
| 68 |
-
json.dump(self.data, f, indent=2)
|
| 69 |
-
|
| 70 |
-
def list_persons(self) -> List[Dict[str, Any]]:
|
| 71 |
-
return [
|
| 72 |
-
{"name": name, "num_encodings": len(encs)}
|
| 73 |
-
for name, encs in self.data.items()
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
def add_encoding(self, name: str, encoding: np.ndarray):
|
| 77 |
-
name = name.strip().lower().replace(" ", "_")
|
| 78 |
-
if name not in self.data:
|
| 79 |
-
self.data[name] = []
|
| 80 |
-
self.data[name].append(encoding.tolist())
|
| 81 |
-
self._save()
|
| 82 |
-
|
| 83 |
-
def remove_person(self, name: str) -> bool:
|
| 84 |
-
name = name.strip().lower().replace(" ", "_")
|
| 85 |
-
if name in self.data:
|
| 86 |
-
del self.data[name]
|
| 87 |
-
self._save()
|
| 88 |
-
return True
|
| 89 |
-
return False
|
| 90 |
-
|
| 91 |
-
def clear(self):
|
| 92 |
-
self.data = {}
|
| 93 |
-
self._save()
|
| 94 |
-
|
| 95 |
-
def get_all_encodings(self) -> Dict[str, List[np.ndarray]]:
|
| 96 |
-
return {
|
| 97 |
-
name: [np.array(e) for e in encs]
|
| 98 |
-
for name, encs in self.data.items()
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
# Singleton database
|
| 103 |
-
db = FaceDatabase()
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# --------------------------------------------------------------------------- #
|
| 107 |
-
# Core recognition
|
| 108 |
-
# --------------------------------------------------------------------------- #
|
| 109 |
-
def encode_face(img: np.ndarray, face_box: Optional[FaceBox] = None) -> np.ndarray:
|
| 110 |
-
"""Compute the 128-d face encoding for a face in the image.
|
| 111 |
-
|
| 112 |
-
If face_box is None, the first detected face is used.
|
| 113 |
-
Uses the `face_recognition` library (CNN or HOG model).
|
| 114 |
-
"""
|
| 115 |
-
import face_recognition
|
| 116 |
-
|
| 117 |
-
rgb = cv2_bgr_to_rgb(img)
|
| 118 |
-
|
| 119 |
-
if face_box is not None:
|
| 120 |
-
# Convert our FaceBox (x,y,w,h) to face_recognition format (top,right,bottom,left)
|
| 121 |
-
loc = (
|
| 122 |
-
face_box.y,
|
| 123 |
-
face_box.x + face_box.w,
|
| 124 |
-
face_box.y + face_box.h,
|
| 125 |
-
face_box.x,
|
| 126 |
-
)
|
| 127 |
-
encs = face_recognition.face_encodings(rgb, [loc], model=settings.face_recognition_model)
|
| 128 |
-
else:
|
| 129 |
-
encs = face_recognition.face_encodings(rgb, model=settings.face_recognition_model)
|
| 130 |
-
|
| 131 |
-
if not encs:
|
| 132 |
-
raise ValueError("No encodable face found in the image.")
|
| 133 |
-
return encs[0]
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def register_face(img: np.ndarray, name: str, detector_name: Optional[str] = None) -> dict:
|
| 137 |
-
"""Register a face from an image under a given person name.
|
| 138 |
-
|
| 139 |
-
Returns:
|
| 140 |
-
{"name": str, "encodings_added": int, "total_encodings": int}
|
| 141 |
-
"""
|
| 142 |
-
detector = get_detector(detector_name)
|
| 143 |
-
boxes = detector.detect(img)
|
| 144 |
-
if not boxes:
|
| 145 |
-
raise ValueError("No face detected in the provided image.")
|
| 146 |
-
if len(boxes) > 1:
|
| 147 |
-
# Use the largest face (most prominent subject)
|
| 148 |
-
boxes.sort(key=lambda b: b.area, reverse=True)
|
| 149 |
-
|
| 150 |
-
box = boxes[0]
|
| 151 |
-
encoding = encode_face(img, box)
|
| 152 |
-
name = name.strip().lower().replace(" ", "_")
|
| 153 |
-
db.add_encoding(name, encoding)
|
| 154 |
-
|
| 155 |
-
return {
|
| 156 |
-
"name": name,
|
| 157 |
-
"encodings_added": 1,
|
| 158 |
-
"total_encodings": len(db.data[name]),
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
def recognize_faces(
|
| 163 |
-
img: np.ndarray,
|
| 164 |
-
detector_name: Optional[str] = None,
|
| 165 |
-
tolerance: Optional[float] = None,
|
| 166 |
-
) -> dict:
|
| 167 |
-
"""Recognize all faces in an image against the known-faces database.
|
| 168 |
-
|
| 169 |
-
Args:
|
| 170 |
-
img: BGR image array.
|
| 171 |
-
detector_name: which detector to use for locating faces.
|
| 172 |
-
tolerance: distance threshold (lower = stricter). Defaults to settings.
|
| 173 |
-
|
| 174 |
-
Returns:
|
| 175 |
-
{
|
| 176 |
-
"num_faces": int,
|
| 177 |
-
"tolerance": float,
|
| 178 |
-
"results": List[RecognitionResult dict],
|
| 179 |
-
"unknown_count": int,
|
| 180 |
-
}
|
| 181 |
-
"""
|
| 182 |
-
import face_recognition
|
| 183 |
-
|
| 184 |
-
tol = tolerance or settings.face_recognition_tolerance
|
| 185 |
-
detector = get_detector(detector_name)
|
| 186 |
-
boxes = detector.detect(img)
|
| 187 |
-
rgb = cv2_bgr_to_rgb(img)
|
| 188 |
-
|
| 189 |
-
# Convert boxes to face_recognition location tuples
|
| 190 |
-
locations = [
|
| 191 |
-
(b.y, b.x + b.w, b.y + b.h, b.x) for b in boxes
|
| 192 |
-
]
|
| 193 |
-
encodings = face_recognition.face_encodings(
|
| 194 |
-
rgb, locations, model=settings.face_recognition_model
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
-
known = db.get_all_encodings()
|
| 198 |
-
results: List[RecognitionResult] = []
|
| 199 |
-
|
| 200 |
-
for loc, enc in zip(locations, encodings):
|
| 201 |
-
all_distances: Dict[str, float] = {}
|
| 202 |
-
best_name: Optional[str] = None
|
| 203 |
-
best_dist: float = float("inf")
|
| 204 |
-
|
| 205 |
-
for person, person_encs in known.items():
|
| 206 |
-
dists = face_recognition.face_distance(person_encs, enc)
|
| 207 |
-
min_dist = float(min(dists)) if len(dists) else float("inf")
|
| 208 |
-
all_distances[person] = round(min_dist, 4)
|
| 209 |
-
if min_dist < best_dist:
|
| 210 |
-
best_dist = min_dist
|
| 211 |
-
best_name = person
|
| 212 |
-
|
| 213 |
-
if best_dist > tol:
|
| 214 |
-
best_name = None # unknown
|
| 215 |
-
|
| 216 |
-
confidence = max(0.0, min(1.0, 1.0 - (best_dist / (tol * 2)))) if best_name else 0.0
|
| 217 |
-
|
| 218 |
-
results.append(RecognitionResult(
|
| 219 |
-
location={"top": loc[0], "right": loc[1], "bottom": loc[2], "left": loc[3]},
|
| 220 |
-
encoding=enc.tolist(),
|
| 221 |
-
best_match=best_name,
|
| 222 |
-
confidence=round(confidence, 4),
|
| 223 |
-
all_distances=all_distances,
|
| 224 |
-
))
|
| 225 |
-
|
| 226 |
-
unknown_count = sum(1 for r in results if r.best_match is None)
|
| 227 |
-
|
| 228 |
-
return {
|
| 229 |
-
"num_faces": len(results),
|
| 230 |
-
"tolerance": tol,
|
| 231 |
-
"results": [r.to_dict() for r in results],
|
| 232 |
-
"unknown_count": unknown_count,
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
# --------------------------------------------------------------------------- #
|
| 237 |
-
# Helpers
|
| 238 |
-
# --------------------------------------------------------------------------- #
|
| 239 |
-
def cv2_bgr_to_rgb(img: np.ndarray) -> np.ndarray:
|
| 240 |
-
return img[:, :, ::-1]
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
def compare_two_faces(img1: np.ndarray, img2: np.ndarray, tolerance: Optional[float] = None) -> dict:
|
| 244 |
-
"""Compare the primary face in two images and return whether they match."""
|
| 245 |
-
tol = tolerance or settings.face_recognition_tolerance
|
| 246 |
-
enc1 = encode_face(img1)
|
| 247 |
-
enc2 = encode_face(img2)
|
| 248 |
-
import face_recognition
|
| 249 |
-
dist = float(face_recognition.face_distance([enc1], enc2)[0])
|
| 250 |
-
return {
|
| 251 |
-
"distance": round(dist, 4),
|
| 252 |
-
"tolerance": tol,
|
| 253 |
-
"is_same_person": dist <= tol,
|
| 254 |
-
"confidence": round(max(0.0, 1.0 - dist / (tol * 2)), 4),
|
| 255 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/modules/reverse_search.py
DELETED
|
@@ -1,283 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Reverse Image Search Module
|
| 3 |
-
===========================
|
| 4 |
-
Finds visually similar images online. Two backends:
|
| 5 |
-
|
| 6 |
-
1. Google Lens (default, free) - automated via Selenium
|
| 7 |
-
2. SerpAPI Google Images - paid, returns structured JSON
|
| 8 |
-
|
| 9 |
-
Returns a list of ReverseSearchResult dataclasses with the matching
|
| 10 |
-
image URL, source page, title, and (when available) a thumbnail.
|
| 11 |
-
|
| 12 |
-
NOTE ON ETHICS & COMPLIANCE:
|
| 13 |
-
Reverse image search of faces to identify private individuals raises
|
| 14 |
-
serious privacy and legal concerns. This module is intended for:
|
| 15 |
-
- Lost-and-found / family reunification
|
| 16 |
-
- Authorized security and fraud-prevention workflows
|
| 17 |
-
- Journalism with documented public-interest justification
|
| 18 |
-
- Personal self-monitoring (searching for your OWN face)
|
| 19 |
-
It is NOT intended for de-anonymizing strangers, stalking, harassment,
|
| 20 |
-
or surveillance of people without their consent. Users are responsible
|
| 21 |
-
for complying with GDPR, CCPA, BIPA, ILCS, and all applicable laws.
|
| 22 |
-
"""
|
| 23 |
-
|
| 24 |
-
from __future__ import annotations
|
| 25 |
-
|
| 26 |
-
import io
|
| 27 |
-
import time
|
| 28 |
-
from dataclasses import dataclass, asdict
|
| 29 |
-
from pathlib import Path
|
| 30 |
-
from typing import List, Optional
|
| 31 |
-
|
| 32 |
-
import requests
|
| 33 |
-
|
| 34 |
-
from config import settings, TEMP_DIR
|
| 35 |
-
from utils.image_utils import save_temp_image
|
| 36 |
-
import numpy as np
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# --------------------------------------------------------------------------- #
|
| 40 |
-
# Data model
|
| 41 |
-
# --------------------------------------------------------------------------- #
|
| 42 |
-
@dataclass
|
| 43 |
-
class ReverseSearchResult:
|
| 44 |
-
image_url: str
|
| 45 |
-
source_page: str
|
| 46 |
-
title: str
|
| 47 |
-
snippet: str = ""
|
| 48 |
-
thumbnail: str = ""
|
| 49 |
-
|
| 50 |
-
def to_dict(self) -> dict:
|
| 51 |
-
return asdict(self)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
@dataclass
|
| 55 |
-
class ReverseSearchResponse:
|
| 56 |
-
query_image: str
|
| 57 |
-
method: str
|
| 58 |
-
num_results: int
|
| 59 |
-
results: List[ReverseSearchResult]
|
| 60 |
-
error: Optional[str] = None
|
| 61 |
-
|
| 62 |
-
def to_dict(self) -> dict:
|
| 63 |
-
return {
|
| 64 |
-
"query_image": self.query_image,
|
| 65 |
-
"method": self.method,
|
| 66 |
-
"num_results": self.num_results,
|
| 67 |
-
"results": [r.to_dict() for r in self.results],
|
| 68 |
-
"error": self.error,
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# --------------------------------------------------------------------------- #
|
| 73 |
-
# 1. Google Lens via Selenium (free)
|
| 74 |
-
# --------------------------------------------------------------------------- #
|
| 75 |
-
def reverse_search_google_lens(img: np.ndarray, max_results: Optional[int] = None) -> ReverseSearchResponse:
|
| 76 |
-
"""Upload an image to Google Lens and parse visual-match results.
|
| 77 |
-
|
| 78 |
-
Uses Selenium to drive a headless Chrome browser. This is the free
|
| 79 |
-
method; it is somewhat brittle because Google's DOM changes over time.
|
| 80 |
-
For production use, prefer the SerpAPI backend.
|
| 81 |
-
"""
|
| 82 |
-
max_results = max_results or settings.reverse_search_max_results
|
| 83 |
-
temp_path = save_temp_image(img, prefix="reverse_query")
|
| 84 |
-
query_image_str = str(temp_path)
|
| 85 |
-
|
| 86 |
-
try:
|
| 87 |
-
from selenium import webdriver
|
| 88 |
-
from selenium.webdriver.chrome.options import Options
|
| 89 |
-
from selenium.webdriver.chrome.service import Service
|
| 90 |
-
from selenium.webdriver.common.by import By
|
| 91 |
-
from selenium.webdriver.common.keys import Keys
|
| 92 |
-
from selenium.webdriver.support.ui import WebDriverWait
|
| 93 |
-
from selenium.webdriver.support import expected_conditions as EC
|
| 94 |
-
from webdriver_manager.chrome import ChromeDriverManager
|
| 95 |
-
except ImportError as e:
|
| 96 |
-
return ReverseSearchResponse(
|
| 97 |
-
query_image=query_image_str, method="google_lens",
|
| 98 |
-
num_results=0, results=[], error=f"Selenium not installed: {e}",
|
| 99 |
-
)
|
| 100 |
-
|
| 101 |
-
options = Options()
|
| 102 |
-
if settings.selenium_headless:
|
| 103 |
-
options.add_argument("--headless=new")
|
| 104 |
-
options.add_argument("--no-sandbox")
|
| 105 |
-
options.add_argument("--disable-dev-shm-usage")
|
| 106 |
-
options.add_argument("--disable-gpu")
|
| 107 |
-
options.add_argument(f"--user-agent={settings.user_agent}")
|
| 108 |
-
options.add_argument("--window-size=1920,1080")
|
| 109 |
-
|
| 110 |
-
try:
|
| 111 |
-
service = Service(ChromeDriverManager().install())
|
| 112 |
-
driver = webdriver.Chrome(service=service, options=options)
|
| 113 |
-
except Exception as e:
|
| 114 |
-
return ReverseSearchResponse(
|
| 115 |
-
query_image=query_image_str, method="google_lens",
|
| 116 |
-
num_results=0, results=[], error=f"Could not start Selenium: {e}",
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
results: List[ReverseSearchResult] = []
|
| 120 |
-
try:
|
| 121 |
-
driver.get("https://lens.google.com/")
|
| 122 |
-
wait = WebDriverWait(driver, 20)
|
| 123 |
-
|
| 124 |
-
# Click the "upload image" tab / file input
|
| 125 |
-
# Google Lens has a file input; we send the file path directly
|
| 126 |
-
file_inputs = driver.find_elements(By.CSS_SELECTOR, "input[type='file']")
|
| 127 |
-
if not file_inputs:
|
| 128 |
-
return ReverseSearchResponse(
|
| 129 |
-
query_image=query_image_str, method="google_lens",
|
| 130 |
-
num_results=0, results=[],
|
| 131 |
-
error="Could not locate file upload input on Google Lens page.",
|
| 132 |
-
)
|
| 133 |
-
file_inputs[0].send_keys(query_image_str)
|
| 134 |
-
|
| 135 |
-
# Wait for results to load
|
| 136 |
-
time.sleep(6)
|
| 137 |
-
|
| 138 |
-
# Parse visual matches - Google Lens renders result cards
|
| 139 |
-
# Selector may change; we try several known patterns
|
| 140 |
-
card_selectors = [
|
| 141 |
-
".Vd9T9c", # visual match card
|
| 142 |
-
"[jsname='R8qPlf']",
|
| 143 |
-
"div[jsname]",
|
| 144 |
-
"a[href]",
|
| 145 |
-
]
|
| 146 |
-
cards = []
|
| 147 |
-
for sel in card_selectors:
|
| 148 |
-
cards = driver.find_elements(By.CSS_SELECTOR, sel)
|
| 149 |
-
if cards:
|
| 150 |
-
break
|
| 151 |
-
|
| 152 |
-
for card in cards[:max_results]:
|
| 153 |
-
try:
|
| 154 |
-
href = card.get_attribute("href") or ""
|
| 155 |
-
title = card.get_attribute("title") or card.text or ""
|
| 156 |
-
# Try to extract thumbnail
|
| 157 |
-
thumb = ""
|
| 158 |
-
try:
|
| 159 |
-
img_el = card.find_element(By.TAG_NAME, "img")
|
| 160 |
-
thumb = img_el.get_attribute("src") or ""
|
| 161 |
-
except Exception:
|
| 162 |
-
pass
|
| 163 |
-
if href and href.startswith("http"):
|
| 164 |
-
results.append(ReverseSearchResult(
|
| 165 |
-
image_url=thumb or href,
|
| 166 |
-
source_page=href,
|
| 167 |
-
title=title.strip()[:200],
|
| 168 |
-
thumbnail=thumb,
|
| 169 |
-
))
|
| 170 |
-
except Exception:
|
| 171 |
-
continue
|
| 172 |
-
|
| 173 |
-
return ReverseSearchResponse(
|
| 174 |
-
query_image=query_image_str, method="google_lens",
|
| 175 |
-
num_results=len(results), results=results,
|
| 176 |
-
)
|
| 177 |
-
except Exception as e:
|
| 178 |
-
return ReverseSearchResponse(
|
| 179 |
-
query_image=query_image_str, method="google_lens",
|
| 180 |
-
num_results=0, results=[], error=str(e),
|
| 181 |
-
)
|
| 182 |
-
finally:
|
| 183 |
-
driver.quit()
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
# --------------------------------------------------------------------------- #
|
| 187 |
-
# 2. SerpAPI Google Images (paid, structured)
|
| 188 |
-
# --------------------------------------------------------------------------- #
|
| 189 |
-
def reverse_search_serpapi(img: np.ndarray, max_results: Optional[int] = None) -> ReverseSearchResponse:
|
| 190 |
-
"""Use SerpAPI's Google Reverse Image endpoint. Requires a paid API key."""
|
| 191 |
-
if not settings.serpapi_key:
|
| 192 |
-
return ReverseSearchResponse(
|
| 193 |
-
query_image="", method="serpapi",
|
| 194 |
-
num_results=0, results=[],
|
| 195 |
-
error="SerpAPI key not configured. Set FI_SERPAPI_KEY in environment.",
|
| 196 |
-
)
|
| 197 |
-
|
| 198 |
-
max_results = max_results or settings.reverse_search_max_results
|
| 199 |
-
|
| 200 |
-
# Step 1: upload image to SerpAPI's /upload endpoint
|
| 201 |
-
import cv2
|
| 202 |
-
ok, buffer = cv2.imencode(".jpg", img)
|
| 203 |
-
if not ok:
|
| 204 |
-
return ReverseSearchResponse(
|
| 205 |
-
query_image="", method="serpapi", num_results=0, results=[],
|
| 206 |
-
error="Could not encode query image.",
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
-
try:
|
| 210 |
-
upload_resp = requests.post(
|
| 211 |
-
"https://assets.serpapi.com/upload",
|
| 212 |
-
files={"file": ("query.jpg", io.BytesIO(buffer.tobytes()), "image/jpeg")},
|
| 213 |
-
data={"serp_api_key": settings.serpapi_key},
|
| 214 |
-
timeout=60,
|
| 215 |
-
)
|
| 216 |
-
upload_resp.raise_for_status()
|
| 217 |
-
uploaded_url = upload_resp.text.strip().strip('"')
|
| 218 |
-
except requests.RequestException as e:
|
| 219 |
-
return ReverseSearchResponse(
|
| 220 |
-
query_image="", method="serpapi", num_results=0, results=[],
|
| 221 |
-
error=f"Upload failed: {e}",
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
# Step 2: query the reverse image search engine
|
| 225 |
-
try:
|
| 226 |
-
params = {
|
| 227 |
-
"engine": "google_reverse_image",
|
| 228 |
-
"image_url": uploaded_url,
|
| 229 |
-
"api_key": settings.serpapi_key,
|
| 230 |
-
}
|
| 231 |
-
search_resp = requests.get("https://serpapi.com/search", params=params, timeout=60)
|
| 232 |
-
search_resp.raise_for_status()
|
| 233 |
-
data = search_resp.json()
|
| 234 |
-
except requests.RequestException as e:
|
| 235 |
-
return ReverseSearchResponse(
|
| 236 |
-
query_image=uploaded_url, method="serpapi", num_results=0, results=[],
|
| 237 |
-
error=f"Search failed: {e}",
|
| 238 |
-
)
|
| 239 |
-
|
| 240 |
-
results: List[ReverseSearchResult] = []
|
| 241 |
-
for match in data.get("image_results", [])[:max_results]:
|
| 242 |
-
results.append(ReverseSearchResult(
|
| 243 |
-
image_url=match.get("image", ""),
|
| 244 |
-
source_page=match.get("link", ""),
|
| 245 |
-
title=match.get("title", ""),
|
| 246 |
-
snippet=match.get("snippet", ""),
|
| 247 |
-
thumbnail=match.get("thumbnail", ""),
|
| 248 |
-
))
|
| 249 |
-
# Also include full-page image matches if present
|
| 250 |
-
for match in data.get("inline_images", [])[:max_results]:
|
| 251 |
-
results.append(ReverseSearchResult(
|
| 252 |
-
image_url=match.get("image", ""),
|
| 253 |
-
source_page=match.get("link", ""),
|
| 254 |
-
title=match.get("title", ""),
|
| 255 |
-
snippet=match.get("snippet", ""),
|
| 256 |
-
thumbnail=match.get("thumbnail", ""),
|
| 257 |
-
))
|
| 258 |
-
|
| 259 |
-
return ReverseSearchResponse(
|
| 260 |
-
query_image=uploaded_url, method="serpapi",
|
| 261 |
-
num_results=len(results), results=results[:max_results],
|
| 262 |
-
)
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
# --------------------------------------------------------------------------- #
|
| 266 |
-
# Dispatcher
|
| 267 |
-
# --------------------------------------------------------------------------- #
|
| 268 |
-
def reverse_image_search(
|
| 269 |
-
img: np.ndarray,
|
| 270 |
-
method: Optional[str] = None,
|
| 271 |
-
max_results: Optional[int] = None,
|
| 272 |
-
) -> ReverseSearchResponse:
|
| 273 |
-
"""Run a reverse image search using the configured or specified method."""
|
| 274 |
-
method = method or settings.reverse_search_method
|
| 275 |
-
if method == "google_lens":
|
| 276 |
-
return reverse_search_google_lens(img, max_results)
|
| 277 |
-
elif method == "serpapi":
|
| 278 |
-
return reverse_search_serpapi(img, max_results)
|
| 279 |
-
else:
|
| 280 |
-
return ReverseSearchResponse(
|
| 281 |
-
query_image="", method=method, num_results=0, results=[],
|
| 282 |
-
error=f"Unknown method: {method}",
|
| 283 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/modules/web_scraper.py
DELETED
|
@@ -1,235 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Web Scraping Module
|
| 3 |
-
===================
|
| 4 |
-
Extracts images from web pages using two strategies:
|
| 5 |
-
|
| 6 |
-
1. BeautifulSoup + requests - fast, works on static HTML
|
| 7 |
-
2. Selenium (headless Chrome) - handles JavaScript-rendered content
|
| 8 |
-
|
| 9 |
-
Both strategies return a list of ImageResult dataclasses containing
|
| 10 |
-
the image URL, alt text, source page, and (optionally) downloaded
|
| 11 |
-
bytes for downstream processing.
|
| 12 |
-
"""
|
| 13 |
-
|
| 14 |
-
from __future__ import annotations
|
| 15 |
-
|
| 16 |
-
import re
|
| 17 |
-
from dataclasses import dataclass, asdict
|
| 18 |
-
from pathlib import Path
|
| 19 |
-
from typing import List, Optional
|
| 20 |
-
from urllib.parse import urljoin, urlparse
|
| 21 |
-
|
| 22 |
-
import requests
|
| 23 |
-
from bs4 import BeautifulSoup
|
| 24 |
-
|
| 25 |
-
from config import settings
|
| 26 |
-
from utils.image_utils import url_to_numpy, bytes_to_numpy
|
| 27 |
-
import numpy as np
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# --------------------------------------------------------------------------- #
|
| 31 |
-
# Data model
|
| 32 |
-
# --------------------------------------------------------------------------- #
|
| 33 |
-
@dataclass
|
| 34 |
-
class ImageResult:
|
| 35 |
-
url: str
|
| 36 |
-
alt: str
|
| 37 |
-
source_page: str
|
| 38 |
-
width: Optional[int] = None
|
| 39 |
-
height: Optional[int] = None
|
| 40 |
-
downloaded: bool = False
|
| 41 |
-
|
| 42 |
-
def to_dict(self) -> dict:
|
| 43 |
-
return asdict(self)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@dataclass
|
| 47 |
-
class ScrapeResult:
|
| 48 |
-
url: str
|
| 49 |
-
method: str
|
| 50 |
-
num_images: int
|
| 51 |
-
images: List[ImageResult]
|
| 52 |
-
title: str = ""
|
| 53 |
-
error: Optional[str] = None
|
| 54 |
-
|
| 55 |
-
def to_dict(self) -> dict:
|
| 56 |
-
return {**asdict(self), "images": [i.to_dict() for i in self.images]}
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# --------------------------------------------------------------------------- #
|
| 60 |
-
# Static scraper (BeautifulSoup)
|
| 61 |
-
# --------------------------------------------------------------------------- #
|
| 62 |
-
def scrape_static(url: str, max_images: Optional[int] = None) -> ScrapeResult:
|
| 63 |
-
"""Scrape images from a static HTML page using requests + BeautifulSoup."""
|
| 64 |
-
max_images = max_images or settings.scrape_max_images
|
| 65 |
-
headers = {"User-Agent": settings.user_agent}
|
| 66 |
-
|
| 67 |
-
try:
|
| 68 |
-
resp = requests.get(url, headers=headers, timeout=settings.scrape_timeout)
|
| 69 |
-
resp.raise_for_status()
|
| 70 |
-
except requests.RequestException as e:
|
| 71 |
-
return ScrapeResult(url=url, method="static", num_images=0, images=[], error=str(e))
|
| 72 |
-
|
| 73 |
-
soup = BeautifulSoup(resp.text, "lxml")
|
| 74 |
-
title = soup.title.string.strip() if soup.title and soup.title.string else ""
|
| 75 |
-
|
| 76 |
-
images: List[ImageResult] = []
|
| 77 |
-
seen: set = set()
|
| 78 |
-
|
| 79 |
-
for img_tag in soup.find_all("img"):
|
| 80 |
-
if len(images) >= max_images:
|
| 81 |
-
break
|
| 82 |
-
src = img_tag.get("src") or img_tag.get("data-src") or img_tag.get("data-lazy-src")
|
| 83 |
-
if not src:
|
| 84 |
-
continue
|
| 85 |
-
absolute_url = urljoin(url, src)
|
| 86 |
-
if absolute_url in seen:
|
| 87 |
-
continue
|
| 88 |
-
# Filter out tiny icons / tracking pixels
|
| 89 |
-
w = img_tag.get("width")
|
| 90 |
-
h = img_tag.get("height")
|
| 91 |
-
if w and h:
|
| 92 |
-
try:
|
| 93 |
-
if int(w) < 50 or int(h) < 50:
|
| 94 |
-
continue
|
| 95 |
-
except ValueError:
|
| 96 |
-
pass
|
| 97 |
-
seen.add(absolute_url)
|
| 98 |
-
images.append(ImageResult(
|
| 99 |
-
url=absolute_url,
|
| 100 |
-
alt=(img_tag.get("alt") or "").strip(),
|
| 101 |
-
source_page=url,
|
| 102 |
-
width=int(w) if w and w.isdigit() else None,
|
| 103 |
-
height=int(h) if h and h.isdigit() else None,
|
| 104 |
-
))
|
| 105 |
-
|
| 106 |
-
return ScrapeResult(url=url, method="static", num_images=len(images), images=images, title=title)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
# --------------------------------------------------------------------------- #
|
| 110 |
-
# Dynamic scraper (Selenium)
|
| 111 |
-
# --------------------------------------------------------------------------- #
|
| 112 |
-
def scrape_dynamic(url: str, max_images: Optional[int] = None, scroll: bool = True) -> ScrapeResult:
|
| 113 |
-
"""Scrape images from a JS-rendered page using headless Selenium."""
|
| 114 |
-
max_images = max_images or settings.scrape_max_images
|
| 115 |
-
|
| 116 |
-
try:
|
| 117 |
-
from selenium import webdriver
|
| 118 |
-
from selenium.webdriver.chrome.options import Options
|
| 119 |
-
from selenium.webdriver.chrome.service import Service
|
| 120 |
-
from selenium.webdriver.common.by import By
|
| 121 |
-
from webdriver_manager.chrome import ChromeDriverManager
|
| 122 |
-
except ImportError as e:
|
| 123 |
-
return ScrapeResult(url=url, method="dynamic", num_images=0, images=[],
|
| 124 |
-
error=f"Selenium not installed: {e}")
|
| 125 |
-
|
| 126 |
-
options = Options()
|
| 127 |
-
if settings.selenium_headless:
|
| 128 |
-
options.add_argument("--headless=new")
|
| 129 |
-
options.add_argument("--no-sandbox")
|
| 130 |
-
options.add_argument("--disable-dev-shm-usage")
|
| 131 |
-
options.add_argument("--disable-gpu")
|
| 132 |
-
options.add_argument(f"--user-agent={settings.user_agent}")
|
| 133 |
-
options.add_argument("--window-size=1920,1080")
|
| 134 |
-
|
| 135 |
-
try:
|
| 136 |
-
service = Service(ChromeDriverManager().install())
|
| 137 |
-
driver = webdriver.Chrome(service=service, options=options)
|
| 138 |
-
except Exception as e:
|
| 139 |
-
return ScrapeResult(url=url, method="dynamic", num_images=0, images=[],
|
| 140 |
-
error=f"Could not start Selenium: {e}")
|
| 141 |
-
|
| 142 |
-
driver.implicitly_wait(settings.selenium_implicit_wait)
|
| 143 |
-
images: List[ImageResult] = []
|
| 144 |
-
seen: set = set()
|
| 145 |
-
|
| 146 |
-
try:
|
| 147 |
-
driver.get(url)
|
| 148 |
-
title = driver.title or ""
|
| 149 |
-
|
| 150 |
-
if scroll:
|
| 151 |
-
# Scroll to bottom to trigger lazy-loading
|
| 152 |
-
last_height = driver.execute_script("return document.body.scrollHeight")
|
| 153 |
-
for _ in range(5): # max 5 scroll iterations
|
| 154 |
-
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
|
| 155 |
-
import time
|
| 156 |
-
time.sleep(1.5)
|
| 157 |
-
new_height = driver.execute_script("return document.body.scrollHeight")
|
| 158 |
-
if new_height == last_height:
|
| 159 |
-
break
|
| 160 |
-
last_height = new_height
|
| 161 |
-
|
| 162 |
-
img_elements = driver.find_elements(By.TAG_NAME, "img")
|
| 163 |
-
for el in img_elements:
|
| 164 |
-
if len(images) >= max_images:
|
| 165 |
-
break
|
| 166 |
-
src = el.get_attribute("src") or el.get_attribute("data-src")
|
| 167 |
-
if not src or src.startswith("data:"):
|
| 168 |
-
continue
|
| 169 |
-
absolute_url = urljoin(url, src)
|
| 170 |
-
if absolute_url in seen:
|
| 171 |
-
continue
|
| 172 |
-
seen.add(absolute_url)
|
| 173 |
-
w = el.get_attribute("naturalWidth")
|
| 174 |
-
h = el.get_attribute("naturalHeight")
|
| 175 |
-
try:
|
| 176 |
-
w_int = int(w) if w else None
|
| 177 |
-
h_int = int(h) if h else None
|
| 178 |
-
if w_int and h_int and (w_int < 50 or h_int < 50):
|
| 179 |
-
continue
|
| 180 |
-
except (TypeError, ValueError):
|
| 181 |
-
w_int = h_int = None
|
| 182 |
-
images.append(ImageResult(
|
| 183 |
-
url=absolute_url,
|
| 184 |
-
alt=(el.get_attribute("alt") or "").strip(),
|
| 185 |
-
source_page=url,
|
| 186 |
-
width=w_int,
|
| 187 |
-
height=h_int,
|
| 188 |
-
))
|
| 189 |
-
|
| 190 |
-
return ScrapeResult(url=url, method="dynamic", num_images=len(images),
|
| 191 |
-
images=images, title=title)
|
| 192 |
-
except Exception as e:
|
| 193 |
-
return ScrapeResult(url=url, method="dynamic", num_images=0, images=[], error=str(e))
|
| 194 |
-
finally:
|
| 195 |
-
driver.quit()
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
# --------------------------------------------------------------------------- #
|
| 199 |
-
# Convenience: download images from a ScrapeResult
|
| 200 |
-
# --------------------------------------------------------------------------- #
|
| 201 |
-
def download_images(scrape_result: ScrapeResult, limit: Optional[int] = None) -> List[Optional[np.ndarray]]:
|
| 202 |
-
"""Download up to `limit` images from the scrape result. Returns BGR arrays."""
|
| 203 |
-
limit = limit or len(scrape_result.images)
|
| 204 |
-
downloaded: List[Optional[np.ndarray]] = []
|
| 205 |
-
for img_res in scrape_result.images[:limit]:
|
| 206 |
-
try:
|
| 207 |
-
arr = url_to_numpy(img_res.url)
|
| 208 |
-
downloaded.append(arr)
|
| 209 |
-
img_res.downloaded = True
|
| 210 |
-
except Exception:
|
| 211 |
-
downloaded.append(None)
|
| 212 |
-
return downloaded
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
# --------------------------------------------------------------------------- #
|
| 216 |
-
# Site-specific helpers (extensible)
|
| 217 |
-
# --------------------------------------------------------------------------- #
|
| 218 |
-
def is_social_media_url(url: str) -> dict:
|
| 219 |
-
"""Identify which social platform a URL belongs to."""
|
| 220 |
-
parsed = urlparse(url)
|
| 221 |
-
host = parsed.netloc.lower()
|
| 222 |
-
platforms = {
|
| 223 |
-
"instagram": ("instagram.com",),
|
| 224 |
-
"twitter": ("twitter.com", "x.com"),
|
| 225 |
-
"facebook": ("facebook.com", "fb.com"),
|
| 226 |
-
"linkedin": ("linkedin.com",),
|
| 227 |
-
"tiktok": ("tiktok.com",),
|
| 228 |
-
"youtube": ("youtube.com", "youtu.be"),
|
| 229 |
-
"pinterest": ("pinterest.com",),
|
| 230 |
-
"reddit": ("reddit.com",),
|
| 231 |
-
}
|
| 232 |
-
for platform, domains in platforms.items():
|
| 233 |
-
if any(d in host for d in domains):
|
| 234 |
-
return {"is_social": True, "platform": platform}
|
| 235 |
-
return {"is_social": False, "platform": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/requirements.txt
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
# ============================================================
|
| 2 |
-
# Face Intelligence API - Dependencies
|
| 3 |
-
# ============================================================
|
| 4 |
-
|
| 5 |
-
# --- Web Framework ---
|
| 6 |
-
fastapi==0.110.0
|
| 7 |
-
uvicorn[standard]==0.27.1
|
| 8 |
-
python-multipart==0.0.9
|
| 9 |
-
pydantic==2.6.1
|
| 10 |
-
pydantic-settings==2.2.1
|
| 11 |
-
|
| 12 |
-
# --- Face Detection & Recognition ---
|
| 13 |
-
opencv-python==4.9.0.80
|
| 14 |
-
opencv-contrib-python==4.9.0.80
|
| 15 |
-
face-recognition==1.3.0
|
| 16 |
-
dlib==19.24.2
|
| 17 |
-
numpy==1.26.4
|
| 18 |
-
Pillow==10.2.0
|
| 19 |
-
|
| 20 |
-
# --- MTCNN (optional alternative detector) ---
|
| 21 |
-
mtcnn==0.1.1
|
| 22 |
-
tensorflow==2.16.1
|
| 23 |
-
|
| 24 |
-
# --- Web Scraping ---
|
| 25 |
-
beautifulsoup4==4.12.3
|
| 26 |
-
lxml==5.1.0
|
| 27 |
-
requests==2.31.0
|
| 28 |
-
selenium==4.18.1
|
| 29 |
-
webdriver-manager==4.0.1
|
| 30 |
-
urllib3==2.2.1
|
| 31 |
-
|
| 32 |
-
# --- Utilities ---
|
| 33 |
-
aiofiles==23.2.1
|
| 34 |
-
httpx==0.27.0
|
| 35 |
-
tqdm==4.66.2
|
| 36 |
-
python-dotenv==1.0.1
|
| 37 |
-
loguru==0.7.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
download/face-intelligence-api/utils/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
"""Utils package."""
|
|
|
|
|
|
download/face-intelligence-api/utils/image_utils.py
DELETED
|
@@ -1,94 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Image utility helpers: loading, decoding, encoding, face cropping.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
from __future__ import annotations
|
| 6 |
-
|
| 7 |
-
import base64
|
| 8 |
-
import io
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
from typing import Tuple
|
| 11 |
-
|
| 12 |
-
import cv2
|
| 13 |
-
import numpy as np
|
| 14 |
-
from PIL import Image
|
| 15 |
-
|
| 16 |
-
from config import TEMP_DIR
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
def bytes_to_numpy(image_bytes: bytes) -> np.ndarray:
|
| 20 |
-
"""Decode raw image bytes into an OpenCV BGR numpy array."""
|
| 21 |
-
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 22 |
-
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 23 |
-
if img is None:
|
| 24 |
-
raise ValueError("Could not decode image bytes. Unsupported format or corrupted data.")
|
| 25 |
-
return img
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def base64_to_numpy(b64_string: str) -> np.ndarray:
|
| 29 |
-
"""Decode a base64-encoded image string into an OpenCV BGR numpy array."""
|
| 30 |
-
if "," in b64_string:
|
| 31 |
-
b64_string = b64_string.split(",", 1)[1]
|
| 32 |
-
raw = base64.b64decode(b64_string)
|
| 33 |
-
return bytes_to_numpy(raw)
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def numpy_to_base64(img: np.ndarray, fmt: str = ".jpg") -> str:
|
| 37 |
-
"""Encode an OpenCV BGR numpy array as a base64 string."""
|
| 38 |
-
ok, buffer = cv2.imencode(fmt, img)
|
| 39 |
-
if not ok:
|
| 40 |
-
raise ValueError("Could not encode image.")
|
| 41 |
-
return base64.b64encode(buffer).decode("utf-8")
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def save_temp_image(img: np.ndarray, prefix: str = "img", fmt: str = ".jpg") -> Path:
|
| 45 |
-
"""Save an image to the temp directory and return its path."""
|
| 46 |
-
path = TEMP_DIR / f"{prefix}_{int(cv2.getTickCount())}{fmt}"
|
| 47 |
-
cv2.imwrite(str(path), img)
|
| 48 |
-
return path
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def crop_face(img: np.ndarray, box: Tuple[int, int, int, int], margin: float = 0.2) -> np.ndarray:
|
| 52 |
-
"""Crop a face region from an image with an optional margin.
|
| 53 |
-
|
| 54 |
-
Args:
|
| 55 |
-
img: BGR image array.
|
| 56 |
-
box: (x, y, w, h) bounding box.
|
| 57 |
-
margin: fractional margin to add around the face.
|
| 58 |
-
"""
|
| 59 |
-
x, y, w, h = box
|
| 60 |
-
dx = int(w * margin)
|
| 61 |
-
dy = int(h * margin)
|
| 62 |
-
x0 = max(0, x - dx)
|
| 63 |
-
y0 = max(0, y - dy)
|
| 64 |
-
x1 = min(img.shape[1], x + w + dx)
|
| 65 |
-
y1 = min(img.shape[0], y + h + dy)
|
| 66 |
-
return img[y0:y1, x0:x1]
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def resize_for_display(img: np.ndarray, max_width: int = 800) -> np.ndarray:
|
| 70 |
-
"""Resize image maintaining aspect ratio for display purposes."""
|
| 71 |
-
h, w = img.shape[:2]
|
| 72 |
-
if w <= max_width:
|
| 73 |
-
return img
|
| 74 |
-
scale = max_width / w
|
| 75 |
-
return cv2.resize(img, (max_width, int(h * scale)), interpolation=cv2.INTER_AREA)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def url_to_numpy(url: str, timeout: int = 15) -> np.ndarray:
|
| 79 |
-
"""Download an image from a URL and return it as a BGR numpy array."""
|
| 80 |
-
import requests
|
| 81 |
-
headers = {"User-Agent": "Mozilla/5.0"}
|
| 82 |
-
resp = requests.get(url, headers=headers, timeout=timeout, stream=True)
|
| 83 |
-
resp.raise_for_status()
|
| 84 |
-
return bytes_to_numpy(resp.content)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def validate_image_file(path: Path) -> bool:
|
| 88 |
-
"""Validate that a file is a readable image."""
|
| 89 |
-
try:
|
| 90 |
-
with Image.open(path) as im:
|
| 91 |
-
im.verify()
|
| 92 |
-
return True
|
| 93 |
-
except Exception:
|
| 94 |
-
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|