Files
wehub-resource-sync 39a086b41b
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 10.2 / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:08 +08:00

1115 lines
50 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
@Author : Fang Yao
@Time : 2021/3/24 9:28 上午
@FileName: main.py
@desc: 主程序入口文件
"""
import os
import re
import random
import shutil
import traceback
from collections import Counter, namedtuple
import unicodedata
from threading import Thread
from pathlib import Path
import cv2
from Levenshtein import ratio
from PIL import Image
from numpy import average, dot, linalg
from tqdm import tqdm
import sys
sys.path.insert(0, os.path.dirname(__file__))
import subprocess
from backend.config import *
from backend.tools.hardware_accelerator import HardwareAccelerator
from backend.tools import reformat
from backend.tools.ocr import OcrRecogniser, get_coordinates
from backend.tools import subtitle_ocr
from backend.tools.paddle_model_config import PaddleModelConfig
from backend.tools.process_manager import ProcessManager
from backend.tools.subtitle_detect import SubtitleDetect
from backend.bean.subtitle_area import SubtitleArea
import threading
import platform
import multiprocessing
import time
import pysrt
class SubtitleExtractor:
"""
视频字幕提取类
"""
def __init__(self, vd_path):
# 线程锁
self.lock = threading.RLock()
# 用户指定的字幕区域位置
self.sub_area = None
self.hardware_accelerator = HardwareAccelerator.instance()
# 是否使用硬件加速
self.hardware_accelerator.set_enabled(config.hardwareAcceleration.value)
self.model_config = PaddleModelConfig(self.hardware_accelerator)
# 创建字幕检测对象
self.sub_detector = SubtitleDetect()
# 视频路径
self.video_path = vd_path
self.video_cap = cv2.VideoCapture(vd_path)
# 通过视频路径获取视频名称
self.vd_name = Path(self.video_path).stem
# 临时存储文件夹
self.temp_output_dir = os.path.join(os.path.dirname(BASE_DIR), 'output', str(self.vd_name))
# 视频帧总数
self.frame_count = self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT)
# 视频帧率
self.fps = self.video_cap.get(cv2.CAP_PROP_FPS)
# 视频尺寸
self.frame_height = int(self.video_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
self.frame_width = int(self.video_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
# 提取的视频帧储存目录
self.frame_output_dir = os.path.join(self.temp_output_dir, 'frames')
# 提取的字幕文件存储目录
self.subtitle_output_dir = os.path.join(self.temp_output_dir, 'subtitle')
# 定义是否使用vsf提取字幕帧
self.use_vsf = False
# 定义vsf的字幕输出路径
self.vsf_subtitle = os.path.join(self.subtitle_output_dir, 'raw_vsf.srt')
# 提取的原始字幕文本存储路径
self.raw_subtitle_path = os.path.join(self.subtitle_output_dir, 'raw.txt')
# 定义输出字幕文件路径
self.subtitle_output_path = os.path.splitext(self.video_path)[0] + '.srt'
# 自定义ocr对象
self.ocr = None
# 总处理进度(帧提取100 + OCR100 + 后处理100 = 300
self.progress_total = 300
# 视频帧提取进度
self.progress_frame_extract = 0
# OCR识别进度
self.progress_ocr = 0
# 后处理进度
self.progress_post = 0
# 是否完成
self.isFinished = False
# 字幕OCR任务队列
self.subtitle_ocr_task_queue = None
# 字幕OCR进度队列
self.subtitle_ocr_progress_queue = None
# vsf运行状态
self.vsf_running = False
# 进度监听器列表
self.progress_listeners = []
def run(self):
"""
运行整个提取视频的步骤
"""
# 记录开始运行的时间
start_time = time.time()
self.lock.acquire()
# 重置进度条
self.update_progress(ocr=0, frame_extract=0, post=0)
self.append_output('-----------------------------')
# 打印识别语言与识别模式
self.append_output(f" {tr['Main']['RecSubLang']}{config.language.value} | {tr['Main']['RecMode']}{config.mode.value}")
# 如果使用GPU加速,则打印GPU加速提示
if self.hardware_accelerator.has_accelerator():
self.append_output(f" {tr['Main']['AcceleratorON'].format(self.hardware_accelerator.accelerator_name)}")
# 打印视频帧数与帧率
self.append_output(f" {tr['Main']['FrameCount']}{self.frame_count}"
f" | {tr['Main']['FrameRate']}{self.fps}")
# 打印加载模型信息
self.append_output(f" DET: {os.path.basename(self.model_config.DET_MODEL_PATH)} | REC: {os.path.basename(self.model_config.REC_MODEL_PATH)}")
self.append_output('-----------------------------')
# 打印视频帧提取开始提示
self.append_output(tr['Main']['StartProcessFrame'])
# 删除缓存
self.__delete_frame_cache()
# 若目录不存在,则创建文件夹
if not os.path.exists(self.frame_output_dir):
os.makedirs(self.frame_output_dir)
if not os.path.exists(self.subtitle_output_dir):
os.makedirs(self.subtitle_output_dir)
self.capture_frame_with_subtitle_area()
# 创建一个字幕OCR识别进程
subtitle_ocr_process = self.start_subtitle_ocr_async()
if self.sub_area is not None:
if platform.system() in ['Windows', 'Linux', 'Darwin']:
# 使用GPU且使用accurate模式时才开放此方法:
if self.hardware_accelerator.has_accelerator() and config.mode.value == 'accurate':
self.extract_frame_by_det()
else:
self.extract_frame_by_vsf()
else:
self.extract_frame_by_fps()
else:
self.extract_frame_by_fps()
# 往字幕OCR任务队列中,添加OCR识别任务结束标志
# 任务格式为:(total_frame_count总帧数, current_frame_no当前帧, dt_box检测框, rec_res识别结果, 当前帧时间, subtitle_area字幕区域)
self.subtitle_ocr_task_queue.put((self.frame_count, -1, None, None, None, None))
# 等待子线程完成
subtitle_ocr_process.join()
# 打印完成提示
self.append_output(tr['Main']['FinishProcessFrame'])
self.append_output(tr['Main']['FinishFindSub'])
if self.sub_area is None:
self.append_output(tr['Main']['StartDetectWaterMark'])
# 询问用户视频是否有水印区域
user_input = input(tr['Main']['checkWaterMark']).strip()
if user_input == 'y':
self.filter_watermark()
self.append_output(tr['Main']['FinishDetectWaterMark'])
else:
self.append_output('-----------------------------')
if self.sub_area is None:
self.append_output(tr['Main']['StartDeleteNonSub'])
self.filter_scene_text()
self.append_output(tr['Main']['FinishDeleteNonSub'])
self.update_progress(post=20)
# 打印开始字幕生成提示
self.append_output(tr['Main']['StartGenerateSub'])
# 判断是否使用了vsf提取字幕
if self.use_vsf:
# 如果使用了vsf提取字幕,则使用vsf的字幕生成方法
self.generate_subtitle_file_vsf()
else:
# 如果未使用vsf提取字幕,则使用常规字幕生成方法
self.generate_subtitle_file()
self.update_progress(post=90)
if config.wordSegmentation.value:
reformat.execute(self.subtitle_output_path, config.language.value)
self.append_output(tr['Main']['FinishGenerateSub'], f"{round(time.time() - start_time, 2)}s")
self.append_output('-----------------------------')
self.update_progress(ocr=100, frame_extract=100, post=100)
self.isFinished = True
# 删除缓存文件
self.empty_cache()
self.lock.release()
if config.generateTxt.value:
self.srt2txt(self.subtitle_output_path)
def capture_frame_with_subtitle_area(self):
"""
截取视频的一帧,并在上面绘制字幕区域,保存到temp_output_dir/sub_area.jpg
"""
# 确保输出目录存在
if not os.path.exists(self.temp_output_dir):
os.makedirs(self.temp_output_dir)
# 确保视频已打开
if not self.video_cap.isOpened():
self.video_cap = cv2.VideoCapture(self.video_path)
# 将视频指针设置到第一帧
# self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
# 读取第一帧
ret, frame = self.video_cap.read()
if ret:
# 如果有字幕区域,绘制矩形
sub_area = self.sub_area
if sub_area is not None:
# 绘制绿色矩形框
cv2.rectangle(frame, (sub_area.xmin, sub_area.ymin), (sub_area.xmax, sub_area.ymax), (0, 255, 0), 2)
# 添加文字标注
cv2.putText(frame, "Subtitle Area", (sub_area.xmin, sub_area.ymin - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
# 保存图像
output_path = os.path.join(self.temp_output_dir, 'sub_area.jpg')
cv2.imwrite(output_path, frame)
# 重置视频指针到第一帧
self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
def extract_frame_by_fps(self):
"""
根据帧率,定时提取视频帧,容易丢字幕,但速度快,将提取到的视频帧加入ocr识别任务队列
"""
# 计算采样间隔(每隔多少帧取一帧)
frame_interval = int(self.fps // config.extractFrequency.value)
if frame_interval < 1:
frame_interval = 1
# 当前视频帧的帧号
current_frame_no = 0
total_frames = int(self.frame_count)
while current_frame_no < total_frames:
# 直接跳到目标帧
self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, current_frame_no)
ret, frame = self.video_cap.read()
if not ret:
break
current_frame_no += 1
# subtitle_ocr_task_queue: (total_frame_count总帧数, current_frame_no当前帧, dt_box检测框, rec_res识别结果, 当前帧时间,subtitle_area字幕区域)
task = (self.frame_count, current_frame_no, None, None, None, config.subtitleArea.value)
self.subtitle_ocr_task_queue.put(task)
# 更新进度条
self.update_progress(frame_extract=(current_frame_no / self.frame_count) * 100)
# 跳到下一个采样帧
current_frame_no = current_frame_no + frame_interval - 1
self.video_cap.release()
def extract_frame_by_det(self):
"""
通过检测字幕区域位置提取字幕帧
"""
# 当前视频帧的帧号
current_frame_no = 0
frame_lru_list = []
frame_lru_list_max_size = 2
ocr_args_list = []
compare_ocr_result_cache = {}
tbar = tqdm(total=int(self.frame_count), unit='f', position=0, file=sys.__stdout__)
first_flag = True
is_finding_start_frame_no = False
is_finding_end_frame_no = False
start_frame_no = 0
start_end_frame_no = []
start_frame = None
if self.ocr is None:
self.ocr = OcrRecogniser()
while self.video_cap.isOpened():
ret, frame = self.video_cap.read()
# 如果读取视频帧失败(视频读到最后一帧)
if not ret:
break
# 读取视频帧成功
current_frame_no += 1
tbar.update(1)
dt_boxes, elapse = self.sub_detector.detect_subtitle(frame)
has_subtitle = False
sub_area = self.sub_area
if sub_area is not None:
coordinate_list = get_coordinates(dt_boxes.tolist())
if coordinate_list:
for coordinate in coordinate_list:
xmin, xmax, ymin, ymax = coordinate
if (sub_area.xmin <= xmin and xmax <= sub_area.xmax
and sub_area.ymin <= ymin
and ymax <= sub_area.ymax):
has_subtitle = True
# 检测到字幕时,如果列表为空,则为字幕头
if first_flag:
is_finding_start_frame_no = True
first_flag = False
break
else:
has_subtitle = len(dt_boxes) > 0
# 检测到包含字幕帧的起始帧号与结束帧号
if has_subtitle:
# 判断是字幕头还是尾
if is_finding_start_frame_no:
start_frame_no = current_frame_no
dt_box, rec_res = self.ocr.predict(frame)
area_text1 = "".join(self.__get_area_text((dt_box, rec_res)))
if start_frame_no not in compare_ocr_result_cache.keys():
compare_ocr_result_cache[current_frame_no] = {'text': area_text1, 'dt_box': dt_box, 'rec_res': rec_res}
frame_lru_list.append((frame, current_frame_no))
ocr_args_list.append((self.frame_count, current_frame_no))
# 缓存头帧
start_frame = frame
# 开始找尾
is_finding_start_frame_no = False
is_finding_end_frame_no = True
# 判断是否为最后一帧
if is_finding_end_frame_no and current_frame_no == self.frame_count:
is_finding_end_frame_no = False
is_finding_start_frame_no = False
end_frame_no = current_frame_no
frame_lru_list.append((frame, current_frame_no))
ocr_args_list.append((self.frame_count, current_frame_no))
start_end_frame_no.append((start_frame_no, end_frame_no))
# 如果在找结束帧的时候
if is_finding_end_frame_no:
# 判断该帧与头帧ocr内容是否一致,若不一致则找到尾,尾巴为前一帧
if not self._compare_ocr_result(compare_ocr_result_cache, None, start_frame_no, frame, current_frame_no):
is_finding_end_frame_no = False
is_finding_start_frame_no = True
end_frame_no = current_frame_no - 1
frame_lru_list.append((start_frame, end_frame_no))
ocr_args_list.append((self.frame_count, end_frame_no))
start_end_frame_no.append((start_frame_no, end_frame_no))
else:
# 如果检测到字幕头后有没有字幕,则找到结尾,尾巴为前一帧
if is_finding_end_frame_no:
end_frame_no = current_frame_no - 1
is_finding_end_frame_no = False
is_finding_start_frame_no = True
frame_lru_list.append((start_frame, end_frame_no))
ocr_args_list.append((self.frame_count, end_frame_no))
start_end_frame_no.append((start_frame_no, end_frame_no))
while len(frame_lru_list) > frame_lru_list_max_size:
frame_lru_list.pop(0)
# if len(start_end_frame_no) > 0:
# self.append_output(start_end_frame_no)
while len(ocr_args_list) > 1:
total_frame_count, ocr_info_frame_no = ocr_args_list.pop(0)
if current_frame_no in compare_ocr_result_cache:
predict_result = compare_ocr_result_cache[current_frame_no]
dt_box, rec_res = predict_result['dt_box'], predict_result['rec_res']
else:
dt_box, rec_res = None, None
# subtitle_ocr_task_queue: (total_frame_count总帧数, current_frame_no当前帧, dt_box检测框, rec_res识别结果, 当前帧时间, subtitle_area字幕区域)
task = (total_frame_count, ocr_info_frame_no, dt_box, rec_res, None, config.subtitleArea.value)
# 添加任务
self.subtitle_ocr_task_queue.put(task)
self.update_progress(frame_extract=(current_frame_no / self.frame_count) * 100)
while len(ocr_args_list) > 0:
total_frame_count, ocr_info_frame_no = ocr_args_list.pop(0)
if current_frame_no in compare_ocr_result_cache:
predict_result = compare_ocr_result_cache[current_frame_no]
dt_box, rec_res = predict_result['dt_box'], predict_result['rec_res']
else:
dt_box, rec_res = None, None
task = (total_frame_count, ocr_info_frame_no, dt_box, rec_res, None, config.subtitleArea.value)
# 添加任务
self.subtitle_ocr_task_queue.put(task)
self.video_cap.release()
def extract_frame_by_vsf(self):
"""
通过调用videoSubFinder获取字幕帧
"""
self.use_vsf = True
if self.video_cap:
self.video_cap.release()
self.video_cap = None
def count_process():
duration_ms = (self.frame_count / self.fps) * 1000
last_total_ms = 0
processed_image = set()
rgb_images_path = os.path.join(self.temp_output_dir, 'RGBImages')
time_pattern = re.compile(r'^\d+_\d+_\d+_\d+__')
while self.vsf_running and not self.isFinished:
time.sleep(0.2)
# 如果还没有rgb_images_path说明vsf还没处理完
if not os.path.exists(rgb_images_path):
# 继续等待
continue
try:
# 将列表按文件名排序
rgb_images = sorted(os.listdir(rgb_images_path))
for rgb_image in rgb_images:
# 如果当前图片已被处理,则跳过
if rgb_image in processed_image:
continue
if not time_pattern.match(rgb_image):
continue
# self.append_output('rgb_image: ', rgb_image)
processed_image.add(rgb_image)
# 根据vsf生成的文件名读取时间
h, m, s, ms = rgb_image.split('__')[0].split('_')
total_ms = int(ms) + int(s) * 1000 + int(m) * 60 * 1000 + int(h) * 60 * 60 * 1000
if total_ms > last_total_ms:
frame_no = int(total_ms / self.fps)
task = (self.frame_count, frame_no, None, None, total_ms, config.subtitleArea.value)
self.subtitle_ocr_task_queue.put(task)
last_total_ms = total_ms
if total_ms / duration_ms >= 1:
self.update_progress(frame_extract=100)
return
else:
self.update_progress(frame_extract=(total_ms / duration_ms) * 100)
# 文件被清理了
except FileNotFoundError:
return
def vsf_output(out, ):
duration_ms = (self.frame_count / self.fps) * 1000
last_total_ms = 0
for line in iter(out.readline, b''):
line = line.decode("utf-8")
# self.append_output('line', line, type(line), line.startswith('Frame: '))
if line.startswith('Frame: '):
line = line.replace("\n", "")
line = line.replace("Frame: ", "")
h, m, s, ms = line.split('__')[0].split('_')
total_ms = int(ms) + int(s) * 1000 + int(m) * 60 * 1000 + int(h) * 60 * 60 * 1000
if total_ms > last_total_ms:
frame_no = int(total_ms / self.fps)
task = (self.frame_count, frame_no, None, None, total_ms, config.subtitleArea.value)
self.subtitle_ocr_task_queue.put(task)
last_total_ms = total_ms
if total_ms / duration_ms >= 1:
self.update_progress(frame_extract=100)
return
else:
self.update_progress(frame_extract=(total_ms / duration_ms) * 100)
else:
self.append_output(line.strip())
out.close()
# 定义videoSubFinder所在路径
if platform.system() == 'Windows':
path_vsf = os.path.join(BASE_DIR, 'subfinder', 'windows', 'VideoSubFinderWXW.exe')
elif platform.system() == 'Darwin':
path_vsf = os.path.join(BASE_DIR, 'subfinder', 'macos', 'VideoSubFinderCli')
os.chmod(path_vsf, 0o775)
else:
path_vsf = os.path.join(BASE_DIR, 'subfinder', 'linux', 'VideoSubFinderCli.run')
os.chmod(path_vsf, 0o775)
# :图像上半部分所占百分比,取值【0-1】
top_end = 1 - self.sub_area.ymin / self.frame_height
# bottom_end:图像下半部分所占百分比,取值【0-1】
bottom_end = 1 - self.sub_area.ymax / self.frame_height
# left_end:图像左半部分所占百分比,取值【0-1】
left_end = self.sub_area.xmin / self.frame_width
# re:图像右半部分所占百分比,取值【0-1】
right_end = self.sub_area.xmax / self.frame_width
if (not self.hardware_accelerator.has_cuda()) and len(self.hardware_accelerator.onnx_providers) > 0:
cpu_count = multiprocessing.cpu_count()
else:
# 留2核心来给其他任务使用
cpu_count = max(multiprocessing.cpu_count() - 2, 1)
if config.videoSubFinderCpuCores.value > 0:
cpu_count = config.videoSubFinderCpuCores.value
if platform.system() == 'Windows':
# 定义执行命令
cmd = f"{path_vsf} --use_cuda -c -r -i \"{self.video_path}\" -o \"{self.temp_output_dir}\" -ces \"{self.vsf_subtitle}\" "
cmd += f"-te {top_end} -be {bottom_end} -le {left_end} -re {right_end} -nthr {cpu_count} -nocrthr {cpu_count} "
cmd += f"--open_video_{config.videoSubFinderDecoder.value.value.lower()} "
# 计算进度
try:
self.vsf_running = True
Thread(target=count_process, daemon=True).start()
# 已知BUG: test_chinese_cht.flv在net drive上会导致无法停止, 但在本地不会, 可能是vsf的原因
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1,
close_fds='posix' in sys.builtin_module_names, shell=False, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
ProcessManager.instance().add_process(p)
self.manage_process(p.pid)
p.wait()
finally:
self.vsf_running = False
else:
# 定义执行命令
cmd = f"{path_vsf} -c -r -i \"{self.video_path}\" -o \"{self.temp_output_dir}\" -ces \"{self.vsf_subtitle}\" "
if self.hardware_accelerator.has_accelerator():
cmd += "--use_cuda "
cmd += f"-te {top_end} -be {bottom_end} -le {left_end} -re {right_end} -nthr {cpu_count} -dsi "
cmd += f"--open_video_{config.videoSubFinderDecoder.value.value.lower()} "
self.vsf_running = True
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1,
close_fds='posix' in sys.builtin_module_names, shell=True,
start_new_session=True)
Thread(target=vsf_output, daemon=True, args=(p.stderr,)).start()
ProcessManager.instance().add_process(p)
self.manage_process(p.pid)
p.wait()
finally:
self.vsf_running = False
def filter_watermark(self):
"""
去除原始字幕文本中的水印区域的文本
"""
# 获取潜在水印区域
watermark_areas = self._detect_watermark_area()
# 随机选择一帧, 将所水印区域标记出来,用户看图判断是否是水印区域
cap = cv2.VideoCapture(self.video_path)
ret, sample_frame = False, None
for i in range(10):
frame_no = random.randint(int(self.frame_count * 0.1), int(self.frame_count * 0.9))
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
ret, sample_frame = cap.read()
if ret:
break
cap.release()
if not ret:
self.append_output("Error in filter_watermark: reading frame from video")
return
# 给潜在的水印区域编号
area_num = ['E', 'D', 'C', 'B', 'A']
for watermark_area in watermark_areas:
ymin = min(watermark_area[0][2], watermark_area[0][3])
ymax = max(watermark_area[0][3], watermark_area[0][2])
xmin = min(watermark_area[0][0], watermark_area[0][1])
xmax = max(watermark_area[0][1], watermark_area[0][0])
cover = sample_frame[ymin:ymax, xmin:xmax]
cover = cv2.blur(cover, (10, 10))
cv2.rectangle(cover, pt1=(0, cover.shape[0]), pt2=(cover.shape[1], 0), color=(0, 0, 255), thickness=3)
sample_frame[ymin:ymax, xmin:xmax] = cover
position = ((xmin + xmax) // 2, ymax)
cv2.putText(sample_frame, text=area_num.pop(), org=position, fontFace=cv2.FONT_HERSHEY_SIMPLEX,
fontScale=1, color=(255, 0, 0), thickness=2, lineType=cv2.LINE_AA)
sample_frame_file_path = os.path.join(os.path.dirname(self.frame_output_dir), 'watermark_area.jpg')
cv2.imwrite(sample_frame_file_path, sample_frame)
self.append_output(f"{tr['Main']['WatchPicture']}: {sample_frame_file_path}")
area_num = ['E', 'D', 'C', 'B', 'A']
for watermark_area in watermark_areas:
user_input = input(f"{area_num.pop()}{str(watermark_area)} "
f"{tr['Main']['QuestionDelete']}").strip()
if user_input == 'y' or user_input == '\n':
with open(self.raw_subtitle_path, mode='r+', encoding='utf-8') as f:
content = f.readlines()
f.seek(0)
for i in content:
if i.find(str(watermark_area[0])) == -1:
f.write(i)
f.truncate()
self.append_output(tr['Main']['FinishDelete'])
self.append_output(tr['Main']['FinishWaterMarkFilter'])
# 删除缓存
if os.path.exists(sample_frame_file_path):
os.remove(sample_frame_file_path)
def filter_scene_text(self):
"""
将场景里提取的文字过滤,仅保留字幕区域
"""
# 获取潜在字幕区域
subtitle_area = self._detect_subtitle_area()[0][0]
# 随机选择一帧,将所水印区域标记出来,用户看图判断是否是水印区域
cap = cv2.VideoCapture(self.video_path)
ret, sample_frame = False, None
for i in range(10):
frame_no = random.randint(int(self.frame_count * 0.1), int(self.frame_count * 0.9))
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
ret, sample_frame = cap.read()
if ret:
break
cap.release()
if not ret:
self.append_output("Error in filter_scene_text: reading frame from video")
return
# 为了防止有双行字幕,根据容忍度,将字幕区域y范围加高
ymin = abs(subtitle_area[0] - config.subtitleAreaDeviationPixel.value)
ymax = subtitle_area[1] + config.subtitleAreaDeviationPixel.value
# 画出字幕框的区域
cv2.rectangle(sample_frame, pt1=(0, ymin), pt2=(sample_frame.shape[1], ymax), color=(0, 0, 255), thickness=3)
sample_frame_file_path = os.path.join(os.path.dirname(self.frame_output_dir), 'subtitle_area.jpg')
cv2.imwrite(sample_frame_file_path, sample_frame)
self.append_output(f"{tr['Main']['CheckSubArea']} {sample_frame_file_path}")
user_input = input(f"{(ymin, ymax)} {tr['Main']['DeleteNoSubArea']}").strip()
if user_input == 'y' or user_input == '\n':
with open(self.raw_subtitle_path, mode='r+', encoding='utf-8') as f:
content = f.readlines()
f.seek(0)
for i in content:
i_ymin = int(i.split('\t')[1].split('(')[1].split(')')[0].split(', ')[2])
i_ymax = int(i.split('\t')[1].split('(')[1].split(')')[0].split(', ')[3])
if ymin <= i_ymin and i_ymax <= ymax:
f.write(i)
f.truncate()
self.append_output(tr['Main']['FinishDeleteNoSubArea'])
# 删除缓存
if os.path.exists(sample_frame_file_path):
os.remove(sample_frame_file_path)
def generate_subtitle_file(self):
"""
生成srt格式的字幕文件
"""
if not self.use_vsf:
subtitle_content = self._remove_duplicate_subtitle()
# 保存持续时间不足1秒的字幕行,用于后续处理
post_process_subtitle = []
with open(self.subtitle_output_path, mode='w', encoding='utf-8') as f:
for index, content in enumerate(subtitle_content):
line_code = index + 1
frame_start = self._frame_to_timecode(int(content[0]))
# 比较起始帧号与结束帧号, 如果字幕持续时间不足1秒,则将显示时间设为1s
if abs(int(content[1]) - int(content[0])) < self.fps:
frame_end = self._frame_to_timecode(int(int(content[0]) + self.fps))
post_process_subtitle.append(line_code)
else:
frame_end = self._frame_to_timecode(int(content[1]))
frame_content = content[2]
subtitle_line = f'{line_code}\n{frame_start} --> {frame_end}\n{frame_content}\n'
f.write(subtitle_line)
self.append_output(tr['Main']['SubLocation'].format(self.subtitle_output_path))
# 返回持续时间低于1s的字幕行
return post_process_subtitle
def generate_subtitle_file_vsf(self):
if not self.use_vsf:
return
subs = pysrt.open(self.vsf_subtitle)
sub_no_map = {}
for sub in subs:
sub.start.no = self._timestamp_to_frameno(sub.start.ordinal)
sub_no_map[sub.start.no] = sub
subtitle_content = self._remove_duplicate_subtitle()
subtitle_content_start_map = {int(a[0]): a for a in subtitle_content}
final_subtitles = []
for sub in subs:
found = sub.start.no in subtitle_content_start_map
if found:
subtitle_content_line = subtitle_content_start_map[sub.start.no]
sub.text = subtitle_content_line[2]
end_no = int(subtitle_content_line[1])
sub.end = sub_no_map[end_no].end if end_no in sub_no_map else sub.end
sub.index = len(final_subtitles) + 1
final_subtitles.append(sub)
if not found and not config.deleteEmptyTimeStamp.value:
# 保留时间轴
sub.text = ""
sub.index = len(final_subtitles) + 1
final_subtitles.append(sub)
continue
pysrt.SubRipFile(final_subtitles).save(self.subtitle_output_path, encoding='utf-8')
self.append_output(tr['Main']['SubLocation'].format(self.subtitle_output_path))
def _detect_watermark_area(self):
"""
根据识别出来的raw txt文件中的坐标点信息,查找水印区域
假定:水印区域(台标)的坐标在水平和垂直方向都是固定的,也就是具有(xmin, xmax, ymin, ymax)相对固定
根据坐标点信息,进行统计,将一直具有固定坐标的文本区域选出
:return 返回最有可能的水印区域
"""
with open(self.raw_subtitle_path, mode='r', encoding='utf-8') as f:
lines = f.readlines()
# 一次性解析所有行
parsed = []
for line in lines:
parts = line.split('\t')
if len(parts) < 3:
continue
frame_no = parts[0]
text_position = parts[1].split('(')[1].split(')')[0].split(', ')
content = parts[2]
coordinate = (int(text_position[0]), int(text_position[1]),
int(text_position[2]), int(text_position[3]))
parsed.append((frame_no, coordinate, content))
frame_no_list = [p[0] for p in parsed]
coordinates_list = [p[1] for p in parsed]
content_list = [p[2] for p in parsed]
# 将坐标列表的相似值统一
coordinates_list = self._unite_coordinates(coordinates_list)
# 将原txt文件的坐标更新为归一后的坐标
with open(self.raw_subtitle_path, mode='w', encoding='utf-8') as f:
for frame_no, coordinate, content in zip(frame_no_list, coordinates_list, content_list):
f.write(f'{frame_no}\t{coordinate}\t{content}')
count = Counter(coordinates_list).most_common()
num = min(config.waterarkAreaNum.value, len(count))
return count[:num]
def _detect_subtitle_area(self):
"""
读取过滤水印区域后的raw txt文件,根据坐标信息,查找字幕区域
假定:字幕区域在y轴上有一个相对固定的坐标范围,相对于场景文本,这个范围出现频率更高
:return 返回字幕的区域位置
"""
with open(self.raw_subtitle_path, mode='r', encoding='utf-8') as f:
lines = f.readlines()
y_coordinates_list = []
for line in lines:
parts = line.split('\t')
if len(parts) < 2:
continue
text_position = parts[1].split('(')[1].split(')')[0].split(', ')
y_coordinates_list.append((int(text_position[2]), int(text_position[3])))
return Counter(y_coordinates_list).most_common(1)
def _frame_to_timecode(self, frame_no):
"""
将视频帧转换成时间(纯计算,不打开视频文件)
:param frame_no: 视频的帧号,i.e. 第几帧视频帧
:returns: SMPTE格式时间戳 as string, 如'01:02:12,032'
"""
total_ms = frame_no / self.fps * 1000
total_ms_int = int(total_ms)
milliseconds = total_ms_int % 1000
total_seconds = total_ms_int // 1000
seconds = total_seconds % 60
minutes = (total_seconds // 60) % 60
hours = total_seconds // 3600
return "%02d:%02d:%02d,%03d" % (hours, minutes, seconds, milliseconds)
def _timestamp_to_frameno(self, time_ms):
return int(time_ms / self.fps)
def _frameno_to_milliseconds(self, frame_no):
return float(int(frame_no / self.fps * 1000))
def _remove_duplicate_subtitle(self):
"""
读取原始的raw txt,去除重复行,返回去除了重复后的字幕列表
"""
self._concat_content_with_same_frameno()
with open(self.raw_subtitle_path, mode='r', encoding='utf-8') as r:
lines = r.readlines()
RawInfo = namedtuple('RawInfo', 'no content')
content_list = []
for line in lines:
frame_no = line.split('\t')[0]
content = line.split('\t')[2]
content_list.append(RawInfo(frame_no, content))
# 去重后的字幕列表
unique_subtitle_list = []
idx_i = 0
content_list_len = len(content_list)
# 循环遍历每行字幕,记录开始时间与结束时间
last_reported_idx = -1
report_interval = max(1, content_list_len // 100)
while idx_i < content_list_len:
i = content_list[idx_i]
start_frame = i.no
idx_j = idx_i
while idx_j < content_list_len:
# 定期更新去重进度(post: 20→85),基于 idx_j 推进
if idx_j - last_reported_idx >= report_interval:
last_reported_idx = idx_j
self.update_progress(post=20 + int(65 * idx_j / content_list_len))
# 计算当前行与下一行的Levenshtein距离
# 判决idx_j的下一帧是否与idx_i不同,若不同(或者是最后一帧)则找到结束帧
if idx_j + 1 == content_list_len or ratio(i.content.replace(' ', ''), content_list[idx_j + 1].content.replace(' ', '')) < (config.thresholdTextSimilarity.value / 100.0):
# 若找到终点帧,定义字幕结束帧帧号
end_frame = content_list[idx_j].no
if not self.use_vsf:
if end_frame == start_frame and idx_j + 1 < content_list_len:
# 针对只有一帧的情况,以下一帧的开始时间为准(除非是最后一帧)
end_frame = content_list[idx_j + 1][0]
# 寻找最长字幕
similar_list = content_list[idx_i:idx_j + 1]
similar_content_strip_list = [item.content.replace(' ', '') for item in similar_list]
index, _ = max(enumerate(similar_content_strip_list), key=lambda x: len(x[1]))
# 添加进列表
unique_subtitle_list.append((start_frame, end_frame, similar_list[index].content))
idx_i = idx_j + 1
break
else:
idx_j += 1
continue
return unique_subtitle_list
def _concat_content_with_same_frameno(self):
"""
将raw txt文本中具有相同帧号的字幕行合并
"""
with open(self.raw_subtitle_path, mode='r', encoding='utf-8') as r:
lines = r.readlines()
# 用dict按frame_no分组,保留原始顺序
from collections import OrderedDict
grouped = OrderedDict()
for line in lines:
parts = line.split('\t', 2)
if len(parts) < 3:
continue
frame_no, coordinate, content = parts
if frame_no not in grouped:
grouped[frame_no] = (coordinate, [])
grouped[frame_no][1].append(content)
with open(self.raw_subtitle_path, mode='w', encoding='utf-8') as f:
for frame_no, (coordinate, contents) in grouped.items():
merged = ' '.join(contents).replace('\n', ' ')
if not merged.endswith('\n'):
merged += '\n'
merged = unicodedata.normalize('NFKC', merged)
f.write(f'{frame_no}\t{coordinate}\t{merged}')
def _unite_coordinates(self, coordinates_list):
"""
给定一个坐标列表,将这个列表中相似的坐标统一为一个值
e.g. 由于检测框检测的结果不是一致的,相同位置文字的坐标可能一次检测为(255,123,456,789),另一次检测为(253,122,456,799)
因此要对相似的坐标进行值的统一
:param coordinates_list 包含坐标点的列表
:return: 返回一个统一值后的坐标列表
"""
if not coordinates_list:
return coordinates_list
# 按xmin排序后用滑动窗口,相似坐标的xmin差值在容忍范围内
n = len(coordinates_list)
indexed = sorted(enumerate(coordinates_list), key=lambda x: x[1][0])
# parent数组用于并查集
parent = list(range(n))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(i, j):
ri, rj = find(i), find(j)
if ri != rj:
# 保留较小索引的坐标作为代表
if ri > rj:
ri, rj = rj, ri
parent[rj] = ri
# 滑动窗口:xmin已排序,只要xmin差值超过容忍度就移动左边界
left = 0
for right in range(n):
while left < right and indexed[right][1][0] - indexed[left][1][0] >= config.tolerantPixelX:
left += 1
# 检查窗口内所有元素与当前元素是否相似
for k in range(left, right):
if self.__is_coordinate_similar(indexed[k][1], indexed[right][1]):
union(indexed[k][0], indexed[right][0])
# 将所有坐标统一为其组代表的坐标
result = list(coordinates_list)
for i in range(n):
result[i] = coordinates_list[find(i)]
return result
def _compute_image_similarity(self, image1, image2):
"""
计算两张图片的余弦相似度
"""
image1 = self.__get_thum(image1)
image2 = self.__get_thum(image2)
images = [image1, image2]
vectors = []
norms = []
for image in images:
vector = []
for pixel_tuple in image.getdata():
vector.append(average(pixel_tuple))
vectors.append(vector)
# linalg=linear(线性)+algebra(代数),norm则表示范数
# 求图片的范数
norms.append(linalg.norm(vector, 2))
a, b = vectors
a_norm, b_norm = norms
# dot返回的是点积,对二维数组(矩阵)进行计算
res = dot(a / a_norm, b / b_norm)
return res
def __get_area_text(self, ocr_result):
"""
获取字幕区域内的文本内容
"""
box, text = ocr_result
coordinates = get_coordinates(box)
area_text = []
for content, coordinate in zip(text, coordinates):
sub_area = self.sub_area
if sub_area is not None:
xmin = coordinate[0]
xmax = coordinate[1]
ymin = coordinate[2]
ymax = coordinate[3]
if sub_area.xmin <= xmin and xmax <= sub_area.xmax and sub_area.ymin <= ymin and ymax <= sub_area.ymax:
area_text.append(content[0])
return area_text
def _compare_ocr_result(self, result_cache, img1, img1_no, img2, img2_no):
"""
比较两张图片预测出的字幕区域文本是否相同
"""
if self.ocr is None:
self.ocr = OcrRecogniser()
if img1_no in result_cache:
area_text1 = result_cache[img1_no]['text']
else:
dt_box, rec_res = self.ocr.predict(img1)
area_text1 = "".join(self.__get_area_text((dt_box, rec_res)))
result_cache[img1_no] = {'text': area_text1, 'dt_box': dt_box, 'rec_res': rec_res}
if img2_no in result_cache:
area_text2 = result_cache[img2_no]['text']
else:
dt_box, rec_res = self.ocr.predict(img2)
area_text2 = "".join(self.__get_area_text((dt_box, rec_res)))
result_cache[img2_no] = {'text': area_text2, 'dt_box': dt_box, 'rec_res': rec_res}
delete_no_list = []
for no in result_cache:
if no < min(img1_no, img2_no) - 10:
delete_no_list.append(no)
for no in delete_no_list:
del result_cache[no]
if ratio(area_text1, area_text2) > config.thresholdTextSimilarity.value / 100.0:
return True
else:
return False
@staticmethod
def __is_coordinate_similar(coordinate1, coordinate2):
"""
计算两个坐标是否相似,如果两个坐标点的xmin,xmax,ymin,ymax的差值都在像素点容忍度内
则认为这两个坐标点相似
"""
return abs(coordinate1[0] - coordinate2[0]) < config.tolerantPixelX and \
abs(coordinate1[1] - coordinate2[1]) < config.tolerantPixelX and \
abs(coordinate1[2] - coordinate2[2]) < config.tolerantPixelY and \
abs(coordinate1[3] - coordinate2[3]) < config.tolerantPixelY
@staticmethod
def __get_thum(image, size=(64, 64), greyscale=False):
"""
对图片进行统一化处理
"""
# 利用image对图像大小重新设置, Image.ANTIALIAS为高质量的
image = image.resize(size, Image.ANTIALIAS)
if greyscale:
# 将图片转换为L模式,其为灰度图,其每个像素用8个bit表示
image = image.convert('L')
return image
def __delete_frame_cache(self):
if os.path.exists(self.temp_output_dir):
shutil.rmtree(self.temp_output_dir, True)
def empty_cache(self):
"""
删除字幕提取过程中所有生产的缓存文件
"""
if not config.debugNoDeleteCache.value:
if os.path.exists(self.temp_output_dir):
shutil.rmtree(self.temp_output_dir, True)
def update_progress(self, ocr=None, frame_extract=None, post=None):
"""
更新进度条
:param ocr ocr进度
:param frame_extract 视频帧提取进度
:param post 后处理进度
"""
if ocr is not None:
self.progress_ocr = max(0, min(100, ocr)) # Clamp value between 0 and 100
if frame_extract is not None:
self.progress_frame_extract = max(0, min(100, frame_extract))
if post is not None:
self.progress_post = max(0, min(100, post))
# 通知所有监听器
self.notify_progress_listeners()
def start_subtitle_ocr_async(self):
def get_ocr_progress():
"""
获取ocr识别进度
生产者在所有帧入队后发送 (-2, total_tasks) 告知总帧数
消费者每处理一帧发送 (frame_no, processed_count)
消费者处理完全部帧后发送 (-1, total_tasks)
"""
notify = True
total_tasks = None
while True:
value = self.subtitle_ocr_progress_queue.get(block=True)
if notify:
self.append_output(tr['Main']['StartFindSub'])
notify = False
# 生产者告知总帧数 value = (-2, total_tasks)
if isinstance(value, tuple) and len(value) == 2 and value[0] == -2:
total_tasks = value[1]
continue
# 终止条件
if value == -1:
self.update_progress(ocr=100)
return
if isinstance(value, tuple) and value[0] == -1:
self.update_progress(ocr=100)
return
# value = (frame_no, processed_count)
if isinstance(value, tuple) and len(value) == 2:
_, processed_count = value
if total_tasks and total_tasks > 0:
ocr_pct = min(99, processed_count / total_tasks * 100)
else:
ocr_pct = min(99, processed_count)
self.update_progress(ocr=ocr_pct)
options = {
'REC_CHAR_TYPE': config.language.value,
'DROP_SCORE': config.dropScore.value / 100.0,
'SUB_AREA_DEVIATION_RATE': config.subtitleAreaDeviationRate.value / 100.0,
'DEBUG_OCR_LOSS': config.debugOcrLoss.value,
'HARDWARD_ACCELERATOR': self.hardware_accelerator,
}
process, task_queue, progress_queue = subtitle_ocr.async_start(self.video_path, self.raw_subtitle_path, self.sub_area, options)
ProcessManager.instance().add_process(process)
self.manage_process(process.pid)
self.subtitle_ocr_task_queue = task_queue
self.subtitle_ocr_progress_queue = progress_queue
# 开启线程负责更新OCR进度
Thread(target=get_ocr_progress, daemon=True).start()
return process
def srt2txt(self, srt_file):
subs = pysrt.open(srt_file, encoding='utf-8')
output_path = os.path.join(os.path.dirname(srt_file), Path(srt_file).stem + '.txt')
self.append_output(output_path)
with open(output_path, 'w', encoding='utf-8') as f:
for sub in subs:
f.write(f'{sub.text}\n')
def append_output(self, *args):
"""输出信息到控制台
Args:
*args: 要输出的内容,多个参数将用空格连接
"""
print(*args)
def add_progress_listener(self, listener):
"""
添加进度监听器
Args:
listener: 一个回调函数,接收参数 (progress_ocr, progress_frame_extract, progress_total, isFinished)
"""
if listener not in self.progress_listeners:
self.progress_listeners.append(listener)
def remove_progress_listener(self, listener):
"""
移除进度监听器
Args:
listener: 要移除的监听器函数
"""
if listener in self.progress_listeners:
self.progress_listeners.remove(listener)
def notify_progress_listeners(self):
"""
通知所有进度监听器当前进度
"""
for listener in self.progress_listeners:
try:
listener(self.progress_ocr, self.progress_frame_extract, self.progress_total, self.isFinished, self.progress_post)
except Exception as e:
traceback.print_exc()
def manage_process(pid):
pass
if __name__ == '__main__':
multiprocessing.set_start_method("spawn")
# 提示用户输入视频路径
video_path = input(f"{tr['Main']['InputVideo']}").strip()
# 提示用户输入字幕区域
try:
y_min, y_max, x_min, x_max = map(int, input(
f"{tr['Main']['ChooseSubArea']} (ymin ymax xmin xmax)").split())
subtitle_area = SubtitleArea(y_min, y_max, x_min, x_max)
except ValueError as e:
subtitle_area = None
# 新建字幕提取对象
se = SubtitleExtractor(video_path)
se.sub_area = subtitle_area
# 开始提取字幕
se.run()