OnsetsFramesPostProcessor 对比实现
OnsetsFramesPostProcessor 是 piano_transcription 仓库中为对比评测目的保留的 Google "Onsets and Frames" 后处理算法实现:它把转录模型输出的逐帧概率矩阵(onset / offset / frame / velocity / pedal)转换为 MIDI 音符事件与踏板事件。该类仅在 post_processor_type == 'onsets_frames' 时被选用,用作论文中与作者提出的高分辨率回归后处理(RegressionPostProcessor)进行基线对比。
目的与范围(Purpose and Scope)
本页面完整剖析 OnsetsFramesPostProcessor 这个对比基线的实现,覆盖:
- 类的职责、构造参数与硬编码的判决阈值(与
RegressionPostProcessor的可配置阈值形成对照); - 从
output_dict(模型逐帧输出)到est_note_events/est_pedal_events(MIDI 事件列表)的完整控制流; - 核心算法:
sharp_output峰值锐化(peak-picking)、逐音高音符检测、踏板检测、数组到事件字典的重排; - 底层检测函数
onsets_frames_note_detection/onsets_frames_pedal_detection(位于utils/piano_vad.py)的调用契约; - 边界条件、失败模式与性能特征。
不在本页范围(由兄弟页面覆盖):
- 论文主方法的高分辨率回归后处理(含 onset_shift / offset_shift 亚帧细化):参见 RegressionPostProcessor 页面;
- 音频分段前向推理流水线(enframe / forward / deframe):参见 推理流水线(PianoTranscriber) 页面——本页仅在调用点引用;
- MIDI 事件写出(
write_events_to_midi)与评测打分(calculate_score_for_paper.py)的实现细节。
概述(Overview)
在钢琴转录任务中,神经网络输出的是逐帧、逐音高的概率矩阵,而最终交付物是 MIDI 音符事件列表 {'onset_time', 'offset_time', 'midi_note', 'velocity'}。后处理器的职责就是把"概率热图"解码为"离散事件序列"。
Google 的 Onsets and Frames 方案把这一解码分成两步:
- 锐化(sharp):回归输出的 onset/offset 概率是"平缓的钟形",先用阈值 + 局部极大值检测把它压缩成单帧的 0/1 尖峰(
sharp_output_dict→sharp_output); - 检测(detect):以 onset 尖峰为音符起点、以 frame 输出为音符持续区间、以 offset 尖峰为音符终点,逐音高(88 个键)解码出
(onset, offset, velocity)元组,再除以帧率换算成秒。
与之对照,本仓库提出的方法(RegressionPostProcessor)额外预测 onset_shift / offset_shift 亚帧偏移量,因此时间精度可以超过 1 帧;而 OnsetsFramesPostProcessor 直接 帧索引 / frames_per_second,时间分辨率被锁死在帧长(100 Hz 帧率即 10 ms)。这正是它"仅用于对比"的原因——它复现了基线的时间精度上限。
关键源码入口:
- 类实现:utilities.py(
class OnsetsFramesPostProcessor) - 调用点一(推理 CLI):inference.py
- 调用点二(论文评测):calculate_score_for_paper.py
- 底层检测函数:piano_vad.py(
onsets_frames_note_detection)、piano_vad.py(onsets_frames_pedal_detection)
架构(Architecture)
分层说明:
- 推理层:
PianoTranscriber.transcribe()完成音频补零、分帧(enframe)、模型前向、去帧(deframe)后,依据post_processor_type在'regression'(论文主方法)与'onsets_frames'(本页基线)之间选择后处理器,随后调用统一入口output_dict_to_midi_events()。 - 后处理器层:
OnsetsFramesPostProcessor是纯 Python/Numpy 的解码器,自身不接触音频与模型。它把工作拆成"锐化 → 逐音高检测 → 数组重排为事件字典"三级;音符与踏板两条通路并行、互不依赖。 - 检测函数层:真正的状态机式逐帧扫描在
utils/piano_vad.py的两个自由函数里完成,后处理器按音高列(88 次)与踏板通道(1 次)分别调用它们。 - 配置依赖:
begin_note(音高索引 → MIDI 音号的偏移)与velocity_scale(归一化力度 → 0–127 MIDI 力度)来自全局config模块,阈值则在构造函数中硬编码。
实现剖析(Implementation Walkthrough)
构造函数:硬编码的基线阈值
1def __init__(self, frames_per_second, classes_num):
2 """Postprocess the Googl's onsets and frames system output. Only used
3 for comparison.
4
5 Args:
6 frames_per_second: int
7 classes_num: int
8 """
9 self.frames_per_second = frames_per_second
10 self.classes_num = classes_num
11 self.begin_note = config.begin_note
12 self.velocity_scale = config.velocity_scale
13
14 self.frame_threshold = 0.5
15 self.onset_threshold = 0.1
16 self.offset_threshold = 0.3Source: utilities.py
设计意图有两点值得注意:
- 阈值不可配置是有意的。对比
inference.py中RegressionPostProcessor的构造——它接受onset_threshold、offset_threshold、frame_threshold、pedal_offset_threshold四个可调参数——而基线只接受frames_per_second与classes_num。基线应固定复现 Google 论文/官方实现的超参(0.5 / 0.1 / 0.3),任何调参都会破坏"公平对比"的前提。 - 音高映射与力度缩放来自全局 config(
config.begin_note、config.velocity_scale),保证与主方法共用同一套 MIDI 音域与力度标尺。
主入口:output_dict_to_midi_events()
1def output_dict_to_midi_events(self, output_dict):
2 ...
3 # Post process piano note outputs to piano note and pedal events information
4 (est_on_off_note_vels, est_pedal_on_offs) = \
5 self.output_dict_to_note_pedal_arrays(output_dict)
6 """est_on_off_note_vels: (events_num, 4), the four columns are: [onset_time, offset_time, piano_note, velocity],
7 est_pedal_on_offs: (pedal_events_num, 2), the two columns are: [onset_time, offset_time]"""
8
9 # Reformat notes to MIDI events
10 est_note_events = self.detected_notes_to_events(est_on_off_note_vels)
11
12 if est_pedal_on_offs is None:
13 est_pedal_events = None
14 else:
15 est_pedal_events = self.detected_pedals_to_events(est_pedal_on_offs)
16
17 return est_note_events, est_pedal_eventsSource: utilities.py
该方法是两个后处理器共用的统一契约:inference.py 与 calculate_score_for_paper.py 都只调用它,不感知内部差异。输入 output_dict 的键包括 reg_onset_output、reg_offset_output、frame_output、velocity_output(均为 (segment_frames, classes_num)),以及可选的 reg_pedal_onset_output、reg_pedal_offset_output、pedal_frame_output((segment_frames, 1))。
注意 est_pedal_on_offs is None 的分支:当输出字典不含踏板键时(如只加载音符模型),踏板事件返回 None 而非空列表,下游 write_events_to_midi 据此跳过踏板轨。
第一步:锐化 onset / offset(sharp_output_dict → sharp_output)
1def output_dict_to_note_pedal_arrays(self, output_dict):
2 ...
3 # Sharp onsets and offsets
4 output_dict = self.sharp_output_dict(
5 output_dict, onset_threshold=self.onset_threshold,
6 offset_threshold=self.offset_threshold)
7
8 # Post process output_dict to piano notes
9 est_on_off_note_vels = self.output_dict_to_detected_notes(output_dict,
10 frame_threshold=self.frame_threshold)
11
12 if 'reg_pedal_onset_output' in output_dict.keys():
13 # Detect piano pedals from output_dict
14 est_pedal_on_offs = self.output_dict_to_detected_pedals(output_dict)
15
16 else:
17 est_pedal_on_offs = None
18
19 return est_on_off_note_vels, est_pedal_on_offsSource: utilities.py
sharp_output_dict 按键探测地写入两个新键:'onset_output' 与 'offset_output'(阈值分别为 0.1 与 0.3),随后原地返回 output_dict。这是本算法的第一个关键差异点:Google 方案不使用连续回归值,而是先把它们二值化。
峰值锐化的核心扫描在 sharp_output 中,采用"局部极大值 + 迟滞置零"模式:
1(frames_num, classes_num) = input.shape
2output = np.zeros_like(input)
3
4for piano_note in range(classes_num):
5 loct = None
6 for i in range(1, frames_num - 1):
7 if input[i, piano_note] > threshold and input[i, piano_note] > input[i - 1, piano_note] and input[i, piano_note] > input[i + 1, piano_note]:
8 loct = i
9 else:
10 if loct is not None:
11 output[loct, piano_note] = 1
12 loct = None
13
14return outputSource: utilities.py
逻辑解读(对应文档字符串中的示例:[0, 0.1, 0.4, 0.7, 0, 0] → [0, 0, 0, 1, 0, 0]):
- 逐音高独立扫描,对每帧
i判断是否为"严格局部极大值"(同时大于左右邻居)且超过threshold; - 满足条件时不立即置 1,而是记入
loct;一旦出现不满足的帧,才把loct处置 1 并复位。这样即使概率在多个连续帧上越过阈值,也只保留最后一个局部极大值,保证每个 onset/offset 只产生单帧尖峰; loct是纯 Python 局部变量,每次内层循环重置——即同一音高在同一时间邻域内的多个尖峰会各自独立输出。
第二步:逐音高音符检测(output_dict_to_detected_notes)
1est_tuples = []
2est_midi_notes = []
3
4for piano_note in range(self.classes_num):
5
6 est_tuples_per_note = onsets_frames_note_detection(
7 frame_output=output_dict['frame_output'][:, piano_note],
8 onset_output=output_dict['onset_output'][:, piano_note],
9 offset_output=output_dict['offset_output'][:, piano_note],
10 velocity_output=output_dict['velocity_output'][:, piano_note],
11 threshold=frame_threshold)
12
13 est_tuples += est_tuples_per_note
14 est_midi_notes += [piano_note + self.begin_note] * len(est_tuples_per_note)Source: utilities.py
这里体现了典型的列分离(column-split)设计:88 个音高的检测完全独立、互不通信,因此该循环天然可并行(当前实现为纯 Python 循环,未并行化)。每个音高抽取四个通道的一维序列交给 onsets_frames_note_detection,返回该音高下所有 (onset_frame, offset_frame, velocity) 三元组;随后把帧索引换算为秒并堆叠:
1onset_times = est_tuples[:, 0] / self.frames_per_second
2offset_times = est_tuples[:, 1] / self.frames_per_second
3velocities = est_tuples[:, 2]
4
5est_on_off_note_vels = np.stack((onset_times, est_midi_notes, velocities), axis=-1)Source: utilities.py
(实际代码为 np.stack((onset_times, offset_times, est_midi_notes, velocities), axis=-1),四列依次为 onset 秒、offset 秒、MIDI 音号、归一化力度。)注意换算方式是整除帧率、无亚帧插值——这是时间精度被帧长锁死的直接原因。
返回值有两处防御:若 len(est_midi_notes) == 0(整段音频未检出任何音符),返回空 Python 列表 [] 而非空数组;最终再 astype(np.float32) 统一类型。
第三步:踏板检测(output_dict_to_detected_pedals)
1frames_num = output_dict['pedal_frame_output'].shape[0]
2
3est_tuples = onsets_frames_pedal_detection(
4 frame_output=output_dict['pedal_frame_output'][:, 0],
5 offset_output=output_dict['reg_pedal_offset_output'][:, 0],
6 frame_threshold=0.5)
7
8est_tuples = np.array(est_tuples)
9"""(notes, 2), the two columns are pedal onsets and pedal offsets"""
10
11if len(est_tuples) == 0:
12 return np.array([])Source: utilities.py
踏板通路与音符通路结构对称但更简单:踏板是单通道([:, 0]),不使用 onset 输出,起振由 pedal_frame_output 越过 frame_threshold=0.5 决定,释止由 reg_pedal_offset_output 决定;帧→秒换算与音符通路一致。空结果返回 np.array([])(与音符通路的 [] 不同),下游需注意两种空态的类型差异。
第四步:数组 → 事件字典(detected_notes_to_events / detected_pedals_to_events)
1midi_events = []
2for i in range(len(est_on_off_note_vels)):
3 midi_events.append({
4 'onset_time': est_on_off_note_vels[i][0],
5 'offset_time': est_on_off_note_vels[i][1],
6 'midi_note': int(est_on_off_note_vels[i][2]),
7 'velocity': int(est_on_off_note_vels[i][3] * self.velocity_scale)})Source: utilities.py
重排阶段做两处类型收敛:midi_note 与 velocity 强制 int 化(MIDI 协议要求整数),其中力度由归一化值乘 config.velocity_scale(即 128)映射到 0–127 区间;onset_time / offset_time 保持浮点秒。detected_pedals_to_events 同构,仅保留两个时间字段。
核心流程(Core Flow)
对照真实调用点(inference.py):
1# Post processor
2if self.post_processor_type == 'regression':
3 """Proposed high-resolution regression post processing algorithm."""
4 post_processor = RegressionPostProcessor(self.frames_per_second,
5 classes_num=self.classes_num, onset_threshold=self.onset_threshold,
6 offset_threshold=self.offset_threshod,
7 frame_threshold=self.frame_threshold,
8 pedal_offset_threshold=self.pedal_offset_threshold)
9
10elif self.post_processor_type == 'onsets_frames':
11 """Google's onsets and frames post processing algorithm. Only used
12 for comparison."""
13 post_processor = OnsetsFramesPostProcessor(self.frames_per_second,
14 self.classes_num)
15
16# Post process output_dict to MIDI events
17(est_note_events, est_pedal_events) = \
18 post_processor.output_dict_to_midi_events(output_dict)Source: inference.py
可以看到两条路径在分支汇合点之后完全一致——这正是"可替换后处理器"策略的体现:模型与评测完全不动,仅切换解码算法即可测量后处理本身的贡献。
使用示例
基本用法(推理 CLI 中的选择)
from utilities import (create_folder, get_filename, RegressionPostProcessor,
OnsetsFramesPostProcessor, write_events_to_midi, load_audio)
from models import Note_pedalSource: inference.py
运行 pytorch/inference.py 时通过参数把 post_processor_type 设为 'onsets_frames' 即可切换到本基线;默认 'regression' 使用论文主方法。
评测场景中的实例化
elif self.post_processor_type == 'onsets_frames':
post_processor = OnsetsFramesPostProcessor(self.frames_per_second,
self.classes_num)Source: calculate_score_for_paper.py
在论文评测脚本中同样以分支方式构造后处理器,用于在 Maestro 等测试集上对比两种解码策略的 Note F1 / Offset F1 / Velocity 指标。
配置选项
| 选项 | 类型 | 默认值 | 来源 | 说明 |
|---|---|---|---|---|
frames_per_second | int | 构造时传入(如 100) | 调用方 | 帧率,帧索引 → 秒的唯一换算因子 |
classes_num | int | 构造时传入(如 88) | 调用方 | 音高通道数,决定逐音高循环范围 |
frame_threshold | float | 0.5 | 硬编码 | 音符持续判定阈值 |
onset_threshold | float | 0.1 | 硬编码 | onset 尖峰锐化阈值 |
offset_threshold | float | 0.3 | 硬编码 | offset 尖峰锐化阈值 |
踏板 frame_threshold | float | 0.5 | 硬编码(函数内) | 踏板持续判定阈值 |
config.begin_note | int | 全局 config | config 模块 | 音高索引 → MIDI 音号偏移 |
config.velocity_scale | int | 全局 config | config 模块 | 归一化力度 → 0–127 的乘子 |
与 RegressionPostProcessor 的关键区别:上表三个音频阈值在该基线中不可注入,构造函数签名只有两个参数。
API 参考
__init__(frames_per_second, classes_num)
构造后处理器。读取全局 config.begin_note 与 config.velocity_scale,并把 frame_threshold=0.5、onset_threshold=0.1、offset_threshold=0.3 硬编码为实例属性。
参数:
frames_per_second(int):帧率,用于帧索引到秒的换算。classes_num(int):音高通道数(钢琴为 88)。
output_dict_to_midi_events(output_dict)
主入口,与 RegressionPostProcessor 共享的统一契约。
参数:
output_dict(dict):模型逐帧输出,键与形状见上文。
返回:
est_note_events(list[dict]):{'onset_time', 'offset_time', 'midi_note', 'velocity'},时间单位秒,midi_note/velocity为 int。est_pedal_events(list[dict] | None):{'onset_time', 'offset_time'};当输入不含reg_pedal_onset_output键时返回None。
sharp_output(input, threshold=0.3)
参数: input 为 (frames_num, classes_num) 数组。返回: 同形状 0/1 数组,仅在"局部极大值且超阈值"的位置置 1。
output_dict_to_detected_notes(output_dict, frame_threshold)
返回: (notes, 4) float32 数组 [onset_time, offset_time, midi_note, velocity];无音符时返回 []。
output_dict_to_detected_pedals(output_dict)
返回: (notes, 2) float32 数组 [onset_time, offset_time];无踏板时返回 np.array([])。
detected_notes_to_events(est_on_off_note_vels) / detected_pedals_to_events(pedal_on_offs)
纯重排函数,无副作用;前者完成 velocity × velocity_scale 与 int 化。
失败模式、边界条件与并发
- 空检测:整段无音符时返回 Python 空列表
[];无踏板时返回np.array([]);模型未输出踏板键时返回None。三种"空态"类型不一致,下游遍历前需判空。 - 首尾帧盲区:
sharp_output的扫描范围为range(1, frames_num - 1),第 0 帧与最后一帧永远不可能是尖峰——若 onset 恰落在音频边界处会被丢弃。这是刻意简化(局部极大值需要左右邻居),但在极短片段上可能损失边界事件。 loct复位语义:连续越过阈值的平台上只保留最后一个局部极大值,而非第一个。若概率曲线单调爬升后骤降,锐化结果可能比直觉的 onset 时刻晚若干帧。- 无亚帧精度:所有时间均为
帧索引 / frames_per_second,理论上限即 1 帧时长(100 fps 时 10 ms)。velocity直接取检测函数给出的归一化值,不做平滑。 - 纯 CPU、单线程:
sharp_output是 88 × frames_num 的双层 Python 循环,output_dict_to_detected_notes再叠加 88 次函数调用;长音频下是推理流水线中可感知的耗时点(相对 GPU 前向)。类本身无任何共享可变状态(阈值在构造时定死,方法均为纯函数式对输入 dict 做键扩展),因此多进程/多线程并发调用同一实例是安全的,前提是各调用持有独立的output_dict(sharp_output_dict会向 dict 写入新键)。 - 原地修改输入:
sharp_output_dict会向传入的output_dict追加onset_output/offset_output两个键并原地返回。调用方若复用该 dict(例如inference.py把output_dict一并放进transcribed_dict返回),需知晓其已被扩展。
性能与扩展点
- 性能热点在
sharp_output的纯 Python 双层循环;若需优化,可用向量化峰值检测(scipy.signal.argrelmax类操作或滑窗比较)替换,行为等价且大幅提速。逐音高检测循环同理可joblib并行。 - 扩展点:新增后处理策略时只需实现同样的
output_dict_to_midi_events(output_dict)契约并在inference.py/calculate_score_for_paper.py的分支中注册,模型、MIDI 写出、评测链路零改动。 - 可复现性约束:若修改三个硬编码阈值,则不再是 Google Onsets and Frames 基线的忠实复现,对比实验结论即失效——如需调参请使用
RegressionPostProcessor。
与 RegressionPostProcessor 的对比定位
| 维度 | OnsetsFramesPostProcessor(本页) | RegressionPostProcessor(兄弟页面) |
|---|---|---|
| 定位 | Google 基线复现,仅用于对比 | 论文提出的高分辨率方法 |
| 阈值 | 构造时硬编码(0.5 / 0.1 / 0.3) | 构造参数可注入 |
| onset/offset 处理 | 二值化峰值锐化(sharp_output) | 保留连续回归值 + 亚帧 shift |
| 时间精度上限 | 1 帧(100 fps → 10 ms) | 亚帧(shift 回归补偿) |
| 共享契约 | output_dict_to_midi_events(output_dict) | 同左 |
| 调用点 | inference.py、calculate_score_for_paper.py 分支 | 同左 |
这种"同一模型、同一输出、仅换解码器"的实验设计使两个后处理器构成严格的消融对照,直接量化了高分辨率回归解码的增益。
相关链接(Related Links)
- 实现源码:utils/utilities.py(
OnsetsFramesPostProcessor类) - 底层检测函数:utils/piano_vad.py(
onsets_frames_note_detection)、utils/piano_vad.py(onsets_frames_pedal_detection) - 推理调用点:pytorch/inference.py
- 评测调用点:pytorch/calculate_score_for_paper.py
- 姊妹主题:RegressionPostProcessor(论文主方法后处理)、推理流水线 / PianoTranscriber(enframe / forward / deframe 与 MIDI 写出)