摘要:萬萬沒想到,在圣誕節前夕,女神居然答應了在下的約會請求。想在下正如在座的一些看官一樣,雖玉樹臨風風流倜儻,卻總因猜不透女孩的心思,一不留神就落得個母胎單身。在內部將張量表示為基本數據類型的維數組。
本文將結合移動設備攝像能力與 TensorFlow.js,在瀏覽器里實現一個實時的人臉情緒分類器。鑒于文章的故事背景較長,對實現本身更有興趣的同學可直接跳轉至技術方案概述。
DEMO 試玩
前言看遍了 25 載的雪月沒風花,本旺早已不悲不喜。萬萬沒想到,在圣誕節前夕,女神居然答應了在下的約會請求。可是面對這么個大好機會,本前端工程獅竟突然慌張起來。想在下正如在座的一些看官一樣,雖玉樹臨風、風流倜儻,卻總因猜不透女孩的心思,一不留神就落得個母胎單身。如今已是 8102 年,像我等這么優秀的少年若再不脫單,黨和人民那都是一萬個不同意!痛定思痛,在下這就要發揮自己的技術優勢,將察「顏」觀色的技能樹點滿,做一個洞悉女神喜怒哀愁的優秀少年,決勝圣誕之巔!
正題開始 需求分析我們前端工程師終于在 2018 年迎來了 TensorFlow.js,這就意味著,就算算法學的再弱雞,又不會 py 交易,我們也能靠著 js 跟著算法的同學們學上個一招半式。如果我們能夠在約會期間,通過正規渠道獲得女神的照片,是不是就能用算法分析分析女神看到在下的時候,是開心還是...不,一定是開心的!
可是,約會的戰場瞬息萬變,我們總不能拍了照就放手機里,約完會回到靜悄悄的家,再跑代碼分析吧,那可就 「too young too late」 了!時間就是生命,如果不能當場知道女神的心情,我們還不如給自己 -1s!
因此,我們的目標就是能夠在手機上,實時看到這樣的效果(嘛,有些簡陋,不過本文將專注于功能實現,哈哈):
技術方案概述很簡單,我們需要的就兩點,圖像采集 & 模型應用,至于結果怎么展示,嗨呀,作為一個前端工程師,render 就是家常便飯呀。對于前端的同學來說,唯一可能不熟悉的也就是算法模型怎么用;對于算法的同學來說,唯一可能不熟悉的也就是移動設備怎么使用攝像頭。
我們的流程即如下圖所示(下文會針對計算速度的問題進行優化):
下面,我們就根據這個流程圖來梳理下如何實現吧!
核心一:圖像采集與展示 圖像采集我們如何使用移動設備進行圖像或者視頻流的采集呢?這就需要借助 WebRTC 了。WebRTC,即網頁即時通信(Web Real-Time Communication),是一個支持網頁瀏覽器進行實時語音對話或視頻對話的 API。它于 2011 年 6 月 1 日開源,并在 Google、Mozilla、Opera 支持下被納入萬維網聯盟的 W3C 推薦標準。
拉起攝像頭并獲取采集到的視頻流,這正是我們需要使用到的由 WebRTC 提供的能力,而核心的 API 就是 navigator.mediaDevices.getUserMedia。
該方法的兼容性如下,可以看到,對于常見的手機來說,還是可以較好支持的。不過,不同手機、系統種類與版本、瀏覽器種類與版本可能還是存在一些差異。如果想要更好的做兼容的話,可以考慮使用 Adapter.js 來做 shim,它可以讓我們的 App 與 Api 的差異相隔離。此外,在這里可以看到一些有趣的例子。具體 Adapter.js 的實現可以自行查閱。
那么這個方法是如何使用的呢?我們可以通過 MDN 來查閱一下。MediaDevices getUserMedia() 會向用戶申請權限,使用媒體輸入,獲得具有指定類型的 MediaStream(如音頻流、視頻流),并且會 resolve 一個 MediaStream 對象,如果沒有權限或沒有匹配的媒體,會報出相應異常:
navigator.mediaDevices.getUserMedia(constraints) .then(function(stream) { /* use the stream */ }) .catch(function(err) { /* handle the error */ });
因此,我們可以在入口文件統一這樣做:
class App extends Component { constructor(props) { super(props); // ... this.stream = null; this.video = null; // ... } componentDidMount() { // ... this.startMedia(); } startMedia = () => { const constraints = { audio: false, video: true, }; navigator.mediaDevices.getUserMedia(constraints) .then(this.handleSuccess) .catch(this.handleError); } handleSuccess = (stream) => { this.stream = stream; // 獲取視頻流 this.video.srcObject = stream; // 傳給 video } handleError = (error) => { console.log("navigator.getUserMedia error: ", error); } // ... }實時展示
為什么需要 this.video 呢,我們不僅要展示拍攝到的視頻流,還要能直觀的將女神的面部神情標記出來,因此需要通過 canvas 來同時實現展示視頻流和繪制基本圖形這兩點,而連接這兩點的方法如下:
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
當然,我們并不需要在視圖中真的提供一個 video DOM,而是由 App 維護在實例內部即可。canvas.width 和 canvas.height 需要考慮移動端設備的尺寸,這里略去不表。
而繪制矩形框與文字信息則非常簡單,我們只需要拿到算法模型計算出的位置信息即可:
export const drawBox = ({ ctx, x, y, w, h, emoji }) => { ctx.strokeStyle = EmojiToColor[emoji]; ctx.lineWidth = "4"; ctx.strokeRect(x, y, w, h); } export const drawText = ({ ctx, x, y, text }) => { const padding = 4 ctx.fillStyle = "#ff6347" ctx.font = "16px" ctx.textBaseline = "top" ctx.fillText(text, x + padding, y + padding) }核心二:模型預測
在這里,我們需要將問題進行拆解。鑒于本文所說的「識別女神表情背后的情緒」屬于圖像分類問題,那么這個問題就需要我們完成兩件事:
從圖像中提取出人臉部分的圖像;
將提取出的圖像塊作為輸入交給模型進行分類計算。
下面我們來圍繞這兩點逐步討論。
人臉提取我們將借助 face-api.js 來處理。face-api.js 是基于 tensorflow.js 核心 API (@tensorflow/tfjs-core) 來實現的在瀏覽器環境中使用的面部檢測與識別庫,本身就提供了
SSD Mobilenet V1、Tiny Yolo V2、MTCNN 這三種非常輕量的、適合移動設備使用的模型。很好理解的是效果自然是打了不少折扣,這些模型都從模型大小、計算復雜度、機器功耗等多方面做了精簡,盡管有些專門用來計算的移動設備還是可以駕馭完整模型的,但我們一般的手機是肯定沒有卡的,自然只能使用 Mobile 版的模型。
這里我們將使用 MTCNN。我們可以小瞄一眼模型的設計,如下圖所示。可以看到,我們的圖像幀會被轉換成不同 size 的張量傳入不同的 net,并做了一堆 Max-pooling,最后同時完成人臉分類、bb box 的回歸與 landmark 的定位。大致就是說,輸入一張圖像,我們可以得到圖像中所有人臉的類別、檢測框的位置信息和比如眼睛、鼻子、嘴唇的更細節的位置信息。
當然,當我們使用了 face-api.js 時就不需要太仔細的去考慮這些,它做了較多的抽象與封裝,甚至非常兇殘的對前端同學屏蔽了張量的概念,你只需要取到一個 img DOM,是的,一個已經加載好 src 的 img DOM 作為封裝方法的輸入(加載 img 就是一個 Promise 咯),其內部會自己轉換成需要的張量。通過下面的代碼,我們可以將視頻幀中的人臉提取出來。
export class FaceExtractor { constructor(path = MODEL_PATH, params = PARAMS) { this.path = path; this.params = params; } async load() { this.model = new faceapi.Mtcnn(); await this.model.load(this.path); } async findAndExtractFaces(img) { // ...一些基本判空保證在加載好后使用 const input = await faceapi.toNetInput(img, false, true); const results = await this.model.forward(input, this.params); const detections = results.map(r => r.faceDetection); const faces = await faceapi.extractFaces(input.inputs[0], detections); return { detections, faces }; } }情緒分類
好了,終于到了核心功能了!一個「好」習慣是,扒一扒 GitHub 看看有沒有開源代碼可以參考一下,如果你是大佬請當我沒說。這里我們將使用一個實時面部檢測和情緒分類模型來完成我們的核心功能,這個模型可以區分開心、生氣、難過、惡心、沒表情等。
對于在瀏覽器中使用 TensorFlow.js 而言,很多時候我們更多的是應用現有模型,通過 tfjs-converter 來將已有的 TensorFlow 的模型、Keras 的模型轉換成 tfjs 可以使用的模型。值得一提的是,手機本身集成了很多的傳感器,可以采集到很多的數據,相信未來一定有 tfjs 發揮的空間。具體轉換方法可參考文檔,我們繼續往下講。
那么我們可以像使用 face-api.js 一樣將 img DOM 傳入模型嗎?不行,事實上,我們使用的模型的輸入并不是隨意的圖像,而是需要轉換到指定大小、并只保留灰度圖的張量。因此在繼續之前,我們需要對原圖像進行一些預處理。
哈哈,躲得了初一躲不過十五,我們還是來了解下什么是張量吧!TensorFlow 的官網是這么解釋的:
張量是對矢量和矩陣向潛在的更高維度的泛化。TensorFlow 在內部將張量表示為基本數據類型的 n 維數組。
算了沒關系,我們畫個圖來理解張量是什么樣的:
因此,我們可將其簡單理解為更高維的矩陣,并且存儲的時候就是個數組套數組。當然,我們通常使用的 RGB 圖像有三個通道,那是不是就是說我們的圖像數據就是三維張量(寬、高、通道)了呢?也不是,在 TensorFlow 里,第一維通常是 n,具體來說就是圖像個數(更準確的說法是 batch),因此一個圖像張量的 shape 一般是 [n, height, width, channel],也即四維張量。
那么我們要怎么對圖像進行預處理呢?首先我們將分布在 [0, 255] 的像素值中心化到 [-127.5, 127.5],然后標準化到 [-1, 1]即可。
const NORM_OFFSET = tf.scalar(127.5); export const normImg = (img, size) => { // 轉換成張量 const imgTensor = tf.fromPixels(img); // 從 [0, 255] 標準化到 [-1, 1]. const normalized = imgTensor .toFloat() .sub(NORM_OFFSET) // 中心化 .div(NORM_OFFSET); // 標準化 const { shape } = imgTensor; if (shape[0] === size && shape[1] === size) { return normalized; } // 按照指定大小調整 const alignCorners = true; return tf.image.resizeBilinear(normalized, [size, size], alignCorners); }
然后將圖像轉成灰度圖:
export const rgbToGray = async imgTensor => { const minTensor = imgTensor.min() const maxTensor = imgTensor.max() const min = (await minTensor.data())[0] const max = (await maxTensor.data())[0] minTensor.dispose() maxTensor.dispose() // 灰度圖則需要標準化到 [0, 1],按照像素值的區間來標準化 const normalized = imgTensor.sub(tf.scalar(min)).div(tf.scalar(max - min)) // 灰度值取 RGB 的平均值 let grayscale = normalized.mean(2) // 擴展通道維度來獲取正確的張量形狀 (h, w, 1) return grayscale.expandDims(2) }
這樣一來,我們的輸入就從 3 通道的彩色圖片變成了只有 1 個通道的黑白圖。
注意,我們這里所做的預處理比較簡單,一方面我們在避免去理解一些細節問題,另一方面也是因為我們是在使用已經訓練好的模型,不需要做一些復雜的預處理來改善訓練的效果。
準備好圖像后,我們需要開始準備模型了!我們的模型主要需要暴露加載模型的方法 load 和對圖像進行分類的 classify 這兩個方法。加載模型非常簡單,只需要調用 tf.loadModel 即可,需要注意的是,加載模型是一個異步過程。我們使用 create-react-app 構建的項目,封裝的 Webpack 配置已經支持了 async-await 的方法。
class Model { constructor({ path, imageSize, classes, isGrayscale = false }) { this.path = path this.imageSize = imageSize this.classes = classes this.isGrayscale = isGrayscale } async load() { this.model = await tf.loadModel(this.path) // 預熱一下 const inShape = this.model.inputs[0].shape.slice(1) const result = tf.tidy(() => this.model.predict(tf.zeros([1, ...inShape]))) await result.data() result.dispose() } async imgToInputs(img) { // 轉換成張量并 resize let norm = await prepImg(img, this.imageSize) // 轉換成灰度圖輸入 norm = await rgbToGrayscale(norm) // 這就是所說的設置 batch 為 1 return norm.reshape([1, ...norm.shape]) } async classify(img, topK = 10) { const inputs = await this.imgToInputs(img) const logits = this.model.predict(inputs) const classes = await this.getTopKClasses(logits, topK) return classes } async getTopKClasses(logits, topK = 10) { const values = await logits.data() let predictionList = [] for (let i = 0; i < values.length; i++) { predictionList.push({ value: values[i], index: i }) } predictionList = predictionList .sort((a, b) => b.value - a.value) .slice(0, topK) return predictionList.map(x => { return { label: this.classes[x.index], value: x.value } }) } } export default Model
我們可以看到,我們的模型返回的是一個叫 logits 的量,而為了知道分類的結果,我們又做了 getTopKClasses 的操作。這可能會使得較少了解這塊的同學有些困惑。實際上,對于一個分類模型而言,我們返回的結果并不是一個特定的類,而是對各個 class 的概率分布,舉個例子:
// 示意用 const classifyResult = [0.1, 0.2, 0.25, 0.15, 0.3];
也就是說,我們分類的結果其實并不是說圖像中的東西「一定是人或者狗」,而是「可能是人或者可能是狗」。以上面的示意代碼為例,如果我們的 label 對應的是 ["女人", "男人", "大狗子", "小狗子", "二哈"],那么上述的結果其實應該理解為:圖像中的物體 25% 的可能性為大狗子,20% 的可能性為一個男人。
因此,我們需要做 getTopKClasses,根據我們的場景我們只關心最可能的情緒,那么我們也就會取 top1 的概率分布值,從而知道最可能的預測結果。
怎么樣,tfjs 封裝后的高級方法是不是在語義上較為清晰呢?
最終我們將上文提到的人臉提取功能與情緒分類模型整合到一起,并加上一些基本的 canvas 繪制:
// 略有調整 analyzeFaces = async (img) => { // ... const faceResults = await this.faceExtractor.findAndExtractFaces(img); const { detections, faces } = faceResults; // 對提取到的每一個人臉進行分類 let emotions = await Promise.all( faces.map(async face => await this.emotionModel.classify(face)) ); // ... } drawDetections = () => { const { detections, emotions } = this.state; if (!detections.length) return; const { width, height } = this.canvas; const ctx = this.canvas.getContext("2d"); const detectionsResized = detections.map(d => d.forSize(width, height)); detectionsResized.forEach((det, i) => { const { x, y } = det.box const { emoji, name } = emotions[i][0].label; drawBox({ ctx, ...det.box, emoji }); drawText({ ctx, x, y, text: emoji, name }); }); }
大功告成!
實時性優化事實上,我們還應該考慮的一個問題是實時性。事實上,我們的計算過程用到了兩個模型,即便已經是針對移動設備做了優化的精簡模型,但仍然會存在性能問題。如果我們在組織代碼的時候以阻塞的方式進行預測,那么就會出現一幀一幀的卡頓,女神的微笑也會變得抖動和僵硬。
因此,我們要考慮做一些優化,來更好地畫出效果。
筆者這里利用一個 flag 來標記當前是否有正在進行的模型計算,如果有,則進入下一個事件循環,否則則進入模型計算的異步操作。同時,每一個事件循環都會執行 canvas 操作,從而保證標記框總是會展示出來,且每次展示的其實都是緩存在 state 中的前一次模型計算結果。這種操作是具有合理性的,因為人臉的移動通常是連續的(如果不連續這個世界可能要重新審視一下),這種處理方法能較好的對結果進行展示,且不會因為模型計算而阻塞,導致卡頓,本質上是一種離散化采樣的技巧吧。
handleSnapshot = async () => { // ... 一些 canvas 準備操作 canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height); this.drawDetections(); // 繪制 state 中維護的結果 // 利用 flag 判斷是否有正在進行的模型預測 if (!this.isForwarding) { this.isForwarding = true; const imgSrc = await getImg(canvas.toDataURL("image/png")); this.analyzeFaces(imgSrc); } const that = this; setTimeout(() => { that.handleSnapshot(); }, 10); } analyzeFaces = async (img) => { // ...其他操作 const faceResults = await this.models.face.findAndExtractFaces(img); const { detections, faces } = faceResults; let emotions = await Promise.all( faces.map(async face => await this.models.emotion.classify(face)) ); this.setState( { loading: false, detections, faces, emotions }, () => { // 獲取到新的預測值后,將 flag 置為 false,以再次進行預測 this.isForwarding = false; } ); }效果展示
我們來在女神這試驗下效果看看:
嗯,馬馬虎虎吧!雖然有時候還是會把笑容識別成沒什么表情,咦,是不是 Gakki 演技還是有點…好了好了,時間緊迫,趕緊帶上武器準備赴約吧。穿上一身帥氣格子衫,打扮成程序員模樣~
結尾約會當晚,吃著火鍋唱著歌,在下與女神相談甚歡。正當氣氛逐漸曖昧,話題開始深入到感情方面時,我自然的問起女神的理想型。萬萬沒想到,女神突然說了這樣的話:
那一刻我想起了 Eason 的歌:
Lonely Lonely christmas參考
Merry Merry christmas
寫了卡片能寄給誰
心碎的像街上的紙屑
https://developer.mozilla.org...
https://github.com/webrtc/ada...
https://github.com/justadudew...
https://github.com/tensorflow...
[Zhang K, Zhang Z, Li Z, et al. Joint face detection and alignment using multitask cascaded convolutional networks[J]. IEEE Signal Processing Letters, 2016, 23(10): 1499-1503.][7]
文章可隨意轉載,但請保留此 原文鏈接。非常歡迎有激情的你加入 ES2049 Studio,簡歷請發送至 caijun.hcj(at)alibaba-inc.com 。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/100361.html
摘要:差分編碼的目的,就是盡可能的將圖片數據值轉換成一組重復的低的值,這樣的值更容易被壓縮。最后還要注意的是,差分編碼處理的是每一個的像素點中每條顏色通道的值,紅綠藍透明四個顏色通道的值分別進行處理。 背景 今天凌晨一點,突然有個人加我的qq,一看竟然是十年前被我刪掉的初戀。。。。 因為之前在qq空間有太多的互動,所以qq推薦好友里面經常推薦我倆互相認識。。。。謎之尷尬 showImg(ht...
摘要:差分編碼的目的,就是盡可能的將圖片數據值轉換成一組重復的低的值,這樣的值更容易被壓縮。最后還要注意的是,差分編碼處理的是每一個的像素點中每條顏色通道的值,紅綠藍透明四個顏色通道的值分別進行處理。 背景 今天凌晨一點,突然有個人加我的qq,一看竟然是十年前被我刪掉的初戀。。。。 因為之前在qq空間有太多的互動,所以qq推薦好友里面經常推薦我倆互相認識。。。。謎之尷尬 showImg(ht...
摘要:差分編碼的目的,就是盡可能的將圖片數據值轉換成一組重復的低的值,這樣的值更容易被壓縮。最后還要注意的是,差分編碼處理的是每一個的像素點中每條顏色通道的值,紅綠藍透明四個顏色通道的值分別進行處理。 背景 今天凌晨一點,突然有個人加我的qq,一看竟然是十年前被我刪掉的初戀。。。。 因為之前在qq空間有太多的互動,所以qq推薦好友里面經常推薦我倆互相認識。。。。謎之尷尬 showImg(ht...
摘要:小明追女神的故事小明遇到了他的女神,打算送一朵鮮花來表白。剛好小明打聽到他和有一個共同的好友,于是小明決定讓來替自己來完成這件事。 1.單例模式 單例模式的核心:(1)確保只有一個實例(2)提供全局訪問 用代理實現單例模式: var ProxySingletonCreateDiv = (function(){ var instance; return f...
閱讀 1480·2021-11-17 09:33
閱讀 1259·2021-10-11 10:59
閱讀 2891·2021-09-30 09:48
閱讀 1903·2021-09-30 09:47
閱讀 3023·2019-08-30 15:55
閱讀 2335·2019-08-30 15:54
閱讀 1491·2019-08-29 15:25
閱讀 1644·2019-08-29 10:57