有赞零售小票打印图片二值化方案

 

有赞零售小票打印跨平台解决方案

 

 

做者:王前、林昊(鱼干)javascript

 

 

 

1、背景

零售商家的平常经营中,小票打印的场景无处不在,顾客的每笔消费都会收到商家打印出的消费小票,这个是顾客的消费凭证,因此小票的内容对顾客和商家都尤其重要。对于有赞零售应用软件来讲,小票打印功能也是必不可少的,诸多业务场景都须要提供相应的小票打印能力。html

  • 打印需求端

 

printRequirement

 

  • 小票业务场景

 

receiptType

 

  • 小票打印机设备类型

 

printerType

 

过去咱们存在的痛点:前端

  1. 每一个端各自实现一套打印流程,方案不统一。致使每次修改都会三端修改,并且 iOS 和 Android 必须依赖发版才可上线,不具备动态性,并且研发效率比较低。
  2. 打印小票的业务场景比较多,每一个业务都本身实现模板封装及打印逻辑,模板及逻辑不统一,维护成本大。
  3. 多种小票设备的适配,对于每一个端来讲都要适配一遍。

其中最主要的痛点仍是在于第一点,多端的不统一问题。因为不统一,致使开发和维护的成本成倍级增加。java

针对以上痛点,小票打印技术方案须要解决的三个主要问题:git

  1. iOS 、安卓和网页端的零售软件都须要提供小票样式设置和打印的能力,如何下降小票打印代码的维护和更新成本。
  2. 如何定制显示不一样业务场景的小票内容:不一样业务场景下的小票信息都不尽相同,好比购物小票和退款小票,商品信息的样式是同样的,可是支付信息是不同的,购物小票应当显示顾客的支付信息,退款小票显示商家退款信息。
  3. 如何更灵活的适配多种多样的小票打印机,从链接方式上分为蓝牙链接和 WIFI 链接,从纸张样式分为 80mm 和 58mm 两种宽度。

2、总体解决方案

针对以上三个问题,咱们提出了一个涉及前端、移动端和服务端的跨平台解决方案,github

  • 架构图

 

structureChart

 

架构设计的核心在于经过 JS 实现支持跨平台的小票解析脚本,并具备动态更新的优点;经过服务端下发可编辑的样式模板实现小票内容的灵活定制;客户端启动 JS 执行器执行 JS 小票脚本引擎(如下简称:JS 引擎)并负责打印机设备的链接管理。ajax

1 、JS 引擎设计

JS 引擎主要能力就是处理小票模版和业务数据,将业务数据整合到模版中(处理不了的交给移动端处理,好比图片),而后将整合模版数据转换成打印指令返给移动端。算法

  • 总体处理流程图

 

jsHandleFlow

 

  • 结构设计
    jsTemplateLayout

    • 小票格式中,打印机是一行一行的输出。那么基本输出布局单位,咱们定义为 layout
    • 默认一行有一个内容块,即一个 layout 里面有一个 content object
    • 当一行有多列内容的时候,即一个 layout 里面包含 N 个 content object 。 各自内容块有 pagerWeight 表明每一个内容的宽度占比
    • 每一行的后面的是一个占位符,用数据模型的 key 作占位

小票 layout 样式描述:json

jsLayoutDesc

 

content block 内容块: 后端

jsLayoutContentDesc

 

不一样类型内容所支持的能力:

jsPower

 

  • 模版编译

这里使用了 HandleBars.js 做为模板编译的库。此外,目前还额外提供了部分能力支持。

自定义能力:

jsCustomPower

 

  • 打印机设备适配

主要进行适配指令集解析适配,根据链接不一样设备进行不一样指令解析。目前已适配设备:365wifi 、 sunmi 、 sprt80 、 sprt58 、 wangpos 、 aclas 、 xprinter 。若是链接未适配的设备抛出找不到相应打印机解析器 error。

  • 调用对应打印机的 parser 指令解析流程

 

pastedImage

 

  • 兼容性问题

    • 切纸:支持外部传入是否须要切纸,防止外部发送打印指令时加入切纸指令后重复切纸问题,默认加切纸指令。
    • 一机多尺寸打印:存在一台打印机支持两种纸张打印( 80mm 、 58mm ),这时须要从外部传入打印尺寸,默认 80mm 。好比,sunmiT1 支持 80mm 和 58mm 打印,默认是 80mm 。
  • 容错处理

    • 因为模版解析有必定格式要求,因此一些特殊字符及转移字符存在数据中会存在解析错误。因此 JS 在传入数据时,作了一层过滤,将 "\\" 、 "\n" 、 "\b" ... 等字符去掉或替换,保证打印。
    • 若是在解析过程当中存在错误,将抛出异常给移动端捕获。

2 、模板管理服务

小票模板的动态编辑和下发,模版动态配置信息存储和各业务全量模版存储,提供移动端动态配置信息接口,拉取业务小票模版接口,各业务方业务数据接口。

  • 总体处理流程图

 

serverHandleFlow

 

  • 小票基础模版库存储示例

    serverTemplateStored

     

    shopId:店铺 ID

    business:业务方

    type:打印内容类型

    content:layout 中 content 内容

    sortWeight:排序比重,用于输出模板 layout 顺序

  • 动态设置数据存储示例

    serverDynamicTemplateStored

     

    shopId:店铺 ID

    business:业务方

    type:打印内容类型

    params:须要替换填充的内容

  • 接口返回整合后的小票模版 json

{
    "business": "shopping", "shopId": 111111, "id": 321, "version": 0, "layouts": [{ "name": "LOGO", "content": "[{\"content\":\"http://www.test.com/test.jpg\",\"contentType\":\"image\",\"textAlign\":\"center\",\"width\":45}]" },{ "name": "电话", "content": "[{\"content\":\"电话:{{mobile}}\",\"contentType\":\"text\",\"textAlign\":\"left\",\"fontSize\":\"default\",\"pagerWeight\":1}]" },...] } 
12345678910111213

其中相关动态数据后端已经作过整合替换,须要替换的业务数据保留在模板 json 中,等获取业务数据后由 JS 引擎进行替换。 上面 json 中 http://www.test.com/test.jpg 就是动态整合替换数据,{{mobile}} 是一个须要替换的业务数据。

3 、移动端

移动端除了动态模版配置以外,主要的就是打印流程。移动端只须要关心须要打印什么业务小票,而后去后端拉取业务小票模版和业务数据,将拉取到的数据传给 JS 引擎进行预处理,返回模版中处理不了的图片 url 信息,而后移动端进行下载图片,进行二值转换,输出像素的 16 进制字符串,替换原来模版中的 url ,最后将链接的打印机类型和处理后的模版传给 JS 引擎进行打印指令转换返回给打印机打印。

  • 动态模版配置

 

nativeDynamicConfi

 

动态配置小票内容,支持 LOGO 、店铺数据、营销活动配置等。左侧为在 80mm 和 58mm 上预览样式。经过动态配置模版,实现后端接口模版更新,而后能够实时同步修改打印内容。网页零售软件上动态配置内容和移动端同样。

  • 打印业务流程

 

nativePrintFlow

 

该业务流程,移动端彻底脱离数据,只须要作一些额外能力以及传输功能,有效解决了业务数据修改依赖移动端发版的问题。 Android 和 iOS 流程统一。

3、移动端功能设计

1 、动态化

动态化在本解决方案里是必不可少的一环,实时更新业务数据模板依赖于后端,可是 JS 解析引擎的下发要依靠移动端来实现,为了及时修复发现的 JS 问题或者快速适配新设备等功能。更新流程图以下:

 

nativeDynamicJS

 

这里说明一下,由于可能会出现执行 JS 的过程当中,正在执行本地 JS 文件更新,致使执行 JS 出错。因此在完成本地更新后会发送一个通知,告知业务方 JS 已更新完成,这时业务方可根据自身需求作逻辑处理,好比从新加载 JS 进行处理业务。

2 、JS 执行器

iOS 使用 JavaScriptCore 框架,Android 使用 J2V8 框架,具体框架的介绍这里就不说明了。JS 执行器设计包含加载指定 JS 文件,调用 JS 方法,获取 JS 属性,JS 异常捕获。

/** 初始化 JSExecutor @param fileName JS 文件名 @return JSExecutor */ - (instancetype)initWithScriptFile:(NSString *)fileName; /** 加载 JS 文件 @param fileName JS 文件名 */ - (void)loadSriptFile:(NSString *)fileName; /** 执行 JS 方法 @param functionName 方法名 @param args 入参 @return 方法返回值 */ - (JSValue *)runJSFunction:(NSString *)functionName args:(NSArray *)args; /** 获取 JS 属性 @param propertyName 属性名 @return 属性值 */ - (JSValue *)getJSProperty:(NSString *)propertyName; /** JS 异常捕获 @param handler 异常捕获回调 */ - (void)catchExceptionWithHandler:(JSExceptionHandler)handler; 
1234567891011121314151617181920212223242526272829303132333435363738

加载 JS 文件方法,能够加载动态下发的 JS 。逻辑是先判断本地下发的文件是否存在,若是存在就加载下发 JS ,不然加载 app 中 bundle 里面的 JS 文件。

- (void)loadSriptFile:(NSString *)fileName{ NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); if (paths.count > 0) { NSString *docDir = [paths objectAtIndex:0]; NSString *docSourcePath = [docDir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.js", fileName]]; NSFileManager *fm = [NSFileManager defaultManager]; if ([fm fileExistsAtPath:docSourcePath]) { NSString *jsString = [NSString stringWithContentsOfFile:docSourcePath encoding:NSUTF8StringEncoding error:nil]; [self.content evaluateScript:jsString]; return; } } NSString *sourcePath = [[YZCommonBundle bundle] pathForResource:fileName ofType:@"js"]; NSAssert(sourcePath, @"can't find jscript file"); NSString *jsString = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil]; [self.content evaluateScript:jsString]; } 
1234567891011121314151617

这时候可能会有人疑问,为何这里是直接强制加载本地下发 JS ,而不是对比版本优先加载。这里主要有两点缘由:

  • 动态下发 JS 文件,就是为了补丁或者优化更新,因此通常新版本下发配置不会存在
  • 为了支持 JS 版本回滚

JS 异常捕获功能,将异常抛出给业务方,可让调用者各自实现逻辑处理。

3 、缓存优化

因为模板和数据都在后端,须要拉取两次接口进行打印,因此须要提供一套缓存机制来提升打印体验。因为业务数据须要实时拉取,因此必须走接口,模板相对于业务数据来讲,能够容许必定的延迟。因此,模板采用本地文件缓存,业务数据采用和业务打印页面挂钩的内存缓存,业务数据只须要第一次打印是请求接口,从新打印直接使用。

流程图:

nativeCacheFlow

 

本缓方案存会存在偶现的模板不一样步问题,在即将打印时,若是网页后台修改了模板,就会出现本次打印模板不是最新的,可是在下一次打印时就会是最新的了。因为出现的概率比较低,模板也容许有一点延迟,因此不会影响总体流程。

对于离线场景,咱们在 app 中存放一个最小可用模板,专门用于离线下小票打印使用。为何是最小可用模板,由于离线下,业务数据及一些其余数据有可能不全,因此最小可用模板能够保证打印出来的数据准确性。

4 、图片处理

因为 JS 引擎是不能解析图片文件的,因此在最初模板中存在图片连接时,所有由移动端进行处理,而后进行替换。图片处理主要就是下载图片,图片压缩,二值图处理,图片像素点压缩(打印指令要求),每一个字节转换成 16 进制,拼接 16 进制字符串。

  • 下载图片

采用 SDWebImage 进行下载缓存,建立并行队列进行多图片下载,每下载成功一张后回到主线程进行后续的相关处理。全部图片都处理完成或,回调给 JS 引擎进行指令解析。

  • 图片压缩

根据 JS 引擎模板要求的 width(必须是 8 的倍数,后续说明),进行等比例压缩,转换成 jpg 格式,过滤掉 alpha 通道。

  • 二值图处理

遍历每个像素点,进行 RGB 取值,而后算出 RGB 均值与 255 的比值,根据比值进行取值 0 或 255 。这里没有使用直方图寻找阈值 T 的方式进行处理,是出于性能和时间考虑。

  • 像素点压缩

因为打印机指令要求,须要对转换成二值后的每一个点进行 width 上压缩,须要将 8 个字节压缩到 1 个字节,这里也是为何图片压缩时 width 必须是 8 的倍数的缘由,不然打印出来的图片会错位。

 

nativeImageByte

 

  • 16 进制字符串

由于打印机打印图片接收的是 16 进制字符串,因此须要将处理后的每一个字节转换成 16 进制字符,而后拼成一个字符串。

5 、实现屡次打印

因为业务场景须要,须要自动打印多张小票,因此设计了屡次打印逻辑。因为每次打印都是异步线程中,因此不能够直接循环打印,这里使用信号量 dispatch_semaphore_t ,在异步线程中建立和 wait 信号量,每次打印完成回调线程中 signal 信号量,实现屡次打印,保证每次打印依次进行。若是中途打印出错,则终止后续打印。

dispatch_async(dispatch_get_global_queue(0, 0), ^{ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); for (int i = 1; i <= printCount; i++) { if (stop) { break; } [self print:template andCompletionBlock:^(State state, NSString *errorStr) { dispatch_async(dispatch_get_main_queue(), ^{ if (errorStr.length > 0 || i == printCount) { if (completion) { completion(state, errorStr); } stop = YES; } dispatch_semaphore_signal(semaphore); }); }]; dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 15*NSEC_PER_SEC)); } }); 
1234567891011121314151617181920

4、总结与展望

本方案已经实施,在零售 app 中使用来看,已经知足目前大部分业务场景及需求,后续的开发及维护成本也会大幅度下降,提升了研发效率,接入新业务小票也比较方便。客户使用上来讲,使用体验和之前没有较大差异,同时在处理客户反映的问题来讲,也能够作到快速修改,实时下发等。不过目前还存在一些不足点,好比说图片打印的功能,还不能彻底知足全部图片都作到完美打印,毕竟图片处理考虑到性能体验方面;还有模板后续能够增长版本号,这样在模板存在异常时也能够回滚或兼容处理等;再者就是缓存优化能够后续进一步优化体验,好比加入模板推送,本地缓存优化等。

参考连接

 

 

 

 

 

 

1、背景

小票打印是零售商家的基础功能,在小票信息中,必然会存在一些相关店铺的信息。好比,logo 、店铺二维码等。对于商家来讲,上传 logo 及店铺二维码时,基本都是彩图,可是小票打印机基本都是只支持黑白二值图打印。为了商家的服务体验,咱们没有对商家上传的图片进行要求,商家能够根据实际状况上传本身的个性化图片,所以就须要咱们对商家的图片进行二值图处理后进行打印。

此次文章是对《有赞零售小票打印跨平台解决方案》中的图片的二值图处理部分的解决方案的说明。

2、图像二值化处理流程

图像二值化就是将图像上的像素点的灰度值(若是是 RGB 彩图,则须要先将像素点的 RGB 转成灰度值)设置为 0 或 255 ,也就是将整个图像呈现出明显的黑白效果的过程。

其中划分 0 和 255 的中间阈值 T 是二值化的核心,一个准确的阈值能够获得一个较好的二值图。

 

二值化总体流程图:

 

从上面的流程图中能够看出,获取灰度图和计算阈值 T 是二值化的核心步骤。

3、之前的解决方案

之前使用的方案是,首先将图像处理成灰度图,而后再基于 OTSU(大津法、最大类间方差法)算法求出分割 0 和 255 的阈值 T ,而后根据 T 对灰度值进行二值化处理,获得二值图像。

咱们的全部算法都有使用 C 语言实现,目的为了跨平台通用性。

流程图:

 

灰度算法:

对于 RGB 彩色转灰度,有一个很著名的公式:

 

Gray = R * 0.299 + G * 0.587 + B * 0.114

 

这种算法叫作 Luminosity,也就是亮度算法。目前这种算法是最经常使用的,里面的三个数据都是经验值或者说是实验值。由来请参见 wiki 。

然而实际应用时,你们都但愿避免低速的浮点运算,为了提升效率将上述公式变造成整数运算和移位运算。这里将采用移位运算公式:

Gray = (R * 38 + G * 75 + B * 15) >> 7

 

若是想了解具体由来,能够自行了解,这里不作过多解释。

具体实现算法以下:

/**
 获取灰度图

 @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 上红色框内信息缺失了一部分,可能不能知足商家需求。

  • 存在问题总结
    • 算法单一,对于不一样图片处理结果可能与预期不一致
    • 每次打印都对图片进行处理,没有缓存机制

 

4、新的解决方案

针对之前使用的方案中存在的两个问题,新的方案中加入了具体优化。

4.1 问题一 (算法单一,对于不一样图片处理结果可能与预期不一致)

加入多算法求阈值 T ,而后根据每一个算法得出的二值图和原图的灰度图进行对比,相识度比较高的做为最优阈值 T 。

流程图:

 

整个流程当中会并行三个算法进行二值图处理,同时获取二值图的图片指纹 hashCode ,与原图图片指纹 hashCode 进行对比,获取与原图最为相近的二值图做为最优二值图。

其中的OTSU算法上面已经说明,此次针对平均灰度算法和双峰平均值算法进行解析。

平均灰度算法:

平均灰度算法其实很简单,就是将图片灰度处理后,求一下灰度图的平均灰度。假设总灰度为 sum ,总像素点为 pixel_total ,则阈值 T :

 

T = sum / pixel_total

 

具体实现算法以下:

/**
 平均灰度算法获取二值图

 @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 和平均灰度两个算法之间选择的。因此,后续能够考虑加入选择统计,若是双峰均值几率确实特别低且结果与其余两种差很少大,那就能够去掉该方法。

 

4.2 问题二 (每次打印都对图片进行处理,没有缓存机制)

加入缓存机制,通常店铺的 logo 和店铺二维码都是固定的,不多会更换,因此,在进入店铺和修改店铺二维码时能够对其进行预处理,并缓存处理后的图片打印指令,后续打印时直接拿缓存使用便可。

因为缓存的内容是处理后的打印指令字符串,因此使用 NSUserDefaults 进行存储。

 

缓存策略流程图:

 

这里面为何只有修改店铺二维码,而没有店铺 logo ?由于在咱们 app 中,logo 是不可修改的,只能在 pc 后台修改,而登陆店铺后,本地就能够直接拿到店铺信息;店铺二维码是在小票模板设置里自行上传的图片,因此商家在 app 中是能够自行修改店铺二维码的。

打印时图片处理流程图:

 

 

在新流程中,若是缓存中没有查到,则会走老方案去处理图片。缘由是考虑到,这时候是商家实时打印小票,若是选用新方案处理,恐怕时间会加长,使用户体验下降。老方案已经在线上跑了好久,因此使用老的方案处理也问题不大。

5、将来指望与规划

在后续规划中加入几点优化:

  • 添加新流程处理统计,对商家 logo 和店铺二维码处理后的最优算法进行统计,为后续优化作数据准备。
  • 处理后的结果若是商家不满意,商家能够自主选择处理二值图的阈值 T ,达到满意为止。
  • 图片更新不及时问题,PC 后台修改了图片没法及时更新本地缓存。
  • 图片精细化处理,针对二维码能够采用分块处理算法。

其中第二点,商家自主选择阈值 T ,预览效果以下:

 

 

 

 

参考连接

相关文章
相关标签/搜索