Move functions related to subtitles OCR in a dedicated module.
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import IO
|
||||||
|
from io import TextIOWrapper
|
||||||
|
from datetime import timedelta
|
||||||
|
from os import (
|
||||||
|
read,
|
||||||
|
SEEK_SET,
|
||||||
|
lseek,
|
||||||
|
memfd_create,
|
||||||
|
write,
|
||||||
|
fstat,
|
||||||
|
)
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from subprocess import PIPE, Popen
|
||||||
|
|
||||||
|
from iso639 import Lang
|
||||||
|
from iso639.exceptions import InvalidLanguageValue
|
||||||
|
from typeguard import typechecked
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_tesseract_supported_lang(tesseract_path:str) -> dict[Lang, str]|None:
|
||||||
|
"""
|
||||||
|
Retrieves the set of natural languages supported by the Tesseract OCR tool.
|
||||||
|
|
||||||
|
This function runs the Tesseract binary with the --list-langs option and parses the output
|
||||||
|
to extract the supported languages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tesseract_path (str): The path to the Tesseract binary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[Lang, str] | None:
|
||||||
|
- A dictionary mapping Lang objects to their corresponding language codes
|
||||||
|
(e.g., "eng" for English)
|
||||||
|
- None if an error occurs while running the Tesseract binary
|
||||||
|
"""
|
||||||
|
res = {}
|
||||||
|
|
||||||
|
with Popen([tesseract_path, '--list-langs'], stdout=PIPE) as tesseract:
|
||||||
|
for line in tesseract.stdout:
|
||||||
|
line = line.decode('utf8')
|
||||||
|
p = re.compile('(?P<lang>[a-z]{3})\n')
|
||||||
|
m = re.match(p,line)
|
||||||
|
if m is not None:
|
||||||
|
try:
|
||||||
|
lang = m.group('lang')
|
||||||
|
key = Lang(lang)
|
||||||
|
res[key] = lang
|
||||||
|
except InvalidLanguageValue as e:
|
||||||
|
logger.warning('Invalid language: %s', e)
|
||||||
|
|
||||||
|
tesseract.wait()
|
||||||
|
|
||||||
|
if tesseract.returncode != 0:
|
||||||
|
logger.error("Tesseract returns an error code: %d",tesseract.returncode)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def extract_srt(mkvextract:str, filename:str, subtitles:dict[str, list[int]],
|
||||||
|
langs:dict[Lang,str]) -> list[tuple[str,str,str,str]]|None:
|
||||||
|
params = [mkvextract, filename, 'tracks']
|
||||||
|
|
||||||
|
res = []
|
||||||
|
|
||||||
|
for lang in subtitles:
|
||||||
|
iso = Lang(lang)
|
||||||
|
|
||||||
|
if iso in langs:
|
||||||
|
ocrlang = langs[iso]
|
||||||
|
else:
|
||||||
|
logger.warning("Language not supported by Tesseract: %s", iso.name)
|
||||||
|
ocrlang ='osd'
|
||||||
|
|
||||||
|
if len(subtitles[lang]) == 1:
|
||||||
|
params.append(f'{subtitles[lang][0]:d}:{lang}')
|
||||||
|
res.append((f'{lang}.idx', f'{lang}.sub', lang, ocrlang))
|
||||||
|
else:
|
||||||
|
count = 1
|
||||||
|
for track in subtitles[lang]:
|
||||||
|
params.append(f'{track:d}:{lang}-{count:d}')
|
||||||
|
res.append((f'{lang}-{count:d}.idx', f'{lang}-{count:d}.sub', lang, ocrlang))
|
||||||
|
count = count+1
|
||||||
|
|
||||||
|
logger.debug('Executing %s', params)
|
||||||
|
|
||||||
|
env = {**os.environ, 'LANG': 'C'}
|
||||||
|
with Popen(params, stdout=PIPE, close_fds=False, env=env) as extract:
|
||||||
|
pb = tqdm(TextIOWrapper(extract.stdout, encoding="utf-8"), total=100, unit='%',
|
||||||
|
desc='Extraction:')
|
||||||
|
for line in pb:
|
||||||
|
if line.startswith('Progress :'):
|
||||||
|
p = re.compile('^Progress : (?P<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
pb.update(int(m['progress'])-pb.n)
|
||||||
|
pb.update(100-pb.n)
|
||||||
|
pb.refresh()
|
||||||
|
pb.close()
|
||||||
|
|
||||||
|
extract.wait()
|
||||||
|
|
||||||
|
# mkvextract returns 0, 1 or 2 as error code.
|
||||||
|
match extract.returncode:
|
||||||
|
case 0:
|
||||||
|
logger.info('Subtitle tracks were succesfully extracted.')
|
||||||
|
case 1:
|
||||||
|
logger.warning('Mkvextract returns warning')
|
||||||
|
case 2:
|
||||||
|
logger.error('Mkvextract returns an error code: %d', extract.returncode)
|
||||||
|
res = None
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def do_ocr(vobsubocr:str, idxs: list[tuple[str,str,str,str]], duration:timedelta,
|
||||||
|
temporaries:list[IO[bytes]], dump_mem_fd:bool=False):
|
||||||
|
res = []
|
||||||
|
|
||||||
|
for idx_name, _, lang, iso in idxs:
|
||||||
|
srtname = f'{os.path.splitext(idx_name)[0]}.srt'
|
||||||
|
# Tesseract seems to recognize the three dots ... as "su"
|
||||||
|
ldots = re.compile('^su\n$')
|
||||||
|
# Timestamps produced by vobsubocr: 01:52:19,861 --> 01:52:21,641
|
||||||
|
timestamps = re.compile((r'^[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3} \-\-> (?P<hours>[0-9]{2}):'
|
||||||
|
r'(?P<minutes>[0-9]{2}):(?P<seconds>[0-9]{2}),[0-9]{3}$'))
|
||||||
|
|
||||||
|
srtfd = memfd_create(srtname, flags=0)
|
||||||
|
with Popen([vobsubocr, '--lang', iso, idx_name], stdout=PIPE) as ocr:
|
||||||
|
pb = tqdm(TextIOWrapper(ocr.stdout, encoding="utf-8"), total=
|
||||||
|
int(duration/timedelta(seconds=1)), unit='s', desc='OCR')
|
||||||
|
for line in pb:
|
||||||
|
m = re.match(ldots,line)
|
||||||
|
if m is not None:
|
||||||
|
write(srtfd, '...'.encode(encoding='UTF-8'))
|
||||||
|
else:
|
||||||
|
write(srtfd, line.encode(encoding='UTF-8'))
|
||||||
|
|
||||||
|
m = re.match(timestamps, line)
|
||||||
|
if m is not None:
|
||||||
|
hours = int(m.group('hours'))
|
||||||
|
minutes = int(m.group('hours'))
|
||||||
|
seconds = int(m.group('seconds'))
|
||||||
|
ts = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||||
|
pb.n = int(ts/timedelta(seconds=1))
|
||||||
|
pb.update()
|
||||||
|
|
||||||
|
status = ocr.wait()
|
||||||
|
|
||||||
|
if status != 0:
|
||||||
|
logger.error('OCR failed with status code: %d', status)
|
||||||
|
|
||||||
|
if dump_mem_fd:
|
||||||
|
try:
|
||||||
|
with open(srtname,'w', encoding='utf8') as dump_srt:
|
||||||
|
lseek(srtfd, 0, SEEK_SET)
|
||||||
|
srt_length = fstat(srtfd).st_size
|
||||||
|
buf = read(srtfd, srt_length)
|
||||||
|
outfd = dump_srt.fileno()
|
||||||
|
pos = 0
|
||||||
|
while pos < srt_length:
|
||||||
|
pos+=write(outfd, buf[pos:])
|
||||||
|
temporaries.append(dump_srt)
|
||||||
|
except OSError:
|
||||||
|
logger.error('Impossible to create file: %s', srtname)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
srt_length = fstat(srtfd).st_size
|
||||||
|
if srt_length > 0:
|
||||||
|
res.append((srtfd, lang))
|
||||||
|
|
||||||
|
return res
|
||||||
+5
-162
@@ -58,6 +58,11 @@ from tscut.tools.timeframe import (
|
|||||||
parse_time_interval
|
parse_time_interval
|
||||||
)
|
)
|
||||||
from tscut.tools.discovery import check_required_tools
|
from tscut.tools.discovery import check_required_tools
|
||||||
|
from tscut.ocr.subtitles import (
|
||||||
|
do_ocr,
|
||||||
|
extract_srt,
|
||||||
|
get_tesseract_supported_lang
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Useful SPS/PPS discussion.
|
# Useful SPS/PPS discussion.
|
||||||
@@ -85,169 +90,7 @@ from tscut.tools.discovery import check_required_tools
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@typechecked
|
|
||||||
def get_tesseract_supported_lang(tesseract_path:str) -> dict[Lang, str]|None:
|
|
||||||
"""
|
|
||||||
Retrieves the set of natural languages supported by the Tesseract OCR tool.
|
|
||||||
|
|
||||||
This function runs the Tesseract binary with the --list-langs option and parses the output
|
|
||||||
to extract the supported languages.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tesseract_path (str): The path to the Tesseract binary.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[Lang, str] | None:
|
|
||||||
- A dictionary mapping Lang objects to their corresponding language codes
|
|
||||||
(e.g., "eng" for English)
|
|
||||||
- None if an error occurs while running the Tesseract binary
|
|
||||||
"""
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
res = {}
|
|
||||||
|
|
||||||
with Popen([tesseract_path, '--list-langs'], stdout=PIPE) as tesseract:
|
|
||||||
for line in tesseract.stdout:
|
|
||||||
line = line.decode('utf8')
|
|
||||||
p = re.compile('(?P<lang>[a-z]{3})\n')
|
|
||||||
m = re.match(p,line)
|
|
||||||
if m is not None:
|
|
||||||
try:
|
|
||||||
lang = m.group('lang')
|
|
||||||
key = Lang(lang)
|
|
||||||
res[key] = lang
|
|
||||||
except InvalidLanguageValue as e:
|
|
||||||
logger.warning('Invalid language: %s', e)
|
|
||||||
pass
|
|
||||||
|
|
||||||
tesseract.wait()
|
|
||||||
|
|
||||||
if tesseract.returncode != 0:
|
|
||||||
logger.error("Tesseract returns an error code: %d",tesseract.returncode)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@typechecked
|
|
||||||
def extract_srt(mkvextract:str, filename:str, subtitles:dict[str, list[int]],
|
|
||||||
langs:dict[Lang,str]) -> list[tuple[str,str,str,str]]|None:
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
params = [mkvextract, filename, 'tracks']
|
|
||||||
|
|
||||||
res = []
|
|
||||||
|
|
||||||
for lang in subtitles:
|
|
||||||
iso = Lang(lang)
|
|
||||||
|
|
||||||
if iso in langs:
|
|
||||||
ocrlang = langs[iso]
|
|
||||||
else:
|
|
||||||
logger.warning("Language not supported by Tesseract: %s", iso.name)
|
|
||||||
ocrlang ='osd'
|
|
||||||
|
|
||||||
if len(subtitles[lang]) == 1:
|
|
||||||
params.append(f'{subtitles[lang][0]:d}:{lang}')
|
|
||||||
res.append((f'{lang}.idx', f'{lang}.sub', lang, ocrlang))
|
|
||||||
else:
|
|
||||||
count = 1
|
|
||||||
for track in subtitles[lang]:
|
|
||||||
params.append(f'{track:d}:{lang}-{count:d}')
|
|
||||||
res.append((f'{lang}-{count:d}.idx', f'{lang}-{count:d}.sub', lang, ocrlang))
|
|
||||||
count = count+1
|
|
||||||
|
|
||||||
logger.debug('Executing %s', params)
|
|
||||||
|
|
||||||
env = {**os.environ, 'LANG': 'C'}
|
|
||||||
with Popen(params, stdout=PIPE, close_fds=False, env=env) as extract:
|
|
||||||
pb = tqdm(TextIOWrapper(extract.stdout, encoding="utf-8"), total=100, unit='%',
|
|
||||||
desc='Extraction:')
|
|
||||||
for line in pb:
|
|
||||||
if line.startswith('Progress :'):
|
|
||||||
p = re.compile('^Progress : (?P<progress>[0-9]{1,3})%$')
|
|
||||||
m = p.match(line)
|
|
||||||
if m is None:
|
|
||||||
logger.error('Impossible to parse progress')
|
|
||||||
pb.update(int(m['progress'])-pb.n)
|
|
||||||
pb.update(100-pb.n)
|
|
||||||
pb.refresh()
|
|
||||||
pb.close()
|
|
||||||
|
|
||||||
extract.wait()
|
|
||||||
|
|
||||||
# mkvextract returns 0, 1 or 2 as error code.
|
|
||||||
match extract.returncode:
|
|
||||||
case 0:
|
|
||||||
logger.info('Subtitle tracks were succesfully extracted.')
|
|
||||||
case 1:
|
|
||||||
logger.warning('Mkvextract returns warning')
|
|
||||||
case 2:
|
|
||||||
logger.error('Mkvextract returns an error code: %d', extract.returncode)
|
|
||||||
res = None
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
@typechecked
|
|
||||||
def do_ocr(vobsubocr:str, idxs: list[tuple[str,str,str,str]], duration:timedelta,
|
|
||||||
temporaries:list[IO[bytes]], dump_mem_fd:bool=False):
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
res = []
|
|
||||||
|
|
||||||
for idx_name, _, lang, iso in idxs:
|
|
||||||
srtname = f'{os.path.splitext(idx_name)[0]}.srt'
|
|
||||||
# Tesseract seems to recognize the three dots ... as "su"
|
|
||||||
ldots = re.compile('^su\n$')
|
|
||||||
# Timestamps produced by vobsubocr: 01:52:19,861 --> 01:52:21,641
|
|
||||||
timestamps = re.compile((r'^[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3} \-\-> (?P<hours>[0-9]{2}):'
|
|
||||||
r'(?P<minutes>[0-9]{2}):(?P<seconds>[0-9]{2}),[0-9]{3}$'))
|
|
||||||
|
|
||||||
srtfd = memfd_create(srtname, flags=0)
|
|
||||||
with Popen([vobsubocr, '--lang', iso, idx_name], stdout=PIPE) as ocr:
|
|
||||||
pb = tqdm(TextIOWrapper(ocr.stdout, encoding="utf-8"), total=
|
|
||||||
int(duration/timedelta(seconds=1)), unit='s', desc='OCR')
|
|
||||||
for line in pb:
|
|
||||||
m = re.match(ldots,line)
|
|
||||||
if m is not None:
|
|
||||||
write(srtfd, '...'.encode(encoding='UTF-8'))
|
|
||||||
else:
|
|
||||||
write(srtfd, line.encode(encoding='UTF-8'))
|
|
||||||
|
|
||||||
m = re.match(timestamps, line)
|
|
||||||
if m is not None:
|
|
||||||
hours = int(m.group('hours'))
|
|
||||||
minutes = int(m.group('hours'))
|
|
||||||
seconds = int(m.group('seconds'))
|
|
||||||
ts = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
|
||||||
pb.n = int(ts/timedelta(seconds=1))
|
|
||||||
pb.update()
|
|
||||||
|
|
||||||
status = ocr.wait()
|
|
||||||
|
|
||||||
if status != 0:
|
|
||||||
logger.error('OCR failed with status code: %d', status)
|
|
||||||
|
|
||||||
if dump_mem_fd:
|
|
||||||
try:
|
|
||||||
with open(srtname,'w', encoding='utf8') as dump_srt:
|
|
||||||
lseek(srtfd, 0, SEEK_SET)
|
|
||||||
srt_length = fstat(srtfd).st_size
|
|
||||||
buf = read(srtfd, srt_length)
|
|
||||||
outfd = dump_srt.fileno()
|
|
||||||
pos = 0
|
|
||||||
while pos < srt_length:
|
|
||||||
pos+=write(outfd, buf[pos:])
|
|
||||||
temporaries.append(dump_srt)
|
|
||||||
except OSError:
|
|
||||||
logger.error('Impossible to create file: %s', srtname)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
srt_length = fstat(srtfd).st_size
|
|
||||||
if srt_length > 0:
|
|
||||||
res.append((srtfd, lang))
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user