我在市体中心 50m 标准池测过一段 400m 自由泳,8:38.6 游完,平均配速 2:09 / 100m,目标 2:12。数据出来以后我第一反应是写个最简单的配速计算器,就三行:
functionavgPace(totalMeters:number,totalSeconds:number):number{returntotalSeconds/(totalMeters/100)}constpace=avgPace(400,8*60+38.6)// pace = 2.16,即 2:09.6 / 100m跑出来没错,但我盯着分段成绩看的时候发现不对劲。8 个 50m 分段分别是 32.4、33.1、32.8、33.5、34.2、33.8、33.2、32.6 秒,目标虚线画在 33.0。第 4 段 33.5、第 5 段 34.2 明显偏慢,配速表上就这两根柱子冒尖。问题是:这两个"偏慢"的 50m 里,混着转身。游到池壁要减速触壁、团身、蹬壁、再滑出去,这几秒的慢动作被平均进了那 50m 的分母里,配速被活活拉低。
转身不是游泳,它是个独立事件。我后来把整个配速模型推倒重来,核心是先把"转身窗口"从分段里切出去。
为什么教练要的是分段,不是总配速
先交代一下背景。练间歇的人不看总成绩,看分段。一套 400m 拆成 8 个 50m,教练要的是每一段相对 33.0 秒目标线的偏差——哪段稳、哪段崩、哪段冲刺。总配速 2:09 只是把 400 米的起伏全摊平了,什么都看不出来。
分段数据怎么来?最朴素的做法是每个 50m 手动按一下按键。我在泳池边试过,按快了按慢了误差随随便便半秒,还打断节奏。所以改成用穿戴设备自动计时,传感器采样、算法切分。这时候转身就成了躲不掉的问题:设备不知道你在转身,它只看到速度忽高忽低,如果简单按"每游 50m 记一次时",转身的慢动作一定会被算进某个分段里。
转身是一个独立事件
想清楚一个 50m 分段里到底发生了什么。游进阶段速度稳定在 1.5m/s 上下,接近池壁前 3 米开始收腿减速,触壁那一刻速度掉到接近零,团身蹬壁后速度又瞬间顶到 2m/s 以上,滑行几米回落。如果我只记录"这一趟 50m 花了多少秒",触壁前后这 2.5 到 3 秒的扰动就全被抹平成一个数字。
所以要识别分段,得先把一趟泳程拆成三段:游进段、转身段、下一段游进。数据建模上我用了三个结构:
exportinterfaceLapSample{t:number// 采样时刻,毫秒acc:number// 前进方向加速度,m/s²speed:number// 估计瞬时速度,m/sdist:number// 距出发端的累积距离,m}exportinterfaceTurnEvent{index:number// 采样点索引startMs:number// 转身窗口起点endMs:number// 转身窗口终点wallMs:number// 触壁时刻durationMs:number// 窗口总时长}exportinterfaceSwimSplit{lapNo:number// 第几段 50mrawMs:number// 原始分段耗时(含转身)cleanMs:number// 剔除转身后的净分段turnMs:number// 这段末尾的转身耗时paceSec:number// 净配速 / 100m}LapSample是采样层,TurnEvent是识别结果,SwimSplit是给界面展示的重算值。三层职责分开,后面改判定逻辑就不用动展示层。
采样流长什么样
真机采下来的触壁前后数据,加速度波形大概是这样的(50Hz,前进方向,单位为 m/s²):
| 时刻 | 事件 | 加速度 |
|---|---|---|
| 触壁前 1.2s | 正常游进 | 0.3 ~ 0.6 |
| 触壁前 0.8s | 开始收腿 | -1.2 |
| 触壁前 0.4s | 明显减速 | -2.8 |
| 触壁瞬间 | 速度近零 | -3.5(谷值) |
| 触壁后 0.3s | 团身 | -0.5 |
| 蹬壁瞬间 | 蹬腿发力 | +3.8(峰值) |
| 蹬壁后 0.8s | 滑行 | +0.8 回落 |
| 蹬壁后 1.5s | 恢复游进 | 0.4 |
负向谷值和正向峰值的间隔只有 0.6 秒左右,形状固定。这就是识别的物理基础——转身在加速度上是一对"急刹 + 猛蹬"的尖峰,跟正常游进的匀速小波动完全不是一个量级。
用加速度尖峰找触壁和蹬壁
识别转身靠加速度计。我用@ohos.sensor的加速度计,50Hz 采样,连续滚动比较相邻采样点的加速度变化:
importsensorfrom'@ohos.sensor'constTOUCH_THRESHOLD=-2.5// 触壁减速阈值 m/s²constPUSH_THRESHOLD=3.0// 蹬壁加速阈值 m/s²constMIN_GAP_MS=800// 两次转身最小间隔functiondetectTurn(samples:LapSample[]):TurnEvent[]{constevents:TurnEvent[]=[]for(leti=2;i<samples.length;i++){constprev=samples[i-1]constcur=samples[i]constaccSlope=cur.acc-prev.accconstgap=events.length>0?samples[i].t-samples[events[events.length-1].index].t:Number.MAX_VALUEif(accSlope<TOUCH_THRESHOLD&&gap>MIN_GAP_MS){events.push({index:i,startMs:cur.t,endMs:cur.t,wallMs:cur.t,durationMs:0})}}returnevents}MIN_GAP_MS是我一开始就想到要加的:50m 池里两次转身之间最短也有二十几秒,800ms 的下限能滤掉同一个尖峰被相邻采样点重复触发的情况。但这里有个更隐蔽的坑——触壁的负向尖峰和蹬壁的正向尖峰会各触发一次,如果只抓负向尖峰,一次转身会被记成两件事。所以我让状态机来收口。
阈值为什么不能写死
TOUCH_THRESHOLD = -2.5这个数是我自己戴着手表在池子里标出来的。换个人就未必合适:队友的动作幅度大,触壁那一下能到 -3.4;另一个练长距离的,收腿动作特别柔,谷值只有 -2.0,压着阈值走,转身十次能漏两次。
所以工程里留了一个标定入口。前提是知道这一趟游了几个转身——教练数得出来——然后从松到紧扫一遍阈值,第一次数对上就收工:
// 阈值参数化的版本,只给标定用;线上跑的还是常量那版functiondetectTurnWith(samples:LapSample[],touchTh:number):TurnEvent[]{constevents:TurnEvent[]=[]for(leti=1;i<samples.length;i++){if(samples[i].acc-samples[i-1].acc<touchTh){events.push({index:i,startMs:samples[i].t,endMs:samples[i].t,wallMs:samples[i].t,durationMs:0})}}returnevents}// 已知这段有 7 次转身,从 -1.6 开始往严里扫functioncalibrateTouch(samples:LapSample[],expected:number):number{for(letth=-1.6;th>=-4.0;th-=0.2){if(detectTurnWith(samples,th).length===expected){returnMath.round(th*10)/10}}returnTOUCH_THRESHOLD// 扫不到就退回默认值}标定结果存在本地,跟着设备走不跟着阈值走。同一块表拆给两个人用,各自的标定值分开存,切换用户时一起换。这套做法只在训练池里成立——开放水域没有池壁,标定本身无从谈起。
状态机把窗口闭合起来
检测到减速尖峰就进入APPROACH状态,在它后面 1.5 秒内等一个正向尖峰,凑齐了才算一次完整转身,凑不齐就当作水花噪声丢掉。状态机就四个状态:SWIM(游进)、APPROACH(触壁前减速)、WALL(触壁停顿)、PUSH(蹬壁滑行)。
enumSwimPhase{SWIM,APPROACH,WALL,PUSH}constAPPROACH_WINDOW_MS=1500// 减速尖峰后多久内必须有蹬壁constPUSH_SLIDE_MS=1200// 蹬壁后滑行多久算转身结束functioncloseTurns(samples:LapSample[],raw:TurnEvent[]):TurnEvent[]{constmerged:TurnEvent[]=[]letphase:SwimPhase=SwimPhase.SWIMletpending:TurnEvent|null=nullfor(constsofsamples){switch(phase){caseSwimPhase.SWIM:if(s.acc<TOUCH_THRESHOLD){pending={index:raw.findIndex(r=>r.startMs===s.t),startMs:s.t,endMs:s.t,wallMs:s.t,durationMs:0}phase=SwimPhase.APPROACH}breakcaseSwimPhase.APPROACH:if(s.acc>PUSH_THRESHOLD){pending!.wallMs=s.t phase=SwimPhase.WALL}elseif(s.t-pending!.startMs>APPROACH_WINDOW_MS){phase=SwimPhase.SWIM// 超时没等到蹬壁,判定为噪声pending=null}breakcaseSwimPhase.WALL:if(s.t-pending!.wallMs>PUSH_SLIDE_MS){pending!.endMs=s.t pending!.durationMs=s.t-pending!.startMs merged.push(pending!)pending=nullphase=SwimPhase.SWIM}break}}returnmerged}APPROACH状态里那个超时分支是我后来加的。最开始没有它,结果蛙泳的收腿蹬夹偶尔会凑出一个假转身——收腿减速、蹬腿加速,形状和触壁蹬壁一模一样。加了 1.5 秒窗口之后,蛙泳那种 0.6 秒内完成的蹬夹动作就被排除了。这个失败分支解释了我当时为什么游蛙泳时配速被切得乱七八糟。
一次调试会话
状态机这种带隐式状态的东西,光看代码看不出问题,得让它在时间轴上说话。我写了个只打印状态迁移的跟踪函数,把每一跳和当时的加速度、速度一起打出来:
// 调试用:把状态迁移打进日志,回头对着时间轴核functiontracePhases(samples:LapSample[]):void{letphase:SwimPhase=SwimPhase.SWIMfor(constsofsamples){constnext=advance(phase,s)if(next!==phase){console.log(`[${(s.t/1000).toFixed(2)}s]${SwimPhase[phase]}->${SwimPhase[next]}`+`acc=${s.acc.toFixed(2)}speed=${s.speed.toFixed(2)}`)phase=next}}}第 3 段那次转身,日志打出来是这样:
[32.41s] SWIM -> APPROACH acc=-2.63 speed=1.42 [33.07s] APPROACH -> WALL acc=3.81 speed=0.18 [34.19s] WALL -> PUSH acc=1.24 speed=2.11 [35.62s] PUSH -> SWIM acc=0.41 speed=1.53从APPROACH到WALL只隔了 0.66 秒,这就是收腿到蹬壁的实际耗时;WALL那 1.12 秒是蹬壁后的滑行段;整个窗口 3.21 秒。第 4 段那根柱子的转身窗口为什么能扣掉 1.9 秒,对着这段日志一目了然。真正卡住的两次不是算法问题,是采样率——手表在某些泳姿下会掉到 25Hz,WALL那一步的 0.6 秒里只剩 15 个点,斜率估得糙。我把PUSH_SLIDE_MS从 1000 放宽到 1200 就是为了吃掉这点误差。
到这里,转身窗口能稳定识别了。界面中间那格泳池俯视图,就是根据这些转身事件把泳者位置标在池子里的——当前泳道 3,距端点 18m,配速 2:06。
俯视图只是展示,真正决定配速准不准的,是下面这步重算。
重算净配速,把转身时间扣掉
拿到TurnEvent列表后,就能把每段 50m 的净耗时算出来。转身发生在 50m 和下一段 50m 的交界处,所以第 N 段的rawMs要减去它末尾那个转身窗口,第 N+1 段则天然从转身结束后开始计时:
functionresplit(rawMs:number[],turns:TurnEvent[]):SwimSplit[]{returnrawMs.map((ms,i)=>{constturn=turns.find(t=>t.index>=i&&t.index<i+1)constturnMs=turn?turn.durationMs:0constcleanMs=ms-turnMsconstpaceSec=cleanMs/50*100return{lapNo:i+1,rawMs:ms,cleanMs,turnMs,paceSec:Math.round(paceSec*10)/10}})}重算之后,那两根"偏慢"的柱子回来了。第 4 段原始 33.5,转身窗口扣掉 1.9 秒后净分段 31.6,配速 1:03;第 5 段 34.2 里转身占了 2.3 秒,净分段 31.9。真正拖慢的是呼吸节奏那一下,而那段本来就在备注里写着"本段最慢,呼吸节奏乱"。扣掉转身之后,慢在哪、慢多少才对得上教练的判断。
回归用例钉住净分段
resplit这个函数有两个容易写反的地方:一段只该扣自己末尾那次转身,不能把下一段的也扣掉;扣完的净耗时要能正确换算成每 100m 的读数。这两条我用断言钉死了:
functioncheckResplit():void{constraw=[33500,34200]constturns:TurnEvent[]=[{index:0,startMs:0,endMs:1900,wallMs:800,durationMs:1900}]constout=resplit(raw,turns)if(out[0].cleanMs!==31600){thrownewError(`第 1 段净耗时错了:${out[0].cleanMs}`)}if(out[1].cleanMs!==34200){thrownewError(`第 2 段不该被扣:${out[1].cleanMs}`)}if(out[0].paceSec!==63200){thrownewError(`净配速换算错了:${out[0].paceSec}`)}}字段名叫paceSec,单位其实是"毫秒 / 100m",这个命名我后来想改,怕动了展示层就没动。63200 除以 1000 得 63.2 秒,界面格式化成 1:03,所以断言里写的是 63200 而不是 63.2。写着别扭,但比断言和实现各用一套单位要安全。
配速只是入口,衍生指标一起跟着校准
配速本身只是展示,我给它的定位是给教练的决策依据。界面右侧那一组数字——SWOLF 效率 42、划频 18 spm、单趟划水 9 次——都是同一条采样流算出来的。SWOLF 是单趟耗时加单趟划水次数的和,转身切分错了,耗时虚高,SWOLF 就跟着虚高;划频靠加速度的周期性波动估出来的,转身窗口里那个猛蹬尖峰如果不剥掉,会被误算成一次划水。所以转身切分是这些衍生指标的地基,地基歪了上面全歪。
落地结构
工程里我按职责拆了几块,文章里的逻辑对应到文件大致是这样:
SwimSplit/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 泳池俯视图 + 分段成绩柱状图 │ ├─ model/SwimModel.ets # LapSample / TurnEvent / SwimSplit 数据模型 │ ├─ core/TurnDetector.ets # 触壁、蹬壁检测与状态机 │ └─ core/PaceEngine.ets # 净分段重算与配速换算 └─ entry/src/main/resources/ # 泳道配色与文案资源TurnDetector只负责"哪里有一次转身",PaceEngine只负责"转身扣掉后配速是多少",两个模块失败时返回空数组或原样透传,不让脏数据污染配速曲线。
还有一件事要说明白:这套判定只在训练复盘场景用,采样来自穿戴设备的加速度计,不同泳姿、不同转身技术的波形差异很大,阈值要按个人重新标定。本文描述的是本地数据切分的思路,不构成任何游泳训练或安全指导;开放水域没有固定池壁,这套触壁识别也完全不适用。
游完 400m,分段从"两段明显偏慢"变成"转身干净、呼吸那段真实偏慢",教练要抓的问题就露出来了。切掉池壁时间,是为了让慢的地方藏不住。