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,
    }