Bootstrap

关于人脸识别的一个应用案例

背景

1202年了,随着云服务的快速发展,现在开发人工智能类应用的技术门槛也降低了很多。

分享一个人工智能队伍里比较火热的智能场景,人脸识别的案例,依赖的是百度云的API(传送门:)。为什么用百度呢,这里我想多说两句,现在不论是国内(比如百度云,腾讯云,阿里云,旷视,虹软等),还是国外(如Azure,Amazon,Google等)的云服务厂商,都提供了基于自家算法的人脸识别接口和SDK。在这些厂商里,我认为百度是对小微企业以及个人开发者最友好的,在线接口只要完成实名认证就可以无限量调用,完成企业认证后QPS还会增加到10,基本可以满足绝大部分的应用场景。而其余厂商,比如腾讯云和旷视,是每日给10000的免费调用量,看起来也很厚道,可在人脸识别的场景里,人脸检测接口是调用最频繁的,调用频率几乎要按秒计算,所以这么看,10000的调用量就显得杯水车薪了。另外,这些厂商也都有提供离线SDK,其中虹软的离线包包含了两种,其中个人版只要完成实名认证,就可以免费试用,但需要每年激活一次,(传送门:),商业版的和其他厂商的厂商一样都是付费的。值得一提的是,百度云和旷视(很多手机的人脸解锁技术都是基于旷视的face++平台)在软件基础上,还推出了基于自家技术的硬件产品,等于也是在消费市场为开发者提供了差异化的选择。

我个人还是推荐尽可能采用线上接口的形式,因为使用离线SDK的话,对运行设备有较高的要求,比如刚提到的虹软的SDK,最低要求都是8代I5的CPU才行(虹软sdk文档:)。而且,随着应用的运行频率提高,离线SDK的识别准确率也会下降(个人版是这样),导致我们不得不调低识别阈值,而如果阈值太低,会把不相似的人脸也识别通过。总之就是采用离线方案的话,虽然开发成本很低,但使用成本很高,因为不得不依赖更高配置的终端设备,而作为小微企业来讲,这些成本是应该极力节省的。综上,在选择人脸识别技术路线的时候,还是要结合各自的实际情况来选择。

案例流程图

先看下开发思路。

注册人脸

注册人脸的过程中,需要将人脸数据和个人基本数据进行绑定,也就是用户在注册或者完善个人信息的时候需要先上传自己的个人照片,并且经过人脸检测接口鉴定该照片可以作为人脸检测基础数据之后,才可以进行后续的人脸识别。

可以看下上面流程图的【人脸注册】环节。

代码其实非常简单,只是按照百度的接口,封装一下模型和方法。

/// 
/// 人脸检测请求模型
/// 
public class FaceDetect
{
    /// 
    /// 图片信息(总数据大小应小于10M),图片上传方式根据image_type来判断
    /// 
    public string image { get; set; }
    /// 
    /// 图片类型
    /// BASE64:图片的base64值,base64编码后的图片数据,编码后的图片大小不超过2M.需要注意的是,图片的base64编码是不包含图片头的,如data:image/jpg;base64;
    /// URL:图片的 URL地址(可能由于网络等原因导致下载图片时间过长);
    /// FACE_TOKEN: 人脸图片的唯一标识,调用人脸检测接口时,会为每个人脸图片赋予一个唯一的FACE_TOKEN,同一张图片多次检测得到的FACE_TOKEN是同一个。
    /// 
    public string image_type { get; set; }
    /// 
    /// 包括age,beauty,expression,face_shape,gender,glasses,landmark,landmark150,
    /// quality,eye_status,emotion,face_type,mask,spoofing信息
    /// 逗号分隔.默认只返回face_token、人脸框、概率和旋转角度
    /// 这里额外增加性别,是否戴眼镜,图片质量三个参数
    /// 
    public string face_field { get; set; } = "face_token,gender,glasses,quality";
    /// 
    /// 最多处理人脸的数目,默认值为1,根据人脸检测排序类型检测图片中排序第一的人脸(默认为人脸面积最大的人脸),最大值120
    /// 
    public uint max_face_num { get; set; } = 1;
    /// 
    /// 人脸的类型
    /// LIVE表示生活照:通常为手机、相机拍摄的人像图片、或从网络获取的人像图片等
    /// IDCARD表示身份证芯片照:二代身份证内置芯片中的人像照片
    /// WATERMARK表示带水印证件照:一般为带水印的小图,如公安网小图
    /// CERT表示证件照片:如拍摄的身份证、工卡、护照、学生证等证件图片
    /// 默认LIVE
    /// 
    public string face_type { get; set; } = "LIVE";
    /// 
    /// 活体控制 检测结果中不符合要求的人脸会被过滤
    /// NONE: 不进行控制
    /// LOW:较低的活体要求(高通过率 低攻击拒绝率)
    /// NORMAL: 一般的活体要求(平衡的攻击拒绝率, 通过率)
    /// HIGH: 较高的活体要求(高攻击拒绝率 低通过率)
    /// 默认NONE
    /// 
    public string liveness_control { get; set; } = "NORMAL";
    /// 
    /// 人脸检测排序类型
    /// 0:代表检测出的人脸按照人脸面积从大到小排列
    /// 1:代表检测出的人脸按照距离图片中心从近到远排列
    /// 默认为0
    /// 
    public int face_sort_type { get; set; } = 0;
}
/// 
/// 人脸检测
/// https://ai.baidu.com/ai-doc/FACE/yk37c1u4t#%E8%BF%94%E5%9B%9E%E8%AF%B4%E6%98%8E
/// 
/// 
/// 
public async Task faceDetect(CloudModels.FaceDetect model)
{
    if (model.image_type=="BASE64" && !string.IsNullOrEmpty(model.image) && model.image.Contains(','))
    {
        model.image = model.image.Split(',')[1];
    }
    return await httpPost(JsonHelper.JsonSerialize(model), "https://aip.baidubce.com/rest/2.0/face/v3/detect?access_token=");

}
/// 
/// 执行请求
/// 
/// 
/// 
/// 
public async Task httpPost(string str,string host)
{
    var Json = JObject.Parse(await getAccessToken());
    string token = Json["access_token"].ToString();
    host += token;
    Encoding encoding = Encoding.Default;
    HttpWebRequest request = (HttpWebRequest)WebRequest.Create(host);
    request.Method = "post";
    request.KeepAlive = true;

    byte[] buffer = encoding.GetBytes(str);
    request.ContentLength = buffer.Length;
    await request.GetRequestStream().WriteAsync(buffer, 0, buffer.Length);
    HttpWebResponse response = (HttpWebResponse)request.GetResponse();
    StreamReader reader = new StreamReader(response.GetResponseStream(), Encoding.Default);
    string result = await reader.ReadToEndAsync();
    return result;
}

简单介绍下,总体的代码结构就是,先根据文档,构造接口的请求模型,然后构造生成请求签名的方法,注意对签名进行缓存,我这里是存到了redis里,因为每次token都是7天有效的,之后就是去请求正式接口获取结果了。然后封装接口,以及控制器里注入使用的步骤就不介绍了。

完成后台代码后,在设计下前台页面,只需要有一个再上传图片的时候,加一个步骤即可。

我这里就不放前台上传和具体页面代码了,只放一下人脸检测的(前端代码用的还是jquery,比较粗糙)。

//第一步, 检测图片是否包含人脸
function detectFace(config) {
    let faceModel = {
        "image": config.image,
        "image_type": config.type,
        "liveness_control": config.liveness,
    }
    let params = {
        "__RequestVerificationToken": config.token,
        "model": faceModel
    };
    
    Toast.info("正在检测图片是否包含人脸...");
    $.post(config.detectUrl, params, function (data) {
        var json = JSON.parse(data.data);
        if (json.error_code == 0) {
            let result = json.result;
            if (result.face_num == 1) {
                console.log(result);
                Toast.info("检测到人脸信息");
                faceToken = result.face_list[0].face_token;                
                config.image = faceToken;
                config.type = "FACE_TOKEN";

                queryFace(config);
            } else {
                Toast.info("检测到多张人脸信息,请重新上传只包含单张人脸的图片");
            }
        } else {
            console.log(json.error_msg);
            Toast.error("检测失败," + json.error_msg);
        }
    })
}
//第二步, 检测是否已经注册过人脸
function queryFace(config) {
    let faceModel = {
        "image": config.image,
        "image_type": config.type,
        "group_id_list": config.groupId,
        "user_id": config.userId
    };
    let params = {
        "__RequestVerificationToken": config.token,
        "model": faceModel
    };
    Toast.info("正在检测人脸是否已被注册");
    $.post(config.queryUrl, params, function (data) {
        //console.log(json);
        var json = JSON.parse(data.data);
        if (json.error_code == 0 && json.result.user_list) {
            console.log(json.result);
            for (var i = 0; i < json.result.user_list.length; i++) {
                let item = json.result.user_list[i];
                if (item.score < 80) {
                    Toast.info("人脸未被注册,即将注册人脸信息");
                    addFace(config);
                    return;
                }
            }
            if (config.actionType && config.actionType == "update") {
                //Toast.error("人脸已被注册");
                updateFace(config);
            } else {
                Toast.error("人脸已被注册");
                $(".save").hide();
                $(".saveDisabled").show();
            }
        } else if (json.error_code == 222207) {
            Toast.info("人脸未被注册,即将注册人脸信息");
            addFace(config);
        }
    });
}
//第三步,注册人脸
function addFace(config) {
    let faceModel = {
        "image": config.image,
        "image_type": config.type,
        "group_id": config.groupId,
        "user_id": config.userId,
        "user_info": config.userInfo
    };
    let params = {
        "__RequestVerificationToken": config.token,
        "model": faceModel
    };
    $.post(config.addUrl, params, function (json) {
        if (json.code == 1) {
            Toast.info("人脸注册成功");
        } else {
            Toast.error(json.msg);
        }
    });
}
//第三部,或者更新人脸
function updateFace(config) {
    let faceModel = {
        "image": config.image,
        "image_type": config.type,
        "group_id": config.groupId,
        "user_id": config.userId,
        "user_info": config.userInfo,
        //"action_type": "REPLACE"
    };
    let params = {
        "__RequestVerificationToken": config.token,
        "model": faceModel
    };
    $.post(config.updateUrl, params, function (json) {
        if (json.code == 1) {
            Toast.info("人脸信息更新成功");
        } else {
            Toast.error(json.msg);
        }
    });
}

基本流程就是,先检测人脸是否可用,可用的话在检测人脸库里是否存在该人脸或极其相似的人脸,如果不存在则新增人脸,如果存在,则根据我们设定的规则来决定是否更新人脸库。

信息注册完成后,需要生成一个可识别的唯一标识码,我这里是生成的二维码,如果是走人证合一的流程,这一步是必不可少的。

展示结果如下

我在流程图里画的第二个框是认领卡片,其实如果我们是基于电子化流程的,这一步以及前面的归类这些步骤其实就没有了,用户只需在移动终端登陆个人账户即可,而如果是线下的活动,可能需要制作入场卡片,则需要将标识码打印到卡片上面。

至此注册的环节就结束了

人脸搜索

人脸搜索是实现签到的一种方式,通过设定阈值,来判定当前采集的人脸是否在人脸库中存在,如果存在,则认为是签到成功。这种方法其实比较高效,直接刷脸,没有证件也没关系。但从整体流程上来说,并不如人脸匹配的方式来的严谨。

看下代码,后台的部分我就不展示了,跟检测的流程差不多,定义模型,请求接口,验证结果。

只看下前台的逻辑。

//检测人脸
function faceDetect(base64) {
    let faceModel = {
        "image": base64,
        "image_type": "BASE64",
    }
    let params = {
        "__RequestVerificationToken": token,
        "model": faceModel
    };
    $.post("@Url.Action("faceDetect")", params, function (data) {
        //console.log(json);
        var json = JSON.parse(data.data);
        if (json.error_code == 0) {
            let faceInfo = json.result.face_list[0];
            drawFaceInfo(faceInfo);
            queryFace(faceInfo.face_token, "FACE_TOKEN");
        } else if (json.error_code = 222202) {
            Toast.error("未检测到活体人脸信息,请确保站在摄像头中央左右位置,要使用手机照片等进行拍摄");
        } else {
            Toast.warning(json.error_msg);
        }
    })
}

//写入图片检测信息
function drawFaceInfo(faceInfo) {
    let canvas = document.getElementById("canvas");
    let context = canvas.getContext("2d");
    context.strokeStyle = "#1E9FFF";
    context.lineWidth = 4;
    let location = faceInfo.location;
    context.strokeRect(location.left, location.top, location.width, location.height);
    context.font = 'bolder 14px Microsoft YaHei';
    context.textAlign = 'left';
    context.textBaseline = 'bottom';
    let left = 5;
    let top = 18;
    let i = 1;
    context.fillStyle = '#5FB878';
    context.fillText(faceInfo.face_token, left, top);
    if (faceInfo.gender.type == "male")
        context.fillText('男', left, top + 16 * i);
    else
        context.fillText('女', left, top + 16 * i);
    i++;
    if (faceInfo.glasses.type == "none")
        context.fillText('无眼镜', left, top + 16 * i);
    else if (faceInfo.glasses.type == "common")
        context.fillText('普通眼镜', left, top + 16 * i);
    else
        context.fillText('墨镜', left, top + 16 * i);
    i++;
    if (faceInfo.quality.completeness > 0.9) {
        context.fillText("面部完整", left, top + 16 * i);
    }
    else {
        context.fillStyle = '#FF5722';
        context.fillText("面部不完整,请重拍", left, top + 16 * i);
    }
    i++;
    if (faceInfo.quality.blur < 0.7) {
        context.fillStyle = '#5FB878';
        context.fillText("清晰", left, top + 16 * i);
    } else {
        context.fillStyle = '#FF5722';
        context.fillText("模糊,请重拍", left, top + 16 * i);
    }
    i++;
    if (faceInfo.quality.illumination > 40) {
        context.fillStyle = '#5FB878';
        context.fillText("光线好", left, top + 16 * i);
    } else {
        context.fillStyle = '#FF5722';
        context.fillText("光线差,请重拍", left, top + 16 * i);
    }
}
//检索人脸信息
function queryFace(image,imageType) {
    let model = {
        "image": image,
        "image_type": imageType,
        "group_id_list": "user_ai_2021"
    };
    let params = {
        "__RequestVerificationToken": token,
        "model": model
    }
    $.post("@Url.Action("getUserInfoByFace")", params, function (json) {
        $(".score").hide();
        if (json.code == 1) {
            let score = json.data.score;
            jValSet("score", score);
            let userModel = json.data.user;
            jValSet("userName", userModel.userName);
            jValSet("name", userModel.name);
            jValSet("idCard", userModel.idCard);
            jValSet("mobile", userModel.mobile);
            jValSet("unitName", userModel.unitName);
            $("#faceUrl").attr("src", userModel.faceUrl);
            if (score > 80) {
                $("#score80").show();
            } else if (score > 60) {
                $("#score60").show();
            }
        } else {
            //json = JSON.parse(json.data);
            Toast.error(json.msg);
        }

    })
}

这里也没什么可说的,大体思路也是先执行Detect接口检测人脸数据是否可用,然后在进行人脸搜索和一些数据的赋值操作。

效果如下

这里还有一些调取摄像头的操作,我就不展开了,现成的代码方案有很多。

人脸匹配(人证合一)

人证合一的方案,是另外一种验证形式,思路是,先通过证件匹配个人数据,然后在通过采集人脸来和原有人脸数据做匹配,如果匹配分数高于一个阈值,则完成了“你是你”的认证过程,反之则验证失败。这种方案在流程上会多一步操作,但是从整个的验证效率来讲是提高的,因为人脸搜索的话是1:N或者M:N的方式,在人脸库里查找一张或多张目标人脸,然后逐一匹配相似值,而人脸匹配则是1:1进行的人脸比对,只匹配当前采集的人脸和已有人脸是否是同一个人即可。

因此,在一些比较严谨的场合,还是应该尽可能采取匹配的方式进行。

我这里在获取人员信息的时候,是通过在管理人员和匹配窗口之间建立一个长链接,长链接的形式开始是自己利用singalr建立自己的hub中心来完成,实际中发现当连接数过多之后,有时候效率会低,所以改用了声网的方案。流程就是当管理人员扫码证件码的时候,会直接把用户数据推送到匹配窗口,推送过来之后,在进行人脸采集和结果匹配。

async function loginRTM(uid) {
    // 显示连接状态变化
    client.on('ConnectionStateChanged', function (state, reason) {
        console.log("State changed To: " + state + " Reason: " + reason)
    })
    // 设置 RTM 用户 ID
    options.uid = uid
    // 获取 Token
    options.token = await fetchToken(options.uid)
    // 登录 RTM 系统
    await client.login(options)
    while (!stopped) {
        // 每 30 秒更新一次 Token。此更新频率是为了功能展示。生产环境建议每小时更新一次。
        await sleep(60000 * 60);
        options.token = await fetchToken(options.uid)
        client.renewToken(options.token)

        let currentDate = new Date();
        let time = currentDate.getHours() + ":" + currentDate.getMinutes() + ":" + currentDate.getSeconds();
        console.log("Renew RTM token at " + time)
    }
}
//登录
loginRTM("face");
//监听消息
client.on('MessageFromPeer', function (message, peerId) {
    Toast.notify(message.text)
    //获取用户信息
    getUserInfo(message.text)
})

//人脸比对
function matchFace(imageOrgin, typeOrgin, imageCam, typeCam) {
    let models = [];
    let matchOrgin = {
        "image": imageOrgin,
        "image_type": typeOrgin,
        "liveness_control": "NONE",
    }
    let matchCam = {
        "image": imageCam,
        "image_type": typeCam,
        "liveness_control": "NORMAL",
    }
    models.push(matchOrgin);
    models.push(matchCam);
    let params = {
        "__RequestVerificationToken": token,
        "models": models
    };
    $.post("@Url.Action("faceMatch")", params, function (data) {            
        var json = JSON.parse(data.data);
        console.log(json);
        if (json.error_code == 0) {
            let score = json.result.score;
            jValSet("score", "相似度为" + score + "%")
            $(".score").hide();
            if (score > 80) {
                $("#score80").show();
            } else if (score > 60) {                    
                $("#score60").show();
            } else {
                $("#score59").show();
            }
        } else {
            Toast.error("比对失败," + json.error_msg);
        }
    })
}

效果如下

好了,至此,一套包含人脸检测,比对,注册,搜索,更新的人脸识别方案的基本流程就完成了。