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

303 lines
14 KiB
Python

import os
import re
from multiprocessing import Queue, Process
import cv2
from PIL import ImageFont, ImageDraw, Image
from tqdm import tqdm
from backend.tools.ocr import OcrRecogniser, get_coordinates
from backend.tools.constant import SubtitleArea
from backend.tools import constant
from threading import Thread
import queue
from types import SimpleNamespace
import shutil
import numpy as np
from collections import namedtuple
from backend.config import tr
def extract_subtitles(data, text_recogniser, img, raw_subtitles,
sub_area, options, dt_box_arg, rec_res_arg, ocr_loss_debug_path):
"""
提取视频帧中的字幕信息
"""
# 从参数中获取检测框与检测结果
dt_box = dt_box_arg
rec_res = rec_res_arg
# 如果没有检测结果,则获取检测结果
if dt_box is None or rec_res is None:
dt_box, rec_res = text_recogniser.predict(img)
# rec_res格式为: ("hello", 0.997)
# 获取文本坐标
coordinates = get_coordinates(dt_box)
# 将结果写入txt文本中
if options.REC_CHAR_TYPE == 'en':
# 如果识别语言为英文,则去除中文
text_res = [(re.sub('[\u4e00-\u9fa5]', '', res[0]), res[1]) for res in rec_res]
else:
text_res = [(res[0], res[1]) for res in rec_res]
line = ''
loss_list = []
for content, coordinate in zip(text_res, coordinates):
text = content[0]
prob = content[1]
if sub_area is not None:
selected = False
# 初始化超界偏差为0
overflow_area_rate = 0
# 使用AABB矩形重叠判断(比Shapely Polygon快得多)
c_xmin, c_xmax, c_ymin, c_ymax = coordinate
# 计算交集矩形
inter_xmin = max(sub_area.xmin, c_xmin)
inter_ymin = max(sub_area.ymin, c_ymin)
inter_xmax = min(sub_area.xmax, c_xmax)
inter_ymax = min(sub_area.ymax, c_ymax)
has_intersection = inter_xmin < inter_xmax and inter_ymin < inter_ymax
drop_reason = ''
# 如果有交集
if has_intersection:
sub_area_w = sub_area.xmax - sub_area.xmin
sub_area_h = sub_area.ymax - sub_area.ymin
sub_area_size = sub_area_w * sub_area_h
inter_area = (inter_xmax - inter_xmin) * (inter_ymax - inter_ymin)
coord_area = (c_xmax - c_xmin) * (c_ymax - c_ymin)
# 计算越界允许偏差
overflow_area_rate = ((sub_area_size + coord_area - inter_area) / sub_area_size) - 1
# 如果越界比例低于设定阈值且该行文本识别的置信度高于设定阈值
not_overflow = overflow_area_rate <= options.SUB_AREA_DEVIATION_RATE
confident = prob > options.DROP_SCORE
if not_overflow and confident:
# 保留该帧
selected = True
line += f'{str(data["i"]).zfill(8)}\t{coordinate}\t{text}\n'
raw_subtitles.append(f'{str(data["i"]).zfill(8)}\t{coordinate}\t{text}\n')
else:
if not not_overflow:
drop_reason = tr['Main']['OcrDropOutOfBoxRate'].format(int(options.SUB_AREA_DEVIATION_RATE * 100), int(overflow_area_rate * 100))
elif not confident:
drop_reason = tr['Main']['OcrDropConfidentLow'].format(int(options.DROP_SCORE * 100))
else:
drop_reason = tr['Main']['OcrDropNoIntercetion']
if drop_reason:
tqdm.write(tr['Main']['OcrResultWithDropReason'].format(text, round(prob * 100,1), drop_reason))
else:
tqdm.write(tr['Main']['OcrResult'].format(text, round(prob * 100,1)))
# 保存丢掉的识别结果
loss_info = namedtuple('loss_info', 'text prob overflow_area_rate coordinate selected')
loss_list.append(loss_info(text, prob, overflow_area_rate, coordinate, selected))
else:
raw_subtitles.append(f'{str(data["i"]).zfill(8)}\t{coordinate}\t{text}\n')
# 输出调试信息
dump_debug_info(options, line, img, loss_list, ocr_loss_debug_path, sub_area, data)
def dump_debug_info(options, line, img, loss_list, ocr_loss_debug_path, sub_area, data):
loss = False
if options.DEBUG_OCR_LOSS and options.REC_CHAR_TYPE in ('ch', 'japan ', 'korea', 'ch_tra'):
loss = len(line) > 0 and re.search(r'[\u4e00-\u9fa5\u3400-\u4db5\u3130-\u318F\uAC00-\uD7A3\u0800-\u4e00]', line) is None
if loss:
if not os.path.exists(ocr_loss_debug_path):
os.makedirs(ocr_loss_debug_path, mode=0o777, exist_ok=True)
img = cv2.rectangle(img, (sub_area.xmin, sub_area.ymin), (sub_area.xmax, sub_area.ymax), constant.BGR_COLOR_BLUE, 2)
for loss_info in loss_list:
coordinate = loss_info.coordinate
color = constant.BGR_COLOR_GREEN if loss_info.selected else constant.BGR_COLOR_RED
text = f"[{loss_info.text}] prob:{loss_info.prob:.4f} or:{loss_info.overflow_area_rate:.2f}"
img = paint_chinese_opencv(img, text, pos=(coordinate[0], coordinate[2] - 30), color=color)
img = cv2.rectangle(img, (coordinate[0], coordinate[2]), (coordinate[1], coordinate[3]), color, 2)
cv2.imwrite(os.path.join(os.path.abspath(ocr_loss_debug_path), f'{str(data["i"]).zfill(8)}.png'), img)
FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'NotoSansCJK-Bold.otf')
FONT = ImageFont.truetype(FONT_PATH, 20)
def paint_chinese_opencv(im, chinese, pos, color):
img_pil = Image.fromarray(im)
fill_color = color # (color[2], color[1], color[0])
position = pos
draw = ImageDraw.Draw(img_pil)
draw.text(position, chinese, font=FONT, fill=fill_color)
img = np.array(img_pil)
return img
def ocr_task_consumer(ocr_queue, raw_subtitle_path, sub_area, video_path, options, progress_queue):
"""
消费者: 消费ocr_queue,将ocr队列中的数据取出,进行ocr识别,写入字幕文件中
:param ocr_queue (current_frame_no当前帧帧号, frame 视频帧, dt_box检测框, rec_res识别结果)
:param raw_subtitle_path
:param sub_area
:param video_path
:param options
"""
data = {'i': 1}
# 初始化文本识别对象
text_recogniser = OcrRecogniser()
text_recogniser.hardware_accelerator = options.HARDWARD_ACCELERATOR
# 丢失字幕的存储路径
ocr_loss_debug_path = os.path.join(os.path.abspath(os.path.splitext(video_path)[0]), 'loss')
# 删除之前的缓存垃圾
if os.path.exists(ocr_loss_debug_path):
shutil.rmtree(ocr_loss_debug_path, True)
raw_subtitles = []
processed_count = 0
try:
while True:
try:
frame_no, frame, dt_box, rec_res = ocr_queue.get(block=True)
if frame_no == -1:
# frame 是生产者统计的总帧数
total_tasks = frame if frame is not None else processed_count
progress_queue.put((-1, total_tasks))
return
data['i'] = frame_no
extract_subtitles(data, text_recogniser, frame, raw_subtitles, sub_area, options, dt_box,
rec_res, ocr_loss_debug_path)
processed_count += 1
progress_queue.put((frame_no, processed_count))
except Exception as e:
print(e)
progress_queue.put(-1)
break
finally:
with open(raw_subtitle_path, mode='w+', encoding='utf-8') as raw_subtitle_file:
for line in raw_subtitles:
raw_subtitle_file.write(line)
def ocr_task_producer(ocr_queue, task_queue, progress_queue, video_path, raw_subtitle_path):
"""
生产者:负责生产用于OCR识别的数据,将需要进行ocr识别的数据加入ocr_queue中
:param ocr_queue (current_frame_no当前帧帧号, frame 视频帧, dt_box检测框, rec_res识别结果)
:param task_queue (total_frame_count总帧数, current_frame_no当前帧帧号, dt_box检测框, rec_res识别结果, subtitle_area字幕区域)
:param progress_queue
:param video_path
:param raw_subtitle_path
"""
cap = cv2.VideoCapture(video_path)
tbar = None
frame_count = 0
while True:
try:
# 从任务队列中提取任务信息
total_frame_count, current_frame_no, dt_box, rec_res, total_ms, default_subtitle_area = task_queue.get(block=True)
if tbar is None:
tbar = tqdm(total=round(total_frame_count), position=1)
# current_frame 等于-1说明所有视频帧已经读完
if current_frame_no == -1:
# ocr识别队列加入结束标志,附带总帧数
ocr_queue.put((-1, frame_count, None, None))
# 通过 progress_queue 提前通知总帧数,让主进程可以精确计算进度
progress_queue.put((-2, frame_count))
# 更新进度条
tbar.update(tbar.total - tbar.n)
break
tbar.update(round(current_frame_no - tbar.n))
# 设置当前视频帧
# 如果total_ms不为空,则使用了VSF提取字幕
if total_ms is not None:
cap.set(cv2.CAP_PROP_POS_MSEC, total_ms)
else:
cap.set(cv2.CAP_PROP_POS_FRAMES, current_frame_no - 1)
# 读取视频帧
ret, frame = cap.read()
# 如果读取成功
if ret:
frame_count += 1
# 根据默认字幕位置,则对视频帧进行裁剪,裁剪后处理
if default_subtitle_area is not None:
frame = frame_preprocess(default_subtitle_area, frame)
# print(f"current_frame_no: {current_frame_no}")
ocr_queue.put((current_frame_no, frame, dt_box, rec_res))
except Exception as e:
print(e)
break
cap.release()
def subtitle_extract_handler(task_queue, progress_queue, video_path, raw_subtitle_path, sub_area, options):
"""
创建并开启一个视频帧提取线程与一个ocr识别线程
:param task_queue 任务队列,(total_frame_count总帧数, current_frame_no当前帧, dt_box检测框, rec_res识别结果, subtitle_area字幕区域)
:param progress_queue 进度队列
:param video_path 视频路径
:param raw_subtitle_path 原始字幕文件路径
:param sub_area 字幕区域
:param options 选项
"""
# 删除缓存
if os.path.exists(raw_subtitle_path):
os.remove(raw_subtitle_path)
# 创建一个OCR队列,大小建议值8-20
ocr_queue = queue.Queue(20)
# 创建一个OCR事件生产者线程
ocr_event_producer_thread = Thread(target=ocr_task_producer,
args=(ocr_queue, task_queue, progress_queue, video_path, raw_subtitle_path,),
daemon=True)
# 创建一个OCR事件消费者提取线程
ocr_event_consumer_thread = Thread(target=ocr_task_consumer,
args=(ocr_queue, raw_subtitle_path, sub_area, video_path, options, progress_queue,),
daemon=True)
# 开启消费者线程
ocr_event_producer_thread.start()
# 开启生产者线程
ocr_event_consumer_thread.start()
# join方法让主线程任务结束之后,进入阻塞状态,一直等待其他的子线程执行结束之后,主线程再终止
ocr_event_producer_thread.join()
ocr_event_consumer_thread.join()
def async_start(video_path, raw_subtitle_path, sub_area, options):
"""
开始进程处理异步任务
options.REC_CHAR_TYPE
options.DROP_SCORE
options.SUB_AREA_DEVIATION_RATE
options.DEBUG_OCR_LOSS
options.HARDWARD_ACCELERATOR
"""
assert 'REC_CHAR_TYPE' in options, "options缺少参数:REC_CHAR_TYPE"
assert 'DROP_SCORE' in options, "options缺少参数: DROP_SCORE'"
assert 'SUB_AREA_DEVIATION_RATE' in options, "options缺少参数: SUB_AREA_DEVIATION_RATE"
assert 'DEBUG_OCR_LOSS' in options, "options缺少参数: DEBUG_OCR_LOSS"
assert 'HARDWARD_ACCELERATOR' in options, "options缺少参数: HARDWARD_ACCELERATOR"
# 创建一个任务队列
# 任务格式为:(total_frame_count总帧数, current_frame_no当前帧, dt_box检测框, rec_res识别结果, subtitle_area字幕区域)
task_queue = Queue()
# 创建一个进度更新队列
progress_queue = Queue()
# 新建一个进程
p = Process(target=subtitle_extract_handler,
args=(task_queue, progress_queue, video_path, raw_subtitle_path, sub_area, SimpleNamespace(**options),))
# 启动进程
p.start()
return p, task_queue, progress_queue
def frame_preprocess(subtitle_area, frame):
"""
将视频帧进行裁剪
"""
# 对于分辨率大于1920*1080的视频,将其视频帧进行等比缩放至1280*720进行识别
# paddlepaddle会将图像压缩为640*640
# if self.frame_width > 1280:
# scale_rate = round(float(1280 / self.frame_width), 2)
# frames = cv2.resize(frames, None, fx=scale_rate, fy=scale_rate, interpolation=cv2.INTER_AREA)
# 如果字幕出现的区域在下部分
if subtitle_area == SubtitleArea.LOWER_PART:
cropped = int(frame.shape[0] // 2)
# 将视频帧切割为下半部分
frame = frame[cropped:]
# 如果字幕出现的区域在上半部分
elif subtitle_area == SubtitleArea.UPPER_PART:
cropped = int(frame.shape[0] // 2)
# 将视频帧切割为下半部分
frame = frame[:cropped]
return frame
if __name__ == "__main__":
pass