Web 端实时防挡脸弹幕(基于机器学习)

668 阅读6分钟

建议阅读原文,体验更好

防档弹幕,即大量弹幕飘过,但不会遮挡视频画面中的人物,看起来像是从人物背后飘过去的。

前言


image.png
mediapipe demo展示

机器学习已经火了好几年了,但很多人的直觉仍然是前端实现不了这些能力,期望本文能打破一些“思维禁区”。

# 主流实现原理介绍

# 点播

  1. up 上传视频
  2. 服务器后台计算提取视频画面中的人像区域,转换成 svg 存储
  3. 客户端播放视频的同时,从服务器下载 svg 与弹幕合成,人像区域不显示弹幕

# 直播

  1. 主播推流时,实时(主播设备)从画面提取人像区域,转换成 svg
  2. 将 svg 数据合并到视频流中(SEI),推流至服务器
  3. 客户端播放视频同时,从视频流中(SEI)解析出 svg
  4. 将 svg 与弹幕合成,人像区域不显示弹幕

# 本文实现方案

  1. 客户端播放视频同时,实时从画面提取人像区域信息
  2. 将人像区域信息导出成图片,与弹幕合成,人像区域不显示弹幕

# 实现原理

  1. 采用机器学习开源库从视频画面实时提取人像轮廓,如Body Segmentation
  2. 将人像轮廓转导出为图片,设置弹幕层的 mask-image

image.png

# 面临的问题

由官方demo转换成工程实践,并非调一下API就行了,最大的挑战就是——性能

一开始我也不敢相信实时计算,能将 CPU 占用优化到 5% 左右(2020 M1 Macbook)
甚至低于主流实现中,单在客户端上的性能损耗(解析 svg,与弹幕合成)


------------------------------ 正片开始,以下是调优过程 ------------------------------


选择机器学习模型


BodyPix X
精确度太差,有很明显的弹幕与面部重合现象

BlazePose X
精确度跟后面的MediaPipe SelfieSegmentation差不多,因为提供了肢体点位信息,CPU 占用相对高出 15% 左右

MediaPipe SelfieSegmentation
精确度优秀,只提供了人像区域信息,性能取胜


参考官方实现 ,未做优化的情况下 CPU 占用 70% 左右

const canvas = document.createElement('canvas')
canvas.width = videoEl.videoWidth
canvas.height = videoEl.videoHeight
async function detect (): Promise<void> {
  const segmentation = await segmenter.segmentPeople(videoEl)
  const foregroundColor = { r: 0, g: 0, b: 0, a: 0 }
  const backgroundColor = { r: 0, g: 0, b: 0, a: 255 }

  const mask = await toBinaryMask(segmentation, foregroundColor, backgroundColor)

  await drawMask(canvas, canvas, mask, 1, 9)
  
  handler(canvas.toDataURL('image/png', 0))

  window.setTimeout(detect, 33)
}

detect().catch(console.error)

降低提取频率,平衡 性能-体验


一般视频 30FPS,尝试弹幕遮罩(后称 Mask)刷新频率降为 15FPS,体验上还能接受
(再低就影响体验了)

window.setTimeout(detect, 66) 

此时,CPU 占用 50% 左右

解决性能瓶颈代码


分析火焰图可发现,性能瓶颈在 toBinaryMasktoDataURL

# 重写toBinaryMask

分析源码,结合打印segmentation的信息,发现segmentation.mask.toCanvasImageSource可获取原始ImageBitmap对象,即是模型提取出来的信息。
尝试自行实现将ImageBitmap转换成 Mask 的能力,替换开源库提供的默认实现。

实现原理

async function detect (): Promise<void> {
  const segmentation = await segmenter.segmentPeople(videoEl)

  context.clearRect(0, 0, canvas.width, canvas.height)
  
  context.drawImage(
    
    await segmentation[0].mask.toCanvasImageSource(),
    0, 0,
    canvas.width, canvas.height
  )
  
  context.globalCompositeOperation = 'source-out'
  
  context.fillRect(0, 0, canvas.width, canvas.height)
  
  handler(canvas.toDataURL('image/png', 0)) 

  window.setTimeout(detect, 66)
}

第 2、3 步相当于给人像区域外的内容填充黑色(反向填充ImageBitmap),是为了配合css(mask-image), 不然只有当弹幕飘到人像区域才可见(与目标效果正好相反)。
globalCompositeOperation MDN

此时,CPU 占用 33% 左右

# 多线程优化

只剩下toDataURL这个耗时操作了,本以为toDataURL是浏览器内部实现,无法再进行优化了。
虽没有替换实现,但可使用 OffscreenCanvas + Worker,将耗时任务转移到 Worker 中去, 避免占用主线程,就不会影响用户体验了。
并且ImageBitmap实现了Transferable接口,可被转移所有权,跨 Worker 传递也没有性能损耗




const reader = new FileReaderSync()

offsecreenCvsEl.convertToBlob({
  type: 'image/png',
  quality: 0
}).then((blob) => {
  const dataURL = reader.readAsDataURL(blob)
  self.postMessage({
    msgType: 'mask',
    val: dataURL
  })
}).catch(console.error)

可以看到两个耗时的操作消失了
此时,CPU 占用 15% 左右

降低分辨率


继续分析,上图重新计算样式(紫色部分)耗时约 3ms
Demo 足够简单很容易推测到是这行代码导致的,发现 imgStr 大概 100kb 左右(视频分辨率 1280x720)。

danmakuContainer.style.webkitMaskImage = `url(${imgStr})`

优化实现


danmakuContainer.style.webkitMaskSize = '100%, 100%' 

const cvsEl = document.createElement('canvas')

const approWidth = 300
if (videoEl.videoWidth > approWidth) {
  cvsEl.width = approWidth
  
  cvsEl.height = videoEl.videoHeight * approWidth / videoEl.videoWidth
}
const ctx = cvsEl.getContext('2d')

async function detect (): Promise<void> {
  
  ctx.drawImage(videoEl, 0, 0, cvsEl.width, cvsEl.height)
  
  const segmention = await segmenter.segmentPeople(cvsEl)
  
}

优化后,导出的 imgStr 大概 12kb,重新计算样式耗时约 0.5ms。
此时,CPU 占用 5% 左右

启动条件优化


虽然提取 Mask 整个过程的 CPU 占用已优化到可喜程度。
当在画面没人的时候,或没有弹幕时候,可以停止计算,实现 0 CPU 占用。

无弹幕判断比较简单(比如 10s 内收超过两条弹幕则启动计算),也不在该 SDK 实现范围,略过

判定画面是否有人
第一步中为了高性能,选择的模型只有ImageBitmap,并没有提供肢体点位信息。
所以只能使用ImageBitmap来判断是否有人。
画面中的人物大概率是画面中间且是连续的区域,所以从中间开始往左右反复横跳检查像素值,碰到一个alpha通道为零的像素点就表示画面有人。


const dataURL = reader.readAsDataURL(blob)


const hasBody = checkHasBody(ctx)

if (!hasBody) {
  
}

function checkHasBody (ctx): boolean {
  const imgData = ctx.getImageData(0, 0, imgW, imgH)
  
  
  const img = new Uint32Array(imgData.data.buffer)
  const len = img.length
  
  const p = Math.floor(len / 2)
  const maxDistance = Math.floor(len / 2)
  let distance = 0
  while (true) {
    if (distance > maxDistance) return false
    
    
    if (img[p + distance] === 0) return true
    if (img[p - distance] === 0) return true

    
    
    distance += 10
  }
}

画面无人时,CPU 占用接近 0%

发布构建优化


依赖包的体积较大,构建出的 bundle 体积:684.75 KiB / gzip: 125.83 KiB

所以,可以进行异步加载SDK,提升页面加载性能。

  1. 分别打包一个 loader,一个主体
  2. 由业务方 import loader,首次启用时异步加载主体

这个两步前端工程已经非常成熟了,略过细节。

总结


# 过程

  • 选择高性能模型后,初始状态 CPU 70%
  • 降低 Mask 刷新频率(15FPS),CPU 50%
  • 重写开源库实现(toBinaryMask),CPU 33%
  • 多线程优化,CPU 15%
  • 降低分辨率,CPU 5%
  • 判断画面是否有人,无人时 CPU 接近 0%

CPU 数值指主线程占用

# 注意事项

  • 兼容性:Chrome 79及以上,不支持 Firefox、Safari。因为使用了OffsccenCanvas
  • 不应创建多个或多次创建segmenter实例(bodySegmentation.createSegmenter),如需复用请保存实例引用,因为:

# 经验

  • 结合业务场景特征进行分析优化,往往有更多的思路和途径
  • 优化完成之后,提取并应用 Mask 关键计算量在 GPU (30%左右),而不是 CPU
  • 性能优化需要业务场景分析,防档弹幕场景可以使用低分辨率、低刷新率的 mask-image,能大幅减少计算量
  • 该方案其他应用场景:
    • 替换/模糊人物背景
    • 人像马赛克
    • 人像抠图
    • 卡通头套,虚拟饰品,如猫耳朵、兔耳朵、带花、戴眼镜什么的(换一个模型,略改)
  • 关注 WebNNWebGPU 的进展和应用,端智能将覆盖更多的应用场景