Spaces:
Sleeping
Sleeping
| """ | |
| Face feature extractor β YuNet (YOLO-architecture, cv2.FaceDetectorYN). | |
| WHY YuNet over BlazeFace: | |
| YuNet is specifically trained for face detection with a YOLO-style architecture. | |
| It handles small, rotated, and stylised painted faces better than BlazeFace full-range | |
| while producing fewer false positives at score_threshold=0.7. | |
| Each YuNet detection row: [x, y, w, h, re_x, re_y, le_x, le_y, | |
| nose_x, nose_y, rm_x, rm_y, lm_x, lm_y, score] | |
| re = right eye, le = left eye, nose = nose tip, rm/lm = right/left mouth corner. | |
| Encodes each image as a 39-dim face vector: | |
| Basic presence (3): | |
| [0] face_detected | |
| [1] log1p(n_faces) / log1p(10) β normalised count | |
| [2] crowd indicator β 1 if n_faces > 5 | |
| Coverage & size (5): | |
| [3] total_coverage β Ξ£(bbox_area) / img_area | |
| [4] mean_face_size | |
| [5] max_face_size β largest face / img_area | |
| [6] dominance_ratio β max_size / mean_size | |
| [7] face_size_std | |
| Size distribution (2): | |
| [8] size_entropy β entropy of normalised size distribution | |
| [9] largest_face_fraction β max_size / total_coverage | |
| Spatial centroid & spread (4): | |
| [10] centroid_x β area-weighted | |
| [11] centroid_y | |
| [12] spread_x β Ο of face x-centres | |
| [13] spread_y | |
| Orientation (4): | |
| [14] mean_tilt β roll from eye vector / 90 (0 = level) | |
| [15] tilt_std | |
| [16] mean_frontal_score β 1 = frontal, 0 = profile (nose-eye landmarks) | |
| [17] frontal_ratio β fraction of faces with frontal_score > 0.6 | |
| Composition (5): | |
| [18] vertical_bias β mean_cy - 0.5 (neg = upper, pos = lower) | |
| [19] arrangement_rowness β std_y / (std_x + Ξ΅): low = row, high = column | |
| [20] mean_pairwise_dist β mean normalised dist between all face pairs | |
| [21] min_pairwise_dist β closest pair distance | |
| [22] clustering_score β fraction of pairs within 0.15 distance | |
| Spatial histograms (8): | |
| [23-26] 4-bin horizontal histogram | |
| [27-30] 4-bin vertical histogram | |
| 4Γ2 spatial grid (8): | |
| [31-38] which grid cell(s) hold faces (4 horiz Γ 2 vert, row-major) | |
| Total: 39 dims. | |
| Output: Religion_art_dataset/features_faces.parquet | |
| filename, face_detected, face_count, face_vector (list[float32], 39 dims) | |
| Usage: | |
| python features/extract_faces.py | |
| python features/extract_faces.py --limit 100 | |
| """ | |
| import argparse | |
| import os | |
| import sys | |
| import urllib.request | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) | |
| import cv2 | |
| import numpy as np | |
| import pandas as pd | |
| from tqdm import tqdm | |
| IMAGES_DIR = "data/images" | |
| METADATA_CSV = "data/artwork_metadata.csv" | |
| OUTPUT = "data/features/faces.parquet" | |
| MODELS_DIR = os.path.join(os.path.dirname(__file__), "models") | |
| FACE_DIMS = 39 | |
| SCORE_THR = 0.7 | |
| NMS_THR = 0.3 | |
| MODEL_PATH = os.path.join(MODELS_DIR, "face_detection_yunet_2023mar.onnx") | |
| MODEL_URL = ( | |
| "https://github.com/opencv/opencv_zoo/raw/main/models/" | |
| "face_detection_yunet/face_detection_yunet_2023mar.onnx" | |
| ) | |
| FACE_COLS = ["filename", "face_detected", "face_count", "face_vector"] | |
| def ensure_model(): | |
| os.makedirs(MODELS_DIR, exist_ok=True) | |
| if not os.path.exists(MODEL_PATH): | |
| print(f"Downloading YuNet model (~380 KB) β {MODEL_PATH}") | |
| urllib.request.urlretrieve(MODEL_URL, MODEL_PATH) | |
| print("Download complete.") | |
| def _zero_vec() -> list: | |
| return [0.0] * FACE_DIMS | |
| def _size_entropy(sizes: np.ndarray) -> float: | |
| if len(sizes) <= 1: | |
| return 0.0 | |
| p = sizes / (sizes.sum() + 1e-10) | |
| return float(-np.sum(p * np.log(p + 1e-10))) | |
| def _frontal_score(re_x, le_x, nose_x) -> float: | |
| """ | |
| Estimate how frontal a face is from YuNet landmarks (all in normalised [0,1] coords). | |
| Returns 1.0 for perfectly frontal, 0.0 for fully in profile. | |
| """ | |
| eye_mid = (re_x + le_x) / 2.0 | |
| eye_dist = abs(re_x - le_x) | |
| nose_off = abs(nose_x - eye_mid) | |
| profile = nose_off / (eye_dist + 1e-6) | |
| return float(max(0.0, 1.0 - min(profile, 1.0))) | |
| def _pairwise_stats(cx: np.ndarray, cy: np.ndarray) -> tuple: | |
| n = len(cx) | |
| if n < 2: | |
| return 0.0, 0.0, 0.0 | |
| dists = [] | |
| for i in range(n): | |
| for j in range(i + 1, n): | |
| dists.append(float(np.sqrt((cx[i]-cx[j])**2 + (cy[i]-cy[j])**2))) | |
| dists = np.array(dists) | |
| return float(dists.mean()), float(dists.min()), float((dists < 0.15).mean()) | |
| def encode_faces(faces, img_h: int, img_w: int) -> tuple: | |
| """ | |
| Build a 39-dim face vector from YuNet detections. | |
| faces: numpy array (N, 15) β None or empty β zero vector. | |
| """ | |
| if faces is None or len(faces) == 0: | |
| return 0, 0, _zero_vec() | |
| vec = np.zeros(FACE_DIMS, dtype=np.float32) | |
| img_area = float(img_h * img_w) or 1.0 | |
| n = len(faces) | |
| # Normalise all coordinates to [0, 1] | |
| sizes, cx_list, cy_list, tilts, frontal_scores = [], [], [], [], [] | |
| for f in faces: | |
| x, y, w, h = f[0], f[1], f[2], f[3] | |
| # normalised bbox | |
| nw = w / img_w | |
| nh = h / img_h | |
| cx = (x + w / 2.0) / img_w | |
| cy = (y + h / 2.0) / img_h | |
| sizes.append(nw * nh) | |
| cx_list.append(cx) | |
| cy_list.append(cy) | |
| # landmarks (pixel β normalised) | |
| re_x, re_y = f[4] / img_w, f[5] / img_h | |
| le_x, le_y = f[6] / img_w, f[7] / img_h | |
| dx = re_x - le_x | |
| dy = re_y - le_y | |
| tilts.append(float(np.degrees(np.arctan2(dy, dx)))) | |
| nose_x = f[8] / img_w | |
| frontal_scores.append(_frontal_score(re_x, le_x, nose_x)) | |
| sizes = np.array(sizes, dtype=np.float32) | |
| cx_arr = np.array(cx_list, dtype=np.float32) | |
| cy_arr = np.array(cy_list, dtype=np.float32) | |
| # ββ [0-2] basic presence βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vec[0] = 1.0 | |
| vec[1] = float(np.log1p(n) / np.log1p(10)) | |
| vec[2] = float(n > 5) | |
| # ββ [3-7] coverage & size ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vec[3] = float(sizes.sum()) | |
| vec[4] = float(sizes.mean()) | |
| vec[5] = float(sizes.max()) | |
| vec[6] = float(sizes.max() / (sizes.mean() + 1e-6)) | |
| vec[7] = float(sizes.std()) if n > 1 else 0.0 | |
| # ββ [8-9] size distribution ββββββββββββββββββββββββββββββββββββββββββββββ | |
| vec[8] = _size_entropy(sizes) | |
| vec[9] = float(sizes.max() / (sizes.sum() + 1e-6)) | |
| # ββ [10-13] spatial centroid & spread ββββββββββββββββββββββββββββββββββββ | |
| w_sum = sizes.sum() + 1e-6 | |
| vec[10] = float((cx_arr * sizes).sum() / w_sum) | |
| vec[11] = float((cy_arr * sizes).sum() / w_sum) | |
| vec[12] = float(cx_arr.std()) if n > 1 else 0.0 | |
| vec[13] = float(cy_arr.std()) if n > 1 else 0.0 | |
| # ββ [14-17] orientation ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vec[14] = float(np.mean(tilts)) / 90.0 | |
| vec[15] = float(np.std(tilts)) / 90.0 if n > 1 else 0.0 | |
| fs_arr = np.array(frontal_scores, dtype=np.float32) | |
| vec[16] = float(fs_arr.mean()) | |
| vec[17] = float((fs_arr > 0.6).mean()) | |
| # ββ [18-22] composition ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| vec[18] = float(cy_arr.mean()) - 0.5 | |
| vec[19] = float(cy_arr.std() / (cx_arr.std() + 1e-6)) if n > 1 else 0.0 | |
| mean_pd, min_pd, clust = _pairwise_stats(cx_arr, cy_arr) | |
| vec[20] = mean_pd | |
| vec[21] = min_pd | |
| vec[22] = clust | |
| # ββ [23-26] horizontal histogram βββββββββββββββββββββββββββββββββββββββββ | |
| h_hist, _ = np.histogram(cx_arr, bins=4, range=(0.0, 1.0)) | |
| vec[23:27] = h_hist.astype(np.float32) / (n + 1e-6) | |
| # ββ [27-30] vertical histogram βββββββββββββββββββββββββββββββββββββββββββ | |
| v_hist, _ = np.histogram(cy_arr, bins=4, range=(0.0, 1.0)) | |
| vec[27:31] = v_hist.astype(np.float32) / (n + 1e-6) | |
| # ββ [31-38] 4Γ2 spatial grid βββββββββββββββββββββββββββββββββββββββββββββ | |
| grid = np.zeros((2, 4), dtype=np.float32) | |
| for cx, cy in zip(cx_list, cy_list): | |
| r = min(int(cy * 2), 1) | |
| c = min(int(cx * 4), 3) | |
| grid[r, c] += 1.0 | |
| grid /= (n + 1e-6) | |
| vec[31:39] = grid.ravel() | |
| return 1, n, vec.tolist() | |
| def _is_correct(v) -> bool: | |
| return hasattr(v, "__len__") and len(v) == FACE_DIMS | |
| def load_existing() -> pd.DataFrame: | |
| if os.path.exists(OUTPUT): | |
| df = pd.read_parquet(OUTPUT) | |
| if "face_vector" not in df.columns: | |
| return pd.DataFrame(columns=FACE_COLS) | |
| return df[FACE_COLS] | |
| return pd.DataFrame(columns=FACE_COLS) | |
| def main(): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--limit", type=int, default=None) | |
| parser.add_argument("--save-every", type=int, default=500) | |
| parser.add_argument("--score-thr", type=float, default=SCORE_THR) | |
| parser.add_argument("--nms-thr", type=float, default=NMS_THR) | |
| parser.add_argument("--subset", default=None, | |
| help="CSV with a 'filename' column to restrict processing to") | |
| args = parser.parse_args() | |
| ensure_model() | |
| meta = pd.read_csv(METADATA_CSV, dtype=str)[["filename"]] | |
| if args.subset: | |
| keep = set(pd.read_csv(args.subset, dtype=str)["filename"].tolist()) | |
| meta = meta[meta["filename"].isin(keep)].reset_index(drop=True) | |
| print(f"Subset: {len(meta)} filenames from {args.subset}") | |
| existing = load_existing() | |
| if "face_vector" in existing.columns and len(existing): | |
| correct_mask = existing["face_vector"].apply(_is_correct) | |
| correct = existing[correct_mask].copy() | |
| else: | |
| correct = pd.DataFrame(columns=FACE_COLS) | |
| done_fns = set(correct["filename"].tolist()) | |
| todo = meta[~meta["filename"].isin(done_fns)].reset_index(drop=True) | |
| if args.limit: | |
| todo = todo.head(args.limit) | |
| n_stale = len(existing) - len(correct) | |
| print(f"Faces (YuNet {FACE_DIMS}-dim, score_thr={args.score_thr}): " | |
| f"{len(correct)} correct, {n_stale} stale, {len(todo)} new.") | |
| if todo.empty: | |
| print("Nothing to do.") | |
| return | |
| new_rows = [] | |
| for _, meta_row in tqdm(todo.iterrows(), total=len(todo), desc="Faces"): | |
| fn = meta_row["filename"] | |
| img_path = os.path.join(IMAGES_DIR, fn) | |
| detected, n_faces, face_vec = 0, 0, _zero_vec() | |
| if os.path.exists(img_path): | |
| img = cv2.imread(img_path) | |
| if img is not None: | |
| h, w = img.shape[:2] | |
| detector = cv2.FaceDetectorYN.create( | |
| MODEL_PATH, "", (w, h), | |
| score_threshold=args.score_thr, | |
| nms_threshold=args.nms_thr, | |
| ) | |
| _, faces = detector.detect(img) | |
| detected, n_faces, face_vec = encode_faces(faces, h, w) | |
| new_rows.append({ | |
| "filename": fn, | |
| "face_detected": detected, | |
| "face_count": n_faces, | |
| "face_vector": face_vec, | |
| }) | |
| if len(new_rows) >= args.save_every: | |
| correct = pd.concat([correct, pd.DataFrame(new_rows)], ignore_index=True) | |
| correct.to_parquet(OUTPUT, index=False) | |
| new_rows = [] | |
| if new_rows: | |
| correct = pd.concat([correct, pd.DataFrame(new_rows)], ignore_index=True) | |
| correct.to_parquet(OUTPUT, index=False) | |
| n_det = int(correct["face_detected"].sum()) | |
| n_tot = len(correct) | |
| print(f"Done. {n_tot} rows, {n_det} faces detected ({100*n_det/n_tot:.1f}%)") | |
| print(f"Saved β {OUTPUT}") | |
| if __name__ == "__main__": | |
| main() | |