🐍 附录C 人脸识别+RFID 门禁系统实验
📄 一句话概括

双因子认证要的是两个不同类别的证据:你知道的、你有的、你是什么。两个密码不算双因子。 门禁的判断就是一个「与」:卡过、脸也过,才开门。四行表格把全部情况穷举完了。 性能上限不在代码,在传感器。 摄像头糊、光不够、卡贴歪,算法再好也没用。

📝 本章和前面几章的关系

这是全书的最后一篇。第 14 章讲了人脸侦测与识别,这一篇把它和 RFID 拼成一个真东西。 教材附录 C 只有一节:【RFID 门禁系统】。 这一篇全真跑:卡号校验、人脸距离计算、与逻辑判定、日志留痕,都是本机实测。 人脸那一半用的是 14.3 篇那张真图。 只有一处是复刻:没有真的 RFID 读卡器,卡号是写在字典里的。 刷卡这一步在真机上是一次串口读,本课直接给值。

一、为什么要两个因子

方案 能挡住什么 挡不住什么
只刷卡 没卡的人 捡到卡、借到卡的人
只认脸 陌生人 照片、双胞胎、光线差时认错
卡 + 脸 捡到卡也没用,长得像也没用 两个都被拿到(概率极低)

这就是双因子认证:两个不同类别的证据。 「密码 + 短信验证码」也是双因子, 因为一个是「你知道的」,一个是「你有的」。 反过来:两个都是密码就不算双因子。 那只是「两个密码」, 泄露方式往往是同一个——比如都记在同一张便利贴上。 类别大致三类:你知道的(密码、口令)、你有的(卡、手机、密钥)、你是什么(指纹、人脸、虹膜)。 同一类里选两个,等于没加。

为什么「卡 + 脸」比「卡 + 密码」强? 因为卡和脸的窃取方式完全不重叠:卡能被复制,脸不能(至少不能靠一张照片)。

二、刷卡这一半:只认 UID

演示第二段三张卡:04A2B3C4 是王工,04D5E6F7 是小李,04FF0011 是作废卡。 刷 04A2B3C4 进入人脸比对,刷 04DEADBE 没登记被拒,刷 04FF0011 已作废被拒。 三种结果,三条分支,一条都不能漏。 很多老门禁只读卡号,而卡号是能克隆的。 网上几十块的读写器就能复制一张。

所以光刷卡的门禁,安全性不取决于技术,取决于「卡有没有被借出去」。这就是为什么要加第二个因子。 作废卡这条分支容易被漏。员工离职了,卡还留在系统里,那就等于门一直开着。 「删除」和「作废」要分开:删除会让人查不到历史,作废还能追溯。

三、人脸这一半:比对特征

演示第三段:同一个人加一点噪声,距离 9.1;换成一坨随机像素,距离 74.5。 同一个人距离小,不同的人距离大,中间有条线。这条线就是阈值——线上开门,线下拒绝。 口径要说清:这里的「另一个人」是一坨随机像素,不是真人脸。 真实的两个人脸距离会近得多,所以真机上的阈值要靠真实数据调,不能照抄 30。

这跟 14.3 篇那个 0.6 是同一件事:face_recognition 的 128 维特征,阈值 0.6。 自己算像素距离,阈值就得另外定。 阈值定在哪,本质是安全性和便利性的取舍。 定低了,本人被拒之门外;定高了,长得像的人能混进去。 这不是技术问题,是产品决策。

四、两个都过才开门

场景 卡 脸 结果
卡对、脸对 过 过 开门
卡对、脸不对 过 不过 拒绝
卡不对、脸对 不过 过 拒绝
卡不对、脸不对 不过 不过 拒绝
作废卡、脸对 不过 过 拒绝

只有第一行开门。 卡和脸都得过,缺一样都不行——这就是「与」的逻辑,也是双因子的全部意思。 写成代码就是 if card_ok and face_ok。注意是 and 不是 or。 这个字写错,门禁就等于没有。 第五行值得单独说:卡作废了,就算脸对也不能开。 逻辑上它已经被 card_ok 挡掉了, 但如果代码里把「作废」判成 False 而不是「不在允许集」,就会漏。

这类边界最容易被忽略,因为它只在「离职员工回来试」的时候才暴露。而那时候已经晚了。

五、每次尝试都要留痕

演示第五段四条日志加一条省略:时刻、卡号、距离、结果。记录条数 5。 门禁日志必须记「谁、什么时候、过了没、为什么没过」。 没过的记录尤其重要——连续失败五次,那就是有人在试。 这条规则比任何算法都管用,因为它是唯一能发现「正在被攻击」的信号。

演示末尾把第一条日志打成了 JSON。 这不是装饰:存成 JSON 就能直接被第 7 章那套流程收走,一天一个文件, 或者直接写进 SQLite(12.1 篇)。 还有一个现实问题:日志会越写越多。 要么按天分文件,要么定期归档。 一个几百 MB 的日志文件,出事的时候你打不开。 最后一句得说清楚:人脸是敏感个人信息。 做门禁、考勤这类应用,采集前要告知、要取得同意, 存下来的特征也要当敏感数据保护。

六、上限不在代码,在传感器

这套东西的性能上限不在代码,在传感器。 摄像头糊、光不够、卡贴歪,算法再好也没用。 所以做这类项目的顺序永远是:先解决「拍得清」,再谈「认得出」。 具体一点:补光、调角度、固定相机高度、让人站的位置有个地贴。 这些「不技术」的事,效果比换个更贵的算法明显得多。

这也是 14.2 篇那句话的落地版:工业上自动数零件的难点不在算法,在打光。 同一个道理,换个场景还是它。 全书到这里就完了。 回头看这六篇:13 章让机器跟人说话,14 章让机器看东西,附录 A 讲语法加速器, B 和 C 把软件接到真实世界。 从 print('hello') 到一个能开门的系统,中间没有魔法,只有一层层的取舍。

七、动手 Demo:门禁:两个因子都过才开门

先看三种方案的强弱,再刷卡:登记卡、没登记的卡、作废卡。 然后算人脸距离,把「卡」和「脸」放进同一张表里判,最后留一份日志。

import json
import os

import cv2
import numpy as np
from PIL import Image

IMG_DIR = 'img'          # 放 astronaut.png / coffee.png / chelsea.png


def load(name):
    return np.array(Image.open(os.path.join(IMG_DIR, name)).convert('RGB'))


print('  ① 为什么要两个因子')
print('  ' + '-' * 68)
print()
print('| 方案 | 能挡住什么 | 挡不住什么 |')
print('|---|---|---|')
for r in (('只刷卡', '没卡的人', '捡到卡、借到卡的人'),
          ('只认脸', '陌生人', '照片、双胞胎、光线差时认错'),
          ('卡 + 脸', '捡到卡也没用,长得像也没用', '两个都被拿到(概率极低)')):
    print('| %s | %s | %s |' % r)
print()
print('  这就是「双因子认证」:两个不同类别的证据。')
print('  「密码 + 短信验证码」也是双因子,因为一个是「你知道的」,一个是「你有的」。')
print('  两个都是密码就不算双因子——那只是「两个密码」,泄露方式往往是同一个。')
print()
print('  ② 刷卡这一半:只认 UID')
print('  ' + '-' * 68)
cards = {'04A2B3C4': '王工', '04D5E6F7': '小李', '04FF0011': '作废卡'}
print('    系统里登记的卡:')
for uid, who in cards.items():
    print('      %s → %s' % (uid, who))
print()
for uid in ('04A2B3C4', '04DEADBE', '04FF0011'):
    who = cards.get(uid)
    if who is None:
        print('    刷 %s → 没登记,拒绝' % uid)
    elif who == '作废卡':
        print('    刷 %s → 已作废,拒绝' % uid)
    else:
        print('    刷 %s → %s,进入人脸比对' % (uid, who))
print('  很多老门禁只读卡号,而卡号是能克隆的——网上几十块的读卡器就能复制一张。')
print('  所以光刷卡的门禁,安全性取决于「卡有没有被借出去」。')
print()
print('  ③ 人脸这一半:比对特征')
print('  ' + '-' * 68)
arr = load('astronaut.png')
gray = cv2.cvtColor(cv2.cvtColor(arr, cv2.COLOR_RGB2BGR), cv2.COLOR_BGR2GRAY)
cas = cv2.CascadeClassifier(os.path.join(cv2.data.haarcascades,
                                         'haarcascade_frontalface_default.xml'))
x, y, w, h = cas.detectMultiScale(gray, 1.1, 5)[0]
face = cv2.resize(gray[y:y + h, x:x + w], (100, 100)).astype(float)
rng = np.random.RandomState(20261102)
same = np.clip(face + rng.normal(0, 9, face.shape), 0, 255)
other = np.clip(rng.normal(120, 40, face.shape), 0, 255)


def dist(a, b):
    return float(np.sqrt(((a - b) ** 2).mean()))


print('    同一个人(加了一点噪声):距离 %.1f' % dist(face, same))
print('    另一个人(一坨随机像素):距离 %.1f' % dist(face, other))
print('  同一个人距离小,不同的人距离大,中间有条线。这条线就是阈值——线上开门,线下拒绝。')
print('  注意这里的「另一个人」是一坨随机像素,不是真人脸。')
print('  真实的两个人脸之间距离会近得多,所以真机上的阈值要靠真实数据调。')
print()
print('  ④ 两个都过才开门')
print('  ' + '-' * 68)
THRESH = 30.0
cases = [
    ('04A2B3C4', face + rng.normal(0, 9, face.shape), '卡对、脸对'),
    ('04A2B3C4', other, '卡对、脸不对'),
    ('04DEADBE', face + rng.normal(0, 9, face.shape), '卡不对、脸对'),
    ('04DEADBE', other, '卡不对、脸不对'),
    ('04FF0011', face + rng.normal(0, 9, face.shape), '作废卡、脸对'),
]
print()
print('| 场景 | 卡 | 脸(距离) | 结果 |')
print('|---|:---:|:---:|:---:|')
for uid, f, desc in cases:
    card_ok = cards.get(uid) not in (None, '作废卡')
    d = dist(face, f)
    print('| %s | %s | %s | %s |'
          % (desc, '过' if card_ok else '不过',
             '过(%.0f)' % d if d < THRESH else '不过(%.0f)' % d,
             '开门' if (card_ok and d < THRESH) else '拒绝'))
print()
print('  只有第一行开门。卡和脸都得过,缺一样都不行——这就是「与」的逻辑,也是双因子的全部意思。')
print()
print('  ⑤ 每次尝试都要留痕')
print('  ' + '-' * 68)
log = []
for uid, f, desc in cases:
    card_ok = cards.get(uid) not in (None, '作废卡')
    d = dist(face, f)
    log.append({'时刻': '2026-11-02 08:%02d:%02d' % (30 + len(log), 11 * len(log) % 60),
                '卡号': uid,
                '距离': round(d, 1),
                '结果': '开门' if (card_ok and d < THRESH) else '拒绝'})
for r in log[:4]:
    print('    %s  %s  距离 %.1f  %s' % (r['时刻'], r['卡号'], r['距离'], r['结果']))
print('    …')
print('    记录条数 %d' % len(log))
print('  门禁日志必须记「谁、什么时候、过了没、为什么没过」。')
print('  没过的记录尤其重要——连续失败五次,那就是有人在试。')
print('  这条日志存成 JSON 就能直接被第 7 章那套流程收走:一天一个文件、或者直接写进 SQLite。')
print()
for ln in json.dumps(log[0], ensure_ascii=False, indent=2).split('\n'):
    print('  ' + ln)
print('  最后一句实话:这套东西的性能上限不在代码,在传感器。摄像头糊、光不够、卡贴歪,算法再好也没用。')
print('  所以做这类项目的顺序永远是:先解决「拍得清」,再谈「认得出」。')

实跑输出:

--------------------------------------------------------------------
门禁:两个因子都过才开门
--------------------------------------------------------------------

  ① 为什么要两个因子
  --------------------------------------------------------------------

| 方案 | 能挡住什么 | 挡不住什么 |
|---|---|---|
| 只刷卡 | 没卡的人 | 捡到卡、借到卡的人 |
| 只认脸 | 陌生人 | 照片、双胞胎、光线差时认错 |
| 卡 + 脸 | 捡到卡也没用,长得像也没用 | 两个都被拿到(概率极低) |

  这就是「双因子认证」:两个不同类别的证据。
  「密码 + 短信验证码」也是双因子,因为一个是「你知道的」,一个是「你有的」。
  两个都是密码就不算双因子——那只是「两个密码」,泄露方式往往是同一个。

  ② 刷卡这一半:只认 UID
  --------------------------------------------------------------------
    系统里登记的卡:
      04A2B3C4 → 王工
      04D5E6F7 → 小李
      04FF0011 → 作废卡

    刷 04A2B3C4 → 王工,进入人脸比对
    刷 04DEADBE → 没登记,拒绝
    刷 04FF0011 → 已作废,拒绝
  很多老门禁只读卡号,而卡号是能克隆的——网上几十块的读卡器就能复制一张。
  所以光刷卡的门禁,安全性取决于「卡有没有被借出去」。

  ③ 人脸这一半:比对特征
  --------------------------------------------------------------------
    同一个人(加了一点噪声):距离 9.1
    另一个人(一坨随机像素):距离 74.5
  同一个人距离小,不同的人距离大,中间有条线。这条线就是阈值——线上开门,线下拒绝。
  注意这里的「另一个人」是一坨随机像素,不是真人脸。
  真实的两个人脸之间距离会近得多,所以真机上的阈值要靠真实数据调。

  ④ 两个都过才开门
  --------------------------------------------------------------------

| 场景 | 卡 | 脸(距离) | 结果 |
|---|:---:|:---:|:---:|
| 卡对、脸对 | 过 | 过(9) | 开门 |
| 卡对、脸不对 | 过 | 不过(74) | 拒绝 |
| 卡不对、脸对 | 不过 | 过(9) | 拒绝 |
| 卡不对、脸不对 | 不过 | 不过(74) | 拒绝 |
| 作废卡、脸对 | 不过 | 过(9) | 拒绝 |

  只有第一行开门。卡和脸都得过,缺一样都不行——这就是「与」的逻辑,也是双因子的全部意思。

  ⑤ 每次尝试都要留痕
  --------------------------------------------------------------------
    2026-11-02 08:30:00  04A2B3C4  距离 9.1  开门
    2026-11-02 08:31:11  04A2B3C4  距离 74.5  拒绝
    2026-11-02 08:32:22  04DEADBE  距离 9.1  拒绝
    2026-11-02 08:33:33  04DEADBE  距离 74.5  拒绝
    …
    记录条数 5
  门禁日志必须记「谁、什么时候、过了没、为什么没过」。
  没过的记录尤其重要——连续失败五次,那就是有人在试。
  这条日志存成 JSON 就能直接被第 7 章那套流程收走:一天一个文件、或者直接写进 SQLite。

  {
    "时刻": "2026-11-02 08:30:00",
    "卡号": "04A2B3C4",
    "距离": 9.1,
    "结果": "开门"
  }
  最后一句实话:这套东西的性能上限不在代码,在传感器。摄像头糊、光不够、卡贴歪,算法再好也没用。
  所以做这类项目的顺序永远是:先解决「拍得清」,再谈「认得出」。

第二段那张卡表里,04FF0011 是「作废卡」而不是「不存在」。 这个区别很重要:作废的卡还能查到历史。 第三段那两个距离(9.1 和 74.5)用的是第 14 章那张真脸。 注意「另一个人」是一坨随机像素,不是真人——真实的两张脸会近得多。 第四段那张表是全篇的核心。四行里只有第一行开门,其余全拒——写错一个 and,这张表就废了。 第五段那个 JSON 只有四个字段,但它能被第 7 章的流程直接收走。日志的格式比日志的量重要。 最后那句「上限在传感器」不是客套。做过门禁的人都知道,调算法的时间远不如调光的时间值。

30 秒实验

  1. 把演示第四段的 THRESH 从 30 改成 20,看第二行(卡对、脸不对)会不会变成开门。
  2. 把演示第二段 cards 里的 04FF0011 整条删掉,再刷它,看走的是哪个分支——跟「作废」有什么区别?
  3. 把演示第五段的日志改成写进一个 SQLite 文件(12.1 篇那套),再写一条查询数「今天失败了几次」。

常见误区

  1. 「双因子就是两种验证方式。」必须是两个不同类别。两个密码、两张卡都不算。
  2. 「卡号唯一,不会重复也不会被复制。」卡号能克隆,几十块的读写器就行。 刷卡能证明「你有这张卡」,证明不了「你是本人」。
  3. 「算法调好就万事大吉。」上限在传感器。 光不够、图糊了,算法再好也认不出。
自测三问(点开对答案) 1. 为什么「卡 + 脸」算双因子,而「密码 + 另一个密码」不算? —— 双因子要求两个证据属于不同类别:你知道的、你有的、你是什么。 卡是「你有的」,脸是「你是什么」,两类不同。 两个密码都属于「你知道的」,泄露方式往往也一样(比如都写在同一张纸上), 所以只是「两个密码」,不是双因子。 2. 门禁的判断为什么要用「与」而不是「或」? —— 因为双因子的意思是「两个都成立才放行」。 用「或」的话,只要有卡或者只要脸对就能进,等于把安全性降到较弱的那一半。 演示里五行表格只有第一行开门,其余全拒,这就是「与」的全部含义。 3. 为什么说门禁的性能上限不在代码,在传感器? —— 因为算法再好,输入是糊的、暗的、歪的,它也认不出来。 摄像头分辨率、补光、拍摄角度、人站的位置,这些决定了上限。 所以做这类项目的顺序是「先解决拍得清,再谈认得出」,调光往往比调算法有用得多。

全书完。回头看,从第一行的 print 到这里,中间没有魔法,只有一层层的取舍。

相关笔记