前言
早在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圈选的轮廓效果如下:
生成的蒙版如下(带透明通道):
之后要把这些蒙版压成视频,不能直接在网络上传输蒙版,太大了,那么压成什么格式视频呢?因为要保存透明通道,所以选择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端播放,手机会卡死!!!