图像如何变成高维向量:从像素矩阵到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:ikh, j:jkw] 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_h112, input_w112): # 卷积层13通道 - 8通道卷积核 3x3 self.w1 np.random.randn(8, 3, 3, 3) * 0.1 # (out_c, in_c, kh, kw) self.b1 np.zeros(8) # 卷积层28通道 - 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:ikh, j:jkw] 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, size2): # 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:(i1)*size, j*size:(j1)*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提交给自己留个后悔药改出问题的时候能一键回滚。