cvikl's picture
First commit.
fb91fdc
Raw
History Blame Contribute Delete
12.4 kB
"""
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()