Tools,

Video OCR: get the text out of a video

45 of 48

burned-in subtitle lines read exactly

Get the SRT script

Burned-in subtitles to an SRT file

You need Python 3, ffmpeg and Tesseract. No pip packages. Save the script, then run:

python3 subs_to_srt.py video.mp4 video.srt

Most of the work is one ffmpeg filter: 2 frames a second, the bottom quarter, and only the white pixels.

VF = f"fps={FPS},crop=iw:ih/4:0:ih*3/4,format=gray,lut=y='if(gt(val,245),0,255)'"
The whole script, 53 lines
import collections
import difflib
import subprocess
import sys
import tempfile
from pathlib import Path

FPS = 2  # frames read each second
VF = f"fps={FPS},crop=iw:ih/4:0:ih*3/4,format=gray,lut=y='if(gt(val,245),0,255)'"
MIN_CONF = 30  # drop words Tesseract is unsure of: mostly specks of background


def ocr(png):
    tsv = subprocess.run(["tesseract", str(png), "-", "--psm", "6", "tsv"], capture_output=True, text=True).stdout
    lines = collections.defaultdict(list)
    for row in tsv.splitlines()[1:]:
        f = row.split("\t")
        if len(f) == 12 and f[0] == "5" and float(f[10]) >= MIN_CONF and f[11].strip():
            lines[f[2], f[3], f[4]].append(f[11])
    return "\n".join(" ".join(words) for words in lines.values()).replace("|", "I")


def read_frames(video, vf=VF):
    with tempfile.TemporaryDirectory() as d:
        subprocess.run(["ffmpeg", "-v", "error", "-i", video, "-vf", vf, f"{d}/%06d.png"], check=True)
        return [ocr(p) for p in sorted(Path(d).glob("*.png"))]


def similar(a, b):
    return difflib.SequenceMatcher(None, a, b).ratio()


def to_cues(texts, fps=FPS):
    cues = []  # [first frame, last frame + 1, readings]
    for i, t in enumerate(texts):
        if cues and cues[-1][1] == i and similar(cues[-1][2][-1], t) > 0.6:
            cues[-1][1] = i + 1
            cues[-1][2].append(t)
        elif t:
            cues.append([i, i + 1, [t]])
    # each cue keeps the reading that agrees most with its other readings
    return [(a / fps, b / fps, max(r, key=lambda x: sum(similar(x, y) for y in r))) for a, b, r in cues]


def stamp(s):
    ms = round(s * 1000)
    return f"{ms // 3600000:02}:{ms // 60000 % 60:02}:{ms // 1000 % 60:02},{ms % 1000:03}"


if __name__ == "__main__":
    with open(sys.argv[2], "w") as f:
        for n, (a, b, text) in enumerate(to_cues(read_frames(sys.argv[1])), 1):
            f.write(f"{n}\n{stamp(a)} --> {stamp(b)}\n{text}\n\n")
What the SRT file looks like
1
00:00:00,500 --> 00:00:02,000
Morning! Are you open yet?

2
00:00:02,500 --> 00:00:06,000
Almost. The oven isn't hot yet.

3
00:00:06,000 --> 00:00:09,000
What time do you open?

What each fix did

A subtitle over a green fractal, and the same frame after the filter: black text on white
Top: the bottom quarter of a frame. Bottom: what Tesseract reads.
Lines read exactly, of 24 a clip
Way Fractal Held out
Tesseract, whole frame 1 0
Bottom quarter only 0 0
White pixels only 11 22
Looser merge, | read as I 14 24
Unsure words dropped (the script) 21 24

I tuned the settings on the fractal clip. The held-out clip came after, with new lines on a moving blue and green background.

The crop alone made things worse: Tesseract read the background as text, 92 cues for 24.

2 frames a second is enough

Both clips, 48 lines
Frames a second Lines exact Time
1 39 15 s
2 45 28 s
4 44 54 s

Start times run late by up to one frame: 0.25 s at most at 2 a second, 0.51 s at 1 a second.

Slides: the scene filter misses them

Most answers online use select='gt(scene,0.3)'. On my two slide clips it kept the first frame only. A new slide changes few pixels: hard cuts scored 0.067 at most, crossfades 0.0053.

Slides found, of 10
Way Hard cuts Fades
gt(scene,0.3) 1 1
gt(scene,0.01) 10 1
slides.py, below 10 10

Reading 1 frame a second finds every slide, but each one 6 times: 1,499 words for 251.

Read each still slide once

ffmpeg's freezedetect logs each stretch of 1 second or more where the picture holds still. slides.py reads the middle frame of each.

python3 slides.py talk.mp4 > talk.txt
slides.py, 17 lines
import re
import subprocess
import sys

video = sys.argv[1]
log = subprocess.run(
    ["ffmpeg", "-i", video, "-vf", "freezedetect=d=1", "-an", "-f", "null", "-"], capture_output=True, text=True
).stderr
starts = [float(s) for s in re.findall(r"freeze_start: ([\d.]+)", log)]
ends = [float(s) for s in re.findall(r"freeze_end: ([\d.]+)", log)]
for i, a in enumerate(starts):
    t = (a + ends[i]) / 2 if i < len(ends) else a + 0.5  # the middle of each still stretch
    png = subprocess.run(
        ["ffmpeg", "-v", "error", "-ss", str(t), "-i", video, "-frames:v", "1", "-c:v", "png", "-f", "image2pipe", "-"],
        capture_output=True, check=True,
    ).stdout  # fmt: skip
    print(subprocess.run(["tesseract", "-", "-"], input=png, capture_output=True).stdout.decode())

10 of 10 slides, cuts and fades

4.7 s for 1 minute of video

videocr fails twice on Tesseract 5

pip installs videocr 0.1.6, from December 2019. Its first run stops on the model download, a GitHub branch that is gone:

urllib.error.HTTPError: HTTP Error 404: Not Found

With eng.traineddata copied to ~/tessdata, it stops again. Tesseract 5 writes word confidence with decimals:

ValueError: invalid literal for int() with base 10: '65.077583'
Both clips, 48 lines
Tool Lines exact Time
The script above 45 28 s
videocr 0.1.6, both fixed 0 459 s
RapidOCR 3.10.0, same frames 36 472 s

videocr reads all 25 frames a second, with one Tesseract per CPU core. I held it to 2. Its merge step left 5 cues for 24 on the fractal clip.

Slides to Markdown with our Actor

Our Actor reads PDF, JPG and PNG files and returns Markdown. I ran its code (build 0.1.65) on the 10 slide frames of slides.py.

251 of 251 slide words, titles as headings

249 with plain Tesseract

It costs $0.006 a page.

How I tested it
  • Clips: 4 videos I made for this test, 1280x720, 25 frames a second, H.264 at 3 Mbit/s at most. The text is mine. Pillow drew it in Open Sans; this ffmpeg build has no drawtext.
  • Subtitles: 24 lines a clip, 1.24 to 3.42 s each, white with a black outline. Fractal: ffmpeg's mandelbrot source, 63 s. Held out: its gradients source, 62 s, made after the settings were fixed.
  • Slides: 10 slides of 6 s, 251 words, once with hard cuts and once with half-second crossfades.
  • ffmpeg 9.0.2 and Tesseract 5.5.4 from Homebrew, its fast eng model. Python 3.12 on an Apple Silicon Mac, CPU, 2 threads (OMP_THREAD_LIMIT=2), lowest priority. Times are rough.
  • Exact: one cue overlaps the true line and has the same text, line breaks as spaces, curly and straight apostrophes the same. Slides: true words found in order.
  • The Actor row is its own code from git, build 0.1.65, run on the Mac with its default inputs. Not a run on Apify.
What I did not test
  • Real films, yellow or boxed subtitles, subtitles at the top, and languages other than English.
  • Text that moves, like credits or tickers.
  • GPUs and Windows.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs and images into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

How do I extract burned-in subtitles from a video?

Take 2 frames a second of the bottom quarter with ffmpeg, keep only the white text, read each frame with Tesseract, and merge repeats into SRT cues. That read 45 of 48 lines exactly in my test.

Why does select='gt(scene,0.3)' miss slide changes?

A new slide changes few pixels. Hard cuts between my text slides scored 0.067 at most and crossfades 0.0053, so 0.3 kept only the first frame. ffmpeg's freezedetect finds each still slide instead.

Why did ffmpeg write 1,499 frames for 10 slides?

The select filter drops frames, but the image output keeps the frame rate and fills the gaps with copies. Add -fps_mode vfr (the old name is -vsync vfr). With it, the same command wrote 10 frames.

Does videocr still work?

Not as pip installs it. Version 0.1.6 stops with HTTP Error 404 on its model download, then with a ValueError on the decimal confidence of Tesseract 5. With both fixed, it read 0 of 48 lines exactly.