synthsenses-api / data /model_b_datasets /extract_features.py
parina004
Initial HF deployment
e6efe7e
Raw
History Blame Contribute Delete
22.7 kB
## For each of the 4,223 labelled videos this script:
## 1. Downloads the first 60 seconds using yt-dlp (3 retries with backoff)
## 2. Extracts 20 features and logs every single one to the terminal
## 3. Writes the row to features.csv immediately (safe against crashes)
## 4. Deletes the clip right away β€” peak disk usage stays under ~500 MB
## Every video is wrapped in its own try/except so one bad video never
## kills the whole run. BATCH_SIZE controls how many videos per loop.
## After each batch it automatically starts the next one β€” no need to
## re-run manually. It stops only when all videos are done.
## yt-dlp uses browser cookies so YouTube doesn't block it as a bot.
## Make sure BROWSER below matches the browser you're logged into YouTube with.
## Run with: uv run data/model_b_datasets/extract_features.py
import csv
import gc
import json
import logging
import re
import subprocess
import time
from datetime import datetime
from pathlib import Path
import cv2
import mediapipe as mp
import numpy as np
import piq
import torch
from deepface import DeepFace
from textblob import TextBlob
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger(__name__)
## file paths
BASE = Path(__file__).parent
INPUT = BASE / "csv" / "labelled_videos.csv"
OUTPUT = BASE / "csv" / "features.csv"
CKPT = BASE / ".extract_checkpoint.json"
TMPDIR = BASE / ".tmp_clips"
TMPDIR.mkdir(exist_ok=True)
## settings
CLIP_SECONDS = 30 ## analyse first 30s only at inference (60s for training)
FRAME_STEP_S = 3 ## sample 1 frame every 3 seconds (was 1/sec)
EMOTION_STEP = 3 ## run DeepFace every 3 frames (was every 10)
BATCH_SIZE = 200 ## how many videos to process per loop before pausing for GC
## the script loops automatically β€” no need to re-run manually
DOWNLOAD_RETRIES = 3 ## how many times to retry a failed download
GC_EVERY = 50 ## call gc.collect() every N videos to release RAM
BROWSER = "chrome" ## browser to borrow YouTube cookies from β€” change to
## "firefox", "safari", "edge", or "brave" if needed
## output columns
CSV_FIELDS = [
"video_id", "youtube_url", "category", "label",
"brisque_score", "color_vibrancy", "motion_intensity",
"face_presence_ratio", "face_emotion_joy", "face_emotion_surprise",
"thumbnail_brightness",
"tempo_bpm", "rms_energy", "speech_ratio",
"zero_crossing_rate", "beat_strength",
"title_sentiment", "title_length", "title_has_question", "title_has_number",
"description_length", "tag_count", "upload_hour", "upload_day",
"like_to_view_ratio", "comment_to_view_ratio",
]
## checkpoint helpers
def load_checkpoint() -> tuple[set, set]:
if CKPT.exists():
data = json.loads(CKPT.read_text())
return set(data.get("done", [])), set(data.get("failed", []))
return set(), set()
def save_checkpoint(done: set, failed: set):
CKPT.write_text(json.dumps({"done": list(done), "failed": list(failed)}, indent=2))
## misc helpers
def _safe_float(val, default: float = 0.0) -> float:
try:
return float(val) if val not in ("", None) else default
except (ValueError, TypeError):
return default
def _cleanup_stale_files():
## deletes any .mp4 or .wav files left behind by a previous crash
removed = 0
for f in TMPDIR.glob("*"):
if f.suffix in (".mp4", ".wav"):
f.unlink(missing_ok=True)
removed += 1
if removed:
log.info(f"Cleaned up {removed} stale temp file(s) from {TMPDIR}")
## download
def download_clip(video_id: str) -> "Path | None":
out_path = TMPDIR / f"{video_id}.mp4"
url = f"https://www.youtube.com/watch?v={video_id}"
for attempt in range(1, DOWNLOAD_RETRIES + 1):
if out_path.exists():
out_path.unlink(missing_ok=True)
result = subprocess.run(
[
"yt-dlp",
"--quiet",
"--no-warnings",
"--cookies-from-browser", BROWSER,
"--format",
"bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]"
"/best[height<=720][ext=mp4]/best[height<=720]",
"--download-sections", f"*0-{CLIP_SECONDS}",
"--force-keyframes-at-cuts",
"--merge-output-format", "mp4",
"--output", str(out_path),
url,
],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode == 0 and out_path.exists() and out_path.stat().st_size > 0:
return out_path
stderr = result.stderr.strip()[:300]
## permanent failures β€” no point retrying these
if any(phrase in stderr for phrase in (
"Video unavailable", "Private video", "has been removed",
"This video is not available", "age-restricted",
)):
log.warning(f" [download] permanent failure: {stderr[:120]}")
return None
if attempt < DOWNLOAD_RETRIES:
wait = 2 ** attempt ## 2s, then 4s
log.warning(
f" [download] attempt {attempt}/{DOWNLOAD_RETRIES} failed β€” "
f"retrying in {wait}s ({stderr[:80]})"
)
time.sleep(wait)
else:
log.warning(f" [download] all {DOWNLOAD_RETRIES} attempts failed β€” skipping")
return None
## visual features
_face_detector = mp.solutions.face_detection.FaceDetection(
model_selection=0,
min_detection_confidence=0.5,
)
def _brisque(frame_rgb: np.ndarray) -> float:
if frame_rgb.shape[0] < 32 or frame_rgb.shape[1] < 32:
return float("nan")
try:
t = torch.from_numpy(frame_rgb).permute(2, 0, 1).unsqueeze(0).float() / 255.0
return float(piq.brisque(t, data_range=1.0))
except Exception:
return float("nan")
def extract_visual_features(video_path: Path) -> dict:
cap = cv2.VideoCapture(str(video_path))
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
sample_indices = [
int(i * fps)
for i in range(CLIP_SECONDS)
if int(i * fps) < total_frames
]
brisque_scores = []
saturations = []
motion_values = []
frames_with_face = 0
thumbnail_bright = float("nan")
prev_gray = None
joy_scores = []
surprise_scores = []
for i, frame_idx in enumerate(sample_indices):
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = cap.read()
if not ret:
continue
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if i == 0:
gray_first = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
thumbnail_bright = float(gray_first.mean())
bq = _brisque(rgb)
if not np.isnan(bq):
brisque_scores.append(bq)
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
saturations.append(float(hsv[:, :, 1].mean()))
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
flow = cv2.calcOpticalFlowFarneback(
prev_gray, gray, None,
pyr_scale=0.5, levels=3, winsize=15,
iterations=3, poly_n=5, poly_sigma=1.2, flags=0,
)
motion_values.append(
float(np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2).mean())
)
prev_gray = gray
if _face_detector.process(rgb).detections:
frames_with_face += 1
if i % EMOTION_STEP == 0:
try:
analysis = DeepFace.analyze(
img_path=rgb,
actions=["emotion"],
enforce_detection=False,
silent=True,
)
emo = analysis[0]["emotion"] if isinstance(analysis, list) else analysis["emotion"]
joy_scores.append(emo.get("happy", 0.0))
surprise_scores.append(emo.get("surprise", 0.0))
except Exception:
pass
finally:
cap.release()
n = len(sample_indices) or 1
result = {
"brisque_score": round(float(np.mean(brisque_scores)), 4) if brisque_scores else float("nan"),
"color_vibrancy": round(float(np.mean(saturations)), 4) if saturations else float("nan"),
"motion_intensity": round(float(np.mean(motion_values)), 4) if motion_values else 0.0,
"face_presence_ratio": round(frames_with_face / n, 4),
"face_emotion_joy": round(float(np.mean(joy_scores)), 4) if joy_scores else 0.0,
"face_emotion_surprise": round(float(np.mean(surprise_scores)), 4) if surprise_scores else 0.0,
"thumbnail_brightness": round(thumbnail_bright, 4),
}
log.info(f" [visual] brisque_score = {result['brisque_score']}")
log.info(f" [visual] color_vibrancy = {result['color_vibrancy']}")
log.info(f" [visual] motion_intensity = {result['motion_intensity']}")
log.info(f" [visual] face_presence_ratio = {result['face_presence_ratio']}")
log.info(f" [visual] face_emotion_joy = {result['face_emotion_joy']}")
log.info(f" [visual] face_emotion_surprise = {result['face_emotion_surprise']}")
log.info(f" [visual] thumbnail_brightness = {result['thumbnail_brightness']}")
return result
## audio features
def _extract_audio_track(video_path: Path) -> "Path | None":
wav_path = TMPDIR / (video_path.stem + ".wav")
result = subprocess.run(
[
"ffmpeg", "-y",
"-i", str(video_path),
"-vn",
"-acodec", "pcm_s16le",
"-ar", "22050",
"-ac", "1",
"-t", str(CLIP_SECONDS),
str(wav_path),
],
capture_output=True,
timeout=60,
)
if result.returncode != 0 or not wav_path.exists():
return None
return wav_path
def extract_audio_features(video_path: Path) -> dict:
nan = float("nan")
defaults = {
"tempo_bpm": nan, "rms_energy": nan, "speech_ratio": nan,
"zero_crossing_rate": nan, "beat_strength": nan,
}
wav_path = _extract_audio_track(video_path)
if wav_path is None:
log.warning(" [audio] ffmpeg failed β€” all audio features = nan")
_log_audio(defaults)
return defaults
try:
import soundfile as sf
data, samplerate = sf.read(str(wav_path), dtype="float32", always_2d=False)
y = data.mean(axis=1) if data.ndim > 1 else data
if samplerate != 22050:
from math import gcd
from scipy.signal import resample_poly
g = gcd(22050, int(samplerate))
y = resample_poly(y, 22050 // g, int(samplerate) // g).astype(np.float32)
if len(y) == 0:
log.warning(" [audio] empty audio array β€” video may have no sound")
_log_audio(defaults)
return defaults
log.info(" [audio] audio loaded via soundfile (numpy/scipy)")
frame_length = 2048
hop_length = 512
n_frames = max(1, 1 + (len(y) - frame_length) // hop_length)
rms_vals = np.zeros(n_frames, dtype=np.float32)
zcr_vals = np.zeros(n_frames, dtype=np.float32)
for i in range(n_frames):
frame = y[i * hop_length : i * hop_length + frame_length]
if len(frame) < frame_length:
frame = np.pad(frame, (0, frame_length - len(frame)))
rms_vals[i] = np.sqrt(np.mean(frame ** 2))
zcr_vals[i] = float(np.mean(np.abs(np.diff(np.sign(frame)))) / 2)
rms_energy = round(float(np.mean(rms_vals)), 6)
zcr_mean = round(float(np.mean(zcr_vals)), 6)
speech_ratio = round(float(np.mean((zcr_vals > 0.05) & (zcr_vals < 0.35))), 4)
tempo_bpm = nan
beat_strength = nan
try:
from scipy.signal import correlate
onset_env = np.maximum(0.0, np.diff(rms_vals, prepend=rms_vals[0]))
if onset_env.std() > 1e-8:
ac = correlate(onset_env, onset_env, mode="full")[len(onset_env) - 1:]
ac = ac / (ac[0] + 1e-8)
frame_rate = 22050 / hop_length
p_min = max(2, int(frame_rate * 60 / 200))
p_max = min(len(ac) - 1, int(frame_rate * 60 / 40))
if p_max > p_min:
region = ac[p_min:p_max]
best_offset = int(np.argmax(region))
tempo_bpm = round(float(frame_rate * 60 / (best_offset + p_min)), 2)
beat_strength = round(float(region[best_offset]), 6)
except Exception as e:
log.warning(f" [audio] tempo/beat_strength failed: {e}")
result = {
"tempo_bpm": tempo_bpm,
"rms_energy": rms_energy,
"speech_ratio": speech_ratio,
"zero_crossing_rate": zcr_mean,
"beat_strength": beat_strength,
}
_log_audio(result)
return result
except Exception as e:
log.warning(f" [audio] unexpected error β€” {e}")
_log_audio(defaults)
return defaults
finally:
if wav_path and wav_path.exists():
wav_path.unlink(missing_ok=True)
def _log_audio(f: dict):
log.info(f" [audio] tempo_bpm = {f['tempo_bpm']}")
log.info(f" [audio] rms_energy = {f['rms_energy']}")
log.info(f" [audio] speech_ratio = {f['speech_ratio']}")
log.info(f" [audio] zero_crossing_rate = {f['zero_crossing_rate']}")
log.info(f" [audio] beat_strength = {f['beat_strength']}")
## metadata features
def extract_metadata_features(row: dict) -> dict:
title = row.get("title", "") or ""
description = row.get("description", "") or ""
tags_str = row.get("tags", "") or ""
upload_date = row.get("upload_date", "") or ""
sentiment = float(TextBlob(title).sentiment.polarity)
has_question = 1 if "?" in title else 0
has_number = 1 if re.search(r"\d", title) else 0
tag_count = len([t for t in tags_str.split("|") if t.strip()]) if tags_str else 0
try:
dt = datetime.fromisoformat(upload_date.replace("Z", "+00:00"))
upload_hour = dt.hour
upload_day = dt.weekday()
except Exception:
upload_hour = -1
upload_day = -1
result = {
"title_sentiment": round(sentiment, 4),
"title_length": len(title),
"title_has_question": has_question,
"title_has_number": has_number,
"description_length": len(description),
"tag_count": tag_count,
"upload_hour": upload_hour,
"upload_day": upload_day,
}
log.info(f" [meta] title_sentiment = {result['title_sentiment']}")
log.info(f" [meta] title_length = {result['title_length']}")
log.info(f" [meta] title_has_question = {result['title_has_question']}")
log.info(f" [meta] title_has_number = {result['title_has_number']}")
log.info(f" [meta] description_length = {result['description_length']}")
log.info(f" [meta] tag_count = {result['tag_count']}")
log.info(f" [meta] upload_hour = {result['upload_hour']}")
log.info(f" [meta] upload_day = {result['upload_day']}")
return result
## main
def extract() -> bool:
## returns True if there are still videos left to process, False if all done
if not INPUT.exists():
raise FileNotFoundError("labelled_videos.csv not found β€” run label_videos.py first.")
_cleanup_stale_files()
log.info("Loading labelled_videos.csv ...")
with open(INPUT, newline="", encoding="utf-8") as f:
all_rows = list(csv.DictReader(f))
log.info(f"Loaded {len(all_rows)} videos")
done, failed = load_checkpoint()
log.info(f"Checkpoint: {len(done)} done, {len(failed)} unavailable")
remaining = [r for r in all_rows if r["video_id"] not in done and r["video_id"] not in failed]
log.info(f"Remaining : {len(remaining)} videos")
if BATCH_SIZE > 0:
batch = remaining[:BATCH_SIZE]
log.info(f"Batch size: {BATCH_SIZE} β€” will stop after this batch and save checkpoint")
else:
batch = remaining
log.info("Batch size: unlimited (running all remaining videos)")
log.info("")
file_exists = OUTPUT.exists()
csv_file = open(OUTPUT, "a", newline="", encoding="utf-8")
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
if not file_exists:
writer.writeheader()
total = len(all_rows)
n_done = len(done)
batch_success = 0
batch_failed = 0
try:
for row in batch:
video_id = row["video_id"]
youtube_url = f"https://www.youtube.com/watch?v={video_id}"
n_done += 1
t_start = time.time()
log.info(f"{'─' * 70}")
log.info(f"[{n_done}/{total}] {video_id}")
log.info(f" category : {row['category']}")
log.info(f" label : {row['label']}")
log.info(f" url : {youtube_url}")
log.info("")
try:
## download the first 60 seconds
log.info(" [step 1/4] downloading first 60 seconds ...")
clip_path = download_clip(video_id)
if clip_path is None:
log.warning(" video unavailable β€” skipping")
failed.add(video_id)
save_checkpoint(done, failed)
batch_failed += 1
log.info("")
continue
log.info(f" download OK ({clip_path.stat().st_size / 1_048_576:.1f} MB)")
log.info("")
## extract visual and audio features, then delete the clip
try:
log.info(" [step 2/4] extracting visual features ...")
visual = extract_visual_features(clip_path)
log.info("")
log.info(" [step 3/4] extracting audio features ...")
audio = extract_audio_features(clip_path)
log.info("")
finally:
clip_path.unlink(missing_ok=True)
log.info(" clip deleted from disk")
## metadata comes straight from the CSV row, no video needed
log.info(" [step 4/4] extracting metadata features ...")
metadata = extract_metadata_features(row)
log.info("")
## ratios were pre-computed by label_videos.py
ratios = {
"like_to_view_ratio": _safe_float(row.get("like_to_view_ratio")),
"comment_to_view_ratio": _safe_float(row.get("comment_to_view_ratio")),
}
log.info(f" [ratio] like_to_view_ratio = {ratios['like_to_view_ratio']}")
log.info(f" [ratio] comment_to_view_ratio = {ratios['comment_to_view_ratio']}")
log.info("")
## write the completed row to features.csv
out_row = {
"video_id": video_id,
"youtube_url": youtube_url,
"category": row["category"],
"label": row["label"],
**visual, **audio, **metadata, **ratios,
}
writer.writerow(out_row)
csv_file.flush()
done.add(video_id)
save_checkpoint(done, failed)
batch_success += 1
elapsed = time.time() - t_start
log.info(
f" row written (total done: {len(done)}) "
f"[{elapsed:.1f}s]"
)
log.info("")
except Exception as e:
## any unexpected error (OOM, codec crash, DeepFace failure, network issue)
## is caught here so the loop continues with the next video
log.error(f" ERROR processing {video_id}: {e}")
log.error(" Skipping this video and continuing.")
failed.add(video_id)
save_checkpoint(done, failed)
batch_failed += 1
## clean up any leftover clip file from the crash
leftover = TMPDIR / f"{video_id}.mp4"
leftover.unlink(missing_ok=True)
log.info("")
## periodically free up RAM β€” prevents memory from growing across thousands of videos
if (batch_success + batch_failed) % GC_EVERY == 0 and (batch_success + batch_failed) > 0:
gc.collect()
log.info(f" [gc] collected β€” processed {batch_success + batch_failed} videos this run")
log.info("")
finally:
csv_file.close()
try:
TMPDIR.rmdir()
except OSError:
pass ## tmp dir not empty β€” leave it, cleanup runs at next startup
remaining_count = len(all_rows) - len(done) - len(failed)
log.info("=" * 70)
log.info(f"Batch complete.")
log.info(f" this run : {batch_success} extracted, {batch_failed} skipped")
log.info(f" total done: {len(done)}")
log.info(f" remaining : {remaining_count}")
log.info(f" saved to : {OUTPUT}")
return remaining_count > 0
if __name__ == "__main__":
batch_num = 1
while True:
log.info(f"{'=' * 70}")
log.info(f"Starting batch {batch_num} ...")
log.info(f"{'=' * 70}")
has_more = extract()
if not has_more:
log.info("")
log.info("All videos processed β€” extraction complete!")
break
log.info("")
log.info(f"Batch {batch_num} done. Starting batch {batch_num + 1} automatically ...")
log.info("")
batch_num += 1
gc.collect()