Flutter · Python AI 彈幕播放器來襲

AI智能彈幕(也稱蒙版彈幕):彈幕浮在視頻的上方卻永遠不會擋住人物。起源於嗶哩嗶哩的web端黑科技,然後分別實如今IOS和Android的app端,現在被用於短視頻、直播等媒體行業,用戶體驗提高顯著。前端

本文除了會使用Flutter新方案進行跨端實現,同時也會講解如何將一段任意視頻流使用opencv-python處理成蒙版數據源,達成從0到1的先後端AI體系。先來看看雙端最終運行效果吧:python

自行clone源碼打包:Zoe barrage
IPhone運行錄屏:點這裏
APP運行截圖:

實現流程目錄

  • Python後端:git

    • 依次提取視頻流的 關鍵幀 保存爲圖片
    • 將全部關鍵幀傳給 神經網絡模型 讓算法將圖片中非人物抹去,並保存圖片幀
    • 將只含有人物的圖片幀進行 像素色值轉換,獲得 灰度圖,最後再轉爲 黑白反色圖
    • 經過識別黑白反色圖的 輪廓座標 ,生成一份 時間:路徑 配置文件提供給前端
  • Flutter前端:github

    • 實現一個彈幕調度動畫組
    • 根據 配置文件 將彈幕外層容器 裁剪 爲一個恰好透出人物的漏洞形狀,也稱蒙版
    • 引入播放器,視頻流播放時,爲 關鍵幀 同步渲染其對應的蒙版形狀
  • 拓展:web

    • Web前端實現
    • 視頻點播與直播
    • 總結與優化

1. Python後端

1.1 提取關鍵幀
# config.py  ---  配置文件
import os
import cv2

VIDEO_NAME = 'source.mp4'     # 處理的視頻文件名
FACE_KEY = '*****'          # AI識別key
FACE_SECRET = '*****'       # AI密鑰

dirPath = os.path.dirname(os.path.abspath(__file__))
cap = cv2.VideoCapture(os.path.join(dirPath, VIDEO_NAME))
FPS = round(cap.get(cv2.CAP_PROP_FPS), 0)

# 進行識別的關鍵幀,FPS每上升30,關鍵幀間隔+1(保證flutter在重繪蒙版時的性能的一致性)
FRAME_CD = max(1, round(FPS / 30))

if cv2.CAP_PROP_FRAME_COUNT / FRAME_CD >= 900:
    raise Warning('經計算你的視頻關鍵幀已經超過了900,建議減小視頻時長或FPS幀率!')

在這份配置文件中,會先讀取視頻的幀率,30FPS的視頻會吧每一幀都當作關鍵幀進行處理,60FPS則會隔一幀處理一次,這樣是爲了保證Flutter在繪製蒙版的性能統一。
另外須要注意的是因爲演示DEMO爲徹底離線環境,視頻和最終蒙版文件都會被打包到APP,視頻文件不宜過大。算法

# frame.py  ---  視頻幀提取
import os
import shutil
import cv2
import config

dirPath = os.path.dirname(os.path.abspath(__file__))
images_path = dirPath + '/images'
cap = cv2.VideoCapture(os.path.join(dirPath, config.VIDEO_NAME))
count = 1

if os.path.exists(images_path):
    shutil.rmtree(images_path)
os.makedirs(images_path)

# 循環讀取視頻的每一幀
while True:
    ret, frame = cap.read()    
    if ret:
        if(count % config.FRAME_CD == 0):
            print('the number of frames:' + str(count))
            # 保存截取幀到本地
            cv2.imwrite(images_path + '/frame' + str(count) + '.jpg', frame)
        count += 1
        cv2.waitKey(0)
    else:
        print('frames were created successfully')
        break

cap.release()

這裏使用opencv提取視頻的關鍵幀圖片並保存在當前目錄images文件夾下。json

1.2 經過AI模型提取人物


提取圖像中人物的工做須要交給 卷積神經網絡 來完成,不一樣程度的訓練對圖像分類的準確率影響很大,而這也直接決定了最終的效果。大公司有算法團隊來專門訓練模型,咱們的DEMO使用FACE++提供的開放測試接口,準確率與其付費商用的無異,就是會被限流,失敗率高達80%,不事後面咱們能夠在代碼編寫中解決這個問題。canvas

# discern.py  ---  調用算法接口返回人體模型灰度圖
import os
import shutil
import base64
import re
import json
import threading
import requests
import config

dirPath = os.path.dirname(os.path.abspath(__file__))
clip_path = dirPath + '/clip'

if not os.path.exists(clip_path):
    os.makedirs(clip_path)

# 圖像識別類
class multiple_req:
    reqTimes = 0
    filename = None
    data = {
        'api_key': config.FACE_KEY,
        'api_secret': config.FACE_SECRET,
        'return_grayscale': 1
    }

    def __init__(self, filename):
        self.filename = filename

    def once_again(self):
        # 成功率大約10%,記錄一下被限流失敗的次數 :)
        self.reqTimes += 1
        print(self.filename +' fail times:' + str(self.reqTimes))
        return self.reqfaceplus()

    def reqfaceplus(self):
        abs_path_name = os.path.join(dirPath, 'images', self.filename)
        # 圖片以二進制提交
        files = {'image_file': open(abs_path_name, 'rb')}
        try:
            response = requests.post(
                'https://api-cn.faceplusplus.com/humanbodypp/v2/segment', data=self.data, files=files)
            res_data = json.loads(response.text)

            # 免費的API 很大機率被限流返回失敗,這裏遞歸調用,一直到這個圖片成功識別後返回
            if 'error_message' in res_data:
                return self.once_again()
            else:
                # 識別成功返回結果
                return res_data
        except requests.exceptions.RequestException as e:
            return self.once_again()

# 多線程並行函數
def thread_req(n):
    # 建立圖像識別類
    multiple_req_ins = multiple_req(filename=n)
    res = multiple_req_ins.reqfaceplus()
    # 返回結果爲base64編碼彩色圖、灰度圖
    img_data_color = base64.b64decode(res['body_image'])
    img_data = base64.b64decode(res['result'])

    with open(dirPath + '/clip/clip-color-' + n, 'wb') as f:
        # 保存彩色圖片
        f.write(img_data_color)
    with open(dirPath + '/clip/clip-' + n, 'wb') as f:
        # 保存灰度圖片
        f.write(img_data)
    print(n + ' clip saved.')

# 讀取以前準備好的全部視頻幀圖片進行識別
image_list = os.listdir(os.path.join(dirPath, 'images'))
image_list_sort = sorted(image_list, key=lambda name: int(re.sub(r'\D', '', name)))
has_cliped_list = os.listdir(clip_path)
for n in image_list_sort:
    if 'clip-' + n in has_cliped_list and 'clip-color-' + n in has_cliped_list:
        continue
    '''
    爲每幀圖片起一個單獨的線程來遞歸調用,達到並行效果。全部圖片被識別保存完畢後退出主進程,此過程須要幾分鐘。
    (這裏每一個線程中都是不斷地遞歸網絡請求、掛起等待、IO寫入,不佔用CPU)
    '''
    t = threading.Thread(target=thread_req, name=n, args=[n])
    t.start()

先讀取上文images目錄下全部關鍵幀列表,併爲每個關鍵幀圖片起一個線程,每一個線程裏建立一個識別類multiple_req的實例,在每一個實例裏會對當前傳入的文件進行不斷遞歸提交識別請求,一直到識別成功爲止(請你們自行申請一個免費KEY,我怕face++把個人號封了:)返回識別後的圖片保存在clip目錄下。
這個過程由於接口命中成功率很低,同一張圖片甚至會反覆識別幾十次,不過大部分時間都是在等待網絡傳輸和IO讀寫,因此能夠放心大膽地起幾百個線程CPU單核都跑不滿,等個幾分鐘所有結果返回腳本會自動退出。後端

1.2 像素轉換、生成輪廓路徑


咱們以前已經獲得了算法幫咱們提取後的人關鍵幀,接下來須要利用opencv來轉換像素:
人物關鍵幀 to 灰度圖 to 黑白反色圖 to 輪廓JSONapi

# translate.py  ---  openCV轉換灰度圖 & 輪廓斷定轉換座標JSON
import os
import json
import re
import shutil
import cv2
import config

dirPath = os.path.dirname(os.path.abspath(__file__))
clip_path = os.path.join(dirPath, 'mask')
cap = cv2.VideoCapture(os.path.join(dirPath, config.VIDEO_NAME))
frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) # 分辨率(寬)
frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) # 分辨率(高)
FPS = round(cap.get(cv2.CAP_PROP_FPS), 0)   # 視頻FPS
mask_cd = int(1000 / FPS * config.FRAME_CD)      # 初始幀時間
milli_seconds_plus = mask_cd  # 每次遞增一幀的增長時間
jsonTemp = {                          # 最後要存入的json配置
    'mask_cd': mask_cd,
    'frame_width': frame_width,
    'frame_height': frame_height
}

if os.path.exists(clip_path):
    shutil.rmtree(clip_path)
os.makedirs(clip_path)

# 輸出灰度圖與輪廓座標集合
def output_clip(filename):
    global mask_cd
    # 讀取原圖(這裏咱們原圖就已是灰度圖了)
    img = cv2.imread(os.path.join(dirPath, 'clip', filename))
    # 轉換成灰度圖(openCV必需要轉換一次才能餵給下一層)
    gray_in = cv2.cvtColor(img , cv2.COLOR_BGR2GRAY)
    # 反色變換,gray_in爲一個三維矩陣,表明着灰度圖的色值0~255,咱們將黑白對調
    gray = 255 - gray_in
    # 將灰度圖轉換爲純黑白圖,要麼是0要麼是255,沒有中間值
    _, binary = cv2.threshold(gray , 220 , 255 , cv2.THRESH_BINARY)
    # 保存黑白圖作參考
    cv2.imwrite(clip_path + '/invert-' + filename, binary)
    # 從黑白圖中識趣包圍圖形,造成輪廓數據
    contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
    # 解析輪廓數據存入緩存
    clip_list = []
    for item in contours:
        if item.size > 0:
            # 每一個輪廓是一個三維矩陣,shape爲(n, 1, 2) ,n爲構成這個面的座標數量,1沒什麼意義,2表明兩個座標x和y
            rows, _, __ = item.shape
            clip = []
            clip_list.append(clip)
            for i in range(rows):
                # 將np.ndarray轉爲list,否則後面JSON序列化解析不了
                clip.append(item[i, 0].tolist())

    millisecondsStr = str(mask_cd)
    # 將每個輪廓信息保存到key爲幀所對應時間的list
    jsonTemp[millisecondsStr] = clip_list

    print(filename + ' time(' + millisecondsStr +') data.')
    mask_cd += milli_seconds_plus

# 列舉剛纔算法返回的灰度圖
clipFrame = []
for name in os.listdir(os.path.join(dirPath, 'clip')):
    if not re.match(r'^clip-frame', name):
        continue
    clipFrame.append(name)

# 對文件名進行排序,按照幀順序輸出
clipFrameSort = sorted(clipFrame, key=lambda name: int(re.sub(r'\D', '', name)))
for name in clipFrameSort:
    output_clip(name)

# 所有座標提取完成後寫成json提供給flutter
jsObj = json.dumps(jsonTemp)

fileObject = open(os.path.join(dirPath, 'res.json'), 'w')
fileObject.write(jsObj)
fileObject.close()

print('calc done')

對每個人物關鍵幀進行計算,這裏就是一層層的像素操做。opencv會把圖片像素點生成numpy三維矩陣,計算速度快,操做起來便捷,好比咱們要把一個三維矩陣gray_in的灰度圖黑白像素對換,只須要gray = 255 - gray_in就能夠獲得一個新的矩陣而不須要用python語言來循環。
最後把計算出的幀的閉包圖形路徑轉換爲普通的多維數組類型並存入配置文件Map<key, value>key爲視頻的進度時間msvalue爲閉包路徑(就是圖中白色區域的包圍路徑,排除黑色人物區域),是一個二維數組,由於一幀裏會有n個閉包路徑組成。另外還要將視頻信息存入配置文件,其中frame_cd就是告訴flutter每間隔多少ms切換下一幀蒙版,視頻的寬高分辨率用於flutter初始化播放器自適應佈局。
具體JSON數據結構可見上方圖片。如今咱們已經獲得了一個res.json的配置文件,裏面包含了該視頻關鍵幀數據的裁剪座標集,接下來就用flutter去剪紙吧~

2. Flutter前端

2.1 彈幕調度動畫組

彈幕調度系統各端實現都大同小異,只是動畫庫的API方式區別。flutter裏使用SlideTransition能夠實現單條彈幕文字的動畫效果。

// core.dart --- 單條彈幕動畫
class Barrage extends StatefulWidget {
  final BarrageController barrageController;
  Barrage(this.barrageController, {Key key}) : super(key: key);

  @override
  _BarrageState createState() => _BarrageState();
}

class _BarrageState extends State<Barrage> with TickerProviderStateMixin {
  AnimationController _animationController;
  Animation<Offset> _offsetAnimation;
  _PlayPauseState _playPauseState;

  void _initAnimation() {
    final barrageController = widget.barrageController;

    _animationController = AnimationController(
      value: barrageController.value.scrollRate,
      duration: barrageController.duration,
      vsync: this,
    );

    _animationController.addListener(() {
      barrageController.setScrollRate(_animationController.value);
    });

    _offsetAnimation = Tween<Offset>(
      begin: const Offset(1.0, 0.0),
      end: const Offset(-1.0, 0.0),
    ).animate(_animationController);

    _playPauseState = _PlayPauseState(barrageController)
      ..init()
      ..addListener(() {
        _playPauseState.isPlaying ? _animationController.forward() : _animationController.stop(canceled: false);
      });

    if (_playPauseState.isPlaying) {
      _animationController.forward();
    }
  }

  void _disposeAnimation() {
    _animationController.dispose();
    _playPauseState.dispose();
  }

  @override
  void initState() {
    super.initState();
    _initAnimation();
  }

  @override
  void didUpdateWidget(Barrage oldWidget) {
    super.didUpdateWidget(oldWidget);
    _disposeAnimation();
    _initAnimation();
  }

  @override
  void deactivate() {
    _disposeAnimation();
    super.deactivate();
  }

  @override
  Widget build(BuildContext context) {
    return SlideTransition(
      position: _offsetAnimation,
      child: SizedBox(
        width: double.infinity,
        child: widget.barrageController.content,
      ),
    );
  }
}

當有海量彈幕來襲時,首先須要在播放器上層的Container容器中創造多個彈幕通道,並經過算法調度每個彈幕該出如今哪一個通道,初始化動畫,並在移除屏幕後dispose動畫並移除該條彈幕的Widget

在此基礎上,還須要設置一個時間的隨機性,讓每一條彈幕動畫的飄動時間有一個細微的差別,以此來優化總體彈幕流的視覺效果。關於彈幕調度詳細代碼可參考此項目core.dart文件。這裏便不作詳述。

2.2 裁剪蒙版容器
// main.dart (部分代碼) ---  初始化時引入配置文件
class Index extends StatefulWidget {
  //...
}
class IndexState extends State<Index> with WidgetsBindingObserver {
  //...
  Map cfg;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    Future<String> loadString = DefaultAssetBundle.of(context).loadString("py/res.json");

    loadString.then((String value) {
      setState(() {
        cfg = json.decode(value);
      });
    });
  }
  //...
  //...
}

正式環境確定是從網絡http長鏈接或者socket獲取實時數據,因爲咱們是離線演示DEMO,方便起見須要在初始化時加載剛纔後端產出蒙版路徑res.json打包到APP中。

// barrage.dart (部分代碼) ---  裁剪蒙版容器
class BarrageInit extends StatefulWidget {
  final Map cfg;
  const BarrageInit({Key key, this.cfg}) : super(key: key);

  @override
  BarrageInitState createState() => BarrageInitState();
}
class BarrageInitState extends State<BarrageInit> {
  //...
  BarrageWallController _controller;
  List curMaskData;

  //...
  @override
  Widget build(BuildContext context) {
    num scale = MediaQuery.of(context).size.width / widget.cfg['frame_width'];
    return ClipPath(
      clipper: curMaskData != null ? MaskPath(curMaskData, scale) : null,
      child: Container(
        color: Colors.transparent,
        child: _controller.buildView(),
      ),
    );
  }
}

class MaskPath extends CustomClipper<Path> {
  List<dynamic> curMaskData;
  num scale;

  MaskPath(this.curMaskData, this.scale);

  @override
  Path getClip(Size size) {
    var path = Path();
    curMaskData.forEach((maskEach) {
      for (var i = 0; i < maskEach.length; i++) {
        if (i == 0) {
          path.moveTo(maskEach[i][0] * scale, maskEach[i][1] * scale);
        } else {
          path.lineTo(maskEach[i][0] * scale, maskEach[i][1] * scale);
        }
      }
    });

    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) {
    return true;
  }
}

flutter實現蒙版效果的核心就在於CustomClipper類,它容許咱們經過Path對象來自定義座標繪製一個裁剪路徑(相似於canvas繪圖),咱們建立一個MaskPath,並在裏面繪製咱們剛纔加載的配置文件的那一幀,而後經過ClipPath包裹彈幕外層容器,就能夠實現一個剪裁蒙版的效果:

這裏加背景色爲了看的更清楚,後續咱們會把Container背景顏色設置爲Colors.transparent

2.3 視頻流蒙版同步

首先咱們須要引入一個播放器,考慮到IOS和Android插件的穩定性,咱們用flutter官方提供的播放器插件video_player

// video.dart (部分代碼) ---  監聽播放器進度重繪蒙版
class VedioBg extends StatefulWidget {
  //...
}
class VedioBgState extends State<VedioBg> {
  VideoPlayerController _controller;
  Future _initializeVideoPlayerFuture;
  bool _playing;
  num inMilliseconds = 0;
  Timer timer;

  //...

  @override
  void initState() {
    super.initState();
    int cd = widget.cfg['mask_cd'];
    _controller = VideoPlayerController.asset('py/source.mp4')
      ..setLooping(true)
      ..addListener(() {
        final bool isPlaying = _controller.value.isPlaying;
        final int nowMilliseconds = _controller.value.position.inMilliseconds;
        if ((inMilliseconds == 0 && nowMilliseconds > 0) || nowMilliseconds < inMilliseconds) {
          timer?.cancel();
          int stepsTime = (nowMilliseconds / cd).round() * cd;
          timer = Timer.periodic(Duration(milliseconds: cd), (timer) {
            stepsTime += cd;
            eventBus.fire(ChangeMaskEvent(stepsTime.toString()));
          });
        }
        inMilliseconds = nowMilliseconds;
        _playing = isPlaying;
      });

    _initializeVideoPlayerFuture = _controller.initialize().then((_) {});
    _controller.play();
  }

  //...
}

在video初始化後,經過addListener開始監聽播放進度。當播放進度改變時候,獲取當前的進度毫秒,去尋找與當前進度最接近的配置文件中的數據集stepsTime,這個配置的蒙版就是當前播放畫面幀的裁剪蒙版,此時馬上經過eventBus.fire通知蒙版容器用keystepsTime的數組路徑進行重繪。校準蒙版。
這裏實際操做中會遇到兩個問題:

  1. 如何肯定當前的進度離哪一幀數據集最近?

    • 答:在以前數據準備時,經過計算在配置寫入了mask_cd,這個時間是最初提取關鍵幀的間隔,有了間隔時長就能夠經過計算獲得int stepsTime = (nowMilliseconds / mask_cd).round() * mask_cd;
  2. 播放器的回調是500毫秒改變一次時間進度,可是咱們要作到極致體驗不能有這麼久的延遲,不然不能保證畫面和蒙版同步

    • 答:在每次觸發進度改變時,新起一個Timer.periodic循環計時器,循環時間就是以前的mask_cd,同時把此刻的進度時間存起來,那麼接下來的500毫秒內,即便播放器沒有通知咱們進度,咱們也能夠經過不斷地累加自行技術,在計時器的回調裏調用eventBus.fire通知蒙版重繪校準。切記當視頻播放完成並開啓循環模式時,要將計時器清除

到這裏已經基本實現了一個Flutter AI彈幕播放器啦~

3. 拓展

3.1 Web前端實現

web前端實現要比native實現簡單,這裏稍微說起一下。服務端處理數據流程是不變的,可是若是隻須要對接web前端,就不用將灰度圖轉換爲json配置。這得益於webkit瀏覽器內核幫咱們作了不少工做。


從嗶哩嗶哩網站中審查元素上就能夠看到,在播放器<video>元素上有一層彈幕蒙版<div>,這個蒙版設置了一個-webkit-mask-image的CSS屬性,傳入咱們以前生成的灰度圖片,瀏覽器內部會幫咱們挖出一個蒙版,省去了咱們本身去計算輪廓的步驟,canvassvg也有的API能夠實現這個效果,可是無疑CSS是最簡單的。

3.2 視頻點播與直播

其實對於蒙版彈幕來說本質上沒有區別,由於視頻網站不可能吧一整個視頻編碼爲mp4格式放給用戶,都是經過長鏈接返回m4sflv的視頻切片給用戶,因此直播點播都同樣。蒙版彈幕的配置信息,不論是web端的base64圖片,仍是app須要的座標點json,都須要跟隨視頻切片一塊兒編碼爲二進制流,拉到端內再解碼,視頻的部分餵給播放器,蒙版信息單獨抽出來。這兩部分得在一個數據包,若是分開傳輸,就會形成畫面蒙版不一樣步的問題。
在直播場景中,視頻上傳到雲端須要實時地提取關鍵幀,進行圖像識別分類,最後再編碼推給用戶端,這個過程須要時間,因此在開啓蒙版彈幕的直播間裏會出現延遲,這個是正常的。

3.3 總結

目前flutter缺乏穩定開源的多功能播放器插件,官方的插件只具有基本功能,好比直播流切片就沒法支持,一些第三方機構的插件又不必定靠得住,也跟不上flutter版本更新的速度,這是目前整個flutter生態存在的問題,致使了要商用就須要投入大量研發成本去開發native插件。
關於這個AI彈幕播放器DEMO,還有些可優化的細節,好比增長蒙版播放器的進度控制,橫豎屏切換,特效彈幕等等。文中代碼只引入了部分片斷,先後端完整代碼請參考:

Github倉庫:https://github.com/yukilzw/zoe_barrage
相關文章
相關標籤/搜索