静默活体
支持单图或录制序列。云端运行 RAW RGB 推理,序列模式额外对最终时间窗口计算时序结果。
rgb_liveness设备端采集,云端完成验证。通过统一、轻量的 REST 接口接入静默 RGB 活体与炫彩活体,按模块授权,按验证组计次。
https://api.inspirehub.cc接入时请使用客户端可访问的服务器地址或 HTTPS 域名。0.0.0.0 是服务监听地址,不是远程客户端的请求目标。
先向服务运营者获取业务 token,再查询已授权模块和可用额度。一个 token 可以拥有一个或多个算法模块的调用权限。
InspireFace 提取眼点,按约定生成 320 × 320 裁剪图。
JSON 元数据与无损图像二进制文件一起上传。
先检查 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"import os
import httpx
base = os.environ["API_BASE"].rstrip("/")
headers = {"Authorization": f"Bearer {os.environ['API_TOKEN']}"}
with httpx.Client(base_url=base, headers=headers, timeout=30) as client:
for path in ("/v1/me", "/v1/modules"):
response = client.get(base + path)
response.raise_for_status()
print(response.json())Python 示例需要 Python 3.10+ 与 httpx(pip install httpx)。运行前先在环境变量中设置 API_BASE 和 API_TOKEN。此文档页面不收集或保存任何 token。
支持单图或录制序列。云端运行 RAW RGB 推理,序列模式额外对最终时间窗口计算时序结果。
rgb_liveness一次采集包含白、红、绿、蓝四个补光阶段。云端计算相邻帧差分,并融合两个 RAW CNN 分支的输出。
color_liveness/v1/modules/{module_id}/verify| 模块 | mode | capture_profile | 输入 |
|---|---|---|---|
rgb_liveness | rgb_single | rgb_single_v1 | 一张裁剪图 |
rgb_liveness | rgb_sequence | rgb_contiguous_v1 | 有序帧与失败事件 |
color_liveness | color | color_wrgb_v1 | white → red → green → blue(白 → 红 → 绿 → 蓝) |
可调用性由两层控制:服务器必须启用该模块,且你的 token 必须获授权。GET /v1/modules 返回已授权模块及其部署状态。
使用 multipart/form-data。metadata 是内容为 JSON 的文本字段;每张图像作为二进制文件字段,其表单字段名必须与 image.part 完全一致。让 HTTP 库自动生成 Content-Type 和 boundary。
接入前,需在移动 SDK 中实现并验收约定的 eye_crop_320_v1 前处理。Core 接收已完成裁剪的图像,不执行人脸检测、对齐或裁剪。通用人脸框缩放不能视为等价实现。整轮采集须保持旋转和镜像处理一致。
| 编码 | 要求 |
|---|---|
png | 320 × 320、8-bit truecolor RGB(PNG color type 2),无 alpha、非调色板格式。无损,推荐用于缩小上传体积。 |
rgb8 | 恰好 307200 字节:连续 HWC 排列的 RGB uint8 像素。无文件头,不交换通道,不做归一化或浮点转换。 |
不上传 JPEG、视频、Base64 或浮点数组。炫彩活体上传四张原始裁剪图,差分由云端计算。对实际上传的字节计算 SHA-256,并使用小写十六进制字符串;该哈希校验完整性,不证明采集真实性。
| 字段 | 约定 |
|---|---|
protocol_version | 整数 1。 |
capture_id | 1–128 字符的字符串,批次内唯一。每轮新采集使用新 ID。 |
mode | rgb_single / rgb_sequence / color |
capture_profile | 与 mode 精确匹配的采集协议,见模块表。 |
preprocess_profile | eye_crop_320_v1 |
complete | 布尔值。仅在约定的采集条件完成后设为 true。false 会产生正常的 retry 结果。 |
frames | 按顺序排列的事件数组。数据包不接受未知字段、标签、阈值或模型选择参数。 |
| 字段 | 必填条件 | 约定 |
|---|---|---|
source_index | 始终 | 非负源帧整数序号,严格递增。保留断号,不要将筛选后的帧重新编号。 |
timestamp_us | 始终 | 同一采集时钟下的非负整数曝光时间戳,单位微秒,严格递增;无需使用 Unix 时间。 |
status | 始终 | ok / face_unavailable / dropped |
source_size | status = ok | 转正后原图的 [宽, 高],均为 ≥ 2 的整数。整个采集过程保持原图坐标系不变。 |
eyes_xy | status = ok | 转正后原图中的 [[x0,y0],[x1,y1]],不是 320 × 320 裁剪图坐标。坐标须有限且不越界,按 x 升序排列,x1 − x0 ≥ 2 像素。COLOR 同样必填。 |
image | status = ok | 恰好三个键:part、encoding、sha256。引用的文件字段必须存在,且只能使用一次。 |
phase | 仅 COLOR;必填 | white → red → green → blue |
display_time_us | 仅 COLOR;选填 | 非负阶段出光时间戳,不晚于本帧曝光,且严格晚于上一阶段曝光。与 timestamp_us 使用同一时钟。 |
rgb_sequence 上传有序裁剪图,并保留不含图像数据的 face_unavailable / dropped 事件。源帧断号、无脸、几何突变或 CNN 分数跳变都可能重置最终窗口。20 帧是窗口上限,并不保证最终有效窗口完整。末帧失败会返回 retry。
{"source_index": 7, "timestamp_us": 233331, "status": "face_unavailable"}COLOR 按白、红、绿、蓝顺序上传原始裁剪图。若中途中断,可上传已采集的前缀阶段并设置 complete=false;不要修改阶段标签或伪造缺失图像。时间戳校验仅排除矛盾,不证明出光与曝光同步或挑战真实性。需在实际设备上验收采集时序。
以下示例假定你已有通过验收的裁剪 PNG 和对应采集元数据。示例坐标与时间仅展示格式,须替换为这些图像对应的原始采集值。辅助程序从文件计算哈希,不执行图像前处理。
使用可信的本地输入文件。程序根据 input JSON 所在目录读取 image.part,填写 sha256,写出 metadata.json,并上传相同的二进制字节。--prepare-only 仅生成 metadata.json,不发起 API 调用。
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,本程序已按此方式构造。
将 rgb.png 放在 input-rgb.json 同级目录。上传前由 send_capture.py 替换 sha256 占位值。
{
"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'export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-rgb.json以上命令启动一次新验证。网络重试时复用已有 IDEMPOTENCY_KEY,不要再次执行 export 行。使用序列时,将 mode 改为 rgb_sequence、capture_profile 改为 rgb_contiguous_v1,再按序加入帧记录与失败事件。
准备各补光阶段对应的 white.png、red.png、green.png 和 blue.png。四张裁剪图一起上传,整轮采集计一次。
下方原图坐标与时序仅为格式示例。请使用逐帧实际采集值;如有真实出光时间,也一并填写 display_time_us。
{
"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'export IDEMPOTENCY_KEY="$(python -c 'import uuid; print(uuid.uuid4())')"
python send_capture.py input-color.json将完整 CapturePacket 放入 captures 数组,并一并上传所有引用的图像文件。一次 batch 只能调用一个模块。RGB 单帧与序列可以混合提交,RGB 与 COLOR 不能混在同一个请求中。
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 名须与表单字段名匹配,不能仅依赖上传文件名识别图像。
| 结果 | 含义 | 客户端处理 |
|---|---|---|
status: "ok"alive: true | 模型分数严格大于 threshold。 | 在响应所示策略下,活体验证通过。 |
status: "ok"alive: false | 模型判定该采集为非活体。 | 按活体未通过处理。 |
status: "retry" | 未得到结论:alive 与 score 均为 null。采集未完成或末端可用数据不足。 | 读取 reason 并重新采集。使用新的 capture_id 与幂等键;这属于新的一次计次验证。 |
下方分数与额度值仅为示例,并非真实模型记录。usage 是按模块列出的额度数组。
{
"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_frameswindow_frame_indicesreset_events | RGB 序列诊断信息:最终窗口长度、实际使用的源帧序号与重置原因。min_final_frames 仅用于序列,其他模式为 null。 |
single_scoretemporal_logitcolor_logits | 对应模式的诊断分数。业务结论以 status + alive 为准,不要用某个诊断分支输出替代。 |
每个 /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,管理员凭据不要放进客户端应用。
每轮验证生成一个 Idempotency-Key,并与未改动的数据包一起保留,直到获取最终结果。key 在当前 token 内唯一,允许 1–200 个无空格的可打印 ASCII 字符,推荐使用 UUID。
{
"error": {"code": "quota_exceeded", "message": "Quota exceeded for module: rgb_liveness"},
"request_id": "example-request-id"
}| HTTP / code | 处理方式 |
|---|---|
400invalid_idempotency_keyinvalid_content_length | 修正不合法的请求头,不要反复发送相同错误请求。 |
401 invalid_token | 检查 Bearer token。可能缺失、无效、过期、停用、已吊销或凭据类型错误,请联系运营者。 |
403 module_forbidden404 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_type422 invalid_input | 使用 multipart,并根据错误修正元数据、帧顺序、编码、尺寸或哈希。图像编码不合法属于 422 输入错误。 |
429 quota_exceeded | 等待额度刷新或联系运营者增加额度。day/month 额度附带 Retry-After;never 额度不会自动恢复。 |
503 server_busymodule_unavailable | 遵守 Retry-After,采用有上限的指数退避与随机延迟,复用原包和 key 重试。module_disabled 则需要运营者启用模块。 |
500 inference_failed | 预扣额度已退回。稍后可使用同 key 重试;持续失败时携带 request_id 联系运营者。 |
500 settlement_failed | 结算需要运营者恢复,预扣可能仍处于 pending。保留原 key 并联系运营者,不要重复发起新操作。 |
500 internal_error | 保留数据包、key 和 request_id。安全重试必须复用原 key;持续失败需运营者排查。 |
以下数值反映当前部署配置。请求体上限包含 multipart 开销,反向代理还可能施加更小的限制。较大批次应先拆分再上传。
| 部署配置 | 当前值 |
|---|---|
| 已启用模块 | rgb_liveness, color_liveness |
| CNN 执行方式 / 内部最大 batch | sequential / 4 |
| RGB 序列最小最终窗口 | 1 |
单帧 RGB 最多一个事件,COLOR 最多四个。RGB 窗口最多 20 帧。最小窗口为 1 仅保持原始数值基线,并不代表经过标定的生产采集策略。更严格的采集要求需与运营者约定;序列最小窗口不限制单帧模式。
服务不建立无限推理等待队列。容量槽覆盖上传、准备和执行阶段,超出时返回 503。数据库保存 token 哈希、用量账本、小型结果 JSON 及请求审计元数据(包括 IP 和近似国家)。指定测试 Token 的完整上传图像、metadata 与验证结果另按 UTC 日期归档,其他 Token 默认不保存图像。具体归档与保留规则请与运营者确认。
运营者使用管理员账号密码登录,管理用户及其归属的业务 token、模块权限、有效期和额度。普通用户登录目前关闭;管理 API 使用会话 Cookie 和 CSRF 验证,业务 token 不具备管理权限。新创建的业务 token 明文仅完整显示一次。
| 接口 | 用途 |
|---|---|
GET /health/liveGET /health/ready | 无需认证的进程存活与模型就绪检查;未就绪时 ready 返回 503。 |
/admin | 打开管理控制台 ↗ |
/admin/api/users/admin/api/modules/admin/api/tokens | 通过管理员登录会话访问用户、模块与 token 管理。具体方法及数据结构见交互式参考。 |
Android 本地运行 InspireFace,负责人脸分析、相机采集、裁剪、联网和界面状态。沿用原 Demo 的录制交互,完成一轮合格采集后一次上传,由云端 RAW 模型判断活体。
相机回调和 SDK 人脸 token 不进入在线接口。Android 提交原始 320 × 320 裁剪图与源帧信息;归一化、炫彩差分及模型分数由后端计算。
| 项目 | 静默 RGB | 炫彩活体 |
|---|---|---|
| 模块 | rgb_liveness | color_liveness |
| mode | rgb_sequence | color |
| capture_profile | rgb_contiguous_v1 | color_wrgb_v1 |
| 提交条件 | 20 张连续有效帧 | 4 张有效帧:white → red → green → blue(白 → 红 → 绿 → 蓝) |
| 一轮计次 | 整组 20 图计 1 次 | 整组 4 图计 1 次 |
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 得到的原图眼点,验证裁剪像素一致;下方上传示例从精确裁剪完成后开始。
首轮联调使用 rgb8:每图 307200 字节,行优先 RGB,每像素三个字节。从约定裁剪图的 ARGB int 数组中显式取出三通道,可避开 PNG alpha 和字节序差异;不要经过 RGB565。
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,示例占位文本不能作为哈希发送。
{
"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 侧另存。
以下 Java 示例使用 Android 的 org.json 与 OkHttp,复用 Android 项目已有 HTTP client。preparedFrames 是上述帧记录的有序数组,rgb8Parts 按 image.part 保存对应 RGB8 字节。裁剪转换、哈希和组包放在工作线程执行。
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。
| 响应 | 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;这些查询不消耗验证次数。