Spaces:
Sleeping
Sleeping
| """ | |
| OCR-based subtitle extractor — fallback when no subtitle file is available. | |
| Captures a frame at the midpoint of each Whisper audio segment, | |
| crops the bottom 15% (where subtitles typically appear), | |
| and runs Tesseract OCR with the appropriate Indic language pack. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from pathlib import Path | |
| from typing import Callable | |
| import cv2 | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| SUBTITLE_REGION_RATIO = 0.15 # bottom 15% of frame | |
| class OCRExtractor: | |
| def __init__( | |
| self, | |
| language: str = "hi", | |
| progress_hook: Callable | None = None, | |
| ): | |
| """ | |
| Parameters | |
| ---------- | |
| language : str | |
| yt-dlp / BCP-47 language code (e.g. 'hi', 'kn', 'en'). | |
| Mapped to Tesseract language pack internally. | |
| """ | |
| from config.settings import SUPPORTED_LANGUAGES | |
| lang_info = SUPPORTED_LANGUAGES.get(language, SUPPORTED_LANGUAGES["en"]) | |
| self._tess_lang = lang_info["tesseract"] | |
| self._progress_hook = progress_hook | |
| # ------------------------------------------------------------------ | |
| # Public API | |
| # ------------------------------------------------------------------ | |
| def extract_from_video( | |
| self, | |
| video_path: str, | |
| audio_segments: list[dict], | |
| ) -> list[dict]: | |
| """ | |
| For each audio segment, OCR the frame at its midpoint. | |
| Returns | |
| ------- | |
| list of { "start": float, "end": float, "text": str } | |
| """ | |
| import pytesseract | |
| cap = cv2.VideoCapture(video_path) | |
| if not cap.isOpened(): | |
| raise RuntimeError(f"Cannot open video: {video_path}") | |
| fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 | |
| results = [] | |
| for i, seg in enumerate(audio_segments): | |
| midpoint = (seg["start"] + seg["end"]) / 2.0 | |
| frame_no = int(midpoint * fps) | |
| cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no) | |
| ok, frame = cap.read() | |
| if not ok: | |
| logger.warning("Could not read frame %d (t=%.2fs)", frame_no, midpoint) | |
| results.append({"start": seg["start"], "end": seg["end"], "text": ""}) | |
| continue | |
| cropped = self._crop_subtitle_region(frame) | |
| preprocessed = self._preprocess(cropped) | |
| text = self._run_ocr(pytesseract, preprocessed) | |
| results.append( | |
| {"start": seg["start"], "end": seg["end"], "text": text.strip()} | |
| ) | |
| if self._progress_hook and i % 10 == 0: | |
| self._progress_hook(i / len(audio_segments)) | |
| cap.release() | |
| logger.info("OCR extracted text for %d segments", len(results)) | |
| return results | |
| # ------------------------------------------------------------------ | |
| # Internals | |
| # ------------------------------------------------------------------ | |
| def _crop_subtitle_region(self, frame: np.ndarray) -> np.ndarray: | |
| h, w = frame.shape[:2] | |
| crop_top = int(h * (1 - SUBTITLE_REGION_RATIO)) | |
| return frame[crop_top:h, 0:w] | |
| def _preprocess(self, img: np.ndarray) -> np.ndarray: | |
| """Improve OCR accuracy: grayscale → denoise → threshold.""" | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) | |
| # Upscale for better OCR | |
| scaled = cv2.resize(gray, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC) | |
| # Adaptive threshold works better than simple binary for varied backgrounds | |
| thresh = cv2.adaptiveThreshold( | |
| scaled, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2 | |
| ) | |
| return thresh | |
| def _run_ocr(self, pytesseract, img: np.ndarray) -> str: | |
| config = ( | |
| f"--oem 3 --psm 6 -l {self._tess_lang}" | |
| " -c tessedit_char_blacklist=|" | |
| ) | |
| try: | |
| return pytesseract.image_to_string(img, config=config) | |
| except Exception as exc: | |
| logger.warning("Tesseract error: %s", exc) | |
| return "" | |