Android跌倒检测识别Demo:从传感器到TFLite状态机报警实现
简介面向Android开发者与AI算法落地人员针对移动端实时跌倒检测需求提供可直接安装运行的完整Demo尤其适合需要快速评估跌倒识别模型效果、或希望将其能力集成进自有App的开发者。资源为zip压缩包共2个文件APK安装包已封装检测模型与实时推理逻辑打开即可体验完整识别流程JSON文件用于模型参数与标签映射配置便于二次开发时理解输出结构。整个包约50.44MB轻量精简下载与部署成本低。目前已有1945人学习下载是跌倒检测系列方案中面向Android平台的基础示例。借助该Demo可直观观察从画面采集、目标检测到跌倒判定的全链路表现为后续算法调优、功能裁剪或业务集成提供一个可对照的工程起点降低从模型训练到端侧落地的转换门槛。无论初次接触还是已有相关基础都能通过该示例快速建立端侧跌倒检测的实现脉络。1. 这个 Android 跌倒检测识别 Demo 能干什么把“摔倒”变成一次及时触发的报警老人或者独居用户在客厅摔倒如果身边没有人手机是唯一可能在场的设备。这套 Android 跌倒检测识别 Demo 就是一个完整可编译的 Android 工程它用手机自带的加速度计和陀螺仪做跌倒识别和摔倒识别核心目标是在真实跌落后 5 到 10 秒内弹出报警界面而不是简单地在合加速度超过阈值时乱吼。工程把传感器数据流切成滑动窗口计算冲击峰值、姿态角度差和静止时长再交给本地 TensorFlow Lite 模型输出“跌倒/非跌倒”概率最后用一套三级状态机做二次确认。它适合两类人一类是刚接触 Android 传感器开发想找一条完整落地链路的初学者另一类是已经做过跌倒检测算法、想要一个现成手机端壳子和真机验证流程的开发者。跑完这个 Demo你得到的不是一段算法碎片而是从传感器采样、特征提取、模型推理到报警交互都打通的最小闭环。2. 工程结构与技术选型拿到 zip 先认清包、依赖和模块边界2.1 源码包的目录结构和类职责解压后是一个标准的 Android Studio 工程Gradle 插件版本和 Kotlin 依赖都能直接同步。推荐先看app/src/main/java下的包结构而不是急着改界面。下面把各个类的作用拆清楚app/src/main/java/com/example/fallalarm/ ├── MainActivity.kt # 主界面启动服务、显示实时状态、开关权限 ├── sensor/ │ ├── FallSensorService.kt # 前台服务统一注册加速度/陀螺仪监听 │ ├── SensorWindow.kt # 环形缓冲区维护最近 N 帧采样数据 │ └── SensorSample.kt # 采样数据模型时间戳、三轴加速度、三轴角速度 ├── detect/ │ ├── FeatureExtractor.kt # 从滑动窗口提取输入特征 │ ├── FallClassifier.kt # 封装 TFLite 推理负责模型加载与输出 │ ├── FallStateMachine.kt # 三级状态机IDLE / IMPACT / STATIC / ALERT │ └── FallConfig.kt # 阈值、窗口长度、报警等待时间等集中配置 ├── alarm/ │ ├── AlarmActivity.kt # 全屏报警页倒计时、取消按钮、震动/铃声 │ └── AlarmScheduler.kt # 报警调度状态机确认后触发页面和通知 └── util/ └── GravityOrientation.kt # 用重力分量修正方向减少手机摆放角度影响这个工程里最值得读的是FallConfig.kt。它把所有阈值集中在一个对象里例如impactThresholdG 2.2f、staticDurationMs 1200、alertDelayMs 8000。把这部分参数集中在同一处是为了在真机上反复调阈值时不用翻找业务代码。如果你拿到的源码版本结构有出入也建议先定位 config 类读它基本能看出当时的判定侧重点冲击大、静止久、姿态变化明显三者之间是“与”还是“或”的组合关系。2.2 技术选型为什么是 Kotlin TFLite 有限状态机技术栈不是随意拼出来的。传感器采集用 Android 官方SensorManager业务逻辑用 Kotlin 协程和HandlerThread处理回调推理层用 TensorFlow Lite 加载 assets 里的fall_model.tflite状态判定没有让模型单独打天下而是用一套阈值状态机做后置治理。这样分层的原因是纯模型在跌倒这类长尾、低频率事件上样本少、误报高纯阈值在剧烈运动场景又压不住误触发。折中做法是模型输出概率作为一个预判再由状态机检查“是否有明显冲击、之后是否出现持续静止、姿态是否发生改变”三个条件都满足才进报警。方案优点缺点Demo 里的落地方式纯阈值可解释、好调试跑步/跳跃误报高峰值 低活动时长纯 TFLite 模型能学习多姿态特征数据少、解释成本高输出跌倒概率阈值 模型 状态机兼顾召回和精确率参数多、需要真机调模型预判 时序确认依赖方面Gradle 里只多出必要的几个库org.tensorflow:tensorflow-lite用于推理androidx.lifecycle:lifecycle-service用于在 Service 和 Activity 之间传递状态其余尽量用 SDK 自带接口。这样的好处是对只想改业务、不想动底层依赖的开发者友好同步一次 Gradle 后基本不会遇到依赖冲突。报警页AlarmActivity使用全屏 Intent 加FLAG_ACTIVITY_NEW_TASK的形式这里的细节和 Android 10 以上的后台启动限制直接相关第 4 章会展开。2.3 数据流链路一个采样点怎么走到报警页面整个识别链路是单向流从底层往上定位问题会比较快SensorManager注册加速度计和陀螺仪采样率使用SENSOR_DELAY_GAME大约 50Hz满足跌倒识别对冲击波形的基本要求又不会像FASTEST一样让手机发烫。每次onSensorChanged把采样点写入SensorWindow的环形缓冲区窗口默认 64 个点大约覆盖 1.28 秒。窗口每滑动 200ms 才计算一次特征避免每个采样点都丢进模型给 CPU 留出余量。FeatureExtractor输出 6 维特征FallClassifier包装 TFLite 的Interpreter做推理输出“跌倒概率”。FallStateMachine把模型概率和原始特征合并成一个状态转移判定进入ALERT状态后交给AlarmScheduler。AlarmScheduler启动全屏AlarmActivity同时发通知、震动、响铃等待用户按键取消。这中间最容易出问题的不是算法本身而是第 2 步的时序。手机上传感器回调并不同步加速度计和陀螺仪是两个硬件到达时间可能相差几十毫秒。工程里用SensorSample.timestampNanos做时间对齐超过 40ms 的陀螺仪数据在特征计算时会做线性插值避免角度积分时把时间轴拉歪。这个细节对真机识别影响很大很多“动作明明对了却识别不出来”的情况都出在这里。2.4 权限与前台服务配置让采集在灭屏时也能持续传感器本身不需要运行时权限但要让采集在 App 退到后台时继续跑得在 Manifest 里做好声明。Demo 工程里的关键配置如下uses-feature android:nameandroid.hardware.sensor.accelerometer android:requiredtrue / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /uses-feature声明加速度计是必需硬件这样在 Google Play 上架时可以让没有加速度计的平板被过滤掉。FOREGROUND_SERVICE权限保证前台服务可以长期运行POST_NOTIFICATIONS是 Android 13 之后通知栏弹窗的前提没有它前台服务的常驻通知不会显示用户会误以为采集已经停了。真机跑的时候第一次启动会跳通知授权弹窗这个要记得点允许否则状态机检测到跌倒之后全屏报警页也依赖通知通道弹出。3. 核心模块拆解跌倒检测和识别的完整链路3.1 传感器注册与环形窗口维护FallSensorService负责传感器生命周期所有回调运行在独立HandlerThread上避免主线程卡顿。核心代码// FallSensorService.kt 传感器注册 class FallSensorService : Service() { private lateinit var sensorManager: SensorManager private val window SensorWindow(capacity 64) private val handlerThread HandlerThread(fall-sensor).apply { start() } override fun onCreate() { super.onCreate() sensorManager getSystemService(Context.SENSOR_SERVICE) as SensorManager val accel sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) val gyro sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) val listener object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type Sensor.TYPE_ACCELEROMETER) { window.addAccel( event.values[0], event.values[1], event.values[2], event.timestamp ) } else if (event.sensor.type Sensor.TYPE_GYROSCOPE) { window.addGyro( event.values[0], event.values[1], event.values[2], event.timestamp ) } window.maybeRunDetection() } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} } sensorManager.registerListener(listener, accel, SensorManager.SENSOR_DELAY_GAME) sensorManager.registerListener(listener, gyro, SensorManager.SENSOR_DELAY_GAME) } }SENSOR_DELAY_GAME大约是 20ms 一次采样比SENSOR_DELAY_UI密集又不像SENSOR_DELAY_FASTEST那样高功耗。由于两个传感器分别回调window.addAccel和window.addGyro各自维护一份环形缓冲时间戳会保存在数组里用于对齐判断。maybeRunDetection()是节流点只有距离上次特征计算超过 200ms 才继续往下走相当于每 10 个采样点滑动一次窗口。SensorWindow的环形缓冲实现如下// SensorWindow.kt 环形缓冲区核心逻辑 class SensorWindow(private val capacity: Int 64) { private val times LongArray(capacity) private val accel Array(capacity) { FloatArray(3) } private var head 0 private var filled 0 fun addAccel(x: Float, y: Float, z: Float, ts: Long) { head (head 1) % capacity times[head] ts accel[head][0] x accel[head][1] y accel[head][2] z if (filled capacity) filled } fun isReady(): Boolean filled capacity fun accelMagnitude(): ListFloat { return (0 until filled).map { i - val idx (head - i capacity) % capacity kotlin.math.sqrt( accel[idx][0] * accel[idx][0] accel[idx][1] * accel[idx][1] accel[idx][2] * accel[idx][2] ) } } }环形缓冲用head指针覆盖最旧的数据保证窗口始终是最近 64 个采样。accelMagnitude()计算每个采样点的合加速度这是后续所有特征的基础。注意capacity是“点数量”而不是“时间长度”如果系统降低采样率同样 64 点覆盖的时间变长所以第 4.2 节会说为什么必须用时间戳做校验。3.2 特征提取与 TFLite 推理特征提取是决定召回率和精确率的关键。代码从窗口里提取 6 个特征// FeatureExtractor.kt 从窗口提取 6 维特征 fun extract(window: SensorWindow): FallFeatures { val mag window.accelMagnitude() val peak mag.maxOrNull() ?: 0f val min mag.minOrNull() ?: 0f val mean mag.average().toFloat() val highImpactCount mag.count { it 2.5f } val angleDiff window.computeTiltChange() val lowActivityDuration window.lowActivityDurationMs(threshold 0.8f) return FallFeatures( peak peak, min min, mean mean, highImpactCount highImpactCount, angleDiff angleDiff, lowActivityDuration lowActivityDuration, ) }peak是窗口内合加速度最大值正常站立接近 1g跌倒瞬间往往超过 2.5gmin代表自由落体阶段可能接近 0g所以“低值 高值”组合比单个峰值更可靠。highImpactCount统计超过 2.5g 的采样数数量太少可能是抖一下数量过多也可能是手机砸地后的剧烈弹跳后续状态机结合静止时长判断。angleDiff由陀螺仪积分得到单位是弧度跌倒一般伴随 90 度左右的躯干翻转lowActivityDurationMs是合加速度持续低于 0.8g 的时长用于区分“走路绊倒”和“跑步跳跃落地”跳跃落地冲击大但之后不会静止。// FallClassifier.kt 封装 TFLite 推理 class FallClassifier(context: Context) { private val interpreter: Interpreter init { val modelBytes context.assets.open(fall_model.tflite).readBytes() interpreter Interpreter(ByteBuffer.wrap(modelBytes)) } fun predict(features: FallFeatures): Float { val input Array(1) { FloatArray(6) }.apply { this[0][0] features.peak this[0][1] features.min this[0][2] features.mean this[0][3] features.highImpactCount.toFloat() this[0][4] features.angleDiff this[0][5] features.lowActivityDuration / 1000f } val output Array(1) { FloatArray(1) } interpreter.run(input, output) return output[0][0] } fun close() { interpreter.close() } }TFLite 的Interpreter在 init 时从 assets 读取模型并常驻predict每次复用同一个实例避免反复 IO。输入是Array(1) { FloatArray(6) }6 个特征按顺序放入输出是单节点概率值越接近 1 表示模型越倾向于“跌倒”。lowActivityDuration / 1000f是把毫秒缩放到秒让它和均值、峰值的数值范围处于同一量级避免模型输入特征量纲差异过大。模型的输入特征数量必须和训练时完全一致这是最容易翻车的地方。如果你拿的是自己训练的模型先确认导出前的输入 shape再改这里的FloatArray(6)否则Interpreter.run会直接抛IllegalArgumentException而且不会提示“少一个特征”只会笼统说维度不匹配。调这种问题通常是对着 Python 侧训练代码的input_details一项一项核对。3.3 三级状态机与报警触发模型概率不能直接作为报警信号状态机负责把它转成可接受的低误报判定enum class FallState { IDLE, IMPACT, STATIC, ALERT } class FallStateMachine(private val config: FallConfig) { private var state FallState.IDLE private var impactTime 0L private var staticStart 0L fun transition( fallProbability: Float, peak: Float, lowActivityDurationMs: Long, nowMs: Long ): FallState { when (state) { FallState.IDLE - { if (fallProbability 0.7f peak config.impactThresholdG) { state FallState.IMPACT impactTime nowMs } } FallState.IMPACT - { if (lowActivityDurationMs config.staticDurationMs) { state FallState.STATIC staticStart nowMs } else if (nowMs - impactTime 1500) { state FallState.IDLE } } FallState.STATIC - { if (nowMs - staticStart 800 lowActivityDurationMs 500) { state FallState.IDLE } else if (nowMs - staticStart config.confirmStaticMs) { state FallState.ALERT } } FallState.ALERT - Unit } return state } }状态转移逻辑IDLE 态只有同时满足模型概率高和冲击峰值高于 2.2g 才进入 IMPACT这一步把弯腰捡东西、剧烈挥手排除掉IMPACT 态必须在 1.5 秒内观察到一个持续静止期否则回到 IDLE说明冲击之后人还能正常活动STATIC 态如果中途又出现连续活动会回退 IDLE这是防止摔倒后站起来继续走动被做成二次报警保持静止超过confirmStaticMs默认 1200ms 才进入 ALERT。三段是“与”的关系任何一个环节不满足就不会报警。报警触发不是直接startActivity而是先发前台通知再通过setFullScreenIntent弹出全屏页面// AlarmScheduler.kt 构造全屏通知 val fullScreenIntent Intent(this, AlarmActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pending PendingIntent.getActivity( this, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification NotificationCompat.Builder(this, fall_alarm) .setSmallIcon(R.drawable.ic_alarm) .setContentTitle(检测到疑似跌倒) .setContentText(请确认您是否安全) .setFullScreenIntent(pending, true) .setPriority(NotificationCompat.PRIORITY_MAX) .build()全屏通知是 Android 10 以上后台弹界面的可靠路径。AlarmActivity启动后展示 8 秒倒计时用户点“我没事”则取消报警并回到 IDLE倒计时结束仍无人操作才进入真正的求助流程比如发送定位短信。这样设计的好处是给误报留了人工出口不至于检测错一次就慌乱。4. 避坑记录真机调试时最容易翻车的四个环节4.1 现象手机放在桌上被敲一下也会触发报警原因早期版本只用了峰值阈值加模型概率桌面上敲击的冲击峰值也能超过 2g模型对“剧烈冲击”召回高但精确率不稳定桌面碰撞和真跌倒都被归成了正样本。解决给状态机增加“冲击后静置确认”。跌倒的强特征是强烈冲击之后人体短时间内不再大范围活动而敲击桌面后加速度很快恢复。具体做法是把lowActivityDurationMs 1200作为从 IMPACT 进入 STATIC 的条件这一条直接把桌面碰撞全部拦截。调参时不要只降峰值阈值更要同时拉长静置时长否则躺着翻身也会进来。4.2 现象低端机上识别结果时好时坏合加速度波形出现明显锯齿原因不同机型的采样策略不同。有的机型亮屏时能稳定 50Hz灭屏后为了省电降到 10 到 20Hz还有的设备加速度计和陀螺仪时间戳差了 100ms 以上。按固定 64 点取窗口时实际覆盖的时间长度已经变了特征里的静止时长和冲击峰值都失真。解决不要用点数当时间基准用时间戳。SensorWindow里判断窗口内最大最小时间戳跨度小于 800ms 就不进识别并写一条日志。计算低活动时长也用“连续低值时间”而不是“点数乘以固定采样周期”。另外不要用SENSOR_DELAY_FASTEST功耗一高系统会主动降频反而中断采样。4.3 现象从最近任务划掉应用后报警页面弹不出来原因Demo 默认整个进程和 MainActivity 绑定用户清理后台任务时进程连同前台服务一起被杀。虽然 Service 调用了startForeground但如果没有常驻通知系统依然认为它是可回收的后台进程。解决把传感器采集放在带startForeground通知的前台服务里并且在AlarmScheduler里使用setFullScreenIntent而不是普通startActivity。Android 10 以上后台弹 Activity 限制非常严格全屏通知是唯一能在进程活着时把界面拉到前台的可靠方式。同时把pendingAlertId写入本地缓存进程被杀死后下次启动还能补发一次告警。4.4 现象报警页反复退出再进入内存占用越来越大原因FallClassifier里的Interpreter没有释放。常见误用是在每次识别时重新 new 一个实例退出页面时忘记 close导致模型文件在 native 堆上的映射一直挂着内存曲线只升不降。解决让 Classifier 成为单例初始化只在 ServiceonCreate里做一次ServiceonDestroy里显式调用interpreter.close()。用 Memory Profiler 看 native 内存曲线如果每次退出页面内存不下降基本就是 Interpreter 没释放。这个坑在资产管理里最容易出现也是我建议把 Classifier 做成单例的直接原因。排查这类问题时建议在 Demo 里把每次状态转移IDLE - IMPACT - STATIC - ALERT和当时的特征值写进一个环形日志 buffer误报或漏报后导出 CSV配合录屏复盘。这个动作是第 5 章改造模型特征的基础没有真实样本记录改参数永远是玄学。5. 二次开发方向从 Demo 改成能用的个人跌倒告警器5.1 改造一报警后带上位置信息当前报警页只做界面和倒计时真正告警还需要位置信息才完整。改造点是在AlarmActivity倒计时结束时读取LocationManager最近一次定位拼进短信内容。常见做法是让AlarmScheduler提供一个sendSosMessage(String location)方法在倒计时为 0 时调用。如果不想引入高精度定位权限可以只填“当前时间 手机型号 经纬度”短信长度控制在 140 字内。实现时建议把位置读取放到子线程因为getLastKnownLocation在部分机型上有几十毫秒的阻塞。测试时先手动给LocationManager注入一个 mock 位置不要直接站在窗口外面等真实定位否则无法判断是定位慢还是短信服务没发出去。5.2 改造二记录误报样本回补训练数据跌倒模型的上限由训练数据决定但训练数据的缺口通常要靠真机误报来补。我会在FallStateMachine每次进入 ALERT 时把当前位置的特征写入本地 CSV 文件字段包括timestamp, peak, min, mean, highImpactCount, angleDiff, lowActivityDuration, modelProbability, state。一个月下来积累几十条“被误报成跌倒”的样本拿去重新训练模型效果比盲目调阈值好得多。配套的验证方法是固定一台真机、固定佩戴位置回归测试一组动作走路、小跑、坐下、躺下、弯腰捡东西、正常站起、前倾跌倒、侧倒、慢速坐下。每个动作重复 5 遍记录状态机的最终状态。从那以后我每次调阈值都强制把这套 20 个动作的回归脚本跑一遍确认没有引入新误报。希望帮到你。本文还有配套的精品资源点击获取