这次分享一下我用 AIUI 在 Rokid AI Glasses 上做背单词应用的开发经验。应用里有学习卡、朗读背诵和错词复习,也做了一个通过头部动作接词义的小游戏。
这篇主要介绍其中的拍照听写:眼镜读出单词,用户在纸上写下答案,再单击镜腿拍照,应用识别照片中的拼写并给出核对结果。下面按照这个功能的处理顺序,说明相机和模型如何接入,以及切题、语音和学习进度怎样处理。
接词闯关的桌面渲染画面,使用项目实际 Canvas 绘制代码,移动输入和下落位置由演示驱动。
一、先把听写流程确定下来
拍照听写保留了纸笔作答的方式。用户不需要在眼镜上输入字母,只在写完之后触发一次拍照。一道题按下面的顺序进行:
**出题:**从当前词库取出一个单词,由眼镜朗读。
**等待作答:**用户在纸上写下答案,应用等待镜腿单击。
**拍照识别:**调用摄像头取得照片,再把照片交给模型读取拼写。
**核对结果:**将识别出的拼写与标准答案比较,展示结果,并更新学习记录。
页面需要跟着这些阶段变化。等待作答时可以拍照,识别中显示等待提示,结果回来后再展示拼写和答案。进入识别阶段后,本题不再接收重复提交,避免用户连续单击时发起多次请求。
项目实际页面模板。动图使用固定的照片识别结果,仅演示处理阶段与结果页面。
下面的代码根据项目实现整理,保留必要的 SDK 调用,省略完整页面和词库。接口说明对应本项目使用的 AIUI v0.14,文档提交为988d312。
二、用 AIUI 完成拍照和拼写识别
1. 在用户操作时调用相机
拍照入口放在镜腿单击的处理逻辑中。AIUI 通过wx.media.createCameraContext()创建相机上下文,再调用takePhoto()。这个调用要求有用户交互,因此不能在进入页面时直接自动拍照。
不同运行环境提供的能力可能不同,所以先检查接口是否存在,再检查返回的相机对象。下面是拍照部分的精简写法:
importwxfrom'wx';if(!wx.media||typeofwx.media.createCameraContext!=='function'){thrownewError('当前环境没有相机接口');}constcamera=wx.media.createCameraContext();if(!camera||typeofcamera.takePhoto!=='function'){thrownewError('当前环境没有可用的拍照能力');}// 在用户点击处理逻辑中执行。constphoto=awaitcamera.takePhoto({});takePhoto()异步返回照片,结果中包含图片数据data和类型mimeType。其中data是ArrayBuffer,也就是图片字节。后面传给模型的图片需要由这份数据转换而来。接口定义见 AIUI 相机文档。
2. 把图片字节转换为 Data URL
模型的图片消息需要一个图片地址。这里用wx.arrayBufferToBase64()将照片编码,再加上 MIME 类型,组成 Data URL。它的形式是data:image/jpeg;base64,...,图片内容就包含在这个字符串里。
functionphotoToDataUrl(photo){letbuffer=photo.data;if(ArrayBuffer.isView(buffer)){buffer=buffer.buffer.slice(buffer.byteOffset,buffer.byteOffset+buffer.byteLength);}if(!(bufferinstanceofArrayBuffer)){thrownewError('没有取得图片字节');}constmime=photo.mimeType||'image/jpeg';return'data:'+mime+';base64,'+wx.arrayBufferToBase64(buffer);}函数也处理了ArrayBufferView。它可能只指向一个缓冲区中的部分数据,所以要按byteOffset和byteLength截取有效范围,再做编码,避免把图片以外的字节一起传给模型。
3. 让模型读取照片里的拼写
接下来调用LanguageModel。先用availability()检查模型能力是否可用,再用create()创建会话。本题的要求和照片一起传给prompt(),等待它返回识别文本。
我在提示中要求模型只返回一个英文单词,无法辨认时返回NONE。这样后续代码拿到的是拼写,不需要再从一段解释中提取答案。
asyncfunctionreadHandwrittenWord(photo){conststatus=awaitLanguageModel.availability();if(status!=='available'){thrownewError('当前模型能力不可用');}constsession=awaitLanguageModel.create({initialPrompts:[{role:'system',content:'Read one handwritten English word. '+'Return only its spelling, or NONE if unreadable.'}]});try{returnawaitsession.prompt([{role:'user',content:[{type:'text',text:'Read the word in this photo.'},{type:'image_url',image_url:{url:photoToDataUrl(photo)}}]}]);}finally{session.destroy();}}代码中,文字和图片放在同一条user消息里,图片使用image_url结构。每道题创建一个独立会话,用完后调用destroy()释放;精简示例用finally保证失败时也会释放。这样下一题不会沿用上一张照片的上下文,也不用让多道题争用同一个会话。SDK 对一个会话只允许一个活跃请求。调用格式见 LanguageModel 文档。
有一项配置需要先确认:availability()通过,只代表宿主提供了模型配置;拍照识词还要求所选模型支持图片输入。具体配置参考 模型图片输入说明。实际项目的提示中还会提供目标单词的长度,上面的示例省略了这项约束。
4. 根据识别文本核对答案
模型返回后,程序先统一大小写、过滤无关字符,再与当前题目的标准答案比较。比如题目是opportunity,识别结果是oportunity,两者不相同,这一题就会按拼写错误结算。识别负责读出用户写了什么,比较和计分则按应用自己的规则执行。
这套文本整理规则针对单个英文单词。如果以后加入短语,就需要保留单词之间的空格,并重新确定比较规则。
三、切题时处理还没结束的任务
1. 旧题目的识别结果不能写回新题目
拍照和模型识别都需要等待。假设用户正在处理 A 题,等待期间返回菜单,又进入 B 题,A 题的识别结果随后才到达。如果直接使用这个结果,B 题的界面或分数就会被更新。
只检查“当前是否在听写页面”还不够,因为用户可能已经重新进入听写。因此,项目用_dictToken记录当前这一轮任务的编号。发起拍照时记住编号,拍照和识别各结束一次,就核对一次编号:
consttoken=this._dictToken;constphoto=awaitcamera.takePhoto({});if(token!==this._dictToken||this.data.screen!=='dictation')return;constguess=awaitthis._recognizeDictationSpelling(photo,currentWord);if(token!==this._dictToken||this.data.screen!=='dictation')return;// 结果仍属于当前题目,才比较答案并更新页面。换题、返回菜单或进入后台时,递增_dictToken。旧任务拿着旧编号返回,就会在检查处退出。这能阻止过期结果更新页面;底层请求的取消和会话释放仍需要单独处理。
2. 学习卡的待播语音也要跟着切换
学习卡依次朗读单词、释义和例句。如果用户提前切到下一张,上一张尚未提交的朗读片段也应该停止调度,否则新卡片出现后,还会继续听到旧内容。
项目实际学习卡模板与切卡处理函数,例句为固定样例。GIF 无声,仅展示界面切换。
这里采用相同的编号检查,用_speechGen标记一轮朗读。每个延迟任务执行之前,先判断自己是否仍属于当前卡片:
constgeneration=this._speechGen;consttimer=setTimeout(()=>{if(generation!==this._speechGen)return;speechSynthesis.speak(newSpeechSynthesisUtterance(text));},delay);this._speechTimers.push(timer);切卡时递增编号,并清理这一轮创建的计时器:
this._speechGen+=1;this._speechTimers.forEach(clearTimeout);this._speechTimers=[];为什么这里使用计时器?AIUI v0.14 的speechSynthesis.speak()支持默认入队的enqueue和立即播报的immediate,但没有提供完整的播放结束事件。项目因此按片段的估算时长安排后续朗读。估算会有偏差,不能把计时器到点当成声音确实播放结束。该版本的限制见 语音播报文档。
还要分清两种任务:上面能清理的是应用尚未提交的片段;已经交给宿主的语音,需要宿主的播放控制接口来停止。项目保留了检测浏览器cancel()和尝试空文本朗读的适配代码,空文本能否打断原生语音仍需在对应宿主上确认。v0.14 的lang、rate当时也未生效。后续 SDK 提供了 语音生成与独立播放器接口,本项目尚未迁移。
朗读背诵模式使用SpeechRecognition获取用户说出的文本,通过onresult、onerror和onend处理结果、错误和会话结束。需要结束并尽量保留结果时用stop(),中止本轮时用abort()。退出时同样让旧回调失效。项目给连续两轮识别留了约三秒间隔,这是适配值,等待期间暂停题目推进并显示倒计时;文本匹配用于核对内容,不用于评价发音质量。接口参考 语音识别文档。
四、共用词库,单独保存学习进度
学习卡、听写和错词复习用的是同一批词条。应用包含七套本地词库,原始内容以紧凑文本保存在 Ink 脚本中,用户选择难度后,才解析对应词库。
加载器需要处理三种情况:
词库已经解析过:直接返回缓存。
词库还没解析:开始解析,完成后缓存结果。
同一词库正在解析:后续请求加入等待队列,完成后一起取得结果。
这样切换练习模式时可以复用解析结果,也不会因为两个入口同时请求词库而重复解析。解析失败时同样要通知等待方,让页面结束加载状态。
这里延后的是解析时机,原始词库仍然随应用加载。代码中的setTimeout只是把解析安排到稍后执行,并不会开启后台线程;词库更大时,还需要考虑分批处理。
页面只接收当前题目需要的数据
完整词库留在脚本中,页面data只保存当前单词、释义、例句和题目状态。因为 AIUI 会把 Page 数据序列化后交给渲染层,所以每次用setData()更新当前显示内容即可。框架的数据机制见 Page 文档。
答题记录按词库分别存储
词库描述单词本身,学习进度记录用户做过什么题。两者分开保存,切换学习模式时就能继续使用同一份进度。
下面只展示按词库区分存储键的写法,项目封装中还处理了 JSON 编解码和浏览器localStorage兼容:
constkey='wordshake-progress-'+difficultyId;constprogress=wx.getStorageSync(key)||{};// 完成本题后更新当前词条,再保存进度。wx.setStorageSync(key,progress);进度包含答对次数、错误次数、连续答对次数和错词专项记录。当前规则是普通练习连续答对两次后降低该词的出题优先级;错词专项累计答对三次后移出错词集合,之后在普通练习中再次答错则重新加入。这是应用当前采用的复习规则,尚未验证记忆曲线效果。
这次先分享到这里。如果你也在用 AIUI 开发 Rokid AI Glasses 应用,欢迎交流拍照、语音和页面交互中的实现经验。