一起用代码吸猫!本文正在参与【喵星人征文活动】
背景介绍
在网上看到过很多铲屎官发自家猫主子弹钢琴的视频。我们家虽然有一只高贵的猫主子,但是苦于没有钢琴,无法发挥猫主子的艺术天赋。
作为一个合格的铲屎官,委屈谁也不能委屈了自己的猫主子,没有条件也要想办法创造条件。于是我们决定给我们家猫主子做一个 “电子” 钢琴,让猫主子可以尽情追求他的音乐梦想。
虽然说是要做 “电子” 钢琴,但是苦于时间有限(要给猫主子赚罐头钱),没办法买机械零件做键盘了,于是打算做一个 “丐版” 的,来给猫主子整一个 “虚拟” 的琴键。
另外,我们都知道,学钢琴最大的阻碍其实只有两个 —— 左手和右手。因此,我们打算整点不一样的 —— 用头来弹钢琴。
于是我们决定给猫主子做一个 “猫头钢琴”,用摄像头捕捉猫头的位置,不同位置对应不同的琴键,弹出不同的声音。这样一来解决了时间和金钱成本问题,二来又避免了猫主子学不会朝我们发脾气,还能让猫主子尽情享受音乐的快乐,一石三鸟,岂不美哉。
(猫主子:就是这么糊弄劳资的吗)
整体设计方案
要想实现猫头钢琴,要解决三个问题:
- 怎么拍摄猫主子的视频
- 怎么检测猫头的位置
- 怎么弹琴与怎么显示
首先是视频的拍摄。我本来在双十一买了一个便宜的 USB 摄像头,但是因为发货慢到现在还没到货。所以只能拿手机录视频来先模拟摄像头采集图像。
猫头的检测是最麻烦的部分,我这里打算用深度学习来搞定。首先需要采集一些猫主子的图片,用标注工具标注下猫头。然后用旷视开源的 YOLOV5 模型训一把。最后用商汤近期开源的 openppl 来做模型推理部署。
至于如何弹琴,我打算用 mingus 来搞定。视频的处理与图形界面部分用 OpenCV 来做。
图像采集与标注
图像采集用手机就好,但是难点在于如何让猫主子乖乖配合。
这里我祭出了神器 —— 酸奶,溜着猫主子在屋子里转了好几圈,终于采到了一些合适的视频
(猫主子:爷这么可爱不给爷喝一口的吗)
采视频的时候手头的 windows 电脑没装 OpenCV,就找了个网站把视频转成图片了:www.img2go.com/convert-to-… ,当然用 OpenCV 的 VideoCapture 也能做到
转成图片后,用了一个在 github 上找的标注工具:github.com/tzutalin/la…
- 众所周知,猫是流体,因此标注猫身子并不利于检测,因此最终选择标注猫头
- 因为背景简单且任务单一,所以标注少量图片即可完成猫头检测
- 标注时需要注意标注的格式,务必要采用 YOLOV5 模型可以识别的模式进行标注
我们最终标注了 430 张图片,训练集、验证集、测试集的数量分别是:400、20、10
YOLOV5 模型训练
模型训练
首先从 github 上 clone YOLOV5 的源码:github.com/ultralytics…
训练过程没对代码做太多改动,直接用的 repo 中的训练脚本 train.py
python .\train.py --data .\data\cat.yaml --weights .\weights\yolov5s.pt --img 160 --epochs 3000
复制代码
为了加速训练速度,可以适当缩小图片的尺寸,本次实验采用的图片尺寸是 (160, 160)
模型测试
模型训练完毕后,用未标注的图片验证下模型是否训对了。测试脚本同样参考YOLOv5 decect.py
模型前期训练效果图
模型前期验证效果图
能够正确检出自家猫主子,模型应该是训的没问题
模型导出
为了更好的适配部署,我们将 PyTorch 模型转换成 ONNX 模型。模型转换同样可以直接采用 YOLOv5 的转换脚本 export.py
推理部署
推理框架安装
部署框架选择了商汤的 openppl,能够在 x86/CUDA 架构上高效运行,且有 python 接口,能够方便的搭建部署工程:github.com/openppl-pub…
下载 & 编译 openppl:
git clone https://github.com/openppl-public/ppl.nn.git
cd ppl.nn
./build.sh -DHPCC_USE_X86_64=ON -DHPCC_USE_OPENMP=ON -DPPLNN_ENABLE_PYTHON_API=ON
复制代码
编译后,在 ./pplnn-build/install/lib
路径下会生成 pyppl
包,设置下 PYTHONPATH
之后就可以用了
模型推理
按照 repo 里的 python example ,我写了一个 ModelRunner,用来给定输入跑出网络输出:
def RegisterEngines():
engines = []
# create x86 engine
x86_options = pplnn.X86EngineOptions()
x86_engine = pplnn.X86EngineFactory.Create(x86_options)
engines.append(pplnn.Engine(x86_engine))
return engines
class ModelRunner(object):
def __init__(self, model_path):
self.__initialize(model_path)
def __initialize(self, model_path):
# register engines
engines = RegisterEngines()
if len(engines) == 0:
raise Exception('failed to register engines')
# create runtime builder
runtime_builder = pplnn.OnnxRuntimeBuilderFactory.CreateFromFile(model_path, engines)
if not runtime_builder:
raise Exception('failed to create runtime builder from file: %s' % (model_path))
# create runtime
self.runtime = runtime_builder.CreateRuntime()
if not self.runtime:
raise Exception('failed to create runtime')
def get_input_tensor_shape(self):
return self.runtime.GetInputTensor(0).GetShape().GetDims()
def forward(self, input):
if not self.runtime:
raise Exception('runtime not created')
# get input tensor info
tensor = self.runtime.GetInputTensor(0)
shape = tensor.GetShape()
np_data_type = g_pplnntype2numpytype[shape.GetDataType()]
dims = shape.GetDims()
# feed input data
input = np.ascontiguousarray(input) # use contiguousarray to avoid calc error
status = tensor.ConvertFromHost(input)
if status != pplcommon.RC_SUCCESS:
raise Exception('failed to set input data')
# start to inference
status = self.runtime.Run()
if status != pplcommon.RC_SUCCESS:
raise Exception('failed to run')
# wait for inference finished
status = self.runtime.Sync()
if status != pplcommon.RC_SUCCESS:
raise Exception('failed to sync')
# get output data
out_datas = {}
for i in range(self.runtime.GetOutputCount()):
# get output tensor info
tensor = self.runtime.GetOutputTensor(i)
tensor_name = tensor.GetName()
# fetch output data
tensor_data = tensor.ConvertToHost()
if not tensor_data:
raise Exception('failed to get output ' + tensor_name)
out_data = np.array(tensor_data, copy=False)
out_datas[tensor_name] = copy.deepcopy(out_data)
return out_datas
复制代码
数据预处理与后处理
数据预处理的代码比较简单:
# preprocess
img = cv2.resize(img, (self.input_img_w, self.input_img_h)) # resize
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # BGR -> RGB
img = img.transpose(2, 0, 1) # HWC -> CHW
img = img.astype(dtype = np.float32) # uint8 -> fp32
img /= 255 # normalize
img = np.expand_dims(img, axis=0) # add batch dimension
复制代码
由于输入 shape 为 (160, 160),且训练的所有图片 shape 一致,这里就没用 letterbox。
关于后处理,标准的 YOLOV5 有三个输出,需要结合不同层级的 anchor 计算输出的 box 位置。不过 repo 导出的 ONNX 模型已经替我们完成了这一部分工作,我们只需要对结果中的 box_score 和 class_score 做筛选,并进行 nms 就好啦。这里就不贴出后处理的代码了
制作琴键
琴键声音
琴键声音我这里用 python 的 mingus 库来解决的,安装非常简单:
pip3 install mingus
pip3 install fluidsynth
复制代码
播放音符也非常简单,就几行代码:
from mingus.midi import fluidsynth
fluidsynth.init('/usr/share/sounds/sf2/FluidR3_GM.sf2', 'alsa') # for ubuntu
fluidsynth.play_Note(64, 0, 100) # 标准音 a1
复制代码
键盘显示
键盘的图形用 OpenCV 来制作
钢琴键盘上一共有四种键 —— 三种白键和一种黑键,这里用 Enum 来描述这四种键,并对不同种类的键进行不同的图形处理,最终抽象成类 PianoKey
:
class KeyType(Enum):
WHITE_KEY = 0,
WHITE_KEY_LEFT = 1,
WHITE_KEY_RIGHT = 2,
BLACK_KEY = 3
复制代码
PianoKey
中有一个 play(self, position)
接口,一旦 position 落在了琴键的范围内,就认定琴键被按下,发出琴键对应的声音。
放一张琴键的效果图(黄色为被按下的琴键,程序员配色):
最终效果
把上述模块组合起来,就得到我们的 “猫头钢琴” 啦
贴一张最终的效果图,红框为检测到的猫头,红点为检测框的中心店,用这个点来触碰琴键:
视频 demo
视频 demo:www.bilibili.com/video/BV17h…
github repo 链接:github.com/ZichenTian/…