查看原文
其他

YOLOv5 + OpenPPL 开发 CatPiano,猫咪也可以弹钢琴啦!

田子宸 OpenPPL 2023-03-21

Tom 所弹奏的是李斯特的《匈牙利狂想曲第 2 号》

背景介绍
在网上看到过很多铲屎官发自家猫主子弹钢琴的视频。我们家虽然有一只高贵的猫主子,但是苦于没有钢琴,无法发挥猫主子的艺术天赋。
作为一个合格的铲屎官,委屈谁也不能委屈了自己的猫主子,没有条件也要想办法创造条件。于是我们决定给我们家猫主子做一个「电子」钢琴,让猫主子可以尽情追求他的音乐梦想。
虽然说是要做「电子」钢琴,但是苦于时间有限(要给猫主子赚罐头钱),没办法买机械零件做键盘了,于是打算做一个「丐版」的,来给猫主子整一个「虚拟」的琴键。
另外,我们都知道,学钢琴最大的阻碍其实只有两个 —— 左手和右手。因此,我们打算整点不一样的 —— 用头来弹钢琴。
于是我们决定给猫主子做一个「猫头钢琴」,用摄像头捕捉猫头的位置,不同位置对应不同的琴键,弹出不同的声音。这样一来解决了时间和金钱成本问题,二来又避免了猫主子学不会朝我们发脾气,还能让猫主子尽情享受音乐的快乐,一石三鸟,岂不美哉。
猫主子:就是这么糊弄劳资的吗

一、整体设计方案


要想实现猫头钢琴,要解决三个问题:

  • 怎么拍摄猫主子的视频
  • 怎么检测猫头的位置
  • 怎么弹琴与怎么显示

首先是视频的拍摄。我本来在双十一买了一个便宜的 USB 摄像头,但是因为发货慢到现在还没到货。所以只能拿手机录视频来先模拟摄像头采集图像。

猫头的检测是最麻烦的部分,我这里打算用深度学习来搞定。首先需要采集一些猫主子的图片,用标注工具标注下猫头。然后用 YOLOV5 模型训练一把,最后用商汤开源的 OpenPPL 来做模型推理部署。

至于如何弹琴,我打算用 mingus 来搞定。视频的处理与图形界面部分用 OpenCV 来做。


二、图像采集与标注


图像采集用手机就好,但是难点在于如何让猫主子乖乖配合。

这里我祭出了神器 —— 酸奶,溜着猫主子在屋子里转了好几圈,终于采到了一些合适的视频


猫主子:爷这么可爱不给爷喝一口的吗


采视频的时候手头的 windows 电脑没装 OpenCV,就找了个网站把视频转成图片了,当然用 OpenCV 的 VideoCapture 也能做到。

转成图片后,用了一个在  GitHub 上找的标注工具:https://github.com/tzutalin/labelImg

  • 众所周知,猫是流体,因此标注猫身子并不利于检测,因此最终选择标注猫头
  • 因为背景简单且任务单一,所以标注少量图片即可完成猫头检测
  • 标注时需要注意标注的格式,务必要采用 YOLOV5 模型可以识别的模式进行标注

我们最终标注了 430 张图片,训练集、验证集、测试集的数量分别是:400、20、10


三、YOLOV5 模型训练

模型训练

首先从 GitHub 上 clone YOLOV5 的源码:

https://github.com/ultralytics/yolov5

训练过程没对代码做太多改动,直接用的 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 接口,能够方便地搭建部署工程。
下载 & 编译 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(201)                                # 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(640100)                                   # 标准音 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 落在了琴键的范围内,就认定琴键被按下,发出琴键对应的声音。
放一张琴键的效果图:
黄色为被按下的琴键,程序员配色


六、最终效果

把上述模块组合起来,就得到我们的「猫头钢琴」啦!
红框为检测到的猫头,红点为检测框的中心点,用这个点来触碰琴键:




⭐️ 欢迎 star 

🔗 https://github.com/openppl-public

扫码添加小助手,加入 OpenPPL 大家庭!
QQ 群:627853444

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存