因为热爱,所以坚持

2YO's BLOG


  • HOME
  • ARCHIVE
  • TAGS
  • CNBLOG
  • ABOUT
  • MUSIC
  • FRIENDS
  •   

© 2020 2YO

Theme Typography by Makito

Proudly published with Hexo

动手实现弹幕蒙版

Posted at 2020-03-20 Web 

前言

早在2018年,B站就推出了弹幕蒙版,即智能防挡弹幕功能,当时觉得很震撼,现在就想动手自己实现一下,花了两天时间,虽然最终效果不是很好,但是也学到了很多东西,这里将一些关键步骤记录下来。主要的思想就是先确定视频中人物轮廓并填充,生成mask,在前端渲染弹幕时将mask标记的位置改成透明。

标记人体轮廓

根据这里讲的,B站是用机器学习实现的,但是不确定使用的哪种模型,我使用最简单的方法(关键是复杂的也不会),即使用图像减去背景的方法确定前景,选用的视频是B站UP主泡芙喵-PuFF的稿件,选用这个视频的理由是视频的背景几乎是不发生改变的。

首先使用FFMPEG提取视频的5~30秒,之后将视频转换为序列帧,蒙版不需要有正常视频那么高的帧率,所以每秒抽20帧就可以了,帧率低了很容易出现蒙版和视频不同步,高了蒙版的总体尺寸又会很大,之后使用OpenCV圈选人体轮廓。具体可以看下面的代码,已经做了注释。

    # 图像增强
    frame = cv2.convertScaleAbs(frame, alpha=1.0, beta=10)
    # 修改尺寸为原来的1/2,减少计算时间
    frame = cv2.resize(frame, (frame.shape[1] // 2, frame.shape[0] // 2))
    # 转换为灰度图像
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    # 高斯模糊去噪声
    gray = cv2.GaussianBlur(gray, (21, 21), 10)


    if prev_frame is None:
        prev_frame = gray
        return None

    # 计算差值,prev_frame为视频第一帧
    frame_delta = cv2.absdiff(prev_frame, gray)

    # 二值化
    thresh = cv2.threshold(frame_delta, 8, 255, cv2.THRESH_BINARY)[1]

    g = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9))
    # 膨胀填洞
    thresh = cv2.dilate(thresh, g, iterations=5)
    # 腐蚀回原来尺寸
    thresh = cv2.erode(thresh, g, iterations=5)
    # 轮廓线追踪
    cnt, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # 去掉小轮廓
    better = []
    for c in cnt:
        if c.size > 200:
            better.append(c)

    # 拆分通道
    b, g, r = cv2.split(frame)
    a = np.ones(b.shape, dtype=b.dtype) * 255
    z = np.ones(b.shape, dtype=b.dtype) * 0

    # mask
    image_draw = cv2.merge((z, z, z, a))
    # png
    # image_merge = cv2.merge((b, g, r, a))
    # 填充轮廓
    cv2.drawContours(image_draw, better, -1, (0, 0, 0, 0), -1, cv2.LINE_AA)

OpenCV圈选的轮廓效果如下:
ti.gif

生成的蒙版如下(带透明通道):
0038.png

之后要把这些蒙版压成视频,不能直接在网络上传输蒙版,太大了,那么压成什么格式视频呢?因为要保存透明通道,所以选择WebM格式(其他视频格式是不保存透明通道的),最终要生成两个WebM视频,一个是蒙版(低帧率),一个是原视频(高帧率)。

其实一开始我想的是直接把透明通道加进原视频,在前端在去掉透明通道,不过在前端发现凡是透明通道不为255的像素会直接被渲染为完全透明,这些像素的RGB值全为0,不知道是我的功力不够姿势不对还是BUG。

前端渲染

首先创建一个video标签播放原视频,在video标签上创见一个canvas用来渲染弹幕,通过调整z-index实现。

再创建一个video标签,用来加载mask,注意添加crossOrigin="anonymous",否则canvas因为跨域会出问题。

之后设个定时器,每30ms运行一次,渲染弹幕,并根据mask改透明通道。代码如下:

function drawMask(ctx) {
    // ctx.drawImage(mask, 0, 0);
    mCtx.clearRect(0, 0, canvas.width, canvas.height);
    mCtx.drawImage(mask, 0, 0);

    let cData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    let mData = mCtx.getImageData(0, 0, canvas.width, canvas.height);

    let pxCount = mData.data.length / 4;
    for(let i = 0; i < pxCount; i++) {
        if(mData.data[(i << 2) + 3] == 0) {
            cData.data[(i << 2) + 3] = 0;
        }
    }
    ctx.putImageData(cData, 0, 0);

}

其中mCtx用来获取mask的image data,ctx为覆盖到video上的canvas的上下文,ctx在传递给drawMask方法之前已经绘制了弹幕。

之后要保证mask和video的同步,设一个定时器解决。

function alignMask() {
    return setInterval(function() {
        let vTime = video.currentTime;
        let mTime = mask.currentTime;
        if(Math.abs(vTime - mTime) > 0.5) {
            mask.currentTime = video.currentTime;
        }
    }, 500);
}

测试

成品就是下面那样了,没怎么写过js,不会优化,电脑CPU直接彪满,手机直接带不动。点击视频区域即可播放。代码上传到GitHUB了,不过写的很烂,大佬轻喷…

温馨提示:请用PC端播放,手机会卡死!!!





Share 

 Previous post: 昨天B站把AV换成了BV Next post: 基于块随机置乱的图像加解密 

© 2020 2YO

Theme Typography by Makito

Proudly published with Hexo