簡介
前幾版How-Old發布后,不少用戶反饋,在顯示結果的頁面中,用於標注前面人年齡的標簽,會遮擋住后面的人的臉。這是因為我們最初采用固定偏移的方式來放置年齡標簽。
而怎么樣讓標簽不遮擋住其他人的臉,則成為一個有趣的問題。最近我們發布了一次How-Old更新,正好用這篇文章,來記錄一下我們對這一問題的實現。
先直觀的看一下新版本的改變(左舊 右新):
問題
我們來抽象一下這個問題。
在服務器端識別出了照片中的臉后,會將識別數據傳回客戶端,其中包含了每個臉的邊緣矩形的位置和大小信息(FaceRect)。
然后我們要為每個臉添加對應的標簽(LabelRect)。LabelRect和FaceRect兩兩不重合,LaebelRect自身兩兩不重合。(FaceRect本身是有可能重合的)
並且我們希望每個標簽都盡量離對應的臉比較近。
以上就是比較核心的問題描述。此外我們在實現中還加入了一些小小的增強體驗的條件,在正文中會為大家敘述。
算法
准備
我們采用了平面分割標記的算法來布置LabelRect。
對於每個Rect(包括LabelRect,FaceRect),我們需要它的中心點RectCenter(x, y),我們需要確定的也正是每個LabelRect的中心點。
簡單的分析一下,我們發現在每個Rect周圍一定的區域內,是不能布置LabelCenter的,否則就會導致重合。
如下圖所示:
亮藍色是FaceRect,墨綠色是LabelRect,中間的綠點是LabelRect的中心。
粉紅色的半透明區域就是那些不能放置LabelCenter的。這個區域的大小也由LabelRect的大小確定(此例中LabelRect的大小是我們設定好的,每個都一樣)。
粉紅區域是有FaceRect分別向左右各擴展LabelRect.Width/2,向上下各擴展LabelRect.Height/2確定的。可以看出只要在粉紅區域以外放置LabelRect,就必然不會導致LabelRect和FaceRect相交。
我們簡單的把每個粉紅區域叫做一個ForbidRect。
這樣我們就只需要在ForbidRect的邊界上選出最合適的點作為LabelCenter就行了(比如離FaceRect最近的點)。
但實際上上圖還有問題。還要保證LabelRect彼此不相交呢?
上圖應該是這樣:
為了方便,我們采用依次布置LabelRect的方式,先布置的一旦布置好就不再移動了,后布置的受限於前面布置的。(即不采用“在一個漏斗里倒入小球,小球會彼此擠開”這種方式)
現在我們提供一種逐步布置的過程,直觀的理解一下:
最初從服務器傳回的FaceRect。
============================
得出最初的ForbidRect集。
============================
布置第一個LabelRect。
============================
更新ForbidRect集。
============================
布置第2個LabelRect。
============================
再更新ForbidRect集就達到了我們之前那樣的結果。
(此過程舉例中先放哪個后放哪個,是隨便選的)。
那,我們怎么確定該把LabelCenter放在哪呢?換言之,我們怎么出ForbidRect的邊界上選出那個合適的點呢?
當時我們就想,怎么在非離散的二維平面上做這個?
分割
然后我們采用了分割平面的方法,就像上圖那些重疊的半透明的粉紅色塊一樣,將平面分成一塊塊的來遍歷。
Like this:
(不重要的色塊被淡化了。)
每個forbidRect都會引入4個分割線,橫向倆,縱向倆。
同時每條分割線會包含引入這條線的ForbidRect編號,每條線都用一個二元組描述:
Tuple1= (offset, rect_id)。Offset是這條線在垂直方向上距原點的偏移量(就是“直線X=3”里面的那個“3”),rect_id就是引入它的ForbidRect編號。
橫線,縱向分開統計。
舉例:假設左上角那個forbidRect編號是0,右下角那個是1。當前縱向的分割線二元組數組為:L1 = {(1, 0), (5, 0), (4, 1), (8, 1)}
然后我們為了以防萬一要處理一下,就是把偏移量相同的線歸組(雖然不太可能有線重合,但這也是優化點之一,我們可以將forbidRect對齊到一些偏移量為某整數倍的位置)。
歸組后的新二元組如下:
Tuple2=(offset,set<rect_id>)。二元組的第二個元素變成了forbidRect 編號的集合了。
此時我們有兩個Tuple2數組了(橫向的,縱向的),我們按照offset字段將它們排序(兩個方向的分開進行)。
舉例,排序后的縱向線的數組為:L2 = {(1, {0}), (4, {1}), (5, {0}), (8, {1})}
這時我們要遍歷一下排序后的數組,收集一些信息,通過類似棧的方式獲取每個forbidRect覆蓋的分割線在分割線數組中的索引(從0開始)。因為分割線排好序了,我們就記一個區間好了。
舉例:forbidRect 0 的“覆蓋線”的索引區間為: [0, 2]。
但是我們是為了分割平面才引入的分割線,因為水平方向上索引為2的線(第三條線)之后已經不是forbidRect 0 的范圍了,所以這個索引區間的意義實際上是[0, 2)——不再是分割線的索引,而是橫向上的小平面區域的索引。
同時,我們還有一個映射M1:(index1, index2) -> isDirty。映射源是一個被橫縱線分割出的小矩形(Cell)的橫縱向索引,映射目標是一個boolean量,用來表示這個Cell是否屬於一個ForbidRect。
舉例:(0,0)->true, (1,0)->true, (2,0)->false. (2,2)->true.
做好這些准備后,就是我們最后的布局階段了。
放置
在How-Old實際使用的算法中,
我們依照距離所有faceRect重心(是“重心”)最小的順序為FaceRect排序,也就是越靠近中心的越先處理。
對每個faceRect,找到它的ForbidRect。通過ForbidRect在X Y方向上的“覆蓋Cell”索引區間,找出位於該Forbidrect邊界上的Cell。
舉例:ForbidRect 0 邊界上的Cell有:(0, -1) (1, -1) (-1, 0) (-1, 1) (2, 0) (2, 1) (0, 2) (1, 2)
就是圖中這四個黃色塊標示的8個Cell(最左邊和最上邊的就為它們編號-1)。
===================================
其中有幾個Cell是Dirty的:
===================================
也就是說,我們只要在這6條線段(藍色標出)上找LabelCenter就可以了
===================================
我們當前的策略是:先上,再左,再右,最后下方。
對每個線段,判斷它的兩個頂點,是在FaceRect與線段垂直的軸線的一左一右?一上一下?還是在同一側?——這樣就能判斷最優的點(距離最近)。每個線段有一個最優解,再從中得出全局最優解。
(如果在上方就能得出這樣的解,直接就用它做全局解。不然依次繼續左、右、下方中找。下方的點,我們不喜歡,設置一個值去抑制它成為全局最優解)。
但,如果一個forbidRect四面受敵,一條這樣的邊界線段也沒有怎么辦呢?
此時我們通過一個forbidRect相交矩陣,廣度優先,遍歷每個和它直接或間接相接的forbidRect,從這些ForbidRect的邊界線段上,找出最優的那個點,作為LabelCenter。
之后我們將這個LabelRect對應的ForbidRect加入ForbidRect集,並對下一個Face(按距重心排序地)進行同樣的過程。直到所有Face都處理完成。
總結
這個算法的大致流程就是這樣,其中也還有一些地方值得繼續優化。當然我們還對標簽大小,標簽偏移等屬性進行了微調。
希望這篇文章能拋磚引玉,如果大家有更好的算法或者想法,歡迎和我們交流。也歡迎下載最新版的How-Old進行各種各樣圖片的測試。
最后 向量子力學致敬:)
並附上我們的微軟顏齡的 應用下載地址:https://www.windowsphone.com/zh-cn/store/app/%E5%BE%AE%E8%BD%AF%E9%A2%9C%E9%BE%84/8f4e7547-7ecb-4736-8306-11b97ba293e1