在 Rokid AIUI 上做「拍照识花」:图像链路与踩坑记录

0 阅读14分钟

1. 项目简介

要解决的问题很单纯:走在路上、植物园里或自家阳台看到一株不认识的植物,想知道它叫什么,但为此掏手机、解锁、打开 App、对焦、等结果,这一串动作和「抬眼看一眼」的成本完全不成比例。眼镜的第一视角天然适合这件事——所见即所问,答案应该出现在眼前,而不是掌心的另一块屏幕上。

目标用户是戴 Rokid 眼镜、对身边植物有即时好奇心的人:户外散步与逛植物园的人、做自然观察的亲子用户、园艺爱好者。他们要的是一句话结论和几个关键鉴别特征,不是养护教程或百科长文。

这个智能体只做一件事:拍一帧 → 识别 → 报出名字 → 存下来。不需要账号、不需要后端、不需要联网服务(模型能力由 AIUI 运行时提供)。识别范围不限于开花植物,观叶、多肉、蕨类等同样在提示词范围内;辨认模糊时明确回答「没认出来」,而不编造物种。


2. 场景与用户意图

触发场景:用户在户外或室内看到一株植物,抬起手唤醒智能体。AIUI 智能体被唤醒后直接落在取景页(app.json 的 pages 首项是 pages/camera/camera),没有输入框、没有功能菜单,第一屏只有两个动作:_actions = ['拍照', '历史']。也就是说,用户不需要先「描述需求」——他看到什么,就是需求本身。

意图理解与承接:一次单击即代表「告诉我眼前这是什么」。智能体不再追问拍摄对象、不给二次确认弹窗,直接把整条链路走完:

用户动作智能体的理解承接方式
唤醒准备取景进入取景页,显示四角取景框与状态角标
单击 / Enter(选中「拍照」)识别眼前这一帧takePhoto() → 转码 → 自动跳结果页 → 调 LanguageModel
等待几秒希望直接用耳朵听到答案左图右信息 + tts_content 语音播报 + 自动写入历史
上下键选「历史」回看之前认过的植物进入记录列表,双击返回

结果之所以是「中文正式名 / 拉丁学名 / 科属 / 生活型 / 花期 / 关键鉴别特征」这几个字段,是因为提示词把输出收敛到了「确认物种」这个意图上:key_feature 只写用于鉴别的形态特征,不写养护教程;tts_content 限 30 字以内,适配眼镜朗读。模型返回 code = -1 时,界面给出「未识别」而不是一段似是而非的猜测,这也是承接意图的一部分——诚实比凑一个答案更重要。


3. 方案设计

3.1 智能体定义

纯前端 AIUI 应用,三个页面,一项权限,无自定义后端服务:

{
  "pages": [
    "pages/camera/camera",
    "pages/result/result",
    "pages/history/history"
  ],
  "permissions": ["CAMERA"]
}

运行时是 QuickJS,不是浏览器:没有 AbortController、不支持 CSS 伪元素、部分 CSS 属性会被静默忽略。交互约定为上下键移动选中项(到头即停、不循环)、Enter / 单击确认、双击返回。

3.2 页面与卡片设计

页面职责主要卡片 / 区块
camera取景、拍照、转码四角取景框 + 准星、状态角标(就绪 / 取景 / 识别中 / 异常)、两个动作项、底部提示条
result识别与结果呈现左侧照片预览卡,右侧信息卡(名称、拉丁名、科属、生活型、花期、鉴别特征),加载 / 未识别 / 错误三态
history记录回看列表项(缩略信息 + 科属标签 + 时间)、详情卡(大图 + 完整字段)、空态

结果页采用左图右信息,是因为单绿屏能同时承载的信息有限:照片用来确认「认的是不是刚才那株」,文字只留能在余光里读完的几行。所有图标、取景角标、加载环都用真实 <view> 子元素拼接(原因见 7.3 坑 6)。

3.3 状态流转

三个页面各有一套 data.phase 状态机,界面按 phase 显隐,失败一律回到可见的 error:

camera   idle ──单击/Enter──> shooting ──拍照完成──> processing ──navigateTo──> result
          ▲                    │                     │
          └──── 超时/失败 ──── error <──────────────┘

result   loading ──code=0────> success ──> 播报 + 存档
           ├────code=-1─────> notflower
           └────异常/超时───> error ──单击──> 重新拍摄(回 camera)

history  loading ──有记录───> list ──单击──> detail ──双击──> list
           └────无记录──────> empty                 └──双击──> 退出

状态机不是 AIUI 的 API,是项目自己约定的 data 字段;ink:if 依据它切换模板区块。之所以每个异步边界都要有超时回退,是因为 QuickJS 下未捕获的 Promise 异常常表现为「页面没反应」(见 7.3 坑 5)。

3.4 能力调用关系

能力API / 指令调用位置作用
摄像头wx.media.createCameraContext().takePhoto()camera.ink 的 _takePhoto()取一帧 JPEG ArrayBuffer
转码wx.arrayBufferToBase64()camera.inkArrayBuffer → base64
本地存储wx.setStorageSync / getStorageSync三个页面 + utils/storage.jslastPhoto、flower_history
多模态模型LanguageModel.availability() / create() / prompt() / destroy()utils/recognizer.js图像识别与文本输出
语音speechSynthesis.speak() / cancel()result.ink 的 _speak() / _stopSpeech()播报与离页打断
模板指令ink:for / ink:key / ink:if三个 .ink列表渲染、状态显隐
绘制wx.createCanvasContext() + requestAnimationFramecamera.ink / result.ink扫描与装饰动画
路由wx.navigateTo / navigateBack三个页面页面切换与返回
权限app.json 的 CAMERA全局相机访问

4. AIUI 实现

4.1 项目结构

拍照识花/
├── app.json                    # 页面注册 + CAMERA 权限
├── pages/
│   ├── camera/camera.ink       # 取景、拍照、转码、状态机与超时
│   ├── result/result.ink       # 识别调用、结果卡片、语音播报
│   └── history/history.ink     # 记录列表、详情、双击返回
└── utils/
    ├── recognizer.js           # LanguageModel 调用、提示词、JSON 解析与降级
    └── storage.js              # 历史记录 CRUD

.ink 单文件里同时放 <script setup> 逻辑、<page> 模板与 <style>;两个 utils 模块负责模型调用和存储,页面不直接碰模型细节。

4.2 数据流

takePhoto()  →  photo.data : JPEG / ArrayBuffer
             →  wx.arrayBufferToBase64()  →  纯 base64 字符串
             →  setStorageSync('lastPhoto')  →  结果页 getStorageSync
             →  'data:image/jpeg;base64,' + base64  →  session.prompt([...])
             →  Promise<string>  →  JSON  →  setData / speak / saveRecord

整条链路里只有一种图片编码(JPEG)、只做一次转码(base64)。同一份字符串被三个地方消费:模型的 image_url、<image> 的 src、历史记录里的照片字段。前缀在消费处拼,不存进 Storage,这是后面能避开格式问题的主要原因。

4.3 拍照与转码

// pages/camera/camera.ink(节选,省略状态机与超时分支)
var cameraCtx = wx.media.createCameraContext()

var photo = await cameraCtx.takePhoto({
  quality: 'high',
  mode: 'default',
  enableSystemPreview: true
})

var buf = photo.data
var base64
if (buf instanceof ArrayBuffer) {
  base64 = wx.arrayBufferToBase64(buf)
} else if (ArrayBuffer.isView(buf)) {
  base64 = wx.arrayBufferToBase64(buf.buffer)
} else {
  throw new Error('unexpected photo.data: ' + Object.prototype.toString.call(buf))
}

wx.setStorageSync('lastPhoto', base64)
wx.navigateTo({ url: '/pages/result/result' })

三个细节:

  1. takePhoto() 返回 Promise,photo.data 是 JPEG 编码的 ArrayBuffer,不是临时文件路径,也不需要任何格式转换。
  2. quality / mode / enableSystemPreview 三个参数必须写全。缺参数时模拟器里的 Promise 不会 settle,页面直接挂起(见 7.3 坑 5)。
  3. 显式兼容 ArrayBuffer 与 TypedArray 两种返回类型,未知类型直接抛错,不把脏数据传到下一页。

4.4 跨页传递

一张 quality: 'high' 的照片转出的 base64 是几十万字符量级,不能塞进 navigateTo 的 query。用 wx.setStorageSync('lastPhoto', base64),结果页 onLoad 里取回:

// pages/result/result.ink
var photo = wx.getStorageSync('lastPhoto') || ''
this.setData({ photo: photo, phase: 'loading' })

页面里显示同一张图时补上前缀即可:

src="{{photo ? 'data:image/jpeg;base64,' + photo : ''}}"

image/jpeg 是刻意的:它与 takePhoto 的实际输出一致,是整条链路的格式契约。

4.5 调用 LanguageModel

utils/recognizer.js 导出的 recognize(base64Photo) 是识别的唯一入口,由 pages/result/result.ink 的 _init() 调用。下面是它的调用主干(省略 catch 分支与 timedOut 标志),SYSTEM_PROMPT、NOT_FLOWER_RESULT、extractJSON() 都定义在同一个文件里,不是运行时 API:

// utils/recognizer.js
import { LanguageModel } from 'language-model'

export async function recognize(base64Photo) {
  var availability = await LanguageModel.availability()
  if (availability !== 'available') {
    return Object.assign({}, NOT_FLOWER_RESULT, { tts_content: '识别能力不可用' })
  }

  var session = await LanguageModel.create({
    initialPrompts: [{ role: 'system', content: SYSTEM_PROMPT }]
  })
  var timeoutId = setTimeout(function () { session.destroy() }, 15000)

  try {
    var responseText = await session.prompt([{
      role: 'user',
      content: [
        { type: 'text', text: '请识别这张图片中的植物,严格按照规则输出 JSON。' },
        { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,' + base64Photo } }
      ]
    }])
    clearTimeout(timeoutId)

    var parsed = extractJSON(responseText)
    if (!parsed) return Object.assign({}, NOT_FLOWER_RESULT, { tts_content: '识别结果解析失败' })

    var complete = {}
    var fields = ['code', 'cn_name', 'latin_name', 'family', 'genus', 'life_form', 'flower_period', 'key_feature', 'tts_content']
    for (var i = 0; i < fields.length; i++) {
      complete[fields[i]] = parsed[fields[i]] !== undefined ? parsed[fields[i]] : (fields[i] === 'code' ? 0 : '')
    }
    if (complete.code === 0 && (!complete.cn_name || !complete.key_feature)) {
      complete.code = -1
      complete.tts_content = '未能确认花卉种类'
    }
    return complete
  } finally {
    clearTimeout(timeoutId)
    session.destroy()
  }
}

调用形状值得单独记一下,这里最容易写错:

  • LanguageModel 是模块导出,必须 import;当成运行时全局对象调用会得到 not a function。
  • prompt(input) 的 input 是消息数组,消息的 content 是内容块数组:type: 'text' 放指令,type: 'image_url' 放图片,图片以 data URI 写在 image_url.url。没有 session.read() 这个 API。
  • prompt() 返回 Promise<string>,不是对象;也不接受 signal 参数。超时只能靠 setTimeout + session.destroy(),用完的 session 在 finally 里销毁。
  • 系统提示词里把输出约束写死:用《中国植物志》正式中文名、有花看花无花看叶形与株型、辨认模糊返回 code = -1 不许编造、tts_content 限 30 字以内适合眼镜朗读、只输出 JSON。返回字段为 cn_name / latin_name / family / genus / life_form / flower_period / key_feature / tts_content。

4.6 结果落地

extractJSON() 是 utils/recognizer.js 里的本地函数,和上面的 recognize() 同一个文件。模型返回的是字符串,偶尔还会带一句说明或 Markdown 代码块,所以解析要容错:

function extractJSON(text) {
  var s = text.trim()
  try { return JSON.parse(s) } catch (e) {}
  var a = s.indexOf('{'), b = s.lastIndexOf('}')
  if (a >= 0 && b > a) {
    try { return JSON.parse(s.substring(a, b + 1)) } catch (e) {}
  }
  return null
}

上面 recognize() 里的 fields 循环和 code = -1 判断就是两道保险:缺字段补空串、关键字段为空时强制降级。宁可播报「未能确认花卉种类」,也不要在眼前显示一张空卡片。

结果页 pages/result/result.ink 的 _init() 拿到 code = 0 的返回值后,一次完成三件事:

// pages/result/result.ink · _init()
this.setData({ phase: 'success', result: result })
speechSynthesis.speak(new SpeechSynthesisUtterance(result.tts_content), 'immediate')
saveRecord({
  photo: photo,
  cn_name: result.cn_name,
  latin_name: result.latin_name,
  family: result.family,
  genus: result.genus,
  life_form: result.life_form,
  flower_period: result.flower_period,
  key_feature: result.key_feature,
  tts_content: result.tts_content
})

saveRecord() 是 utils/storage.js 导出的方法,把记录写入 Storage key flower_history,最新在前,上限 50 条;每条的 id 是写入时的 Date.now(),列表的 ink:key="id" 用的就是它。


5. 交互与体验

5.1 操作路径

唤醒 → camera(idle) ──上下键选「拍照」── 单击/Enter ──> shooting → result(loading)
     → result(success / notflower / error) ──单击「重新拍摄」──> camera
                                          └─上下键选「历史」──> history(list)
     → history(list) ──上下键选记录 ── 单击 ──> detail ──双击──> list ──双击──> 退出

方向键选择到头即停不循环,Enter 与单击等价,双击返回上一页或退出;根页的 idle / error 态不拦截返回键,交给宿主处理。

取景页:单绿屏 + 四角取景框,两个可切换动作

simulator-preview-1793633759170.png

识别记录列表:选中项用背景层与侧条标识,避免动态 class

simulator-preview-1793633990411.png 记录详情:照片与结构化信息一起落盘,可离线回看

simulator-preview-1793633998035 - 副本.png

5.2 反馈机制

  • 状态可见:header 角标随 phase 显示「就绪 / 取景 / 识别中 / 异常」,底部提示条随状态给出「正在识别 · 单击取消」「滑动选择 · 单击确认 · 返回退出」等操作提示。
  • 等待可见:取景页用 requestAnimationFrame 驱动 canvas 扫描动画,结果页有加载环;识别中单击可取消。
  • 结果多通道:屏幕显示 + speechSynthesis 播报,离开页面时 _stopSpeech() 调 cancel() 打断,不把上一条结果念到下一页。
  • 失败有明确文案:相机不可用、拍照超时、模型不可用、识别超时、解析失败、置信度不足各有对应提示,例如「相机不可用,请检查权限后重试」「未能确认花卉种类」。
  • 防误触:_isPhantom() 在页面载入后 400800ms 内忽略按键,避免跳转瞬间的幽灵按键;双击窗口取 80800ms。

6. 调试与验证

验证环境是 AIUI Studio 模拟器,文中界面截图均来自模拟器预览。主要手段:

  • 控制台日志定位调用点:utils/recognizer.js 在可用性检查、模型返回长度、JSON 解析失败、session 异常等位置都有 console.log / console.error;camera.ink 对相机初始化、转码、拍照异常分别打日志。踩坑章节里的报错(not a function、AbortController is not defined、模板变量缺失警告)都是这样定位到具体方法的。
  • 状态机可观察:每个 phase 都有对应界面和文案,拍照超时 10 秒、识别超时 15 秒的兜底可以直接看是否被触发,而不必猜「是不是模型没返回」。
  • 格式对照样本:用一张普通 JPEG 的南天竹原图做输入,确认 PNG 相关问题是数据格式而非提示词导致(见 7.3 坑 1)。
  • 降级分支检查:代码为「相机不可用 / 拍照超时 / 模型不可用 / 识别超时 / JSON 解析失败 / 置信度低 / 空历史列表」分别准备了可显示的兜底状态,逐项走查过 UI 表现。

目前成果验证集中在模拟器:三个页面可运行,识别结果页、历史列表与详情页均有截图。真机调试尚未系统开展,取景质量、实际光照下的识别率、设备端语音播报效果还需要在眼镜上复测,这也是后续迭代第一项。


7. 成果与复盘

7.1 可运行成果

三个页面跑通了完整闭环:唤醒 → 单击拍照 → 多模态识别 → 左图右信息 + 语音播报 → 自动存档 → 历史回看。纯前端实现,没有后端服务;识别范围的提示词覆盖开花植物、观叶、多肉、蕨类等;失败路径都有可见反馈。

7.2遇到的问题与解决

坑 1|PNG 图片识别失败或返回空

  • 位置:recognize() 里拼 dataUri 的那一行(见 4.5)。
  • 当时:拿 PNG 素材调试,把 PNG 字节转成 base64 交给 recognize(),dataUri 却仍按 image/jpeg 拼;反过来把前缀改成 image/png 也不行,因为字节其实来自 takePhoto() 的 JPEG。两种都是声明格式与真实编码对不上。
  • 原因:模型的图像输入要求声明的 MIME 与实际字节一致,PNG 也不一定能被直接接受。
  • 现在:不做任何格式转换,全链路只留一种编码:
// utils/recognizer.js · recognize()
var dataUri = 'data:image/jpeg;base64,' + base64Photo

base64Photo 的来历要连起来看:camera.ink 的 _takePhoto() 把 photo.data(JPEG)转成 base64 存进 lastPhoto,result.ink 的 _init() 读出来传给 recognize()。格式契约跨了三个文件,改动时不能只改一处。调试时我用一张普通 JPEG 原图做对照,确认问题不在提示词。

4ffac6eea2f40e138a4d74ace3e7fd4b.jpeg

对照样本:一张普通 JPEG 原图

坑 2|recognize (utils/recognizer.js:92:32) not a function

  • 位置:utils/recognizer.js 文件顶部。
  • 当时:直接写 LanguageModel.create(...),把它当成运行时注入的全局对象,没有 import。报错里的 recognize 是调用栈中的方法名,不是出错的 API。
  • 现在:文件第一行显式导入。
import { LanguageModel } from 'language-model'

LanguageModel 是模块导出,不是全局构造器;日志出现「全局」字样时也以实际 import 为准。

坑 3|session.read() is not a function

  • 位置:recognize() 里 LanguageModel.create() 返回 session 之后。
  • 当时 / 现在:
// 当时:照搬其它 SDK 的「读流」命名
var text = await session.read(input)
// 现在
var text = await session.prompt(input)

session 是 create() 返回的会话对象,主要可用的是 prompt() 和 destroy()。返回 Promise<string>,结构化数据得靠同文件的 extractJSON() 自己解析。

坑 4|AbortController is not defined

  • 位置:recognize() 的超时处理。
  • 当时:按浏览器习惯 new AbortController(),再把 signal 传给 prompt()。
  • 原因:AIUI 跑在 QuickJS 上,没有 AbortController,prompt() 也不接受 signal。
  • 现在:
var timedOut = false
var timeoutId = setTimeout(function () {
  timedOut = true
  if (session) session.destroy()   // 让 pending 的 prompt 结束
}, 15000)
// catch / finally 里按 timedOut 区分「超时」与「真实异常」,并 destroy()

timedOut 是 recognize() 内部的局部变量,用于超时后返回「识别超时,请重试」而不是笼统的「识别失败」;降级结果统一用文件顶部的 NOT_FLOWER_RESULT 常量展开。

坑 5|拍照后页面卡死、无日志、整个 tab 无响应

  • 当时:var photo = await cameraCtx.takePhoto(),参数为空。
  • 原因:缺参数时模拟器里的 Promise 不会 settle。页面用 data.phase 状态机渲染,一旦停在 shooting,界面就永远是「处理中」,看起来像卡死,连返回键都没反应。
  • 现在:
var photo = await cameraCtx.takePhoto({
  quality: 'high',
  mode: 'default',
  enableSystemPreview: true
})

_takePhoto() 由 _activate()(方向键选中「拍照」后确认)或 onTapPhoto() 调用。除了参数写全,这个方法里还放了两层定时器:拍照前 10 秒、跳转前 15 秒,超时就 setData({ phase: 'error' }) 并显示原因;所有 await 都包 try/catch。phase 的取值 idle / shooting / processing / error 是项目自己约定的状态机,不是 AIUI 的 API。

这四条都不抛异常,只是「什么都不显示」,所以更要标明改在哪个元素上。

坑 6|::before / ::after 画的图标不显示

  • 位置:三个 .ink 的 <style> 与模板,例如结果页的重新拍摄图标、取景页的四角标记。
  • 当时:样式里写 .icon::before { content: ''; ... },用伪元素画相机、花瓣、小圆点。
  • 原因:AIUI 运行时不支持 CSS 伪元素。
  • 现在:用真实 <view> 子元素拼。取景页一个 L 形角标是这样写的:
<view class="vf-corner vf-corner-tl">
  <view class="vf-ch-h"></view>
  <view class="vf-ch-v"></view>
</view>

vf-corner-tl 表示左上角,vf-ch-h / vf-ch-v 分别是水平和垂直那两段,都是项目自定义的 class 名;四个角各写一份,相机图标、花瓣、加载环同理。