岗位区域有没有人,要看一张图的检测结果,并且连续几帧都空,再由你的业务服务发出告警。客流相机上报的是人数和滞留,动检上报的是画面变化。三条结果各有接口。把其中一条的静默当成离岗,收银台空着的时候群里不会响,灯闪一下倒会把计数清掉。
夜班收银台空了十几分钟,列表里只剩动检
便利店规定收银岗营业时段留人。周二夜里,收银员去后仓补货,收银台空了十几分钟。第二天店长把回放拉给开发看,业务后台的消息列表里只有几条动检:冰柜灯亮起一次,门口有人路过一次。人离开岗位的那一段,没有任何一条写着「收银岗无人」。
开发当时的接法是:通道一段时间没有 videoMotion,就当作离岗。这个接法回答的是「画面有没有变化」。冰柜灯闪、门口有人走过,计时被清零,岗位其实是空的。收银员站着对账、画面几乎不动,计时却会涨上去。店长要的是岗位框里有没有员工,不是像素动了没有。
另一家门店已经装了客流相机。进店人数每天都有,人员滞留也会推上来。店长问:人数归零的时候能不能直接当离岗。可以往下做,但先把消息类型拆开。滞留是人停留过久,和岗位空着是相反的两件事。
一次调用只回答这一帧有没有员工
云端这条链路的方法名是 absenceDetect,说明在《离岗检测》。调用方上传一张图片,返回 detectResult:true 表示检测到员工、这一帧在岗;false 表示未检测到员工、这一帧离岗。它是同步接口,结果在当次响应里,不会自动变成一条设备告警。
《离岗检测》写明该能力是按需订阅的增值服务。测试额度通过工单或商务咨询申请,验证后再付费使用。没开通时接口会失败,失败不等于现场有人,也不等于现场无人。
入参里和岗位相关的是这些:
| 参数 | 文档口径 |
|---|---|
token |
管理员 accessToken |
type |
"0" 图片 URL,"1" Base64 |
content |
与 type 对应的地址或 Base64 |
threshold |
检测阈值,范围 (0, 1]。文档样例为 0.8 |
detectRegions |
可选。最多 3 个区域,每个区域 3 到 6 个点,坐标归一化到 0, 1,再转成该区域的最大外接矩形 |
repositoryId |
可选。人形工装检测目标库 ID。传入后,从该库判断图中有没有匹配工装的人员 |
区域要画在岗位上,例如收银台内侧,而不是整间店。框进过道,路过的人会把这一帧打成在岗。需要把「任意被检出的员工」收紧到「指定工装」时,再传 repositoryId。库的创建和注册在《AI服务说明》里,方法是 createAiDetectRepository、addAiDetectTarget。不传该字段时,按《离岗检测》的返回说明理解 detectResult。顾客站进框内会不会被算成在岗,用开通后的回放核对,不要靠改告警文案弥补。
单帧 false 还不是店长要的那条消息。员工弯腰、被货架挡住、低头看手机,都可能让某一帧检不出人。业务侧自己保留连续空帧计数,达到约定次数再通知。次数和间隔是岗位配置,接口没有默认值班表。
图片从设备来。性能较好的设备用 setDeviceSnapEnhanced:客户端请求间隔大于等于 1 秒,短于 1 秒会拿到上一张图;返回的 url 2 小时内有效。2017 年前上市、处理抓图较慢的设备,文档建议改用 setDeviceSnap,间隔大于等于 3 秒。用哪一个,以该设备实测为准。type 为 "0" 时,把这次返回的 url 填进 absenceDetect 的 content,并在有效期内完成检测。图片只在内网能打开时,改为 type "1",content 传 Base64。
《AI服务说明》把 absenceDetect 放在行业场景一组,同组还有抽烟、玩手机、货架、垃圾满溢、工装和热力。humanDetect 是另一条人形分析,返回字段不同,不能拿来代替 detectResult。
人数、滞留、人形、空岗,各看各的结果
动手前用四次核对决定走抓图检测,还是走客流消息。
核对服务开了没有。 absenceDetect 未开通,抓图成功也得不到 detectResult。先申请额度,用一张在岗图和一张空岗图各调一次,确认 true / false 与肉眼一致,再接入值班循环。
核对这一帧的图是不是岗位。 detectRegions 只覆盖岗位。全幅画面会把过道、门口算进去。点位不足 3 个,或超过 6 个,按《离岗检测》的区域说明修正后再测。阈值从样例 0.8 起步,用误报和漏报回放调整,范围仍是 (0, 1]。
核对客流相机能回答的是哪一种人数。 《设备能力集》里,NumberStat 是过线客流量,ManNumDec 是区域客流量。过线统计的是进出,岗位上站着不动的人不会形成一条新的过线。区域客流才和「这块区域里有没有人」接近。II 类能力在单通道设备上标在设备能力集,在多通道设备上标在通道能力集。通道字符串里没有 ManNumDec,就不要用客流消息做这个岗位的空岗判断。有没有这个能力,以 listDeviceDetailsByPage 返回的 deviceAbility、channelAbility 为准。本文不按型号承诺某台相机一定带区域客流或一定能抓图。
核对推上来的消息类型。 订阅大类 callbackFlag=numberstat 之后,《事件消息类型定义》里对应两种 msgType:numberstat 是客流量上报,manyStay 是人员滞留。滞留表示人留在区域里,不能累加进空岗计数。人数字段以《事件消息格式定义》里该类型的样例为准,联调时先落原始报文再映射。映射完成之前,人数持续为 0 的判断先不要上线。
设备告警是第三条线,大类是 alarm。human 是人形检测报警,表示检出了人。videoMotion 是动态检测,表示画面有变化。一段时间内没有 human,可能是岗位真的空了,也可能是角度看不到、策略没开,或回调里根本没订 alarm。它可以给回放对照,不能单独作为离岗结论。HeaderDetect(人头检测)同样只说明设备具备人头检测能力,不是空岗结果。
空岗告警在业务里成立,需要同时满足:当前时刻落在该岗位的值班时段内;这一帧的抓图和检测都成功;detectResult === false;这样的空帧连续达到岗位配置的次数。抓图失败、设备离线、检测接口返回非 0,这一轮跳过,不增加空帧计数。
客流相机若已经能稳定给出区域内人数,空岗也可以定义成:值班时段内,区域人数按报文持续为 0,达到同一套连续次数。这仍然是业务判断。平台没有把 numberstat 定义成离岗告警。
服务开通并且通道在线,再决定抓图还是订客流
设备、抓图、客流消息和图片检测走同一套开放接口。应用在乐橙云开放平台创建之后,在控制台「我的应用 → 应用信息」取得 appId 和 appSecret,只放在服务端。设备添加到这个开发者账号,通道在线,后续查询才有对象。接入额度以控制台「我的资源」为准。
请求统一是 HTTP POST,地址形如 https://openapi.lechange.cn/openapi/{method}。请求体分 system、id、params。签名按《开发规范》:原始串 time:{time},nonce:{nonce},appSecret:{appSecret};password 为 appSecret 的 SHA-256 小写十六进制;sign 为 HMAC-SHA256 后再 Base64。time 与服务器相差超过 5 分钟返回 SN1002,nonce 在 5 分钟内重复返回 SN1005。管理员 token 用方法 accessToken 获取,expireTime 单位是秒,按返回值缓存。过期或不存在时返回 TK1002。
《离岗检测》要求管理员 accessToken。抓图接口允许子账号,最小权限是 Capture,资源形式为 cam:序列号:通道号。空岗这条链路用管理员 token 即可,浏览器不保存 token。
岗位本身写在业务配置里:哪台设备的哪个通道、框的四个点、值班时段、连续几次空帧才通知。平台设备列表没有「收银岗」这个对象。listDeviceDetailsByPage 用来核对序列号、通道号、在线状态和能力集,page 从 1 开始,pageSize 为 1~50。
设备侧消息如果也要进同一套后台,用 setMessageCallback 设置账号的回调。status 为 on 时,callbackUrl 和 callbackFlag 必填。地址必须能从外网访问。callbackFlag 可组合 alarm、deviceStatus、numberstat、iot、faceAnalysis,逗号分隔。客流必须包含 numberstat,只订 alarm 收不到客流。basePush 为 "1" 时推送该账号下关联的乐橙 App 设备消息,"2" 为不推送,默认是 "2"。开发者后台若不需要 App 侧消息,保持 "2"。
这个接口设置的是开发者账号当前这一份回调。改之前先 getMessageCallback,把仍要接收的大类拼进同一次 callbackFlag。只提交 numberstat,原先的 alarm 不会自动留下。控制台「消息推送服务」和这个接口是同一份配置,两边不要各改各的。回调地址暂时无法对外时,可查《消息通道订阅(拉取模式)》的 pullMessages。拉取到的 msgBody 与推送载荷一致,里面同样没有 absenceDetect 的返回值。空岗仍要自己定时抓图检测。
text
业务配置:deviceId + channelId + 岗位框 + 值班时段
│
▼
deviceOnline 在线?
│否 │是
▼ ▼
本轮跳过 setDeviceSnapEnhanced
不计空岗 │失败(DV1007 / DV1053 / DV1010)
▼
本轮跳过,不计空岗
│成功,url 2 小时内有效
▼
absenceDetect
│
detectResult true ┼ false
计数清零 │ 计数 +1,到阈值后业务服务发告警
│
另一路(可选):callbackFlag 含 numberstat
msgType=numberstat 按格式页映射人数
msgType=manyStay 只归档,不进空岗计数
告警文案、接收人、电话或短信,由业务服务在连续判定成立时发送。《离岗检测》的返回体里没有通知对象。不要等 getAlarmMessage 里出现一条类型叫离岗的记录。那组接口查询的是设备告警。
连续空帧之后,由业务服务通知店长
下面是 Node.js 18+。岗位表、连续次数和轮询间隔都是业务参数。示例用 60 秒一轮、连续 3 次空帧再通知,大约 3 分钟。间隔必须大于抓图接口的最小间隔,否则 DV1010(1 秒内同一设备多次抓图)会失败,短于 1 秒时增强抓图还可能重复返回上一张图,把同一帧算成多次空岗。
一次轮询至少两次开放接口调用:抓图和检测。另加在线状态查询时是三次。《全局返回码说明》写明:未付费账号每分钟 20 次、每天 10000 次;付费账号每分钟 500 次、每天 250000 次。超出分别是 OP1039、OP1040。岗位变多时把各岗位的启动时间错开,而不是把间隔压到 1 秒。
javascript
const crypto = require('crypto');
const http = require('http');
const OPENAPI_BASE = 'https://openapi.lechange.cn/openapi';
const posts = {
CASHIER_01: {
name: '湖滨店收银岗',
deviceId: 'DEVICE_SERIAL',
channelId: '0',
snapMethod: 'setDeviceSnapEnhanced',
threshold: 0.8,
// 可选。需要按工装库判断时再填 createAiDetectRepository 返回的库 ID
repositoryId: '',
region: [
{ x: 0.42, y: 0.28 },
{ x: 0.74, y: 0.28 },
{ x: 0.74, y: 0.70 },
{ x: 0.42, y: 0.70 }
],
dutyStart: '08:00',
dutyEnd: '22:00',
missLimit: 3,
missStreak: 0,
alerted: false
}
};
function calcSign(time, nonce, appSecret) {
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
const password = crypto.createHash('sha256').update(appSecret, 'utf8').digest('hex');
return crypto.createHmac('sha256', password).update(raw, 'utf8').digest('base64');
}
let adminToken = { value: '', expireAt: 0 };
async function callOpenApi(method, appId, appSecret, params, allowRetry = true) {
const time = Math.floor(Date.now() / 1000);
const nonce = crypto.randomUUID();
const res = await fetch(`${OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system: {
ver: '1.0',
appId,
sign: calcSign(time, nonce, appSecret),
time,
nonce
},
id: crypto.randomUUID(),
params
})
});
const json = await res.json();
const code = json?.result?.code;
if (code === 'TK1002' && allowRetry && method !== 'accessToken') {
adminToken = { value: '', expireAt: 0 };
const token = await getAdminToken(appId, appSecret);
return callOpenApi(method, appId, appSecret, { ...params, token }, false);
}
if (code !== '0') {
const err = new Error(json?.result?.msg || 'openapi error');
err.code = code;
throw err;
}
return json.result.data || {};
}
async function getAdminToken(appId, appSecret) {
const now = Math.floor(Date.now() / 1000);
if (adminToken.value && adminToken.expireAt - now > 3600) return adminToken.value;
const data = await callOpenApi('accessToken', appId, appSecret, {});
adminToken = {
value: data.accessToken,
expireAt: now + Number(data.expireTime || 0)
};
return adminToken.value;
}
function minutesInShanghai(date) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(date);
const pick = (type) => parts.find((item) => item.type === type).value;
return Number(pick('hour')) * 60 + Number(pick('minute'));
}
function inDuty(post, date) {
const toMin = (hm) => {
const [h, m] = hm.split(':').map(Number);
return h * 60 + m;
};
const cur = minutesInShanghai(date);
const start = toMin(post.dutyStart);
const end = toMin(post.dutyEnd);
if (start <= end) return cur >= start && cur < end;
return cur >= start || cur < end;
}
function hasAbility(deviceAbility, channelAbility, code) {
return `${deviceAbility || ''},${channelAbility || ''}`
.split(',')
.map((item) => item.trim())
.includes(code);
}
async function channelReady(appId, appSecret, post) {
const token = await getAdminToken(appId, appSecret);
const data = await callOpenApi('deviceOnline', appId, appSecret, {
token,
deviceId: post.deviceId
});
if (String(data.onLine) !== '1') return false;
const channel = (data.channels || []).find((item) => {
return String(item.channelId) === String(post.channelId);
});
return !!channel && String(channel.onLine) === '1';
}
async function pollPost(appId, appSecret, post) {
if (!inDuty(post, new Date())) return { state: 'off_duty_hours' };
const online = await channelReady(appId, appSecret, post);
if (!online) return { state: 'skip', reason: 'offline' };
const token = await getAdminToken(appId, appSecret);
let snap;
try {
snap = await callOpenApi(post.snapMethod, appId, appSecret, {
token,
deviceId: post.deviceId,
channelId: String(post.channelId)
});
} catch (err) {
return { state: 'skip', reason: err.code || 'snap_failed' };
}
if (!snap.url) return { state: 'skip', reason: 'no_url' };
const params = {
token,
type: '0',
content: snap.url,
threshold: post.threshold,
detectRegions: [post.region]
};
if (post.repositoryId) params.repositoryId = post.repositoryId;
const detected = await callOpenApi('absenceDetect', appId, appSecret, params);
if (typeof detected.detectResult !== 'boolean') {
return { state: 'skip', reason: 'no_detect_result' };
}
if (detected.detectResult) {
post.missStreak = 0;
post.alerted = false;
return { state: 'present' };
}
post.missStreak += 1;
if (post.missStreak >= post.missLimit && !post.alerted) {
post.alerted = true;
// 抓图 url 2 小时内有效。通知里要附图时,这里先下载转存。
console.log(JSON.stringify({
event: 'absence',
name: post.name,
deviceId: post.deviceId,
channelId: post.channelId,
streak: post.missStreak,
frameUrl: snap.url,
at: new Date().toISOString()
}));
return { state: 'alert' };
}
return { state: 'empty', streak: post.missStreak };
}
async function ensureCallback(appId, appSecret, callbackUrl) {
const token = await getAdminToken(appId, appSecret);
const current = await callOpenApi('getMessageCallback', appId, appSecret, { token });
const flags = new Set(
String(current.callbackFlag || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
);
flags.add('alarm');
flags.add('deviceStatus');
flags.add('numberstat');
await callOpenApi('setMessageCallback', appId, appSecret, {
token,
status: 'on',
callbackUrl,
callbackFlag: [...flags].join(','),
basePush: '2'
});
}
function startCallback(port) {
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
if (req.method !== 'POST') return;
let msg;
try {
msg = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch (err) {
return;
}
const list = Array.isArray(msg) ? msg : [msg];
for (const item of list) {
if (item.msgType === 'manyStay') {
console.log(JSON.stringify({ keep: 'stay', did: item.did || item.deviceId }));
continue;
}
if (item.msgType === 'numberstat') {
console.log(JSON.stringify({ keep: 'footfall', body: item }));
}
}
});
});
server.listen(port);
}
async function loop(appId, appSecret) {
for (const post of Object.values(posts)) {
try {
const result = await pollPost(appId, appSecret, post);
console.log(post.name, result.state, result.reason || result.streak || '');
} catch (err) {
console.log(post.name, 'skip', err.code || err.message);
}
}
}
module.exports = {
calcSign,
pollPost,
ensureCallback,
startCallback,
loop,
hasAbility
};
进程重启会丢掉内存里的 missStreak 和 alerted。生产把这两项放到岗位记录里,避免重启后立刻再报一次,或把已经累积的空帧清零。hasAbility 留给客流方案:上线前确认目标通道含 ManNumDec。过线能力 NumberStat 只说明能做进出统计。
回调处理函数先返回 HTTP 200,再解析报文。《平台主动推送(推送模式)》写明:多次不返回 200,开放平台将不再向该地址推送。解析失败也要先应答。manyStay 只归档。numberstat 先存原文,人数字段对照《事件消息格式定义》映射之后,才能复用上面的连续计数。
设备告警需要回放时,用 getAlarmMessage。入参包含 deviceId、channelId、beginTime、endTime(yyyy-MM-dd HH:mm:ss),count 范围 1~30,nextAlarmId 翻页。子账号最小权限是 Alarm。这里能看到 human、videoMotion 等设备消息,看不到 absenceDetect 的 detectResult。
这些标题和方法名可以按页核对
文档站按标题或方法名检索。下表只列本文用到的页。
| 文档标题 | 方法 | 用来核对什么 |
|---|---|---|
| 《开发规范》 | accessToken |
请求壳、签名三步、SN1002、SN1005 |
| 《accessToken:获取管理员token》 | accessToken |
expireTime 单位为秒 |
| 《全局返回码说明》 | --- | TK1002、DV1010、DV1053、DV1007、OP1012、OP1039、OP1040 |
| 《AI服务说明》 | absenceDetect 等 |
离岗检测属于按需开通的行业场景接口 |
| 《离岗检测》 | absenceDetect |
type、content、threshold、detectRegions、repositoryId、detectResult |
| 《setDeviceSnapEnhanced设备抓图》 | setDeviceSnapEnhanced |
间隔大于等于 1 秒,url 2 小时有效 |
| 《setDeviceSnap设备抓图》 | setDeviceSnap |
间隔大于等于 3 秒,老设备抓图 |
| 《设备操作说明》 | --- | 两个抓图方法的选用说明 |
| 《获取设备在线状态》 | deviceOnline |
onLine:0 离线,1 在线,3 升级中,4 休眠 |
| 《分页查询设备详细信息》 | listDeviceDetailsByPage |
deviceAbility、channelAbility、在线状态 |
| 《设备能力集》 | --- | NumberStat 过线客流,ManNumDec 区域客流,HeaderDetect 人头检测 |
| 《推送配置说明》 | --- | 回调设置与查询的模块入口 |
| 《设置报警回调地址》 | setMessageCallback |
callbackFlag 含 alarm、numberstat 等 |
| 《查看报警回调地址》 | getMessageCallback |
核对当前 URL 和大类,避免覆盖 |
| 《平台主动推送(推送模式)》 | --- | 回调须返回 200,否则停止推送 |
| 《事件消息类型定义》 | --- | numberstat 客流量,manyStay 滞留,human 人形,videoMotion 动检 |
| 《事件消息格式定义》 | --- | 各 msgType 的字段样例 |
| 《消息通道订阅(拉取模式)》 | pullMessages |
无法提供公网回调时的拉取方式 |
| 《设备消息说明》 | --- | 设备告警查询这一组接口 |
| 《查询用户报警信息》 | getAlarmMessage |
查设备告警,不承载 detectResult |
抓图失败被记成离岗,回调不回 200 被停推
| 现象 | 先查什么 |
|---|---|
| 接口失败,现场画面明明是空的或有人 | 离岗检测是否已开通。返回码对照《全局返回码说明》:权限不足是 OP1012,参数不合法是 OP1003。非 0 不要写成 detectResult=false |
| 每一分钟都报一次离岗 | 单帧 false 被直接外推。加上连续次数,并且 alerted 在下一次 true 之前不要重复通知 |
| 人一直在,仍报离岗 | 框是否罩住岗位;员工是否被遮挡;阈值是否过高。需要工装口径时再传 repositoryId,用回放核对 |
| 人已经离开,迟迟不报 | 间隔和 missLimit 决定时延。抓图或检测失败会跳过本轮,计数不会增加。先看日志里的 skip 原因 |
| 灯一闪就不报,人站着不动却报 | 判定用了 videoMotion 的有无。改回 detectResult |
| 收到滞留就通知离岗 | manyStay 是人员滞留。空岗计数只接受你映射之后的区域人数为 0,或 detectResult===false |
| 订了告警却没有客流 | getMessageCallback 的 callbackFlag 是否包含 numberstat。只订 alarm 不会带来客流 |
| 改完回调,原来的人形也没了 | setMessageCallback 按本次传入的整串生效。先读再合并 |
| 生产突然收不到设备消息 | 账号只有一份回调地址。测试 URL 覆盖了生产 URL。测试用独立应用 |
抓图报 DV1010 |
1 秒内同一设备抓了多次。拉长轮询间隔,岗位之间错开 |
抓图报 DV1007 或 DV1053 |
设备离线,或网络原因没返回图片。本轮跳过。deviceOnline 里 3 是升级中,4 是休眠,都不要抓 |
| 增强抓图总是同一张图 | 请求间隔小于 1 秒,接口返回上一张。空帧计数会被同一张图堆满 |
检测报 OP1003 |
区域点数不在 3~6,坐标不在 0, 1,或 threshold 不在 (0, 1]。type 与 content 也要成对 |
Base64 报 OP1030 |
《全局返回码说明》中该码是文件大小超出限制。缩小图片再传,或改回抓图 URL |
返回 TK1002 |
管理员 token 过期或不存在,重新调用 accessToken。TK1003 则是 token 与 appId 不对应 |
返回 SN1001 |
签名与平台不一致,核对三段式和 SHA-256 小写十六进制 |
多岗位跑起来后出现 OP1039 或 OP1040 |
分钟频率或当日总次数到达《全局返回码说明》里的额度。加大间隔、错峰,或按该页说明扩展额度 |
getAlarmMessage 里没有离岗 |
预期如此。离岗结论在 absenceDetect 的响应里,由业务服务自己发 |
| 告警里的图片打不开 | 抓图 url 2 小时有效。通知需要留图时,在判定成立当时下载转存 |
| 回调一段时间后彻底没有设备消息 | 处理逻辑写在返回 200 之前,或经常超时。先应答,再落库。与抓图轮询无关,轮询不依赖这条回调 |
| 子账号能抓图,检测却失败 | 检测文档要求管理员 token。抓图的子账号权限不能代替它 |
离场前用两段录像验收:员工离开岗位并超过设定的连续次数,业务通知只发一次,画面里能对上该岗位;员工回到框内,计数清零,下一次离开可以再次通知。抓图失败的那几分钟不产生通知。做到这三步,离岗告警就和动检、滞留分开了。
以设备能力集与开通服务为准。区域客流、抓图频率、工装库是否参与判断,都看该通道当前能力和离岗检测是否已开通,不随「摄像头已经在线」一起出现。