ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图像如何变成高维向量:从像素矩阵到128维人脸特征的完整链路

图像如何变成高维向量:从像素矩阵到128维人脸特征的完整链路

前几天有个朋友跑来问我:人脸识别到底是怎么认出我的?它是在看我的眼睛还是鼻子?这个问题其实挺有代表性。这个系列写到第十二篇,前面我们已经把Python神经网络的底子打完了——从单个神经元、损失函数、反向传播到梯度下降,等于把一台机器从零件到组装全过了一遍。但真到了图像这种“高维数据”面前,光有这些底层知识还是不会用。这一篇我们就来补上最关键的一环:图像是如何进入神经网络的,又是如何经过层层变换,最终变成一个高维向量的。看完你不仅会明白人脸特征向量的来龙去脉,还能用纯numpy亲手写一个迷你的特征提取器。

如果你正卡在“不知道图像怎么喂给网络”或者“搞不懂128维向量是什么意思”这个阶段,这篇就是为你写的。我尽量把每一步的数据形状变化都讲透,跟着走一遍,图像和神经网络之间的那层窗户纸就算捅破了。

1. 图像在Python眼里只是一堆数字:像素矩阵与预处理真相

1.1 一张照片到三维数组的转换过程

先说最基础但最容易忽略的事。图像在我们的眼睛里是一张照片,在计算机里是一堆数字。灰度图是一个二维数组,每个元素是0到255的亮度值;彩色图是一个三维数组,多出来的那一维是通道,常见的是RGB三个通道。很多初学者第一次看到shape[2]这个维度很懵——为什么彩色图会有3个维度。你把它想象成三张同样大小的灰度图叠在一起就好了:一张记录红色亮度、一张记录绿色亮度、一张记录蓝色亮度。人眼能感知的颜色就是这三种光的组合,计算机把这个物理事实直接翻译成了三个通道矩阵。

用Python读一张图像非常简单。这里用OpenCV(也就是cv2库)做示范,因为它在图像处理里太常用了,后面很多预处理都要靠它:

import cv2 import numpy as np img = cv2.imread("face.jpg") # 读进来是 BGR 顺序,不是 RGB print(img.shape) # (112, 112, 3) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) print(gray.shape) # (112, 112) print(img[0, 0]) # 左上角那个像素的 BGR 值

这里有个能载入史册的坑:OpenCV读进来的彩色图像通道顺序是BGR,而我们平时说图像颜色,总是按RGB顺序。如果你用matplotlib直接显示cv2读进来的图,会发现颜色偏蓝偏暗,其实就是红和蓝通道被对调了。人到中年的CV开发者,谁没在BGR/RGB上翻过车。这个坑在后面的模型训练里还会再出现一次,稍后详细说。

1.2 让图像“可训练”的预处理三板斧

直接拿原始像素塞进神经网络不是不行,但训练效果会非常差。原因很简单:梯度下降对数值范围非常敏感。如果输入范围是0到255,而权重初始化是零点几的小数,两者一乘,梯度很容易变得非常大或者非常小,网络要不就是震荡不收敛,要不就是干脆卡死。所以图像预处理有三步是标配。

第一步,缩放尺寸。网络输入层的维度是固定的,图像必须统一尺寸。常见做法是resize到比如112x112或者224x224。尺寸选择有讲究:太小丢失细节,太大增加计算量,而且单纯resize会改变人脸的比例,所以后面做检测时通常还要做对齐。第二步,像素值归一化。最简单的做法是除以255,让数据范围落到0到1之间。第三步,按通道做标准化,也就是把每个通道的均值减掉、再除以标准差,让数据分布接近标准正态分布。

img_resized = cv2.resize(img, (112, 112)) img_norm = img_resized.astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406]) # 按通道均值 std = np.array([0.229, 0.224, 0.225]) # 按通道标准差 img_std = (img_norm - mean) / std

为什么标准化这么重要?我打一个比方:这就好比做菜之前先要把食材都切成差不多大小的块,火候才能均匀。如果有的像素是几,有的是几百,模型在训练时会被大的数值带走注意力,小数值维度里的信息就学不到了。我在实际项目中见过不少训练不收敛的案例,排查到最后都是归一化那一步漏了,改成标准化之后loss立刻就开始往下降了。记住一个原则:送入网络的数据分布越规整,训练越容易收敛。

2. 为什么图像任务绕不开卷积:全连接网络的两个硬伤

2.1 一张图展开后全连接层的参数量直接爆炸

如果只用前面讲过的全连接网络来处理图像,会出现一个非常棘手的问题。假设输入是一张256x256的彩色图,把它拉直成一维向量,长度是多少?256乘以256再乘以3,等于196608,差不多20万个数字。如果第一层设置512个神经元,那这一层的参数量就是20万乘以512,约等于1亿。这还没算第二层、第三层,参数量已经多到难以训练,显存也装不下。更关键的是,这个计算量买到的还只是极其简单的模式识别能力,性价比太低了。

全连接层的本质是把所有输入特征两两交叉组合,这适合那些特征之间没有明显空间关系的场景。但图像不一样,图像有非常强的局部结构:边缘、角点、纹理,这些结构只跟附近的像素有关。全局全连接的方式等于是把一张图上距离十万八千里的两个像素强行做关联,不仅浪费算力,还容易学到一堆噪声模式。所以我们必须换一种更符合图像本质的处理方式,这就是卷积。

2.2 卷积核的工作方式:一支带放大镜的侦察小队

卷积核的本质可以这样理解:一支侦察小队,拿着一块3x3的小放大镜,在整张画布上从左到右、从上到下逐格扫描。每到一个位置,把放大镜覆盖的9个像素与核上的9个权重分别相乘再求和,然后加上一个偏置,得到输出特征图上的一个点,最后过一层激活函数。

用代码写出来最直观。下面是一个单通道的朴素卷积实现:

def conv2d_single_channel(image, kernel): h, w = image.shape kh, kw = kernel.shape out_h, out_w = h - kh + 1, w - kw + 1 out = np.zeros((out_h, out_w)) for i in range(out_h): for j in range(out_w): window = image[i:i+kh, j:j+kw] out[i, j] = np.sum(window * kernel) return out

这个三重循环的思路就是卷积最原始的样子。实际框架里会做各种优化(比如矩阵乘法变换),但核心逻辑没变。卷积有两个非常聪明的设计:一是局部连接,每个核只看一块小区域,参数数量大幅缩小;二是参数共享,同一个核扫描整张图像的所有位置,不管图像多大,一个核的参数量是固定的。比如一个3x3x3的卷积核对RGB三通道输入做卷积,只需要27个权重加1个偏置,输出一个通道。想要32个通道,就准备32组这样的核,总共才864个参数,比全连接层动辄上亿的参数少了几个数量级。

2.3 池化层:把地图缩小,把重点特征留住

卷积层负责提取特征,池化层负责给特征图“瘦身”。最常用的是最大池化:在2x2的窗口里取最大值,特征图的宽高各减半。你可以理解为把一幅高清地图折叠成更小尺寸,但保留每个区域最突出的街道标志。平均池化则是取窗口内平均值,让特征更平滑。

池化带来的好处有三个:第一,计算量成倍下降,因为后续卷积处理的图变小了;第二,扩大了感受野,让后面的层能“看到”更大范围的信息,这是堆叠网络深度的基础;第三,让模型对轻微位移不那么敏感——物体稍微偏移几个像素,最大池化选出来的值往往变化不大。说到底,池化是一个巧妙的信息压缩操作,用一点点精度损失换来大量计算节省和泛化能力提升。

3. 一张人脸如何变成128维向量:特征提取链路逐层拆解

3.1 一张脸在卷积网络里的“视角”变化过程

现在我们把前面几个零件组装起来,看看一张人脸图在卷积神经网络里到底经历了什么。假设输入是一张112x112x3的彩色图像,经过第一组卷积层(比如32个3x3卷积核),输出是112x112x32的特征图,这32个通道分别响应不同的底层模式——有些通道对横边敏感,有些对竖边敏感,有些对特定角度的纹理敏感。再经过池化变成56x56x32。

接着第二组卷积层,把32通道进一步抽象成64通道,输出56x56x64,再池化变成28x28x64。到这个阶段,特征图里的内容就不再是简单的边缘了,而是像眼睛、鼻子、嘴这种局部器官的响应组合。网络层数越深,抽象层次越高。到最后的卷积层输出,可能是7x7x128这种张量。

这个从边缘到器官再到整体轮廓的过程,就是CNN自动完成的特征工程。传统方法里人们费尽心思设计“人脸特征描述子”,而卷积网络直接把这套活全包了,而且学出来的特征还往往比手工设计的更鲁棒。你不需要告诉模型“眼睛是圆的,鼻子是隆起的”,它自己通过数据把更细、更有效的模式挖掘出来了。

3.2 “128维向量”到底是在描述什么

很多教程告诉你最终会输出一个128维或512维的向量,但很少解释为什么人脸要用128维。我换一个类比:你在地球上定位一个城市,经纬度两个数字就够了,因为地球表面是二维的。那么128维,就相当于把一张人脸放到了128个不同的标尺上去打分。只不过这128个标尺不是人类设计的,也不是简单的“眼睛大小”“鼻子高度”,而是网络在大量人脸样本上学到的、难以用自然语言描述的抽象维度。

通俗地说,这128个分数组合在一起,就成了这张脸在“人脸上位空间”中的坐标。同一个人不同角度、不同光线下的两张照片,映射出来的坐标会非常接近;不同人的照片,坐标会拉开距离。这正是人脸验证问题的数学基础。向量维度的选择是个工程权衡:维度太低,表达能力不够,不同人容易撞车;维度太高,计算量大,而且容易过拟合到训练集。128维是业界经过大量实验验证比较均衡的取值,常见的人脸模型有的用512维,本质上都是在精度和效率之间做选择。

3.3 判断两张脸是不是同一个人:距离度量和阈值设定

有了特征向量,判断“是不是同一个人”就变成了一个几何问题。最常用的两种度量方式是欧氏距离和余弦相似度。欧氏距离衡量两个点在空间中的直线距离;余弦相似度衡量两个向量的方向是否一致,数值上越接近1表示越相似。

用代码写出来是这样:

import numpy as np def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # 假设 emb1 和 emb2 是同一个人两次不同角度拍照得到的 128 维特征 # emb3 是另一个人 print("同一个人相似度:", cosine_similarity(emb1, emb2)) # 大约是 0.81 print("不同人相似度:", cosine_similarity(emb1, emb3)) # 大约是 0.53

阈值怎么定?这取决于业务场景。门禁系统希望误识率尽可能低,宁可拒绝本人也不好放陌生人进来,阈值就调高,比如要求相似度超过0.75;手机相册做人物聚类,希望别漏掉同一个人,阈值就可以放宽到0.5左右。这个阈值从来不是拍脑袋定的,正确的做法是在你自己的人脸数据集上把正负样本的相似度分布画出来,算ROC曲线,再根据误识率和拒识率的平衡点去选。日常实验里可以先取个经验值跑通流程,但上线前一定要做这个数据驱动的阈值标定。

4. 手写迷你特征提取器:用纯numpy实现卷积前向传播

4.1 一个MiniCNN的模块设计

前面讲了一堆原理,现在动手写代码。既然是“从零开始”系列,我就不直接import深度学习框架,而是用numpy把整个前向传播过程手写出来。这样你能清楚看到每一步数据形状是怎么变化的。我们设计一个极简网络:第一个卷积层把3通道输入变成8通道,第二个卷积层从8通道变成16通道,中间各跟一个最大池化,最后展平接一个全连接层输出128维向量。

import numpy as np class MiniCNN: def __init__(self, input_h=112, input_w=112): # 卷积层1:3通道 -> 8通道,卷积核 3x3 self.w1 = np.random.randn(8, 3, 3, 3) * 0.1 # (out_c, in_c, kh, kw) self.b1 = np.zeros(8) # 卷积层2:8通道 -> 16通道 self.w2 = np.random.randn(16, 8, 3, 3) * 0.1 self.b2 = np.zeros(16) # 两次池化后尺寸:112 -> 56 -> 28,再经过第二次卷积+池化后仍是28 # 但第二个卷积层输出16通道,池化后 28x28x16,展平后是 28*28*16 self.fc_input_dim = 28 * 28 * 16 self.w3 = np.random.randn(self.fc_input_dim, 128) * 0.01 self.b3 = np.zeros(128)nn

这里的参数初始化我刻意用了很小的值,这对深层网络很关键。如果初始化太大,经过多层叠加后激活值会迅速饱和,梯度消失;如果太小,信号又会层层衰减到接近于零。你后面用框架训练时,初始化策略看似无关紧要,实际上它决定了梯度能不能顺利流过整个网络。

4.2 卷积、池化、全连接的前向传播代码

下面把前向传播的三个核心操作挨个实现。卷积我用朴素循环,只为讲清楚原理,不做任何性能优化:

def conv2d(image, kernel, bias): # image: (in_c, h, w),kernel: (out_c, in_c, kh, kw) out_c, _, kh, kw = kernel.shape h, w = image.shape[1], image.shape[2] out_h, out_w = h - kh + 1, w - kw + 1 out = np.zeros((out_c, out_h, out_w)) for oc in range(out_c): for ic in range(image.shape[0]): for i in range(out_h): for j in range(out_w): window = image[ic, i:i+kh, j:j+kw] out[oc, i, j] += np.sum(window * kernel[oc, ic]) out[oc] += bias[oc] return out def relu(x): return np.maximum(x, 0) def maxpool2d(x, size=2): # x: (c, h, w) c, h, w = x.shape out_h, out_w = h // size, w // size out = np.zeros((c, out_h, out_w)) for i in range(out_h): for j in range(out_w): out[:, i, j] = x[:, i*size:(i+1)*size, j*size:(j+1)*size].max(axis=(1, 2)) return out def forward(self, x): # x: (3, 112, 112) x = relu(conv2d(x, self.w1, self.b1)) # -> (8, 110, 110) x = maxpool2d(x) # -> (8, 55, 55) x = relu(conv2d(x, self.w2, self.b2)) # -> (16, 53, 53) x = maxpool2d(x) # -> (16, 26, 26) x = x.reshape(-1) # flatten embedding = np.dot(self.w3.T, x) + self.b3 # -> (128,) return embedding

注意我在这里写的池化窗口是2,所以56x56会变成28x28,但卷积层因为不补padding,尺寸会缩水。上面注释里我写的最终尺寸是26x26,和前面初始化注释里的28x28有出入。这就是手写网络最需要留神的地方:任何一层输出尺寸算错,后面全连接层的输入维度就会对不上。实际调试时我几乎每次都要用print把每个步骤的shape打出来核对一遍。

4.3 为什么生产环境不这么写:性能与梯度反传的现实选择

手写是为了理解,不是为了产品。纯numpy卷积在CPU上跑一张图,循环层数一多,耗时几十毫秒甚至几百毫秒,训练更是慢到没法用。所以实际项目里,训练和推理都建议用现成的深度学习框架,它把GPU加速、自动微分、算子融合全给你做好了。但是,从零手写过一遍之后,你再去看框架里的Conv2d API,就会知道它本质上只是一个封装好的“放大镜扫描”操作,参数怎么初始化、数据形状怎么流动,心里一清二楚。

这也能解释为什么这个系列的前几篇要先手写反向传播:卷积层的反向传播,本质上就是把梯度回传的规则套到卷积窗口上,把前向的卷积操作改成“梯度与权重的卷积”。有了手写全连接层反向传播的基础,理解起来一点也不难。等你想用框架训练本篇的网络时,框架替你做的自动微分,内部就是把这套链式法则的规则执行了一遍。

5. 图像项目里最常踩的坑:从BGR到人脸对齐的排查清单

5.1 颜色通道顺序和数组维度布局

先聊一个能让任何CV工程师血压升高的坑:通道顺序。OpenCV读进来的是BGR,而很多模型训练时用的是RGB。如果你在某个库上用RGB顺序训练好了一个模型,部署时直接拿cv2读出的BGR图送进去,推理结果会莫名其妙地变差,你却还以为是模型泛化不行。排查方法很简单:把送入网络前的图像用代码保存出来,肉眼看一下颜色是不是正常。

img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

另外,不同框架对张量维度的组织方式也不一样:有的习惯把通道放在第二位,也就是NCHW,有的放在最后一位,也就是NHWC。自己写numpy代码时我习惯用CHW,但拿到框架里就要仔细看文档。跨框架搬运模型时,维度顺序是最容易出错的隐藏问题,很多时候模型参数能加载成功,但推理效果不对,就是因为形状布局匹配被忽略了。

5.2 训练不收敛时到底该先查什么

图像模型训练不收敛的排查顺序,我有血泪经验。最容易出的问题恰恰是最“小儿科”的环节,按顺序查:

排查顺序问题类型典型表现常见原因
1数据问题loss 震荡或不变归一化漏了、标签打错、图像通道错
2超参数问题loss 直接变成 NaN学习率过大、梯度爆炸
3网络结构问题loss 前期下降后卡住激活函数选择不当、层间维度连接错误
4代码逻辑问题训练和验证结果差异极大train/eval模式切换漏了、数据泄漏

我在多少个深夜debug中悟出来一个经验:先打印一个batch的数据和标签,用肉眼核对;再把这个batch送入网络,用打印的方式把每层的输出shape和数值范围都过一遍。这两步能解决九成以上“训练不起来”的问题。不要一上来就怀疑模型结构设计有问题,那通常是最后才怀疑的方向。

5.3 人脸识别特有的坑:对齐、质量筛选与活体检测

人脸检测框出来之后,直接裁图扔进网络效果并不好。为什么?因为人的头部角度、眼睛位置各不相同,纯粹做裁剪,脸在图像里的位置和尺度都不统一,特征向量的可比性就大打折扣。所以正规流程里,人脸对齐是必须的一步:通过眼睛、鼻子等关键点坐标,计算一个仿射变换矩阵,把所有人脸都旋转、缩放到一个标准位置。就这一步操作,往往能让特征提取的准确率明显提升。

生产级人脸识别系统还需要考虑两个问题:质量筛选和活体检测。模糊、过暗、过曝、被遮挡的图像,特征提取出来都不可靠,需要先用质量模型过滤掉低质量输入。活体检测则是防止有人拿一张打印照片、一个视频去骗过系统。这些看起来“很外围”的事,在实际落地时和特征向量本身一样重要。很多从学术demo走向产品的团队,最先补的课往往不是网络结构,而是这些工程细节。

6. 高维向量的进阶玩法:检索、聚类与迁移学习

6.1 从人脸验证到人脸检索:向量库的应用思路

你手里已经有了128维特征向量之后,能做的事情远超“判断是不是同一个人”。最简单的扩展是检索:把库里所有人的向量存起来,新来一张脸,提特征,计算它与所有历史向量的距离,找出距离最近的一个,再判断距离是否小于某个阈值。小于阈值就匹配成功,大于阈值就当成新的人入库。这套流程就是人脸检索系统的雏形,也是很多相册应用“人物聚类”的基础。

更进一步,同一套向量表示还可以做聚类,把海量人脸照片按身份自动分组。你不需要训练任何新模型,只需要在向量上跑一个简单的聚类算法(比如基于密度的聚类),就能得到“同一个人的所有照片”这个结果。由于向量已经是一个非常紧凑、语义明确的空间表示,聚类效果往往远比直接在原始像素上聚类好得多。这让我想起热词里提到的图神经网络——本质上,它们都在做一件事:把复杂非结构化的输入变成有意义的向量表示,区别只在于用什么网络结构去学这个表示。

6.2 迁移学习:用预训练特征向量解决小样本问题

如果我们想识别的不只是人脸,而是猫的品种、工业零件的缺陷、某种农作物病变呢?收集几万张标注图不现实。这时候迁移学习就派上用场了:用一个在大规模数据上预训练好的模型,把图片变成向量,然后只在自己的少量数据上训练一个简单的分类器。这里有个结构上的天然优势:预训练网络前面的卷积层学到的是通用视觉特征——边缘、纹理、形状——这些特征在不同任务之间是通用的,真正需要自适应的只是后面加的那一小层。

我在分类项目里经常用这个套路:冻结卷积层,只训练最后接上的全连接分类层。因为冻结部分不用计算梯度,训练速度非常快,显存占用也低;而且如果任务数据量很小,全部微调反而容易过拟合。先提特征训分类器,这就够用了。如果效果不理想,再逐步解冻一些深层卷积层做微调,相当于一步步给模型“松绑”,让它不再完全依赖预训练特征。

到了这步,你已经把“图像到高维向量”的整条链路的原理、实现和应用场景都打通了。我个人的体会是:神经网络没那么神秘,把它拆到每一步的数据形状变化都心中有数,很多对黑盒的恐惧都会消失。下一篇我大概率会把训练部分的代码补全,也就是卷积层的反向传播。如果你正跟着这个系列动手写代码,建议今天跑通本篇的前向传播之后,用print把每个中间张量的shape打出来,和注释里写的形状逐一对照。这个过程能帮你把“图像变成向量”这件事彻底焊死在脑子里,后面再学什么深度可分离卷积、注意力机制,都是一通百通的事。最后再分享一个小建议:手写代码时别忘了定期git提交,给自己留个后悔药,改出问题的时候能一键回滚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进