362 lines
14 KiB
Python
362 lines
14 KiB
Python
"""ICEYOU - Authorized User Face Enrollment
|
|
|
|
Captures multiple webcam photos of the authorized user from various angles,
|
|
saves them under faces/owner/, and trains an OpenCV LBPH face-recognition model.
|
|
|
|
Usage:
|
|
python face_enrollment.py
|
|
(optionally append --name <label> to enroll secondary authorized users)
|
|
|
|
Controls in the live preview window:
|
|
SPACE - Capture the current frame (only if a face is detected)
|
|
R - Restart enrollment
|
|
Q/ESC - Quit
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import cv2
|
|
|
|
|
|
REQUIRED_POSES = [
|
|
"Look straight at the camera",
|
|
"Slowly turn your head to the LEFT",
|
|
"Slowly turn your head to the RIGHT",
|
|
"Tilt your head slightly UP",
|
|
"Tilt your head slightly DOWN",
|
|
]
|
|
|
|
MIN_CAPTURES = 3
|
|
TARGET_CAPTURES = len(REQUIRED_POSES)
|
|
|
|
FACES_DIR = Path("faces")
|
|
MODEL_FILE = FACES_DIR / "model.yml"
|
|
LABELS_FILE = FACES_DIR / "labels.json"
|
|
|
|
|
|
def load_face_detectors():
|
|
"""Load both frontal and profile Haar cascades for better coverage."""
|
|
base = Path(cv2.data.haarcascades)
|
|
frontal_path = base / "haarcascade_frontalface_default.xml"
|
|
alt_path = base / "haarcascade_frontalface_alt2.xml"
|
|
profile_path = base / "haarcascade_profileface.xml"
|
|
if not frontal_path.exists():
|
|
print(f"ERROR: Haar cascade not found at {frontal_path}")
|
|
sys.exit(1)
|
|
detectors = {
|
|
"frontal": cv2.CascadeClassifier(str(frontal_path)),
|
|
"alt2": cv2.CascadeClassifier(str(alt_path)) if alt_path.exists() else None,
|
|
"profile": cv2.CascadeClassifier(str(profile_path)) if profile_path.exists() else None,
|
|
}
|
|
return detectors
|
|
|
|
|
|
def detect_face(detectors: dict, gray):
|
|
"""Run multiple cascades and return list of face rects.
|
|
Tries frontal first, then alt2, then profile (also mirrored for the other side).
|
|
"""
|
|
# Improve contrast for low-light conditions
|
|
eq = cv2.equalizeHist(gray)
|
|
|
|
params = dict(scaleFactor=1.1, minNeighbors=4, minSize=(60, 60))
|
|
|
|
faces = detectors["frontal"].detectMultiScale(eq, **params)
|
|
if len(faces) == 0 and detectors["alt2"] is not None:
|
|
faces = detectors["alt2"].detectMultiScale(eq, **params)
|
|
if len(faces) == 0 and detectors["profile"] is not None:
|
|
faces = detectors["profile"].detectMultiScale(eq, **params)
|
|
if len(faces) == 0:
|
|
# Mirror to detect the opposite-facing profile
|
|
flipped = cv2.flip(eq, 1)
|
|
faces = detectors["profile"].detectMultiScale(flipped, **params)
|
|
if len(faces) > 0:
|
|
w = eq.shape[1]
|
|
faces = [(w - x - fw, y, fw, fh) for (x, y, fw, fh) in faces]
|
|
return faces
|
|
|
|
|
|
def run_capture_session(user_label: str, user_dir: Path, camera_index: int = 0) -> List[Path]:
|
|
"""Open camera, prompt for poses, save captures. Returns list of saved paths."""
|
|
user_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"Opening camera index {camera_index}...")
|
|
cap = cv2.VideoCapture(camera_index, cv2.CAP_DSHOW)
|
|
if not cap.isOpened():
|
|
print(f"ERROR: Could not open webcam at index {camera_index}.")
|
|
print("Try a different --camera value (0, 1, 2, ...).")
|
|
sys.exit(1)
|
|
|
|
detectors = load_face_detectors()
|
|
captures: List[Path] = []
|
|
pose_idx = 0
|
|
|
|
# Capture trigger flag (set by mouse click, auto-capture, or SPACE key)
|
|
capture_requested = [False]
|
|
|
|
def on_mouse(event, x, y, flags, param):
|
|
if event == cv2.EVENT_LBUTTONDOWN:
|
|
capture_requested[0] = True
|
|
|
|
print("\n=== ICEYOU Face Enrollment ===")
|
|
print(f"User label: {user_label}")
|
|
print(f"Target captures: {TARGET_CAPTURES} (minimum {MIN_CAPTURES})")
|
|
print("Controls:")
|
|
print(" - CLICK the preview window = capture (most reliable)")
|
|
print(" - SPACE / ENTER / C = capture (window must have focus)")
|
|
print(" - Auto-capture: hold a steady face for ~2s = capture automatically")
|
|
print(" - R = restart, Q/ESC = quit\n")
|
|
|
|
win_name = f"ICEYOU Enrollment - {user_label}"
|
|
cv2.namedWindow(win_name, cv2.WINDOW_NORMAL)
|
|
cv2.setMouseCallback(win_name, on_mouse)
|
|
|
|
import time
|
|
steady_face_start = None # timestamp when a face first became visible & still
|
|
|
|
try:
|
|
while True:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
print("Camera read failed.")
|
|
break
|
|
|
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
faces = detect_face(detectors, gray)
|
|
face_detected = len(faces) > 0
|
|
|
|
display = frame.copy()
|
|
for (x, y, w, h) in faces:
|
|
cv2.rectangle(display, (x, y), (x + w, y + h), (0, 255, 0), 2)
|
|
|
|
done = pose_idx >= TARGET_CAPTURES
|
|
instruction = "ENROLLMENT COMPLETE - press Q to finish" if done else REQUIRED_POSES[pose_idx]
|
|
|
|
# Auto-capture: 2-second steady face countdown
|
|
auto_countdown_remaining = None
|
|
if not done and face_detected:
|
|
if steady_face_start is None:
|
|
steady_face_start = time.time()
|
|
elapsed = time.time() - steady_face_start
|
|
auto_countdown_remaining = max(0.0, 2.0 - elapsed)
|
|
if elapsed >= 2.0:
|
|
capture_requested[0] = True
|
|
steady_face_start = None
|
|
else:
|
|
steady_face_start = None
|
|
|
|
overlay = display.copy()
|
|
cv2.rectangle(overlay, (0, 0), (display.shape[1], 110), (0, 0, 0), -1)
|
|
cv2.addWeighted(overlay, 0.6, display, 0.4, 0, display)
|
|
|
|
cv2.putText(display, f"[{len(captures)}/{TARGET_CAPTURES}] {instruction}",
|
|
(10, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
|
|
if face_detected:
|
|
status = f"Face detected ({len(faces)}) - CLICK or SPACE"
|
|
color = (0, 255, 0)
|
|
else:
|
|
status = "No face auto-detected - CLICK / SPACE = manual center crop"
|
|
color = (0, 200, 255)
|
|
cv2.putText(display, status, (10, 70),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2)
|
|
|
|
if auto_countdown_remaining is not None and auto_countdown_remaining > 0:
|
|
cv2.putText(display, f"Auto-capture in {auto_countdown_remaining:.1f}s",
|
|
(10, 100), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 0), 2)
|
|
|
|
# Draw center-crop guide when no face detected (helps user frame themselves)
|
|
if not face_detected:
|
|
h, w = display.shape[:2]
|
|
side = min(h, w) * 6 // 10
|
|
cx0, cy0 = (w - side) // 2, (h - side) // 2
|
|
cv2.rectangle(display, (cx0, cy0), (cx0 + side, cy0 + side), (0, 200, 255), 2)
|
|
|
|
cv2.imshow(win_name, display)
|
|
key = cv2.waitKey(30) & 0xFF
|
|
|
|
# Capture keys: SPACE (32), ENTER (13), C (99)
|
|
capture_keys = {32, 13, ord("c"), ord("C")}
|
|
quit_keys = {ord("q"), ord("Q"), 27}
|
|
restart_keys = {ord("r"), ord("R")}
|
|
|
|
if key in quit_keys:
|
|
break
|
|
elif key in restart_keys:
|
|
for p in captures:
|
|
try:
|
|
p.unlink()
|
|
except Exception:
|
|
pass
|
|
captures.clear()
|
|
pose_idx = 0
|
|
print("Restarting enrollment...")
|
|
elif key in capture_keys:
|
|
capture_requested[0] = True
|
|
elif key not in (255, 0):
|
|
print(f" (unrecognized key code: {key})")
|
|
|
|
# Handle the capture request from any source (mouse, key, auto-capture)
|
|
if capture_requested[0]:
|
|
capture_requested[0] = False
|
|
if done:
|
|
print("Enrollment already complete - press Q to finish.")
|
|
continue
|
|
if face_detected:
|
|
(x, y, w, h) = max(faces, key=lambda f: f[2] * f[3])
|
|
m = int(0.1 * max(w, h))
|
|
x0 = max(x - m, 0)
|
|
y0 = max(y - m, 0)
|
|
x1 = min(x + w + m, gray.shape[1])
|
|
y1 = min(y + h + m, gray.shape[0])
|
|
face_crop = gray[y0:y1, x0:x1]
|
|
crop_mode = "detected"
|
|
else:
|
|
h, w = gray.shape
|
|
side = min(h, w) * 6 // 10
|
|
cy0 = (h - side) // 2
|
|
cx0 = (w - side) // 2
|
|
face_crop = gray[cy0:cy0 + side, cx0:cx0 + side]
|
|
crop_mode = "manual-center"
|
|
face_resized = cv2.resize(face_crop, (200, 200))
|
|
|
|
filename = user_dir / f"capture_{pose_idx + 1:02d}.png"
|
|
cv2.imwrite(str(filename), face_resized)
|
|
captures.append(filename)
|
|
print(f" Captured ({crop_mode}): {filename.name} ({REQUIRED_POSES[pose_idx]})")
|
|
pose_idx += 1
|
|
steady_face_start = None # reset auto-capture so it waits again
|
|
finally:
|
|
cap.release()
|
|
cv2.destroyAllWindows()
|
|
|
|
return captures
|
|
|
|
|
|
def train_model(faces_root: Path) -> bool:
|
|
"""Train an LBPH model on all images under faces_root/<label>/*.png.
|
|
Saves to faces/model.yml and labels.json (label_id -> label_name).
|
|
Requires opencv-contrib-python (cv2.face module).
|
|
"""
|
|
try:
|
|
recognizer = cv2.face.LBPHFaceRecognizer_create()
|
|
except AttributeError:
|
|
print("\nWARNING: cv2.face is unavailable. Install opencv-contrib-python:")
|
|
print(" pip install --upgrade --force-reinstall opencv-contrib-python")
|
|
print("Photos are saved but recognition model NOT trained.")
|
|
return False
|
|
|
|
samples = []
|
|
sample_labels = []
|
|
label_map = {}
|
|
|
|
user_dirs = sorted([d for d in faces_root.iterdir() if d.is_dir()])
|
|
if not user_dirs:
|
|
print("No user directories to train from.")
|
|
return False
|
|
|
|
for label_id, user_dir in enumerate(user_dirs):
|
|
label_map[label_id] = user_dir.name
|
|
images = sorted(user_dir.glob("*.png")) + sorted(user_dir.glob("*.jpg"))
|
|
for img_path in images:
|
|
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
|
if img is None:
|
|
continue
|
|
samples.append(img)
|
|
sample_labels.append(label_id)
|
|
|
|
if not samples:
|
|
print("No samples found to train.")
|
|
return False
|
|
|
|
import numpy as np
|
|
recognizer.train(samples, np.array(sample_labels))
|
|
MODEL_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
recognizer.save(str(MODEL_FILE))
|
|
with open(LABELS_FILE, "w", encoding="utf-8") as f:
|
|
json.dump({str(k): v for k, v in label_map.items()}, f, indent=2)
|
|
|
|
print(f"\nTrained on {len(samples)} samples across {len(label_map)} user(s).")
|
|
print(f"Model saved: {MODEL_FILE}")
|
|
print(f"Labels saved: {LABELS_FILE}")
|
|
return True
|
|
|
|
|
|
def _default_camera_index() -> int:
|
|
"""Read camera_device_index from config.json if present, else 0."""
|
|
cfg_path = Path("config.json")
|
|
if cfg_path.exists():
|
|
try:
|
|
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
return int(json.load(f).get("camera_device_index", 0))
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
|
|
def list_available_cameras(max_index: int = 5) -> List[int]:
|
|
"""Probe camera indices 0..max_index-1 and return those that open successfully."""
|
|
available = []
|
|
for i in range(max_index):
|
|
cap = cv2.VideoCapture(i, cv2.CAP_DSHOW)
|
|
if cap.isOpened():
|
|
available.append(i)
|
|
cap.release()
|
|
return available
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="ICEYOU face enrollment")
|
|
parser.add_argument("--name", default="owner",
|
|
help="Label for this user (default: owner)")
|
|
parser.add_argument("--camera", "-c", type=int, default=None,
|
|
help="Camera device index (default: from config.json or 0)")
|
|
parser.add_argument("--list-cameras", action="store_true",
|
|
help="Probe and list available camera indices, then exit")
|
|
args = parser.parse_args()
|
|
|
|
if args.list_cameras:
|
|
cams = list_available_cameras()
|
|
if cams:
|
|
print(f"Available camera indices: {cams}")
|
|
else:
|
|
print("No cameras detected.")
|
|
return
|
|
|
|
camera_index = args.camera if args.camera is not None else _default_camera_index()
|
|
user_label = args.name.strip().replace(" ", "_") or "owner"
|
|
user_dir = FACES_DIR / user_label
|
|
|
|
if user_dir.exists() and any(user_dir.iterdir()):
|
|
resp = input(f"Existing enrollment for '{user_label}' found. Overwrite? [y/N]: ").strip().lower()
|
|
if resp != "y":
|
|
print("Aborted.")
|
|
return
|
|
for p in user_dir.glob("*"):
|
|
try:
|
|
p.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
captures = run_capture_session(user_label, user_dir, camera_index=camera_index)
|
|
|
|
if len(captures) < MIN_CAPTURES:
|
|
print(f"\nOnly {len(captures)} photo(s) captured. Need at least {MIN_CAPTURES}.")
|
|
print("Enrollment not finalized.")
|
|
return
|
|
|
|
print(f"\n{len(captures)} photo(s) saved to {user_dir}")
|
|
print("Training recognition model...")
|
|
train_model(FACES_DIR)
|
|
|
|
print("\nEnrollment complete.")
|
|
print("To enable recognition, set in config.json:")
|
|
print(' "face_recognition_enabled": true')
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|