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

1aa93ed1-1171-4784-95b4-bff53f7711a3

Browse files
download/face-intelligence-api/config.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+ """Modules package."""
download/face-intelligence-api/modules/face_detection.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+ """Utils package."""
download/face-intelligence-api/utils/image_utils.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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