做者:王前
小票打印是零售商家的基礎功能,在小票信息中,必然會存在一些相關店鋪的信息。好比,logo 、店鋪二維碼等。對於商家來講,上傳 logo 及店鋪二維碼時,基本都是彩圖,可是小票打印機基本都是隻支持黑白二值圖打印。爲了商家的服務體驗,咱們沒有對商家上傳的圖片進行要求,商家能夠根據實際狀況上傳本身的個性化圖片,所以就須要咱們對商家的圖片進行二值圖處理後進行打印。html
此次文章是對《有贊零售小票打印跨平臺解決方案》中的圖片的二值圖處理部分的解決方案的說明。java
圖像二值化就是將圖像上的像素點的灰度值(若是是 RGB 彩圖,則須要先將像素點的 RGB 轉成灰度值)設置爲 0 或 255 ,也就是將整個圖像呈現出明顯的黑白效果的過程。算法
其中劃分 0 和 255 的中間閾值 T 是二值化的核心,一個準確的閾值能夠獲得一個較好的二值圖。數組
二值化總體流程圖:緩存
從上面的流程圖中能夠看出,獲取灰度圖和計算閾值 T 是二值化的核心步驟。app
之前使用的方案是,首先將圖像處理成灰度圖,而後再基於 OTSU(大津法、最大類間方差法)算法求出分割 0 和 255 的閾值 T ,而後根據 T 對灰度值進行二值化處理,獲得二值圖像。異步
咱們的全部算法都有使用 C 語言實現,目的爲了跨平臺通用性。函數
流程圖:性能
灰度算法:測試
對於 RGB 彩色轉灰度,有一個很著名的公式:
<center>Gray = R 0.299 + G 0.587 + B * 0.114</center>
這種算法叫作 Luminosity,也就是亮度算法。目前這種算法是最經常使用的,裏面的三個數據都是經驗值或者說是實驗值。由來請參見 wiki 。
然而實際應用時,你們都但願避免低速的浮點運算,爲了提升效率將上述公式變造成整數運算和移位運算。這裏將採用移位運算公式:
<center>Gray = (R 38 + G 75 + B * 15) >> 7</center>
若是想了解具體由來,能夠自行了解,這裏不作過多解釋。
具體實現算法以下:
/** 獲取灰度圖 @param bit_map 圖像像素數組地址( ARGB 格式) @param width 圖像寬 @param height 圖像高 @return 灰度圖像素數組地址 */ int * gray_image(int *bit_map, int width, int height) { double pixel_total = width * height; // 像素總數 if (pixel_total == 0) return NULL; // 灰度像素點存儲 int *gray_pixels = (int *)malloc(pixel_total * sizeof(int)); memset(gray_pixels, 0, pixel_total * sizeof(int)); int *p = bit_map; for (u_int i = 0; i < pixel_total; i++, p++) { // 分離三原色及透明度 u_char alpha = ((*p & 0xFF000000) >> 24); u_char red = ((*p & 0xFF0000) >> 16); u_char green = ((*p & 0x00FF00) >> 8); u_char blue = (*p & 0x0000FF); u_char gray = (red*38 + green*75 + blue*15) >> 7; if (alpha == 0 && gray == 0) { gray = 0xFF; } gray_pixels[i] = gray; } return gray_pixels; }
該算法中,主要是爲了統一各平臺的兼容性,入參要求傳入 ARGB 格式的 bitmap 。爲何使用 int 而不是用 unsigned int,是由於在 java 中沒有無符號數據類型,使用 int 具備通用性。
OTSU 算法:
OTSU 算法也稱最大類間差法,有時也稱之爲大津算法,由大津於 1979 年提出,被認爲是圖像分割中閾值選取的最佳算法,計算簡單,不受圖像亮度和對比度的影響,所以在數字圖像處理上獲得了普遍的應用。它是按圖像的灰度特性,將圖像分紅背景和前景兩部分。因方差是灰度分佈均勻性的一種度量,背景和前景之間的類間方差越大,說明構成圖像的兩部分的差異越大,當部分前景錯分爲背景或部分背景錯分爲前景都會致使兩部分差異變小。所以,使類間方差最大的分割意味着錯分機率最小。
原理:
對於圖像 I ( x , y ) ,前景(即目標)和背景的分割閾值記做 T ,屬於前景的像素點數佔整幅圖像的比例記爲 ω0 ,其平均灰度 μ0 ;背景像素點數佔整幅圖像的比例爲 ω1 ,其平均灰度爲 μ1 。圖像的總平均灰度記爲 μ ,類間方差記爲 g 。
假設圖像的背景較暗,而且圖像的大小爲 M × N ,圖像中像素的灰度值小於閾值 T 的像素個數記做 N0 ,像素灰度大於等於閾值 T 的像素個數記做 N1 ,則有:
ω0 = N0 / M × N (1) ω1 = N1 / M × N (2) N0 + N1 = M × N (3) ω0 + ω1 = 1 (4) μ = ω0 * μ0 + ω1 * μ1 (5) g = ω0 * (μ0 - μ)^2 + ω1 * (μ1 - μ)^2 (6)
將式 (5) 代入式 (6) ,獲得等價公式:
g = ω0 * ω1 * (μ0 - μ1)^2 (7)
公式 (7) 就是類間方差計算公式,採用遍歷的方法獲得使類間方差 g 最大的閾值 T ,即爲所求。
由於 OTSU 算法求閾值的基礎是灰度直方圖數據,因此使用 OTSU 算法的前兩步:
一、獲取原圖像的灰度圖
二、灰度直方統計
這裏須要屢次對圖像進行遍歷處理,若是每一步都單獨處理,會增長很多遍歷次數,因此這裏作了步驟整合處理,減小沒必要要的遍歷,提升性能。
具體實現算法以下:
/** OTSU 算法獲取二值圖 @param bit_map 圖像像素數組地址( ARGB 格式) @param width 圖像寬 @param height 圖像高 @param T 存儲計算得出的閾值 @return 二值圖像素數組地址 */ int * binary_image_with_otsu_threshold_alg(int *bit_map, int width, int height, int *T) { double pixel_total = width * height; // 像素總數 if (pixel_total == 0) return NULL; unsigned long sum1 = 0; // 總灰度值 unsigned long sumB = 0; // 背景總灰度值 double wB = 0.0; // 背景像素點比例 double wF = 0.0; // 前景像素點比例 double mB = 0.0; // 背景平均灰度值 double mF = 0.0; // 前景平均灰度值 double max_g = 0.0; // 最大類間方差 double g = 0.0; // 類間方差 u_char threshold = 0; // 閾值 double histogram[256] = {0}; // 灰度直方圖,下標是灰度值,保存內容是灰度值對應的像素點總數 // 獲取灰度直方圖和總灰度 int *gray_pixels = (int *)malloc(pixel_total * sizeof(int)); memset(gray_pixels, 0, pixel_total * sizeof(int)); int *p = bit_map; for (u_int i = 0; i < pixel_total; i++, p++) { // 分離三原色及透明度 u_char alpha = ((*p & 0xFF000000) >> 24); u_char red = ((*p & 0xFF0000) >> 16); u_char green = ((*p & 0x00FF00) >> 8); u_char blue = (*p & 0x0000FF); u_char gray = (red*38 + green*75 + blue*15) >> 7; if (alpha == 0 && gray == 0) { gray = 0xFF; } gray_pixels[i] = gray; // 計算灰度直方圖分佈,Histogram 數組下標是灰度值,保存內容是灰度值對應像素點數 histogram[gray]++; sum1 += gray; } // OTSU 算法 for (u_int i = 0; i < 256; i++) { wB = wB + histogram[i]; // 這裏不算比例,減小運算,不會影響求 T wF = pixel_total - wB; if (wB == 0 || wF == 0) { continue; } sumB = sumB + i * histogram[i]; mB = sumB / wB; mF = (sum1 - sumB) / wF; g = wB * wF * (mB - mF) * (mB - mF); if (g >= max_g) { threshold = i; max_g = g; } } for (u_int i = 0; i < pixel_total; i++) { gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF; } if (T) { *T = threshold; // OTSU 算法閾值 } return gray_pixels; }
測試執行時間數據:
iPhone 6: imageSize:260, 260; OTSU 使用時間:0.005254; 5次異步處理使用時間:0.029240
iPhone 6: imageSize:620, 284; OTSU 使用時間:0.029476; 5次異步處理使用時間:0.050313
iPhone 6: imageSize:2560,1440; OTSU 使用時間:0.200595; 5次異步處理使用時間:0.684509
通過測試,該算法處理時間都是毫秒級別的,並且通常咱們的圖片大小都不大,因此性能沒問題。
處理後的效果:
通過 OTSU 算法處理過的二值圖基本能夠知足大部分商家 logo 。
不過對於實際場景來講還有些不足,好比商家的 logo 顏色差異比較大的時候,可能打印出來的圖片會和商家意願的不太一致。好比以下 logo :
上面 logo 對於算法來講,黃色的灰度值比閾值小,因此二值化變成了白色,可是對於商家來講,logo 上紅色框內信息缺失了一部分,可能不能知足商家需求。
存在問題總結
針對之前使用的方案中存在的兩個問題,新的方案中加入了具體優化。
加入多算法求閾值 T ,而後根據每一個算法得出的二值圖和原圖的灰度圖進行對比,相識度比較高的做爲最優閾值 T 。
流程圖:
整個流程當中會並行三個算法進行二值圖處理,同時獲取二值圖的圖片指紋 hashCode ,與原圖圖片指紋 hashCode 進行對比,獲取與原圖最爲相近的二值圖做爲最優二值圖。
其中的OTSU算法上面已經說明,此次針對平均灰度算法和雙峯平均值算法進行解析。
平均灰度算法:
平均灰度算法其實很簡單,就是將圖片灰度處理後,求一下灰度圖的平均灰度。假設總灰度爲 sum ,總像素點爲 pixel_total ,則閾值 T :
<center> T = sum / pixel_total </center>
具體實現算法以下:
/** 平均灰度算法獲取二值圖 @param bit_map 圖像像素數組地址( ARGB 格式) @param width 圖像寬 @param height 圖像高 @param T 存儲計算得出的閾值 @return 二值圖像素數組地址 */ int * binary_image_with_average_gray_threshold_alg(int *bit_map, int width, int height, int *T) { double pixel_total = width * height; // 像素總數 if (pixel_total == 0) return NULL; unsigned long sum = 0; // 總灰度 u_char threshold = 0; // 閾值 int *gray_pixels = (int *)malloc(pixel_total * sizeof(int)); memset(gray_pixels, 0, pixel_total * sizeof(int)); int *p = bit_map; for (u_int i = 0; i < pixel_total; i++, p++) { // 分離三原色及透明度 u_char alpha = ((*p & 0xFF000000) >> 24); u_char red = ((*p & 0xFF0000) >> 16); u_char green = ((*p & 0x00FF00) >> 8); u_char blue = (*p & 0x0000FF); u_char gray = (red*38 + green*75 + blue*15) >> 7; if (alpha == 0 && gray == 0) { gray = 0xFF; } gray_pixels[i] = gray; sum += gray; } // 計算平均灰度 threshold = sum / pixel_total; for (u_int i = 0; i < pixel_total; i++) { gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF; } if (T) { *T = threshold; } return gray_pixels; }
雙峯平均值算法:
此方法實用於具備明顯雙峯直方圖的圖像,其尋找雙峯的谷底做爲閾值,可是該方法不必定能得到閾值,對於那些具備平坦的直方圖或單峯圖像,該方法不合適。該函數的實現是一個迭代的過程,每次處理前對直方圖數據進行判斷,看其是否已是一個雙峯的直方圖,若是不是,則對直方圖數據進行半徑爲 1(窗口大小爲 3 )的平滑,若是迭代了必定的數量好比 1000 次後仍未得到一個雙峯的直方圖,則函數執行失敗,如成功得到,則最終閾值取雙峯的平均值做爲閾值。所以實現該算法應有的步驟:
一、獲取原圖像的灰度圖
二、灰度直方統計
三、平滑直方圖
四、求雙峯平均值做爲閾值 T
其中第三步平滑直方圖的過程是一個迭代過程,具體流程圖:
具體實現算法以下:
// 判斷是不是雙峯直方圖 int is_double_peak(double *histogram) { // 判斷直方圖是存在雙峯 int peak_count = 0; for (int i = 1; i < 255; i++) { if (histogram[i - 1] < histogram[i] && histogram[i + 1] < histogram[i]) { peak_count++; if (peak_count > 2) return 0; } } return peak_count == 2; } /** 雙峯平均值算法獲取二值圖 @param bit_map 圖像像素數組地址( ARGB 格式) @param width 圖像寬 @param height 圖像高 @param T 存儲計算得出的閾值 @return 二值圖像素數組地址 */ int * binary_image_with_average_peak_threshold_alg(int *bit_map, int width, int height, int *T) { double pixel_total = width * height; // 像素總數 if (pixel_total == 0) return NULL; // 灰度直方圖,下標是灰度值,保存內容是灰度值對應的像素點總數 double histogram1[256] = {0}; double histogram2[256] = {0}; // 求均值的過程會破壞前面的數據,所以須要兩份數據 u_char threshold = 0; // 閾值 // 獲取灰度直方圖 int *gray_pixels = (int *)malloc(pixel_total * sizeof(int)); memset(gray_pixels, 0, pixel_total * sizeof(int)); int *p = bit_map; for (u_int i = 0; i < pixel_total; i++, p++) { // 分離三原色及透明度 u_char alpha = ((*p & 0xFF000000) >> 24); u_char red = ((*p & 0xFF0000) >> 16); u_char green = ((*p & 0x00FF00) >> 8); u_char blue = (*p & 0x0000FF); u_char gray = (red*38 + green*75 + blue*15) >> 7; if (alpha == 0 && gray == 0) { gray = 0xFF; } gray_pixels[i] = gray; // 計算灰度直方圖分佈,Histogram數組下標是灰度值,保存內容是灰度值對應像素點數 histogram1[gray]++; histogram2[gray]++; } // 若是不是雙峯,則經過三點求均值來平滑直方圖 int times = 0; while (!is_double_peak(histogram2)) { times++; if (times > 1000) { // 這裏使用 1000 次,考慮到過屢次循環可能會存在性能問題 return NULL; // 彷佛直方圖沒法平滑爲雙峯的,返回錯誤代碼 } histogram2[0] = (histogram1[0] + histogram1[0] + histogram1[1]) / 3; // 第一點 for (int i = 1; i < 255; i++) { histogram2[i] = (histogram1[i - 1] + histogram1[i] + histogram1[i + 1]) / 3; // 中間的點 } histogram2[255] = (histogram1[254] + histogram1[255] + histogram1[255]) / 3; // 最後一點 memcpy(histogram1, histogram2, 256 * sizeof(double)); // 備份數據,爲下一次迭代作準備 } // 求閾值T int peak[2] = {0}; for (int i = 1, y = 0; i < 255; i++) { if (histogram2[i - 1] < histogram2[i] && histogram2[i + 1] < histogram2[i]) { peak[y++] = i; } } threshold = (peak[0] + peak[1]) / 2; for (u_int i = 0; i < pixel_total; i++) { gray_pixels[i] = gray_pixels[i] <= threshold ? 0xFF000000:0xFFFFFFFF; } if (T) { *T = threshold; } return gray_pixels; }
測試執行時間數據:
iPhone 6: imageSize:260, 260; average_peak 使用時間:0.035254
iPhone 6: imageSize:800, 800; average_peak 使用時間:0.101282
通過測試,該算法在圖片比較小的時候,還算能夠,若是圖片比較大會存在較大性能消耗,並且根據圖片色彩分佈不一樣也可能形成屢次循環平滑,也會影響性能。對於 logo 來講,咱們處理的時候作了壓縮,通常都是很大,因此處理時間也在能夠接受返回內,並且進行處理和對比時,是在異步線程中,不會影響主流程。
圖片指紋 hashCode :
圖片指紋 hashCode ,能夠理解爲圖片的惟一標識。一個簡單的圖片指紋生成步驟須要如下幾步:
一、圖片縮小尺寸通常縮小到 8 * 8 ,一共 64 個像素點。
二、將縮小的圖片轉換成灰度圖。
三、計算灰度圖的平均灰度。
四、灰度圖的每一個像素點的灰度與平均灰度比較。大於平均灰度,記爲 1 ;小於平均灰度,記爲 0。
五、計算哈希值,第 4 步的結果能夠構成一個 64 爲的整數,這個 64 位的整數就是該圖片的指紋 hashCode 。
六、對比不一樣圖片生成的指紋 hashCode ,計算兩個 hashCode 的 64 位中有多少位不同,即「漢明距離」,差別越少圖片約相近。
因爲使用該算法生成的圖片指紋具備差別性比較大,由於對於 logo 來講處理後的二值圖壓縮到 8 8 後的類似性很大,因此使用 8 8 生成 hashCode 偏差性比較大,通過試驗,確實如此。因此,在此基礎上,對上述中的 一、五、6 步進行了改良,改良後的這幾步爲:
一、圖片縮小尺寸可自定義(必須是整數),可是最小像素數要爲 64 個,也就是 width * height >= 64 。建議爲 64 的倍數,爲了減小偏差。
五、哈希值不是一個 64 位的整數,而是一個存儲 64 位整數的數組,數組的長度就是像素點數量對 64 的倍數(取最大的整數倍)。這樣每生成一個 64 位的 hashCode 就加入到數組中,該數組就是圖片指紋。
六、對比不一樣指紋時,遍歷數組,對每個 64 爲整數進行對比不一樣位數,最終結果爲,每個 64 位整數的不一樣位數總和。
在咱們對商家 logo 測試實踐中發現,採用 128 * 128 的壓縮,能夠獲得比較滿意的結果。
最優算法爲 OTSU 算法例子:
最優算法爲平均灰度算法例子:
最優算法爲雙峯均值算法例子:
實際實驗中,發現真是中選擇雙峯均值的機率比較低,也就是絕大多數的 logo 都是在 OTSU 和平均灰度兩個算法之間選擇的。因此,後續能夠考慮加入選擇統計,若是雙峯均值機率確實特別低且結果與其餘兩種差很少大,那就能夠去掉該方法。
加入緩存機制,通常店鋪的 logo 和店鋪二維碼都是固定的,不多會更換,因此,在進入店鋪和修改店鋪二維碼時能夠對其進行預處理,並緩存處理後的圖片打印指令,後續打印時直接拿緩存使用便可。
因爲緩存的內容是處理後的打印指令字符串,因此使用 NSUserDefaults 進行存儲。
緩存策略流程圖:
這裏面爲何只有修改店鋪二維碼,而沒有店鋪 logo ?由於在咱們 app 中,logo 是不可修改的,只能在 pc 後臺修改,而登陸店鋪後,本地就能夠直接拿到店鋪信息;店鋪二維碼是在小票模板設置裏自行上傳的圖片,因此商家在 app 中是能夠自行修改店鋪二維碼的。
打印時圖片處理流程圖:
在新流程中,若是緩存中沒有查到,則會走老方案去處理圖片。緣由是考慮到,這時候是商家實時打印小票,若是選用新方案處理,恐怕時間會加長,使用戶體驗下降。老方案已經在線上跑了好久,因此使用老的方案處理也問題不大。
在後續規劃中加入幾點優化:
其中第二點,商家自主選擇閾值 T ,預覽效果以下:
![圖片上傳中...]