File size: 9,138 Bytes
0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 2221661 0c4dad7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | """
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
# Same constants as the training-time preprocessing
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 ONNX model. OpenCV hosts the official copy on the Hugging Face Hub.
# Using hf_hub_download keeps the cache logic and Xet handling consistent
# with how we pull the main model checkpoint.
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), # placeholder; setInputSize is called per frame
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
# We just iterate through frames sequentially. Seeking with CAP_PROP_POS_FRAMES
# is unreliable on many codecs, so a linear scan is safer.
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:
# OpenCV gives BGR. Convert to RGB.
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
# Build the output array in the requested order. If we ran out of frames
# before hitting all indices, pad with the last one we did get.
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]
# YuNet needs the input size set before each detect() call.
face_detector.setInputSize((frame_width, frame_height))
# YuNet expects BGR
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
# Each detection row is [x, y, w, h, 5 landmark x/y pairs, score] = 15 values.
# Convert (x, y, w, h) -> (x1, y1, x2, y2) and pick the largest by area.
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 YuNet missed this frame, fall back to the most recent valid box.
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)
# [T, H, W, C] uint8 -> [T, C, H, W] float32 in [0, 1]
clip_array = np.stack(cropped_resized_frames, axis=0)
clip_tensor = torch.from_numpy(clip_array).permute(0, 3, 1, 2).float() / 255.0
# ImageNet normalization for model input
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,
}
|