Windows 人脸门禁部署实录:SSH 一断就死、虚终端计划任务静默失败与 fail-closed 阈值标定

2026-10-12 · Windows 运维 / 访问控制 · 全篇数据来自我们在集群主控机上的真机部署台账

需求很朴素:一台放在办公区、24 小时开机的 Windows 主控机,别人走过来动它的时候能"认人"。不是要金融级生物识别,只是挡住顺手牵羊——所以设计取向从一开始就不是"更准",而是简单、fail-closed(判不出来就当拒绝)、先观察再拦截。

难度不在人脸模型。OpenCV 自带 YuNet + SFace 就够用,装完就能跑。真正耗时间的是后半程:怎么让它在 Windows 上"活着"、怎么让它在 SSH 断开后不死、怎么标定一个不会把自己挡在门外的阈值。这篇把可复用的坑按顺序摊开。

一、架构:一个独占摄像头的判定服务 + 一个轮询客户端

整条链路只有两个进程,刻意做小,为的是"每一段都能单独验":

摄像头(独占) ──► face_gate_server.py :8899 (YuNet 检测 + SFace 128 维比对)
                      │  GET  /health   免鉴权(给看门狗用)
                      │  GET  /whoami   → 返回判定(核心,被调用方轮询)
                      │  POST /unlock   → 应急口令换 30 分钟放行
                      │  POST /setmode  → monitor / enforce / off
                      ▲
主控机 gate_client.py ── 轮询显示判定;判不出时 getpass 问口令

采集骨架没有重写,直接照搬团队已在展厅迎宾机上验证过的 CamHub 做法:单采集线程独占设备 + 黑帧检测 + 单实例端口守卫 + 崩溃自动重开。这里有一条容易被忽略的硬约束:

MSMF 后端的第二个采集消费者会拿到黑帧。所以摄像头必须严格独占,别用"顺手再开一个 cv2.VideoCapture 看看"的办法调试——你会看到全黑,然后误判成摄像头坏了。

二、模型与阈值:数字是标定出来的,不是拍脑袋定的

用的都是现成件,不重造:

文件说明
yunet.onnx(227 KB)人脸检测,cv2.FaceDetectorYN
sface.onnx(38,696,353 B)特征提取,输出 128 维(不是 512,别按 512 写缓存)
face_template.npy本人模板,np.load(...).reshape(-1) 后 L2 归一化

阈值怎么来的?用留出法:本人 50 帧多姿态样本,实测相似度区间 0.4883 ~ 0.8743,均值 0.7717。关键发现在下限:单帧最低只有 0.4883,也就是说你一侧头,分数就掉下去。所以判定绝不能只看一帧:

判定: >=0.45 放行 / 0.35~0.45 灰区静默 / <0.35 拒绝
多帧取最优: 首帧不过 → 1.6 秒内最多再判 5 帧,取最高分那一帧
fail-closed: 无脸 / 黑帧 / 多脸 / 脸太小 / 模型错 / 摄像头坏 → 一律 deny

SFace 这块还有两个必踩的 API 细节:

三、Windows 上的六个坑(按踩到顺序排)

坑 1:SSH 会话启动的进程会被会话一起带走

用 Start-Process 或 cmd /c start 起的 python,SSH 一断就死——因为子进程属于该会话的 Job Object。正解只有一个:用 NSSM 装成真正的 Windows 服务(见下一节)。这也是为什么"远程调试时一切正常、人一走服务就没了"。

坑 2:虚终端会话里 schtasks /run 静默失败

在 SSH 里手动触发计划任务,报错 错误: 不支持从虚终端会话启动任务,但 返回码却是 0。如果你只看 rc 判成功,会得到一个"任务跑了但什么都没发生"的幽灵故障。别用 SSH 去触发计划任务。

坑 3:PowerShell 5.1 把 UTF-8 当 GBK

远程执行 .ps1,输出中文必乱码,某些情况下直接报语法错。对策:远程脚本一律纯 ASCII 输出——状态行写 STATUS / RESULT 这种英文标记,中文留在日志文件里用 python 读。

坑 4:非交互 PowerShell 里 Expand-Archive / Invoke-WebRequest 会炸

Write-Progress 会抛 ReadConsoleOutput,而且可能把下载截断(静默的半包)。对策:解压用 python zipfile、下载用 python urllib,文件一律先 SFTP 推上去再执行,别在远程 shell 里嵌套引号拼命令。

坑 5:模型文件可能躺在 models/ 子目录

路径必须做 fallback 解析,否则直接 RuntimeError: 缺文件,而 fail-closed 设计会让你只看到"不启动"、看不到原因。写清楚启动日志里"我找了哪几个路径"。

坑 6:Windows 无密码账户不能走 LogonUser

机器是自动登录账户,第二道防线如果挂在系统口令上会直接失效。改成门禁自己认的应急口令:salt(16B) + pbkdf2_hmac('sha256', pwd, salt, 200000),只存 hash,比对用 hmac.compare_digest,与系统账户密码彻底解耦;POST /unlock 走请求体,不进 URL、不进访问日志。错 5 次锁 10 分钟。

四、装成 Windows 服务:NSSM 是唯一可靠解

nssm install FaceGate D:\facegate\py\python.exe
nssm set FaceGate AppParameters "face_gate_server.py 8899 0 1280 720 --log=..\logs\gate.log"
nssm set FaceGate AppDirectory  D:\facegate
nssm set FaceGate Start SERVICE_AUTO_START
nssm set FaceGate AppRestartDelay 5000
nssm set FaceGate AppStdout ..\logs\svc_stdout.log
nssm set FaceGate AppStderr ..\logs\svc_stderr.log
nssm start FaceGate
nssm status FaceGate        # SERVICE_RUNNING 即成功
参数名陷阱:NSSM 2.24 的参数名是 AppRestartDelay / AppStdout / AppStderr。写成 RestartDelay / stdout 会报 Invalid parameter——它不猜你的意图。另外机器是 x64,务必用 win64\nssm.exe。

Python 环境用的是绿色版(3.11.9 + cv2 5.0.0),不写注册表、删目录即恢复,这对"随时能退场"的部署很关键。

五、三档模式:先用观察期,别一上线就拦人

模式行为用途
monitor(默认)只记日志不拦截,逐条写 logs/decision.csv标定期,收集真实分布
enforce真拦截,非本人静默拒绝确认阈值不会误伤后切换
off完全不判应急停门禁

在线切换:POST /setmode {"mode":"enforce"},立即生效并写回 config.json。

上线流程(别跳):先 monitor 跑几天 → 翻 logs/decision.csv 看本人相似度分布和误伤率 → 确认 0.45 这个阈值不会把自己挡在门外 → 再切 enforce。config.json 里还能配 allow_ips 白名单(默认含 127.0.0.1 与两台主控机)。

六、第二道防线:应急口令与"私密问答"

人脸判不清时要有后手。我们做了两套,都能换 30 分钟放行(reason 统一记 admin_passphrase):

录题的硬规则:题面是明文存盘的,所以题面本身不能是"翻文件就能查到"的技术细节。IP、机型、集群规模这些都不行——同事翻一下知识库就能答。要问只有你本人知道的私人事实。
另外:vault.json 与 gate_admin.bin 是无密码账户机器上的唯一后门,重装系统前必须单独备份,别跟着工作目录一起格式化掉。

七、一个反直觉的认知:归档样本不能当"识别基准"

我们把旧机器上的 50 帧训练集和 8 张演示抓帧归档了,本意是"以后识别不准时拿它当参考"。实测发现这个想法是错的:

归档帧是同一时刻、同一姿态拍的,它们彼此的相似度中位数高达 0.90;但新鲜帧对上去只有 0.49~0.69。拿"内部自洽的旧样本"当基准,只会让你看到一片高分,然后得出错误结论。

所以识别突然不准时,正确优先级是:

  1. 在当前光照/姿态下重新采 30 帧再注册(最有效,几乎一步解决);
  2. 放宽阈值——但先看 decision.csv 的真实分布再调,别盲调;
  3. 用私密问答兜底,先让人进得来。

重建模板的脚本支持留一法算平均模板与相似度分布,直接给出可落地的阈值建议,并可选 --apply 覆盖线上模板。远程跑脚本有一条铁律:先 SFTP 推最新副本再执行,否则跑的是旧版;且 cmd /c 的控制台输出是 GBK 乱码,把 stdout 重定向到文件再 SFTP 读回来。

八、性能与验收信号

机器单帧耗时
主控机(i9-13900HX)约 39 ms/帧
旧办公机(i5-3470)100~120 ms/帧

验收只看三个信号,不靠"感觉好像行":

九、再往前一步:从"锁屏拦截"到"按敏感度过闸"

纯锁屏式门禁有个体验问题:正常干活也要被拦。更合理的方向是按操作敏感度分级放行——做 PPT、剪视频、查文档正常放行;涉及复制数据库、动凭证库、外发文件才触发人脸门禁。我们落了两件东西:

必须知道的薄弱点:分级靠文本匹配,换个说法、改走别的路径就可能绕过。所以规则要匹配资源(凭证库路径、.dump、客户数据目录)而不是动作词("导出""复制"),调用方必须传真实操作路径而不是一句话描述。门禁永远只是第二道,第一道还是目录权限、加密和网络隔离。

十、这套东西的可复用结论

  1. 先定"挡什么",再定"多准"。挡顺手牵羊和挡定向攻击是两种完全不同的系统,混着做会两头不到位。
  2. fail-closed 的代价是"必须能看到为什么拒绝"。判不出来的原因要逐条记进日志,否则你只会体验到一个"什么都进不去"的黑盒。
  3. Windows 上让进程常驻只有一条可靠路径:装成服务。所有"会话里跑得好好的"方案,本质都是临时的。
  4. 阈值来自分布,不来自直觉。先 monitor 收集真实数据,再切 enforce。
  5. 兜底手段要提前验证它命中过。口令和问答都要在真正被锁在外面之前测一遍。

我们站内在售的苏州话标准词库(3550 条)与数字人素材,是完全不同的东西,但方法一样——先把基线标定出来,再谈效果。如果你也在做无人值守终端、访问控制或数字人落地,带着具体链路问题来找我们更高效。

看看苏州话标准词库(含免费样本)→ 终端/数字人定制服务 →

常见问题

为什么我用 SSH 起的 python 服务,一断开就死了?

因为它是从 SSH 会话的 Job Object 里派生的子进程,会话结束就被回收。Start-Process、cmd /c start 都逃不掉。正解是用 NSSM(或同等工具)把它装成真正的 Windows 服务,Start SERVICE_AUTO_START + AppRestartDelay 5000 实现开机自启与崩溃自愈。

SSH 里 schtasks /run 明明返回 0,任务却没跑,为什么?

虚终端会话不支持启动计划任务,PowerShell/CMD 会报"不支持从虚终端会话启动任务",但返回码仍是 0。判断成功不能只看 rc,要去看任务实际产生的副作用(日志文件、输出文件)。

远程 PowerShell 输出中文全是乱码怎么解?

PowerShell 5.1 会把 UTF-8 当 GBK 处理。最省事的办法是远程脚本只输出纯 ASCII(STATUS/RESULT 之类的英文标记),中文写进日志文件再由 python 以 utf-8 读取。不要试图在远程 shell 里靠 chcp 打补丁。

人脸相似度阈值定多少合适?

没有通用值,必须自己标定。我们的实测是本人 50 帧多姿态 0.4883~0.8743、均值 0.7717,取 >=0.45 放行 / 0.35~0.45 灰区 / <0.35 拒绝,并且强制多帧取最优(1.6 秒内最多再判 5 帧)。单帧最低 0.4883 说明侧头就掉分,只看一帧必然误伤。

为什么"旧样本互相比 0.90,新照片只有 0.5"?

因为归档样本是同一时刻、同一姿态连拍的,彼此高度自洽,用它当参考基准没有意义。识别不准时的正确顺序是:在当前光照姿态下重新采 30 帧再注册 > 依据 decision.csv 分布放宽阈值 > 私密问答兜底。

无人值守场景下,敏感操作会自动放行吗?

不会。非交互环境下的敏感操作一律拒绝,绝不默认放行——这是 fail-closed 原则的直接推论。这也是为什么需要 monitor 观察期先把误伤率摸清楚。

这套门禁能防住有备而来的人吗?

不能,也不该这么定位。它的设计目的是挡顺手牵羊与误操作。sensitive.json 是文本规则,换说法或换路径可能绕过;真正的第一道防线是目录权限、加密与网络隔离,门禁只是第二道。