在车展或停车场,用户看到一辆叫不出名字的车,可以在 Rokid Glasses 上说「这是什么车」。眼镜拍一张照片,将车型、颜色、年款和能辨认的车牌显示在眼前,再播报主要结果。本文以 1.0.1 源码和项目调试记录为依据,讨论如何用 AIUI 把这句话接成一次有触发、有结果、可重试、会结束的设备任务。

先跟着一张照片走一遍

假设你在车展上看见一辆车。语音请求命中智能体后,系统打开车辆识别页面,页面调用相机拍一张照片。应用把照片和一张“答题清单”一起交给能看图的模型,清单要求它按固定字段回答,例如品牌、车系、颜色。页面读出这些字段,把它们放到眼镜的对应位置,同时播报车型。车牌看不清时,车型仍可显示。

这里有三个角色。AIUI 宿主负责启动智能体和传递后续唤醒事件。Ink 页面是写眼镜界面和交互逻辑的文件,负责决定什么时候拍照、等待或显示结果。多模态模型负责看照片并返回文本。后面出现的 JSON 是程序和模型约定的字段格式,TTS 是把文字读成声音。这个项目调用平台提供的模型能力,项目源码主要解决的是怎样把语音、相机、模型和页面接成一轮任务。

1. 设计:语音入口、任务调度与结果处理

车辆识别 AIUI 架构图

图 1 车辆识别的触发入口和主数据流

上图概括主数据流。中间的「5 秒内匹配指令」由 Ink 页面在收到宿主事件后执行。页面统一接收首次进入、再次唤醒和手动触发,三条路径最终进入同一个 scanVehicle()。这样,拍照前停止监听、清除上一轮计时和防止重复触发都只需实现一次。

源码职责
AGENTS.md、app.json、app.js定义智能体触发表达与能力,注册唯一页面,记录应用生命周期。
pages/index/index.ink调度拍照、识别、播报和退出,并把状态映射到模板。
lib/vehicle-recognizer.js把照片和提示词提交给 AIUI 官方多模态 LanguageModel,管理单轮模型会话。
lib/vehicle-result.js把模型原文解析成页面字段,独立处理空值、车牌格式和价格区间。

2. 原理:把「唤醒」与「开始拍照」拆开

AGENTS.md 把「这是什么车」「识别这辆车」「识别车牌」等列为启动表达。首次进入时,onShow() 获取相机上下文,再检查 hasAutoScanned 以触发一轮识别。这里的“一次”实际对应一次页面可见周期。因为 onHide() 会重置该标记,页面重新显示后可能再次自动拍照。

应用仍在前台时,onVoiceWakeup() 收到带关键词的宿主事件,启动一次中文 SpeechRecognition。5 秒从语音会话的 onstart 开始计算。页面取最终转写,并用正则匹配动作。当前实现里「识别车牌」也进入整车识别流程,车牌是结果中的一个可选字段。手动确认或点按钮时,同样走 scanVehicle()。

再次唤醒后,最终转写交给 onresult(pages/index/index.ink:233,源码节选)。

recognition.onresult = (resultEvent) => {
    this.clearVoiceRecognitionTimeout();
    const transcript = this.extractRecognitionTranscript(resultEvent);
    this.stopVoiceRecognition();
    if (VoiceCommandPattern.CAPTURE.test(transcript)) {
        this.scanVehicle();
        return;
    }

    this.enterWaitingWake(
        transcript
            ? `未匹配车辆识别指令:“${transcript}”。`
            : '没有听清指令,请重新唤醒后再试。'
    );
};

执行顺序是取转写、停止监听、判断是否拍照。停止监听会解绑四个回调并中止会话,因此命中指令后,旧语音会话的结束事件不会把页面拉回等待状态。这里的正则是子串匹配,带否定的「不要识别这辆车」也可能匹配,适合作为后续回归用例。

调用时序图

图 2 从触发到拍照、识别和播报

3. 开发逻辑:用状态管理一轮异步任务

scanVehicle() 先用 isBusy 拦截重复触发,然后清理上一轮的退出计时和语音识别。开始拍照时清空旧结果。拿到照片并校验 data 与 mimeType 后,才播放本地快门音并进入模型识别。快门音失败时,识别流程仍可继续。

三个入口共享同一段拍照前检查(pages/index/index.ink:600,源码节选)。

async scanVehicle() {
    if (this.data.isBusy) {
        return;
    }

    this.clearIdleExitTimers();
    this.stopVoiceRecognition();

    if (!this.cameraCtx || !this.data.cameraReady) {
        this.showError('相机尚未就绪,请稍后重试。');
        return;
    }

第一道检查拦截正在执行的任务。只有进入新一轮,才清理上一轮计时和监听。第二道检查确认相机上下文存在且页面标记为就绪,失败时直接进入错误状态。通过后,函数才将 isBusy 置为 true,再等待相机返回照片。

模型阶段的异常和忙碌标记在同一处收尾(pages/index/index.ink:649,源码节选)。

try {
    const recognition = await recognizeVehicleAndPrice(photo);
    this.applyRecognitionResult(recognition);
} catch (error) {
    this.showError(`视觉识别失败:${String(error && error.message ? error.message : error)}`);
} finally {
    this.setData({
        isBusy: false
    });
}

相机阶段也单独捕获异常。模型调用、JSON 解析以及结果映射都在这段 try 里,所以错误界面出现「视觉识别失败」时,排查还要看解析和渲染路径。无论模型阶段成功还是抛错,finally 都会释放忙碌标记。这个标记只约束新入口,无法判断旧任务返回时页面是否还在同一个可见周期。

源码定义八个状态,拍照和识别共用一个准星界面,因此实际有七类界面。模型正常返回 isVehicle: false 时,页面提示调整角度。相机失败、模型不可用或响应解析失败时,页面显示具体错误,并提供重试。

页面状态图

图 3 一轮识别中的页面状态流转

图中保留主要状态流转,按钮重试同样回到拍照。播报保护期通过文本长度估算,参数是每字符 200 毫秒、额外 2 秒、最短 4 秒。成功页在保护期后开始 10 秒退出计时。失败页在保护期后再保留 5 秒,然后回到等待唤醒并开始 10 秒退出计时。新指令会清掉旧计时,避免上一轮的退出回调关掉新任务。

播报保护期后的分流由 handlePostSpeechAction() 决定(pages/index/index.ink:450,源码节选)。

handlePostSpeechAction(postSpeechAction) {
    if (postSpeechAction === PostSpeechAction.RETURN_TO_WAITING_WAKE) {
        this.startResultDisplayTimeout();
        return;
    }

    this.startAppExitTimeout();
},

失败走结果页停留计时,成功直接走空闲退出计时。保护时间按字数估算,因而它是界面收尾的时间预算。TTS 接口缺失或调用抛错时,页面会直接进入上述收尾分支。

4. 源码剖析:把模型响应变成界面数据

recognizeVehicleAndPrice() 先检查模型可用性,将相机返回的 ArrayBuffer 编码成带 MIME 类型的 Base64 data URL,再把文字提示和图片放进同一条用户消息。会话在 finally 中销毁。模型协议包含三项,isVehicle 控制结果分支,vehicle 提供车型字段,priceEstimate 是可空的估价区间。

模型收到一条包含两个内容块的消息(lib/vehicle-recognizer.js:48,源码节选)。

const base64 = wx.arrayBufferToBase64(photo.data);
const mimeType = photo.mimeType || RecognitionValue.DEFAULT_MIME_TYPE;
const session = await LanguageModel.create();

try {
    const modelText = await session.prompt([
        {
            role: 'user',
            content: [
                {
                    type: 'text',
                    text: Prompt.VEHICLE_RECOGNITION
                },
                {
                    type: 'image_url',
                    image_url: {
                        url: `data:${mimeType};base64,${base64}`
                    }
                }
            ]
        }
    ]);

    return normalizeVehicleResult(parseModelJson(modelText));
} finally {
    session.destroy();
}

第一个内容块是答题规则,第二个内容块才是照片。Base64 只是把照片字节转成可放入消息的字符串。提示词约定不确定的车型字段写「未知」,读不清的车牌留空,无法估价时填 null。项目配置使用官方模型能力,调用代码未指定具体模型名称。

parseModelJson() 截取第一个左花括号到最后一个右花括号之间的文本再解析,可容忍单个 JSON 前后夹带说明。遇到畸形 JSON 仍会抛错,由页面统一进入错误状态。normalizeVehicleResult() 把缺失的车型字段填为「未知」,并要求价格区间两端都能转换为有限非负数,再排序生成上下界。页面只在有可用区间时显示价格。

价格分支先规范化两个端点,再决定是否展示(lib/vehicle-result.js:127,源码节选)。

const rawMin = normalizePrice(priceEstimate && priceEstimate.min);
const rawMax = normalizePrice(priceEstimate && priceEstimate.max);
const hasValidPriceRange = rawMin !== null && rawMax !== null;
const priceMin = hasValidPriceRange
    ? Math.min(rawMin, rawMax)
    : null;
const priceMax = hasValidPriceRange
    ? Math.max(rawMin, rawMax)
    : null;

下面是构造出来的示例,用来跟踪字段如何穿过系统。

{
    "isVehicle": true,
    "vehicle": {
        "brand": "本田",
        "model": "Super Cub CC110",
        "type": "摩托车",
        "color": "绿色",
        "estimatedYear": "未知",
        "plateNumber": ""
    },
    "priceEstimate": null
}

规范化后,isVehicle 仍为 true,两端价格都是 null。applyRecognitionResult() 因此进入成功状态,模板显示「本田 Super Cub CC110」和「车牌未识别」,价格行因 priceText 为空而隐藏,播报会说明没有可靠读出车牌。如果把示例中的 isVehicle 改为 false,页面就会进入「未识别到车辆」分支,并提示调整角度。

车牌在三层使用三种表示。规范化字段去掉空格和分隔符,界面在地区前缀后加一个分隔点,TTS 逐字读出并把数字换成中文。现有代码处理格式,车牌是否可信仍取决于模型输出。估价也来自模型,界面展示的是参考区间。项目将照片提交给官方模型处理,源码没有实现照片或车牌的本地持久化。

在 Ink 模板中,规范化字段通过插值出现在界面上(pages/index/index.ink:861,源码节选)。

<view class="state-panel result-panel" ink:elif="{{status === 'found'}}">
    <text class="vehicle-name">{{brand}} {{model}}</text>
    <view class="plate-box">
        <text class="plate">{{plateNumber || '车牌未识别'}}</text>
    </view>

这里的空车牌占位文案由页面决定。用户因此能区分车型识别成功和车牌缺失两件事。

5. 界面与验证

眼镜通过准星和四角框提示用户对准车辆,快门声提示照片已取得。结果页把品牌、型号和车牌放在视觉上更显眼的位置,类别、颜色、年款和参考价格依次补充。下面的历史演示截帧展示了车牌缺失时仍能返回车型的情况。

车辆识别演示结果

图 4 历史演示截帧。画面中的车型、年款和估价来自该次模型输出。

项目的调试记录提到,早期在真机上用 Intl.NumberFormat 格式化价格会报 Intl not defined,最后改为纯 JavaScript 计算与字符串格式化。另一处改动是把当时不生效的 wx.exitMiniProgram() 替换为 window.close()。参考价格的字号和对比度也按眼镜上的实际效果调整过。

在 1.0.1 源码上运行 npm run check,结果数据测试和七类状态模板的源码契约测试均通过。测试覆盖 JSON 提取、车牌显示、价格倒序与缺失,界面契约通过读取模板和样式源码检查关键结构。相机、宿主唤醒、播报和退出仍需在眼镜上逐项回归。图 4 记录的是一次历史演示。

6. 复盘:下一轮最值得验证什么

现有 isBusy 可以拦住同一页面的重复请求,但页面隐藏时没有取消在途识别,也没有用请求标记丢弃过期结果。模型响应慢时,值得专门验证「拍照后切到后台,再回前台」是否出现旧结果回写。若能复现,可在隐藏时使本轮请求失效,并在异步返回后校验请求标记。再次唤醒采用子串正则,也值得加入带否定词、歧义指令与非车辆请求的样本,检查是否会误拍。

更多推荐