扫描印刷管弦乐总谱的光学音乐识别(OMR)系统,输出多声部 MusicXML。
项目主页:https://2omegaxv.github.io/GrandOMR/
基于 HOMR 的 TrOMR 模型做逐谱表音符识别,在此之上构建了管弦乐总谱所需的乐器识别、谱表分组、跨声部校正、多页合并四层逻辑。
输入图片 (.png)
│
▼
┌─────────────────────────────┐
│ Stage 1: 谱表检测与符号分割 │ HOMR 内置的语义分割 + staff 检测
│ 碎片过滤 │ 过滤宽度 < 20% 中位数的窄碎片
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Stage 2: 谱表系统分组 │ 左边距连通域分析 → 括号bbox → 间距统计(三层回退)
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Stage 3: 乐器名识别 │ VLM API (Qwen3-VL) 两阶段主路径
│ 移调信息 │ Pass 1: 逐谱表自由识别(附谱表坐标+OCR标签提示)
│ │ Pass 2: 按谱表数格式化为精确 N 行
│ │ RapidOCR + 缩写字典 备用路径
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Stage 4: 逐谱表识别 │ HOMR TrOMR transformer
│ N parts × M sys │ 按乐器数强制均分谱表
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Stage 5: 跨声部后处理 │ Layer 0: 拍号推断(双小节线分段 + VLM投票确认 + 跨页传播)
│ │ Layer 1: 调号传播(双小节线感知 + 跨页继承与回溯修正)
│ │ Layer 2: 拍号/调号多数投票对齐
│ │ Layer 3: 小节数统一
│ │ Layer 4: 时值修正(position tracking)
│ │ + 记谱溢出修复 / 三连音标记 / 双dot清理
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ Stage 6: 力度标记检测 │ YOLO26n 微调模型检测力度符号
│ │ f/p/s/hairpin 等 8 类
│ │ 定位到小节,注入 <direction> 元素
└─────────────┬───────────────┘
▼
MusicXML 输出
| 功能 | HOMR | 本项目 |
|---|---|---|
| 乐器识别 | 无(Part 1, Part 2...) | 两阶段 VLM API + RapidOCR 缩写字典 |
| 移调乐器 | 不支持 | 自动检测调性,注入 <transpose> 元素 |
| 力度标记 | 不支持 | YOLO26n 微调模型检测 + 小节定位注入 |
| 谱表分组 | 几何检测(密集总谱易出错) | OCR 确定声部数 → 强制均分 |
| 跨声部校正 | 无 | 多层后处理(拍号推断/对齐/结构/时值/溢出修复) |
| 多页合并 | 不支持 | 乐器并集 + divisions 归一化 + 小节拼接 |
| MIDI 音色 | 通用钢琴 | 按乐器分配正确的 instrument-sound 和 midi-program |
| 鲁棒性 | 窄碎片崩溃 | 自动过滤 + 质量检查报告 |
输入图片
│
▼
┌───────────────────────────────────────────────┐
│ 1. 谱表检测 (HOMR SegNet) │
│ → staffs_sorted: 按 y 排序的谱表列表 │
└───────────────────┬───────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 2. 括号/大括号检测 │
│ → bracket_groups: 谱表分组 [[0,1],[2],...] │
│ → system_groups: 行划分 [[s0..s12],[s13..]] │
└───────────────────┬───────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 3. OCR 扫描左边栏 (RapidOCR) │
│ → ocr_hint: "Fl.", "Klar. 1.in A", ... │
│ 若 ocr_hint 为空 → 跳过 VLM,返回 [] │
└───────────────────┬───────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 4. 两阶段 VLM 识别 (Qwen3-VL-235B) │
│ Pass 1: 逐谱表自由推理 │
│ 输入: 裁切图 + 谱表坐标提示 + 每谱表最近OCR标签 │
│ 输出: 每行 "Staff N (y=...): 乐器 — 理由" │
│ Pass 2: 严格格式化 │
│ 输入: Pass 1 结果 + 裁切图 + 谱表坐标提示 │
│ 输出: 精确 N 行乐器名 │
│ 验证: ≥50% 名字在 INSTRUMENT_MIDI 中 │
│ 失败 → RapidOCR 备用路径 │
└───────────────────┬───────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ 5. 跨页传播 & Override 匹配 │
│ 第一页结果 → detected_names │
│ 后续页 OCR 为空 → 直接复用 detected_names │
│ override 数 > 当前检测数 → 子序列匹配(tacet) │
└───────────────────────────────────────────────┘
_ocr_margin_labels 用 RapidOCR 扫描谱表左侧区域,提取文字标签并按 y 坐标排序。扫描范围限定在第一个 system 的 y 区间内,避免后续 system 的标签干扰。
关键设计:若 OCR 未检测到任何标签(ocr_hint 为空),说明该页没有乐器标注(管弦乐总谱中常见——只有第一页/第一 system 有标注),此时跳过 VLM 调用,直接返回空列表 []。这避免了 VLM 在无标签页面上胡乱猜测乐器名。
页面有标签 → OCR 提取 → 传给 VLM 做精确识别
页面无标签 → OCR 返回空 → 跳过 VLM → 返回 [] → 由跨页传播机制复用第一页名字
单次调用 VLM 并要求同时"识别乐器"和"输出精确 N 行"会相互干扰:VLM 倾向于把大括号内的多个谱表合并为一个条目,或者把"Oboen 1.2"展开为两行但依赖标签数字而非实际谱表数量,导致行数不符。两阶段方法将这两个目标解耦:
Pass 1 — 自由推理(无行数约束)
每个谱表的 y 坐标(全图坐标系)由 HOMR 精确测量,并附上距该 y 坐标最近的 OCR 标签,构成提示:
HOMR stave layout (17 staves, crop starts at full-image y=320):
Stave 1 (y=410): nearest label 'Fl.' (dist=12px)
Stave 2 (y=480): nearest label 'Ob.' (dist=8px)
...
Stave 16 (y=1820): nearest label 'Vcll.' (dist=15px)
Stave 17 (y=1890): nearest label 'B.' (dist=11px)
Pass 1 要求 VLM 对每个谱表逐行给出 Staff N (y=...): 乐器名 — 理由,不限行数,允许自由推理。这一步解决了"一个标签对应几个谱表"的歧义:VLM 看到 Stave 16 和 Stave 17 的最近标签都是 Vcll.,就会把两者都识别为 Cello,而不会只识别一个。
Pass 2 — 严格格式化
以 Pass 1 的输出为上下文,加上谱表坐标提示,要求输出精确 N 行乐器名(无解释、无编号)。两行共用同一谱表时用 Trombone/Tuba 合并。关键规则写入 prompt:
Name:Key格式,仅在乐谱上明确标注时才加 key(如Kl.(A)→Clarinet:A)B作为 key 表示 B♭;B./Kb.作为乐器名表示 Contrabass- 德文缩写表:Fl./Ob./Kl./Fg./Hr./Trp./Pos./Pk. 等
VLM 返回 Name:Key 格式,将乐器名和移调调性分开:
| VLM 返回 | 解析结果 |
|---|---|
Clarinet:A |
base=Clarinet, key=A |
Horn:F |
base=Horn, key=F |
Trumpet:Bb |
base=Trumpet, key=Bb |
Violin |
base=Violin, key=None |
Timpani:E |
base=Timpani, key=E(不移调,见下文) |
_parse_instrument_key 同时支持 Name:Key(VLM 格式)和 Name in Key(旧格式/显示格式)两种写法。
_instrument_base 在查询 INSTRUMENT_MIDI / ORCHESTRAL_ORDER 之前,先经过 _INSTRUMENT_SYNONYMS 做同义词折叠:
| VLM/OCR 输出 | 规范化为 |
|---|---|
| Keyboard / Klavier / Pianoforte / Grand Piano | Piano |
| Double Bass / String Bass / Bass | Contrabass |
| French Horn | Horn |
INSTRUMENT_ABBREVS 缩写字典同样收录了 klav / klav. / klavier / keyboard → Piano,确保德文缩写在 OCR 路径下也能正确映射。
移调值由调性字母计算得出(_compute_transpose),而非查表:
_KEY_SEMITONES = {"C": 0, "D": 2, "Eb": 3, "E": 4, "F": 5, "G": 7, "A": 9, "Bb": 10, ...}- 方向规则:Horn/English Horn 始终向下移(如 F → chromatic=-7);其他乐器取最近方向(semitones > 6 时向下)。
- 八度补偿:Bass Clarinet 额外 octave-change=-1。
- 仅限已知移调乐器:只有
DEFAULT_TRANSPOSE_KEY中列出的乐器(Clarinet, Bass Clarinet, Horn, Trumpet, English Horn)才会生成<transpose>元素。即使 VLM 为 Timpani 返回了Timpani:E,也不会产生错误的移调。
| 乐器 | 默认调 |
|---|---|
| Clarinet | B♭ |
| Bass Clarinet | B♭ |
| Horn | F |
| Trumpet | B♭ |
| English Horn | F |
_detect_system_breaks 将一页的谱表划分为若干行(system),采用三层优先级:
- 全页 CC 分析(主路径):取所有 SegNet 分割掩码的并集,对全页二值图做连通域分析,保留高度超过一个谱表高度的连通域,合并间距小于一个谱表高度的区间,得到各 system 的 y 范围。适用于管弦乐和钢琴谱。
- Fallback A — 括号/大括号检测:在左边距 CC 分析失败时,改用 brace_dots 中高大括号的 bounding box 推断 system 边界(原始逻辑)。
- Fallback B — 间距分析:若无高大括号,检测谱表间 y 轴间距分布的最大相对跳变来切分 system(适用于钢琴/室内乐谱)。
后处理会合并被错误拆分的 system:当相邻小组谱表数之和恰好等于第一个 system 的谱表数时,自动合并;若累积数不能整除,则保持各组独立(真正的小 system)。
检测结果: [13, 9, 4] → 合并为: [13, 13] ← 9+4=13
检测结果: [13, 9, 5] → 保持: [13, 9, 5] ← 9+5≠13
当一页包含多个 system 且谱表数不同时(如 system 1 有 5 个谱表、system 2 有 13 个),进入 multi-system 模式:
- System 0:使用 OCR→VLM 检测到的
part_names - System 1+:调用
_ocr_extra_system_names,裁切左边栏图像给 VLM,在已知乐器名列表(master_names)中匹配master_names优先使用跨页传播的part_names_override(完整乐器列表),而非当前页 OCR 检测到的部分名字- 验证时用
_instrument_base比较,使Horn能匹配Horn:F
- Override 匹配:仅当 override 乐器数多于当前检测数时(说明部分乐器 tacet),才触发子序列匹配(
_match_override_to_detected)。检测数与 override 数相等时,直接使用 VLM 检测到的名字(包含当前页的正确调性信息),不替换。
# main() 中的核心逻辑
detected_names = None
for img in pages:
_, names = run_pipeline(img, out, part_names_override=detected_names)
if detected_names is None and names:
detected_names = names # 第一页的结果作为后续页的 override- 第一页:正常 OCR→VLM 检测,结果存入
detected_names - 后续页:
- 若 OCR 检测到标签 → VLM 识别 → override 仅在 tacet 时介入
- 若 OCR 无标签 → 跳过 VLM → 直接复用
detected_names
- Override 匹配算法 (
_match_override_to_detected):当 override 有 N 个名字但当前页只有 M 个谱表(M < N,部分乐器 tacet)时,遍历 C(N,M) 种子序列组合,按名字匹配度 + 音高范围验证选出最佳匹配,同时报告哪些乐器 tacet
run_pipeline 返回一个 ts_context 元组,供下一页调用时传入:
ts_context = (last_ts, ended_with_bar, last_ks, ks_changed, pending_ks_fixup)| 字段 | 含义 |
|---|---|
last_ts |
本页最后生效的拍号 (beats, beat_type) |
ended_with_bar |
本页最后一小节是否以双小节线结尾(下一页新段落开始) |
last_ks |
本页末尾各声部的调号上下文 [(name, chromatic, fifths), ...] |
ks_changed |
本页是否出现过双小节线(下一页应信任 HOMR 读取的调号) |
pending_ks_fixup |
回溯修正待定项 (file_path, from_m, to_m) |
_cross_part_post_process 内的 Layer 0 在每页独立处理拍号,并利用跨页上下文:
- 双小节线分段:用
_double_bar_measures找到页内所有双小节线位置,对每段独立推断拍号。 - 幻象小节剥离:双小节线后内容稀疏的尾随小节(< 1/3 声部有音符)作为 TrOMR 识别末端伪影被剥离。
- 段落内拍号:
- 无前页上下文或上页以双小节线结尾(
prev_bar_ended=True):信任 HOMR 自身的多数投票拍号。 - 有继承上下文(
prev_ts):若 HOMR 分母变化或 ≥90% 置信度则认为有新拍号打印,否则继承prev_ts。
- 无前页上下文或上页以双小节线结尾(
- VLM 辅助(
_retry_tromr_post_double):双小节线后 TrOMR 拍号置信度 ≤75% 时,裁取双小节线右侧区域,向 VLM 采集最多 5 个谱表的投票:- VLM 读出数字拍号 → 若与前段不同则注入;若相同则确认并覆写任何错误读数。
- VLM 判断无拍号符号 → 继承前段拍号,覆盖 TrOMR 的错误读数。
- VLM 出错 → 退回 TrOMR 重跑后双小节线区域。
调号传播依赖同一 ts_context 中的 last_ks / ks_changed:
- 无双小节线页(
prev_ks_changed=False):继承上页/system 末尾的调号,覆盖 HOMR 在无打印调号时的乱读。 - 有双小节线页(
prev_ks_changed=True):信任 HOMR 在新段落开头读取的调号(TrOMR 能正确读出段落首的调号符号)。 - 回溯修正(
ks_fixup_range):若双小节线出现在页面中间,双小节线之后到页末的小节带有旧调号。等下一页处理完成、正确调号已知后,_fixup_ks_in_file对之前写入磁盘的文件做回溯修正。
页 N 处理完 → pending_ks_fixup = (page_N.musicxml, m_dbl+1, m_end)
页 N+1 处理完 → prev_ks 即为新调号
→ _fixup_ks_in_file(page_N.musicxml, prev_ks, m_dbl+1, m_end)
merge_pages 中的 page_measure_ts 为每个小节位置确定拍号,规则如下:
- 仅统计显式标记:只有包含
<time>元素的小节,以及之后拥有实音符(非全休止)的小节才参与投票。全休止符在 4/4 和 6/4 下外观完全相同,无法区分,不参与投票。 - 多数投票:同一小节位置各声部拍号不同时,取票数最多的那个。
- 跨页传播:从前一页末尾拍号继承,避免无标记小节被默认为 4/4。
这解决了多 system 页面(如 system 1 = 6/4,system 2 新乐器入场无标记)中新乐器的全休止小节被错误地判断为 4/4 的问题。
_display_name 将内部格式转为 MusicXML 显示名:Clarinet:A → Clarinet in A。用于 <part-name> 和 <instrument-name> 元素。
pip install -r requirements.txtVLM API 配置(可选,用于乐器名识别的主路径):
cp .env.example .env
# 编辑 .env,填入 API_KEY 和 BASE_URL不配置 VLM 时,自动退回到 RapidOCR 识别乐器名。也可用 --no-vlm 强制使用 OCR 路径。
run_score.py 是端到端的入口脚本,从 PDF 到最终合并的 MusicXML 一条龙完成:
# 基本用法:PDF + 起止页号(1-based,闭区间)
python run_score.py Bruckner7.pdf 1 3
# 自定义输出路径
python run_score.py Tchai1.pdf 3 5 -o tchai_mvt1.musicxml
# 单页
python run_score.py Brahms4.pdf 93 93
# 更高分辨率(默认 300 DPI)
python run_score.py score.pdf 1 10 --dpi 400默认输出到 outputs/{pdf名}_{起始页}_{结束页}.musicxml,中间产物(逐页 PNG 和 MusicXML)存在 outputs/{pdf名}/ 下。
内部流程:
pdf2image将指定页渲染为 PNG(存于outputs/{stem}/)- 逐页调用
run_pipeline,自动跨页传播乐器名、拍号与调号(ts_context) - 多页时调用
merge_pages合成最终 MusicXML
| 参数 | 说明 |
|---|---|
pdf |
PDF 文件路径 |
start |
起始页号(1-based,包含) |
end |
结束页号(1-based,包含,等于 start 时只转一页) |
-o, --output |
输出 .musicxml 路径(默认 outputs/{stem}_{start}_{end}.musicxml) |
--dpi |
PDF 渲染 DPI(默认 300) |
--no-gpu |
禁用 GPU 推理 |
--no-vlm |
禁用 VLM,使用 RapidOCR 识别乐器名 |
如果已有裁好的 PNG 页面图片,可以直接调用 pipeline.py:
# 单页识别
python pipeline.py score_page.png -o output.musicxml --check
# 多页合并(自动跨页传播乐器名)
python pipeline.py page5.png page6.png page7.png -o merged.musicxml --check
# 批量处理目录(不合并)
python pipeline.py image_directory/ --checkfrom pipeline import run_pipeline, merge_pages
# 单页,指定乐器名(跳过 VLM/OCR 识别)
out_path, part_names, ts_ctx = run_pipeline(
"page.png", "out.musicxml",
part_names_override=["Flute", "Clarinet:A", "Horn:F", "Violin", ...]
)
# 多页跨页传播(拍号/调号/乐器名全部跨页传播)
detected_names = None
ts_context = None
for img, out in zip(png_paths, xml_paths):
_, names, ts_context = run_pipeline(
img, out,
part_names_override=detected_names,
ts_context=ts_context,
)
if detected_names is None and names:
detected_names = names
merge_pages(xml_paths, "merged.musicxml")run_pipeline 返回 (output_path, part_names, ts_context) 三元组。ts_context 应原样传给下一页的 run_pipeline 调用,以实现拍号/调号的跨页传播(详见跨页拍号/调号传播)。
pipeline.py 参数:
| 参数 | 说明 |
|---|---|
-o, --output |
输出路径(默认与输入同名 .musicxml) |
--check |
运行质量检查(音域/时值异常报告 + piano roll 图) |
--no-vlm |
禁用 VLM API,使用 RapidOCR 识别乐器名 |
--no-gpu |
禁用 GPU 推理 |
单页(Mahler 7, page 5)— 17 声部,5 小节:
多页合并(pages 5-7)— 22 声部,15 小节:
YOLO26n 在 DeepScoresV2 dense 子集上微调 15 epoch(736张训练图,160张测试图)。
- 基础权重:
yolo26n.pt(DeepScoresV2 全类预训练) - 微调权重:
runs_dynamics/dynamics_finetune_15ep/weights/best.pt - 检测类别(8类):
dynamicCrescendoHairpin,dynamicDiminuendoHairpin,dynamicF,dynamicM,dynamicP,dynamicR,dynamicS,dynamicZ
15 epoch 优于 30 epoch,原因是 YOLO 在 epoch 21 关闭 mosaic 增强后出现分布偏移,30ep 轻微过拟合。
| 无微调 | 微调30ep | 微调15ep | |
|---|---|---|---|
| mAP50 all | 0.812 | 0.809 | 0.894 |
| mAP50 f | 0.943 | 0.888 | 0.956 |
| mAP50 p | 0.898 | 0.972 | 0.980 |
| mAP50 s | 0.595 | 0.566 | 0.745 |
f、p 类优先保证精度(漏报比误报代价低),阈值取 P=0.95 对应的最低置信度。
s 类(dynamicS,即 sforzando)只在与 f 或 p 共同出现时才有音乐意义;单独出现的 s 检测无效,因此优先保召回,取 F1 最优点。
| 类别 | 置信度阈值 | Precision | Recall | 策略 |
|---|---|---|---|---|
| dynamicF | 0.65 | ~0.952 | ~0.875 | precision-first (conf at P=0.95 → rounded up) |
| dynamicP | 0.65 | ~0.952 | ~0.875 | precision-first |
| dynamicS | 0.31 | 0.877 | 0.906 | recall-first (F1最优) |
| 其余类别 | 0.25 | — | — | 默认 |
YOLO 检测到的原始标签(dynamicF、dynamicP、dynamicS 等字符)经过规则化后写入 MusicXML:
ff/sf/mf→f;pp/mp→p(简化为标准两级)- 三个及以上字母(
fff、ppp等)丢弃 fp/pf丢弃(复合力度超出当前模型能力)- Hairpin 直接对应
<wedge type="crescendo/diminuendo">
- Tremolo 记谱:TrOMR 无法识别 tremolo(成对黑块),产生空声部。这是模型训练数据的限制。
- 多页声部匹配:依赖乐器名精确匹配。如果同一乐器在不同页面被 VLM 识别为不同名称(如移调标记变化),会被视为不同声部。可通过
part_names_override手动指定乐器名绕过。 - VLM 调用开销:两阶段各调用一次 API,识别耗时约为单阶段的两倍。若需加速,可用
--no-vlm切换到 RapidOCR 路径(精度稍低)。

