照片人物识别
概述
判断依据有两条腿:文字(强线索)+人脸(软证据)。缺了文字这条腿,就退化成「凭长相猜」——本技能第一版就是这么错的。
先想清楚任务性质,这是 v2.0 改版的起点。
用户要的是「从一堆照片里挑出有我的」——这是筛选/检索任务,不是「这张脸是不是这个人」的判定/验证任务。两者最优策略相反:
| 判定(验证) | 筛选(本技能的定位) | |
|---|---|---|
| 指标 | 精度优先,宁可漏 | 召回优先,宁可多给候选 |
| 低分怎么处理 | 判「不同人」不再提示 | 保留,排在队尾,仍出裁图 |
| 出错代价 | 误认(尴尬,可发现) | 漏掉(不可发现——你不知道有漏) |
所以 v2.0 的输出是排序式候选表:所有检出的人脸都列出,按分数降序,低分标「仅供参考」但不隐藏。藏起低分等于把「漏判」变成「我以为筛完了」——这正是第一版最大的设计错误。
默认引擎:本机免费人脸比对——检测统一用 OpenCV YuNet,识别可在 SFace(默认 37MB 可商用)与 InsightFace ArcFace(182MB 更准、个人非商用)之间切换;0 成本、无需 API key、照片不出本机。云端(腾讯云 CompareFace)只作兜底。
为什么不用多模态大模型做判定:视觉大模型擅长「描述」,不做「身份判定」。实战教训——让人看两张照片判「像不像」,把长相相近的同行者认成了本人;换成嵌入向量算余弦,同一对 0.147(明确不同人),本人两张 0.90/0.67(明确同一人)。描述型能力可以描述,不能判定。
一、引擎选型:本地两档免费 + 云端一条兜底
| sface(默认) | arcface(要更准再装) | |
|---|---|---|
| 组成 | OpenCV YuNet(检测)+ SFace(识别) | YuNet(检测)+ InsightFace ArcFace R50 |
| 体积 | 37 MB | 182 MB |
| 维度 | 128 | 512 |
| 许可 | Apache-2.0,可商用 | 仅个人研究/非商用 |
| 强项 | 正脸、日常照片 | 侧脸、低清、年代跨度大、长相相近难分 |
| 实测(同批样本) | 本人跨场合 0.665;与同行者 0.147(比 4.5×) | 0.634;0.117(比 5.4×) |
给使用者的建议(如实告知)
1. 先用 sface:绝大多数是正脸,一次判对,省 145MB,许可干净;
2. 只在下列情况切 arcface:结果「存疑」、两人相近难分、侧脸/远景/老照片;
3. arcface 只作个人自用:交付客户或商用必须退回 sface;
4. 两套底库不通用(维度不同),已分开存 gallery_sface.npz / gallery_arcface.npz;
5. 两者检测都用 YuNet,边界(背影检不出、牌匾误检)完全一样,肉眼复核不可跳过;
6. 第三条路:腾讯云 CompareFace(API、按次计费、照片上云),见下方「备选方案」。
装(一次性,全免费、无需账号)
S=~/.workbuddy/skills/photo-face-match/scripts
python3 -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
opencv-python-headless onnxruntime pyobjc-framework-Vision # Vision 供 OCR
bash $S/fetch_models.sh # sface,37MB
bash $S/fetch_models.sh --arcface # 追加 arcface,182MB
不用装 insightface 包(Python 3.13 要现场编译、极易失败);直接取官方 ONNX,配合 YuNet 的 5 点做 estimateAffinePartial2D + warpAffine 对齐到 112×112。
模型必须校验:fetch_models.sh 内置 sha256 校验(2026-11 实测值),下载后逐文件核对,不符即删除并报错,绝不静默加载。模型是从第三方镜像抓的 ONNX,不校验等于信任任何中间人。上游更新模型时用 SKIP_VERIFY=1 显式跳过。
备选方案(第三选择):腾讯云人脸比对 CompareFace
什么时候该主动提示(满足任一条,别默认推荐)
- 本机装不上依赖(Python 版本诡异、无管理员权限、离线无模型);
- 用户要的不是「像不像」,而是「是不是这个人」的正式结论——官方文档明确建议此类场景用「人脸验证/人员验证」接口,而非 CompareFace;
- 需要 1:N 人员库、活体检测、质量控制等本地没做的能力;
- 精度要求极高且不在意照片出本机与费用。
关键事实(2026-11 核对官方文档)
| 项目 | 说明 |
|---|---|
| 接口 | iai.tencentcloudapi.com,Action=CompareFace,Version=2020-03-03 |
| 返回 | Score 0–100;FaceModelVersion |
| 阈值(3.0 版) | 误识率 1‰=40 分、1万‰=50 分、10万‰=60 分;官方口径「一般超过 50 分可认定为同一人」 |
| 阈值(2.0 版) | 对应 70/80/90,一般超 80 认定同一人 |
| 入参 | base64 ≤5MB(jpg 长边 ≤4000px)、或 URL;多脸默认取置信度最高,FaceMatchingStrategy=1 改取面积最大 |
| 其它 | QualityControl 0–4、NeedRotateDetection(无 EXIF 的旋转图要开) |
| 费用 | 按次计费,需实名账号与密钥;照片会上传腾讯云 |
用之前必须说清三件事:①照片离开本机上传第三方;②按次计费、需开通;③密钥只放环境变量或本机凭据文件。
交接方式:①官方 SDK(tencentcloud-sdk-python 的 iai);②控制台 API Explorer 在线调试(不写代码);③装 SkillHub 官方「腾讯云 FaceID CompareFace」技能。
与本地引擎的关系:它是兜底不是替代,升级前必须先取得用户明确同意。
二、标准流程(先读字,再看脸)
⓪ 先找文字锚点:OCR 把「字」变成证据
这一步必须排在所有人脸比对之前。照片里若有桌牌姓名、大屏课程标题、横幅单位名、工牌,那它就是高优先级证据——比任何相似度分数都高一级。
python3 $S/text_anchor.py 照片.jpg 目录/ # 看有什么字
python3 $S/text_anchor.py ~/Downloads --grep 陆凌燕 律协 授课人 # 批量筛,命中标 ★
python3 $S/text_anchor.py 照片.jpg --json # 要位置时输出 JSON
引擎是 macOS 自带 Vision 框架(离线、免费、无 key),中文场景文字优于 tesseract。实测:荆州授课照读出桌牌「陆凌燕 / 授课人」置信度 1.00;黄石授课照读出大屏「法律人 AI 生产力革命」;会场全景读出「黄石市律师行业2026年度继续教育培训」。
读到的字怎么用
| 读到什么 | 能定什么 | 处理 |
|---|---|---|
| 桌牌/工牌有目标姓名 | 该座位的人应为目标人物 | 强线索,但仍需位置/角色一致 |
| 大屏是她在讲的课题名 | 讲台上的人是授课人 | 结合站位确认 |
| 横幅主办单位+年份 | 活动性质、时间 | 用于选图与写图注 |
| 完全无字 | 只剩面部路径 | 走①–④;分数也缺则「无法判断」 |
文字是强线索,不是决定性证明。桌牌可以放错位置、大屏可以是别人的课件、颁奖牌可能是别人举着的。「读到名字」+「位置角色也对」才结案;只有字、位置明显不对,就要打问号。
OCR 会认错字,但不会认错场景:实测把「陆凌燕」认成「陆凑燕」、「继续」认成「维续」,置信度只有 0.30。因此脚本内置模糊容错(3 字以上关键词允许错 1 字,可容忍 OCR 插空格),命中时会明确标注「近似『陆凑燕』差1字」——容错但透明,不悄悄放过。字面近似而位置场景都不对的,仍不许当锚点。
① 建底库(enroll)
python3 $S/face_match.py enroll --label 陆凌燕 --images a.heic b.jpg c.jpg
必须给 2–4 张不同角度/场合的近期照。enroll 会存下每一张的原始向量 + 均值,并做类内一致性校验:
第1张 与其余平均 0.710(与均值 0.903) IMG_7907.HEIC
第2张 与其余平均 0.669(与均值 0.872) 陆凌燕授课现场.jpg
第3张 与其余平均 0.176(与均值 0.603) IMG_1480.HEIC ⚠ 与其余照片差异过大
⚠⚠ 有 1 张与其余照片差异过大(留一平均低于 0.33),疑似不是同一人
为什么用「留一平均」而不是「与均值相似度」:均值本身会被污染项拉偏,所以污染项离均值也不远(上例 0.603,看着很正常)。真正有判别力的是它和其余各张的平均相似度(0.176,一眼异常)。一张错标照片能把整个底库废掉——实测混入同行者后,陌生人对该底库的分数从 0.147 涨到 0.603,直接跨过「高置信」线。所以这条校验不是锦上添花,是防止底库静默失效的唯一闸门。
同一人重复 enroll 会合并向量(不是覆盖),保留历史样本。
② 检索(match)——逐脸、排序式
python3 $S/face_match.py match <文件或目录...> --crops /tmp/faces --log /tmp/run.json
python3 $S/face_match.py match --engine arcface <...> --crops /tmp/faces # 切高精度
python3 $S/face_match.py match ~/Photos --recursive --crops /tmp/faces # 多层目录
两个关键行为:
- 逐脸,不是「只取最大脸」。一张合影里她可能是第 2、第 3 大的脸,只比最大那张必然漏。输出里带
脸#列指明是第几张脸。 - 排序式:所有检出的人脸都进表,
--min-score默认 0(全显示)。低分只降序、不隐藏。
文件 脸# 位置(x,y) 脸宽 最像 分数 次优 差 结论
IMG_7907.HEIC 1 996,254 9.4% 陆凌燕 0.903 -(仅1人) - 同一人(高置信)
IMG_1480.HEIC 1 515,377 9.4% 陆凌燕 0.222 -(仅1人) - 低相似(大概率不是,仅备查)
IMG_1704.HEIC 1 440,630 2.9% 陆凌燕 0.136 -(仅1人) - ⚠检测分0.50|人脸过小,不作结论
IMG_7926.HEIC - ---------- 未检出人脸(已试阈值 0.5/0.4;背影、侧后脑、极低清皆可能)
检测阈值偏低是有意的(默认 0.5,仍无检出降到 0.4)。这是筛选任务,漏掉真照片的代价远大于多给一个候选(多出来的看一眼裁图就能排除,漏掉的你永远不知道)。实测同一张会场全景:阈值 0.7 → 0 张、0.6 → 1 张、0.5 → 9 张、0.3 → 21 张(开始误检)。第一版硬编码 0.7,于是这张坐满一屋子人的照片被判「未检出人脸」——假阴性被包装成「照片里没人」,这是最坏的失败方式。
代价是误检,所以要靠「检测分」这个信号兜住。真人正脸实测检测分约 0.94;实测一张照片里圆形木牌上的「水」「宏」二字被误检成脸,检测分 0.50 / 0.70。脚本对检测分 < 0.80 的候选会在结论前标 ⚠检测分0.50,并在汇总里单独列出。看到这个标记就必须看裁图——它可能是牌匾/窗格,也可能是脸太小或太模糊,两者处理方式完全相反。
③ 肉眼核对裁图(不可跳过,不是可选步骤)
python3 $S/photo_sheet.py sheet /tmp/faces --out /tmp/faces_sheet.png --cols 4
看三件事:裁出来的是不是人脸(检测器会把圆形牌匾、窗格纹样、木牌上的字误检成人脸,实测全部发生过)、是不是目标、构图是否可用。分数只做排序,肉眼复核才算定案。
为什么这一步是硬的:v2 为了不漏真照片,主动把检测阈值降到 0.5,代价就是误检增加。如果不看裁图,误检会以「一张低分人脸」的形式混在结果里,被误读成「照片里有个人,但不是她」。宁可多这一步,不可省这一步。
④ 1:1 复核(compare)
python3 $S/face_match.py compare 照片1 照片2
python3 $S/face_match.py compare 合影.jpg 单人照.jpg --face1 3 # 指定第 3 张脸
多脸图会自动找最相似的一对并说明用的是第几张脸;一张脸小于画面 5% 会提示「特征提取不可靠,分数仅供参考」。
三、分数怎么读(各引擎刻度不同,只比结论、不比数值)
sface(默认,OpenCV 官方 1:1 阈值 0.363)
| 分数 | 结论 |
|---|---|
| ≥ 0.45 | 同一人(高置信)——实测本人 0.665 / 0.903 |
| 0.363–0.45 | 同一人(中等),建议人工确认 |
| 0.28–0.363 | 分数偏低,不作结论 |
| < 0.28 | 低相似,大概率不是——实测本人 vs 同行者 0.147 |
arcface:≥0.50 高置信;0.40–0.50 中等;0.30–0.40 不作结论;<0.30 低相似。
腾讯云:0–100 百分制,3.0 版「一般超过 50 分认定为同一人」。三家刻度不可混比。
⚠ 阈值不是统计阈值。这些档位是拿两个人、不到十张照片的经验值,样本量在统计上毫无意义。换场景(不同年龄、光照、相机、族群)必须自己抽检几张确知的照片校准。--log 里会把 bands 和 calibration_note 一起写进审计日志,就是为了让这一点可追溯。
⚠ 这些是 1:1 阈值,用在 1:N 检索上天生偏松。底库 N 人时「任一人误命中」的概率约 1-(1-P)^N,N 越大假阳性越高。脚本在底库 >3 人时会在结论里加一句提醒。底库人数多又要求严格时,把结论往下降一档看。
margin = Top1 − Top2:底库里有长相相近的人时两者双双高分,此时即使 Top1 达标也必须存疑(脚本内置 margin < 0.08 标「存疑」)。但它只能防「底库内混淆」——如果那个长相相近的人根本没录进底库,margin 会很大,系统会自信地报错。防不住的是「底库外的相似者」。
四、四条铁律(优先级高于分数)
铁律 1:场景锚点与机器分数互证,缺一降级
证据排序:照片中的文字(桌牌姓名、大屏标题、横幅、工牌)> 位置与角色(讲台授课者、主席台、合影位次)> 同组照片连续性(同一人同套衣服、同场景多张)> 人脸相似度分数。
实战:黄石、荆州两篇通稿的授课照,定案靠桌牌「陆凌燕」+大屏课件标题,分数只作确认。分数高但没有锚点、且候选里有长相相近者 → 仍要求人确认。
铁律 2:必须同角度比对,不同角度不比长短宽窄
侧视让短发投影变短、正脸显脸宽——拿侧面照和正面照比「发型长短」「脸宽脸窄」必然错。实战:把同一人的侧脸误判为「超短发」,与正面照的「齐肩波波头」当成两个发型,凭空造出不存在的外观差异。机器不受这个坑影响(它比的是嵌入向量,不是发型描述),这是要换引擎的根本原因。
铁律 3:看不清就说看不清;被纠正过一次的差异,先假设「其实没差异」
细框眼镜在远距/低清下会「消失」,正确表述是「眼部细节不可辨」,不能归类成「未戴眼镜」。连续给同一件事造新变量解释,是本次最大的推理错误。
铁律 4:「未检出」不等于「不在里面」;「底库没有」不等于「不是」
- 没检出脸:可能是背影、侧后脑、低头、手遮脸、光线太暗、只露出半张脸。要说「这张判不了」,不能说「这张没有她」。
- 脸太小(< 画面 3%):特征提取不可靠,结论一律作废。要说「这张图判不了,换个近的」,不能顺口说「不像」。
- 底库没有这个人:脚本会照常算出一个分数(对谁都不像)。这时说「底库里没有可比对的人」,而不是「不是这个人」。
把「判不了」说成「不是」,就是把不确定性伪装成结论——这是本技能最容易犯、也最难被发现的错。
五、能判 / 不能判(如实告知使用者)
| 情形 | 结果 |
|---|---|
| 正脸、脸宽 ≥ 画幅 5% | 可判,给分数 |
| 同人不同角度/场合/戴镜与否 | 可判(实测 0.67 仍命中) |
| 脸宽 < 3% 的远景人物 | 标「人脸过小,不作结论」,不出结论 |
| 背影、侧后脑、低头、手遮脸 | 检不出 → 无法判断,不要硬判 |
| 检测到「非人脸的圆形/纹样」 | 靠 --crops 肉眼复核剔除 |
| 目标根本不在底库 | 会说「底库里没有可比对的人」,不能说「不是」 |
六、已知失败模式(都是静默的,不报错、不崩溃)
| 失败 | 原因 | 现行处理 |
|---|---|---|
| 合影里漏掉她 | 只比最大一张脸 | 改逐脸检索,带 脸# |
| 「没检出」被理解成「没人」 | 检测阈值过高 | 阈值降到 0.5/0.4,并写明是「判不了」不是「没有」 |
| 牌匾/木牌被当成脸 | 低阈值的代价 | 检测分 <0.80 标 ⚠检测分,汇总单列,强制看裁图 |
| 陌生人被判成她 | 底库混入错标照片 | enroll 做留一法类内校验并报警 |
| 读错图 | HEIC 临时文件按 basename 缓存,不同目录同名文件串图 | 缓存键改「路径+大小+mtime」哈希 |
| 看错行 | 同名文件在表格和裁图里撞车 | 重名自动切「父目录/文件名」,裁图加序号前缀 |
| 锚点丢失 | OCR 认错一个字就整条不命中 | 模糊容错 + 标出近似片段 |
| 结论无法复现 | 没有留痕 | --log 写审计 JSON:版本、模型指纹、阈值、逐脸分数 |
技术要点(都是踩过的坑)
- HEIC:iPhone 直出,
sips -s format jpeg转;脚本已内置。 - EXIF 旋转:必须
ImageOps.exif_transpose,否则按比例裁会把脸裁到别处(曾裁到狗身上)。 - 尺寸上限:原图 4000px 直接喂读图工具会被压缩丢弃并报
Image too large;脚本内已缩到 ≤2000px 再检测,读图用裁图 ≤1500px。 - 序号↔文件名映射:拼总览图后用 dict 写死映射,不要凭记忆回填(曾把 p2 对应成错误的 IMG 号)。
- 模型下载:GitHub 直连在国内常被限速(37MB 卡在 7MB);
fetch_models.sh按 hf-mirror → gh-proxy → 官方 回退,并做 sha256 校验。 --engine解析:argparse 会把写在后头的--engine arcface的值当位置参数(实测把文件名顶掉),脚本已改为先全局摘出。- OCR 方向:Vision 不自动读 EXIF 方向,必须显式传 orientation,否则竖拍图文字变乱码。
- OCR 置信度:场景文字常在 0.30–0.50 但内容是对的——不要因分低丢弃;
--min-conf默认 0.2。 - OCR 坐标:
--json的 box 是(x, y, w, h)归一化,原点在左下角(Vision 原生)。转左上原点用top = 1 - (y + h),不转就上下颠倒。 - 检测分(YuNet 的 score)是判断「这到底是不是脸」的唯一内置信号:真人正脸约 0.94,误检的牌匾约 0.5–0.7,阈值定在 0.80。但远景真人的检测分也会低,所以它只指示「要去看裁图」,不指示「这不是人」。
代跑口径
单批(≤30 张)从建库到出候选表默认 ≤2 分钟;实测 10 张 iPhone 原图走完 OCR+逐脸比对约 5–10 秒,arcface 首跑含模型加载再 +5 秒左右。
禁止三种自造开销:为「刷新」重跑已完成的 enroll / match(向量已落库,重跑等价)、通读脚本源码、逐张读原图复核。逐张目检裁图留给人;agent 只处理两类:标了 ⚠检测分 的、以及分数打架(margin < 0.08)的。
边界与红线
- 不做身份认定,不承诺「百分百」。结论用于使用者自己筛选照片,不对外作为身份认定依据,不得用于取证、考勤、门禁等任何正式场合。
- 不防伪造、不防翻拍、不做活体检测:屏幕翻拍、打印件、Deepfake 合成图都能通过本技能。因此绝对不能用来证明「某人当时在场」——它能证明的只有「这张照片里的人脸,在数值上接近底库里的人脸」。
- 仅处理使用者自己的照片;本地引擎下照片与底库不出本机、不上传任何第三方人脸库。
- 底库是人脸特征,属《个保法》第 28 条的敏感个人信息:落盘自动
chmod 600;list可审计(谁、几张、何时、类内一致性);remove可单独撤回;不用了就rm删文件。批量给别人建底库前须取得本人同意。 - 走腾讯云前必须先取得明确同意:照片上传第三方、按次计费;密钥只放环境变量或本机凭据文件,绝不写进脚本、不进聊天记录。
- 涉及公开发布时提醒肖像权与同行者授权。
- 不把三家的分数混着比:sface/arcface 是余弦相似度(−1~1),腾讯云是百分制(0~100)。
> 法律科技实务工具 · 维护者陆凌燕律师(北京德恒·无锡)