Skip to main content
Glama

mcp-vision

CI

让 AI 看视频。 纯 Python 的 MCP 服务器,不依赖 Node 运行时。

仅供学习研究使用。代码以 MIT 发布。

仓库结构参照 dsh-video-vision 的分层重写: 保留同样的 workspace / video / audio / subtitles 四层与工作区目录布局, 实现全部换成 Python —— ffmpeg 解码、numpy 计算、onnxruntime 跑 PP-OCRv4, 没有浏览器、没有 Node 进程、没有 Electron。

输入本地文件或 B 站链接,输出模型能直接消费的东西:带时间戳的帧、近似正方形的网格故事板、 音频电平与静音区间、画面 OCR 与折叠后的字幕轨。全部在本机完成。

设计原则

  • 纯 Python:stdlib + numpy + onnxruntime(可选)+ ffmpeg/ffprobe + yt-dlp。无 Node、无 Pillow、无 ImageMagick。

  • 自含:结构照搬,代码重写,不 import 任何外部仓库(见「致谢」)。

  • MCP 优先:stdio + HTTP 双入口,工具 schema 与实现一一对应。

  • MIT。

Related MCP server: avv-mcp

依赖

  • Python 3.10+(开发在 3.14)

  • ffmpeg / ffprobe(PATH 或配置)

  • numpy

  • onnxruntime(可选,OCR 用)

  • sherpa-onnx(可选,转写用)

模型不随仓库分发,路径全部走环境变量:

变量

用途

MVV_OCR_DET / MVV_OCR_REC / MVV_OCR_KEYS

PP-OCRv4 三个文件

MVV_ASR_MODEL / MVV_ASR_TOKENS / MVV_VAD_MODEL

SenseVoice + silero VAD

没配就只缺对应能力,抽帧和电平分析不受影响。详见 INSTALL.md。

  • yt-dlp(可选,链接下载)

快速开始

sh check.sh
python3 mcp_server.py            # stdio
python3 mcp_server.py --http 8765

sh check.sh --test 跑一遍体检 + 不需要模型的回归测试(L/R 分层出图)。 基准脚本在 scripts/(需要模型或运行中的服务),回归测试在 tests/。

依赖、模型下载、客户端配置见 INSTALL.md; 使用范围与责任见其中的「免责声明」。

工具

四个大工具,按职责分层:

  • workspace:open(打开/下载)/ list

  • video:给窗口 + 张数,抽帧拼成一页网格;quantity=1 时不出页,直接给单帧原图(可 crop)

    • quantity 抽帧数(默认 15,上限 30;1 = 单帧);quality 像素上限:low=512² medium=1024² high=1536² max=2048²(拼页时管整页,单帧时管裁剪后那张)

  • audio:按 view 出图 —— level(20ms RMS 电平)/ spectrum(FFT 幅度谱)/ spectrogram(时频图)/ peak(峰值频率轨迹)/ wave(原始波形),all 一次全给

  • subtitles:mode=ocr 识字幕(band 可指范围)/ mode=stt 语音转写 (silero-vad 自动断句 + SenseVoice,模型路径由环境变量指定)

音频图一律 1024×1024,上半 L、下半 R。读数口径见「音频图的读数口径」。

视频固定在工作区 workspace/<文件夹名>/。 video / audio / subtitles 默认作用于最近打开的那个,可用 video_id 传文件夹名指定。

所有产物落到 workspace/<标题>/{video,audio,subtitles}/,可直接用文件系统管理(ls/rm/cp), 不再用不透明的 .mvv-cache。

OCR

PP-OCRv4(det + rec)在 numpy + onnxruntime 上从零实现,不依赖 OpenCV / Pillow / pyclipper / ImageMagick。 读图用 ffmpeg 解 raw RGB;连通域、CTC 解码都是纯 Python。

pkg install python-onnxruntime
# 先按 INSTALL.md 3.1 把 PP-OCRv4 三个文件放进 models/(约 16MB)
python3 ocr.py out/ocr_test.png      # 单图
  • 加速器:优先 XnnpackExecutionProvider,可回落 CPU;Android 上另有 NnapiExecutionProvider

  • 调参:DET_MAX_SIDE(默认 720)、DET_TH(默认 0.2)、MVV_ORT_THREADS

  • 实测:880x320 三行中英混排,1.0s 识别完毕,置信度 0.94–0.98

取窗:先选时间戳,再选看多久

所有耗时动作(video / audio / subtitles)共用一套取窗:

参数

含义

time

从哪个时间戳开始,默认 0(frame 里是第一帧的时刻)

seconds

看多久;不传则用该工具的默认值

各工具的上限:audio 15 秒(不传默认 10 秒)、video / subtitles 900 秒(15 分钟)。

超过上限会被夹住,并在返回里带 note 说明夹到了哪里。长视频靠 time 翻页, 不会一次跑到底。

{"mode":"stt","time":1200,"seconds":600}   // 从 20 分开始,转 10 分钟

音频图的读数口径

五张图共用一套标定,跨窗口、跨文件都能横向比:

项

口径

FFT 点数

4096;44.1k 下分辨率 10.77 Hz,窗长 92.9 ms

幅度

满幅正弦 = 1(`2·

峰值频率

对数域抛物线插值,幅度再除以汉宁窗在偏移处的增益(扇贝损失)

电平

20 ms 不重叠帧的 RMS,单位 dBFS

纵轴 / 色标范围

顶端 = 峰值向上取整到 10 的整数次幂,底端退 3~6 个十进位档

时频图峰值格

每列单独补扇贝损失,纯音不论落在 bin 哪个位置都读准

频域图横轴对数 10 Hz–Nyquist,时频图 20 Hz–Nyquist,波形取的就是那 4096 点。

已知偏差(有意保留):时频图的噪声底比读数低 1.76 dB,这是纯音标定 + 汉宁窗 ENBW=1.5 的必然结果;全局减 1.76 dB 会让纯音偏低,所以不动。

输出不裁剪

_dump() 默认不截断。之前 8000 字符上限会把 97 段字幕砍成 55 段还不显眼, 现在整份返回;确实要塞小响应时才显式传 limit。

字幕段统一是 {"from": "MM:SS", "to": …, "text": …},OCR 与语音转写同形, 折叠后整份返回;要 SRT 直接拿三个字段拼,工具本身不落盘。

时间只到秒。 毫秒对定位没用——没人会拿 00:23.417 去切帧—— 但每段两个时间各写 4 个字符,98 段字幕就是 784 字符(约 7% 的响应)。 OCR 的采样步长本来就是 1.0s,毫秒位恒为 000,留着等于伪造精度。

两条路能拿到的东西不一样

mode=ocr

mode=stt

读的是

画面像素

声音波形

text

屏幕上的字

嘴里说的话

conf

有(OCR 置信度)

无

emotion

没有

有

event(BGM/Applause/Laughter)

没有

有

lang

没有

有

summary 统计

无

有

声学三项只有 stt 有,这不是没实现,是原理上取不到—— OCR 只看像素,画面里没有「情感」和「有没有 BGM」这两样信息。 响应里用 acoustic_fields: true/false 直接标明,省得调用方反复试。

所以:

  • 想知道 BGM / 语气 / 语种 → 必须走 mode=stt,即使字幕已经在画面上了

  • 想知道 画面上的字(歌词、烧录字幕、弹幕)→ 走 mode=ocr

  • 两样都要 → 调两次。这类片子通常有口播,先跑 stt 拿语义,再用 ocr 核对画面原文

语音转写在 stt 模式下会带上 SenseVoice 同一次前向算出的三项(有才写,默认值省略):

字段

取值

说明

emotion

HAPPY / SAD / ANGRY / NEUTRAL …

NEUTRAL 与 EMO_UNKNOWN 不写

event

Speech / BGM / Applause / Laughter …

Speech 不写

lang

zh / en / ja / ko / yue …

这首歌里出现 en 就是混了英文句

顶层另给一个 summary 统计(如 {"event": {"BGM": 4}})。 event=BGM 说明那段里音乐压过了人声,转写掉字多半就是这个原因—— 但注意它是模型对整段音频的判断,不是声道分离的结论。

性能

实测环境:Android aarch64,Python 3.14,onnxruntime 1.31.1。

线程数:这机器 2 线程最优。det 推理 1 线程 0.182s → 2 线程 0.117s → 4 线程 0.125s → 8 线程 0.149s(小核争用)。 默认已设为 2,可用 MVV_ORT_THREADS 调。

加速器:CPU 实测最快,XNNPACK 反而更慢,NNAPI 初始化贵。默认 cpu,可用 MVV_ORT_PROVIDERS 改。

字幕带 + 跳帧(subtitles 的 mode=ocr)是最有效的一刀。

30 秒测试片(15 条字幕,每条 2 秒):

方案

采样

总帧数

OCR 次数

耗时

全帧逐帧

1.0s

30

30

8.10s

字幕带+跳帧

1.0s

30

15

2.20s

原理:只裁一条字幕带(不是整个下半屏),字幕没变的帧直接复用上次结果。 带高由自动检测给出,1280×720 上实测 84–129px,占整幅 12–18%。

字幕带是怎么定的(mode=ocr 且 band=auto 时):

  • 两段式:先只 OCR 下方 40%(多数片子的字幕在那儿,只花 0.4 屏);找不到够像的簇, 再补扫上半屏。两段共用同一批已解码的帧,整幅只扫一遍、不重复解码

  • 簇按纵向重叠聚类,并限高 25%:重叠是传递的,一屏小字能从顶串到底, 串成一个 0.0–0.8 的「簇」会让选带彻底跑偏(《areyoulost》实测栽过一次)

  • 8 个采样点按时长均分;窗口 ≤45 秒走一次解码取帧,更长的逐点 seek(成本与片长无关)

  • 先筛掉不像字幕的簇:最宽 <12%(单个字母/图标)、纵向串成柱 >25%(几个无关的框连成一片)

  • 簇打分 = 行高中位数^1.5 × 宽度 × 位置权重 × 出现次数;位置权重给底部(1.0→2.2)和 顶部(1.0→1.6)加分,画面中部压到 0.6

  • 纵向间隔 ≤2% 的簇并成一条带(并完不超过画面 1/5)

  • 边距按实测行高给(行高的 0.6 倍,至少 0.02):两行字幕会整块上移,固定边距会切掉一行

  • 常驻水印:同一串字(去标点、大小写归一)在簇内占比 ≥50% 判为静态标,得分 ×0.25

  • 一点字都没检出就判「无字幕」,直接返回空;band=auto 且 0 段时返回 hint

注意:耗时由「字幕条数」决定,不由采样密度决定。 同一测试片:

采样

总帧数

OCR 次数

耗时

0.5s

60

15

2.33s

1.0s(默认)

30

15

2.29s

1.5s

20

15

2.24s

采样越密只多出便宜的跳帧比对。默认 1.0s 是为了省临时数据量,而非省时间:

视频长度

step=0.5s

step=1.0s

step=1.5s

30 分钟

691 MB

346 MB

230 MB

1 小时

1382 MB

691 MB

461 MB

2 小时

2765 MB

1382 MB

922 MB

因此实现上不落盘:ffmpeg 用管道(-f rawvideo -)流出带帧,逐帧读、逐帧判、逐帧丢, 磁盘占用恒为零,任意长度视频都不爆盘。

关于自量化(结论:死路,已验证): 权重 int8 量化(QDQ)与真·动态量化(DynamicQuantizeLinear + QLinearConv,62 个卷积) 两条路都跑通了——需自建 onnx_pb2,因为 Termux 装不了 onnx 包 (cp314t+manylinux 轮子对不上 android/Bionic)。 但 ORT 的 ARM int8 卷积和 fp32 NEON 一样快:推理 0.101s vs 0.101s,提速 1.00x, 还多出 62×3 个节点、加载慢 3 倍、精度下降(per-tensor,mean diff 0.064)。 量化工具与产物已删除,结论记录在此。

实测踩坑记录

起初两支片子:动画 MV BV1Rx54znE7k(328s,720p,有歌词字幕)、 实拍 vlog(320s,白色泡沫满屏)。后来又加了《areyoulost 降调》(字幕贴顶) 和《南充 0 分作文》(整屏手写稿)两支。按发现顺序记:

1. 跳帧在动画背景上失效

原按「带内像素绝对差」判重,背景一动就超阈值,跳过仅 7/328 (2%)。 改为「灰度 > 223 的高亮文字掩码差」:字幕未变 ≤0.15%,变了 ≥0.86%,跳过率升到 160/328 (49%)。 (220 与 223 在这几支片子上结果完全一致,取 223 只是留点余量。)

2. 不能把「短 cue」当噪声合并(重要)

该视频的歌词会把每句尾短语重复演唱一遍,字幕轨因此有独立短 cue: 小弦切切如私语 → 如私语、大珠小珠落玉盘 → 落玉盘。

最初误判为「OCR 残句」和「逐字擦除动画」并做了后缀合并——两次都错了。 查证过程:

  • 密集抽帧(10fps):75.50 还是完整 7 字,75.60 直接变成居中 3 字,硬切

  • 剩余 3 字重新居中,不是右侧残留 → 是新 cue,不是擦除

  • SenseVoice 转写:…如私语,如私语。 —— 唱了两遍,证实是真实重复

修正后 fold_segments 只做三件事:丢空帧、同文延长时间、编辑距离合并 同一 cue 的 OCR 变体。「更短的纯后缀」一律视为新 cue 保留。

3. ASR 预处理反直觉

试过降速/加音量/带通/降噪,全部让识别更差:

处理

结果

原始

下弦奇迹如私雨,如私雨。 ← 最好

0.8x + 1.2x 音量

雨下弦奇啼不思语,不思语。

0.7x + 2x + 带通+降噪

雨下弦情情不思雨,不思雨草。

降速破坏音高与时长特征,带通削掉辅音高频,对唱腔 + BGM 反而有害。

4. 字幕带会切掉第二行

320s、实拍、白色泡沫满屏。0–60 秒只出 4 段,片头标题整句丢失。

聚类打印显示这片的字幕有两档高度:大字在 0.822–0.900,矮的一行低到 0.902–0.950。 检测只取最高分那一簇,带定成 0.8022–0.92,而 t=0 那行在 0.907–0.944—— 下边缘从字中间切过去,OCR 读不出来。

改为「纵向间隔 ≤2% 的簇并成一条带」(上限画面 1/5)后,带变 0.8022–0.97, 段数 4 → 15,片头标题和「说搞就搞」都回来了;《琵琶行》仍是 19 段,无回归。

代价:条子从 84px 涨到 129px,条内变化更容易越过 skip_th, 同一窗口 OCR 19 → 24 次、8.2 → 9.9 秒。skip_th 提到 1.5 可回到 21 次且段数不变, 但 3.0 会丢掉 5 段,没敢默认动。

5. 网格凑整:要 12 帧会回 15 帧(已修)

早期版本为了让整页接近方形,按 cols×rows 抽帧,quantity=12 实际返回 15 帧 (排成 3×5,填 12 格空 3 格)。现在要几张给几张,网格只决定排版,最后一排可以留空。

同时把空格惩罚从 0.05 提到 0.5:只要有无空格的方案,就不为了更方形去留黑格。 起因是 4 帧裁剪成字幕带时选了 1×5——页面比例 0.98 确实比 1×4 的 1.23 更方, 但白占一格。现在 4 帧稳定走 1×4。

6. 整屏都是字的片子:检测得出来,但读的仍是页面碎片

《南充 0 分作文》整屏是手写作答纸,底部压一行白色旁白。下方 40% 全是作文正文, 自动检测给出 23 段(改全帧后 37 段),条条是作文不是字幕。

当时试过四条按「像素/几何」筛的路子,全部不成立:

判据

为什么不成立

提 text_th

纸面比字幕还亮(≥250 的像素占 15–18%,字幕行只有 6%)

按行高筛

作文行和字幕行高度同量级

亮度极性(白字黑边 vs 黑字白底)

反了,纸底更亮

时间变化(字幕变、纸不动)

纸在滚动,t=10 vs t=16 整屏每段差 22–31

后来换成「结构」判据才成立:不看像素,看文字怎么分布。

字幕不管换多少句,都挤在同一条(偶尔两行)里;整页文字会在多个高度各自成行。 于是数「可用簇」(宽度 ≥12%、高度 ≤25% 的纵向簇)的个数和跨度:

片子

可用簇

纵向跨度

判定

《琵琶行》歌词

1

0.00

字幕

泡沫实拍

3

0.00

字幕

幻灯片(dsh-storyboard)

6

0.50

整页

作文纸《南充 0 分》

12

0.94

整页

判据是 簇数 ≥ 4 且 跨度 ≥ 0.45。命中时不扣数据,而是把 page_like 和 hint 一起返回——它确实读到了字,只是读的是页面的一个横条。

关键改动:以前下半屏命中 count≥2 且 w≥0.2 就直接收工,从不看上半屏。 幻灯片的表格行恰好满足这个门槛,于是永远发现不了「整屏都是字」。 现在命中后仍要扫上半屏验证,多付约 1.7 秒。

对这类片子,mode=stt 才是正解:口播转出来是连贯成句的, 比从页面碎片里拼要准得多。hint 也照这个顺序给建议。

状态

v0.4 已通:workspace(open / list)、video、 audio(level / spectrum / spectrogram / peak / wave)、subtitles(ocr / stt)。

转写链路:ffmpeg 直出 16k 单声道 → silero-vad 自动断句(阈值/静音/最长段交给模型, 不写死时间网格)→ 逐段 SenseVoice 解码 → 丢弃纯语气词片段。

模型全部按需加载,启动时不碰任何推理库:

时刻

常驻内存

进程启动

~36 MB

首次 OCR(只建检测模型)

~68 MB

首次框出文字(才建识别模型)

~90 MB

转写(VAD 切出第一段语音才建 SenseVoice)

~340 MB

纯静音视频转写 0.17 秒结束,不加载 229 MB 的识别模型。

未做:B 站元数据/评论/弹幕、镜头切分、多站登录态。

致谢

思路参考了以下三个项目。代码没有复用,全部按 Python 重写,运行时也不依赖它们。

  • video-understanding-kit — 把视频拆成 analyze(整片)和 frame(单帧)两层的做法,以及网格故事板的排版思路。

  • dsh-video-vision — 链接规范化(lib/ytdlp.js 的 normalizeSource)、按时间点取原帧。

  • dsh-bili-watch — B 站下载与登录态(cookies)处理的经验。

也感谢 ffmpeg、yt-dlp、numpy、onnxruntime 的作者。

模型与依赖:

  • PP-OCRv4,来自 PaddleOCR(Apache-2.0)

  • SenseVoice,来自 FunAudioLLM / FunASR。 代码 MIT;模型权重是另一套:FunASR Model Open Source License v1.1,须保留模型名与署名

  • Silero VAD(MIT)、sherpa-onnx(Apache-2.0)

许可证

定位:仅供学习研究使用。

本仓库代码:MIT,见 LICENSE。© 2026 Huangjiang-a11y

LICENSE 只覆盖本仓库的代码。 模型权重不随仓库分发,遵循各自的上游许可; ffmpeg 是以子进程调用的外部程序(不链接、不捆绑); 经由本工具处理的音视频与字幕,版权归原权利人。

各组件许可、ffmpeg 的分发注意事项、模型署名的要求,见 THIRD_PARTY.md。

Related MCP Connectors

Related MCP Servers