简记后台工作站人脸识别登录开发方案

人脸识别登录开发方案


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. 安全考虑

  1. 特征码本地存储:人脸特征码仅存储数值数据,不存储原始图片
  2. 实时比对:每次登录实时拍摄比对,不依赖缓存
  3. 会话安全:人脸验证成功后仍使用标准 Flask session
  4. 防止暴力破解:可添加同 IP 多次失败后的冷却期(可选)
  5. 图片即时销毁:上传的人脸图片不持久化存储

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 KeySecret Key
  • 实现后端调用

好了,就到此吧,不玩了。

  • 全屏阅读F11
  • 打赏支持
  • 快速评论

评论

评论列表

文章目录

    查看评论
    小程序码 微信扫码访问小程序