Repository Wiki
bytedance/piano_transcription

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 方案把这一解码分成两步:

  1. 锐化(sharp):回归输出的 onset/offset 概率是"平缓的钟形",先用阈值 + 局部极大值检测把它压缩成单帧的 0/1 尖峰(sharp_output_dict → sharp_output);
  2. 检测(detect):以 onset 尖峰为音符起点、以 frame 输出为音符持续区间、以 offset 尖峰为音符终点,逐音高(88 个键)解码出 (onset, offset, velocity) 元组,再除以帧率换算成秒。

与之对照,本仓库提出的方法(RegressionPostProcessor)额外预测 onset_shift / offset_shift 亚帧偏移量,因此时间精度可以超过 1 帧;而 OnsetsFramesPostProcessor 直接 帧索引 / frames_per_second,时间分辨率被锁死在帧长(100 Hz 帧率即 10 ms)。这正是它"仅用于对比"的原因——它复现了基线的时间精度上限。

关键源码入口:

架构(Architecture)

Loading diagram...

分层说明:

  • 推理层: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)

构造函数:硬编码的基线阈值

python
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.3

Source: utilities.py

设计意图有两点值得注意:

  1. 阈值不可配置是有意的。对比 inference.py 中 RegressionPostProcessor 的构造——它接受 onset_threshold、offset_threshold、frame_threshold、pedal_offset_threshold 四个可调参数——而基线只接受 frames_per_second 与 classes_num。基线应固定复现 Google 论文/官方实现的超参(0.5 / 0.1 / 0.3),任何调参都会破坏"公平对比"的前提。
  2. 音高映射与力度缩放来自全局 config(config.begin_note、config.velocity_scale),保证与主方法共用同一套 MIDI 音域与力度标尺。

主入口:output_dict_to_midi_events()

python
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_events

Source: 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)

python
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_offs

Source: utilities.py

sharp_output_dict 按键探测地写入两个新键:'onset_output' 与 'offset_output'(阈值分别为 0.1 与 0.3),随后原地返回 output_dict。这是本算法的第一个关键差异点:Google 方案不使用连续回归值,而是先把它们二值化。

峰值锐化的核心扫描在 sharp_output 中,采用"局部极大值 + 迟滞置零"模式:

python
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 output

Source: 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)

python
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) 三元组;随后把帧索引换算为秒并堆叠:

python
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)

python
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)

python
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)

Loading diagram...

对照真实调用点(inference.py):

python
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 中的选择)

python
from utilities import (create_folder, get_filename, RegressionPostProcessor, OnsetsFramesPostProcessor, write_events_to_midi, load_audio) from models import Note_pedal

Source: inference.py

运行 pytorch/inference.py 时通过参数把 post_processor_type 设为 'onsets_frames' 即可切换到本基线;默认 'regression' 使用论文主方法。

评测场景中的实例化

python
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_secondint构造时传入(如 100)调用方帧率,帧索引 → 秒的唯一换算因子
classes_numint构造时传入(如 88)调用方音高通道数,决定逐音高循环范围
frame_thresholdfloat0.5硬编码音符持续判定阈值
onset_thresholdfloat0.1硬编码onset 尖峰锐化阈值
offset_thresholdfloat0.3硬编码offset 尖峰锐化阈值
踏板 frame_thresholdfloat0.5硬编码(函数内)踏板持续判定阈值
config.begin_noteint全局 configconfig 模块音高索引 → MIDI 音号偏移
config.velocity_scaleint全局 configconfig 模块归一化力度 → 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 分支同左

这种"同一模型、同一输出、仅换解码器"的实验设计使两个后处理器构成严格的消融对照,直接量化了高分辨率回归解码的增益。

Sources

(2 files)