Files
vrsub/nodes/subtitle_cleanup.py
cat-shark 4d2823c005 fix: 短时重复伪影也整条删除(判据补重复次数上限)
真实产物里出现 3.7 秒的 cue 被同一个假名填满 111 次(`は`×111),此前判据要求
"时长 ≥15 秒"才删,于是它漏到翻译层,最终变成 56 个"哈"进中文成品字幕。

- 判据改为:重复段占正文 ≥70% 且重复 ≥6 次,并且**时长 ≥15 秒或重复 ≥20 次**。
- 短条走"极端重复"档:真实呻吟的重复次数实测 ≤15(ぇ×15 占 3.2 秒),不会误删。
- 测试数据取自真实产物(A 的日语转写里那条 112 字短伪影)。
2026-09-18 23:41:32 +08:00

286 lines
13 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.
"""字幕清洗:删除寒暄幻觉与纯呻吟碎片。
ASRwhisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套话(如“晚安/
感谢观看”/“おやすみなさい”),且在翻译产物里反复出现。处理方式为**连带
时间戳整条删除** cue 并重新编号:不能改成 '-' 占位,否则会一路渲染成可见
减号,并在翻译/过滤阶段需要额外特判。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄);
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。
删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实
互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)与
whisper 窗口内重复循环造成的重复伪影(判据见 _is_repetition_artifact)。
纯函数,不修改输入。
"""
from __future__ import annotations
import re
# Greeting/closing hallucination tokens that LLM repeats on empty/end segments.
HALLUCINATION_TOKENS = (
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
"晚安", "下次再见", "再会", "敬请期待", "感谢您的光临", "欢迎光临",
"再见", "多谢观看", "观看愉快",
)
# Display-duration threshold (seconds): only remove entries longer than this.
DEFAULT_THRESHOLD_SECONDS = 15.0
# 日文套话词表:whisper 无 VAD 解码会把无语音/音乐段当语音,从而重复输出
# 这些寒暄;短时相同词可能是剧情真实台词,靠时长阈值区分(同中文表机制)。
JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看
"ありがとうございました", # 感谢
"ご視聴ありがとうございます", # 感谢观看(现在时)
"また見てね", # 下次再见
"お楽しみに", # 敬请期待
"チャンネル登録よろしくお願いします", # 求订阅
"さようなら", # 再见
"音楽", # 音乐(自述)
"Goodbye",
"Thank you for watching",
)
# 纯呻吟/喘息字符集合:文本(去空白/标点)全部由本集合字符组成、且有效假名数
# ≤ 阈值时才判为噪声删除。集合**刻意排除** そ/こ/ね/や/ば/だ/く/へ 等假名——
# 真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,因此天然不命中,从
# 判据根源上避免误删真实短句。
MOAN_CHARS = frozenset(
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
"あいうえおんふはっ"
# 小写假名(ぁぃぅぇぉ)与片假名对应(アィゥェォ、ン)
"ぁぃぅぇぉアィゥェォン"
# 长音符/省略号/半浊音(ー〜…、…)与空白、标点(呻吟常带这些装饰)
"ー〜…\u2026。、!??!、"
" \t"
)
# 短呻吟过滤的默认有效假名上限:真实呻吟/喘息碎片(あ/ん/ん?/はぁ…/あ!あ!
# /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。
DEFAULT_MOAN_MAX_CHARS = 3
# 重复伪影判据(whisper 循环解码会把一个单元写满整条 cue):
# 单元最长 6 字(更长的重复单元在真实数据里未见),重复段占正文 ≥70%。
# 长度维度两档:长条(≥15s)重复 ≥6 次即删;短条则要求极端重复(≥20 次)——
# 真实呻吟重复次数实测 ≤15(ぇ×15 / しゅ×7),而短时伪影实测 3.7 秒填了 111 次。
REPETITION_UNIT_MAX_CHARS = 6
REPETITION_MIN_REPEATS = 6
REPETITION_HARD_REPEATS = 20
REPETITION_MIN_COVERAGE = 0.7
def _longest_repeated_run(text: str, unit_max: int) -> tuple[str, int] | None:
"""返回正文中最长的'同一单元连续重复'片段(原文, 重复次数)。
单元长度 1..unit_max 由短到长尝试,取片段最长的一次;无重复返回 None。
注意用 finditer 而不是 re.search(..., pos):模块级 re.search 的第三个位置
参数是 flags,拿它当偏移会导致原地重搜、死循环。
"""
best: tuple[str, int] | None = None
for size in range(1, unit_max + 1):
for match in re.finditer(rf"(.{{1,{size}}})\1+", text):
run = match.group(0)
if best is None or len(run) > len(best[0]):
best = (run, len(run) // len(match.group(1)))
return best
def _is_repetition_artifact(text: str, duration: float, threshold_seconds: float,
min_repeats: int, min_coverage: float) -> bool:
"""判断一条 cue 是否为 whisper 重复循环伪影(整条删除判据)。
同时满足:同一 1–6 字单元连续重复 ≥ min_repeats 次、重复段占正文
≥ min_coverage,且**时长 ≥ 阈值或重复次数 ≥ REPETITION_HARD_REPEATS**。
长度维度分两档是必要的:只按"长条才删"会漏掉短时循环(实测 3.7 秒的 cue
被填了 111 个假名,翻译后变成 56 个"哈"进成品);而真实呻吟的重复次数实测
≤15(ぇ×15 占 3.2 秒),所以短条用"极端重复"判据即可区分,不会误删。
threshold_seconds ≤ 0 关闭过滤。
"""
if threshold_seconds <= 0:
return False
stripped = text.replace("\n", "")
found = _longest_repeated_run(stripped, REPETITION_UNIT_MAX_CHARS)
if found is None:
return False
run, repeats = found
if not stripped or repeats < min_repeats:
return False
if len(run) / len(stripped) < min_coverage:
return False
return duration >= threshold_seconds or repeats >= REPETITION_HARD_REPEATS
def _moan_chars(text: str) -> int:
"""返回 text 中'有效假名字符'数量(呻吟判据的一部分)。
只统计假名(片/平)与发音健全字符,空白/标点/长音符/省略号不计入,
这样'あ…'/'ん?'/'あ〜' 的有效字符都是 1 个。
"""
return sum(ch in "あいうえおんふはっぁぃぅぇぉアィゥェォン" for ch in text)
def _is_pure_moan(text: str, max_chars: int) -> bool:
"""判断一条字幕文本是否为'纯呻吟/喘息碎片'(整条删除判据)。
两个条件同时满足才返回 True:
1. 去除空白/标点后剩余字符**全部** ∈ MOAN_CHARS(即整个文本只能由呻吟
字符、标点、空白组成,不允许出现そ/こ/ね/や/ば 等真实词假名);
2. 有效假名字符数 ≤ max_chars(超过阈值即使是纯呻吟长串也不删)。
"""
chars = [c for c in text if not c.isspace()]
# 全部字符必须都在呻吟字符集合中(含标点/长音符)。
if not chars or any(c not in MOAN_CHARS for c in chars):
return False
return _moan_chars(text) <= max_chars
# 解析 SRT:每个 cue 由 序号行 + 时间轴行 + 文本行(可能多行) + 空行 组成。
# 采用逐行解析(不依赖可能粘连的跨 cue 正则),兼容文本多行。
_TS_RE = re.compile(r"^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*$")
def _ts_to_seconds(ts: str) -> float:
"""Convert an SRT timestamp HH:MM:SS,mmm to seconds (float)."""
hours, minutes, rest = ts.split(":")
seconds, millis = rest.split(",")
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
def remove_hallucination_entries(
srt_text: str,
tokens: tuple[str, ...],
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""删除 SRT 中展示时长 ≥ 阈值且文本命中 tokens 的**整条 cue**(连带时间戳)。
被删除的 cue 不再输出(序号、时间轴、文本全部消失),剩余 cue 按原顺序
重新从 1 编号,保证产物是合法连续的 SRT。输入不被修改(纯函数)。
用途:幻觉在产生处直接剔除——whisper decode_full(日语词表)与 LLM 翻译后
(中文词表)均调用本函数,避免 '-' 占位一路流到 ASS 渲染成可见减号。
内部委托 _remove_cues_by_predicate,与短呻吟过滤共用同一套 SRT 解析/重建。
"""
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
"""保留判据:不命中寒暄幻觉才保留。"""
duration = end_sec - start_sec
return not (duration >= threshold_seconds and any(t in text for t in tokens))
return _remove_cues_by_predicate(srt_text, _keep)
def remove_short_moan_entries(
srt_text: str,
max_chars: int = DEFAULT_MOAN_MAX_CHARS,
) -> str:
"""删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**whisper decode_full 去噪)。
无 VAD 解码会把呻吟段也整段救回,字幕因此混入纯语气词碎片(あ…/ん?)。
判据见 _is_pure_moan:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ max_chars
才删除;max_chars=0 时关闭过滤。纯函数,不修改输入。
"""
if max_chars <= 0:
return srt_text
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
"""保留判据:非纯呻吟碎片才保留。"""
return not _is_pure_moan(text, max_chars)
return _remove_cues_by_predicate(srt_text, _keep)
def remove_repetition_entries(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
min_repeats: int = REPETITION_MIN_REPEATS,
min_coverage: float = REPETITION_MIN_COVERAGE,
) -> str:
"""删除 SRT 中 whisper 重复循环造成的**整条重复伪影**(ASR 产物去噪)。
模型在窗口内卡住重复时会把同一单元写满整条 cue(实测 30 秒、重复 74446
次):这种正文会让下游 LLM 跟着重复、把预算耗在思考上而报结构错误,也会
直接渲染成超长字幕行,因此在产生处整条删除。判据见 _is_repetition_artifact
长条(时长 ≥ 阈值)或极端重复(≥ REPETITION_HARD_REPEATS 次)+ 同一短单元
占满正文;真实短促呻吟(重复 ≤15 次)不会命中。
threshold_seconds ≤ 0 时关闭过滤。纯函数,不修改输入。
"""
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
"""保留判据:非重复伪影才保留。"""
return not _is_repetition_artifact(
text, end_sec - start_sec, threshold_seconds, min_repeats, min_coverage,
)
return _remove_cues_by_predicate(srt_text, _keep)
def _remove_cues_by_predicate(
srt_text: str,
keep: callable,
) -> str:
"""通用 SRT 逐条过滤:keep(起始秒, 结束秒, 文本) 为 False 的 cue 整条删除。
删除 cue 时序号/时间轴/文本全部消失,剩余 cue 重新从 1 连续编号(合法
SRT)。用逐行解析(不依赖跨 cue 正则,兼容多行文本)。纯函数不修改输入。
"""
lines = srt_text.splitlines()
kept: list[str] = []
number = 1
index = 0
while index < len(lines):
line = lines[index]
if line.strip() and line.strip().isdigit() and index + 1 < len(lines):
ts_match = _TS_RE.match(lines[index + 1].strip())
if ts_match:
# 收集本 cue 文本:时间轴后直到空行前的所有非空行(可多行)。
text_lines: list[str] = []
cursor = index + 2
while cursor < len(lines) and lines[cursor].strip():
text_lines.append(lines[cursor].strip())
cursor += 1
text = "\n".join(text_lines)
start_sec = _ts_to_seconds(ts_match.group(1))
end_sec = _ts_to_seconds(ts_match.group(2))
if keep(start_sec, end_sec, text):
# 保留:输出 新序号+时间轴+文本+空行(重建标准 SRT)。
kept.append(
f"{number}\n{lines[index + 1].strip()}\n{text}\n"
)
number += 1
# 删除:整条跳过(序号/时间轴/文本都不输出)。
index = cursor
continue
# 非 cue 行(文件头/尾部噪声)跳过,避免序号/空行残留。
index += 1
return "\n".join(kept).rstrip() + "\n"
def clean_japanese_hallucinations(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""从 SRT 中整条删除日文收尾/寒暄长时幻觉(ASR 无 VAD 解码兜底)。
whisper 节点 decode_full=true(无 VAD 整段解码)在无语音段会输出长时
套话占位;本函数把展示时长 ≥ 阈值且文本含 JAPANESE_HALLUCINATION_TOKENS
的**整条 cue 连带时间戳删除**、剩余重编号。短时相同词(剧情真实道晚安)
保留。纯函数,不修改输入。
"""
return remove_hallucination_entries(srt_text, JAPANESE_HALLUCINATION_TOKENS, threshold_seconds)
def clean_srt_text(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""从 SRT 中整条删除中文长时寒暄幻觉(LLM 翻译产物清洗)。
对展示时长 ≥ 阈值且文本含 HALLUCINATION_TOKENS 的 cue 连带时间戳整条
删除、剩余重编号;短时相同词(剧情真实互道晚安)保留。与
clean_japanese_hallucinations 同机制,词表不同。纯函数,不修改输入。
"""
return remove_hallucination_entries(srt_text, HALLUCINATION_TOKENS, threshold_seconds)