Flutter · Python AI 彈幕播放器來襲

心動音符發表於2020-12-07

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

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

自行clone原始碼打包:Zoe barrage
IPhone執行錄屏:點這裡
APP執行截圖:

實現流程目錄

  • Python後端:
    • 依次提取視訊流的 關鍵幀 儲存為圖片
    • 將所有關鍵幀傳給 神經網路模型 讓演算法將圖片中非人物抹去,並儲存圖片幀
    • 將只含有人物的圖片幀進行 畫素色值轉換,得到 灰度圖,最後再轉為 黑白反色圖
    • 通過識別黑白反色圖的 輪廓座標 ,生成一份 時間:路徑 配置檔案提供給前端
  • Flutter前端:
    • 實現一個彈幕排程動畫組
    • 根據 配置檔案 將彈幕外層容器 裁剪 為一個剛好透出人物的漏洞形狀,也稱蒙版
    • 引入播放器,視訊流播放時,為 關鍵幀 同步渲染其對應的蒙版形狀
  • 擴充:
    • 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資料夾下。

1.2 通過AI模型提取人物


提取影像中人物的工作需要交給 卷積神經網路 來完成,不同程度的訓練對影像分類的準確率影響很大,而這也直接決定了最終的效果。大公司有演算法團隊來專門訓練模型,我們的DEMO使用的開放測試介面,準確率與其付費商用的無異,就是會被限流,失敗率高達80%,不過後面我們可以在程式碼編寫中解決這個問題。

# 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 輪廓JSON

# 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;
  1. 播放器的回撥是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

相關文章