在这个大模型、云端算力满天飞的时代,我为了做一个“完全本地”的棋牌辅助工具,硬生生逼着自己走上了边缘端纯本地推理的绝路。

大半年的时间里,我经历了开源协议的背刺、NCNN 算子不兼容的绝望、自编译 OpenCV 的折磨、双重 Sigmoid 的降智打击、鸿蒙系统的底层锁、以及商业闭环中的并发陷阱。

这是一篇不仅讲 AI 部署、更讲系统架构与商业化踩坑的深度复盘。希望它能帮后来人少走几公里弯路。

楔子:为什么要死磕“纯本地”?

市面上的记牌器分两种:一种是读内存的(秒封),一种是传截图到云端 AI 识别的(容易封)。

为了做到真正的安全,我决定把所有 AI 推理全放在用户的手机上。这听起来很美好,但落地到 Android 客户端时,噩梦开始了。

最开始,我兴冲冲地拿着开源界大红大紫的 YOLO 模型,心想这玩意跑个扑克牌识别还不是手到擒来?结果刚准备写商业变现逻辑,就被现实狠狠扇了一巴掌:YOLO 的底层框架(Ultralytics 版本)使用的是 AGPL-3.0 协议。

这意味着什么?这意味着只要你的软件分发给用户,你的全部源代码就必须开源!想闭源商业使用?要么交昂贵的授权费,要么被直接发律师函。

作为一个准备靠广告费赚点服务器钱和个人辛苦费的独立开发者,为了彻底规避侵权风险,我被迫放弃了现成的 YOLO,转头扎进了一篇学术论文,选择了旷视开源、采用 Apache 2.0 协议(完全免费商用)的 YOLOX。

不仅如此,模型最初用 896×896 的输入尺寸,手机跑起来像个暖宝宝。为了降功耗,我被迫开启了这段“炼丹渡劫”之旅。

渡劫第一关:NCNN 转换与“5000 个纳米框”的噩梦

为了榨干安卓手机的性能,我盯上了腾讯的 NCNN 推理框架(支持 Vulkan GPU 加速)。我按官方步骤将 YOLOX 导成 ONNX,再用 PNNX 或 onnx2ncnn 转换。结果,一跑吓一跳:

现象:屏幕上出现了 5000 多个极其微小的 12x12 像素“纳米框”,密密麻麻,NMS(非极大值抑制)直接罢工。置信度虽然正常,但坐标完全偏移。

排查:我用 Python 在 PC 端纯净环境复现,结果和手机上崩得一模一样。好家伙,锅不在 Android JNI 的预处理和内存对齐,是 NCNN 吃不下这个模型!

根源:YOLOX 独创的 Focus 层。这个层在导出 ONNX 时,被切成了多个 Slice + Concat + Crop 算子。但 NCNN 对这些算子组合的步长和内存重排支持极差,导致输出的特征图数值偏差,解码坐标直接飞到姥姥家。

终极解法:

不用 PNNX 的自动转换了,直接手改模型结构!

  1. 打开转换出来的 .param 文件,把 Focus 相关的那一坨 Split、Crop、Concat 节点全删了。

  2. 插入一行自定义层:YoloV5Focus focus 1 1 in0 9。

  3. 在 C++ 代码里手写继承 ncnn::Layer 的 YoloV5Focus 类,重写 forward 方法,手动把空间维度的像素交错重排到通道维。

  4. 在 JNI 里注册自定义层,重新编译。在这里插入图片描述

在这里插入图片描述

随着这通“外科手术”,模型终于在 NCNN 上跑通了。

渡劫第二关:自编译 OpenCV 与 OpenMP 的“隐形锁”(附打脸实录)

为了在 JNI 层做图像预处理,我引入了 OpenCV。结果引入官方 opencv-mobile 预编译包后,链接时直接报错:__kmpc_fork_call 符号缺失。

原来,官方包默认开启了 OpenMP 多线程,而在 Android 静态链接 OpenMP 是个巨大的深坑。各种 -fopenmp 和 libomp.so 的链接顺序都试了,死活过不去。更恶心的是,预编译包里还带了 opencv_videoio,拉取了一堆 AMediaExtractor 的系统底层依赖,而我根本不需要这些。

当时的终极解法:自己动手,丰衣足食。

我下载了 OpenCV 4.13.0 的源码,安装 NDK r25c,用 CMake 配置(加上 -DWITH_OPENMP=OFF),剔除了所有不需要的模块。经过漫长的编译,终于拿到了一个 100% 无 OpenMP、纯 C++ 且体积精简的静态库,链接问题迎刃而解。

【打脸番外篇:C++ 程序员的宿命】

后来项目重构,我无意中把 CMake 里的“全量链接”改成了“只链接核心模块”,甚至换回了官方原版的 opencv-mobile 预编译包。

结果……它居然直接跑通了!

原来,只要代码里不去碰 videoio,链接器根本不会去拉取那些恶心的底层系统依赖;只要 NCNN 接管了推理,OpenCV 内部的 OpenMP 符号压根就不会被实际触发。我之前苦苦编译出来的那堆 .a 文件,最终全进了回收站。看着屏幕上的编译成功,我流下了属于“吃饱了撑的”的眼泪。😂 这大概就是底层开发的魅力:折腾了三天三夜,最后发现“少写几行配置”才是终极解法。

渡劫第三关:降智打击——“双重 Sigmoid”惨案

在 Android 端跑通推理后,发现框全部挤在左上角,或者置信度始终在 0.5 徘徊。我排查了预处理缩放、灰边填充、BGRA 转 RGB 的通道顺序,全都没问题。

根源:YOLOX 的 ONNX 导出时如果设置了 decode_in_inference=True,模型内部已经帮你把网格偏移做了 Sigmoid 处理(变成 0 到 1 之间)。

我在 Android 的 C++ 后处理代码里,又习惯性地套了一次 sigmoid(cx_raw)。结果,原本是 0.9 的坐标,被硬生生压缩成了 0.71,画面彻底漂移。

血泪教训:记住,YOLOX 的宽高需要 exp(),但中心点偏移 cx, cy 如果是模型内嵌激活的,直接加网格坐标乘 stride,千万别碰 Sigmoid!

渡劫第四关:系统底层的“宫斗剧”

  1. 鸿蒙系统的录屏独占:

华为审核一直反馈“开始记牌后未记牌”。我用真机一测,发现鸿蒙 4.2 里,如果系统自带录屏先开,我的 App 的 MediaProjection 就建不起来了。原因是音频通道被独占。

最终通过反射没用,而是在创建 VirtualDisplay 时,加上了 VIRTUAL_DISPLAY_FLAG_PUBLIC 标志,让两个录屏共享屏幕流,才把这个 Bug 修掉。

  1. 归一化与状态机坐标的错位:

换了 YOLOX 后,因为坐标映射从正方形画面变回了长方形画面,导致归一化后的 Y 轴数值变大,直接越过了我状态机里设定的 selfDiscardYMin 阈值。队友的牌被系统当成对手出的牌直接过滤了!

解决办法:抛弃归一化坐标,直接在 JNI 层算出绝对像素坐标,传给 Java 层判定,彻底绕开不同分辨率带来的误差。

渡劫第五关:商业闭环与“回调四宗罪”

技术跑通了,得变现啊。我设计了“新用户送 24 小时,看激励视频每次加 30 分钟,满 8 次全天无限”的规则。结果踩了后端最大的坑。

广告回调的“四宗罪”:你看不到、控不住、等不起、查不到。

一开始,我把发放时长的逻辑绑在了广告平台的服务端回调上。结果网络一卡,用户看完了广告,时长没到账,投诉直接爆炸。而且,前端有时候也会触发一次请求,导致一次广告发了双倍的时长。

终极解法(架构重构):

前端主动上报:用户看完视频,客户端主动调 /api/user/watchAd,立马给时长,保证体验。

后端幂等去重:利用 transId 唯一性做缓存,同一笔交易只发一次。

回调做对账:广告平台的回调只用来后台核对,不负责发钱。

同时,为了保证高并发下用户永久时长不被扣成负数,我在数据库层用 JPQL 写了原子操作:UPDATE … SET freeMinutes = freeMinutes - ? WHERE id = ? AND freeMinutes >= ?。

番外篇:PC端的“代码裸奔”防御战

不仅是安卓,我还顺手把 PC 端的 Python 脚本打包成了 EXE。但 Python 是明文啊,裸奔等于把源代码送给别人。

我动用了 PyArmor 9.x + PyInstaller。

PyArmor 的 9 版本命令全变了(变成了 gen),而且遇到 cryptography 这种带 C 扩展的库、以及 YOLOX 的动态模块,PyInstaller 打包后直接报“找不到模块”。

最后,我在 main.spec 里把所有需要的模块放到 hiddenimports,把 .py 文件放到 datas 里,再手动把混淆生成的 pyarmor_runtime 塞进去。通过 collect_all 完整拉取依赖,终于打造出了一个防逆向的 EXE。

结局:这 1 块 6 毛 2,是最好的奖赏
在这里插入图片描述

系统上线后,我打开了广告后台,看着那涨涨跌跌的曲线。昨天收益 0.16 元,累计收益 2.66 元。

很多人可能会笑:折腾大半年,就赚了一瓶矿泉水的钱?

但我不这么看。在这背后,我看到了:

日均点击转化率 83% 的数据漏斗;

小米和 vivo 机型权限失败的精准统计;

甚至有 6 个铁粉,一天贡献了 30 次广告播放。
在这里插入图片描述

这套从 Android 客户端、YOLOX 边缘端推理、Spring Boot 后端、到 MariaDB 数据库、再到前端 Web 官网的完整生态,是我一个人用无数个通宵拼出来的。

最后写给同行的话

如果你也是独立开发者,或者正在搞模型落地:

  1. 动手前,先做 PC 验证:把锅甩干净,再动移动端。

  2. 遇到框架不支持的算子,别怕看源码:自定义层能救命。

  3. 永远不要把核心业务,绑在第三方的异步回调上。

  4. 完美主义是最大的敌人:我的智子AI记牌 App 现在仍有 Bug,但我把它推向了市场,拿到了真实的数据。

我是老吴,一个销售出身的被逼的自学的全栈开发者

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐