Video OCR: get the text out of a video
45 of 48
burned-in subtitle lines read exactly
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
| 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
| 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.
| 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'
| 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.
Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First 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.