活体 API / 接入指南

轻量接入,云端活体验证。

设备端采集,云端完成验证。通过统一、轻量的 REST 接口接入静默 RGB 活体与炫彩活体,按模块授权,按验证组计次。

REST v1Bearer tokenPNG / RGB8HTTP 接入无需专用客户端 SDK
当前 API 基础地址https://api.inspirehub.cc

接入时请使用客户端可访问的服务器地址或 HTTPS 域名。0.0.0.0 是服务监听地址,不是远程客户端的请求目标。

01

建立首次连接

先向服务运营者获取业务 token,再查询已授权模块和可用额度。一个 token 可以拥有一个或多个算法模块的调用权限。

01设备端前处理

InspireFace 提取眼点,按约定生成 320 × 320 裁剪图。

02上传一轮采集

JSON 元数据与无损图像二进制文件一起上传。

03读取验证结论

先检查 status,再读取 alive;retry 时重新采集。

export API_BASE='https://api.inspirehub.cc'
export API_TOKEN='YOUR_BUSINESS_TOKEN'

curl --fail-with-body "$API_BASE/v1/me" \
  -H "Authorization: Bearer $API_TOKEN"

curl --fail-with-body "$API_BASE/v1/modules" \
  -H "Authorization: Bearer $API_TOKEN"

Python 示例需要 Python 3.10+ 与 httpx(pip install httpx)。运行前先在环境变量中设置 API_BASE 和 API_TOKEN。此文档页面不收集或保存任何 token。

02

选择算法模块

RGB

静默活体

支持单图或录制序列。云端运行 RAW RGB 推理,序列模式额外对最终时间窗口计算时序结果。

rgb_liveness
COLOR

炫彩活体

一次采集包含白、红、绿、蓝四个补光阶段。云端计算相邻帧差分,并融合两个 RAW CNN 分支的输出。

color_liveness
POST/v1/modules/{module_id}/verify
模块modecapture_profile输入
rgb_livenessrgb_singlergb_single_v1一张裁剪图
rgb_livenessrgb_sequencergb_contiguous_v1有序帧与失败事件
color_livenesscolorcolor_wrgb_v1white → red → green → blue(白 → 红 → 绿 → 蓝)

可调用性由两层控制:服务器必须启用该模块,且你的 token 必须获授权。GET /v1/modules 返回已授权模块及其部署状态。

03

采集一轮,上传轻量数据包

使用 multipart/form-data。metadata 是内容为 JSON 的文本字段;每张图像作为二进制文件字段,其表单字段名必须与 image.part 完全一致。让 HTTP 库自动生成 Content-Type 和 boundary。

裁剪算法也是协议的一部分。

接入前,需在移动 SDK 中实现并验收约定的 eye_crop_320_v1 前处理。Core 接收已完成裁剪的图像,不执行人脸检测、对齐或裁剪。通用人脸框缩放不能视为等价实现。整轮采集须保持旋转和镜像处理一致。

图像编码

编码要求
png320 × 320、8-bit truecolor RGB(PNG color type 2),无 alpha、非调色板格式。无损,推荐用于缩小上传体积。
rgb8恰好 307200 字节:连续 HWC 排列的 RGB uint8 像素。无文件头,不交换通道,不做归一化或浮点转换。

不上传 JPEG、视频、Base64 或浮点数组。炫彩活体上传四张原始裁剪图,差分由云端计算。对实际上传的字节计算 SHA-256,并使用小写十六进制字符串;该哈希校验完整性,不证明采集真实性。

CapturePacket:所有顶层字段均为必填

字段约定
protocol_version整数 1。
capture_id1–128 字符的字符串,批次内唯一。每轮新采集使用新 ID。
modergb_single / rgb_sequence / color
capture_profile与 mode 精确匹配的采集协议,见模块表。
preprocess_profileeye_crop_320_v1
complete布尔值。仅在约定的采集条件完成后设为 true。false 会产生正常的 retry 结果。
frames按顺序排列的事件数组。数据包不接受未知字段、标签、阈值或模型选择参数。

帧字段

字段必填条件约定
source_index始终非负源帧整数序号,严格递增。保留断号,不要将筛选后的帧重新编号。
timestamp_us始终同一采集时钟下的非负整数曝光时间戳,单位微秒,严格递增;无需使用 Unix 时间。
status始终ok / face_unavailable / dropped
source_sizestatus = ok转正后原图的 [宽, 高],均为 ≥ 2 的整数。整个采集过程保持原图坐标系不变。
eyes_xystatus = ok转正后原图中的 [[x0,y0],[x1,y1]],不是 320 × 320 裁剪图坐标。坐标须有限且不越界,按 x 升序排列,x1 − x0 ≥ 2 像素。COLOR 同样必填。
imagestatus = ok恰好三个键:part、encoding、sha256。引用的文件字段必须存在,且只能使用一次。
phase仅 COLOR;必填whiteredgreenblue
display_time_us仅 COLOR;选填非负阶段出光时间戳,不晚于本帧曝光,且严格晚于上一阶段曝光。与 timestamp_us 使用同一时钟。
序列采集、失败帧与炫彩时序

rgb_sequence 上传有序裁剪图,并保留不含图像数据的 face_unavailable / dropped 事件。源帧断号、无脸、几何突变或 CNN 分数跳变都可能重置最终窗口。20 帧是窗口上限,并不保证最终有效窗口完整。末帧失败会返回 retry。

frames[] 中的一个事件
{"source_index": 7, "timestamp_us": 233331, "status": "face_unavailable"}

COLOR 按白、红、绿、蓝顺序上传原始裁剪图。若中途中断,可上传已采集的前缀阶段并设置 complete=false;不要修改阶段标签或伪造缺失图像。时间戳校验仅排除矛盾,不证明出光与曝光同步或挑战真实性。需在实际设备上验收采集时序。

04

构造并发送完整请求

以下示例假定你已有通过验收的裁剪 PNG 和对应采集元数据。示例坐标与时间仅展示格式,须替换为这些图像对应的原始采集值。辅助程序从文件计算哈希,不执行图像前处理。

  1. 将辅助程序保存为 send_capture.py,并安装 httpx。把下方 input JSON 示例与其引用的裁剪文件放在同一目录。
  2. 为本轮采集设置 API_BASE、API_TOKEN 和新的 IDEMPOTENCY_KEY。网络重试时保留原 key 与完整数据包。
  3. 运行 Python 命令上传;或先使用 --prepare-only,再按 cURL 示例上传。每轮验证选择其中一种方式即可。
完整 multipart 辅助程序 · send_capture.py · 无需依赖 liveness_core

使用可信的本地输入文件。程序根据 input JSON 所在目录读取 image.part,填写 sha256,写出 metadata.json,并上传相同的二进制字节。--prepare-only 仅生成 metadata.json,不发起 API 调用。

send_capture.py
import argparse
import hashlib
import json
import os
from pathlib import Path

import httpx

parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--prepare-only", action="store_true")
args = parser.parse_args()
metadata = json.loads(args.input.read_text(encoding="utf-8"))
captures = metadata["captures"] if "captures" in metadata else [metadata]
module_for = {"rgb_single": "rgb_liveness",
              "rgb_sequence": "rgb_liveness", "color": "color_liveness"}
modules = {module_for[capture["mode"]] for capture in captures}
if len(modules) != 1:
    raise ValueError("Use exactly one module per HTTP request")
module = modules.pop()
parts = {}
for capture in captures:
    for frame in capture["frames"]:
        if "image" not in frame:
            continue
        descriptor = frame["image"]
        name = descriptor["part"]
        if name in parts:
            raise ValueError("Each image.part must be unique")
        data = (args.input.parent / name).read_bytes()
        descriptor["sha256"] = hashlib.sha256(data).hexdigest()
        parts[name] = data
text = json.dumps(metadata, ensure_ascii=False, allow_nan=False)
Path("metadata.json").write_text(text, encoding="utf-8")
if args.prepare_only:
    raise SystemExit(0)

base = os.environ["API_BASE"].rstrip("/")
headers = {
    "Authorization": f"Bearer {os.environ['API_TOKEN']}",
    "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"],
}
files = [("metadata", (None, text))]
files.extend((name, (name, data, "application/octet-stream"))
             for name, data in parts.items())
response = httpx.post(
    f"{base}/v1/modules/{module}/verify",
    headers=headers, files=files, timeout=60,
)
print(response.status_code)
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
response.raise_for_status()
for result in response.json()["results"]:
    if result["status"] == "ok":
        print(result["capture_id"], "alive:", result["alive"])
    else:
        print(result["capture_id"], "retry:", result["reason"])

生成 cURL 上传用的元数据后,不要再修改 PNG 文件,其字节必须与 metadata.json 中的哈希一致。不要手工设置 Content-Type。即便采集仅含失败事件,也需使用 multipart,本程序已按此方式构造。

A. 静默 RGB:完整单帧请求

将 rgb.png 放在 input-rgb.json 同级目录。上传前由 send_capture.py 替换 sha256 占位值。

input-rgb.json
{
  "protocol_version": 1,
  "capture_id": "rgb-capture-001",
  "mode": "rgb_single",
  "capture_profile": "rgb_single_v1",
  "preprocess_profile": "eye_crop_320_v1",
  "complete": true,
  "frames": [{
    "source_index": 0, "timestamp_us": 100000, "status": "ok",
    "source_size": [640, 480],
    "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
    "image": {"part": "rgb.png", "encoding": "png",
              "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
  }]
}
export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-rgb.json --prepare-only
curl --fail-with-body "$API_BASE/v1/modules/rgb_liveness/verify" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -F 'metadata=<metadata.json' \
  -F 'rgb.png=@rgb.png;type=image/png'

以上命令启动一次新验证。网络重试时复用已有 IDEMPOTENCY_KEY,不要再次执行 export 行。使用序列时,将 mode 改为 rgb_sequence、capture_profile 改为 rgb_contiguous_v1,再按序加入帧记录与失败事件。

B. 炫彩:一组完整四阶段采集

准备各补光阶段对应的 white.png、red.png、green.png 和 blue.png。四张裁剪图一起上传,整轮采集计一次。

完整 COLOR 元数据 · input-color.json

下方原图坐标与时序仅为格式示例。请使用逐帧实际采集值;如有真实出光时间,也一并填写 display_time_us。

input-color.json
{
  "protocol_version": 1,
  "capture_id": "color-capture-001",
  "mode": "color",
  "capture_profile": "color_wrgb_v1",
  "preprocess_profile": "eye_crop_320_v1",
  "complete": true,
  "frames": [
    {
      "source_index": 0, "timestamp_us": 200000, "status": "ok",
      "phase": "white", "display_time_us": 0,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
      "image": {"part": "white.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 1, "timestamp_us": 450000, "status": "ok",
      "phase": "red", "display_time_us": 250000,
      "source_size": [640, 480],
      "eyes_xy": [[221.0, 180.0], [341.0, 180.0]],
      "image": {"part": "red.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 2, "timestamp_us": 700000, "status": "ok",
      "phase": "green", "display_time_us": 500000,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 181.0], [340.0, 181.0]],
      "image": {"part": "green.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    },
    {
      "source_index": 3, "timestamp_us": 950000, "status": "ok",
      "phase": "blue", "display_time_us": 750000,
      "source_size": [640, 480],
      "eyes_xy": [[220.0, 180.0], [340.0, 180.0]],
      "image": {"part": "blue.png", "encoding": "png",
                "sha256": "REPLACED_BY_SEND_CAPTURE_PY"}
    }
  ]
}
export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-color.json --prepare-only
curl --fail-with-body "$API_BASE/v1/modules/color_liveness/verify" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
  -F 'metadata=<metadata.json' \
  -F 'white.png=@white.png;type=image/png' \
  -F 'red.png=@red.png;type=image/png' \
  -F 'green.png=@green.png;type=image/png' \
  -F 'blue.png=@blue.png;type=image/png'
05

多组采集,一次请求

将完整 CapturePacket 放入 captures 数组,并一并上传所有引用的图像文件。一次 batch 只能调用一个模块。RGB 单帧与序列可以混合提交,RGB 与 COLOR 不能混在同一个请求中。

由两组采集生成 input-batch.json
import json
from pathlib import Path

captures = []
for folder in ("capture-a", "capture-b"):
    capture = json.loads(Path(folder, "input.json").read_text())
    for frame in capture["frames"]:
        if "image" in frame:
            name = frame["image"]["part"]
            frame["image"]["part"] = f"{folder}/{name}"
    captures.append(capture)
Path("input-batch.json").write_text(json.dumps({"captures": captures}))

确保两组输入的 capture_id 不同。为本次 batch 设置新的 IDEMPOTENCY_KEY,再用同一个 send_capture.py 处理 input-batch.json。带前缀的 part 名须与表单字段名匹配,不能仅依赖上传文件名识别图像。

  • 顶层仅允许 captures 这一个键。请求内所有 capture_id 和 image.part 分别唯一,不允许多余或未引用的文件字段。
  • 推理前先完整校验所有输入,并原子预扣整个批次额度。N 组采集计 N 次;额度不足时整批拒绝。
  • results 与输入顺序一致,各 RGB 采集的时序窗口独立。内部 CNN batch 是服务器配置,不影响 HTTP 协议或计次方式。
06

读取业务结论,而非仅看 HTTP 200

结果含义客户端处理
status: "ok"
alive: true
模型分数严格大于 threshold。在响应所示策略下,活体验证通过。
status: "ok"
alive: false
模型判定该采集为非活体。按活体未通过处理。
status: "retry"未得到结论:alive 与 score 均为 null。采集未完成或末端可用数据不足。读取 reason 并重新采集。使用新的 capture_id 与幂等键;这属于新的一次计次验证。
HTTP 200 响应示例 · 单帧 RGB 采集

下方分数与额度值仅为示例,并非真实模型记录。usage 是按模块列出的额度数组。

application/json
{
  "request_id": "res_example",
  "module_id": "rgb_liveness",
  "charged_units": 1,
  "results": [{
    "capture_id": "rgb-capture-001", "mode": "rgb_single",
    "status": "ok", "alive": true, "score": 0.91, "reason": null,
    "final_window_frames": 0, "window_frame_indices": [],
    "reset_events": [], "single_score": 0.91,
    "temporal_logit": null, "color_logits": null,
    "model_set": "raw_v1", "decision_policy": "single_frame_v1",
    "threshold": 0.5, "min_final_frames": null
  }],
  "usage": [{
    "module_id": "rgb_liveness", "limit": 1000, "period": "day",
    "used": 1, "remaining": 999, "reset_at": "2027-01-02T00:00:00Z"
  }]
}
字段使用说明
request_id追踪标识,同样通过 X-Request-ID 返回。保留此值以便排查与技术支持。
charged_units / usage本次操作对应的次数与额度快照。幂等重放返回原快照;当前用量以 /v1/me 为准。
model_set / decision_policy用于追溯的模型集与决策策略标识。threshold 由服务端决定,客户端不传入。
final_window_frames
window_frame_indices
reset_events
RGB 序列诊断信息:最终窗口长度、实际使用的源帧序号与重置原因。min_final_frames 仅用于序列,其他模式为 null。
single_score
temporal_logit
color_logits
对应模式的诊断分数。业务结论以 status + alive 为准,不要用某个诊断分支输出替代。
07

Token 与清晰的计次规则

每个 /v1 请求均需发送 Authorization: Bearer YOUR_BUSINESS_TOKEN。运营者管理 token 的启停、有效期及各模块授权。账号登录会话不能替代业务 token。

一组采集 = 一次。

一段 RGB 录制计一次,COLOR 四阶段整组计一次,N 组采集计 N 次。活体、非活体及正常 retry 结果均计次。认证失败、格式校验失败、容量不足或额度拒绝不扣次。

额度配置刷新与行为
period: "day"每天 UTC 00:00 刷新,不受设备时区影响。
period: "month"每个自然月第一天 UTC 00:00 刷新。
period: "never"累计额度,不自动刷新。reset_at 为 null。
limit: null不限次数,但仍记录用量。remaining 为 null。
limit: 0模块已授权,但无可用次数。

通过 GET /v1/me 查询当前用量,此类只读查询不消耗算法额度。同周期内修改限额或取消后重新授权不会重置已用次数。生产环境通过 HTTPS 传输 token,管理员凭据不要放进客户端应用。

08

正确重试,避免重复计次

每轮验证生成一个 Idempotency-Key,并与未改动的数据包一起保留,直到获取最终结果。key 在当前 token 内唯一,允许 1–200 个无空格的可打印 ASCII 字符,推荐使用 UUID。

  • 超时或断线:使用相同的 key、模块、元数据和文件字节重新发送。连接断开后,已受理的推理仍可能继续完成。
  • 已完成请求的重放:返回已保存响应,带 Idempotency-Replayed: true,不重复推理或扣次。JSON 空白与键顺序可以不同,但逻辑输入必须一致。
  • 新图像或重新采集:使用新的 key 与 capture_id。重放原来的 retry 结果不会进行新验证。不提供 key 时,每次 HTTP 调用独立处理,可能再次扣次。
错误响应结构
{
  "error": {"code": "quota_exceeded", "message": "Quota exceeded for module: rgb_liveness"},
  "request_id": "example-request-id"
}
HTTP / code处理方式
400
invalid_idempotency_key
invalid_content_length
修正不合法的请求头,不要反复发送相同错误请求。
401 invalid_token检查 Bearer token。可能缺失、无效、过期、停用、已吊销或凭据类型错误,请联系运营者。
403 module_forbidden
404 module_not_found
申请对应模块权限,或修正模块 ID。
409 request_in_progress相同 key 的请求仍未完成。按 Retry-After 等待,再使用原 key 和原包重试。
409 idempotency_conflict该 key 已用于不同输入。恢复原包;只有确实属于新一轮验证时才使用新 key。
413 payload_too_large减小请求或 batch 大小,同时遵守服务端与反向代理的大小限制。
415 unsupported_media_type
422 invalid_input
使用 multipart,并根据错误修正元数据、帧顺序、编码、尺寸或哈希。图像编码不合法属于 422 输入错误。
429 quota_exceeded等待额度刷新或联系运营者增加额度。day/month 额度附带 Retry-After;never 额度不会自动恢复。
503 server_busy
module_unavailable
遵守 Retry-After,采用有上限的指数退避与随机延迟,复用原包和 key 重试。module_disabled 则需要运营者启用模块。
500 inference_failed预扣额度已退回。稍后可使用同 key 重试;持续失败时携带 request_id 联系运营者。
500 settlement_failed结算需要运营者恢复,预扣可能仍处于 pending。保留原 key 并联系运营者,不要重复发起新操作。
500 internal_error保留数据包、key 和 request_id。安全重试必须复用原 key;持续失败需运营者排查。
09

了解服务边界

以下数值反映当前部署配置。请求体上限包含 multipart 开销,反向代理还可能施加更小的限制。较大批次应先拆分再上传。

32 MiB最大 HTTP 请求体
1 MiB单图 / metadata 字段
32每请求最大采集组数
256每请求最大事件总数
128单组采集最大事件数
2每个 API worker 同时受理数
部署配置当前值
已启用模块rgb_liveness, color_liveness
CNN 执行方式 / 内部最大 batchsequential / 4
RGB 序列最小最终窗口1

单帧 RGB 最多一个事件,COLOR 最多四个。RGB 窗口最多 20 帧。最小窗口为 1 仅保持原始数值基线,并不代表经过标定的生产采集策略。更严格的采集要求需与运营者约定;序列最小窗口不限制单帧模式。

服务不建立无限推理等待队列。容量槽覆盖上传、准备和执行阶段,超出时返回 503。数据库保存 token 哈希、用量账本、小型结果 JSON 及请求审计元数据(包括 IP 和近似国家)。指定测试 Token 的完整上传图像、metadata 与验证结果另按 UTC 日期归档,其他 Token 默认不保存图像。具体归档与保留规则请与运营者确认。

10

服务管理

运营者使用管理员账号密码登录,管理用户及其归属的业务 token、模块权限、有效期和额度。普通用户登录目前关闭;管理 API 使用会话 Cookie 和 CSRF 验证,业务 token 不具备管理权限。新创建的业务 token 明文仅完整显示一次。

接口用途
GET /health/live
GET /health/ready
无需认证的进程存活与模型就绪检查;未就绪时 ready 返回 503。
/admin打开管理控制台 ↗
/admin/api/users
/admin/api/modules
/admin/api/tokens
通过管理员登录会话访问用户、模块与 token 管理。具体方法及数据结构见交互式参考。
11

安卓接入指南

Android 本地运行 InspireFace,负责人脸分析、相机采集、裁剪、联网和界面状态。沿用原 Demo 的录制交互,完成一轮合格采集后一次上传,由云端 RAW 模型判断活体。

交付边界是准备完成的 CapturePacket。

相机回调和 SDK 人脸 token 不进入在线接口。Android 提交原始 320 × 320 裁剪图与源帧信息;归一化、炫彩差分及模型分数由后端计算。

1. 完成一轮采集

项目静默 RGB炫彩活体
模块rgb_livenesscolor_liveness
modergb_sequencecolor
capture_profilergb_contiguous_v1color_wrgb_v1
提交条件20 张连续有效帧4 张有效帧:white → red → green → blue(白 → 红 → 绿 → 蓝)
一轮计次整组 20 图计 1 次整组 4 图计 1 次
  • RGB:保留真实源帧号并逐张加 1,起点可为任意非负编号。收集一个连续有效段;本地人脸、连续性或几何检查失败时重新收集该段,不拼接断开的有效帧。
  • 两种模式均使用 eye_crop_320_v1。裁剪图转正且不镜像。eyes_xy 是当前帧原图坐标系中的两个眼点,按 x 排序;source_size 是转正原图宽高,不是 320 × 320。
  • 使用统一单调时钟,以 ns / 1000L 转为整数微秒。timestamp_us 必须严格递增;不要把系统日期时间与相机、屏幕的 elapsed 时间混用。
  • COLOR:源帧号只需严格递增;phase 对应实际 WRGB 阶段。display_time_us 可省略;如提供,须晚于上一阶段曝光且不晚于本阶段曝光。RGB 帧不带 phase 和 display_time_us。

20 帧是本版 Android 的正常提交规格,通用 core 仍支持变长输入。CNN 分数变化仍可能缩短最终 GRU 窗口,应读取响应中的 final_window_frames;Android 无需本地运行云端活体模型来提前预测该规则。

匹配当前 RGB 几何规则:d_old、d_new 为相邻两帧原图眼距,m_old、m_new 为对应双眼中点。需满足 0.65 ≤ d_new / d_old ≤ 1.5,且 distance(m_new, m_old) / d_old ≤ 0.6。同轮 source_size 固定,不满足时重收连续段。眼点须为有限数值、位于原图范围内,并按 x 排序,水平间距至少 2 像素。

eye_crop_320_v1 可复用原 arcface/android/flash_lab 示例中 ModelMath.java 的 ModelMath.crop(),并以 ModelMathTest.java 的冻结样例作为对齐起点。输入该帧本地 InspireFace 得到的原图眼点,验证裁剪像素一致;下方上传示例从精确裁剪完成后开始。

2. 生成 RGB8 并填写帧信息

首轮联调使用 rgb8:每图 307200 字节,行优先 RGB,每像素三个字节。从约定裁剪图的 ARGB int 数组中显式取出三通道,可避开 PNG alpha 和字节序差异;不要经过 RGB565。

AndroidRgb8.java
public final class AndroidRgb8 {
    private AndroidRgb8() {}

    // Input: the agreed 320 x 320 crop as ARGB int pixels.
    public static byte[] fromArgb(int[] cropArgb) {
        if (cropArgb.length != 320 * 320) {
            throw new IllegalArgumentException("Expected a 320 x 320 crop");
        }
        byte[] rgb = new byte[307200];
        int offset = 0;
        for (int pixel : cropArgb) {
            rgb[offset++] = (byte) (pixel >>> 16); // R
            rgb[offset++] = (byte) (pixel >>> 8);  // G
            rgb[offset++] = (byte) pixel;         // B
        }
        return rgb;
    }
}

每帧填写一条元数据。下面仅是一条 RGB 帧记录,并非完整 20 帧请求。坐标、尺寸、序号与时间必须使用本地实际观测值。Java 请求构造器会计算 image.sha256,示例占位文本不能作为哈希发送。

frames[] 中的一条记录
{
  "source_index": 340,
  "timestamp_us": 1200000,
  "status": "ok",
  "source_size": [
    640,
    480
  ],
  "eyes_xy": [
    [
      180.5,
      160.25
    ],
    [
      300.5,
      161.0
    ]
  ],
  "image": {
    "part": "f0340.rgb",
    "encoding": "rgb8",
    "sha256": "CALCULATED_BY_THE_JAVA_BUILDER_FROM_UPLOADED_BYTES"
  }
}

COLOR 的每条记录还需增加 phase,有实际显示时间时再加 display_time_us。四张图都要保留 source_size 和 eyes_xy。metadata 只包含协议字段,SDK 对象和设备诊断数据在 Android 侧另存。

3. 构造可重试的一次上传

以下 Java 示例使用 Android 的 org.json 与 OkHttp,复用 Android 项目已有 HTTP client。preparedFrames 是上述帧记录的有序数组,rgb8Parts 按 image.part 保存对应 RGB8 字节。裁剪转换、哈希和组包放在工作线程执行。

Java / OkHttp 请求构造器:展开复制
AndroidCaptureRequest.java
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.json.JSONArray;
import org.json.JSONObject;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.Request;
import okhttp3.RequestBody;

public final class AndroidCaptureRequest {
    private AndroidCaptureRequest() {}

    // Build ONCE after a complete recording. Keep this Request for retries.
    // preparedFrames contain the fields shown below; rgb8Parts is keyed by image.part.
    public static Request buildRgb8(
            String apiBase, String businessToken, String mode,
            JSONArray preparedFrames, Map<String, byte[]> rgb8Parts) throws Exception {
        boolean color = "color".equals(mode);
        if (!color && !"rgb_sequence".equals(mode)) {
            throw new IllegalArgumentException("Use rgb_sequence or color");
        }
        int count = color ? 4 : 20;
        if (preparedFrames.length() != count) {
            throw new IllegalArgumentException("Capture is not complete");
        }
        String module = color ? "color_liveness" : "rgb_liveness";
        String profile = color ? "color_wrgb_v1" : "rgb_contiguous_v1";
        String[] phases = {"white", "red", "green", "blue"};
        String captureId = UUID.randomUUID().toString();
        JSONArray frames = new JSONArray(preparedFrames.toString());
        MultipartBody.Builder multipart = new MultipartBody.Builder()
                .setType(MultipartBody.FORM);
        Set<String> used = new HashSet<>();
        long lastIndex = -1, lastTimeUs = -1;

        for (int i = 0; i < count; i++) {
            JSONObject row = frames.getJSONObject(i);
            long index = row.getLong("source_index");
            long timeUs = row.getLong("timestamp_us");
            if (!"ok".equals(row.getString("status")) || index <= lastIndex
                    || timeUs <= lastTimeUs || (!color && i > 0 && index != lastIndex + 1)) {
                throw new IllegalArgumentException("Invalid frame status, order or continuity");
            }
            if (color) {
                if (!phases[i].equals(row.getString("phase"))) {
                    throw new IllegalArgumentException("Expected white/red/green/blue");
                }
                if (row.has("display_time_us")) {
                    long displayed = row.getLong("display_time_us");
                    if (displayed < 0 || displayed > timeUs || (i > 0 && displayed <= lastTimeUs)) {
                        throw new IllegalArgumentException("Invalid phase timestamps");
                    }
                }
            } else if (row.has("phase") || row.has("display_time_us")) {
                throw new IllegalArgumentException("RGB must omit color-phase fields");
            }
            lastIndex = index;
            lastTimeUs = timeUs;

            JSONObject image = row.getJSONObject("image");
            String part = image.getString("part");
            byte[] source = rgb8Parts.get(part);
            if (!used.add(part) || source == null || source.length != 307200) {
                throw new IllegalArgumentException("Missing, duplicate or invalid RGB8 part");
            }
            byte[] snapshot = source.clone(); // Keep retries independent of capture buffers.
            image.put("encoding", "rgb8");
            image.put("sha256", sha256(snapshot));
            multipart.addFormDataPart(part, part,
                    RequestBody.create(MediaType.parse("application/octet-stream"), snapshot));
        }
        if (used.size() != rgb8Parts.size()) {
            throw new IllegalArgumentException("Unreferenced image parts");
        }
        JSONObject packet = new JSONObject()
                .put("protocol_version", 1)
                .put("capture_id", captureId)
                .put("mode", mode)
                .put("capture_profile", profile)
                .put("preprocess_profile", "eye_crop_320_v1")
                .put("complete", true)
                .put("frames", frames);
        multipart.addFormDataPart("metadata", packet.toString());
        String base = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
        return new Request.Builder()
                .url(base + "/v1/modules/" + module + "/verify")
                .header("Authorization", "Bearer " + businessToken)
                .header("Idempotency-Key", captureId)
                .post(multipart.build())
                .build();
    }

    private static String sha256(byte[] data) throws Exception {
        byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
        char[] hex = "0123456789abcdef".toCharArray();
        StringBuilder out = new StringBuilder(64);
        for (byte b : digest) {
            out.append(hex[(b & 0xff) >>> 4]);
            out.append(hex[b & 0x0f]);
        }
        return out.toString();
    }
}
// Upload asynchronously with your existing OkHttpClient and Callback:
// Request frozen = AndroidCaptureRequest.buildRgb8(API_BASE, businessToken, mode, frames, parts);
// client.newCall(frozen).enqueue(callback);
// Retry: create a NEW Call using the SAME frozen Request.
// New recording: call buildRgb8 again to create a new capture_id / Idempotency-Key.
保留准备好的请求,直到本轮操作结束。

每轮新录制只调用一次构造器。超时或断线后,基于同一个 Request 新建 OkHttp Call,保留原 key、元数据与图像字节。重新构造数据包会生成新 key,可能再次计费。由 MultipartBody 生成 Content-Type 和 boundary;metadata 使用两个参数的 addFormDataPart 文本字段重载,不带 filename。

4. 完成 Android 结果处理

响应Android 处理
HTTP 200 · status=ok按 capture_id 关联本轮操作,再读取 alive 和 score。HTTP 200 本身不代表活体通过。
HTTP 200 · status=retry用新的 capture_id 和 key 重新采集;score/ alive 为 null。正常 retry 结果也计一次。
401 / 403 / 429分别处理凭据、模块权限或额度,不显示为非活体。day 额度在 UTC 00:00 刷新。
409 / 503 / network timeout遵循 Retry-After 与错误章节的说明。同一轮验证重试时保留原 key 和字节。

建议本地状态:Ready → Preparing → Capturing → Encoding → Uploading → Result / Retry / Error。迟到回调只处理其原始 capture。配置业务 token 时可先调用 GET /v1/me 和 GET /v1/modules;这些查询不消耗验证次数。

5. Android 接入验收清单

  • 先用固定 RGB8 数据包验证字段、通道顺序、哈希和服务结果,再接本地 InspireFace 与相机采集。
  • 分别验收 RGB20 与 COLOR4,包括采集中断后重采,以及同请求重试不重复扣次。
  • Android Manifest 需启用 INTERNET。开发时针对测试端点配置 HTTP 访问,正式使用部署的 HTTPS 端点。业务 token 由 App 配置提供,日志和导出包不含 token;App 不保存管理员账号凭据。
  • 在目标设备验收曝光/阶段时序与前处理性能。本地质量筛选、生命周期处理和重试界面由 Android 实现。