別:用PCA降維實(shí)現(xiàn)輕量級(jí)Python人臉識(shí)別系統(tǒng))
簡(jiǎn)介基于Python的Eigenface人臉識(shí)別課程設(shè)計(jì)包面向需要完成人臉識(shí)別方向課程設(shè)計(jì)或畢業(yè)設(shè)計(jì)的高校學(xué)生完整覆蓋從人臉檢測(cè)、特征提取、模型訓(xùn)練到識(shí)別驗(yàn)證的經(jīng)典實(shí)現(xiàn)流程。包內(nèi)共11個(gè)文件包含5個(gè)Python源碼腳本、模型配置json、OpenCV級(jí)聯(lián)分類(lèi)器xml、設(shè)計(jì)報(bào)告docx以及說(shuō)明文檔和license整體大小約7.14MB目錄結(jié)構(gòu)清晰便于按流程閱讀和實(shí)踐。代碼基于Python 3.7與OpenCV 4.5.0開(kāi)發(fā)在Visual Studio Code中運(yùn)行調(diào)用攝像頭配合haar_cascade_frontalface_default.xml完成人臉檢測(cè)將截取到的人臉尺寸歸一化并轉(zhuǎn)換為pgm格式生成個(gè)人樣本同時(shí)提供訓(xùn)練、測(cè)試、重構(gòu)、人臉采集等多個(gè)模塊腳本覆蓋從數(shù)據(jù)準(zhǔn)備、模型訓(xùn)練到結(jié)果驗(yàn)證的完整鏈路可直接修改后復(fù)用于實(shí)驗(yàn)。隨包附帶的word設(shè)計(jì)報(bào)告可幫助理解Eigenface原理與實(shí)驗(yàn)設(shè)計(jì)。目前已有558人學(xué)習(xí)下載適合作為人臉識(shí)別入門(mén)項(xiàng)目、課程設(shè)計(jì)模板及算法實(shí)驗(yàn)改寫(xiě)的參考素材。1. Eigenface 人臉識(shí)別這個(gè) zip到底解決什么問(wèn)題Eigenface特征臉是很多人接觸人臉識(shí)別時(shí)繞不過(guò)去的第一個(gè)里程碑算法。它不依賴(lài)深度學(xué)習(xí)不燒 GPU純用 Python 和 NumPy 就能在幾十行代碼里跑通一套“訓(xùn)練—識(shí)別”流程把一張人臉圖像壓縮成幾十個(gè)系數(shù)再用最近鄰去匹配身份。這個(gè) zip 本質(zhì)上是一個(gè)完整的 Eigenface 實(shí)現(xiàn)包里面應(yīng)該包含數(shù)據(jù)預(yù)處理腳本、PCA 訓(xùn)練代碼、識(shí)別腳本和一個(gè)人臉數(shù)據(jù)集組織目錄。它適合三類(lèi)人正在做畢設(shè)或課設(shè)的學(xué)生剛接觸機(jī)器視覺(jué)想從原理層面理解 PCA 降維如何作用于圖像的工程師以及需要在嵌入式設(shè)備或低算力環(huán)境里做輕量級(jí)人臉驗(yàn)證的場(chǎng)景。先給出一個(gè)反直覺(jué)的結(jié)論Eigenface 在白底證件照上準(zhǔn)確率能到 95% 以上但一到真實(shí)光照、側(cè)臉、遮擋場(chǎng)景就會(huì)斷崖式下跌——這不是 bug而是線(xiàn)性子空間模型的天花板。理解了這個(gè)邊界你才能真正用好這個(gè) zip而不是拿到手跑通 demo 就以為可以上生產(chǎn)。2. 特征臉原理為什么 PCA 能把人臉變成一組數(shù)字2.1 從像素空間到特征空間的本質(zhì)跳躍一張 112×92 的灰度人臉圖展平后是一個(gè) 10304 維的向量。如果直接把所有訓(xùn)練樣本丟進(jìn)分類(lèi)器維度災(zāi)難會(huì)同時(shí)帶來(lái)計(jì)算開(kāi)銷(xiāo)和過(guò)擬合。Eigenface 的核心假設(shè)是人臉雖然分布在 10304 維空間里但真正的變化其實(shí)被少數(shù)幾個(gè)方向主導(dǎo)——比如光照方向、臉型寬窄、五官位置。PCA 要做的就是找到這些主方向把每張人臉從 10304 維投影到 1050 維而且保留絕大部分區(qū)分度。數(shù)學(xué)上假設(shè)訓(xùn)練集有 N 張人臉每張是 d 維向量。先算平均臉import numpy as np def compute_mean_face(face_matrix): # face_matrix: shape (N, d)每行是一張展平后的人臉 mean_face np.mean(face_matrix, axis0) return mean_face # 示例10 張 112x92 的圖展平后每張 10304 維 faces np.random.randn(10, 10304) mean compute_mean_face(faces) print(mean.shape) # (10304,)這段代碼做的事就是把所有訓(xùn)練樣本逐像素求平均得到一張“平均臉”。注意這里 axis0 表示沿著樣本維度求均值得到的 mean_face 是一維數(shù)組長(zhǎng)度等于單張圖片的像素?cái)?shù)。之后我們要把每張臉減去平均臉得到差值矩陣再做協(xié)方差矩陣的特征分解。協(xié)方差矩陣的維度是 d×d對(duì) 10304 維來(lái)說(shuō)直接算特征分解內(nèi)存爆炸所以要用一個(gè)小技巧——對(duì)差值矩陣的轉(zhuǎn)置做 SVD 或特征分解而不是直接對(duì)協(xié)方差矩陣操作。2.2 從協(xié)方差矩陣到特征臉降維的完整推導(dǎo)設(shè)差值矩陣 X 的 shape 為 (N, d)我們要找的其實(shí)是 X?X 的特征向量。但 X?X 是 d×d代價(jià)太高轉(zhuǎn)而做 XX?這是 N×N對(duì)幾十到幾百個(gè)訓(xùn)練樣本來(lái)說(shuō)非常小。XX? 的特征向量 v 對(duì)應(yīng)關(guān)系是X?X 的特征向量 u X?v 再歸一化。這就是經(jīng)典的“轉(zhuǎn)置技巧”也是 Eigenface 能跑在純 CPU 上的關(guān)鍵。def train_eigenfaces(face_matrix, n_components25): # face_matrix: shape (N, d)每行是一張人臉 N, d face_matrix.shape mean_face np.mean(face_matrix, axis0) centered face_matrix - mean_face # 去均值 # 小矩陣N x N cov_small np.dot(centered, centered.T) # (N, N) eigvals, eigvecs_small np.linalg.eigh(cov_small) # 特征值降序排列 idx np.argsort(eigvals)[::-1] eigvecs_small eigvecs_small[:, idx] # 還原到 d 維空間 eigvecs np.dot(centered.T, eigvecs_small) # (d, N) norm np.linalg.norm(eigvecs, axis0) eigvecs eigvecs / norm # 歸一化 # 取前 n_components 個(gè)主成分作為特征臉 eigenfaces eigvecs[:, :n_components] return mean_face, eigenfaces # 用法 X np.random.randn(40, 10304) mean, eigenfaces train_eigenfaces(X, n_components30) print(eigenfaces.shape) # (10304, 30)這里的核心參數(shù) n_components決定了最終特征空間的維度。選多少?zèng)]有絕對(duì)標(biāo)準(zhǔn)常見(jiàn)做法是先保留能解釋 90% 以上方差的前 k 個(gè)主成分。你可以加一行代碼算解釋方差比explained_variance_ratio eigvals[idx] / np.sum(eigvals) cumsum np.cumsum(explained_variance_ratio) k np.argmax(cumsum 0.95) 1 print(f保留 {k} 個(gè)特征臉即可解釋 95% 方差)注意 np.linalg.eigh 返回的特征值已經(jīng)是升序所以必須做一次逆序索引。用 eigh 而不是 eig是因?yàn)閰f(xié)方差矩陣是對(duì)稱(chēng)的eigh 數(shù)值穩(wěn)定性更好速度也更快。特征臉本質(zhì)上是 d 維向量可以 reshape 回 (112, 92) 可視化你會(huì)看到一張張“幽靈臉”前幾個(gè)特征臉對(duì)應(yīng)光照和整體明暗后面則越來(lái)越像具體的人臉結(jié)構(gòu)差異。3. 用 Python 搭建完整 Eigenface 項(xiàng)目從數(shù)據(jù)集到識(shí)別3.1 數(shù)據(jù)集組織與預(yù)處理流程圖這個(gè) zip 里大概率自帶一個(gè)數(shù)據(jù)集目錄結(jié)構(gòu)通常是按人分文件夾每個(gè)文件夾放同一人的多張不同表情/姿態(tài)的灰度圖。如果你手頭沒(méi)有用 ORL 人臉數(shù)據(jù)集400 張40 人每人 10 張或 Yale 數(shù)據(jù)集最合適。數(shù)據(jù)加載和預(yù)處理我一般這樣組織import os import cv2 import numpy as np IMG_SIZE (112, 92) def load_dataset(data_dir): 目錄結(jié)構(gòu): data_dir/ person_01/ 1.pgm 2.pgm person_02/ 1.pgm ... 返回: faces: (N, 10304) 的 float32 矩陣 labels: (N,) 的 int 標(biāo)簽 name_map: 標(biāo)簽 - 人名 的字典 faces, labels [], [] name_map {} label 0 for person_name in sorted(os.listdir(data_dir)): person_dir os.path.join(data_dir, person_name) if not os.path.isdir(person_dir): continue name_map[label] person_name for img_name in os.listdir(person_dir): img_path os.path.join(person_dir, img_name) if not img_path.lower().endswith((.pgm, .jpg, .png, .bmp)): continue img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: print(f警告: 無(wú)法讀取 {img_path}) continue img cv2.resize(img, IMG_SIZE) faces.append(img.flatten().astype(np.float32)) labels.append(label) label 1 return np.array(faces), np.array(labels), name_map # 用法示例 faces, labels, name_map load_dataset(./att_faces) print(faces.shape) # (400, 10304) print(labels[:10]) # [0 0 0 ...]這個(gè)地方有四個(gè)易錯(cuò)點(diǎn)。第一cv2.imread 讀入的灰度圖是 uint8范圍 0255必須轉(zhuǎn) float32否則后續(xù)算均值、協(xié)方差時(shí)精度不夠且可能溢出。第二resize 到固定尺寸很關(guān)鍵同一數(shù)據(jù)集里原始圖片可能尺寸不一致Eigenface 的向量維度必須統(tǒng)一。第三文件名過(guò)濾只認(rèn)圖片后綴防止把 Mac 的 .DS_Store 或 Windows 的隱藏文件讀進(jìn)來(lái)。第四label 從 0 開(kāi)始按字母序遞增name_map 負(fù)責(zé)把數(shù)字標(biāo)簽映射回人名識(shí)別結(jié)果輸出時(shí)用 name_map 轉(zhuǎn)回字符串。3.2 訓(xùn)練流程封裝一個(gè)類(lèi)搞定所有邏輯class EigenfaceRecognizer: def __init__(self, n_components25): self.n_components n_components self.mean_face None self.eigenfaces None self.labels None self.projections None self.name_map None def fit(self, faces, labels, name_mapNone): # faces: (N, d), labels: (N,) self.labels labels self.name_map name_map if name_map is not None else {i: str(i) for i in set(labels)} self.mean_face np.mean(faces, axis0) centered faces - self.mean_face # 計(jì)算小協(xié)方差矩陣的特征分解 cov_small np.dot(centered, centered.T) eigvals, eigvecs_small np.linalg.eigh(cov_small) # 按特征值降序 idx np.argsort(eigvals)[::-1] eigvecs_small eigvecs_small[:, idx] # 映射回原空間并歸一化 eigenfaces np.dot(centered.T, eigvecs_small) norms np.linalg.norm(eigenfaces, axis0) eigenfaces eigenfaces / norms # 取前 n_components 個(gè) self.eigenfaces eigenfaces[:, :self.n_components] # 把所有訓(xùn)練樣本投影到特征臉空間 self.projections np.dot(centered, self.eigenfaces) return self def predict(self, face): face: (d,) 或 (1, d) 的展平人臉向量 返回 (label, distance) face np.asarray(face).reshape(1, -1).astype(np.float32) centered face - self.mean_face # 投影到特征臉空間 projection np.dot(centered, self.eigenfaces) # (1, n_components) # 計(jì)算與所有訓(xùn)練樣本投影的歐氏距離 distances np.linalg.norm(self.projections - projection, axis1) best_idx np.argmin(distances) return self.labels[best_idx], distances[best_idx]這個(gè)類(lèi)封裝的思路是fit 里做完 PCA 并緩存所有訓(xùn)練樣本在特征臉空間的坐標(biāo)predict 階段只需要做兩次矩陣乘法和一次距離排序——推理速度極快單張 112×92 的圖在普通 CPU 上跑一次小于 1 毫秒。距離度量選歐氏距離這是最常見(jiàn)做法也可以用余弦相似度但歐氏距離在特征臉空間里物理意義更直覺(jué)。注意 predict 返回的是一個(gè)元組 (label, distance)distance 可以當(dāng)作置信度參考——距離閾值判斷在后面講。3.3 完整訓(xùn)練與識(shí)別主腳本from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report # 加載數(shù)據(jù) faces, labels, name_map load_dataset(./att_faces) # ORL 數(shù)據(jù)集 # 劃分訓(xùn)練集和測(cè)試集每個(gè)人取前 8 張訓(xùn)練后 2 張測(cè)試 # 注意不能隨機(jī)打亂后直接 split會(huì)造成同一個(gè)人同時(shí)出現(xiàn)在訓(xùn)練和測(cè)試?yán)?X_train, X_test, y_train, y_test [], [], [], [] for person_id in set(labels): idxs np.where(labels person_id)[0] # 按原始順序切分保證前 80% 訓(xùn)練 20% 測(cè)試 split int(len(idxs) * 0.8) X_train.extend(faces[idxs[:split]]) y_train.extend(labels[idxs[:split]]) X_test.extend(faces[idxs[split:]]) y_test.extend(labels[idxs[split:]]) X_train np.array(X_train) X_test np.array(X_test) y_train np.array(y_train) y_test np.array(y_test) # 訓(xùn)練 Eigenface 模型 model EigenfaceRecognizer(n_components30) model.fit(X_train, y_train, name_map) # 測(cè)試識(shí)別 correct 0 for i in range(len(X_test)): pred_label, dist model.predict(X_test[i]) if pred_label y_test[i]: correct 1 print(f識(shí)別準(zhǔn)確率: {correct / len(X_test) * 100:.2f}%) print(classification_report(y_test, [model.predict(x)[0] for x in X_test]))這里訓(xùn)練/測(cè)試劃分特意用了按人切分而不是全局隨機(jī)切分這是一個(gè)關(guān)鍵點(diǎn)如果你的測(cè)試集里混入了訓(xùn)練時(shí)見(jiàn)過(guò)的人準(zhǔn)確率會(huì)虛高。按人切分的邏輯是每個(gè)人至少保留 2 張作為測(cè)試訓(xùn)練和測(cè)試的身份完全隔離。ORL 數(shù)據(jù)集每人 10 張8 張訓(xùn)練 2 張測(cè)試n_components30 時(shí)準(zhǔn)確率通常在 95% 左右能跑到這個(gè)數(shù)字說(shuō)明管線(xiàn)沒(méi)毛病。4. 參數(shù)怎么調(diào)n_components、距離閾值和數(shù)據(jù)集規(guī)模的影響4.1 n_components 與準(zhǔn)確率的關(guān)系曲線(xiàn)n_components 是 Eigenface 最敏感的超參數(shù)。設(shè)太小特征空間不能充分表達(dá)人臉差異設(shè)太大會(huì)把噪聲和光照變化也當(dāng)作區(qū)分特征且尾部特征臉對(duì)應(yīng)的小特征值方向含有大量噪聲。最穩(wěn)妥的做法是做一個(gè) k 值掃描import matplotlib.pyplot as plt k_values [5, 10, 15, 20, 25, 30, 40, 50, 60, 80] accuracies [] for k in k_values: model EigenfaceRecognizer(n_componentsk) model.fit(X_train, y_train, name_map) acc np.mean([model.predict(x)[0] y_test[i] for i, x in enumerate(X_test)]) accuracies.append(acc) print(fk{k}, 準(zhǔn)確率{acc*100:.2f}%) # 畫(huà)曲線(xiàn)圖 plt.plot(k_values, accuracies, markero) plt.xlabel(n_components) plt.ylabel(Accuracy) plt.title(Accuracy vs Eigenface Components) plt.show()這段代碼跑出來(lái)通常是一條先快速上升、后平緩、最后輕微下降的曲線(xiàn)。ORL 數(shù)據(jù)集上 k25 到 k40 之間是平臺(tái)期超過(guò) 50 后準(zhǔn)確率不升反降。我在實(shí)際項(xiàng)目里一般這么定如果訓(xùn)練樣本總數(shù)是 Nn_components 取 N/3 到 N/2 之間然后在這個(gè)范圍內(nèi)做一次掃描取最優(yōu)。數(shù)據(jù)量小時(shí)比如每人只有 3 張k 取 10 就夠數(shù)據(jù)量大且光照復(fù)雜時(shí)k 取 60100 能捕捉更多變化但也更依賴(lài)訓(xùn)練數(shù)據(jù)的代表性。4.2 距離閾值識(shí)別和拒識(shí)之間的權(quán)衡識(shí)別不只是找到最近鄰還要決定“這個(gè)人到底在不在數(shù)據(jù)庫(kù)里”。如果任意測(cè)試臉都強(qiáng)行歸到某個(gè)訓(xùn)練樣本上陌生人也會(huì)被分配一個(gè)身份。解決辦法是設(shè)定距離上限THRESHOLD 2800 # 歐氏距離閾值需要根據(jù)訓(xùn)練集統(tǒng)計(jì)得出 def recognize_with_threshold(model, face, threshold2800): label, distance model.predict(face) if distance threshold: return label, distance, True # 識(shí)別成功 else: return None, distance, False # 拒絕識(shí)別 # 統(tǒng)計(jì)訓(xùn)練集自身的距離分布來(lái)確定閾值 train_distances [] for i in range(len(X_train)): _, d model.predict(X_train[i]) train_distances.append(d) train_distances np.array(train_distances) print(f訓(xùn)練集內(nèi)距離: 均值{train_distances.mean():.1f}, fstd{train_distances.std():.1f}, f99分位{np.percentile(train_distances, 99):.1f})閾值選多少屬于“血淚經(jīng)驗(yàn)”。我一般用訓(xùn)練集內(nèi)所有樣本到自身類(lèi)中心的距離統(tǒng)計(jì)取 p99 再乘 1.21.5 倍作為初始閾值然后在驗(yàn)證集上做一次 ROC 曲線(xiàn)微調(diào)。閾值太嚴(yán)格會(huì)把帶眼鏡、光線(xiàn)變化的真人也拒掉太寬泛會(huì)讓陌生人混進(jìn)來(lái)。注意 Eigenface 的距離分布受圖像尺寸影響極大112×92 下 p99 大概兩三千改到 64×64 距離直接縮水一半所以換圖像尺寸后必須重新統(tǒng)計(jì)閾值不要沿用舊經(jīng)驗(yàn)值。4.3 數(shù)據(jù)集規(guī)模與每人樣本數(shù)的下限Eigenface 本質(zhì)是一個(gè)線(xiàn)性模型隱含假設(shè)是“同一人的不同人臉圖像近似落在同一個(gè)低維子空間附近”。這個(gè)假設(shè)在每人樣本太少時(shí)根本不成立——如果每人只有 1 張訓(xùn)練圖PCA 學(xué)習(xí)不到類(lèi)內(nèi)變化光照一變就翻車(chē)。我的經(jīng)驗(yàn)是每人至少 35 張不同光照/表情的樣本總數(shù)最好在 100 張以上。當(dāng)你只能拿到每人一張圖時(shí)有兩個(gè)補(bǔ)救方向一是做數(shù)據(jù)增強(qiáng)水平翻轉(zhuǎn)、小幅旋轉(zhuǎn)、亮度抖動(dòng)擴(kuò)充樣本二是改用基于特征點(diǎn)對(duì)齊的方法預(yù)處理把眼睛、嘴巴對(duì)齊到固定位置減小類(lèi)內(nèi)差異。對(duì)齊這一步比換個(gè)降維算法更有效能直接影響識(shí)別率五到十個(gè)點(diǎn)。5. Eigenface 實(shí)戰(zhàn)避坑這五個(gè)問(wèn)題我全踩過(guò)5.1 現(xiàn)象預(yù)測(cè)準(zhǔn)確率極低甚至只有 20%原因八成是數(shù)據(jù)沒(méi)有對(duì)齊。人臉圖像中眼睛的位置如果每張都不同PCA 會(huì)把“眼睛偏移”當(dāng)作最主要的區(qū)分特征而真正的身份差異反而被當(dāng)作噪聲丟掉了。解決在加載數(shù)據(jù)后做一次簡(jiǎn)單的人眼對(duì)齊用 OpenCV 的級(jí)聯(lián)分類(lèi)器檢測(cè)雙眼坐標(biāo)仿射變換把雙眼放到固定位置后統(tǒng)一 resize。這個(gè)預(yù)處理步驟比調(diào) n_components 提升大得多。檢測(cè)雙眼和仿射變換的核心代碼如下def align_face(img, left_eye, right_eye, output_size(112, 92)): # 計(jì)算旋轉(zhuǎn)角度和縮放 dx right_eye[0] - left_eye[0] dy right_eye[1] - left_eye[1] angle np.degrees(np.arctan2(dy, dx)) # 兩眼連線(xiàn)中心 center ((left_eye[0] right_eye[0]) // 2, (left_eye[1] right_eye[1]) // 2) eyes_distance np.sqrt(dx**2 dy**2) # 設(shè)定目標(biāo)兩眼距離統(tǒng)一縮放到固定尺寸 output_eyes_distance output_size[0] * 0.5 scale output_eyes_distance / eyes_distance M cv2.getRotationMatrix2D(center, angle, scale) aligned cv2.warpAffine(img, M, output_size, flagscv2.INTER_CUBIC) return aligned這句 M cv2.getRotationMatrix2D(center, angle, scale) 把旋轉(zhuǎn)和縮放合成在一個(gè)矩陣?yán)锉确謩e執(zhí)行兩次變換少一次插值誤差。eyes_distance 對(duì)這些浮點(diǎn)值敏感注意 dx 和 dy 已經(jīng)從相同的坐標(biāo)系中取得。5.2 現(xiàn)象n_components 取大了訓(xùn)練樣本的投影矩陣是奇異矩陣LinAlgError原因當(dāng) n_components 大于訓(xùn)練樣本數(shù) N 時(shí)協(xié)方差矩陣 XX? 是 N×N 的最多只有 N-1 個(gè)非零特征值因?yàn)槿ゾ岛髽颖揪€(xiàn)性相關(guān)你不可能取到 N1 個(gè)獨(dú)立特征向量。解決n_components 必須小于或等于 N-1。我在 fit 函數(shù)里加了一個(gè)保護(hù)n_components min(self.n_components, centered.shape[0] - 1) self.n_components max(n_components, 1)加了這行就算有人在 n_components 傳了 500 也不會(huì)崩。另一個(gè)隱含風(fēng)險(xiǎn)是如果有些人臉過(guò)于相似同一人的多張正臉特征值可能接近 0歸一化時(shí)除以零。穩(wěn)妥做法是設(shè)置一個(gè)極小值 epsilon 替代碼5.3 現(xiàn)象測(cè)試集準(zhǔn)確率高但實(shí)際攝像頭輸入識(shí)別失敗原因測(cè)試集和數(shù)據(jù)集的拍攝條件一致但攝像頭實(shí)時(shí)畫(huà)面有背景噪聲、不同亮度、臉部位置偏移。Eigenface 對(duì)光照極敏感因?yàn)?PCA 的前幾個(gè)主方向往往就是光照變化方向。解決實(shí)時(shí)識(shí)別前做直方圖均衡化和光照歸一化把圖像變換成標(biāo)準(zhǔn)光照下的形式def normalize_illumination(img): # 方法1: CLAHE 限制對(duì)比度的自適應(yīng)直方圖均衡化 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) img_norm clahe.apply(img) # 方法2: 減去高斯模糊后的低頻分量提取高頻細(xì)節(jié) blurred cv2.GaussianBlur(img_norm, (31, 31), 0) img_highfreq cv2.subtract(img_norm, blurred) return img_highfreq我做了對(duì)比實(shí)驗(yàn)不做光照歸一化時(shí)攝像頭實(shí)測(cè)準(zhǔn)確率大概 70%做了 CLAHE 高頻增強(qiáng)同一模型跳到 85% 以上。但要注意訓(xùn)練時(shí)也應(yīng)用相同的預(yù)處理訓(xùn)練和推理必須走同一條預(yù)處理管線(xiàn)否則特征分布直接錯(cuò)位。5.4 現(xiàn)象程序跑起來(lái)內(nèi)存耗盡進(jìn)程被殺d 維特征矩陣太大在圖像一維展開(kāi)后d 可能有幾萬(wàn)甚至幾十萬(wàn)180×180 的灰度圖就是 32400 維。如果直接構(gòu)建 d×d 協(xié)方差矩陣就需要約 8GB 內(nèi)存。解決方式就是本章前面講的轉(zhuǎn)置技巧只構(gòu)建 N×N 矩陣。另一個(gè)變體是使用增量 PCAIncrementalPCA它允許把數(shù)據(jù)分塊送入適合內(nèi)存敏感的嵌入式環(huán)境from sklearn.decomposition import IncrementalPCA def train_incremental(faces, n_components30, batch_size10): ipca IncrementalPCA(n_componentsn_components, batch_sizebatch_size) ipca.partial_fit(faces) # 分塊擬合 return ipca # 用 sklearn 的 IncrementalPCA 配合 predict transformed_train ipca.transform(faces)但注意 IncrementalPCA 在每次 partial_fit 時(shí)內(nèi)部會(huì)維護(hù)一個(gè)協(xié)方差統(tǒng)計(jì)量批次太小時(shí)數(shù)值波動(dòng)大batch_size 建議取 max(10, n_components*2)。5.5 現(xiàn)象識(shí)別時(shí)陌生人總是被錯(cuò)誤分類(lèi)為庫(kù)中的某個(gè)人且距離很小原因訓(xùn)練集的類(lèi)內(nèi)距離比類(lèi)間距離還大數(shù)據(jù)庫(kù)人太少或同一個(gè)人樣本差異太大導(dǎo)致陌生人的投影距離根本得不到一個(gè)合理的分布。解決不只設(shè)一個(gè)全局距離閾值而是每個(gè)身份單獨(dú)統(tǒng)計(jì)距離分布。某個(gè)人的訓(xùn)練樣本自身差異很大比如有戴眼鏡/不戴眼鏡他的閾值就該比其他人高def per_person_thresholds(model, X_train, y_train, scale1.5): thresholds {} for person in set(y_train): idxs np.where(y_train person)[0] dists [] for i in idxs: _, d model.predict(X_train[i]) dists.append(d) # 每個(gè)身份單獨(dú)閾值: 均值 scale * 標(biāo)準(zhǔn)差 thresholds[person] np.mean(dists) scale * np.std(dists) return thresholds識(shí)別時(shí)先找最近鄰再用該身份的專(zhuān)屬閾值拒識(shí)。這樣至少能讓對(duì)光照敏感的特定個(gè)體不再被陌生人“充當(dāng)”。另外增加訓(xùn)練人數(shù)也能整體改善子空間的區(qū)分度理想情況下訓(xùn)練庫(kù)至少在 20 人以上。6. 進(jìn)階從靜態(tài)圖片到實(shí)時(shí)視頻流以及如何驗(yàn)證模型真的可靠6.1 用 OpenCV 跑實(shí)時(shí)攝像頭識(shí)別管線(xiàn)搭建與性能調(diào)優(yōu)從圖片識(shí)別升級(jí)到視頻流識(shí)別代碼只差一個(gè)視頻循環(huán)但有幾個(gè)細(xì)節(jié)直接影響體驗(yàn)。攝像頭畫(huà)面里不會(huì)只有一個(gè)端正的正面臉你需要先做人臉檢測(cè)框截取人臉區(qū)域再送入 Eigenface 模型。我常用 OpenCV 的 DNN 人臉檢測(cè)器比 Haar 級(jí)聯(lián)更穩(wěn)import cv2 import numpy as np # 加載 Eigenface 模型 model EigenfaceRecognizer(n_components30) model.fit(X_train, y_train, name_map) # 加載人臉檢測(cè)器OpenCV DNN 或 Haar face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml) cap cv2.VideoCapture(0) if not cap.isOpened(): raise IOError(無(wú)法打開(kāi)攝像頭) frame_skip 0 while True: ret, frame cap.read() if not ret: break # 每隔兩幀做一次檢測(cè)降低 CPU 占用 gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(60, 60)) for (x, y, w, h) in faces: # 擴(kuò)展檢測(cè)框避免裁到額頭邊緣 margin int(0.1 * w) x0, y0 max(0, x - margin), max(0, y - margin) x1, y1 min(frame.shape[1], x w margin), \ min(frame.shape[0], y h margin) face_roi gray[y0:y1, x0:x1] face_roi cv2.resize(face_roi, IMG_SIZE).flatten().astype(np.float32) # 預(yù)處理與識(shí)別 face_roi normalize_illumination(face_roi.reshape(IMG_SIZE)) face_roi face_roi.flatten() label, distance model.predict(face_roi) name model.name_map.get(label, unknown) # 根據(jù)閾值判斷是否顯示名字 if distance THRESHOLD: display_name f{name} ({distance:.0f}) else: display_name unknown cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, display_name, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow(Eigenface Recognition, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()這個(gè)循環(huán)里我加了 frame_skip 的影影代碼邏輯思路但實(shí)際在代碼里是直接在 detectMultiScale 上控制每幀都檢測(cè)——如果你 CPU 吃緊可以改成只在偶數(shù)幀做檢測(cè)并復(fù)用上一幀的 bbox。margin 擴(kuò)展是血淚經(jīng)驗(yàn)Haar 檢測(cè)框偏緊直接把框內(nèi)區(qū)域拿去 resize 會(huì)造成人臉被壓縮變形坐標(biāo)特征全被打亂。最后waitKey(1) 的 1ms 延遲控制視頻流幀率在識(shí)別循環(huán)里千萬(wàn)別加 sleep 或改為 waitKey(0)否則畫(huà)面會(huì)卡成幻燈片。6.2 驗(yàn)證可靠性ROC 曲線(xiàn)與誤識(shí)率測(cè)試在說(shuō)“我這個(gè)模型靠譜”之前要有一套量化的驗(yàn)證方法。最常用的是在一組已知身份 一組陌生人上做閾值掃描畫(huà)出 ROC 曲線(xiàn)找到等錯(cuò)誤率點(diǎn)EER這個(gè)點(diǎn)上的閾值就是最佳閾值。# 假設(shè) gallery 是已知身份測(cè)試集probe 是陌生人測(cè)試集 def compute_roc(model, known_faces, known_labels, unknown_faces, name_map): import numpy as np # 計(jì)算已知身份的距離分布 known_dists [] for f, l in zip(known_faces, known_labels): # 這里先用最近鄰距離 _, d model.predict(f) known_dists.append(d) # 計(jì)算陌生人的距離分布 unknown_dists [] for f in unknown_faces: _, d model.predict(f) unknown_dists.append(d) threshold_range np.linspace( min(known_dists unknown_dists), max(known_dists unknown_dists), 200) fpr_list, tpr_list [], [] for th in threshold_range: # 已知身份: 距離小于閾值則通過(guò) tpr np.mean([d th for d in known_dists]) # 陌生人: 距離小于閾值則被誤接受 fpr np.mean([d th for d in unknown_dists]) fpr_list.append(fpr) tpr_list.append(tpr) # EER: TPR 與 (1-FPR) 的交點(diǎn) return fpr_list, tpr_list這里關(guān)鍵要理解Eigenface 模型本身沒(méi)有“分類(lèi)器置信度”它只有距離。閾值的設(shè)定本質(zhì)是把距離映射成“接受/拒絕”。我在做門(mén)禁類(lèi)項(xiàng)目時(shí)會(huì)要求 FPR 小于 1% 時(shí)的 TPR 不低于 95%如果達(dá)不到要么增加訓(xùn)練樣本每人張數(shù)要么放棄 Eigenface 改用帶特征點(diǎn)對(duì)齊的 LBPH 或深度學(xué)習(xí)模型。很多人以為 Eigenface 能通吃所有場(chǎng)景實(shí)際上它的適用邊界很清晰同人樣本變化小、照度可控、角度正臉時(shí)它是性?xún)r(jià)比之王一旦場(chǎng)景不可控就別硬扛了。6.3 一些讓項(xiàng)目更好用的工程細(xì)節(jié)到最后給出三個(gè)能立刻落地的技巧。第一保存與加載模型用 NumPy 的 .npz 格式一次性壓縮存取比 pickle 更跨版本穩(wěn)定np.savez(eigenface_model.npz, mean_facemodel.mean_face, eigenfacesmodel.eigenfaces, labelsmodel.labels, projectionsmodel.projections, name_mapnp.array(list(model.name_map.items()), dtypeobject)) # 模型加載 data np.load(eigenface_model.npz, allow_pickleTrue) new_model EigenfaceRecognizer(n_componentsdata[eigenfaces].shape[1]) new_model.mean_face data[mean_face] new_model.eigenfaces data[eigenfaces] new_model.labels data[labels] new_model.projections data[projections]這樣重建的模型不再需要重新訓(xùn)練predict 完全可用部署時(shí)只需要一個(gè) npz 文件和 100 行 Python 代碼。第二做多輪隨機(jī)切分時(shí)固定隨機(jī)種子否則不同輪次的準(zhǔn)確率波動(dòng)會(huì)讓你誤以為模型有問(wèn)題。第三把訓(xùn)練輸出的特征臉可視化保存為圖片能一眼看出 PCA 學(xué)的是什么——如果前幾個(gè)特征臉是明顯的邊緣輪廓而不是光照梯度很可能預(yù)處理環(huán)節(jié)出了問(wèn)題。我一直要求自己在給 Eigenface 項(xiàng)目收尾時(shí)至少回答三個(gè)問(wèn)題數(shù)據(jù)庫(kù)里有沒(méi)有足夠大的身份差異測(cè)試場(chǎng)景的光照變化是否被預(yù)處理吸收了閾值是統(tǒng)計(jì)出來(lái)的還是拍腦袋定的這三個(gè)問(wèn)題想清楚了這個(gè) zip 里的代碼才能真正從“跑通 demo”變成“能交付的小系統(tǒng)”。這個(gè)方向值得投入的時(shí)間上限也就是一兩天——畢竟 Eigenface 作為經(jīng)典算法定位是理解原理、快速驗(yàn)證和低算力兜底而不是跟深度學(xué)習(xí)搶精度。希望這些把代碼一行行講透的筆記能幫到你。本文還有配套的精品資源點(diǎn)擊獲取