Spaces:
Running
Running
Ryan Christian D. Deniega commited on
Commit ·
7097cb7
1
Parent(s): 32ac1d9
feat: add video frame OCR — extract on-screen text alongside Whisper ASR
Browse files- api/routes/verify.py +6 -5
- inputs/asr.py +43 -1
- inputs/video_ocr.py +121 -0
api/routes/verify.py
CHANGED
|
@@ -17,7 +17,7 @@ from api.schemas import (
|
|
| 17 |
from scoring.engine import run_verification
|
| 18 |
from inputs.url_scraper import scrape_url
|
| 19 |
from inputs.ocr import extract_text_from_image
|
| 20 |
-
from inputs.asr import
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
router = APIRouter(prefix="/verify", tags=["Verification"])
|
|
@@ -167,8 +167,8 @@ async def verify_image(file: UploadFile = File(...)) -> VerificationResponse:
|
|
| 167 |
@router.post(
|
| 168 |
"/video",
|
| 169 |
response_model=VerificationResponse,
|
| 170 |
-
summary="Verify a video/audio (Whisper ASR)",
|
| 171 |
-
description="Accepts a video or audio file. Runs Whisper ASR
|
| 172 |
)
|
| 173 |
async def verify_video(file: UploadFile = File(...)) -> VerificationResponse:
|
| 174 |
start = time.perf_counter()
|
|
@@ -185,11 +185,12 @@ async def verify_video(file: UploadFile = File(...)) -> VerificationResponse:
|
|
| 185 |
)
|
| 186 |
try:
|
| 187 |
media_bytes = await file.read()
|
| 188 |
-
text = await
|
| 189 |
if not text or len(text.strip()) < 10:
|
| 190 |
raise HTTPException(
|
| 191 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 192 |
-
detail="Could not
|
|
|
|
| 193 |
)
|
| 194 |
result = await run_verification(text, input_type="video")
|
| 195 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
|
|
| 17 |
from scoring.engine import run_verification
|
| 18 |
from inputs.url_scraper import scrape_url
|
| 19 |
from inputs.ocr import extract_text_from_image
|
| 20 |
+
from inputs.asr import transcribe_and_ocr_video
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
router = APIRouter(prefix="/verify", tags=["Verification"])
|
|
|
|
| 167 |
@router.post(
|
| 168 |
"/video",
|
| 169 |
response_model=VerificationResponse,
|
| 170 |
+
summary="Verify a video/audio (Whisper ASR + Frame OCR)",
|
| 171 |
+
description="Accepts a video or audio file. Runs Whisper ASR and frame OCR in parallel — handles speech-only, on-screen text only, or both.",
|
| 172 |
)
|
| 173 |
async def verify_video(file: UploadFile = File(...)) -> VerificationResponse:
|
| 174 |
start = time.perf_counter()
|
|
|
|
| 185 |
)
|
| 186 |
try:
|
| 187 |
media_bytes = await file.read()
|
| 188 |
+
text = await transcribe_and_ocr_video(media_bytes, filename=file.filename or "upload")
|
| 189 |
if not text or len(text.strip()) < 10:
|
| 190 |
raise HTTPException(
|
| 191 |
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 192 |
+
detail="Could not extract any usable text from the media file. "
|
| 193 |
+
"Ensure the video has audible speech or visible on-screen text.",
|
| 194 |
)
|
| 195 |
result = await run_verification(text, input_type="video")
|
| 196 |
result.processing_time_ms = round((time.perf_counter() - start) * 1000, 1)
|
inputs/asr.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
PhilVerify — Whisper ASR Module
|
| 3 |
Transcribes video/audio files using OpenAI Whisper.
|
|
|
|
| 4 |
Recommended model: large-v3 (best Filipino speech accuracy).
|
| 5 |
"""
|
| 6 |
-
import
|
| 7 |
import logging
|
| 8 |
import tempfile
|
| 9 |
import os
|
|
@@ -47,3 +48,44 @@ async def transcribe_video(media_bytes: bytes, filename: str = "upload") -> str:
|
|
| 47 |
except Exception as e:
|
| 48 |
logger.error("Whisper transcription failed: %s", e)
|
| 49 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
PhilVerify — Whisper ASR Module
|
| 3 |
Transcribes video/audio files using OpenAI Whisper.
|
| 4 |
+
Also provides combined ASR + frame OCR for full video text extraction.
|
| 5 |
Recommended model: large-v3 (best Filipino speech accuracy).
|
| 6 |
"""
|
| 7 |
+
import asyncio
|
| 8 |
import logging
|
| 9 |
import tempfile
|
| 10 |
import os
|
|
|
|
| 48 |
except Exception as e:
|
| 49 |
logger.error("Whisper transcription failed: %s", e)
|
| 50 |
return ""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def transcribe_and_ocr_video(media_bytes: bytes, filename: str = "upload") -> str:
|
| 54 |
+
"""
|
| 55 |
+
Full video text extraction: runs Whisper ASR and frame OCR in parallel,
|
| 56 |
+
then merges results based on what was found.
|
| 57 |
+
|
| 58 |
+
Cases handled:
|
| 59 |
+
- Audio only (no on-screen text) → returns speech transcript alone
|
| 60 |
+
- On-screen text only (silent) → returns OCR text alone
|
| 61 |
+
- Both → returns labelled combination
|
| 62 |
+
- Neither → returns empty string (caller raises 422)
|
| 63 |
+
"""
|
| 64 |
+
from inputs.video_ocr import extract_text_from_video_frames
|
| 65 |
+
|
| 66 |
+
# Run Whisper ASR and frame OCR concurrently
|
| 67 |
+
speech_text, ocr_text = await asyncio.gather(
|
| 68 |
+
transcribe_video(media_bytes, filename=filename),
|
| 69 |
+
extract_text_from_video_frames(media_bytes, filename=filename),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
speech_text = (speech_text or "").strip()
|
| 73 |
+
ocr_text = (ocr_text or "").strip()
|
| 74 |
+
|
| 75 |
+
has_speech = len(speech_text) >= 10
|
| 76 |
+
has_ocr = len(ocr_text) >= 10
|
| 77 |
+
|
| 78 |
+
if has_speech and has_ocr:
|
| 79 |
+
logger.info("Video has both speech (%d chars) and on-screen text (%d chars) — combining", len(speech_text), len(ocr_text))
|
| 80 |
+
return f"[SPEECH]\n{speech_text}\n\n[ON-SCREEN TEXT]\n{ocr_text}"
|
| 81 |
+
|
| 82 |
+
if has_speech:
|
| 83 |
+
logger.info("Video has speech only (%d chars)", len(speech_text))
|
| 84 |
+
return speech_text
|
| 85 |
+
|
| 86 |
+
if has_ocr:
|
| 87 |
+
logger.info("Video has on-screen text only (%d chars)", len(ocr_text))
|
| 88 |
+
return ocr_text
|
| 89 |
+
|
| 90 |
+
logger.warning("Video yielded no usable text from either ASR or frame OCR")
|
| 91 |
+
return ""
|
inputs/video_ocr.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PhilVerify — Video Frame OCR Module
|
| 3 |
+
Extracts on-screen text from video files by sampling frames with ffmpeg
|
| 4 |
+
and running Tesseract OCR on each frame.
|
| 5 |
+
|
| 6 |
+
Strategy:
|
| 7 |
+
- Extract 1 frame every FRAME_INTERVAL seconds using ffmpeg (already in Docker)
|
| 8 |
+
- Run existing Tesseract OCR on each frame
|
| 9 |
+
- Deduplicate consecutive near-identical frames (static lower-thirds, etc.)
|
| 10 |
+
- Return unique on-screen text joined by newlines
|
| 11 |
+
"""
|
| 12 |
+
import asyncio
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
from difflib import SequenceMatcher
|
| 18 |
+
|
| 19 |
+
from inputs.ocr import extract_text_from_image
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# Sample 1 frame every N seconds — good balance for news/social media clips
|
| 24 |
+
FRAME_INTERVAL = 3
|
| 25 |
+
# Similarity threshold — skip frame if >80% similar to previous (avoids repeating static text)
|
| 26 |
+
SIMILARITY_THRESHOLD = 0.80
|
| 27 |
+
# Minimum meaningful OCR text length per frame
|
| 28 |
+
MIN_FRAME_CHARS = 8
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _similarity(a: str, b: str) -> float:
|
| 32 |
+
"""Return similarity ratio between two strings (0.0 – 1.0)."""
|
| 33 |
+
return SequenceMatcher(None, a.strip(), b.strip()).ratio()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _extract_frames_with_ffmpeg(video_path: str, output_dir: str) -> list[str]:
|
| 37 |
+
"""
|
| 38 |
+
Use ffmpeg to extract 1 frame every FRAME_INTERVAL seconds as JPEG files.
|
| 39 |
+
Returns list of frame file paths. Returns [] on failure.
|
| 40 |
+
"""
|
| 41 |
+
pattern = os.path.join(output_dir, "frame_%04d.jpg")
|
| 42 |
+
cmd = [
|
| 43 |
+
"ffmpeg", "-i", video_path,
|
| 44 |
+
"-vf", f"fps=1/{FRAME_INTERVAL}",
|
| 45 |
+
"-q:v", "2", # high quality JPEG
|
| 46 |
+
"-frames:v", "300", # safety cap: max 300 frames (~15 min @ 3s interval)
|
| 47 |
+
pattern,
|
| 48 |
+
"-y", # overwrite
|
| 49 |
+
"-loglevel", "error", # suppress noise
|
| 50 |
+
]
|
| 51 |
+
try:
|
| 52 |
+
result = subprocess.run(cmd, capture_output=True, timeout=120)
|
| 53 |
+
if result.returncode != 0:
|
| 54 |
+
logger.warning("ffmpeg frame extraction failed: %s", result.stderr.decode())
|
| 55 |
+
return []
|
| 56 |
+
frames = sorted(f for f in os.listdir(output_dir) if f.endswith(".jpg"))
|
| 57 |
+
logger.info("ffmpeg extracted %d frames from video", len(frames))
|
| 58 |
+
return [os.path.join(output_dir, f) for f in frames]
|
| 59 |
+
except FileNotFoundError:
|
| 60 |
+
logger.warning("ffmpeg not found — video OCR unavailable")
|
| 61 |
+
return []
|
| 62 |
+
except subprocess.TimeoutExpired:
|
| 63 |
+
logger.warning("ffmpeg frame extraction timed out")
|
| 64 |
+
return []
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error("ffmpeg error: %s", e)
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def extract_text_from_video_frames(media_bytes: bytes, filename: str = "upload.mp4") -> str:
|
| 71 |
+
"""
|
| 72 |
+
Extract on-screen text from a video by sampling frames with ffmpeg
|
| 73 |
+
and running Tesseract OCR on each frame.
|
| 74 |
+
|
| 75 |
+
Returns deduplicated on-screen text, or empty string if no text found
|
| 76 |
+
or ffmpeg/tesseract unavailable.
|
| 77 |
+
"""
|
| 78 |
+
suffix = os.path.splitext(filename)[-1] or ".mp4"
|
| 79 |
+
|
| 80 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 81 |
+
# Write video bytes to temp file
|
| 82 |
+
video_path = os.path.join(tmpdir, f"input{suffix}")
|
| 83 |
+
with open(video_path, "wb") as f:
|
| 84 |
+
f.write(media_bytes)
|
| 85 |
+
|
| 86 |
+
frames_dir = os.path.join(tmpdir, "frames")
|
| 87 |
+
os.makedirs(frames_dir)
|
| 88 |
+
|
| 89 |
+
# Extract frames (blocking — run in executor to avoid blocking event loop)
|
| 90 |
+
loop = asyncio.get_event_loop()
|
| 91 |
+
frame_paths = await loop.run_in_executor(
|
| 92 |
+
None, _extract_frames_with_ffmpeg, video_path, frames_dir
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
if not frame_paths:
|
| 96 |
+
logger.info("No frames extracted — skipping video OCR")
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
# Run OCR on each frame, deduplicate consecutive similar text
|
| 100 |
+
unique_texts: list[str] = []
|
| 101 |
+
last_text = ""
|
| 102 |
+
|
| 103 |
+
for frame_path in frame_paths:
|
| 104 |
+
with open(frame_path, "rb") as f:
|
| 105 |
+
frame_bytes = f.read()
|
| 106 |
+
|
| 107 |
+
text = await extract_text_from_image(frame_bytes)
|
| 108 |
+
text = text.strip()
|
| 109 |
+
|
| 110 |
+
if len(text) < MIN_FRAME_CHARS:
|
| 111 |
+
continue # mostly blank frame
|
| 112 |
+
|
| 113 |
+
if last_text and _similarity(text, last_text) > SIMILARITY_THRESHOLD:
|
| 114 |
+
continue # too similar to previous — static overlay, skip
|
| 115 |
+
|
| 116 |
+
unique_texts.append(text)
|
| 117 |
+
last_text = text
|
| 118 |
+
|
| 119 |
+
result = "\n".join(unique_texts).strip()
|
| 120 |
+
logger.info("Video OCR: %d unique text segments, %d total chars", len(unique_texts), len(result))
|
| 121 |
+
return result
|