AI智能彈幕(也稱蒙版彈幕):彈幕浮在視頻的上方卻永遠不會擋住人物。起源於嗶哩嗶哩的web端黑科技,然後分別實如今IOS和Android的app端,現在被用於短視頻、直播等媒體行業,用戶體驗提高顯著。前端
本文除了會使用Flutter
新方案進行跨端實現,同時也會講解如何將一段任意視頻流使用opencv-python
處理成蒙版數據源,達成從0到1的先後端AI體系。先來看看雙端最終運行效果吧:python
Python後端:git
Flutter前端:github
拓展:web
# 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
提取圖像中人物的工做須要交給 卷積神經網絡 來完成,不一樣程度的訓練對圖像分類的準確率影響很大,而這也直接決定了最終的效果。大公司有算法團隊來專門訓練模型,咱們的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單核都跑不滿,等個幾分鐘所有結果返回腳本會自動退出。後端
咱們以前已經獲得了算法幫咱們提取後的人關鍵幀,接下來須要利用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
爲視頻的進度時間ms
,value
爲閉包路徑(就是圖中白色區域的包圍路徑,排除黑色人物區域),是一個二維數組,由於一幀裏會有n個閉包路徑組成。另外還要將視頻信息存入配置文件,其中frame_cd
就是告訴flutter每間隔多少ms
切換下一幀蒙版,視頻的寬高分辨率用於flutter初始化播放器自適應佈局。
具體JSON數據結構可見上方圖片。如今咱們已經獲得了一個res.json
的配置文件,裏面包含了該視頻關鍵幀數據的裁剪座標集,接下來就用flutter去剪紙吧~
彈幕調度系統各端實現都大同小異,只是動畫庫的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文件。這裏便不作詳述。
// 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
首先咱們須要引入一個播放器,考慮到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
通知蒙版容器用key
爲stepsTime
的數組路徑進行重繪。校準蒙版。
這裏實際操做中會遇到兩個問題:
如何肯定當前的進度離哪一幀數據集最近?
mask_cd
,這個時間是最初提取關鍵幀的間隔,有了間隔時長就能夠經過計算獲得int stepsTime = (nowMilliseconds / mask_cd).round() * mask_cd;
播放器的回調是500毫秒改變一次時間進度,可是咱們要作到極致體驗不能有這麼久的延遲,不然不能保證畫面和蒙版同步
Timer.periodic
循環計時器,循環時間就是以前的mask_cd
,同時把此刻的進度時間存起來,那麼接下來的500毫秒內,即便播放器沒有通知咱們進度,咱們也能夠經過不斷地累加自行技術,在計時器的回調裏調用eventBus.fire
通知蒙版重繪校準。切記當視頻播放完成並開啓循環模式時,要將計時器清除到這裏已經基本實現了一個Flutter AI彈幕播放器啦~
web前端實現要比native實現簡單,這裏稍微說起一下。服務端處理數據流程是不變的,可是若是隻須要對接web前端,就不用將灰度圖轉換爲json配置。這得益於webkit瀏覽器內核幫咱們作了不少工做。
從嗶哩嗶哩網站中審查元素上就能夠看到,在播放器<video>
元素上有一層彈幕蒙版<div>
,這個蒙版設置了一個-webkit-mask-image
的CSS屬性,傳入咱們以前生成的灰度圖片,瀏覽器內部會幫咱們挖出一個蒙版,省去了咱們本身去計算輪廓的步驟,canvas
和svg
也有的API能夠實現這個效果,可是無疑CSS是最簡單的。
其實對於蒙版彈幕來說本質上沒有區別,由於視頻網站不可能吧一整個視頻編碼爲mp4
格式放給用戶,都是經過長鏈接返回m4s
或flv
的視頻切片給用戶,因此直播點播都同樣。蒙版彈幕的配置信息,不論是web端的base64圖片,仍是app須要的座標點json,都須要跟隨視頻切片一塊兒編碼爲二進制流,拉到端內再解碼,視頻的部分餵給播放器,蒙版信息單獨抽出來。這兩部分得在一個數據包,若是分開傳輸,就會形成畫面蒙版不一樣步的問題。
在直播場景中,視頻上傳到雲端須要實時地提取關鍵幀,進行圖像識別分類,最後再編碼推給用戶端,這個過程須要時間,因此在開啓蒙版彈幕的直播間裏會出現延遲,這個是正常的。
目前flutter缺乏穩定開源的多功能播放器插件,官方的插件只具有基本功能,好比直播流切片就沒法支持,一些第三方機構的插件又不必定靠得住,也跟不上flutter版本更新的速度,這是目前整個flutter生態存在的問題,致使了要商用就須要投入大量研發成本去開發native插件。
關於這個AI彈幕播放器DEMO,還有些可優化的細節,好比增長蒙版播放器的進度控制,橫豎屏切換,特效彈幕等等。文中代碼只引入了部分片斷,先後端完整代碼請參考: