【Keras】一文弄懂如何使用Keras打造一个实时可用交通标志识别App

深度学习这几年是一个很火的技术,也有很多涌入这个领域,对于新手来说,入门很容易,训练一个简单的模型,调下参数谁都会。对于任何一个公司主要有高质量的数据,随便一个研究生,本科生都能复现大神的模型直接部署到应用上。困难的是如何找到应用的方向,未来深度学习的趋势也肯定是比拼如何快速优质应用这些技术为产品服务。我在这个方向也接近有一年经验了。最开始做的是缺陷检测,接触最多的是分类模型,后来也逐渐接触分割,检测等等,在这个过程中自己也踩了不少坑,也学了不少东西,从最开始的在服务器上追求精度,到现在直接在移动端部署模型,效率精度兼备。这篇文章主要是以交通标志分类为例给新人提供一个深度学习从数据查找,模型训练,以及应用到生产环境整个流程的思路。

大纲

数据来源
模型训练
模型转换
模型部署

数据来源

对于我们做应用的人而言,最重要的应该就是数据。数据往往是一个算法公司的主要财产之一。那么如何为自己的问题获取对应的数据呢?先说结论:大型公开数据集 > 迁移学习 > 自己标注。
如果自己做的问题有大型公开数据集最好,那么直接用大型数据集就行,免去自己查找数据的麻烦,只需要专注于选模型,调参数等。这里给几个个CV方向的数据查找网址:

  1. Google最近推出的数据集搜索引擎
    https://toolbox.google.com/datasetsearch
  2. Kaggle 上面的一些比赛也会有一些公开数据
    https://www.kaggle.com
  3. Google检索
    这就主要考验一个人的检索能力了,翻墙是必然的。
    当然使用公开数据集的时候也要遵守相应的规范,看是不是可以直接拿来商用。这方面其实中国公司都不怎么注意。
    如果没有大型公开数据集就要看有没有小一点的数据集,然后在这个基础上使用在大型数据集上训练好的模型权重进行迁移学习。
    最开始来现在这个公司的时候,给公司做一个人像分割模型。和最新版微信的制作自己的表情背后的技术实现是一致的。但是我们有的数据只有2000张左右,先使用Pascal数据训练一个物体分割数据模型,然后在这个基础上使用我们自己的人像数据迁移学习一个人像模型,最后取得了比较好的效果。可以参考我另一篇文章:
    Tensorflow移动端模型转换
    实在没有办法可以自己标注数据,但是这个是成本很大的问题,还有准确度的问题。当然这只是针对小公司而言,对于大公司数据也是壁垒之一。
    这篇文章我们使用一个交通标志数据集。
    下载训练和测试数据:
    BelgiumTSC_Training (171.3MBytes)
    BelgiumTSC_Testing (76.5MBytes)
    下载后分别命名为train/val文件夹放在traffic_sign目录下:
.
├── data
│   └── traffic_sign
│       ├── train
│       └── val

注意这里我们只将这个数据用来学习使用。
获得数据之后,最好大致检查一遍所有的数据,观察下数据质量,统计下每个类的数目,这样数据有个大致的了解。

模型训练

这里我们使用Keras框架,Tensorflow作为后端来进行训练,其实对于一般做移动端应用的公司我觉得使用Keras,然后转换到移动端推理框架挺方便的。
这里就不介绍Keras使用了,有需要的童鞋可以参考我的其他文章,介绍了很多Keras使用。这里提供一个训练网络和读取数据的易用接口,省去很多重复性工作:
train.py

"""
Easy to use train script for different kinds of networkds and dataset...
@author: Vincent

"""
import os
import glob
from collections import Counter
import numpy as np
import keras
from keras.optimizers import SGD
import keras.backend as K
from keras.models import load_model
from keras.preprocessing.image import ImageDataGenerator
from keras import callbacks
import argparse
from simplenet import SimpleNet
from learning_rate import create_lr_schedule


if __name__ == "__main__":
        ap = argparse.ArgumentParser()
        ap.add_argument(
                '--dataset',
                type=str,
                default='traffic_sign',
                help='directory name of dataset, which should have structure ./train ./val and according classes to suit flow from directory'
        )
        ap.add_argument(
                '--batch_size',
                type=int,
                default=16,
                help='training batch size'
        )
        ap.add_argument(
                '--input_shape',
                type=list,
                default=(112,112,3),
                help='input image shape',
        )
        ap.add_argument(
                '--epochs',
                type=int, 
                default=100,
                help='training epochs'
        )
        ap.add_argument(
                '--class_weight_balance_mode',
                type=bool,
                default=True,
                help='whether to enable class weights mode to deal with classs unbalance'
        )
        ap.add_argument(
                '--model',
                type=str,
                default="SimpleNet",
                help="which model to use to train"
        )

        args = vars(ap.parse_args())
        num_classes = len([f for f in os.listdir(os.path.join('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}'.format(args['dataset']),'train')) 
                        if os.path.isdir(os.path.join('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}/train/'.format(args['dataset']),f))])
        print("num_classes:", num_classes)
        num_train_samples = len(glob.glob('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}/train/*/*.ppm'.format(args['dataset'])))
        num_val_samples = len(glob.glob('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}/val/*/*.ppm'.format(args['dataset'])))
        if args['class_weight_balance_mode']:
                trained_model_path = './models/{0}_with_class_weights.h5'.format(args['dataset'])
        else:
                trained_model_path = './models/{0}_without_class_weights.h5'.format(args['dataset'])
         
        train_gen = ImageDataGenerator(
                    rescale = 1/255.,
                    samplewise_center=True,
                    samplewise_std_normalization=True,
                    rotation_range=15,
                    zoom_range=0.15,
                    width_shift_range=0.1,
                    height_shift_range=0.1,
                    horizontal_flip=True,
                    )
        val_gen = ImageDataGenerator(
                    rescale = 1/255.,
                    samplewise_center=True,
                    samplewise_std_normalization=True
                    )

        train_iter = train_gen.flow_from_directory('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}/train'.format(args['dataset']), 
                            target_size=args['input_shape'][0:2], 
                            batch_size=args['batch_size'],
                            # color_mode='grayscale',
                            # save_to_dir='./aug_train',
                            class_mode='categorical', 
                            interpolation='bicubic')

        val_iter = train_gen.flow_from_directory('/Users/yuhua.cheng/Opt/temp/traffic_sign/data/{0}/val'.format(args['dataset']), 
                            target_size=args['input_shape'][0:2], 
                            batch_size=args['batch_size'],
                            # color_mode='grayscale',
                            # save_to_dir='./aug_val',
                            class_mode='categorical',
                            interpolation='bicubic')
        # 针对样本不均衡问题进行weight balance
        class_weight = {}
        counter = Counter(train_iter.classes)
        max_val = float(max(counter.values()))
        class_weights = {class_id:max_val/num_images for class_id, num_images in counter.items()}
        print("class_weights for samples:", class_weights)
        # 
        model = locals()[args['model']](input_shape=args['input_shape'], num_classes=num_classes)
        # sgd = SGD(lr=1e-1, decay=1e-6, momentum=0.9, nesterov=True)
        sgd = keras.optimizers.Adadelta()

        # create callbacks
        tensorboard = callbacks.TensorBoard(log_dir='./logs', write_graph=False)
        learning_rate = callbacks.LearningRateScheduler(create_lr_schedule(epochs=args['epochs'], lr_base=0.01, mode='progressive_drops'))
        callbacks = [tensorboard, learning_rate]

        # compile the model
        model.compile(optimizer=sgd, loss='categorical_crossentropy', metrics=['accuracy'])

        # train the model
        if args['class_weight_balance_mode']:
                history = model.fit_generator(
                    generator = train_iter,
                    steps_per_epoch = num_train_samples // args['batch_size'],
                    epochs=args['epochs'],
                    validation_data = val_iter,
                    validation_steps = num_val_samples // args['batch_size'],
                    class_weight = class_weights,
                    verbose = 1,
                    callbacks = callbacks)
        else:
                history = model.fit_generator(
                    generator = train_iter,
                    steps_per_epoch = num_train_samples // args['batch_size'],
                    epochs = args['epochs'],
                    validation_data = val_iter,
                    validation_steps = num_val_samples // args['batch_size'],
                    verbose = 1,
                    callbacks = callbacks)
                
             
        model.save(trained_model_path)

我的网络结构:
simplenet.py

"""
my simplenet for experiments
"""
import keras
from keras.models import load_model, Model
from keras import regularizers, optimizers
from keras.layers import Input, Conv2D, Activation, Dense, Flatten
from keras.layers import BatchNormalization, Dropout
from keras.layers import MaxPooling2D, GlobalMaxPooling2D, GlobalAveragePooling2D
from keras.datasets import cifar10


def conv2d_bn_drop(x, filters, kernel_size=3, strides=1, padding='same', activation='relu', use_bias=False, dropout_rate=0, name=None):
    """Utility fucntion to apply conv + BN + dropout
    # Arguments:

    # Returns:
        Output tensor after applying 'Conv2D' and 'BatchNormalization' and "DropOut'
    """
    if name is not None:
        conv_name = name + '_conv'
        bn_name = name + '_bn'
        drop_name = name + '_dropout'
        ac_name = name + '_' + activation
    else:
        conv_name = None
        bn_name = None
        drop_name = name + '_dropout'
    x = Conv2D(filters, kernel_size, strides=strides, padding=padding, use_bias=use_bias, name=conv_name)(x)
    x = BatchNormalization(axis=-1, scale=False, name=bn_name)(x)
    x = Activation(activation, name=ac_name)(x)
    x = Dropout(rate=dropout_rate, name=drop_name)(x)
    return x

def conv2d_bn_pooling_drop(x, filters, kernel_size=3, strides=1, padding='same', activation='relu', use_bias=False, pooling="max", dropout_rate=0, name=None):
    """Utility fucntion to apply conv + BN + dropout
    # Arguments:

    # Returns:
        Output tensor after applying 'Conv2D' and 'BatchNormalization' and "DropOut'
    """
    if name is not None:
        conv_name = name + '_conv'
        bn_name = name + '_bn'
        drop_name = name + '_dropout'
        ac_name = name + '_' + activation
    else:
        conv_name = None
        bn_name = None
        drop_name = name + '_dropout'
    x = Conv2D(filters, kernel_size, padding=padding, use_bias=use_bias, name=conv_name)(x)
    x = BatchNormalization(axis=-1, scale=False, name=bn_name)(x)
    if pooling == 'max':
        x = MaxPooling2D(pool_size=(2,2), strides=2, padding='valid')(x)
    else:
        x = AveragePooling2D(pool_size=(2,2), strides=2, padding='valid')(x)
    x = Activation(activation, name=ac_name)(x)
    x = Dropout(rate=dropout_rate, name=drop_name)(x)
    return x
def conv2d_pooling_bn_drop(x, filters, kernel_size=3, strides=1, padding='same', activation='relu', use_bias=False, pooling="max", dropout_rate=0, name=None):
    """Utility fucntion to apply conv + BN + dropout
    # Arguments:

    # Returns:
        Output tensor after applying 'Conv2D' and 'BatchNormalization' and "DropOut'
    """
    if name is not None:
        conv_name = name + '_conv'
        bn_name = name + '_bn'
        drop_name = name + '_dropout'
        ac_name = name + '_' + activation
    else:
        conv_name = None
        bn_name = None
        drop_name = name + '_dropout'
    x = Conv2D(filters, kernel_size, padding=padding, use_bias=use_bias, name=conv_name)(x)
    if pooling == 'max':
        x = MaxPooling2D(pool_size=(2,2), strides=2, padding='valid')(x)
    else:
        x = AveragePooling2D(pool_size=(2,2), strides=2, padding='valid')(x)
    x = BatchNormalization(axis=-1, scale=False, name=bn_name)(x)
    x = Activation(activation, name=ac_name)(x)
    x = Dropout(rate=dropout_rate, name=drop_name)(x)
    return x

def SimpleNet(input_tensor=None, stride=2, weight_decay=1e-2, pooling="Max", act='relu',
input_shape=(227,227,3), num_classes=10):
    s = stride
    act = 'relu' 
    
    if input_tensor is None:
        input_tensor = Input(shape=input_shape)   
    
    x = conv2d_bn_drop(input_tensor, 64, (7,7), strides=2, padding='same', activation='relu', name="block1_0")
    
    x = conv2d_bn_drop(x, 64, (3,3), padding='same', activation='relu', name="block1_1")
    
    x = conv2d_bn_drop(x, 96, (3,3), padding='same', activation='relu', name="block2_0")
    
    x = conv2d_bn_pooling_drop(x, 96, (3,3), padding='same', activation='relu', name="block2_1")
    
    x = conv2d_bn_drop(x, 96, (3,3), padding='same', activation='relu', name="block2_2")
    
    x = conv2d_bn_drop(x, 128, (3,3), padding='same', activation='relu', name="block3_0")
    
    x = conv2d_pooling_bn_drop(x, 128, (3,3), padding='same', activation='relu', name="block4_0")
    
    x = conv2d_bn_drop(x, 160, (3,3), padding='same', activation='relu', name="block4_1")
    
    x = conv2d_bn_pooling_drop(x, 160, (3,3), padding='same', activation='relu', dropout_rate=0.3, name="block4_2")

    x = Conv2D(filters=256, kernel_size=(3,3), strides=1, padding="same", activation='relu', name='block5_0_conv')(x)
    
    x = Conv2D(filters=512, kernel_size=(3,3), strides=1, padding="same", activation='relu', name='cccp5')(x)
    
    x = MaxPooling2D(pool_size=(2,2), strides=2, padding='valid', name='poolcp5')(x)
    
    x = Conv2D(filters=512, kernel_size=(3,3), strides=2, padding="same", activation='relu', name='cccp6')(x)
    
    x = GlobalAveragePooling2D()(x)
    x = Dense(num_classes)(x) 
    x = Activation('softmax')(x)
    
    model = Model(inputs=input_tensor, outputs=x)
    model.summary()
    return model

if __name__ == '__main__':
    input_tensor = Input(shape=(227, 227,3))
    model = SimpleNet(input_tensor)

准备好数据和网络配置文件之后在tran.py训练脚本中传入相应的参数,直接训练便可。
训练100 epochs之后就有0.945-0.95的准确度了,说明我们的模型效果还可以。


image.png

训练好模型之后一般需要在真实环境测试一下:
测试脚本:

import cv2
import os
import glob
import numpy as np
from matplotlib import pyplot as plt
from keras.models import load_model
from imageio import imread

image_files = [f for f in os.listdir('./data/traffic_sign/test') if not f.startswith('.')]
classes = sorted(os.listdir('./data/traffic_sign/val'))
model = load_model('./models/traffic_sign_with_class_weights.h5')
model.summary()
for image_file in image_files:
    img = imread(os.path.join('./data/traffic_sign/test', image_file))
    plt.subplot(1,2,1)
    plt.imshow(img)
    plt.title("img")
    img = cv2.resize(img, (112,112))
    img = img.astype("float32")
    img = (img - np.mean(img)) / np.std(img)
    img = np.expand_dims(img, 0)
    label = np.argmax(model.predict(img))
    label_image = imread(glob.glob('./data/traffic_sign/train/{0}/*.ppm'.format(classes[label]))[0])    
    plt.subplot(1,2,2)
    plt.imshow(label_image)
    plt.title("predicted img")
    plt.show()
Screen Shot 2019-01-05 at 12.00.15 PM.png

Screen Shot 2019-01-05 at 12.02.18 PM.png

看起来还可以哈

一般很多人的文章调完参数,达到一定的准确度,观察一些测试数据,就不介绍了。然而你有这个模型,如何将它应用到生产环境中还有一段路要走。接下来的部分就介绍如何将训练好的模型移植到移动端,打造一个真正实时可用的App。

模型转换

这一小结介绍如何将模型转换到移动端可用框架。
现有的移动端推理框架有很多,如CoreML, tensorflow lite,Caffe2等。需要了解的话,可以参考下我这篇文章: Tensorflow移动端模型转换。国内ncnn的口碑和速度算是比较好的了,用的人也比较多。这里我们选用苹果自带的CoreML,CoreML入门比较简单,不需要太多配置,将模型格式转化正确便可,笔者不是做IOS开发的,也是在前人的基础上进行一些修改。

我们遇到的第一个问题是需要将Keras模型转换到CoreML可用的格式, 这里提供一个转换脚本(版本不同会有接口的变换, 这里是python2, Keras 2.1.6, tensorflow 1.12.0):

  import coremltools
  import keras
  from keras.models import load_model
  from keras.utils.generic_utils import CustomObjectScope
  class_labels = []
  for i in range(62):
       class_labels.append(str(i))

   with CustomObjectScope({'relu6': keras.applications.mobilenet.relu6}):
      keras_model = load_model('traffic_sign_with_class_weights.h5')
      coreml_model = coremltools.converters.keras.convert(keras_model,
                                                      input_names=['input_1'],
                                                      image_input_names='input_1',
                                                      output_names='activation_1',
                                                      image_scale=2/255.0,
                                                      red_bias=-1,
                                                      green_bias=-1,
                                                      blue_bias=-1,
                                                      class_labels=class_labels)
  
 coreml_model.save('traffic_sign_with_class_weights.mlmodel')

里面具体的参数意义可以参考我的Tensorflow移动端模型转换
正确转换之后我们就得到CoreML下可用的深度学习模型了,剩下的只需要在IOS工程中正确调用便可,稍微有些IOS 开发相关的知识就能完成。
有需要的童鞋可以关注下这个github list:
https://github.com/likedan/Awesome-CoreML-Models
里面有很多CoreML相关的Demo,可以用来进行二次开发。
最后的结果, 分类的label按照训练数据的子类文件夹排序:

IMG_3197.PNG

至此我们就完成一个深度学习应用的开发了,这里只是抛砖引玉,要实现其他功能的应用,流程也大致如此。
希望这篇文章可以对入门计算机视觉的童鞋有所裨益,有什么问题都可以留言或者私信讨论。
Todo:
完成ncnn 调用的 demo

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容