AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

Posted 品质出行技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)相关的知识,希望对你有一定的参考价值。

问题背景

Core ML作为Apple在2017年推出的机器学习框架,有着很多糟心的设计:比如和主流机器学习模型格式不兼容,需要通过CoreML Tools手动转换、没有专业的模型训练模块(playground的中的训练方式感觉更像是玩具)。但由于其在ios平台针对设备的性能进行了优化,有着执行效率高、调用接口简单等特点,依旧赢得了大量的用户。

由于业务需要,我们也尝试在工程中去使用Core ML框架,本篇主要是记录了团队使用Core ML 时,工程上遇到的各种问题。关于Core ML在网上查了不少资料,但是能查到相关的解答还是比较少的,因此在这里给大家踩个坑,供大家参考,欢迎大家一起交流学习。


最初的尝试

学写代码自然先看官方文档,Apple官方给出工程集成的实例很简单,开发者只要把.mlmodel文件拖入工程,Apple的编译器会自动完成所有有的处理。

Xcode都帮我们自动完成了些什么呢?为了了解这些幕后的故事,我们做了一个简单的尝试:新建一个工程然后放入训练好的.mlmodel文件,然后在程序中使用core ML去调用模型。Apple给出的示例是通过引用.mlmodel文件同名的.h文件或者模块名来调用,我们也采用了这种方式。同时他的文档也指出,当添加模型到工程中的时候Xcode会自动生成一个模型入口访问文件(根据语言会变化,Objective-C就是.h和.m文件,swift是.swift,以下使用的语言是Objective-C),实际这个过程在Xcode10中是在编译的时候才完成的。编译.mlmodel文件时会产生两部分:

1. 在工程的buildtemp目录(Intermediates下级目录)下会生成一个入口访问文件,如下图的testmlmodel.h.m 文件。


2. 在工程的products目录下则会生成.mlmodelc文件夹,其中coremldata.bin记录了模型的元数据信息,例如作者姓名,模型描述等,还有所有分类标签等,model0和model1则记录了模型的网络以及权重等信息,mlmodelc其实才是模型的实际存储文件,目录结构如下图。 

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)


.mlmodelc会被当做资源文件,放入到APP文件夹中;生成的.m文件会在编译时被Xocde编译成.o然后link到二进制包中,如下图

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)


了解了.mlmodel文件编译的过程,我们就可以尝试在我们的APP里添加机器学习模型了。由于我们的业务只是滴滴出行APP中的一部分,所以我们开发的代码只是整个APP的一个组件,整个APP开发是由CocoaPods做管理的,我们像之前的例子那样,将.mlmodel文件加入到的source_files中,编译运行后发现提示找不到模型。


为什么找不到模型呢?问题一定出在 CocoaPods 上了。


CocoaPods是一个iOS开发的依赖管理工具,开发者只要按照一定的规则配置podspec文件,经过安装后就能为工程添加组件。podspec配置也很简单,首先需要配置组件的基本信息,其中包括name、version、source等关键字;其次定义好组件build需要的文件以及资源即可,例如通过source_files来确定组件要编译的文件,resources确定组件需要的资源文件,frameworks可以设置组件依赖的framework等等,如果以上还不能满足你,CocoaPods还为你提供了丰富的接口,原则上凡是Xcode有的设置podspec都是可以配置的。整个架构如下图:

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

CocoaPods 通常会管理文件的编译以及资源的打包拷贝,但是这些都是限于已存在的文件,对于.mlmodel文件这种会在编译过程中产生中间文的情况,就爱莫能助了。我们打开APP包后,并没有发现.mlmodelc文件,说明 CocoaPods 并没有将编译后产生的资源文件拷贝进来,所以运行时才会一直提示找不到模型。

原因找到了,我们该怎么整理一下呢?既然资源文件找不到,那我们把他copy到APP文件夹就好了.mlmodel文件可以正常编译,继续让他保持就好了,然后把已经生成好的.mlmodelc文件方在podspec 中设置为资源,这样就可以把.mlmodelc文件放到APP里了。我们按照这样的思路设置后,找不到模型的问题总算解决了,大致的结构如下图。

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

podspec文件配置如下图,红线部分是将生成的.mlmodelc模型资源文件添加到资源规则当中

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

需要注意 CocoaPods 的资源配置字段使用不同调用的路径也会不同,比如配置resources字段会直接将资源文件拷贝到APP包的根目录下,resource_bundles则会给每个组件产生一个放APP包根目录下的.bundle文件夹,所有组件配置的资源都会在这个文件夹下,前一种情况只需要访问APP的mainbundle,而后一种则需要通过bundle名称才能找到对应路径。

经过了一个月的平静后,我们发现了新的问题:

工程师A: 你们的组件使用预编译包以后,.mlmodel文件会产生duplicate symbol错误啊

我: 喵喵喵?

第一次改进 —— 解决预编译包的重复定义的问题

先把自动生成的文件集成到工程中吧。

先做个科普,什么是预编译?由于Objective-C要机器识别前是需要编译的,整个 APP 构建过程是先要将源文件编译成.o文件,然后再将多个.o文件, link成一个二进制文件。当文件太多时整个构建过程就是个灾难,拿滴滴出行这样的规模的软件举例,如果关联的代码文件全部都是源文件,估计工程构建一次会超过1个小时。为了避免这种情况,我们的发版平台会对集成的组件进行处理,开发者拉到他人开发的组件可以是已经完成编译的二进制文件,在iOS开发中一般都是扩展名为.a的文件,这样在APP的构建过程中编译器只需要做link操作即可,大大节约了开发者的时间。大致流程如下图:

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

看了A工程师发来的图后,我明白了,平台在产生预编译包以后,组件依旧会保留.mlmodel文件,所以当再次编译时,.mlmodel文件会再次编译一遍,而之前编译生成的.a文件会包含.mlmodel文件生成的.m文件中类的symbol,所以在link的时候就会出现重复的错误。这种情况下为先解决问题,我们可以将生成的.h 和 .m文件 以及.mlmodelc文件分别放在podspec的source_files字段和resource_bundle字段,大体上结构如下图所示。 

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

podspec中去掉了.mlmodel的编译,将.h 和 .m文件放在了classes文件夹下,.mlmodelc文件继续以资源的规则添加到resource_bundle中。

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

嗯嗯,虽然矬了点,每次模型更换包都要将生成好的文件手动替换.mlmodel和资源文件,暂时就这样吧。。。

老大: 正好出问题,能不能想一个更自动化的方案,比如放进去.mlmodel就完事了。

我: ……好

第二次改进 —— 尝试自动载入.mlmodel

要不把.mlmodel想办法放到主工程编译?

要想解决编译duplicate symbol的问题也很简单,只要在打包平台生成预编译包的时候,把mlmodel文件排除掉就行了,但是即使这样,也需要手动拷贝呀,一步到位追求极致才是我们要做的。

ok,我们再来捋一捋思路,我们最终必须满足一下两个条件:

  • 我们只需要输入.mlmodel文件

  • 业务组件不能编译.mlmodel文件

看着这两个条件我逐渐陷入沉思,以下是大型精分现场:

精分A: 既然.mlmodel不能在组件内编译,那怎么能引用到.h呢?

精分B: 要是能放在主工程里编译,不但编译了产生,还能把拷贝资源文件的动作都省了?

精分C: 那A的问题也解决不了呀。还有你是一个组件怎么能取修改主工程呢?

精分A: C突然给我了一个思路,引用的问题可以写一个headerSearch过去呀

精分B: headerSearch访问主工程?你能知道路径么?时机是什么时候?

精分C: 我觉得最好的时机是post_install但是这个需要权限。

精分A: 可以先试试在post_install写脚本去主工程修改,至于连接headersearch的问题可以用相对路径尝试一下。

精分B: ....

经过一段时间的思考,思路大致清晰起来:

  • 可以在post_install的钩子函数中添加脚本,往主工程中添加链接.mlmodel文件,这样主工程就能生成.h.m文件和.mlmodelc文件,最终编译产物会被自动添加到APP包中,Pod组件不受任何影响。

  • 组件为了能编译,实际上只需要告诉编译器能访问到.h即可,至于在组件内是否有对应实现并没有关系。了解了这种特性,我们可以在podspec中配置headersearch,用来访问到主工程生成的.h文件。

  • 在post_install写还有一个好处是,如果所有的组件都使用一个规则,就可以对所有的组件都能生效。

经过了一天的尝试,之前的思路就被推翻了,两个亟需解决的问题抱在面前:post_install的钩子函数没有办法取到主工程的project对象,这样就没有办法往主工程添加文件链接,我们需要自己写一个Ruby脚本来完成这个工作;构建主工程时会先构建主工程的依赖组件以及link组件,组件的构建往往都是在主工程之前,这时组件所需要.h文件并没有准备好,必须想办法在业务组件编译之前就要准备好需要访问的.h文件。

综合上面的问题,我们做了以下4步尝试

  • 添加一个新的组件包含.mlmodel文件,用于编译生成.h.m文件和模型资源文件。

  • 在业务组件中podspec添加指向新添加组件buildtemp目录的headersearch配置;添加执行脚本入口,并配置执行时机在编译之前。

  • 添加一段脚本,用于执行Ruby脚本,主要作用是编辑主工程的xcodeproj添加链接.mlmodel,并且启动新添加的组件的构建。

  • 在post_install中剔除主工程对新添加的组件的依赖。

整体结构图如下:

AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)

这个方案最大的工作量就是写脚本,一共两部分,一部分是编译新组件,生成.h.m文件;另一部分是用Ruby写主工程引用.mlmodel文件的脚本。之所以需要使用Ruby是因为CocoaPods是用Ruby写的,其中有个模块就叫Xcodeproj,专门用于编辑Xcode的工程管理文件,我们修改主工程可以直接使用这个模块。之前对Shell脚本和Ruby可以说写的很少,单CocoaPods的文档就查了好久。写脚本的同时就在想:能不能直接用commandline直接生成.h.m文件就好了?这样就不用添加新组件了。

第三次改进 —— 去繁存简

能不能简单点?

虽然解决了问题,但是现在这种方案是不是有点太臃肿了?首先我们需要新建一个组件来储存.mlmodel文件,之所以新建组件是为了方便构建,如果我们能用 commandline 直接去编译.mlmodel文件是不是就可以不用新建一个组件了?其次我们使用新组建需要修改主工程的podfile文件,影响面比较大,想要修改的阻力也会大很多,而且可能很多人都不更新podfile文件,导致整个流程的失败。再次写的脚本比较有有针对性,路径和名称都用了自己工程的,能不能更加通用?

往主工程添加.mlmodel文件并编译这一步是不能省了,需要想办法既能删除新建组件,又能不影响.mlmodel文件在组件构建之前的编译,最后脚本的入口参数也要收拢到一处。

仔细阅读了.mlmodel文件的编译日志后,发现生成.h.m文件与.mlmodelc文件的过程都是有各自独自的指令的,我们只要去调用即可。Xcode通常会先生成.mlmodelc,然后根据当前的config如果是release还需要去调用CoreMLModelCodegen,最后再生成.h和.m文件

生成.mlmodelc文件的命令

xcrun coremlcompiler compile .mlmodel目录 output目录

生成.h.m文件的命令

Applications/Xcode.app/Contents/Developer/usr/bin/coremlc generate .mlmodel目录 output目录 --output-partial-info-plist plist目录 --language 生成的语言类型Objective-C还是Swift

了解了.mlmodel文件编译使用的commandline操作,就可以单个编译这个文件了,以下是我们的改进思路:删除新添加组件,去掉新添加的post_install相关代码,脚本部分由编译新组件修改为生成.mlmodel文件的入口文件,产生文件我们指定到一个中间临时文件夹中,然后我们将业务组件的headersearch指向这个中间临时文件夹,脚本添加遍历文件夹功能,只要给出.mlmodel所在文件夹就能加载到所有的模型,用户只要将.mlmodel模型丢到指定的目录就可以了,将所有需要的参数都在podspec的入口脚本中设置,收拢到一处,这样修改就完成了。结构如下图:

最终的podspec配置如下

  #省略掉了不必要的字段只保留了关键部分
s.ios.deployment_target = '11.0'

s.source_files = "#{s.name}/classes/**/*"

temp_module_name='helpMLModels' # 定义的虚拟文件夹名称
script_file_name = 'installMLmodels' # 脚本名称
main_proj_name = 'testMLProj' # 主工程名称

s.xcconfig = { 'USER_HEADER_SEARCH_PATHS' => "\"${PROJECT_TEMP_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/#{temp_module_name}.build/DerivedSources/CoreMLGenerated/**\"" }
s.resource_bundles = {
s.name => ["#{s.name}/Assets/**/*"]
}
#add script_phase to #{s.name} BuildPhase before compile source
script_file_path = Pathname.new(__FILE__).realpath.dirname.to_s.force_encoding('UTF-8')
script_command = "bash #{script_file_path}/#{s.name}/scripts/#{script_file_name}.sh #{main_proj_name} #{script_file_path}/#{s.name}/models \n echo \"#{s.name} build #{temp_module_name} finish\""
s.script_phase = { :name => 'install mlmodel files', :shell_path => '/bin/bash', :script => script_command , :execution_position => :before_compile }

在写Ruby脚本的时候遇到了一个小插曲,在Ruby脚本中使用Xcodeproj编辑demo工程文件时会提示因为pbxproj新添加的字段导致无法打开工程文件,但是同样使用Xcodeproj模块的CocoaPods就能正常使用。在CocoaPods的issue上查询到了类似的问题,得到的回答是PBXShellScriptBuildPhase类型添加了inputPaths和outputPaths两个新的字段,只需要升级CocoaPods就能解决问题。但是我当前版本已经高于他要求的版本了。自己阅读了日志后发现Ruby的执行的路径不同,Ruby脚本中require关键字默认是从/System/Library/Frameworks/Ruby.framework路径获取模块的,这里使用的是系统默认安装的Ruby3.0,和当前用户的.rvm管理的Ruby程序路径不同,所以应用也不同,使用系统路径的gem重新安装CocoaPods后问题解决。

需要注意 由于业务组件的二进制文件中实际上没有.mlmodel模型入口类的symbol,如果不做判断就直接调用有可能会有崩溃的风险,所以调用的时候注意需要做保护。模型的资源文件调用路径也需要注意,放在主工程后编译会直接放在程序目录的根目录下。

最后总结一下最终的执行方案

  • 添加脚本到组件目录下,脚本文件主要完成两部分工作,首先是给主工程添加.mlmodel文件的引用,其次是编译.mlmodel文件到指定的文件夹。

  • 参照前面的podspec文件配置:添加在编译前运行的script,用来启动脚本文件;添加指向.mlmodel编译结果的headersearch配置。组件安装完成后会自动完成xocode的配置,用户直接编译即可。

最后放出两段脚本:

  • 简化后的Ruby脚本


project_obj = Xcodeproj::Project.open(project_path)
mlmodels_proup = project_obj[group_name]
if !project_obj.main_group.children.include? mlmodels_proup
mlmodels_proup = project_obj.new_group(group_name)
end
project_obj.native_targets.each do |target|
if target.name == target_name
if File.directory? mlmodels_path
Dir.foreach(mlmodels_path) do |file|
if File.extname(file) == ".mlmodel"
mlFileExist = false
project_obj.files.each do |file|
if Pathname.new(file_path).basename.to_s == file.display_name
file.path = file_path
mlFileExist = true
break
end
end
if mlFileExist == false
addfile(group_obj, target_obj, file_path)
end
end
end
end
end
end
project_obj.save
  • 简化后的sell脚本


#!/bin/zsh bash
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
RUBY_SCIRPT_FILE_NAME="$(basename "$0" ".sh")"
RUBY_SCIRPT_PATH="${SHELL_FOLDER}/${RUBY_SCIRPT_FILE_NAME}.rb"

MAIN_PROJ_NAME="$1"
MLMODELS_PATH=$2

INTER_MODULE_NAME='helpMLmodels'
MLMODEL_FILE_EXT='mlmodel'

#执行 ruby脚本 根据给定的mlmodel文件的路径给主工程添加.mlmodel引用
ruby $RUBY_SCIRPT_PATH "$MAIN_PROJ_NAME" "$MLMODELS_PATH" "$INTER_MODULE_NAME"

inter_module_path="${PROJECT_TEMP_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/${INTER_MODULE_NAME}.build"

#遍历mlmodel文件夹路径,分别对.mlmodel文件进行编译
for model_path in "${MLMODELS_PATH}/*"
do
model_name="$(basename $model_path)"
model_ext="$(echo $model_name | sed 's/^.*\.//')"

if [ -f $model_path -a $model_ext=$MLMODEL_FILE_EXT ]; then
#generate_path 与 podspec 中设置的headersearch相似,不同的是headersearch是通过通配符访问上一级目录的
generate_path="${inter_module_path}/DerivedSources/CoreMLGenerated/${model_name}"

# $DEVELOPER_BIN_DIR、$CONFIGURATION 这种上下文缺失的参数请参考Xcode build环境变量。
$DEVELOPER_BIN_DIR/coremlc generate $model_path $generate_path --output-partial-info-plist $inter_module_path/$model_name-CoreMLPartialInfo.plist --language Objective-C
fi
done

程序修改完了,运行的也很成功。但是需要注意的是 podspec 的 script 方法需要 1.4.0 才能支持,低版本使用不了这个方案,针对无法运行脚本的方案我们会在下篇中去做讨论,这又是一种新的思路。

总结

本文记录了团队在使用Core ML时遇到的一系列的问题,以及解决方案。主要围绕Core ML的模型文件在CocoaPods组件中如何保证能够正常运行所展开,在保证正常使用基础上我们又尝试了自动化去解决.mlmodel文件的编译问题,虽然没有能在我们的平台上使用,但是在CocoaPods 1.4.0以上版本,本文给出的解决方案还是比较完美的,几乎不需要用户去做任何工作,只需要填写几个参数,就可以完成所有设置。后期的想法是自己去实现入口类,最终想办法实现模型下发。如果本文有什么问题以及其他想法可以与我们联系,大家一起做到更好。

参考

Ruby cocoapods 文档

Ruby xcodeproj 文档

Apple Core ML 文档

技术干货 | 机器学习框架Core ML深度解读


欢迎打赏赏赏赏赏赏赏



以上是关于AoE工程实践 —— 记CoreML模型在CocoaPods应用中的集成(上)的主要内容,如果未能解决你的问题,请参考以下文章

AOE工程实践-银行卡OCR里的图像处理

图论算法之AOE网

自动调优工具AOE,让你的模型在昇腾平台上高效运行

我们可以在 Server 上编译 CoreML 模型吗?

如何在 TensorFlow、Keras 或 PyTorch 中部署 CoreML 模型?

AOE网络的关键路径问题