内容分四块: 1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。 2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。 3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。 4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。 .gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""对关键帧字幕区做 OCR,按时间整理成字幕文档。
|
|
依赖:rapidocr-onnxruntime(中文 OCR)。复用 cluster_subs 的字幕区二值哈希聚类,只 OCR 代表帧。
|
|
用法:python ocr_subs.py --frames <frames目录> --video <源视频名/说明> --out <输出.md> [--thresh 25] [--crop 0.15]
|
|
"""
|
|
import argparse, glob, os, re, sys, difflib
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from cluster_subs import bin_hash, hamming, crop_subtitle, ts_from_name
|
|
|
|
|
|
def fmt_ts(t):
|
|
m = int(t // 60); s = t - m * 60
|
|
return "%02d:%06.3f" % (m, s)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--frames", required=True)
|
|
ap.add_argument("--video", required=True, help="源视频说明(写入文档头)")
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--thresh", type=int, default=25)
|
|
ap.add_argument("--crop", type=float, default=0.15)
|
|
args = ap.parse_args()
|
|
|
|
from rapidocr_onnxruntime import RapidOCR
|
|
engine = RapidOCR()
|
|
|
|
files = sorted(glob.glob(os.path.join(args.frames, "*.jpg")))
|
|
print("总帧数: %d" % len(files))
|
|
|
|
# 聚类取代表帧
|
|
clusters = []
|
|
for f in files:
|
|
sub = crop_subtitle(Image.open(f), args.crop)
|
|
hsh = bin_hash(sub)
|
|
best, bestd = None, 999
|
|
for ci, c in enumerate(clusters):
|
|
d = hamming(hsh, c["hash"])
|
|
if d < bestd:
|
|
bestd, best = d, ci
|
|
ts = ts_from_name(os.path.basename(f))
|
|
if best is not None and bestd <= args.thresh:
|
|
clusters[best]["items"].append((f, ts))
|
|
else:
|
|
clusters.append({"hash": hsh, "items": [(f, ts)]})
|
|
reps = []
|
|
for c in clusters:
|
|
it = sorted(c["items"], key=lambda x: x[1])
|
|
mid = it[len(it) // 2]
|
|
reps.append((mid[0], mid[1]))
|
|
reps.sort(key=lambda x: x[1])
|
|
print("代表帧(待 OCR): %d" % len(reps))
|
|
|
|
lines = []
|
|
for f, ts in reps:
|
|
sub = crop_subtitle(Image.open(f), args.crop)
|
|
sub = sub.resize((sub.width * 2, sub.height * 2), Image.LANCZOS)
|
|
res = engine(np.array(sub))
|
|
if not res or not res[0]:
|
|
continue
|
|
txt = " ".join([t[1] for t in res[0] if t[1].strip()]).strip()
|
|
txt = re.sub(r"\s+", " ", txt)
|
|
if not txt:
|
|
continue
|
|
# 丢弃纯符号/数字垃圾行(几乎无中文字符)
|
|
cjk = len(re.findall(r"[一-鿿]", txt))
|
|
if cjk < 2:
|
|
continue
|
|
lines.append((ts, txt))
|
|
|
|
# 模糊合并连续近重复句(OCR 抖动导致同句多版本)
|
|
def sim(a, b):
|
|
return difflib.SequenceMatcher(None, a, b).ratio()
|
|
|
|
merged = []
|
|
for ts, txt in lines:
|
|
if merged and sim(txt, merged[-1][1]) >= 0.82:
|
|
# 保留更长更完整的一句,时间取最早
|
|
if len(txt) > len(merged[-1][1]):
|
|
merged[-1] = (merged[-1][0], txt)
|
|
continue
|
|
merged.append((ts, txt))
|
|
lines = merged
|
|
|
|
with open(args.out, "w", encoding="utf-8") as fo:
|
|
fo.write("# 字幕文档(关键帧 OCR)\n\n")
|
|
fo.write("源视频:%s\n\n" % args.video)
|
|
fo.write("> 说明:视频无独立字幕轨,本字幕由关键帧画面字幕区 OCR 提取,按出现时间排序;连续近重复句(相似度≥0.82)已合并、纯符号垃圾行已剔除。\n\n")
|
|
for ts, txt in lines:
|
|
fo.write("- [%s] %s\n" % (fmt_ts(ts), txt))
|
|
print("已写出 %d 条字幕 → %s" % (len(lines), args.out))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|