Z User commited on
Commit ·
25ee7a9
1
Parent(s): 4660f87
1aa93ed1-1171-4784-95b4-bff53f7711a3
Browse files- download/face-intelligence-api/config.py +87 -0
- download/face-intelligence-api/modules/__init__.py +1 -0
- download/face-intelligence-api/modules/face_detection.py +261 -0
- download/face-intelligence-api/modules/face_recognition.py +255 -0
- download/face-intelligence-api/modules/reverse_search.py +283 -0
- download/face-intelligence-api/modules/web_scraper.py +235 -0
- download/face-intelligence-api/requirements.txt +37 -0
- download/face-intelligence-api/utils/__init__.py +1 -0
- download/face-intelligence-api/utils/image_utils.py +94 -0
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
|