点击上传戏曲视频 或拖拽到此处
支持 MP4 / WebM / MOV 格式
点击上传戏曲视频 或拖拽到此处
支持 MP4 / WebM / MOV 格式
系统采用双流水线并行架构:字幕提取流水线基于 OpenCV 按帧号跳帧采样,裁剪戏曲视频底部 18% 的固定字幕区域, 送入 PP-OCRv5(server 级检测+识别模型)识别文字,再经 VSE(Video Subtitle Extractor)双阶段去重算法合并连续相同字幕。 手语翻译流水线将字幕文本经 jieba 分词后,在 271 词的中国手语(CSL)手势库中匹配——未命中时逐字分解, 最终以 Unicode 码点为随机种子生成确定性回退手势(同一文字永远得到同一手势)。匹配结果经关键帧生成器展开为 含保持帧与 25% 过渡插值的动画序列,驱动 Three.js 3D 虚拟人(RPM 骨骼装配)以 10fps 实时演绎。 两条流水线通过字幕时间戳同步,实现"唱词出现 → 虚拟人即时手语"的实时联动,同时经 googletrans 输出英文双语字幕。
| 项目名称 | 京韵 · 戏曲无障碍播放器(Opera Accessible Player) |
| 适用场景 | 戏曲视频(京剧/越剧等)字幕自动识别与 AI 手语传译,服务听障人群的戏曲无障碍观看 |
| 开发语言 | Python 3.10+(后端)、JavaScript ES Module(前端) |
| 后端框架 | FastAPI + Uvicorn(端口 5015,systemd 托管) |
| 核心依赖 | PaddleOCR(PP-OCRv5)、OpenCV、jieba、python-Levenshtein、googletrans |
| 3D 渲染 | Three.js 0.160(importmap CDN 加载)+ RPM 骨骼装配 + AvatarEngine v3 |
| 手语数据 | 国家通用手语词典(shouyu.bmcx.com),271 个 CSL 词汇,含戏曲专有词 |
| 部署方式 | Ubuntu 22.04 + systemd + nginx 反代(/opera/ → 127.0.0.1:5015),可选 PyInstaller exe 打包 |
| 参数 | 默认值 | 说明 |
|---|---|---|
| SUBTITLE_Y_RATIO_MIN/MAX | 0.78 / 0.96 | 字幕区域纵向裁剪比例(画面底部 18%) |
| SUBTITLE_X_RATIO_MIN/MAX | 0.05 / 0.95 | 字幕区域横向裁剪比例(居中 90% 宽度) |
| sample_interval | 30 帧 | 抽帧采样间隔,约 1 秒/帧(25fps 视频) |
| text_rec_score_thresh | 0.5 (VSE) / 0.4 (简易) | OCR 识别置信度过滤阈值 |
| threshold_similarity | 0.80 | Levenshtein 相似度阈值,连续帧归组去重 |
| fps(手势动画) | 10 | 手语动画帧率,平衡流畅度与数据量 |
| gesture_duration | 0.8 秒 | 每个词汇手势的保持时长 |
| transition_frames | fps × 0.25 | 词间过渡插值帧数(约 2–3 帧) |
| frames_per_gesture | fps × 0.8 | 单手势保持帧数(默认 8 帧) |
| 上传限制 | 500 MB | nginx client_max_body_size,支持 MP4/WebM/MOV |
# 按帧号跳帧采样,裁剪底部 18% 字幕区域
frame_no = 0
while frame_no < total_frames:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no)
ret, frame = cap.read()
if not ret:
break
# Crop subtitle region (bottom 18% of frame)
y1 = int(frame_height * subtitle_y_min) # 0.78
y2 = int(frame_height * subtitle_y_max) # 0.96
x1 = int(frame_width * subtitle_x_min) # 0.05
x2 = int(frame_width * subtitle_x_max) # 0.95
roi = frame[y1:y2, x1:x2]
results = list(ocr.predict_iter(roi)) # PP-OCRv5
if results:
res = results[0]
texts = [t for t, s in zip(res["rec_texts"], res["rec_scores"])
if float(s) > 0.5] # 置信度过滤
if texts:
raw_results.append((frame_no, f"({x1},{y1},{x2},{y2})",
" ".join(texts)))
frame_no += sample_interval # 每 30 帧采样一次def _dedup(self, content_list):
"""连续相似帧归组,取组内最长文本(消除单帧误识别)"""
items = [RawInfo(str(c[0]), c[2]) for c in content_list]
unique_list = []
idx_i = 0
n = len(items)
while idx_i < n:
current = items[idx_i]
start_frame = current.no
idx_j = idx_i
# 向后扫描:相似度 >= 0.80 的连续帧归入同一组
while idx_j < n:
if idx_j + 1 == n or ratio(
current.content.replace(" ", ""),
items[idx_j + 1].content.replace(" ", ""),
) < self.threshold_similarity: # 0.80
group = items[idx_i : idx_j + 1]
# 组内取最长文本作为最优识别结果
stripped = [it.content.replace(" ", "") for it in group]
best_idx, _ = max(enumerate(stripped), key=lambda x: len(x[1]))
unique_list.append((start_frame, items[idx_j].no,
group[best_idx].content))
idx_i = idx_j + 1
break
else:
idx_j += 1
return unique_list# Finger curl values: 0.0 = fully straight, 1.0 = fully curled (fist)
HS = {
# "B" — 平掌,五指并拢伸直
"flat": {"thumb": 0.15, "index": 0.0, "middle": 0.0, "ring": 0.0, "pinky": 0.0},
# "A" — 握拳,拇指贴侧
"fist": {"thumb": 0.3, "index": 1.0, "middle": 1.0, "ring": 1.0, "pinky": 1.0},
# "V" — 食指中指伸出(胜利/二)
"victory": {"thumb": 0.7, "index": 0.0, "middle": 0.0, "ring": 1.0, "pinky": 1.0},
# "L" — 拇指+食指伸直成 L 形
"l_shape": {"thumb": 0.0, "index": 0.0, "middle": 1.0, "ring": 1.0, "pinky": 1.0},
# 点赞 — 拇指竖起
"thumbs_up": {"thumb": 0.0, "index": 1.0, "middle": 1.0, "ring": 1.0, "pinky": 1.0},
# ILY — 拇指+食指+小指伸出("我爱你")
"ily": {"thumb": 0.0, "index": 0.0, "middle": 1.0, "ring": 1.0, "pinky": 0.0},
}
# 每词手势还含位置(8 个身体参照位)与移动序列:
# 肩/肘/腕三关节旋转 [rx, ry, rz] + 五指卷曲度 + 序列姿势(接近→主姿势→返回)def text_to_gesture_sequence(text, fps=10, gesture_duration=0.8):
# 1. jieba 分词(词库词汇自动注册为自定义词)
for word in GESTURE_LIBRARY:
if len(word) >= 2:
jieba.add_word(word)
words = list(jieba.cut(text))
# 2. 逐词查库:精确 → 大小写 → 逐字分解 → 确定性回退
def gesture_for_token(token):
if token in GESTURE_LIBRARY:
return GESTURE_LIBRARY[token]
chars = list(token)
if len(chars) > 1:
return _merge_gestures([_gesture_for_char(c) for c in chars])
return _gesture_for_char(chars[0])
def _gesture_for_char(ch):
# 未收录汉字:以 Unicode 码点为种子生成确定性手势
seed_val = sum(ord(c) for c in ch) # 同一文字永远同一手势
rng = random.Random(seed_val)
shapes = ["flat","five","point","fist","c_shape","pinch", ...]
refs = [_FWD_CHEST, _CHEST, _HEAD, _FOREHEAD, _MOUTH, ...]
return {"left": _rest(),
"right": {**rng.choice(refs), "fingers": F[rng.choice(shapes)]}}
# 3. 关键帧生成:保持帧 + 25% 过渡插值
for i in range(len(gesture_frames) - 1):
g_from, g_to = gesture_frames[i], gesture_frames[i + 1]
for f in range(hold_frames): # 保持当前手势
keyframes.append(_frame_dict(current_frame + f, g_from))
current_frame += hold_frames
for f in range(transition_frames): # 词间过渡
t = (f + 1) / (transition_frames + 1)
keyframes.append(_frame_dict(current_frame + f,
_interp_gesture(g_from, g_to, t))) # 逐关节线性插值
current_frame += transition_framesPOST /api/extract-subtitles 上传视频 → 字幕提取(VSE 引擎,失败自动降级简易 OCR)
POST /api/ocr-image 单帧图片 OCR(字幕区域识别)
POST /api/translate 中译英(googletrans,失败回退戏曲词典)
POST /api/segment-text jieba 分词 + 词库命中/未命中统计
POST /api/sign-language 文本 → 手语关键帧序列({frame, left, right})
POST /api/csl-translate 中文词 → CSL 手语 ID 映射(精确→映射→逐字三级查找)
GET /api/health 健康检查(含翻译服务可用状态)
GET / 前端页面(禁缓存)· /css /js /assets /avatar 静态挂载