Files
operating-room-monitor-server/scripts/baidu_face_1n_search.py
Kevin 69980d8073 feat: align surgery API with schemas and extend client tooling
- Refactor app API and schemas; adjust surgery pipeline, repository, and session manager.

- Improve consumption TSV logging and consumable vision integration; trim voice resolution.

- Add Baidu Face 1:N search script, .env.example entries, and client API integration doc.

- Update demo client, staging checklist, surgery interface doc, and related tests; add sample face image.

Made-with: Cursor
2026-04-23 16:09:20 +08:00

292 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""百度智能云人脸 1N 搜索(独立脚本,不接入本仓库 FastAPI
对应官方文档:人脸 1N 搜索 — https://cloud.baidu.com/doc/FACE/s/Gk37c1uzc
接口POST https://aip.baidubce.com/rest/2.0/face/v3/search
前置条件(本脚本不负责「建库 / 注册人脸」):
- 在控制台创建应用并开通「人脸识别」相关接口权限;
- 已使用人脸库管理 API 或控制台建立用户组,并向库中注册用户与人脸照片;
- 否则搜索会失败(例如未找到匹配用户、人脸库为空等)。人脸库管理说明见产品文档「人脸库管理」章节。
配置从环境变量读取;启动时会从**仓库根目录**下的 `.env` 与**当前工作目录**下的 `.env` 加载(需已安装 `python-dotenv`,随 pydantic-settings 提供)。
主要环境变量(详见仓库 `.env.example` 中 Baidu Face 节):
BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、BAIDU_FACE_SECRET_KEY必填
BAIDU_FACE_GROUP_ID_LIST与命令行 --groups 二选一;格式以百度人脸库文档为准,非法值由接口返回错误码)
用法示例(输入为**文件夹**,遍历其下所有支持的图片并打印识别日志):
uv run python scripts/baidu_face_1n_search.py /path/to/photos
支持格式PNG、JPG、JPEG、BMP单张 base64 建议 <2M分辨率 <1920x1080以官方文档为准
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from aip import AipFace
from dotenv import load_dotenv
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp"}
def _validate_group_id_list(s: str) -> None:
"""仅校验列表非空与数量上限group_id 字符集等由百度接口校验。"""
parts = [p.strip() for p in s.split(",") if p.strip()]
if not parts:
print("错误group_id_list 解析后为空。", file=sys.stderr)
sys.exit(2)
if len(parts) > 10:
print("错误group_id 最多 10 个(逗号分隔)。", file=sys.stderr)
sys.exit(2)
def _load_dotenv_files() -> None:
load_dotenv(_PROJECT_ROOT / ".env")
load_dotenv()
def _env(name: str) -> str:
return (os.environ.get(name) or "").strip()
def _env_int(name: str, default: int) -> int:
v = _env(name)
if v.isdigit() or (v.startswith("-") and v[1:].isdigit()):
return int(v)
return default
def _face_client() -> AipFace:
app_id = _env("BAIDU_FACE_APP_ID")
api_key = _env("BAIDU_FACE_API_KEY")
secret = _env("BAIDU_FACE_SECRET_KEY")
if not app_id or not api_key or not secret:
print(
"错误:未配置百度人脸凭据。\n"
"请在 `.env` 或环境中设置BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、"
"BAIDU_FACE_SECRET_KEY\n"
"(参考仓库 `.env.example` 中 Baidu Face 节;与语音 BAIDU_SPEECH_* 可为不同应用。)",
file=sys.stderr,
)
sys.exit(2)
client = AipFace(app_id, api_key, secret)
conn_ms = _env("BAIDU_FACE_CONNECTION_TIMEOUT_MS")
sock_ms = _env("BAIDU_FACE_SOCKET_TIMEOUT_MS")
if conn_ms.isdigit():
client.setConnectionTimeoutInMillis(int(conn_ms))
if sock_ms.isdigit():
client.setSocketTimeoutInMillis(int(sock_ms))
return client
def _read_image_base64(path: Path) -> str:
if not path.is_file():
raise FileNotFoundError(str(path))
raw = path.read_bytes()
if not raw:
raise ValueError("empty file")
return base64.b64encode(raw).decode("ascii")
def _ts() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _list_image_files(folder: Path, *, recursive: bool) -> list[Path]:
if not folder.is_dir():
print(f"错误:不是有效文件夹:{folder}", file=sys.stderr)
sys.exit(2)
if recursive:
out: list[Path] = []
for p in folder.rglob("*"):
if p.is_file() and p.suffix.lower() in _IMAGE_SUFFIXES:
out.append(p)
else:
out = [
p
for p in folder.iterdir()
if p.is_file() and p.suffix.lower() in _IMAGE_SUFFIXES
]
return sorted(out, key=lambda p: p.name.lower())
def _search_options_from_env_and_args(args: argparse.Namespace) -> dict[str, Any]:
qc = _env("BAIDU_FACE_QUALITY_CONTROL") or "NONE"
lc = _env("BAIDU_FACE_LIVENESS_CONTROL") or "NONE"
if args.quality_control is not None:
qc = args.quality_control
if args.liveness_control is not None:
lc = args.liveness_control
max_n = _env_int("BAIDU_FACE_MAX_USER_NUM", 1) if args.max_user_num is None else args.max_user_num
match_th = _env_int("BAIDU_FACE_MATCH_THRESHOLD", 80) if args.match_threshold is None else args.match_threshold
return {
"max_user_num": max(1, min(50, max_n)),
"match_threshold": max(0, min(100, match_th)),
"quality_control": qc,
"liveness_control": lc,
}
def _resolve_group_id_list(args: argparse.Namespace) -> str:
g = (args.group_id_list or "").strip() or _env("BAIDU_FACE_GROUP_ID_LIST")
if not g:
print(
"错误:未指定人脸组。\n"
"请设置环境变量 BAIDU_FACE_GROUP_ID_LIST或传入命令行--groups a,b",
file=sys.stderr,
)
sys.exit(2)
return g
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="百度人脸 1N 搜索:在指定人脸库组中,对文件夹内每张照片做相似度检索并打印识别日志。"
)
p.add_argument(
"folder",
type=Path,
help="含照片的文件夹路径(仅处理 PNG/JPG/JPEG/BMP",
)
p.add_argument(
"--groups",
dest="group_id_list",
default=None,
help="人脸组 id逗号分隔最多 10 个;未传时使用环境变量 BAIDU_FACE_GROUP_ID_LIST",
)
p.add_argument(
"--max-user-num",
type=int,
default=None,
help="覆盖环境变量 BAIDU_FACE_MAX_USER_NUM返回前 N 个最相似用户150",
)
p.add_argument(
"--match-threshold",
type=int,
default=None,
help="覆盖环境变量 BAIDU_FACE_MATCH_THRESHOLD0100默认 80",
)
p.add_argument(
"--quality-control",
choices=("NONE", "LOW", "NORMAL", "HIGH"),
default=None,
help="覆盖环境变量 BAIDU_FACE_QUALITY_CONTROL默认 NONE",
)
p.add_argument(
"--liveness-control",
choices=("NONE", "LOW", "NORMAL", "HIGH"),
default=None,
help="覆盖环境变量 BAIDU_FACE_LIVENESS_CONTROL默认 NONE",
)
p.add_argument(
"--recursive",
action="store_true",
help="递归包含子目录中的图片",
)
p.add_argument(
"--json",
action="store_true",
help="每张照片输出一行 JSONfile + API 原样响应),便于脚本解析",
)
return p.parse_args()
def main() -> None:
_load_dotenv_files()
args = _parse_args()
folder = args.folder.resolve()
group_id_list = _resolve_group_id_list(args)
_validate_group_id_list(group_id_list)
options = _search_options_from_env_and_args(args)
_group_log = f"[{_ts()}] 使用 group_id_list={group_id_list!r}"
if args.json:
print(_group_log, file=sys.stderr)
else:
print(_group_log)
files = _list_image_files(folder, recursive=args.recursive)
if not files:
print(
f"[{_ts()}] 文件夹内未找到支持格式的图片:{folder}{', '.join(sorted(_IMAGE_SUFFIXES))};可加 --recursive",
file=sys.stderr,
)
sys.exit(2)
client = _face_client()
n = len(files)
any_error = False
for i, path in enumerate(files, start=1):
rel = path.name
try:
b64 = _read_image_base64(path)
except (OSError, ValueError) as e:
any_error = True
print(
f"[{_ts()}] [{i}/{n}] 文件 {rel!r} 读取失败:{e}",
file=sys.stderr,
)
continue
resp = client.search(b64, "BASE64", group_id_list, options)
if args.json:
line = {
"file": str(path),
"relpath": rel,
"index": i,
"total": n,
"response": resp,
}
print(json.dumps(line, ensure_ascii=False))
if resp.get("error_code", -1) != 0:
any_error = True
continue
err = resp.get("error_code")
if err != 0:
any_error = True
msg = resp.get("error_msg", "")
print(
f"[{_ts()}] [{i}/{n}] {rel!r} 识别失败 error_code={err} error_msg={msg!r}"
)
continue
result = resp.get("result") or {}
users = result.get("user_list") or []
if not users:
print(
f"[{_ts()}] [{i}/{n}] {rel!r} 无匹配用户 user_list 为空(可检查人脸库或调低匹配阈值)"
)
continue
for r, u in enumerate(users, start=1):
gid = u.get("group_id", "")
uid = u.get("user_id", "")
info = u.get("user_info", "")
score = u.get("score", "")
tag = f" [{r}]" if len(users) > 1 else ""
print(
f"[{_ts()}] [{i}/{n}] {rel!r} 识别成功{tag} group_id={gid!r} "
f"user_id={uid!r} user_info={info!r} score={score}"
)
if any_error:
sys.exit(1)
if __name__ == "__main__":
main()