基于姿态估计的运动计数APP开发

算法模型应用与实际生活,在原始模型上针对具体任务做了相应的改动,真的是学以致用;而且很好玩,真的让人感觉到了科研的乐趣!所以专门整理收藏

前言:

    看着自己日渐发福的身材,回想当年的英姿煞爽,感慨颇多。作为一个有羞耻心的程序猿,我决定开始减肥。考察了数十项减肥项目,我选择了仰卧起坐。因为它场地限制小,时间限制短,不剧烈,不伤身,最关键的是能够一边看综艺,一边锻炼,简直妙不可言!试行了几天之后发现有一个比较棘手的问题,做着做着,就忘了做了多少个,一脸懵逼,万般无奈,于是便有了这个运动计数APP的灵感。不用太花哨的功能,最最简单的一点,能够对我的仰卧起坐进行计数,并显示在APP的界面上,到了10,20这种(%10==0)的关键节点上面会有林志玲小姐姐的加油鼓励!其实界面的编码并不难,难的是算法的开发。。。

总体思路:

    计算机视觉中有一个应用分支叫做姿态估计,能够以关键点的方式来估计出一个/多个人的姿态信息。如下图所示:

为了算法开发减低难度,这个APP只需要进行单人的姿态检测即可。如果姿态检测的效果好,那么理论上就能完成仰卧起坐的计数。计数规则很简单:通过观察仰卧起坐的姿势,只要代表头部的关键点与代表膝盖的关键点之间的欧氏距离出现周期性的变化,即可认为是在做仰卧起坐,并且可以方便的统计出频率与周期,从而进行计数。所以剩下的问题就是如何选择设计一个模型,能够又快又好的进行姿态检测了。毕竟要在手机上实现,没有速度,一切都是扯淡!

拍拍脑门的第一个方案:

    由于我之前做的比较多的是目标检测,没有做过姿态估计,对该领域不太了解,但是直觉却告诉我这很简单。因为做的是单人的姿态检测,并且假设我需要预测13个关键点。如下图所示:

 

那么理论上我只要回归13*2的坐标即可。一个很自然的思路就是用轻量级的分类网络,将最后的分类向量修改为坐标回归向量即可。有了这个一拍脑门的想法之后,分分钟就改好了shufflenetV2的网络,如下图所示:

 

此时,离真正的训练还有一步之遥,那就是训练数据。由于姿态估计是一个比较大众的领域,已经有很多开源的数据集,恰好我的笔记本上有COCO数据集,里面有人体姿态估计的标签,于是就选择了COCO作为训练集。由于COCO的姿态检测包含多人,我修改的网络只针对一个人。于是我把COCO中有姿态估计的图片进行了过滤,只保留了单人的姿态估计图片。大概有2万多张,还好,数目不算少。

准备好网络与数据集之后,启动了我可怜的笔记本,那风扇的轰鸣声让我觉得异常踏实。经过3天2夜的鏖战,竟然发现loss无法下降!此时直觉告诉我用Global Pooling的方式数据损失太大,用于分类还可以,用于回归可能不行。于是我将最后的GlobalingPooling去掉,直接使用两个FC层进行预测。如下图所示:

 

又经过三天两夜的咆哮,loss还算比较正正常。当我满心欢喜的准备做测试的时候,发现效果苦不堪言。基本无法使用。。。。看来姿态估计没有我想的那么简单,需要从长计议。

第二个方案:

经过狂补各种姿态估计的知识后,发现直接回归坐标确实已经很少使用。取而代之的是使用heatmap来输出关键点的位置。这和anchor free的目标检测中的centness之类的做法差不多,即通过查找heatmap中响应值最大的点来确定关键点的坐标。如下图所示(只显示部分heatmap):

 

思考了一下原因,直接回归坐标,通常会将最后的featuremap下采样到很小,这样才能够实现全局的回归,但是关键点预测这种任务对位置信息非常敏感,过小的特征会极大的丢失空间信息,因而导致预测位置非常不准。而heatmap方式一般要求最后的特征图比较大,通常是输入图片的1/2或者1/4,那么就非常适合做一些空间相关的任务。其实如果人为的将特征图压缩的很小,heatmap的方式也一样不太准。有了上面的思考,便有了第二个方案,就是将shufflenet最终输出的7*7的特征图进行上采样到13*56*56大小(考虑到最终的应用以及场景,56*56足够实现仰卧起坐动作的识别),13表示的是13个关键点。然后输出的特征经过sigmoid激活之后便得到了13*56*56的heatmaps。这里多提两点,就是heatmap标签的设计和loss的平衡问题。先说说标签的设计,如果只是简单的将标签转化成一个one_hot的heatmap,效果不会太好。因为标签点附件的点实际上对于网络来说提取的特征是类似的,那么如果强行把不是标签附近的点设置为0,表现不会很好,一般会用高斯分布来制作标签heatmap,如下图所示:

 

另外要说的就是loss的平衡了,上面的标签heatmap大家也看到了,无论是one-hot的heatmap还是高斯分布的heatmap,大部分的点都是负样本点,直接使用MSE而不加以区分,网络基本上会训练出一个输出全是0的heatmap。主要原因就是训练的梯度被负样本压制,正样本的梯度实在太小。因此需要做一个分区。我这里把正负样本的比重设置为10:1

又开始3天2夜的咆哮,网络总算能够work了,对于coco上训练过的数据,效果不错。如下图所示:

 

但是对于从百度拉下来的仰卧起坐图片,效果有点欠缺,分析了一下原因,COCO的单人姿态图片中,都是一些很生活化的场景,很少有人躺在地上的情形。因此需要对数据做一些增强或者增加一些特别的仰卧起坐的图片。

总结:

目前的网络只是能够work,距离真正的落地还有一段距离。主要表现在对仰卧起坐的泛化性上比较差。后期的计划主要包括两方面:一方面是基于数据。例如增加一些姿态估计的数据集,MPII等,或者是自己收集一些仰卧起坐的视频,人工标注,另外还可以对数据增强进行完善。另外一方面是考虑网络的设计以及loss的求解上面。网络是否可以增加一些shortcut,是否可以接借鉴一些成熟的姿态检测网络,以及对于最终的heatmap是否可以使用softargmax转换成坐标回归等。这些都留在下一期的内容中。

                                        二

1、先展示一下当前的效果

从keep上扒了一段仰卧起坐的视频教程进行计数测试:

(CSDN放不了视频,有兴趣的下方评论区留言)

2、回顾:

在上一期的内容中([开源]基于姿态估计的运动计数APP开发(一)),通过使用shufflenet轻量级网络+上采样输出关键点的heatmap已经可以在coco数据集中进行训练,并能够进行关键点识别。但是也存在一个问题,就是针对仰卧起坐这种动作,识别准确率非常低。通过分析原因,主要有两方面。一是开源的数据集中人的姿态是一些比较生活化的姿态,很少有仰卧起坐之类的姿态。另外一方面是网络本身比较小,提取特征能力有限,且为了在移动端实现实时检测,输出分辨率被限制在224*224,这些都会限制精度。本期主要是基于这些问题展开一些优化,初步实现一个可以进行实时计数的demo。

3、重新思考数据:

前面已经分析了coco以及mpii这些数据集对于我们训练仰卧起坐计数器这样一个APP来说并不是非常合适。并且对于这个任务来说,其实并不需要识别那么多的关键点。只要识别两个关键点就可以了,一个是头部关键点,一个是膝盖关键点。这样的话既不会影响我们最终计数APP的功能,又使得网络的任务减轻,可以专注与识别这两个关键点,从而提升精度。如下图所示。

 

由于没有现成的仰卧起坐数据集,只能自己动手,丰衣足食。好在对于仰卧起坐这样常规的运动,网上还是有很多相关资源的。这里我采用下载视频和图片两种方式。先从网上搜索“仰卧起坐”的视频,下载了10个左右的视频片段,然后通过抽帧的方式,从每个视频中抽取一部分帧作为训练用的数据。如下图所示为从视频中抽取的关键帧。

 

仅仅使用视频中抽取的帧会有一个比较严重的问题,就是背景过于单一,很容易造成过拟合。于是我从网上进行图片搜索,得到一分部背景较为丰富的图片,如下图所示:

 

收集完数据,就是进行标注了,这里我为了方便,自己开发了一款关键点标注工具,毕竟自己开发的,用着顺手。鼠标左键进行标注,右键取消上一次标注。不得不说,用python+qt开发一些基于UI的工具非常方便!与C++相比,解放了太多的生产力!

 

4、解决过拟合:

    通过前面搜集的大约1K张图片,训练完之后会发现泛化性能并不好,很容易出现误识别。其实不难发现,由于数据量太少,网络已经出现了过拟合,训练到最后的loss非常小。解决过拟合最好的办法是增加数据量,但是时间有限,真的不想再去收集,标注数据,简直是浪费青春啊。于是就得考虑用一些数据增强的方法。我之前已经使用了一些光照增强的方法,例如随机改变亮度,随机调整HSV,这里主要增加一些几何上的变换。由于需要修改标签值,因此会麻烦一些。这里我主要考虑crop,padding,以及flip。

 

用来上述数据增强方法之后,效果显著改善了一些,过拟合没有那么严重,但是会出现一些错误的召回,会把一些书包或者衣服之类的当作关键点。那么主要原始还是训练数据的背景不够丰富,这里采用mixup的方法,从coco数据集中挑选一部分没有人的图片作为背景,随机的与训练图片进行重叠,从而有利于解决这种问题。

 

最终经过这些数据增强之后效果还不错。下面是相关代码,crop和padding合在一起实现。

   

class KPRandomPadCrop(object):
    def __init__(self, ratio=0.25, pad_value=[128, 128, 128]):
        assert (ratio > 0 and ratio <= 1)
        self.ratio = ratio
        self.pad_value = pad_value
 
    def __call__(self, image, labels=None):
        if random.randint(0,1):
            h, w = image.shape[:2]
            top_offset = int(h * random.uniform(0, self.ratio))
            bottom_offset = int(h * random.uniform(0, self.ratio))
            left_offset = int(w * random.uniform(0, self.ratio))
            right_offset = int(w * random.uniform(0, self.ratio))
            # pad
            if random.randint(0,1):
                image = cv2.copyMakeBorder(image, top_offset, bottom_offset, left_offset, right_offset, cv2.BORDER_CONSTANT, value=self.pad_value)
                if labels is not None and len(labels) > 0:
                    labels[:, 0] = (labels[:, 0] * w + left_offset) / (w + left_offset + right_offset)
                    labels[:, 1] = (labels[:, 1] * h + top_offset) / (h + top_offset + bottom_offset)
            # crop
            else:
                image = image[top_offset:h - bottom_offset, left_offset:w-right_offset]
                if labels is not None and len(labels) > 0:
                    labels[:, 0] = (labels[:, 0] * w - left_offset) / (w - left_offset - right_offset)
                    labels[:, 1] = (labels[:, 1] * h - top_offset) / (h - top_offset - bottom_offset)
        return image, labels
                
class KPRandomHorizontalFlip(object):
    def __init__(self):
        pass
 
    def __call__(self, image, labels=None):
        if random.randint(0, 1):
            image = cv2.flip(image, 1)
            h, w = image.shape[:2]
            if labels is not None and len(labels) > 0:
                labels[:, 0] = 1.0 - labels[:, 0]
        return image, labels
        
  
class KPRandomNegMixUp(object):
    def __init__(self, ratio=0.5, neg_dir='./coco_neg'):
        self.ratio = ratio
        self.neg_dir = neg_dir
        self.neg_images = []
        files = os.listdir(self.neg_dir)
        for file in files:
            if str(file).endswith('.jpg') or str(file).endswith('.png'):
                self.neg_images.append(str(file))
 
    def __call__(self, image, labels):
        if random.randint(0, 1):
            h, w = image.shape[:2]
            neg_name = random.choice(self.neg_images)
            neg_path = self.neg_dir + '/' + neg_name
            neg_img = cv2.imread(neg_path)
            neg_img = cv2.resize(neg_img, (w, h)).astype(np.float32)
            neg_alpha = random.uniform(0, self.ratio)
            ori_alpha = 1 - neg_alpha
            gamma = 0
            img_add = cv2.addWeighted(image, ori_alpha, neg_img, neg_alpha, gamma)
            return image, labels
        else:
            return image, labels

5、在线难例挖掘

    通过上面的数据增强,训练出来的模型已经具备了一定的能力,但是通过大量的测试图片发现网络对于膝盖和头部的检测能力是不一样的。膝盖的检测较为稳定,而头部的检测经常会出现错误。分析原因,可能在于头部的变化比膝盖大的多,头部可能是正对相机,背对相机,也有可能被手臂挡住,因为网络很难学习到真正的头部特征。这里可以通过两种方法来改善,一种是简单的通过loss权重来赋予头部更大的权值,使得头部的梯度信息比膝盖的大,强迫网络更关注头部信息,还有一种就是用到了online hard keypoint mining。这是我在查看旷世的cpn人体姿态估计网络时候看到的,作者有视频进行介绍。其实实现起来很简单,就是对不同的关键点分别求loss,然后对loss排序,只返回一定比例的loss求梯度,作者给出的比例是0.5,即返回一半的关键点loss来求梯度。

 

6、总结

    这个阶段主要是对网络精度的提升,通过精简关键点数目,重新收集,标注数据,增加padding,crop,flip数据增强,并引入mixup和在线难例挖掘等措施,来逐步提升网络泛化性能。并实现了一个python的demo(见文章开头),下一期主要是将该demo的功能实现为APP。
————————————————

                                                        三

1、前言:

在上一期中[开源]基于姿态估计的运动计数APP开发(二)中,我们已经完成了仰卧起坐算法的开发和windows的demo开发。本期主要是将该算法一直到android平台上面,实现一个android手机上可以使用的APP。下面的视频是我在西湖边进行的测试,在背景比较干净的情况下,效果还不错哦。【获取APP源码请留言,或者添加我的微信,15158106211,备注“仰卧起坐APP”,让我们一起学习一起进步。】

(CSDN放不了视频,请见谅)

2、模型改进

 

有的朋友已经发现,上一期的demo中,只有两个关键点,头部和膝盖,而这次的APP有三个关键点,分别是头部,腰部和膝盖。当只有两个关键点的时候,模型很容易出现误识别,会把关键点定位在一些其他东西上面,很少会考虑这是不是属于一个人的特征。而使用了三个关键点之后,一方面更加有利于判断姿势,另一方面也相对于增加了一个监督信号,并且三个关键点都位于人身上,使得模型更容易理解和学习。因此在性能上有一定的提升,主要是降低了误识别率。

3、APP框架

APP主要要Activiry类,两个SurfaceView类,一个Alg类,一个Camera类组成。Alg类主要负责调用算法进行推理,并返回结果。这里我已经将pytorch训练好的模型转换成了NCNN模型,因此实际上是调用的NCNN库的推理功能。Camera类主要负责摄像头的打开和关闭,以及进行预览回调。第一个SurfaceView(DisplayView)主要用于摄像头预览的展示。第二个SurfaceView(CustomView)主要用于绘制一些关键点信息,计数统计信息等。Activity就是最上层的一个管理类,负责管理整个APP,包括创建按钮,创建SurfaceView,创建Alg类,创建Camera类等。

 

 

3、主要类源码

3.1、Activity类核心源码

  

public class MainActivity extends Activity implements Camera.PreviewCallback, AlgCallBack{
    private DisplayView  mViewDisplay;
    private CustomView   mViewCustom;
    private Button       mBtnCameraOp;
    private Button       mBtnCameraChange;
    private CameraUtil   mCameraUtil;
    private AlgUtil      mAlgUtil;
    private int          mFrameCount = 0;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Log.i("zhengxing", "MainActivity::onCreate");
        super.onCreate(savedInstanceState);
        this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
        this.requestWindowFeature(Window.FEATURE_NO_TITLE);
        this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
        setContentView(R.layout.main);
        Log.i("zhengxing", "MainActivity::onCreate set basic info finished");
        
        // 创建两个SurfaceView
        mViewDisplay = (DisplayView)this.findViewById(R.id.display_view);
        mViewCustom = (CustomView)this.findViewById(R.id.custom_view);
        Log.i("zhengxing", "MainActivity::onCreate create visual toolkits finished");
        
        //初始化camera类和alg类
        mCameraUtil = new CameraUtil(mViewDisplay, this);
        mAlgUtil = new AlgUtil(getAssets(), this);
        Log.i("zhengxing", "MainActivity::onCreate create camera util and alg util finished");
    }
 
    // 开始按钮
    public void onBtnStartClick(View view){
        Log.i("zhengxing", "MainActivity::onBtnStartClick");
        if (mCameraUtil.getCameraState() < 0){
            mCameraUtil.openCamera();
            Log.i("zhengxing", "MainActivity::onBtnStartClick the camera is closed, open it");
        }
    }
 
    //停止按钮
    public void onBtnStopClick(View view){
        Log.i("zhengxing", "MainActivity::onBtnStopClick");
        if (mCameraUtil.getCameraState() >= 0){
            mCameraUtil.closeCamera();
            Log.i("zhengxing", "MainActivity::onBtnStopClick the camera is open, close it");
        }
    }
    
    //算法回调函数,处理算法返回结果
    @Override
    public void onAlgRet(float[] ret) {
        float nAlgType = ret[0];
        float nClasss = ret[1];
        Log.i("zhengxing", "MainActivity::onAlgRet ret value:" +  ret[0] + ';' + ret[1]);
        mViewCustom.drawAlgRet(ret);
    }
 
    //预览回调函数,处理每一帧图片
    @Override
    public void onPreviewFrame(byte[] data, Camera camera) {
        mFrameCount ++;
        Log.i("zhengxing", "MainActivity::onPreviewFrame");
        Camera.Size size = camera.getParameters().getPreviewSize();
        mAlgUtil.addDataToQueue(data, size.width, size.height);
    }
    
}

3.2、Camera类核心源码

   

public class CameraUtil
{
    private Camera mCamera;
    private final int mCameraID;
    private final SurfaceView mViewDisplay;
    private final int mOrientation;
    private final Camera.PreviewCallback mPreviewCBack;
    
    public CameraUtil(SurfaceView displayView, Camera.PreviewCallback cameraCBack) {
        Log.i("zhengxing", "CameraUtil::CameraUtil");
        mCamera = null;
        mViewDisplay = displayView;
        mCameraID = Camera.CameraInfo.CAMERA_FACING_FRONT;
        mOrientation = 0;
        mPreviewCBack = cameraCBack;
    }
    
    //打开摄像头
    public void openCamera() {
        Log.i("zhengxing", "CameraUtil::openCamera");
        if(mCamera == null) {
            mCamera = Camera.open(mCameraID);
            Camera.Parameters parameters = mCamera.getParameters();
            //parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO);
            //mCamera.setParameters(parameters);
            mCamera.setDisplayOrientation(mOrientation);
            mCamera.setPreviewCallback(mPreviewCBack);
            try {
                mCamera.setPreviewDisplay(mViewDisplay.getHolder());
            } catch (IOException e) {
                e.printStackTrace();
            }
            mCamera.startPreview();
        }
    }
    
    //关闭摄像头
    public void closeCamera() {
        Log.i("zhengxing", "CameraUtil::closeCamera");
        if (mCamera != null) {
            mCamera.setPreviewCallback(null);
            mCamera.stopPreview();
            mCamera.release();
            mCamera = null;
        }
    }
}

3.3、Alg类核心源码

   

class AlgUtil implements Runnable {
    private boolean mThreadFlag;
    private final ArrayBlockingQueue mQueue;
    private final Alg mAlg;
    private final int mAlgThreads;
    private AlgCallBack mAlgCB;
    private final Thread mThread;
 
    public AlgUtil(AssetManager assertManager, AlgCallBack algCallBack) {
        Log.i("zhengxing", "AlgUtil::AlgUtil");
        mAlgCB = algCallBack;
        mAlgThreads = 1;
        mQueue = new ArrayBlockingQueue(3);
        mAlg = new Alg();
        mAlg.Init(assertManager);
        mThreadFlag = true;
        mThread = new Thread(this);
        mThread.start();
    }
  
    //将预览图片投递到队列中(由于算法处理可能会比较慢,并不是每一帧图片都做算法处理)
    public boolean addDataToQueue(byte [] bytes, int width, int height) {
        Log.i("zhengxing", "AlgUtil::addDataToQueue");
        Bitmap bmp = null;
        try {
            YuvImage image = new YuvImage(bytes, ImageFormat.NV21, width, height, null);
            if (image != null) {
                ByteArrayOutputStream stream = new ByteArrayOutputStream();
                image.compressToJpeg(new Rect(0, 0, width, height), 100, stream);
                bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size());
                stream.close();
            }
        } catch (Exception ex) {
        }
        Bitmap rgba = bmp.copy(Bitmap.Config.ARGB_8888, true);
        Bitmap imgSelect = Bitmap.createScaledBitmap(rgba, 312, 312, false);
        rgba.recycle();
        return mQueue.offer(imgSelect);
    }
 
    //返回算法推理结果给上层(Activity)
    public void setCallBack(AlgCallBack callBack) {
        Log.i("zhengxing", "AlgUtil::setCallBack");
        this.mAlgCB = callBack;
    }
 
    //线程体(所有算法推理都在线程中执行)
    @Override
    public void run() {
        Log.i("zhengxing", "AlgUtil::run");
        while (mThreadFlag) {
            try {
                Bitmap bmp = (Bitmap) mQueue.poll(1000, TimeUnit.MILLISECONDS);
                if (bmp != null) {
                    float[] x = mAlg.Run(bmp);
                    this.mAlgCB.onAlgRet(x);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4、总结

这次仰卧起坐APP开发前后经历了大约一个月的时间,都是在晚上或者周末进行的。虽然很累,但是我也学到了很多东西。从原来完全不了解姿态估计这个领域,以及严重低估关键点检测的难度,到后来慢慢学习,整理相关论文,解决一个又一个难点,对我自己来说是一次极大的提升,也是对自己兴趣爱好的执着。如果整个过程也让你也有一点点的收获,那将是对我极大的鼓励。【获取APP源码请留言或者添加我的微信,15158106211,备注“仰卧起坐APP”,让我们一起学习一起进步。】
————————————————
版权声明:本文为CSDN博主「DL-Practise」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/cjnewstar111/article/details/115446099

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值