一个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) { // 执行特殊逻辑 }?掩码就是图像领域的条件分支——只不过它是像素级的,一个像素一个像素地决定用哪里的值。


五、二维码为什么能识别——我终于用上了线性代数

这是让我最兴奋的一个知识点,因为我大学学的线性代数终于用上了。

问题:二维码被拉伸、褶皱、甚至透视变形了,为什么还能扫码?

答案:算法根本不直接识别变形的二维码。它先做"校正"。

具体步骤:

  1. 寻找三个"回"字定位图案。不管怎么变形,"回"字的内外黑白比例特征是稳定的,所以能找到。
  2. 找到三个角点(加上推算出的第四个角点),知道了"扭曲的四边形"的四个顶点坐标。
  3. 定义一个"标准的正方形"四个顶点坐标(比如(0,0), (100,0), (100,100), (0,100))。
  4. 根据这两组点对,求解一个3x3的透视变换矩阵
  5. 对整个二维码区域应用这个变换,把它"拉"成正方形。
  6. 这时候二维码已经规规矩矩了,按二进制读取解码。

透视变换的本质:把一个四边形映射成另一个四边形。它比仿射变换(旋转、平移、缩放)更强大——能处理近大远小的透视效果。公式里包含除法,所以每个像素的"缩放系数"根据位置不同。

我手动推了一遍公式,用到了齐次坐标和矩阵求逆。数学推导过程不展开了,但推完那一刻我很确定——我学过的线性代数没有白学,它在这里真的在用。


六、从YOLO到人脸识别——我终于理解了"特征向量"

识别出"人"是一回事,认出"这个人是谁"完全是另一回事。

6.1 YOLO是怎么画框的

YOLO的核心思路其实很"工程":

  1. 把图片分成N×N的网格
  2. 每个格子负责预测"中心点落在这个格子里的物体"
  3. 每个格子预测若干个候选框(含坐标、宽高、置信度)
  4. 所有候选框经过**非极大值抑制(NMS)**过滤掉重复框

NMS的逻辑我一看就懂——按置信度排序,从高到低遍历,遇到和已选框重叠(IoU)过大的就扔掉。这不就是业务开发里常见的"去重过滤"逻辑吗?只不过去重的标准从"ID相同"变成了"两个矩形框重叠面积比例大于50%"。

6.2 人脸识别:"认人"不认图

人脸识别和指纹识别有一个共同的底层逻辑——从不比较原始图像,而是比较"特征向量"

以人脸为例:

  1. 先把检测到的脸通过关键点(眼、鼻、嘴角)做一个几何归一化——把侧脸、仰头脸强行"摆正"成标准正脸。这一步用的就是前面学到的仿射变换。
  2. 把摆正后的112x112标准脸送入识别网络,输出一个512维的浮点数向量
  3. 张三的脸 -> [0.12, -0.56, ..., 0.88],李四的脸 -> [0.98, 0.33, ..., -0.21]
  4. 判断两个人是否同一人,就是计算这两个向量的余弦相似度(或者欧氏距离)。相似度大于阈值,就是同一个人。

这个512维向量的意义:它是把"人的长相"映射到高维空间里的一个点。训练时,算法会迫使同一个人的不同照片在空间里距离很近,不同人的照片距离很远。所以它本质上是一个距离度量问题,不是图像比对问题。

这和我之前写过的"用户画像"系统本质一样——把多维特征压缩成向量,然后做相似度匹配。只不过用户画像的特征是"年龄、消费习惯、浏览记录",而人脸的特征是"眉骨间距、颧骨弧度、瞳孔位置"这些卷积核自动提取的东西。


七、ONNX和Runtime:我终于搞懂了模型怎么"跑起来"

作为一个后端工程师,我最关心的事情是:“模型训练出来之后,我怎么把它集成到我的系统里?”

以前我看AI项目,总觉得代码里全是import torchmodel.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适用场景
ONNXONNX Runtime通用跨平台(CV、NLP、多模态)
GGUFllama.cpp本地CPU跑大语言模型
TorchScriptLibTorchPyTorch的C++部署
SavedModelTensorFlow Serving谷歌生态,分布式推理
TFLiteTFLite InterpreterAndroid/嵌入式设备

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服务做中间层。

核心流程大致是这样:

  1. 加载libonnxruntime.so
  2. 用GD或Imagick读取图片,缩放到模型输入尺寸(比如640x640)
  3. 把像素数据用pack("f*")打包成C语言能读的连续内存块
  4. 把内存指针传给ONNX Runtime,执行推理
  5. 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闲着,资源利用率很低。

更好的方式是流水线

  1. 解码进程持续解码,把帧放到共享内存队列
  2. 推理进程从队列取帧,批量推理(batch推理压榨GPU利用率)
  3. 后处理进程处理结果,推送给业务层

这和Workerman里把"网络IO"和"业务处理"分离的思路是一样的——让不同的进程做不同的事,通过队列解耦。

9.3 共享内存做零拷贝

如果用Redis或消息队列传JPEG图片,序列化/反序列化的开销比推理本身还大。

Python的multiprocessing.shared_memory可以创建一块共享内存区域,多个进程直接读写这块内存,不需要拷贝数据。推理进程拿到的是内存地址,直接用np.ndarray包装一下就开始推理了。

零拷贝 + 批量推理,这是压榨硬件性能的关键手段。


十、我学到的东西和走过的弯路

最后简单总结一下这段学习经历,也给自己留个记录:

弯路

  • 一开始试图从数学公式开始推导——方向错了,我应该先从"能跑起来"开始
  • 花了很多时间看训练相关的资料,但我的任务只需要做推理,不需要训练
  • 纠结于Python/PHP的语言之争,后来发现用FFI直接调用C库才是正解

扎实的理解

像素就是数据,模型就是函数。 计算机视觉本质上就是把"图像矩阵"通过一系列数学运算映射到"语义标签"或"坐标位置"。这个映射关系可以手工设计(传统图像处理),也可以从数据中学习(深度学习)。

学习路线,对于跟我背景相似的开发者,我建议的顺序是:

  1. 先搞懂卷积怎么算(手算一遍3x3卷积)
  2. 搞懂透视变换的4点求解(用你会的线性代数)
  3. 跑起来一个YOLO(检测"人")
  4. 跑起来一个人脸识别(认出"这个人")
  5. 把模型导出成ONNX
  6. 用PHP FFI调用ONNX Runtime做推理

每一步都能跑通并看到效果,比堆砌理论要重要得多。


以上,就是我从零开始理解计算机视觉的全过程。如果你和我背景相似(多年后端、熟悉网络和并发、对图像零基础),希望这篇文章能帮你少走一些弯路。

项目还在进行中,后面遇到新的坑,我还会继续记录。

更多推荐