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
Files changed (3) hide show
  1. api/routes/verify.py +6 -5
  2. inputs/asr.py +43 -1
  3. 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 transcribe_video
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 to transcribe, then verifies the transcript.",
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 transcribe_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 transcribe meaningful speech from the media file.",
 
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 io
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