☰
Android跌倒检测Demo:三轴加速度与状态机报警链路
2026/10/26 22:02:30 网站建设 项目流程

简介:面向 Android 开发与计算机视觉入门者的跌倒检测识别演示程序,聚焦移动端实时摔倒识别这一常见场景,可用于课程设计、毕业项目原型验证或算法落地体验,兼顾初学者与有一定移动端开发经验的读者。zip 压缩包仅含 2 个文件,以 1 个 APK 安装包和 1 个 JSON 辅助文件为主,前者可直接安装体验跌倒检测流程,后者用于记录构建输出等信息,整体约 50.44MB,体积轻量,获取后即可上手。目前已有 1946 人学习下载,说明该方向具有稳定的实践需求。对希望快速了解 Android 端跌倒检测效果的读者而言,可通过实际运行演示观察识别流程与交互表现,并以此为参照评估自身方案在移动端的可行性,适合作为入门体验与原型参考。

1. 跌倒检测 Demo 的第一印象:它不是一个“摔倒就报警”的按钮

给家里老人手机装一个 Android 跌倒检测 Demo,最直观的验证场景是:手机放在口袋里,人快速下蹲再起身,App 不能乱响;模拟一次自由落体加撞击,它要在几秒内弹窗或响铃。这个 Demo 解决的就是把三轴加速度、姿态角和状态机串成一条可运行的跌倒识别链路,适合想快速验证传感器方案、准备做老人看护或户外作业监护的 Android 开发者。它不是一上来就堆摄像头模型,而是先把摔倒识别最核心的采集、判定、报警跑通,后面再扩展视频跌倒检测和识别也更容易。

2. 跌倒识别工程骨架:SensorManager 加前台服务的最小闭环

2.1 目录结构:一个能跑的最小闭环

这个 Demo 的工程目标很克制:把 Android 跌倒检测 从传感器注册到通知弹窗跑成一条闭环,不追求一上来就做深度学习分类。常见目录会按职责拆成 sensor、detector、service、alarm、ui 五块,先把数据采集和判定分开,后面换算法不用动界面。

路径职责改动频率
sensor/AccelCollector.kt注册加速度计、统一采样率、把SensorEvent转成数组低
detector/FallDetector.kt滤波、阈值、状态机、输出跌倒事件高
service/FallMonitorService.kt前台服务保活、传感器生命周期、报警分发中
alarm/AlarmBus.kt本地通知、震动、页面回调的解耦入口中
ui/MainActivity.kt权限申请、开始/停止监测、倒计时取消中
res/layout/activity_main.xml一个开始按钮、一个状态文本、一个日志区低

真正影响跌倒识别效果的只有detector和service两个目录。很多初版 Demo 把传感器注册写在 Activity 里,屏幕一关就收不到数据,后面排查半天以为是算法问题,其实是生命周期没管住。我一般会把采集放进前台服务,Activity 只负责权限和开关,这样锁屏、切后台都不容易翻车。

2.2 技术栈拆解:为什么用三轴加速度而不是一上来就上摄像头

选型上,三轴加速度计是跌倒识别最省电的入口。手机放在口袋或腰间,自由落体、撞击、静止三个物理阶段都能在加速度曲线上看到。摄像头方案能看姿态,但功耗、隐私、角度遮挡都是成本;端侧姿态关键点模型适合做二次校验,不适合作为唯一触发。这个 Demo 用SensorManager拿TYPE_ACCELEROMETER,配合前台服务和本地通知,先把“摔倒识别”最刚需的链路跑通。

依赖配置不用堆太多库,常见做法是只保留核心和界面基础库:

// app/build.gradle.kts dependencies { implementation("androidx.core:core-ktx") implementation("androidx.appcompat:appcompat") implementation("androidx.lifecycle:lifecycle-service") // 需要曲线调试时再加图表库,不要在首版塞进来 }

这段配置不写具体版本号,是为了让工程跟着本机 Android Studio 的模板走。lifecycle-service主要给前台服务用,方便在服务里拿生命周期;如果 Demo 里没有用到,删掉也不影响传感器采集。依赖同步失败时先看仓库网络和镜像配置,不要先怀疑代码。

清单文件里要提前声明前台服务和通知权限:

<!-- AndroidManifest.xml --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <service android:name=".service.FallMonitorService" android:foregroundServiceType="health" android:exported="false" />

foregroundServiceType="health"是新系统对健康类前台服务的类型声明,不写会在启动时抛异常。POST_NOTIFICATIONS在较新系统上必须运行时申请,否则报警通知只会在通知栏外面“路过”一下。WAKE_LOCK不是让你一直持有,而是在冲击确认后的短窗口里保证 CPU 不睡死,报警发完就释放。

提示:监控入口最好从可见界面触发,不要靠开机广播直接拉起前台服务,新系统对后台启动限制很严。

2.3 检测链路:采集、滤波、阈值、状态机、报警

一条完整的跌倒检测和识别链路通常按 50Hz 采样,先做低通滤波分离重力,再算合加速度和姿态角。状态机比单阈值靠谱:自由落体、冲击、静止确认、报警,中间任何一步不满足就回空闲。常见参数是自由落体阈值 0.5 到 0.6g,冲击阈值 2.5 到 3.0g,静止窗口 2 到 3 秒,姿态角变化 50 到 60 度。具体值跟手机佩戴位置有关,别照抄。

链路可以拆成五步:

  1. 采集:SensorManager.registerListener注册加速度计,采样率用SENSOR_DELAY_GAME,大约 50Hz,够看清冲击尖峰。
  2. 滤波:用一阶低通分离重力分量,原始值减重力得到线性加速度;合加速度仍然用原始三轴计算。
  3. 判定:先看合加速度是否掉到自由落体区间,再看是否出现短时高冲击,最后看冲击后是否持续静止且姿态角明显改变。
  4. 确认:状态机进入静止确认后等 2 到 3 秒,期间线性加速度不能太大,否则回空闲。
  5. 报警:本地通知加震动,页面弹 10 秒倒计时,用户点“我没事”就取消,不点再走紧急联系人。

功耗上,50Hz 对加速度计压力不大,真正耗电的是屏幕常亮和网络请求。Demo 阶段建议只在报警时发网络请求,平时只写本地日志。误报抑制也别只靠调阈值,加入“冲击后静止”和“姿态角改变”两个条件,坐下、起身、把手机扔桌上这类动作会少很多。后面第 4 章会讲几个真机调试时最容易踩的坑。

3. 跌倒检测和识别核心:三轴加速度采集与状态机判定

3.1 传感器采集与滑动窗口滤波

先看判定类。它不直接持有SensorManager,只接收三轴数组和时间戳,这样单元测试和离线回放都能复用。下面这段是 Demo 里最核心的FallDetector,把滤波、阈值、状态机放在一个类里,方便新手顺着读。

// detector/FallDetector.kt class FallDetector( private val onFall: (FallEvent) -> Unit ) { enum class State { IDLE, FREEFALL, IMPACT, STILL, ALARM } private var state = State.IDLE private val gravity = FloatArray(3) private var baseline = floatArrayOf(0f, 0f, 9.81f) private var freeFallAt = 0L private var impactAt = 0L private var stillAt = 0L private var lastAt = 0L fun reset() { state = State.IDLE lastAt = 0L } fun process(values: FloatArray, now: Long) { if (lastAt == 0L) { lastAt = now // 第一次采样把当前重力方向当作佩戴基线 for (i in 0..2) gravity[i] = values[i] baseline = gravity.copyOf() return } lastAt = now // 一阶低通分离重力,0.8 越大越稳,越小越灵敏 for (i in 0..2) { gravity[i] = 0.8f * gravity[i] + 0.2f * values[i] } val rawG = sqrt( values[0] * values[0] + values[1] * values[1] + values[2] * values[2] ) / 9.81f val linearMax = sqrt( (values[0] - gravity[0]) * (values[0] - gravity[0]) + (values[1] - gravity[1]) * (values[1] - gravity[1]) + (values[2] - gravity[2]) * (values[2] - gravity[2]) ) / 9.81f val angle = angleBetween(gravity, baseline) when (state) { State.IDLE -> { if (rawG < 0.55f) { state = State.FREEFALL freeFallAt = now } } State.FREEFALL -> { if (rawG > 2.6f && now - freeFallAt < 1200) { state = State.IMPACT impactAt = now } else if (now - freeFallAt > 1500) { state = State.IDLE } } State.IMPACT -> { if (now - impactAt > 1200) { state = State.IDLE } else if (rawG in 0.85f..1.15f) { state = State.STILL stillAt = now } } State.STILL -> { val still = rawG in 0.8f..1.2f && linearMax < 0.25f if (!still) { state = State.IDLE } else if (now - stillAt > 2500 && angle > 55f) { state = State.ALARM onFall( FallEvent( time = now, peakG = rawG, angle = angle, reason = "冲击后静止且姿态改变" ) ) } } State.ALARM -> { // 等页面取消或上层调用 reset,不在这里重复上报 } } } private fun angleBetween(a: FloatArray, b: FloatArray): Float { val dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] val ma = sqrt(a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) val mb = sqrt(b[0] * b[0] + b[1] * b[1] + b[2] * b[2]) if (ma == 0f || mb == 0f) return 0f val cos = (dot / (ma * mb)).coerceIn(-1f, 1f) return Math.toDegrees(acos(cos).toDouble()).toFloat() } } data class FallEvent( val time: Long, val peakG: Float, val angle: Float, val reason: String )

这段代码的关键在三个量:rawG是原始合加速度,用来抓自由落体和冲击;linearMax是去掉重力后的线性加速度,用来判断冲击后是否真的静下来;angle是当前重力方向与佩戴基线的夹角,用来区分“手机还在手里晃”和“人已经躺下”。gravity的 0.8 系数是低通滤波的平滑度,调大后重力估计更稳,但姿态变化反应慢;调小后反应快,静止时容易抖。

状态机不能省。单看一个 3g 的冲击就报警,拍桌子、跑步落地都会中招;单看自由落体也不够,手机从手里滑到沙发上也会触发自由落体。FREEFALL到IMPACT限时 1.2 秒,是防止下落信号和冲击信号隔太久;IMPACT到STILL限时 1.2 秒,是防止人已经站起来才进入静止判断;STILL持续 2.5 秒且角度超过 55 度才上报,是给误报加一道闸。

参数可以按下面这张表调:

参数默认值作用调大后的影响调小后的影响
自由落体阈值0.55g判断失重更难触发,漏报增加更容易触发,误报增加
冲击阈值2.6g判断撞击漏掉轻摔日常动作容易中招
静止窗口2500ms确认人不再动报警更慢可能人还没停就报警
姿态角阈值55 度判断手机姿态改变漏掉平躺角度小的场景坐下也可能触发
线性加速度上限0.25g判断是否静止更容易进入静止更难进入静止

3.2 前台服务与传感器生命周期

判定类只负责算,真正让它在锁屏后继续跑的是前台服务。下面这段把传感器注册、线程切换、通知和事件分发串起来。HandlerThread用来避开主线程,传感器回调本来就在独立线程,但统一放到一个 worker 里更好排查。

// service/FallMonitorService.kt class FallMonitorService : Service(), SensorEventListener { private lateinit var sensorManager: SensorManager private var accelerometer: Sensor? = null private lateinit var detector: FallDetector private lateinit var worker: HandlerThread private lateinit var handler: Handler override fun onCreate() { super.onCreate() startForeground(1001, buildMonitorNotification()) worker = HandlerThread("fall-sensor").also { it.start() } handler = Handler(worker.looper) sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) detector = FallDetector { event -> publishAlarm(event) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val sensor = accelerometer if (sensor == null) { // 设备没有加速度计,直接停掉,别让用户以为在监控 stopSelf() return START_NOT_STICKY } sensorManager.registerListener( this, sensor, SensorManager.SENSOR_DELAY_GAME, handler ) return START_STICKY } override fun onSensorChanged(event: SensorEvent) { // elapsedRealtime 不受用户改时间影响,状态机时间戳用它 detector.process(event.values, SystemClock.elapsedRealtime()) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit private fun publishAlarm(event: FallEvent) { val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager manager.notify(1002, buildAlarmNotification(event)) AlarmBus.emit(event) } override fun onDestroy() { sensorManager.unregisterListener(this) worker.quitSafely() super.onDestroy() } }

SENSOR_DELAY_GAME是采样率参数,大约 50Hz,比SENSOR_DELAY_UI快,又不像SENSOR_DELAY_FASTEST那样耗电。SystemClock.elapsedRealtime()比System.currentTimeMillis()稳,用户改系统时间不影响状态机计时。START_STICKY让服务被系统回收后有机会重建,但别把它当保活神器,前台通知和电池优化白名单才是正路。

publishAlarm里先发本地通知,再走AlarmBus给页面。通知和页面回调分开,是为了后面接短信、电话、网络上报时不用改判定类。通知渠道要在buildMonitorNotification和buildAlarmNotification里提前建好,渠道被用户关掉后,代码里再notify也不会显示。

3.3 报警确认与取消倒计时

跌倒检测最怕“报了但用户没法取消”。Demo 里给一个 10 秒倒计时,用户点“我没事”就取消,点不到再走紧急逻辑。这个交互比单纯弹通知更接近真实产品。

// ui/MainActivity.kt 中的报警确认 private fun showFallCountdown(event: FallEvent) { val dialog = AlertDialog.Builder(this) .setTitle("检测到跌倒") .setMessage("${event.reason},10 秒后通知紧急联系人") .setNegativeButton("我没事") { d, _ -> d.dismiss() AlarmBus.cancel(event) } .create() dialog.show() handler.postDelayed({ if (dialog.isShowing) { dialog.dismiss() sendEmergency(event) // 这里再接短信、电话或自己的上报接口 } }, 10_000) }

倒计时用Handler.postDelayed就够了,不需要额外引调度库。AlarmBus.cancel(event)里把当前事件标记为已取消,服务端和页面都不要再重复弹窗。sendEmergency是二次开发接出去的口子,Demo 里可以先写日志,后面换成自己的接口。注意倒计时期间如果 Activity 被销毁,handler要跟着清理,否则容易泄漏。

4. 真机跑通与避坑:摔倒识别 Demo 的 5 个高频翻车点

4.1 运行步骤:从导入工程到模拟一次跌倒

拿到压缩包后,先用 Android Studio 打开工程根目录,等依赖同步完。不要直接在模拟器上点开始,加速度计在模拟器里通常是固定值,状态机永远进不了自由落体。真机开启开发者模式和 USB 调试,连上后跑app模块。

首次启动按这个顺序走:

  1. 授予通知权限,没有通知权限时先别点开始监测。
  2. 点“开始监测”,确认通知栏出现常驻通知。
  3. 把手机放口袋里走动 30 秒,看日志里rawG是否在 0.8 到 1.2 之间波动。
  4. 模拟跌倒:手持手机快速下坠到软垫,别真摔手机,观察状态是否从IDLE走到IMPACT。
  5. 锁屏 1 分钟再重复一次,确认前台服务还在采数据。

模拟跌倒时,自由落体阶段要短暂,冲击要明显。用手把手机从 30 厘米高度松到沙发垫上,通常能打出 2g 以上冲击;如果日志里rawG峰值只有 1.5g,先把冲击阈值降到 2.2g 试一次,再决定是否改硬件佩戴方式。

4.2 常见问题排查:现象、原因、解决

现象一:模拟器上日志一直不动,状态机永远空闲。原因:模拟器没有真实加速度计,SensorEvent返回固定数组。 解决:换真机;必须在模拟器上演示时,用传感器注入工具手动喂三轴数据,但别拿注入结果判断算法好坏。

现象二:屏幕一锁,跌倒不报警。原因:传感器注册在 Activity,界面不可见后回调被停;或者服务被系统限制。 解决:把注册放进前台服务,通知类型声明正确,测试机把 App 加入电池优化白名单。锁屏后看onSensorChanged是否还有日志,没有日志就是生命周期问题,不是阈值问题。

现象三:坐着起身、把手机放桌上也报警。原因:只看了冲击阈值,没有静止确认和姿态角;或者冲击阈值太低。 解决:把冲击阈值从 2.2g 提到 2.6g 以上,静止窗口加到 2500ms,姿态角阈值加到 55 度。先录一段正常动作的 CSV,回放看误报点落在哪个状态,别盲调。

现象四:报警时通知栏没有弹窗。原因:没有申请POST_NOTIFICATIONS,或者通知渠道被用户关掉,或者渠道重要性太低。 解决:启动时运行时申请权限;在设置页引导用户检查通知渠道;报警通知用高重要性渠道,并加震动。代码里notify不报错不代表用户能看到。

现象五:registerListener返回 false,或者服务启动就崩。原因:设备没有加速度计,采样率不支持,或者前台服务类型没声明。 解决:先判断getDefaultSensor是否为 null,为空就提示设备不支持;前台服务类型按健康类声明;把registerListener的返回值打日志,失败时降一档采样率再试。

4.3 用日志和曲线验证阈值

调阈值最忌“凭感觉”。我一般会在onSensorChanged里把时间戳、三轴、rawG、状态写进 CSV,跑完导出来看曲线。正常走路是 0.8 到 1.2g 的小波动,自由落体有一段明显低于 0.6g,冲击是一个尖峰,静止后回到 1g 附近。把这三个阶段在曲线上标出来,阈值就有依据。

# 真机调试时抓日志,过滤自己的 tag adb logcat -s FallDetector:V FallService:V

-s后面跟的是日志 tag,代码里用Log.d("FallDetector", ...)打状态迁移,用Log.d("FallService", ...)打传感器注册结果。日志里重点看状态顺序是不是IDLE -> FREEFALL -> IMPACT -> STILL -> ALARM,如果经常FREEFALL直接回IDLE,说明自由落体窗口太长或冲击没达到阈值。CSV 回放比真机反复摔手机靠谱,也更容易对比两套参数。

5. 二次开发:把误报压下去并把报警接出去

第一版跑通后,最值得改的不是界面,而是误报和报警出口。单阈值状态机在实验室里够用,放到真实场景里会遇到跑步、下楼梯、坐电梯。我一般会先加特征,不急着换模型:滑动窗口里算均值、方差、峰值、过零率、姿态角变化,再用一个轻量分类器做二次确认。特征和状态机不冲突,状态机负责触发候选,分类器负责压误报。

改造点做法验证指标
多特征分类2 秒窗口提取均值、方差、峰值、过零率、姿态角召回率、每天误报次数
场景判别用屏幕状态、位置传感器区分口袋、手持、桌面误报下降比例
报警外发定义FallAlarmSink接口,本地通知和远端上报解耦断网重试、重复上报是否幂等
离线回放CSV 录制与回放,跑同一套判定类混淆矩阵、状态迁移次数

接报警出口时,别把网络请求写进FallDetector。判定类只吐FallEvent,服务层决定发通知还是调接口。下面这个接口就够用:

interface FallAlarmSink { fun onFall(event: FallEvent) fun onCancel(event: FallEvent) }

onFall里做本地通知、震动、上报;onCancel里把已经排队的紧急动作撤掉。网络上报要加幂等键,避免重试导致重复通知。验证时先录 20 组正常动作和 10 组模拟跌倒,离线回放跑参数,再上真机。阈值不是玄学,记录曲线后就是调参问题。从那以后我每次改阈值都先走一遍 CSV 回放,再拿真机试,不直接拿手机反复摔。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询