| """ |
| Video pre-processing pipeline for the demo. |
| |
| This mirrors 01_preprocessing.ipynb conceptually, but we use OpenCV's bundled |
| YuNet face detector instead of MTCNN because facenet-pytorch is incompatible |
| with the Python 3.13 + numpy 2.x + torch 2.11 stack on HF Spaces. |
| |
| Pipeline: |
| video file |
| -> sample 24 evenly-spaced frames |
| -> detect the largest face per frame with YuNet |
| -> crop with 30% margin and resize to 224x224 |
| -> stack into [24, 3, 224, 224] float tensor in [0, 1] |
| -> ImageNet-normalize for the model |
| """ |
|
|
| from typing import List, Optional |
|
|
| import cv2 |
| import numpy as np |
| import torch |
| from huggingface_hub import hf_hub_download |
| from PIL import Image |
|
|
|
|
| |
| FRAMES_PER_CLIP = 24 |
| FACE_CROP_SIZE = 224 |
| FACE_MARGIN_RATIO = 0.3 |
|
|
| IMAGENET_MEAN = [0.485, 0.456, 0.406] |
| IMAGENET_STD = [0.229, 0.224, 0.225] |
|
|
| |
| |
| |
| YUNET_HF_REPO_ID = "opencv/face_detection_yunet" |
| YUNET_HF_FILENAME = "face_detection_yunet_2023mar.onnx" |
|
|
|
|
| def build_yunet_face_detector(): |
| """Create a YuNet face detector. Input size is reset per frame.""" |
| model_path = hf_hub_download( |
| repo_id=YUNET_HF_REPO_ID, |
| filename=YUNET_HF_FILENAME, |
| ) |
| detector = cv2.FaceDetectorYN.create( |
| model=model_path, |
| config="", |
| input_size=(320, 320), |
| score_threshold=0.6, |
| nms_threshold=0.3, |
| top_k=5000, |
| ) |
| return detector |
|
|
|
|
| def sample_evenly_spaced_frame_indices(total_frame_count: int, num_frames_wanted: int) -> List[int]: |
| """Return num_frames_wanted indices spread evenly across a video.""" |
| if total_frame_count <= num_frames_wanted: |
| indices = list(range(total_frame_count)) |
| while len(indices) < num_frames_wanted: |
| indices.append(total_frame_count - 1) |
| return indices |
| return np.linspace(0, total_frame_count - 1, num=num_frames_wanted, dtype=int).tolist() |
|
|
|
|
| def read_frames_with_opencv(video_path: str, frame_indices: List[int]) -> Optional[np.ndarray]: |
| """Load the requested frames using OpenCV. Returns [N, H, W, 3] uint8 in RGB.""" |
| video_capture = cv2.VideoCapture(video_path) |
| if not video_capture.isOpened(): |
| return None |
|
|
| indices_to_grab = set(frame_indices) |
| frames_by_index = {} |
| current_frame_index = 0 |
|
|
| |
| |
| max_index_needed = max(frame_indices) |
| while current_frame_index <= max_index_needed: |
| read_success, frame_bgr = video_capture.read() |
| if not read_success: |
| break |
| if current_frame_index in indices_to_grab: |
| |
| frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) |
| frames_by_index[current_frame_index] = frame_rgb |
| current_frame_index += 1 |
|
|
| video_capture.release() |
|
|
| if not frames_by_index: |
| return None |
|
|
| |
| |
| last_available_frame = frames_by_index[max(frames_by_index.keys())] |
| ordered_frames = [ |
| frames_by_index.get(idx, last_available_frame) for idx in frame_indices |
| ] |
| return np.stack(ordered_frames, axis=0) |
|
|
|
|
| def crop_face_with_margin(frame_rgb: np.ndarray, face_box: np.ndarray, margin_ratio: float) -> np.ndarray: |
| """Crop with extra margin around a face bounding box [x1, y1, x2, y2].""" |
| frame_height, frame_width = frame_rgb.shape[:2] |
| x1, y1, x2, y2 = face_box |
| box_width = x2 - x1 |
| box_height = y2 - y1 |
|
|
| margin_x = box_width * margin_ratio |
| margin_y = box_height * margin_ratio |
|
|
| x1_expanded = max(0, int(x1 - margin_x)) |
| y1_expanded = max(0, int(y1 - margin_y)) |
| x2_expanded = min(frame_width, int(x2 + margin_x)) |
| y2_expanded = min(frame_height, int(y2 + margin_y)) |
|
|
| return frame_rgb[y1_expanded:y2_expanded, x1_expanded:x2_expanded] |
|
|
|
|
| def resize_to_square(image_rgb: np.ndarray, target_size: int) -> np.ndarray: |
| """Resize an RGB image to (target_size, target_size).""" |
| pil_image = Image.fromarray(image_rgb) |
| pil_resized = pil_image.resize((target_size, target_size), Image.BILINEAR) |
| return np.array(pil_resized) |
|
|
|
|
| def detect_largest_face_per_frame(frames_rgb_uint8: np.ndarray, face_detector) -> List[Optional[np.ndarray]]: |
| """Run YuNet on each frame and return the largest detected box per frame. |
| |
| Returns a list of [x1, y1, x2, y2] numpy arrays, with None where no face |
| was found. |
| """ |
| largest_box_per_frame = [] |
|
|
| for frame_rgb in frames_rgb_uint8: |
| frame_height, frame_width = frame_rgb.shape[:2] |
| |
| face_detector.setInputSize((frame_width, frame_height)) |
|
|
| |
| frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR) |
| _, detections = face_detector.detect(frame_bgr) |
|
|
| if detections is None or len(detections) == 0: |
| largest_box_per_frame.append(None) |
| continue |
|
|
| |
| |
| boxes_xyxy = [] |
| for detection in detections: |
| x, y, width, height = detection[:4] |
| boxes_xyxy.append([x, y, x + width, y + height]) |
|
|
| box_areas = [(b[2] - b[0]) * (b[3] - b[1]) for b in boxes_xyxy] |
| largest_index = int(np.argmax(box_areas)) |
| largest_box_per_frame.append(np.array(boxes_xyxy[largest_index], dtype=np.float32)) |
|
|
| return largest_box_per_frame |
|
|
|
|
| def process_video_to_clip_tensor(video_path: str, face_detector, progress_callback=None): |
| """ |
| Run the full preprocessing pipeline on a single uploaded video. |
| |
| Returns a dict with: |
| - clip_tensor: [24, 3, 224, 224] float32 in [0, 1] (un-normalized for display) |
| - clip_normalized: [24, 3, 224, 224] float32, ImageNet-normalized (model input) |
| - face_crops_rgb: list of 24 uint8 RGB images of the cropped faces (for display) |
| - error: optional error message string |
| |
| progress_callback is an optional function(message, fraction) the caller can pass |
| in to update a Streamlit progress bar. |
| """ |
| def update_progress(message, fraction): |
| if progress_callback is not None: |
| progress_callback(message, fraction) |
|
|
| update_progress("Opening video", 0.05) |
| video_capture = cv2.VideoCapture(video_path) |
| if not video_capture.isOpened(): |
| return {"error": "Could not open the video file. Is it a valid MP4?"} |
| total_frames = int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT)) |
| video_capture.release() |
|
|
| if total_frames <= 0: |
| return {"error": "The video appears to have zero frames."} |
|
|
| update_progress("Sampling frames", 0.15) |
| frame_indices = sample_evenly_spaced_frame_indices(total_frames, FRAMES_PER_CLIP) |
| sampled_frames = read_frames_with_opencv(video_path, frame_indices) |
| if sampled_frames is None or len(sampled_frames) == 0: |
| return {"error": "Failed to decode any frames from the video."} |
|
|
| update_progress("Detecting faces", 0.40) |
| face_boxes = detect_largest_face_per_frame(sampled_frames, face_detector) |
|
|
| update_progress("Cropping and resizing", 0.70) |
| cropped_resized_frames = [] |
| last_valid_box = None |
| for frame_rgb, face_box in zip(sampled_frames, face_boxes): |
| |
| if face_box is None: |
| face_box = last_valid_box |
| else: |
| last_valid_box = face_box |
|
|
| if face_box is None: |
| return {"error": "No face was detected in any of the sampled frames."} |
|
|
| face_crop = crop_face_with_margin(frame_rgb, face_box, FACE_MARGIN_RATIO) |
| if face_crop.size == 0: |
| return {"error": "An empty crop was produced. The face box may be invalid."} |
| face_crop_resized = resize_to_square(face_crop, FACE_CROP_SIZE) |
| cropped_resized_frames.append(face_crop_resized) |
|
|
| update_progress("Building tensor", 0.90) |
| |
| clip_array = np.stack(cropped_resized_frames, axis=0) |
| clip_tensor = torch.from_numpy(clip_array).permute(0, 3, 1, 2).float() / 255.0 |
|
|
| |
| mean_tensor = torch.tensor(IMAGENET_MEAN).view(1, 3, 1, 1) |
| std_tensor = torch.tensor(IMAGENET_STD).view(1, 3, 1, 1) |
| clip_normalized = (clip_tensor - mean_tensor) / std_tensor |
|
|
| update_progress("Preprocessing done", 1.0) |
| return { |
| "clip_tensor": clip_tensor, |
| "clip_normalized": clip_normalized, |
| "face_crops_rgb": cropped_resized_frames, |
| "error": None, |
| } |
|
|