一个9年PHP后端,是如何硬啃计算机视觉的
一个9年PHP后端,是如何硬啃计算机视觉的
我没写过C++,没搞过CUDA,PyTorch只在教程里见过。接手计算机视觉项目的第一周,我连"卷积"是动词还是名词都搞不清。但七天之后,我搞懂了YOLO怎么画框、人脸怎么变成一串数字、PHP为什么可以直接调用AI模型——这篇文章记录了我从零开始的学习过程,或许对你也有用。
一、背景:我为什么会碰这个东西
先说一下我的技术背景,这样你就知道我起点在哪了:
- PHP干了9年,熟练ThinkPHP 5/6、Workerman
- 手写过TCP通信,尝试过epoll模型
- 了解HTTP、WebSocket、CGI协议
- 学过一点逆向,能看简单的汇编代码和PE结构
- Python会一点,Java干过一年但忘得差不多了
- 前端停留在jQuery,Vue2用过但细节记不清
- 数学方面:高中水平、线代入门、微积分入门
说白了,我是纯应用层出身,离底层越近的东西我反而越熟(因为搞过逆向),但离"图像"这个领域,我是完全的白纸。
项目需求来了:要给一个监控系统做"人形检测 + 人脸识别"。甲方说得很轻松:“就是摄像头拍到了人,系统能认出他是谁。”
我当时脑子里只有几个模糊的概念——视频流、RTMP、OpenCV(听说过没用过)。"认人"怎么认?拿图片去数据库比对?图片怎么比对?一个个像素相减吗?稍微歪一点不就对不上了?完全没头绪。
于是我开始硬啃。
二、第一关:打破"监听"的思维惯性
作为写惯了Workerman的人,我第一反应是:“视频流里有没有类似onMessage的回调?检测到人触发一下,没检测到就不触发?”
学了才知道,完全不是这回事。
视频处理本质上就是一个while(true)死循环,根本没有异步事件这一说。 每一帧都是独立的"数据包",你要自己决定要不要处理它、怎么处理它。
while True:
frame = cap.read() # 读一帧
boxes = model.detect(frame) # 检测这一帧
if boxes: do_something() # 有结果就处理
show(frame) # 显示/推流
这不就是我用Workerman写的while(true) { $conn->recv(); }吗?只不过recv()收的是字符串,read()收的是一个叫"图像"的多维数组。
一个关键认知转变:图像在计算机里就是一个大矩阵。 1920x1080的彩色图 = 三个(RGB)1920x1080的二维数组,每个格子存一个0~255的数字。视频 = 连续播放的这些矩阵。
好了,这就回到了我熟悉的领域——数组操作和循环。接下来要搞明白的是:这个叫"图像"的矩阵,要怎么处理才能变成"这里有人"的判断?
三、卷积到底是什么(绕不开的第一道坎)
所有图像处理的教程都会讲"卷积",但我看了三天才真正理解。
卷积 = 滑窗加权求和。
一个3x3的小方块(叫"核"或者"卷积核"),像滑窗一样在图像上滑动。每到一个位置,把核里的9个数字和下面盖住的9个像素值分别相乘,再加起来,得到一个新值。这个新值就作为输出图像对应位置的像素。
以我写过的TCP协议解析来类比:你定义了一个固定格式的包头(比如4字节长度 + 2字节命令字),然后从数据流中截取对应字节做解析。卷积也是类似的——定义一个小窗口,从图像矩阵里截取对应区域做"加权求和"。
不同之处在于:包头格式是固定的,但卷积核里的数字是可以变的。 不同的核,会提取出完全不同的特征:
- 某个核能提取"横向边缘"(类似Sobel算子)
- 某个核能提取"纵向边缘"
- 某些核组合起来,能提取"圆角"、"尖角"甚至更复杂的纹理
这让我第一次理解了"特征提取"是什么意思——它不是什么玄学,就是一堆数学运算的叠加。
四、美颜、磨皮、锐化——原来都是卷积的排列组合
学完卷积,我又去翻了些美颜算法的文章,发现它们比我预想的简单得多。
磨皮:用"均值核"或"高斯核"对图像做卷积,让每个像素和周围像素"平均"一下。脸上的痘痘因为颜色和周围皮肤差异大,被平均后就不明显了。就这么朴实无华。
锐化:用专门的"锐化核"做卷积。它的效果是增强边缘对比度,让模糊掉的部分看起来"清晰"一些。很多美颜算法里,锐化的步骤叫做"细节还原"——先模糊去掉瑕疵,再锐化补回轮廓。
只磨皮不磨五官:这个靠"掩码"(Mask)。掩码是一张黑白图——皮肤区域是白色(应用特效),五官区域是黑色(保持不变)。最后合成的时候,白色区域用处理后的图,黑色区域用原图。
这像不像我们在业务代码里写
if ($is_admin) { // 执行特殊逻辑 }?掩码就是图像领域的条件分支——只不过它是像素级的,一个像素一个像素地决定用哪里的值。
五、二维码为什么能识别——我终于用上了线性代数
这是让我最兴奋的一个知识点,因为我大学学的线性代数终于用上了。
问题:二维码被拉伸、褶皱、甚至透视变形了,为什么还能扫码?
答案:算法根本不直接识别变形的二维码。它先做"校正"。
具体步骤:
- 寻找三个"回"字定位图案。不管怎么变形,"回"字的内外黑白比例特征是稳定的,所以能找到。
- 找到三个角点(加上推算出的第四个角点),知道了"扭曲的四边形"的四个顶点坐标。
- 定义一个"标准的正方形"四个顶点坐标(比如
(0,0), (100,0), (100,100), (0,100))。 - 根据这两组点对,求解一个3x3的透视变换矩阵。
- 对整个二维码区域应用这个变换,把它"拉"成正方形。
- 这时候二维码已经规规矩矩了,按二进制读取解码。
透视变换的本质:把一个四边形映射成另一个四边形。它比仿射变换(旋转、平移、缩放)更强大——能处理近大远小的透视效果。公式里包含除法,所以每个像素的"缩放系数"根据位置不同。
我手动推了一遍公式,用到了齐次坐标和矩阵求逆。数学推导过程不展开了,但推完那一刻我很确定——我学过的线性代数没有白学,它在这里真的在用。
六、从YOLO到人脸识别——我终于理解了"特征向量"
识别出"人"是一回事,认出"这个人是谁"完全是另一回事。
6.1 YOLO是怎么画框的
YOLO的核心思路其实很"工程":
- 把图片分成N×N的网格
- 每个格子负责预测"中心点落在这个格子里的物体"
- 每个格子预测若干个候选框(含坐标、宽高、置信度)
- 所有候选框经过**非极大值抑制(NMS)**过滤掉重复框
NMS的逻辑我一看就懂——按置信度排序,从高到低遍历,遇到和已选框重叠(IoU)过大的就扔掉。这不就是业务开发里常见的"去重过滤"逻辑吗?只不过去重的标准从"ID相同"变成了"两个矩形框重叠面积比例大于50%"。
6.2 人脸识别:"认人"不认图
人脸识别和指纹识别有一个共同的底层逻辑——从不比较原始图像,而是比较"特征向量"。
以人脸为例:
- 先把检测到的脸通过关键点(眼、鼻、嘴角)做一个几何归一化——把侧脸、仰头脸强行"摆正"成标准正脸。这一步用的就是前面学到的仿射变换。
- 把摆正后的112x112标准脸送入识别网络,输出一个512维的浮点数向量。
- 张三的脸 ->
[0.12, -0.56, ..., 0.88],李四的脸 ->[0.98, 0.33, ..., -0.21]。 - 判断两个人是否同一人,就是计算这两个向量的余弦相似度(或者欧氏距离)。相似度大于阈值,就是同一个人。
这个512维向量的意义:它是把"人的长相"映射到高维空间里的一个点。训练时,算法会迫使同一个人的不同照片在空间里距离很近,不同人的照片距离很远。所以它本质上是一个距离度量问题,不是图像比对问题。
这和我之前写过的"用户画像"系统本质一样——把多维特征压缩成向量,然后做相似度匹配。只不过用户画像的特征是"年龄、消费习惯、浏览记录",而人脸的特征是"眉骨间距、颧骨弧度、瞳孔位置"这些卷积核自动提取的东西。
七、ONNX和Runtime:我终于搞懂了模型怎么"跑起来"
作为一个后端工程师,我最关心的事情是:“模型训练出来之后,我怎么把它集成到我的系统里?”
以前我看AI项目,总觉得代码里全是import torch、model.load(),然后执行推理。但那是Python的玩法。如果我的服务是PHP写的,总不能为了推理去起一个Flask服务然后让PHP去curl吧——那延迟和运维成本我接受不了。
于是我去搞清楚了AI模型的"交付物"到底是什么。
7.1 ONNX = 跨平台的"模型字节码"
PyTorch和TensorFlow是两个不同的训练框架,它们各自有私有的模型存储格式(.pt和.pb)。这就像你用Visual Studio编译出.exe,我用GCC编译出ELF,虽然都是机器码但格式不同,不能互相加载。
ONNX就是为解决这个问题而生的。 它定义了一套通用的模型格式,PyTorch和TensorFlow都可以导出成.onnx文件。这个文件包含了网络结构和训练好的权重参数。
可以用一个我熟悉的类比来理解:
- 训练框架(PyTorch/TF) + 源码 = 你写的C代码 + 编译器
- ONNX文件 = 编译生成的字节码(比如Java的.class文件)
- ONNX Runtime = JVM
你不需要PyTorch环境,只要一个ONNX Runtime,就能加载.onnx文件并执行推理。
7.2 不只是ONNX——大模型场景下的GGUF
在接触Ollama的时候,我又遇到了一个新格式:GGUF。
简单说:ONNX是通用格式,适合图像识别、语音等传统深度学习任务。但对于大语言模型(LLM),社区发现GGUF + llama.cpp的组合在CPU上跑得更快、更省内存。因为llama.cpp用了内存映射(mmap)加载模型,启动速度极快,而且对内存使用做了极致优化。
Ollama本质上就是:
- GGUF格式模型
- llama.cpp推理引擎
- Go写的HTTP服务封装
所以模型格式 + Runtime引擎 + 服务封装这个三层架构,不仅适用于ONNX场景,也适用于Ollama。只是不同场景选了不同的技术栈。
7.3 模型格式和Runtime的对照表
这对我理解整个生态系统很有帮助,列出来方便记忆:
| 模型格式 | Runtime | 适用场景 |
|---|---|---|
| ONNX | ONNX Runtime | 通用跨平台(CV、NLP、多模态) |
| GGUF | llama.cpp | 本地CPU跑大语言模型 |
| TorchScript | LibTorch | PyTorch的C++部署 |
| SavedModel | TensorFlow Serving | 谷歌生态,分布式推理 |
| TFLite | TFLite Interpreter | Android/嵌入式设备 |
7.4 一个关键认知:ONNX不能"逆向"成训练工程
有同事问我:“拿到一个.onnx文件,能不能接着训练?”
答案是不行。ONNX只存了"前向传播"的计算图和权重,没有存梯度和优化器状态。想继续训练,必须回到原始的训练工程(.py文件 + 权重)。
类比一下:你拿到了一个编译好的.jar文件,可以反编译出一些代码结构,但想加新功能还是得回去改.java源文件。ONNX是只读的"成品",不是"半成品"。
八、PHP的杀手锏:FFI直接调用ONNX Runtime
这是整个学习过程中让我最兴奋的部分。
ONNX Runtime是用C++写的,但它提供了标准的C ABI接口,编译后是一个动态链接库(Linux下是.so,Windows下是.dll)。
PHP 7.4+提供了**FFI(外部函数接口)**扩展——可以在PHP代码里直接加载C库并调用C函数。
这意味着什么?
我可以直接在PHP进程里执行推理,不需要起一个Python服务做中间层。
核心流程大致是这样:
- 加载
libonnxruntime.so - 用GD或Imagick读取图片,缩放到模型输入尺寸(比如640x640)
- 把像素数据用
pack("f*")打包成C语言能读的连续内存块 - 把内存指针传给ONNX Runtime,执行推理
- 用
unpack()解析返回的内存块,拿到检测结果
为什么这很重要?
- 没有网络开销:不用curl到Python服务
- 没有序列化开销:不传JSON/Base64,直接传内存指针
- 没有GIL限制:PHP-FPM/Workerman是多进程模型,天然并发
- 运维简单:只需要一个.so文件和一个.onnx文件,不用管理conda环境和Python依赖
对于一个写了9年PHP的人来说,这比让我去维护一个Flask服务要舒服太多了。而且我有汇编和内存操作的基础,pack/unpack操作指针对我来说并不陌生。
九、架构上的几个关键思考
学完算法层面之后,我开始想工程落地的事情。毕竟项目里不是一路摄像头,而是十几路甚至几十路。
9.1 不要每帧都跑YOLO
YOLO每帧都跑,GPU也会被吃满。实际工程里常用的策略是:
- 每5~10帧跑一次完整检测
- 中间帧用追踪算法(比如ByteTrack)预测框的位置
- 追踪算法的算力消耗几乎可以忽略
这类似于我们做Web性能优化时,"不要每次请求都查数据库,用缓存扛一下"的思路。
9.2 流水线并行:解码和推理分开
视频解码(CPU密集)和模型推理(GPU密集)是两种不同类型的计算负载。如果串行做,解码时GPU闲着,推理时CPU闲着,资源利用率很低。
更好的方式是流水线:
- 解码进程持续解码,把帧放到共享内存队列
- 推理进程从队列取帧,批量推理(batch推理压榨GPU利用率)
- 后处理进程处理结果,推送给业务层
这和Workerman里把"网络IO"和"业务处理"分离的思路是一样的——让不同的进程做不同的事,通过队列解耦。
9.3 共享内存做零拷贝
如果用Redis或消息队列传JPEG图片,序列化/反序列化的开销比推理本身还大。
Python的multiprocessing.shared_memory可以创建一块共享内存区域,多个进程直接读写这块内存,不需要拷贝数据。推理进程拿到的是内存地址,直接用np.ndarray包装一下就开始推理了。
零拷贝 + 批量推理,这是压榨硬件性能的关键手段。
十、我学到的东西和走过的弯路
最后简单总结一下这段学习经历,也给自己留个记录:
弯路:
- 一开始试图从数学公式开始推导——方向错了,我应该先从"能跑起来"开始
- 花了很多时间看训练相关的资料,但我的任务只需要做推理,不需要训练
- 纠结于Python/PHP的语言之争,后来发现用FFI直接调用C库才是正解
扎实的理解:
像素就是数据,模型就是函数。 计算机视觉本质上就是把"图像矩阵"通过一系列数学运算映射到"语义标签"或"坐标位置"。这个映射关系可以手工设计(传统图像处理),也可以从数据中学习(深度学习)。
学习路线,对于跟我背景相似的开发者,我建议的顺序是:
- 先搞懂卷积怎么算(手算一遍3x3卷积)
- 搞懂透视变换的4点求解(用你会的线性代数)
- 跑起来一个YOLO(检测"人")
- 跑起来一个人脸识别(认出"这个人")
- 把模型导出成ONNX
- 用PHP FFI调用ONNX Runtime做推理
每一步都能跑通并看到效果,比堆砌理论要重要得多。
以上,就是我从零开始理解计算机视觉的全过程。如果你和我背景相似(多年后端、熟悉网络和并发、对图像零基础),希望这篇文章能帮你少走一些弯路。
项目还在进行中,后面遇到新的坑,我还会继续记录。
更多推荐
所有评论(0)