Android跌倒检测Demo实战:加速度状态机与阈值调优 简介这份跌倒检测识别Android Demo面向希望在移动端实现实时摔倒识别的开发者、学生与项目预研人员提供可直接安装运行的Android应用包及配套元数据便于快速验证跌倒检测模型在手机端的推理速度、检测效果与交互流程。压缩包共2个文件以apk安装包为主体另含1个json元数据文件整体约50.44MB体积轻便适合真机安装测试、功能演示与二次开发参考。已有1946人学习下载说明其在跌倒检测和识别方向具有一定参考价值。借助该Demo读者可以直观了解Android端实时跌倒检测的功能形态、界面组织与模型部署结果并可用于课程设计、毕业项目、移动端智能监控方案的可行性验证尤其适合居家养老看护、公共场所安全监控等场景的原型验证同时也能为后续对照训练代码、数据集说明及不同平台实现提供实践入口。1. 跌倒检测 Demo 能跑出什么先把预期摆在桌面上打开这个跌倒检测识别Android Demo.zip之前先想清楚它能帮你验证什么。这是一份跑在 Android 手机上的跌倒检测识别样例工程核心思路是用手机内置的加速度传感器拿到三轴数据再通过状态机判断「失重 → 撞击 → 静止」这条典型的摔倒曲线输出一次跌倒事件。它适合三类人做课程设计或毕设、需要一个能演示的原型做可穿戴或养老看护类产品、想先验证算法可行性以及想搞清楚「手机端摔倒识别到底难在哪」的开发者。它不能直接当医疗级报警器用这一点后面会反复讲。2. 两条技术路线的选型阈值状态机与骨骼关键点各自的边界在 Android 上做跌倒识别绕不开一个选型问题是只用加速度传感器跑阈值还是开摄像头跑人体姿态。这两条路线的成本差一个数量级准召率也差一个量级Demo 里常见的是前者但你要知道后者补的是什么。2.1 加速度三轴阈值法算力和功耗最低的那条路加速度法的物理依据很朴素人正常走路时合加速度在 9.8 m/s²1g附近小幅波动摔倒过程中会经历三段可辨识的波形。第一段是失重脚底打滑或者身体腾空的瞬间合加速度会掉到 3 m/s² 以下接近自由落体第二段是撞击身体触地那一刹那峰值常常冲到 2550 m/s²也就是 2.55g第三段是撞击后的静止或微弱挣扎合加速度回落到接近 1g 但方差很小。把这三段串成状态迁移就是一个可以落地的检测器。它的优点是采样率 50Hz 就够单帧计算量只有几次乘加手机放兜里、放包里都能跑锁屏状态下功耗也在毫瓦级。缺点同样明显它分不清「摔倒」和「把手机扔到床上」也分不清「老人缓慢扶墙坐下」和「轻微踉跄」。所以工业界常见的做法是加速度做一级触发再用别的手段做二级确认。2.2 端侧关键点回归能分清「蹲下系鞋带」和「摔倒」摄像头路线的逻辑是先做人形关键点回归拿到肩、髋、膝、踝的坐标再用几何关系判断躯干是否接近水平、重心是否在支撑面外、关键点是否在短时间内大幅下沉。主流做法是接一个轻量级的端侧推理框架把关键点模型量化到 INT8在中端机上做到 1525 FPS。这条路线真正的价值在于语义区分蹲下系鞋带时躯干前倾但重心稳摔倒时髋部高度会在 0.5 秒内掉到画面下部 1/3 且躯干角超过 60 度两者的几何特征完全不同。代价是功耗高、有隐私顾虑、暗光下模型退化严重。我的建议是如果这份 Demo 只是让你跑通链路先把加速度这条路走扎实真要做产品加速度负责「疑似」、摄像头负责「确认」两级串联比任何单级都稳。2.3 采样率、量程与坐标系三个必须先钉死的参数动手之前有三个参数必须先确认否则后面所有阈值都是玄学。参数常见取值为什么关键采样率SENSOR_DELAY_GAME约 50Hz低于 25Hz 会漏掉撞击峰值高于 100Hz 收益递减且耗电量程≥ ±2g建议 ±8g量程不够时撞击波形会被削顶峰值失真坐标系设备坐标系随手机朝向旋转手机在口袋里怎么放是不确定的直接看单轴必翻车前两个参数可以在运行时读出来第三个参数决定了你必须用合加速度而不是某一轴的分量。合加速度sqrt(x²y²z²)对手机朝向不敏感这是它成为跌倒检测默认特征的根本原因。下面这段代码把这三件事一次性钉死class FallSensorCollector( private val context: Context, private val onSample: (Sample) - Unit ) : SensorEventListener { data class Sample( val timestampNs: Long, val x: Float, val y: Float, val z: Float, val magnitude: Float ) private val sensorManager context.getSystemService(Context.SENSOR_SERVICE) as SensorManager // TYPE_ACCELEROMETER 含重力分量静止时合向量约 9.81 private val accelerometer: Sensor? sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) fun start(): Boolean { val sensor accelerometer ?: return false // 50Hz 左右跌倒检测的经验甜点 return sensorManager.registerListener( this, sensor, SensorManager.SENSOR_DELAY_GAME ) } fun stop() sensorManager.unregisterListener(this) override fun onSensorChanged(event: SensorEvent) { val x event.values[0] val y event.values[1] val z event.values[2] // 合加速度对手机朝向不敏感是首选特征 val magnitude sqrt(x * x y * y z * z) onSample(Sample(event.timestamp, x, y, z, magnitude)) } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) Unit }这段代码里有两个容易忽略的点。event.timestamp单位是纳秒而且是传感器时钟不是系统时钟做时间窗计算时只能用它自己减自己混用System.currentTimeMillis()会算出离谱的间隔。SENSOR_DELAY_GAME只是「建议值」系统在低电量或高负载时会自动降频实际间隔可能在 2060ms 之间抖动所以状态机里的时间窗必须用真实时间戳算不能假设「一帧就是 20ms」。3. 把工程跑起来从导入到屏幕上出现第一组合加速度拿到一个 Android Demo 压缩包最怕的是打开就一堆红。这一章按「能不能跑 → 数据对不对 → 判定准不准」的顺序推进每一步都有可验证的产出。3.1 工程骨架与四个必须认识的类这类 Demo 的目录结构大同小异你真正需要定位的是四类文件传感器采集类注册监听、算合加速度、检测算法类状态机或阈值判断、前台服务或 ViewModel保证锁屏后还在跑、以及一个把结果显示出来的 Activity。找文件的方法不是一个个点开而是全局搜这几个关键词SensorEventListener、TYPE_ACCELEROMETER、onSensorChanged、startForeground。如果搜startForeground一无所获说明这份 Demo 的采集逻辑挂在 Activity 或 Fragment 生命周期上切后台就会断——这是原型工程的通病不是 bug但你得知道它意味着「只能亮屏演示」。定位完这四个类先别改代码直接编译跑一遍确认能在界面上看到数值在跳。3.2 传感器可用性检查与降级路径不是所有设备都有你想要的传感器。模拟器基本没有加速度计部分低端平板会缺线性加速度传感器个别机型在熄屏后会关闭传感器。所以启动前必须做一次能力探测把结果打到界面上而不是等用户点了「开始检测」之后一片空白。data class SensorReport( val hasAccelerometer: Boolean, val maxRange: Float, // 单位 m/s^2小于 19.6 说明量程只有 ±2g val minDelayUs: Int, // 最小采样间隔越小说明支持越高采样率 val hasLinearAccel: Boolean, val hasGyroscope: Boolean ) fun probeSensors(context: Context): SensorReport { val sm context.getSystemService(Context.SENSOR_SERVICE) as SensorManager val acc sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) return SensorReport( hasAccelerometer acc ! null, maxRange acc?.maximumRange ?: 0f, minDelayUs acc?.minDelay ?: 0, hasLinearAccel sm.getDefaultSensor( Sensor.TYPE_LINEAR_ACCELERATION) ! null, hasGyroscope sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE) ! null ) }maximumRange小于 19.6 m/s² 的机型要特别注意撞击峰值会被硬件削顶你测到的「最大 19.6」其实是假的这时候失重判据还能用撞击判据必须放宽或者直接退化成一阶触发。minDelay为 0 通常表示该传感器上报的是「变化即上报」模式实际频率不可控做时间窗时容错要留足。3.3 一条采样数据从进来到出结果的完整链路链路本身不复杂但每一环都有人栽过传感器回调 → 单位与时间戳归一化 → 合加速度计算 → 滑动窗口平滑 → 状态机判定 → 事件上报本地通知 / 界面提示 / 网络上报。这里最容易出问题的是「平滑」这一步。原始合加速度噪声很大手抖一下就有 ±2 m/s² 的毛刺但如果你用长窗口做均值平滑撞击峰值会被抹平反而检测不到。正确做法是双通道一条原始值走峰值检测一条平滑值走静止判定。下面是一个最小实现class DualChannelWindow( private val smoothSize: Int 8 // 约 160ms 50Hz ) { private val smoothBuf ArrayDequeFloat() private var peak 0f private var peakTs 0L // 原始通道只关心窗口内的最大值用于撞击检测 fun pushRaw(mag: Float, tsNs: Long) { if (mag peak) { peak mag; peakTs tsNs } } // 平滑通道均值用于失重与静止判定 fun pushSmooth(mag: Float): Float { smoothBuf.addLast(mag) if (smoothBuf.size smoothSize) smoothBuf.removeFirst() return smoothBuf.average().toFloat() } fun peekPeak(): Float peak fun resetPeak() { peak 0f; peakTs 0L } // 窗口内波动幅度静止判定的核心指标 fun variance(): Float { if (smoothBuf.size 2) return Float.MAX_VALUE val mean smoothBuf.average().toFloat() return smoothBuf.map { (it - mean) * (it - mean) }.average().toFloat() } }peak和resetPeak()必须成对使用撞击确认后立刻清零否则同一个峰值会在后续窗口里反复触发判定造成一次摔倒报好几次。variance()用的是方差而不是极差因为极差对单点毛刺过于敏感方差更能反映「人是不是躺着不动」。3.4 用日志验证数据是否可信判定不准八成是数据本身有问题。别急着调阈值先把原始数据导出来看一眼。最土也最有效的办法是每 100ms 打一条日志包含时间戳、三轴、合加速度跑一次真实的「走路 → 坐下 → 躺下 → 故意摔到床上」然后把日志拉出来对波形。如果你在电脑上做分析把日志存成 CSV用下面这段脚本快速看一眼统计特征比自己盯着数字强import csv rows [] with open(accel_log.csv, newline) as f: for r in csv.DictReader(f): rows.append(float(r[magnitude])) if rows: print(样本数:, len(rows)) print(均值:, round(sum(rows) / len(rows), 2)) print(最大:, round(max(rows), 2)) print(最小:, round(min(rows), 2)) # 静止段的波动应该在 ±0.5 以内超了说明手机没放稳 print(极差:, round(max(rows) - min(rows), 2))均值应该在 9.8 附近最小值如果长期低于 3 说明采集有问题比如单位被当成了 g 处理最大值不到 15 说明你「摔」得太温柔或者量程被削顶。这一步做完你才有资格去谈阈值。4. 四态机实现把「摔倒」拆成失重、撞击、静止三段阈值法最常见的失败写法是「合加速度一旦超过 30 就报警」这种单点判断在真实场景里误报率高到没法用。真正能落地的写法是状态机把时间维度纳进来。4.1 状态机设计IDLE → FREE_FALL → IMPACT → SETTLING四个状态的语义很直白。IDLE是常态检测到合加速度连续一段时间低于失重阈值进入FREE_FALL在失重窗口内出现超过撞击阈值的峰值进入IMPACT撞击后一段时间内波动足够小进入SETTLING并判定为跌倒然后回到IDLE。任何一步超时或条件不满足都退回IDLE。关键在于每个状态都有超时。没有超时的状态机会卡死比如失重之后人接住了手机没摔下去状态就永远停在FREE_FALL后面所有真实跌倒都检测不到。这是我看过最多的翻车点。4.2 完整的状态机实现enum class FallState { IDLE, FREE_FALL, IMPACT, SETTLING } class FallDetector( private val cfg: Config Config(), private val onFall: (FallEvent) - Unit ) { data class Config( val freeFallMag: Float 3.5f, // m/s^2低于此值算失重 val freeFallMinMs: Long 120, // 失重至少持续多久 val freeFallTimeoutMs: Long 600, // 失重后多久没撞击就放弃 val impactMag: Float 22.0f, // m/s^2撞击峰值 val impactWindowMs: Long 1500, // 撞击必须在失重后多久内出现 val settleWindowMs: Long 2500, // 撞击后观察多久 val settleVariance: Float 1.2f, // 方差低于此值算静止 val cooldownMs: Long 5000 // 一次判定后的冷却期 ) data class FallEvent(val tsNs: Long, val peak: Float, val stage: String) private var state FallState.IDLE private var stateEnterNs 0L private var lastFallNs 0L private val win DualChannelWindow() fun onSample(mag: Float, tsNs: Long) { if (tsNs - lastFallNs cfg.cooldownMs * 1_000_000) return val smooth win.pushSmooth(mag) win.pushRaw(mag, tsNs) val elapsedMs { (tsNs - stateEnterNs) / 1_000_000 } when (state) { FallState.IDLE - { if (smooth cfg.freeFallMag) transition(FallState.FREE_FALL, tsNs) } FallState.FREE_FALL - { if (elapsedMs() cfg.freeFallTimeoutMs) { transition(FallState.IDLE, tsNs) // 超时放弃 } else if (win.peekPeak() cfg.impactMag) { transition(FallState.IMPACT, tsNs) win.resetPeak() // 峰值必须清零 } } FallState.IMPACT - { if (elapsedMs() cfg.impactWindowMs) { transition(FallState.IDLE, tsNs) } else if (elapsedMs() 300) { transition(FallState.SETTLING, tsNs) // 撞击后开始观察 } } FallState.SETTLING - { if (elapsedMs() cfg.settleWindowMs) { if (win.variance() cfg.settleVariance) { lastFallNs tsNs onFall(FallEvent(tsNs, win.peekPeak(), settled)) } transition(FallState.IDLE, tsNs) } } } } private fun transition(next: FallState, tsNs: Long) { state next stateEnterNs tsNs } }逻辑上要说明三件事。第一IDLE只检测失重不检测撞击这样能过滤掉绝大多数「手机磕到桌角」的单点冲击——没有失重前兆的撞击一律不算。第二FREE_FALL里的超时是必须的freeFallTimeoutMs这个参数决定了「失重后多久没撞击就作废」设太大会把后面的真实跌倒堵死设太小会漏掉下落时间长的场景600ms 是个折中。第三SETTLING用方差而不是绝对值判断静止因为人摔倒后可能还在地上动绝对值会随姿势变化但方差会稳定在低位。参数上impactMag是最敏感的一个。设 22 m/s² 约等于 2.2g能覆盖大部分中低端机型的撞击峰值如果你的机型量程只有 ±2g约 19.6这个阈值必须下调到 18 以下否则永远触发不了。cooldownMs是为了防止连续误报但设太大会漏掉「二次跌倒」老人摔倒后挣扎起身再次摔倒是个真实场景5000ms 是保守值。4.3 阈值的调整顺序乱调阈值是自找苦吃。正确的顺序是先固定采样率验证数据质量 → 调freeFallMag到能稳定识别自由落体拿手机做自由落体实验不用真摔→ 调impactMag到能覆盖你目标机型的最小撞击峰值 → 最后调settleVariance和settleWindowMs压误报。每调一个参数只跑同一组采集数据回放不要边调边测那样永远不知道是哪个参数起了作用。4.4 四种典型误报波形长什么样坐下并把手机放到桌上会在 300ms 内出现一次 1520 m/s² 的冲击但没有失重前兆IDLE直接过滤。把手机往床上扔有失重有撞击但撞击后马上被手接住方差下不去SETTLING阶段失败。弯腰捡东西手机在口袋里翻转合加速度有小幅下探但到不了 3.5进不了FREE_FALL。真正难处理的是「坐椅子坐空」这时候失重和撞击都真实存在跟跌倒几乎不可分——这类只能靠二级确认别指望加速度一个传感器解决。5. 避坑与排查五条踩过的坑5.1 现象静止时合加速度显示 1.0 左右撞击峰值只有 3原因把TYPE_ACCELEROMETER返回的值当成 g 处理了或者反过来用了TYPE_GRAVITY。也可能是某些机型在省电模式下把单位做了归一化。解决在启动日志里打一条静止状态下的原始三轴值sqrt之后应该接近 9.81如果不接近在采集层统一乘一个修正系数别在每个阈值上分别凑。5.2 现象亮屏时能用一切后台就再也不触发原因采集逻辑注册在 Activity 或 Fragment 上页面销毁监听就注销了或者是 Android 高版本的后台限制把长时间运行的任务掐了。解决把采集和判定挪到一个前台服务里配一个常驻通知startForeground之后再注册监听同时在onDestroy里显式注销避免重复注册导致回调翻倍。5.3 现象一次摔倒连续弹出五六条报警原因撞击峰值检测后没有清零同一个峰值在后续窗口里反复命中或者状态机在SETTLING结束后立刻回到IDLE而这时人还躺着残余波动又触发了新一轮。解决撞击确认后立刻resetPeak()判定成功后加冷却期并且冷却期内完全不处理样本而不是只屏蔽回调。5.4 现象模拟器上跑不起来界面一直是「等待数据」原因模拟器默认没有加速度传感器getDefaultSensor返回 null。解决启动时做能力探测传感器缺失时直接给出明确提示并提供一条「模拟数据注入」的调试路径用定时器按预设波形喂样本这样在没有真机的环境下也能验证判定逻辑不耽误开发。5.5 现象换一台手机同一个 APK 误报率翻了三倍原因不同厂商的传感器量程、滤波策略、上报频率差异很大有的机型在硬件层就做了低通滤波撞击峰值被压掉一半有的机型在息屏后降频到 5Hz。解决把maxRange和minDelay作为运行时参数参与阈值计算而不是写死常量上线前至少覆盖三档机型低端/中端/旗舰各跑一遍固定动作集把结果记成基线后面任何改动都跟基线比。6. 进阶把「能识别」变成「可验证」一套离线回放与阈值搜索流程Demo 跑通只能说明链路通了说不明算法行不行。真要把跌倒检测做到可交付得有一套能反复跑的评估流程。我一般的做法是分三步录数据、离线回放、网格搜阈值。6.1 最小可用的数据录制方案不用搞复杂的标注平台。让三五个同事各做六个动作正常走路 2 分钟、坐下起立 10 次、弯腰捡东西 10 次、躺到床上 5 次、往沙发上摔手机 10 次、原地小跑 1 分钟另外单独录 10 次「从站立到侧倒」的垫子摔倒。每个动作录完打一个时间戳标记存成 CSV字段就是时间戳、三轴、合加速度、动作标签。这套数据量不大但足以把误报率从「感觉还行」压到「有数字」。6.2 阈值网格搜索把状态机逻辑原样移植到 Python对失重阈值、撞击阈值、静止方差做三维网格搜索用 F1 或者你更在意的指标养老场景通常更在意召回率排序import itertools def run_state_machine(mags, cfg): # 与 Kotlin 端保持完全一致的判定逻辑 state, t0, peak, hits IDLE, 0, 0.0, 0 for i, m in enumerate(mags): if state IDLE and m cfg[ff_mag]: state, t0 FREE_FALL, i elif state FREE_FALL: peak max(peak, m) if i - t0 cfg[ff_win]: state, peak IDLE, 0.0 elif peak cfg[impact]: state, t0 SETTLING, i elif state SETTLING and i - t0 cfg[settle_win]: if variance(mags[i - cfg[settle_win]:i]) cfg[var_thr]: hits 1 state, peak IDLE, 0.0 return hits grid { ff_mag: [2.5, 3.0, 3.5, 4.0], impact: [18.0, 22.0, 26.0, 30.0], var_thr: [0.8, 1.2, 1.6, 2.0], ff_win: [25], # 帧数50Hz 下 25 帧 500ms settle_win:[100], # 2000ms } results [] for combo in itertools.product(*grid.values()): cfg dict(zip(grid.keys(), combo)) tp sum(1 for f in fall_files if run_state_machine(load(f), cfg) 0) fp sum(1 for f in normal_files if run_state_machine(load(f), cfg) 0) recall tp / len(fall_files) precision tp / max(tp fp, 1) results.append((recall, precision, cfg)) for recall, precision, cfg in sorted(results, keylambda x: -x[0])[:5]: print(f召回 {recall:.2f} 精确 {precision:.2f} {cfg})搜索空间别开太大四个参数各四档就是 256 组跑得动。重点是离线逻辑必须和端上逐行对齐一旦两边写法有差异搜出来的参数在手机上就是另一回事这个坑我踩过返工两天。6.3 端上回归把参数固化进配置并留痕搜出一组参数后别直接改代码常量。把它抽成一个配置对象连同「这组参数是在哪批数据上、用什么指标选出来的」一起写进注释。之后每次改动判定逻辑都用同一批数据回放一遍比对召回率和误报数。从那以后我每次改跌倒判定哪怕只动一个阈值都会强制走一遍离线回放再上真机因为肉眼判断「好像更准了」这件事翻车概率远高于你的直觉。希望这套流程能帮到你。本文还有配套的精品资源点击获取