test_final / detect_stream.py
sigyllly's picture
Upload 21 files
a108fe5 verified
Raw
History Blame Contribute Delete
23.4 kB
#!/usr/bin/env python3
"""
detect_stream.py — Cloud-Streaming Singer Face Detector (Multi-Threaded)
========================================================================
100% Native Python + OpenCV + FFmpeg. No local video downloads.
Streams directly from Kaggle datasets into memory, then uses
FAST MULTI-THREADED OpenCV Face + Smile cascades for detection.
This is the user's EXACT code from detect_stream.py, refactored into a
function (run_singer_detection) so app.py can call it directly. The
detection logic, streaming logic, and auth approach are ALL VERBATIM
from the user's working script.
The only additions are:
1. run_singer_detection() wrapper function (takes task_id, kaggle creds)
2. log_callback parameter (routes output to the web UI's Live Output)
3. Returns a dict with success/results/error instead of printing + sys.exit
"""
from __future__ import annotations
import os
import sys
import json
import base64
import subprocess
import time
import shutil
from typing import Callable, Optional, Dict, Any, List, Tuple
import numpy as np
import cv2
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
from kaggle.api.kaggle_api_extended import KaggleApi
except ImportError:
KaggleApi = None
# ════════════════════════════════════════════════════════════════════════════
# VERBATIM from user's detect_stream.py — StreamingSingerDetector class
# ════════════════════════════════════════════════════════════════════════════
class StreamingSingerDetector:
def __init__(self, stream_url: str, b64_auth: str, output_dir: str = "singer_snapshots",
log_callback: Optional[Callable[[str], None]] = None):
self.stream_url = stream_url
self.b64_auth = b64_auth
self.output_dir = os.path.abspath(output_dir)
self.temp_dir = os.path.join(self.output_dir, "temp_hq")
self.log = log_callback or (lambda msg: print(msg))
os.makedirs(self.output_dir, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
self.headers_str = f"Authorization: Basic {self.b64_auth}\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)\r\n"
self.log(" [detect] Streaming initialized (Thread-safe mode activated)\n")
# --------------------------------------------------------- FFmpeg utilities
def probe_stream(self) -> tuple:
self.log("[INFO] Probing remote stream properties...")
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration:stream=width,height",
"-print_format", "json",
"-headers", self.headers_str,
self.stream_url
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode == 0:
data = json.loads(result.stdout)
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
w = int(stream.get("width", 1920))
h = int(stream.get("height", 1080))
duration = float(fmt.get("duration", 3600.0))
return w, h, duration
except Exception as e:
self.log(f"[WARNING] Remote probe failed ({e}).")
self.log("[WARNING] Falling back to default 1920x1080, 1 hour duration.")
return 1920, 1080, 3600.0
def fetch_hq_snapshot(self, ts: float, out_path: str, quality: int = 1):
"""Fetches a single high-quality frame directly from the remote stream."""
cmd = [
"ffmpeg", "-y",
"-headers", self.headers_str,
"-ss", f"{ts:.4f}",
"-i", self.stream_url,
"-vframes", "1",
"-q:v", str(quality),
out_path
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# -------------------------------------------------------- Face & Mouth Detection
def detect_faces(self, img: np.ndarray, face_cascade, smile_cascade) -> list:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray)
faces_rects = face_cascade.detectMultiScale(
gray, scaleFactor=1.15, minNeighbors=5, minSize=(60, 60)
)
faces = []
for (x, y, w, h) in faces_rects:
mouth_score = self._get_mouth_score(img, x, y, w, h, smile_cascade)
faces.append({
"bbox": (x, y, x + w, y + h),
"conf": 0.9,
"cx": x + w // 2,
"cy": y + h // 2,
"area": w * h,
"mouth": mouth_score,
})
return faces
def _get_mouth_score(self, img: np.ndarray, x: int, y: int, w: int, h: int, smile_cascade) -> float:
mouth_y_start = y + int(h * 0.6)
mouth_roi = img[mouth_y_start: y + h, x: x + w]
if mouth_roi.size == 0:
return 0.5
gray_mouth = cv2.cvtColor(mouth_roi, cv2.COLOR_BGR2GRAY)
gray_mouth = cv2.equalizeHist(gray_mouth)
smiles = smile_cascade.detectMultiScale(
gray_mouth, scaleFactor=1.7, minNeighbors=12, minSize=(int(w * 0.3), int(h * 0.08))
)
return 0.85 if len(smiles) > 0 else 0.25
# ---------------------------------------------- Face similarity & Clustering
@staticmethod
def _face_similarity(a: np.ndarray, b: np.ndarray) -> float:
if a.size == 0 or b.size == 0: return 0.0
sz = (64, 64)
a = cv2.resize(a, sz)
b = cv2.resize(b, sz)
ha = cv2.calcHist([cv2.cvtColor(a, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 40], [0, 180, 0, 256])
hb = cv2.calcHist([cv2.cvtColor(b, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 40], [0, 180, 0, 256])
cv2.normalize(ha, ha); cv2.normalize(hb, hb)
return cv2.compareHist(ha, hb, cv2.HISTCMP_CORREL)
def _cluster_faces(self, all_detections: list) -> list:
SIM_THRESHOLD = 0.38
clusters = []
for det in all_detections:
best_c, best_s = None, SIM_THRESHOLD
for c in clusters:
s = self._face_similarity(det["crop"], c["rep_img"])
if s > best_s:
best_s, best_c = s, c
if best_c is not None:
best_c["members"].append(det)
else:
clusters.append({"rep_img": det["crop"], "members": [det]})
return clusters
def _score_cluster(self, cluster: dict, video_area: int) -> float:
mems = cluster["members"]
n = len(mems)
freq = min(n / 8.0, 1.0)
mouth = np.mean([m["mouth"] for m in mems])
center = np.mean([m["center_s"] for m in mems])
size = np.mean([m["size_s"] for m in mems])
score = 0.30 * freq + 0.35 * mouth + 0.20 * center + 0.15 * size
cluster["_score"] = score
cluster["_n"] = n
cluster["_mouth"] = mouth
return score
# ------------------------------------------------ Auto Crop Logic
def _auto_crop_half_body(self, img: np.ndarray, bbox: tuple) -> np.ndarray:
x1, y1, x2, y2 = bbox
face_h, face_w = y2 - y1, x2 - x1
img_h, img_w = img.shape[:2]
new_y1 = max(0, y1 - int(face_h * 0.5))
new_y2 = min(img_h, y2 + int(face_h * 3.5))
new_x1 = max(0, x1 - int(face_w * 2.5))
new_x2 = min(img_w, x2 + int(face_w * 2.5))
return img[new_y1:new_y2, new_x1:new_x2]
# ====================================================== PUBLIC API
def run(self, num_samples: int = 45, num_snapshots: int = 3) -> List[Dict]:
self.log("=" * 62)
self.log(" [detect] CLOUD-STREAMING EXTRACTOR (Fast Threaded)")
self.log("=" * 62)
w, h, duration = self.probe_stream()
video_area = w * h
frame_bytes = w * h * 3
self.log(f" Duration : {duration:.1f} s Resolution : {w}x{h}\n")
# ── 1. Stream frames into memory ──
self.log(f"[1/4] Streaming {num_samples} frames from Kaggle cloud into RAM ...")
t_start = time.time()
start_t = duration * 0.08
end_t = duration * 0.95
window = end_t - start_t
extraction_fps = num_samples / window
ffmpeg_cmd = [
"ffmpeg",
"-headers", self.headers_str,
"-ss", str(start_t),
"-t", str(window),
"-i", self.stream_url,
"-vf", f"fps={extraction_fps}",
"-f", "image2pipe",
"-pix_fmt", "bgr24",
"-vcodec", "rawvideo", "pipe:1"
]
p_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
frames = []
frame_idx = 0
while True:
raw_frame = p_ffmpeg.stdout.read(frame_bytes)
if not raw_frame or len(raw_frame) < frame_bytes:
break
img = np.frombuffer(raw_frame, dtype=np.uint8).reshape((h, w, 3))
ts = start_t + (frame_idx / extraction_fps)
frames.append((img.copy(), ts))
frame_idx += 1
self.log(f"\r -> Buffered Frame {frame_idx}/{num_samples}")
p_ffmpeg.terminate()
t_extract = time.time() - t_start
self.log(f"\n {len(frames)} frames loaded in {t_extract:.1f}s\n")
if not frames:
self.log("[ERROR] Stream failed to return frames. Aborting.")
return []
# ── 2. Detect faces + mouth openness (THREADED) ──
self.log("[2/4] Detecting faces & checking for singing (threaded) ...")
t_detect_start = time.time()
all_dets = []
MAX_WORKERS = min(8, os.cpu_count() or 4)
def _detect_one(img, ts):
local_face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
local_smile_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_smile.xml')
fh, fw = img.shape[:2]
fc = (fw // 2, fh // 2)
max_dist = np.hypot(fw, fh) / 2
dets = []
for face in self.detect_faces(img, local_face_cascade, local_smile_cascade):
cx, cy = face["cx"], face["cy"]
dist = np.hypot(cx - fc[0], cy - fc[1])
dets.append({
"ts": ts, "bbox": face["bbox"], "conf": face["conf"],
"crop": img[face["bbox"][1]:face["bbox"][3], face["bbox"][0]:face["bbox"][2]].copy(),
"mouth": face["mouth"], "center_s": 1.0 - dist / max_dist, "size_s": face["area"] / video_area,
})
return dets
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = [pool.submit(_detect_one, img, ts) for img, ts in frames]
for future in as_completed(futures):
all_dets.extend(future.result())
all_dets.sort(key=lambda x: x["ts"])
t_detect = time.time() - t_detect_start
self.log(f" Detection done in {t_detect:.1f}s ({MAX_WORKERS} threads)")
self.log(f" Total detections: {len(all_dets)}\n")
if not all_dets:
self.log(" No faces found — aborting.")
return []
# ── 3. Cluster & identify singer ──
self.log("[3/4] Clustering faces to find the singer ...")
clusters = self._cluster_faces(all_dets)
for c in clusters:
self._score_cluster(c, video_area)
clusters.sort(key=lambda c: c["_score"], reverse=True)
self.log("\n Top clusters:")
for i, c in enumerate(clusters[:6]):
tag = " ★ SINGER" if i == 0 else ""
self.log(f" #{i+1} score={c['_score']:.3f} faces={c['_n']:2d} mouth={c['_mouth']:.2f}{tag}")
singer = clusters[0]
self.log(f"\n -> Singer cluster: {singer['_n']} detections\n")
# ── 4. Pick best snapshots & extract HQ from stream (THREADED) ──
self.log(f"[4/4] Fetching HQ frames from cloud and auto-cropping (threaded) ...\n")
scored = sorted(singer["members"], key=lambda m: 0.50 * m["mouth"] + 0.25 * m["conf"] + 0.25 * m["center_s"], reverse=True)
picked = []
MIN_GAP = max(4.0, duration / (num_snapshots * 3))
for m in scored:
if len(picked) >= num_snapshots: break
if all(abs(m["ts"] - p["ts"]) >= MIN_GAP for p in picked): picked.append(m)
for m in scored:
if len(picked) >= num_snapshots: break
if m not in picked: picked.append(m)
def _extract_hq_snapshot(idx, det):
ts = det["ts"]
bbox = det["bbox"]
raw_path = os.path.join(self.temp_dir, f"hq_{idx}.png")
self.fetch_hq_snapshot(ts, raw_path, quality=1)
hq = cv2.imread(raw_path)
if hq is None: return None
x1, y1, x2, y2 = bbox
# 1. Annotated
ann = hq.copy()
cv2.rectangle(ann, (x1, y1), (x2, y2), (0, 220, 80), 3)
lbl = "SINGER"
(tw, th), _ = cv2.getTextSize(lbl, cv2.FONT_HERSHEY_SIMPLEX, 1.0, 2)
cv2.rectangle(ann, (x1, y1 - th - 14), (x1 + tw + 8, y1), (0, 220, 80), -1)
cv2.putText(ann, lbl, (x1 + 4, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 0), 2)
info_txt = f"t = {ts:.1f}s | singing = {det['mouth']:.2f}"
cv2.putText(ann, info_txt, (12, ann.shape[0] - 16), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2)
ann_path = os.path.join(self.output_dir, f"singer_{idx+1}_annotated.png")
cv2.imwrite(ann_path, ann, [cv2.IMWRITE_PNG_COMPRESSION, 0])
# 2. Clean
clean_path = os.path.join(self.output_dir, f"singer_{idx+1}_clean.png")
cv2.imwrite(clean_path, hq, [cv2.IMWRITE_PNG_COMPRESSION, 0])
# 3. Half-body crop
cropped_body = self._auto_crop_half_body(hq, bbox)
crop_path = os.path.join(self.output_dir, f"singer_{idx+1}_halfbody.png")
cv2.imwrite(crop_path, cropped_body, [cv2.IMWRITE_PNG_COMPRESSION, 0])
c_h, c_w = cropped_body.shape[:2]
return {
"annotated": ann_path, "clean": clean_path, "crop": crop_path,
"_info": f"t={ts:.1f}s singing={det['mouth']:.2f} crop={c_w}x{c_h}px",
"_idx": idx,
}
results = []
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(_extract_hq_snapshot, idx, det): idx for idx, det in enumerate(picked[:num_snapshots])}
for future in as_completed(futures):
r = future.result()
if r: results.append(r)
results.sort(key=lambda x: x["_idx"])
for i, r in enumerate(results):
self.log(f" Snapshot {i+1}: {r['_info']}")
self.log(f" -> {os.path.basename(r['annotated'])}")
self.log(f" -> {os.path.basename(r['clean'])}")
self.log(f" -> {os.path.basename(r['crop'])}")
shutil.rmtree(self.temp_dir, ignore_errors=True)
total_t = time.time() - t_start
self.log(f"\n Total time: {total_t:.1f}s")
self.log("=" * 62)
self.log(f" DONE — {len(results)} sets saved to: {self.output_dir}")
self.log("=" * 62)
return results
# ════════════════════════════════════════════════════════════════════════════
# Main entry point — called from app.py
# ════════════════════════════════════════════════════════════════════════════
def run_singer_detection(
task_id: str,
kaggle_username: str,
kaggle_key: str,
log_callback: Optional[Callable[[str], None]] = None,
num_samples: int = 45,
num_snapshots: int = 3,
output_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""
Run singer detection on the video that step 1 uploaded to Kaggle.
Uses the user's EXACT StreamingSingerDetector class from detect_stream.py.
Streams the video from Kaggle, samples frames, detects faces + singing,
clusters to find the singer, and outputs:
- singer_1_halfbody.png, singer_2_halfbody.png, etc.
- singer_1_annotated.png, singer_1_clean.png, etc.
Args:
task_id: The task ID (e.g. "20260619_142543_0b331b"). Underscores
are auto-converted to hyphens for the Kaggle dataset slug.
kaggle_username: Kaggle username (also the dataset owner).
kaggle_key: Kaggle API key.
log_callback: Function called with progress messages.
num_samples: Number of frames to sample. Default 45.
num_snapshots: Number of final snapshots to export. Default 3.
output_dir: Directory for output images. Defaults to task dir.
Returns:
dict with keys: success, results, output_dir, error
"""
def log(msg):
if log_callback:
log_callback(msg)
else:
print(msg)
# ── Pre-flight checks ─────────────────────────────────────────────────
if KaggleApi is None:
msg = "Kaggle Python package not installed. Run: pip install kaggle"
log(f"[Singer] ERROR: {msg}")
return {"success": False, "results": [], "output_dir": None, "error": msg}
if not kaggle_username or not kaggle_key:
msg = "KAGGLE_USERNAME / KAGGLE_KEY are not set."
log(f"[Singer] ERROR: {msg}")
return {"success": False, "results": [], "output_dir": None, "error": msg}
# ── Set env vars for KaggleApi.authenticate() ─────────────────────────
os.environ["KAGGLE_USERNAME"] = kaggle_username
os.environ["KAGGLE_KEY"] = kaggle_key
# ── Authenticate with Kaggle ──────────────────────────────────────────
log("[Singer] Initializing Kaggle API client...")
try:
api = KaggleApi()
api.authenticate()
except Exception as e:
msg = f"Kaggle authentication failed: {e}"
log(f"[Singer] ERROR: {msg}")
return {"success": False, "results": [], "output_dir": None, "error": msg}
username = api.config_values.get('username') or kaggle_username
key = api.config_values.get('key') or kaggle_key
auth_bytes = f"{username}:{key}".encode('utf-8')
b64_auth = base64.b64encode(auth_bytes).decode('utf-8')
# ── Derive dataset slug and filename from task_id ─────────────────────
# Same underscore→hyphen conversion as detect_edges_video_stream.py
dataset_name = task_id.replace("_", "-")
filename = f"output_{task_id}.mkv"
stream_url = f"https://www.kaggle.com/api/v1/datasets/download/{username}/{dataset_name}/{filename}"
log(f"[Singer] Task ID: {task_id}")
log(f"[Singer] Dataset slug: {username}/{dataset_name}")
log(f"[Singer] Video file: {filename}")
log(f"[Singer] Stream URL: {stream_url}")
# ── Set up output directory ───────────────────────────────────────────
if not output_dir:
output_dir = os.path.join(os.getcwd(), "singer_snapshots")
os.makedirs(output_dir, exist_ok=True)
# ═══════════════════════════════════════════════════════════════════════
# RETRY LOOP — same as detect_edges_video_stream.py
# Kaggle returns 403 when the dataset is still being processed.
# ═══════════════════════════════════════════════════════════════════════
import time as _time
MAX_ATTEMPTS = 6
RETRY_DELAY = 15
results = []
for attempt in range(1, MAX_ATTEMPTS + 1):
log(f"[Singer] === Attempt {attempt}/{MAX_ATTEMPTS} ===")
detector = StreamingSingerDetector(
stream_url=stream_url,
b64_auth=b64_auth,
output_dir=output_dir,
log_callback=log,
)
results = detector.run(num_samples=num_samples, num_snapshots=num_snapshots)
if results:
log(f"[Singer] SUCCESS: {len(results)} singer snapshots generated.")
break
else:
log(f"[Singer] WARNING: No results on attempt {attempt}.")
if attempt < MAX_ATTEMPTS:
log(f"[Singer] Waiting {RETRY_DELAY}s before retry... (Kaggle may still be processing)")
_time.sleep(RETRY_DELAY)
else:
msg = "Singer detection failed after all retries."
log(f"[Singer] ERROR: {msg}")
return {"success": False, "results": [], "output_dir": output_dir, "error": msg}
return {
"success": True,
"results": results,
"output_dir": output_dir,
"error": None,
}
# ── CLI wrapper (kept for manual testing) ────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(description="Streaming Singer Face Detector")
parser.add_argument("-d", "--dataset", required=True, help="Kaggle dataset (e.g. owner/dataset)")
parser.add_argument("-f", "--filename", required=True, help="Target video filename (e.g. video.mkv)")
parser.add_argument("-o", "--output", default="singer_snapshots", help="Output directory")
parser.add_argument("-n", "--samples", type=int, default=45, help="Number of frames to sample")
parser.add_argument("-s", "--snapshots", type=int, default=3, help="Number of final snapshots to export")
args = parser.parse_args()
if "/" not in args.dataset:
print("[ERROR] Dataset must be in the form <username>/<dataset_name>")
sys.exit(1)
kaggle_username, task_id_raw = args.dataset.split("/", 1)
task_id = task_id_raw.replace("-", "_")
kaggle_key = os.environ.get("KAGGLE_KEY", "")
if not kaggle_key:
print("[ERROR] KAGGLE_KEY env var not set")
sys.exit(1)
result = run_singer_detection(
task_id=task_id,
kaggle_username=kaggle_username,
kaggle_key=kaggle_key,
log_callback=lambda msg: print(msg),
num_samples=args.samples,
num_snapshots=args.snapshots,
output_dir=args.output,
)
if not result["success"]:
sys.exit(1)
if __name__ == "__main__":
main()