人脸识别登录开发方案
1. 需求概述
为现有登录页面 (login.html) 添加人脸识别登录功能,作为密码登录的替代方案。
目标
- 用户可选择「密码登录」或「人脸登录」两种方式
- 人脸数据由用户自主上传,存储在本地
- 使用
face_recognition库进行本地人脸比对(隐私友好)
技术栈
- 后端:Python Flask(已有)
- 人脸识别:face_recognition + dlib(需安装)
- 前端:原生 JS + 契合现有 UI 风格
- 存储:SQLite(现有数据库扩展)
2. 当前系统分析
2.1 现有登录页面 (login.html)
- 布局:双栏设计,左侧角色动画 + 右侧表单
- 表单字段:邮箱、密码、记住登录
- 样式:蓝色渐变主题 (#335eea → #1e3a8a)
- 交互:密码可见切换、角色表情动画
2.2 现有数据库结构 (user 表)
-- 需新增字段
ALTER TABLE user ADD COLUMN face_encoding TEXT; -- 人脸特征编码(JSON字符串)
ALTER TABLE user ADD COLUMN face_uploaded_at TEXT; -- 人脸上传时间
2.3 后端登录逻辑 (app.py)
- 路由:
POST /login - 验证:邮箱 + MD5(密码) 比对
- Session:设置
logged_in,user_id,menu_permissions
3. 功能模块设计
3.1 模块划分
| 模块 | 描述 | 文件 |
|---|---|---|
| 人脸上传 | 用户上传人脸照片 | app.py 新增 API |
| 人脸录入 | 后端提取特征码并存储 | app.py + 新建 face_service.py |
| 人脸登录 | 实时人脸比对认证 | login.html + app.py 新增 API |
3.2 数据流程
┌─────────────────────────────────────────────────────────────┐
│ 人脸上传流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 用户在「个人资料」页面点击「上传人脸」 │
│ 2. 选择本地照片文件 (jpg/png, ≤5MB) │
│ 3. 前端预览 + 裁剪建议提示 │
│ 4. 上传到后端 /user/face/upload │
│ 5. 后端验证图片质量(人脸数量=1) │
│ 6. 使用 face_recognition 提取 128维特征码 │
│ 7. 存储到数据库 user.face_encoding │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 人脸登录流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. 用户点击「人脸登录」切换到人脸模式 │
│ 2. 调用摄像头,实时预览 │
│ 3. 用户点击「开始识别」 │
│ 4. 捕获当前帧,上传到后端 /login/face_verify │
│ 5. 后端遍历所有用户的人脸特征码 │
│ 6. 与上传的人脸进行比对 (distance < 0.4 视为匹配) │
│ 7. 匹配成功 → 设置 session → 跳转 admin │
│ 8. 匹配失败 → 提示「未识别到已注册人脸」 │
└─────────────────────────────────────────────────────────────┘
4. 详细实现方案
4.1 后端依赖安装
pip install face_recognition dlib
注意:dlib 需要编译,如安装困难可考虑替代方案: -
pip install face-recognition(预编译轮子) - 或使用opencv-python+face_recognition_models
4.2 数据库扩展 (db.py)
# 新增函数
def update_user_face_encoding(user_id, face_encoding):
"""更新用户人脸特征码"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
'UPDATE user SET face_encoding=?, face_uploaded_at=? WHERE id=?',
(face_encoding, datetime.now().isoformat(), user_id)
)
conn.commit()
def get_user_face_encoding(user_id):
"""获取用户人脸特征码"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute('SELECT face_encoding FROM user WHERE id=?', (user_id,))
row = cursor.fetchone()
return row['face_encoding'] if row else None
def get_all_users_for_face_login():
"""获取所有已录入人脸的用户(用于比对)"""
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
'SELECT id, username, email, face_encoding, del FROM user WHERE face_encoding IS NOT NULL AND face_encoding != ""'
)
users = []
for row in cursor.fetchall():
users.append({
'id': row['id'],
'username': row['username'],
'email': row['email'],
'face_encoding': row['face_encoding'],
'del': row['del']
})
return users
4.3 人脸服务模块 (face_service.py)
import face_recognition
import numpy as np
import json
from io import BytesIO
from PIL import Image
class FaceService:
"""人脸识别服务"""
@staticmethod
def extract_encoding(image_bytes: bytes) -> str:
"""
从图片字节流提取人脸特征码
返回: JSON字符串 或 None(检测失败)
"""
try:
img = Image.open(BytesIO(image_bytes))
img_rgb = np.array(img.convert('RGB'))
encodings = face_recognition.face_encodings(img_rgb)
if len(encodings) == 0:
return None # 未检测到人脸
if len(encodings) > 1:
return None # 多个人脸,不允许
# 转为JSON字符串存储
return json.dumps(encodings[0].tolist())
except Exception as e:
print(f"[FaceService] 提取特征码失败: {e}")
return None
@staticmethod
def verify_face(image_bytes: bytes, known_encodings: list) -> dict:
"""
比对实时人脸
known_encodings: [{'id': int, 'username': str, 'encoding': list}, ...]
返回: {'matched': bool, 'user': dict or None, 'distance': float}
"""
try:
img = Image.open(BytesIO(image_bytes))
img_rgb = np.array(img.convert('RGB'))
# 检测当前帧人脸
unknown_encodings = face_recognition.face_encodings(img_rgb)
if len(unknown_encodings) == 0:
return {'matched': False, 'user': None, 'error': '未检测到人脸'}
if len(unknown_encodings) > 1:
return {'matched': False, 'user': None, 'error': '检测到多个人脸'}
unknown_encoding = unknown_encodings[0]
# 与库中所有人脸比对
best_match = None
best_distance = float('inf')
for user in known_encodings:
stored_encoding = np.array(json.loads(user['encoding']))
distance = face_recognition.face_distance(
[stored_encoding], unknown_encoding
)[0]
if distance < best_distance:
best_distance = distance
best_match = user
# 阈值 0.4(值越小越严格)
if best_distance < 0.4:
return {
'matched': True,
'user': best_match,
'distance': float(best_distance)
}
else:
return {
'matched': False,
'user': None,
'distance': float(best_distance),
'error': f'人脸不匹配 (相似度: {(1-best_distance)*100:.1f}%)'
}
except Exception as e:
return {'matched': False, 'user': None, 'error': str(e)}
@staticmethod
def validate_image(image_bytes: bytes) -> tuple:
"""
验证图片是否合格
返回: (valid: bool, message: str)
"""
try:
img = Image.open(BytesIO(image_bytes))
# 检查格式
if img.format not in ['JPEG', 'PNG', 'JPG']:
return False, '仅支持 JPG/PNG 格式'
# 检查尺寸
if img.width < 100 or img.height < 100:
return False, '图片尺寸太小,请上传更清晰的照片'
# 检查文件大小 (5MB)
if len(image_bytes) > 5 * 1024 * 1024:
return False, '图片大小不能超过 5MB'
# 检查人脸数量
img_rgb = np.array(img.convert('RGB'))
encodings = face_recognition.face_encodings(img_rgb)
if len(encodings) == 0:
return False, '未检测到人脸,请上传清晰的人脸照片'
if len(encodings) > 1:
return False, '检测到多个人脸,请上传单人照片'
return True, '验证通过'
except Exception as e:
return False, f'图片解析失败: {str(e)}'
4.4 后端 API (app.py 新增路由)
from face_service import FaceService
# ========== 人脸上传与录入 ==========
@admin_bp.route('/user/face/upload', methods=['POST'])
def user_face_upload():
"""上传并录入人脸(需已登录)"""
if not session.get('logged_in'):
return jsonify({'success': False, 'message': '请先登录'}), 401
uid = session.get('user_id')
if 'face_image' not in request.files:
return jsonify({'success': False, 'message': '未找到图片文件'})
file = request.files['face_image']
if not file.filename:
return jsonify({'success': False, 'message': '请选择图片'})
# 读取图片数据
image_bytes = file.read()
# 验证图片
valid, msg = FaceService.validate_image(image_bytes)
if not valid:
return jsonify({'success': False, 'message': msg})
# 提取特征码
face_encoding = FaceService.extract_encoding(image_bytes)
if not face_encoding:
return jsonify({'success': False, 'message': '人脸特征提取失败'})
# 存储到数据库
update_user_face_encoding(uid, face_encoding)
return jsonify({
'success': True,
'message': '人脸上传成功'
})
@admin_bp.route('/user/face/status', methods=['GET'])
def user_face_status():
"""获取当前用户人脸录入状态"""
if not session.get('logged_in'):
return jsonify({'registered': False})
uid = session.get('user_id')
encoding = get_user_face_encoding(uid)
return jsonify({
'registered': bool(encoding and encoding.strip())
})
@admin_bp.route('/user/face/delete', methods=['POST'])
def user_face_delete():
"""删除人脸数据"""
if not session.get('logged_in'):
return jsonify({'success': False, 'message': '请先登录'}), 401
uid = session.get('user_id')
update_user_face_encoding(uid, '')
return jsonify({'success': True, 'message': '人脸数据已删除'})
# ========== 人脸登录验证 ==========
@admin_bp.route('/login/face_verify', methods=['POST'])
def login_face_verify():
"""人脸登录验证"""
# 检查是否有文件上传
if 'face_image' not in request.files:
return jsonify({'success': False, 'message': '请先拍摄人脸'})
file = request.files['face_image']
image_bytes = file.read()
# 获取所有已录入人脸的用户
users = get_all_users_for_face_login()
if not users:
return jsonify({'success': False, 'message': '系统中暂无注册人脸'})
# 比对
result = FaceService.verify_face(image_bytes, users)
if result['matched']:
user = result['user']
# 检查用户是否被禁用
user_full = get_user_by_id(user['id'])
if user_full and user_full['del'] == 0:
return jsonify({
'success': False,
'message': '该用户已被禁用'
})
# 登录成功
session['logged_in'] = True
session['user_id'] = user['id']
session['menu_permissions'] = get_user_menu_permissions(user['id'])
return jsonify({
'success': True,
'message': '人脸验证成功',
'redirect': url_for('admin_bp.admin')
})
else:
return jsonify({
'success': False,
'message': result.get('error', '人脸验证失败')
})
# ========== 人脸登录初始化 ==========
@admin_bp.route('/login/face_init', methods=['GET'])
def login_face_init():
"""获取人脸登录所需数据(已注册人脸的用户列表,供前端提示)"""
users = get_all_users_for_face_login()
# 只返回是否有人脸,不返回具体用户信息
return jsonify({
'has_registered_users': len(users) > 0,
'registered_count': len(users)
})
5. 前端实现 (login.html)
5.1 UI 设计原则
- 契合现有登录页面风格
- 保持蓝色渐变主题
- 人脸登录区域与密码表单风格统一
5.2 新增 HTML 结构
<!-- 登录方式切换标签 -->
<div class="login-tabs">
<button class="login-tab active" data-tab="password">密码登录</button>
<button class="login-tab" data-tab="face">人脸登录</button>
</div>
<!-- 人脸登录面板(默认隐藏) -->
<div class="face-login-panel" id="faceLoginPanel" style="display: none;">
<div class="face-video-container">
<video id="faceVideo" autoplay playsinline></video>
<canvas id="faceCanvas" style="display:none;"></canvas>
<div class="face-overlay" id="faceOverlay">
<div class="face-guide-circle"></div>
</div>
</div>
<div class="face-status" id="faceStatus">点击「开始识别」进行人脸登录</div>
<button class="submit-btn face-btn" id="faceLoginBtn">
<span class="btn-text">开始识别</span>
</button>
<p class="face-hint">请确保面部正对摄像头,光线充足</p>
</div>
5.3 新增 CSS 样式(契合现有风格)
/* 登录切换标签 */
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 30px;
background: #f0f2f5;
border-radius: 10px;
padding: 4px;
}
.login-tab {
flex: 1;
padding: 10px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.25s ease;
}
.login-tab.active {
background: linear-gradient(135deg, #335eea, #1e3a8a);
color: white;
box-shadow: 0 2px 8px rgba(51, 94, 234, 0.3);
}
/* 人脸登录区域 */
.face-login-panel {
text-align: center;
}
.face-video-container {
position: relative;
width: 220px;
height: 220px;
margin: 0 auto 20px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, #1e3a8a, #335eea);
box-shadow: 0 8px 30px rgba(51, 94, 234, 0.25);
}
.face-video-container video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 镜像 */
}
.face-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.face-guide-circle {
width: 140px;
height: 140px;
border: 3px dashed rgba(255,255,255,0.6);
border-radius: 50%;
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
.face-status {
font-size: 13px;
color: #666;
margin-bottom: 15px;
min-height: 20px;
}
.face-btn {
background: linear-gradient(135deg, #10b981, #059669) !important;
}
.face-btn:hover {
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4) !important;
}
.face-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.face-hint {
font-size: 11px;
color: #999;
margin-top: 12px;
}
5.4 新增 JavaScript 逻辑
// ========== 人脸登录相关 ==========
const faceVideo = document.getElementById('faceVideo');
const faceCanvas = document.getElementById('faceCanvas');
const faceLoginBtn = document.getElementById('faceLoginBtn');
const faceStatus = document.getElementById('faceStatus');
let faceStream = null;
let isCapturing = false;
// 切换登录方式
document.querySelectorAll('.login-tab').forEach(tab => {
tab.addEventListener('click', function() {
const tabType = this.dataset.tab;
document.querySelectorAll('.login-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
if (tabType === 'face') {
document.getElementById('passwordLoginPanel').style.display = 'none';
document.getElementById('faceLoginPanel').style.display = 'block';
startCamera();
} else {
document.getElementById('passwordLoginPanel').style.display = 'block';
document.getElementById('faceLoginPanel').style.display = 'none';
stopCamera();
}
});
});
// 启动摄像头
async function startCamera() {
try {
faceStream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 320, facingMode: 'user' }
});
faceVideo.srcObject = faceStream;
faceStatus.textContent = '摄像头已启动,请将面部对准圆圈';
faceLoginBtn.disabled = false;
} catch (err) {
faceStatus.textContent = '摄像头访问被拒绝,请检查浏览器权限';
faceLoginBtn.disabled = true;
console.error('Camera error:', err);
}
}
// 停止摄像头
function stopCamera() {
if (faceStream) {
faceStream.getTracks().forEach(track => track.stop());
faceStream = null;
}
}
// 捕获并识别
faceLoginBtn.addEventListener('click', async function() {
if (isCapturing) return;
isCapturing = true;
faceLoginBtn.disabled = true;
faceLoginBtn.querySelector('.btn-text').textContent = '识别中...';
faceStatus.textContent = '正在识别人脸...';
// 捕获当前帧
const ctx = faceCanvas.getContext('2d');
faceCanvas.width = faceVideo.videoWidth;
faceCanvas.height = faceVideo.videoHeight;
ctx.drawImage(faceVideo, 0, 0);
// 转为 Blob
faceCanvas.toBlob(async (blob) => {
const formData = new FormData();
formData.append('face_image', blob, 'face.jpg');
try {
const response = await fetch('/login/face_verify', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
faceStatus.textContent = '验证成功!正在跳转...';
faceStatus.style.color = '#10b981';
stopCamera();
setTimeout(() => {
window.location.href = data.redirect || '/admin';
}, 500);
} else {
faceStatus.textContent = data.message;
faceStatus.style.color = '#dc2626';
resetFaceBtn();
}
} catch (err) {
faceStatus.textContent = '网络异常,请重试';
faceStatus.style.color = '#dc2626';
resetFaceBtn();
}
}, 'image/jpeg', 0.85);
});
function resetFaceBtn() {
isCapturing = false;
faceLoginBtn.disabled = false;
faceLoginBtn.querySelector('.btn-text').textContent = '开始识别';
}
// 页面卸载时释放摄像头
window.addEventListener('beforeunload', stopCamera);
6. 个人资料页面扩展
6.1 人脸上传 UI (profile_edit.html)
在个人资料页面添加人脸管理区块:
<!-- 人脸管理卡片 -->
<div class="profile-card face-card">
<div class="card-header">
<h3>🪪 人脸识别</h3>
</div>
<div class="card-body">
<div class="face-status-display" id="faceStatusDisplay">
<span class="status-badge pending">未录入</span>
</div>
<div class="face-upload-area" id="faceUploadArea">
<div class="upload-preview" id="facePreview" style="display:none;">
<img id="facePreviewImg" src="" alt="人脸预览">
</div>
<div class="upload-placeholder" id="facePlaceholder">
<i class="layui-icon layui-icon-face-surprised"></i>
<p>点击上传人脸照片</p>
<span>支持 JPG/PNG,不超过 5MB</span>
</div>
<input type="file" id="faceFileInput" accept="image/jpeg,image/png" style="display:none;">
</div>
<div class="face-actions">
<button class="layui-btn layui-btn-normal" id="saveFaceBtn">保存人脸</button>
<button class="layui-btn layui-btn-danger" id="deleteFaceBtn" style="display:none;">删除人脸</button>
</div>
<div class="face-tips">
<p>📌 上传人脸后,可使用人脸识别快速登录</p>
<p>📌 请上传清晰、正面、无遮挡的人脸照片</p>
</div>
</div>
</div>
7. 错误处理与边界情况
| 场景 | 处理方式 |
|---|---|
| 摄像头不可用 | 显示友好提示,提示用户检查浏览器权限 |
| 检测不到人脸 | 提示调整光线或距离 |
| 检测到多个人脸 | 提示确保单人入境 |
| 人脸不匹配 | 提示「未识别到已注册人脸」,可切换密码登录 |
| 用户未录入人脸 | 前端检测,若无人脸则隐藏/禁用人脸登录入口 |
| 数据库存储失败 | 回滚操作,返回错误信息 |
8. 安全考虑
- 特征码本地存储:人脸特征码仅存储数值数据,不存储原始图片
- 实时比对:每次登录实时拍摄比对,不依赖缓存
- 会话安全:人脸验证成功后仍使用标准 Flask session
- 防止暴力破解:可添加同 IP 多次失败后的冷却期(可选)
- 图片即时销毁:上传的人脸图片不持久化存储
9. 测试计划
| 测试项 | 预期结果 |
|---|---|
| 首次上传人脸 | 成功提取特征码并存储 |
| 人脸登录 - 正确人脸 | 验证成功,跳转 admin |
| 人脸登录 - 未注册人脸 | 提示「未识别到已注册人脸」 |
| 人脸登录 - 遮挡/侧脸 | 提示「请调整面部角度」 |
| 删除人脸后尝试登录 | 提示「系统中暂无注册人脸」 |
| 摄像头权限拒绝 | 显示权限提示 |
10. 文件清单
| 操作 | 文件 | 说明 |
|---|---|---|
| 新建 | face_service.py |
人脸识别服务核心 |
| 修改 | db.py |
新增人脸相关数据库函数 |
| 修改 | app.py |
新增人脸相关 API 路由 |
| 修改 | login.html |
新增人脸登录 UI |
| 修改 | profile_edit.html |
新增人脸上传管理 |
| 新建 | requirements.txt |
添加依赖 |
11. 依赖清单
# 新增依赖
face_recognition>=1.3.0
dlib>=19.24.0
numpy>=1.24.0
Pillow>=10.0.0
12. 最后
一阵猛烈折腾后,发现自建后端有很多环境问题和技术壁垒,经过浅思一拍脑袋找个现成且可靠的API吧,于是:
| 服务商 | 特点 | 免费额度 |
|---|---|---|
| 百度人脸识别 | 精度高、有免费额度 | 500次/天 |
| 腾讯云人脸识别 | 准确率高 | 1000次/月 |
| 阿里云人脸识别 | 稳定 | 按量付费 |
| 旷视 Face++ | 业界领先 | 有限免费 |
推荐百度:免费额度够用,接入简单。
你需要:
- 注册百度智能云账号
- 创建「人脸识别」应用,获取 API Key 和 Secret Key
- 实现后端调用
好了,就到此吧,不玩了。
评论
评论列表