前阵子接手了一个项目,客户是一个工厂,比较重视安全生产,整个厂区和车间安装了200多个摄像头,用的是海康的网络摄像头+硬盘存储服务器。目的是为了监控在生产车间生产人员是否正确配戴安全帽,是否有人吸烟,是否有烟火、烟雾,是否有人不走斑马线等。但海康的视频监控系统只能在事后调查取证用,不能实时报警。客户要求做一个对上述需求能够实时报警的视频监控系统,我就喜欢这种目标非常明确的项目,在Github上调研了一番,决定使用iSpy+yolo实现客户需求。
iSpy的确是一个非常牛X的开源视频监控软件,支持数量不限的(只受限于计算机的内存和带宽)多种多样的音视频源,包括网络摄像头、USB摄像头、游戏机摄像头、多种格式视频文件,甚至还能够控制摄像头云台,接受游戏机手柄控制等,还支持几种简单的运动检测和跟踪,还有报警策略(短信,Twitter和电子邮件报警)、记录策略(FTP上传、YouTube上传等),最后居然还支持多国语言!呃,基本上就是监控界的航空母舰。
但我还是要吐槽一下iSpy,那个代码写的真叫一个烂!咱就不要说什么面向对象编程这种高大上的要求,毕竟很多高手都不一定做的好,但是像代码书写规范这种基本要求也没达到,缩进从来不对齐,空行满天飞,一边拿着呕吐袋一边看代码!我从来没看过这么烂的开源代码,绝对没有之一!大量的大学生水平代码,处理手法非常稚嫩,如对windows消息的处理,windows消息参数都是无符号的uint,它非要转成有符号的int,真是画蛇添足,如果消息参数大于等于0X80000000,那就会产生overflow错误,这可是一个非常严重的系统错误。他们肯定也发现了这个不稳定问题,不但不耐心从根子上解决问题,反而使用try catch流氓大法蒙混过关。所以iSpy从来不会因为windows消息问题非法操作退出,貌似很稳定,但就是某些操作不起作用。像这种滥用try catch机制的行为,非常鄙视,哪怕在catch中写个调试控制台输出也好啊。最后怕客户将来找我麻烦,把iSpy的windows消息处理系统改了一遍,把我累个半死。
相比之下,yolo的代码水平还是非常高的,但毕竟是C语言写的,没有面向对象设计概念,代码生硬,不够优雅,但调用逻辑层次还是清晰可人的。它是目前非常火的一个基于深度学习的图像识别算法,速度非常快。我在GTX1080Ti上测试,例如安全帽检测只需要38-42毫秒,要知道对图像进行标注重绘也要8-12毫秒。后来找了一块最高端的Titan测试,启用yolo的CUDNN硬件哈夫曼编解码编译选项,检测时间比重绘时间都快!确实非常适合实时检测。
好了,前情提要交代完毕,现在开始进入正题。iSpy是使用C#编写的基于.net架构的托管型应用程序,而yolo最快的版本(那些大批基于Python的版本只能用于学习研究,不适合商业应用)是一个基于c语言的linux程序,幸好有人做了一个非托管型windows dll封装,让我省了不少劲。
但即使这样,仍然碰到了头疼的问题。首先是体系的问题,iSpy是喜爱windows理念的程序员编的托管型应用程序,yolo的原始版本是喜爱linux的程序员编的非托管型应用程序,在接口问题上是互相不鸟。iSpy的对外接口,参数是windows的bitmap,yolo的对外接口,参数是图像文件名!我真是无语了,linux程序员还是那么固执的喜欢命令行程序,注定他们只能学习和研究。如果我把bitmap转成文件再传给yolo,硬盘的读写时间都会远超检测时间,没有商业应用价值。幸好那个做封装的还是有些人性,作为一个编译选项,引入了opencv,封装了一个参数是Mat的检测方法,让我在性能上有了新机会。
但问题变得更加复杂了,首先是托管型iSpy调用我的托管型dll接口,传入视频源的每一帧图像,然后我的托管型dll再调用托管型opencv方法,把bitmap转换为mat后,再调用非托管型yolo封装dll检测方法,非托管型yolo封装dll再调用非托管型opencv方法,将mat转换为yolo内部的image_t数据结构,最后开始调用c语言的yolo检测方法。而且实际上每个dll功能调用都不仅仅是由一个dll完成的,这个dll还需要其它dll支持,例如opencv就是一套dll,所以,我的dll实际上是要调用3套不同体系的dll集群!
在.net架构体系中,dllimport功能负责导入dll方法,其寻找dll策略是在当前目录、系统目录和环境变量path中依序查找,这跟非托管型开发环境(如VC++)是一样的。但这个当前目录在.net环境下非常诡异,微软使用了目录重定向技术,目的是为了不麻烦人的情况下最大机会加载dll,但实际上我觉得这不一定是一个好策略,导致大量.net程序员稀里糊涂的就调通了程序,但当脱离开发环境,在用户方安装部署的时候就傻眼了,各种水土不服,于是手忙脚乱的把dll到处拷贝,企图侥幸过关,实在蒙不过去的,甚至直接在用户方安装个开发环境,以debug方式运行应用来糊弄用户。
经测试表明,.net程序无论是否在开发环境下,无论debug版本还是release版本,在运行时都会检测程序的加载路径,如果发现貌似bin/{1}/{2}这样的目录(其中{1}可能是x86或x64,{2}可能是debug或release),它就会把当前目录重定向为bin的上一级目录,也就是项目的根路径,方便调用项目资源文件,但有时候也不尽然是这样,因为在debug或release目录下,它还按照项目路径复制了一份项目依赖文件,所以有时候搞不懂它调用的是根路径下的dll还是debug或release目录下的dll,或者两者都有。
对于当前目录的这种混乱情况,某些聪明的程序员就不趟这趟浑水,使用系统目录好了,这也不失为一个好策略。但很多人都使用windows/system32这个系统目录,这可就不太好了。这个目录文件非常多,文件重名概率比较高,而且不易维护,碰到升级替换这种事,误操作的可能性也比较高。那么怎么做才是使用系统目录的最佳体验呢?咱们就从吐槽一下windows的系统目录开始。了解一下历史先,第一代windows系统是基于Intel8086开发的,这是一个16位的CPU,所以第一代windows系统是16位的操作系统,其系统目录为windows/system,到了80386时代,CPU变成了32位,第二代windows系统也就成为32位的操作系统,这时候历史包袱就来了,16位程序和32位程序是不兼容的,但是操作系统一定要保持历史兼容性这是微软称霸世界的一个重要策略,怎么办呢?于是windows系统就有了2个系统目录,windows/system存放16位应用程序,windows/system32存放32位应用程序,这个设计只要是正常人类都能理解。到了64位CPU时代,第三代windows系统也就成为64位的操作系统,历史负担越来越重,它需要兼容16位、32位和64位的应用程序,于是就有了3个系统目录,windows/system存放16位应用程序,windows/system32存放64位应用程序,windows/syswow64存放32位应用程序,惊不惊喜,神不神奇,估计一定是总设计师换了,没有一脉相承,脑洞大开的设计,唉,这个我就不吐槽了,只要他高兴就好。总之,在任何windows操作系统中,都有一个windows/system系统目录,这个目录中的文件在32位操作系统时代都已经非常少了,在64位windows7以后,干脆就是空目录,但目录结构还是保留的,所以我在调试时,经常把dll放到windows/system这个目录里,保证能够加载正确的dll,同时还易于操作维护管理。
但现在绿色环保软件是潮流,免安装,即考即用,向系统目录拷贝文件还需要用户确认等都不人性化,所以这个方法也仅仅用于快速原型测试,作为商业级应用还是要下功夫解决这些问题,而不是逃避这些问题。
既然.net能自动重定向,那我们就手动重定向。经研究表明,当前目录是加载进程的一个属性,由操作系统的进程调度系统初始化,但其后可以在任意时刻修改,.net的重定向机制也是源于此。
第一步,我们需要知道发起调用dllimport功能的文件所在位置,但在.net中有一大堆获取加载文件路径的方法,由于对.net不熟,只好一个一个试,最后找到了一个在任何情况下都很靠谱的方法this.GetType().Assembly.Location,这方法能拿到这行代码编译后所在的二进制代码文件在加载时的绝对全路径文件名。
第二步,根据dllimport功能的文件所在位置和dll部署结构拼装dll所在目录,然后在调用有dllimport功能的dll前,通过设置System.Environment.CurrentDirectory这个变量的方法来修改当前目录,这样就能保证加载指定目录中的dll。
void CurrentDirectoryUpdate(string strPathIn) {
string strPathRoot = Path.GetDirectoryName(this.GetType().Assembly.Location);
string strPathCurrent = Path.Combine(strPathRoot, strPathIn);
System.Environment.CurrentDirectory = strPathCurrent;
}
这个方法作了一定的封装,只需要传入dll子目录名称,此方法帮你自动生成全路径,并设置为当前目录。
此法在一般情况下都是适用的,但我就遇到了二般的情况。这就得先吐槽一下Emgu,当时在选型托管型opencv库时,网上对Emgu大加推崇,像我这种.net图像处理的小白,就选用了Emgu。当写完代码,进行联调时,才发现Emgu的Mat,opencv根本就不认识,翻看了一下Emgu和opencv的源码,才知道Emgu早已经跟opencv分道扬镳,自成一体了。虽然Emgu里面还保留有一个cv::Mat的数据结构,这个是跟opencv完全一致的,但没有方法在使用这个数据结构,无法转换。最后,找到了一个跟opencv数据结构100%一致的托管型opencv库opencvsharp,这个日本程序员真是有恒心,硬生生的把opencv用C#重写了一遍!数据结构封送与opencv一模一样,所以非常靠谱。我觉得Emgu这种另起炉灶的做法是给自己掘墓,因为谁也不能保证.net程序不需要调用第三方opencv,这种不兼容的问题会大大遏制Emgu的适用范围,而opencvsharp的这种兼容策略一定会前途无量。但opencvsharp的这种兼容性也是有代价的,它必须跟随opencv版本的升级而升级,工作量非常大,这就是我佩服那个日本程序员的原因,这事要是换了我,打死也不干。所以opencvsharp目前只有2.4.1和4.1.1版本,分别对应opencv的2.4.1和4.1.1版本,而yolo windows dll封装版只能使用opencv3.2.0版本,其他版本各种出错。这样整个系统需要两套不同版本的opencv运行时库,而opencv运行时库的不同版本中,有部分文件名是一样的,无法放到同一个目录中。
这个世界总是这样,要让你登峰造极,必让你苦其心志,劳其筋骨,饿其体肤,空乏其身。思前想后,看来只有修改环境变量path才是终极大法。事实证明,这也是明智之举。因为后来客户要求应用程序能够自适应操作系统,在32位系统上自动运行32位应用程序,在64位系统上自动运行64位应用程序。这就要求,应用程序应编译为32位和64位两套版本,dll也应该带32位和64位两套版本,但是32位的dll文件名和64位的dll文件名可都是一模一样的,它们也没法放在同一个目录里。幸好前面功课做足,这个需求就是小菜一碟。
关于修改环境变量,网上的代码也是一大堆,经认真甄选,找到了一个最优雅的方法。其没有修改系统配置的环境变量,而只是修改了进程的系统环境变量,在运行时即刻生效,退出程序后对系统环境变量没有影响,非常绿色环保。
void EnvironmentPathsAdd(IEnumerable<string> stringsPathsNew) {
List<string> stringsPathsJoin = new List<string>();
stringsPathsJoin.Add(string.Join(Path.PathSeparator.ToString(), stringsPathsNew));
stringsPathsJoin.Add(Environment.GetEnvironmentVariable("PATH"));
Environment.SetEnvironmentVariable("PATH", string.Join(Path.PathSeparator.ToString(), stringsPathsJoin));
}
这是我做的一个封装方法,网上的方法有一些瑕疵,就是把添加的目录放到path环境变量的最后面,这有一定的不确定性,如果前面的path目录中包含目标dll的同名文件,你的应用又开始在某些环境下水土不服,所以一定要把目标路径加到path环境变量的最前面,保证万无一失。