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

78fbc14f-966c-4b10-99c2-e2d762855856

Browse files
Files changed (30) hide show
  1. download/face-intel/api/__init__.py +0 -0
  2. download/face-intel/api/routes/__init__.py +0 -0
  3. download/face-intel/confidence/__init__.py +0 -0
  4. download/face-intel/config/__init__.py +23 -0
  5. download/face-intel/config/settings.py +159 -0
  6. download/face-intel/normalization/__init__.py +0 -0
  7. download/face-intel/orchestrator/__init__.py +0 -0
  8. download/face-intel/providers/__init__.py +132 -0
  9. download/face-intel/providers/base.py +195 -0
  10. download/face-intel/providers/detection/__init__.py +0 -0
  11. download/face-intel/providers/detection/haar.py +57 -0
  12. download/face-intel/providers/recognition/__init__.py +0 -0
  13. download/face-intel/providers/reverse/__init__.py +0 -0
  14. download/face-intel/providers/scraper/__init__.py +0 -0
  15. download/face-intel/storage/__init__.py +0 -0
  16. download/face-intel/ui/__init__.py +0 -0
  17. download/face-intel/utils/__init__.py +0 -0
  18. download/face-intel/utils/audit.py +41 -0
  19. download/face-intel/utils/http.py +47 -0
  20. download/face-intel/utils/image.py +168 -0
  21. download/face-intel/utils/timing.py +20 -0
  22. download/face-intelligence-api/config.py +0 -87
  23. download/face-intelligence-api/modules/__init__.py +0 -1
  24. download/face-intelligence-api/modules/face_detection.py +0 -261
  25. download/face-intelligence-api/modules/face_recognition.py +0 -255
  26. download/face-intelligence-api/modules/reverse_search.py +0 -283
  27. download/face-intelligence-api/modules/web_scraper.py +0 -235
  28. download/face-intelligence-api/requirements.txt +0 -37
  29. download/face-intelligence-api/utils/__init__.py +0 -1
  30. 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