有用户问:啥场景非得要预览相机?我们在做一个导盲的项目:一个手持的导盲杖,胸前戴一个手机识别障碍物,两者通过蓝牙连接。按杖上的按钮就拍照,然后把结果反馈出来;也要能定时自动拍。
答案:凡是「不靠人按快门、由程序决定什么时候拍」的场景,都要用实时预览录制的预览模式拍照。 导盲正是这种:按的是杖上的按钮、或者计时器到点,没有人去点手机屏幕上的快门。
三种拍照方式,导盲该用哪种
| 方式 | 怎么拍 | 导盲能不能用 |
|---|---|---|
| 照相机 | 打开系统相机界面,人按快门才拍 | 不能:使用者看不到快门,也没法定时 |
| 实时预览录制·预览模式 | 先「初始化预览」把画面显示在一个布局里,之后积木随时调用「拍照」 | 能:蓝牙按钮、计时器都能触发 |
| 实时预览录制·后台录制模式 | 不显示画面,但只能在录像进行中拍照 | 勉强:要一直录像,费电又占空间 |
所以「非得要预览」,是因为程序自己拍照需要相机一直开着,而预览模式开相机的方式就是把画面显示在布局里。预览画面对视障使用者没有用,但:
- 布局可以做小(比如高 120 像素放在屏幕顶端),只要可见且有尺寸;
- 调试时很有用:一眼看出手机有没有对着前方、衣服有没有挡住镜头。
整个系统的数据流:
| 导盲杖 | → 蓝牙 | 胸前手机 | |||
|---|---|---|---|---|---|
| 按一下按钮 | 发 1 个字节 P(0x50) |
收到后拍照 | 目标检测 | 算方位和远近 | 语音播报 + 震动 |
准备
| 组件 | 命名 | 关键属性 |
|---|---|---|
| 垂直布局 | 垂直布局_预览 |
宽「充满」、高 120 像素;里面不要放别的组件(初始化预览会清空它) |
| 实时预览录制 | 实时预览录制1 |
「后台录制」不勾、「使用前置摄像头」不勾 |
| 按钮 | 按钮_拍照、按钮_定时 |
文本「拍一张」「开始定时」 |
| 图像 | 图像_照片 |
宽「充满」、高 160 像素 |
| 标签 | 标签_状态 |
— |
| 计时器 | 计时器_拍照 |
间隔 3000,不勾「启用计时」 |
| 计时器 | 计时器_蓝牙 |
间隔 100,不勾「启用计时」 |
| 蓝牙客户端 | 蓝牙客户端1 |
用于经典蓝牙(HC-05 等) |
| 列表选择器 | 列表选择器_连接 |
文本「连接导盲杖」 |
| 文本朗读器、音效 | 文本朗读器1、音效1 |
实验三用 |
杖上的单片机只需要做一件事:按一下按钮,通过蓝牙发 1 个字节 0x50(字符 P),不加回车换行。蓝牙模块先在手机系统设置里配对。
实验一:在 App 里拍一张
申请相机权限后初始化预览,收到「预览已启动」才允许拍照。拍照是异步的,「拍照完成」事件才给出照片路径。 用一个「忙碌」标记保证上一张没处理完不拍下一张:
global 就绪 = false
global 忙碌 = false
procedure 请求拍照() {
if (就绪 and not 忙碌) {
忙碌 = true
标签_状态.Text = "拍照中…"
实时预览录制1.CapturePhoto()
}
}
when Screen1.Initialize() {
Screen1.AskForPermission("android.permission.CAMERA")
}
when Screen1.PermissionGranted(权限名称) {
实时预览录制1.InitializeCamera(垂直布局_预览)
}
when 实时预览录制1.CameraPreviewStarted() {
就绪 = true
标签_状态.Text = "预览已就绪"
}
when 按钮_拍照.Click() {
请求拍照()
}
when 实时预览录制1.PhotoCaptured(文件路径) {
图像_照片.Picture = 文件路径
标签_状态.Text = "已拍照"
忙碌 = false
}
when 实时预览录制1.PhotoCaptureError(消息) {
标签_状态.Text = join("拍照失败:", 消息)
忙碌 = false
}
跑起来看:第一次打开允许相机权限,屏幕上方出现实时画面,标签显示「预览已就绪」;点「拍一张」,下方显示刚拍的照片。 预览一直黑着的话,检查布局是不是被隐藏或高度为 0。
实验二:杖上按钮和定时都能拍
把实验一的积木换成下面这一整段。新增的入口(蓝牙按钮、定时器)都只调用同一个「请求拍照」,所以不会两边同时拍:
global 就绪 = false
global 忙碌 = false
procedure 请求拍照() {
if (就绪 and not 忙碌) {
忙碌 = true
标签_状态.Text = "拍照中…"
实时预览录制1.CapturePhoto()
}
}
when Screen1.Initialize() {
Screen1.AskForPermission("android.permission.CAMERA")
}
when Screen1.PermissionGranted(权限名称) {
实时预览录制1.InitializeCamera(垂直布局_预览)
}
when 实时预览录制1.CameraPreviewStarted() {
就绪 = true
标签_状态.Text = "预览已就绪"
}
when 按钮_拍照.Click() {
请求拍照()
}
when 列表选择器_连接.BeforePicking() {
列表选择器_连接.Elements = 蓝牙客户端1.AddressesAndNames
}
when 列表选择器_连接.AfterPicking() {
if (蓝牙客户端1.Connect(列表选择器_连接.Selection)) {
计时器_蓝牙.TimerEnabled = true
标签_状态.Text = "导盲杖已连接"
}
}
when 计时器_蓝牙.Timer() {
if (蓝牙客户端1.IsConnected) {
if (蓝牙客户端1.BytesAvailableToReceive() > 0) {
var 收到 = 蓝牙客户端1.ReceiveUnsignedBytes(蓝牙客户端1.BytesAvailableToReceive())
if (contains(收到, 80)) {
请求拍照()
}
}
}
}
when 按钮_定时.Click() {
计时器_拍照.TimerEnabled = not 计时器_拍照.TimerEnabled
按钮_定时.Text = choose(计时器_拍照.TimerEnabled, "停止定时", "开始定时")
}
when 计时器_拍照.Timer() {
请求拍照()
}
when 实时预览录制1.PhotoCaptured(文件路径) {
图像_照片.Picture = 文件路径
标签_状态.Text = "已拍照"
忙碌 = false
}
when 实时预览录制1.PhotoCaptureError(消息) {
标签_状态.Text = join("拍照失败:", 消息)
忙碌 = false
}
跑起来看:
- 点「连接导盲杖」选配对好的模块,标签显示「导盲杖已连接」;按一下杖上的按钮,大约 0.1 秒内拍一张;
- 点「开始定时」,每 3 秒自动拍一张;再点一次停止;
- 定时和按钮同时触发也只会拍一张:「忙碌」时的请求直接忽略。
80 就是 0x50(字符 P)。蓝牙收到的是字节列表,「包含」判断里面有没有这个字节;按钮抖动连发几个 P 也只拍一次。
还没有导盲杖时,可以用手机上的蓝牙串口助手连另一台手机发 P 来测试。
实验三:识别后语音提醒方位和远近
导入 MLKitObjectDetector 离线目标检测拓展,再拖一个文本朗读器和一个音效。 照片拍好后交给检测器;检测器对每个物体给出位置框,取最大的那个(通常离得最近),按框的中心在左、中、右三段判断方位, 框宽超过画面一半就算「很近」,加一下震动。下面是完整程序,替换实验二:
global 就绪 = false
global 忙碌 = false
global 物体数 = 0
global 图宽 = 1
global 最大宽 = 0
global 方位 = ""
procedure 请求拍照() {
if (就绪 and not 忙碌) {
忙碌 = true
标签_状态.Text = "拍照中…"
实时预览录制1.CapturePhoto()
}
}
when Screen1.Initialize() {
Screen1.AskForPermission("android.permission.CAMERA")
}
when Screen1.PermissionGranted(权限名称) {
实时预览录制1.InitializeCamera(垂直布局_预览)
}
when 实时预览录制1.CameraPreviewStarted() {
就绪 = true
标签_状态.Text = "预览已就绪"
}
when 按钮_拍照.Click() {
请求拍照()
}
when 列表选择器_连接.BeforePicking() {
列表选择器_连接.Elements = 蓝牙客户端1.AddressesAndNames
}
when 列表选择器_连接.AfterPicking() {
if (蓝牙客户端1.Connect(列表选择器_连接.Selection)) {
计时器_蓝牙.TimerEnabled = true
标签_状态.Text = "导盲杖已连接"
}
}
when 计时器_蓝牙.Timer() {
if (蓝牙客户端1.IsConnected) {
if (蓝牙客户端1.BytesAvailableToReceive() > 0) {
var 收到 = 蓝牙客户端1.ReceiveUnsignedBytes(蓝牙客户端1.BytesAvailableToReceive())
if (contains(收到, 80)) {
请求拍照()
}
}
}
}
when 按钮_定时.Click() {
计时器_拍照.TimerEnabled = not 计时器_拍照.TimerEnabled
按钮_定时.Text = choose(计时器_拍照.TimerEnabled, "停止定时", "开始定时")
}
when 计时器_拍照.Timer() {
请求拍照()
}
when 实时预览录制1.PhotoCaptured(文件路径) {
图像_照片.Picture = 文件路径
标签_状态.Text = "识别中…"
MLKitObjectDetector1.DetectFromFile(文件路径)
}
when 实时预览录制1.PhotoCaptureError(消息) {
标签_状态.Text = join("拍照失败:", 消息)
忙碌 = false
}
when MLKitObjectDetector1.GotObjects(数量, imageWidth, imageHeight, resultJson) {
物体数 = 数量
图宽 = imageWidth
最大宽 = 0
}
when MLKitObjectDetector1.GotObject(索引, x坐标, y坐标, 宽度, 高度, 标签文字, confidence, trackingId) {
if (宽度 > 最大宽) {
最大宽 = 宽度
var 中心 = x坐标 + 宽度 / 2
方位 = choose(中心 < 图宽 / 3, "左前方", choose(中心 > 图宽 * 2 / 3, "右前方", "正前方"))
}
if (索引 == 物体数) {
var 很近 = 最大宽 > 图宽 / 2
标签_状态.Text = join(方位, "有物体", choose(很近, ",很近", ""), "(共 ", 物体数, " 个)")
文本朗读器1.Speak(join(方位, "有物体", choose(很近, ",很近", "")))
if (很近) {
音效1.Vibrate(500)
}
忙碌 = false
}
}
when MLKitObjectDetector1.NoObjectFound(操作) {
标签_状态.Text = "未检出物体(不代表前方安全)"
忙碌 = false
}
when MLKitObjectDetector1.DetectFailed(操作, 错误信息) {
标签_状态.Text = join("识别失败:", 错误信息)
忙碌 = false
}
跑起来看:手机对着桌上的水杯,把杯子放在画面左边,按一下杖上的按钮(或点「拍一张」),听到「左前方有物体」; 把杯子挪到镜头跟前,听到「正前方有物体,很近」并震动一下。
要点:
- 检测器先触发一次「检测到物体」汇总,再对每个物体触发一次「检测到一个物体」,所以在汇总里记下总数和图宽,在最后一个物体(序号等于总数)时才播报;
- 「忙碌」在每条结束分支都要放开:播报完、未检出、识别失败、拍照失败,漏一个就再也不拍了;
- 每 3 秒最多拍一张、处理完才拍下一张,语音不会堆积。
识别的边界(务必看)
这套程序验证的是「按键/定时 → 拍照 → 识别 → 反馈」这条链路能跑通,不是可以直接上路的导盲系统:
- MLKit 的基础模型只有 5 个粗类(时尚商品、食物、家居用品、地点、植物),认不出台阶、坑洞、电线杆、车辆,也给不出距离;「很近」只是按框的大小估计;
- 未检出不代表前方安全,绝不能据此播报「可以通行」;
- 真正上路需要专门训练的障碍物模型(可换自定义模型版拓展)或测距传感器(超声波、激光测距放在杖上,用同一条蓝牙传距离更可靠),并在有人陪同的环境里反复测试。
常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 预览一直黑、标签不显示就绪 | 没给相机权限,或预览布局被隐藏、高度为 0 | 允许权限;布局保持可见、给出高度 |
| 预览布局里原来的按钮不见了 | 初始化预览会清空这个布局 | 预览布局专用,其它组件放在外面 |
| 点拍照没反应 | 还没收到「预览已启动」,或上一张还没处理完 | 等就绪;检查每个结束分支都放开了「忙碌」 |
| 按杖上按钮没反应 | 没连上、固件发的不是 0x50,或用的是低功耗蓝牙 |
先用串口助手发 P 测试;BLE 设备改用低功耗蓝牙订阅 |
| 识别一次后再也不拍 | 某个分支没放开「忙碌」 | 对照实验三的四个结束分支 |
| 锁屏后不拍了 | 预览模式只在 App 前台工作 | 使用时保持亮屏,见让 App 不休眠 |
| 用久了占空间 | 每次拍照都生成新文件 | 识别完用文件管理器删除旧照片,或只保留最近几张 |
平台说明
- 安卓:本文积木按安卓编写,实时预览录制、蓝牙客户端、文本朗读器都内置;MLKit 检测是安卓拓展。
- 鸿蒙:实时预览录制的预览与拍照接口已实现,相机权限名、蓝牙类型请按鸿蒙设备实测;MLKit 拓展不能在鸿蒙上使用。
扫码添加客服咨询