Iori's Blog

陸上短距離用の動画から接地位置を予想し、ピッチを計測するアプリのプロトタイプを作成した

Published on: Sat May 30 2026 00:00:00 GMT+0000 (Coordinated Universal Time)

今回はタイトル通り、自分が楽しんでいる陸上短距離向けの、動画からピッチの計測ができるアプリのプロトタイプを作ったので紹介をします。

背景の部分は陸上の説明しているだけなので飛ばしてOKです。

まず何を作ったのか

pitchrのスクリーンショット1

pitchrのスクリーンショット2

このように動画から音の強弱を見つつ接地した地点にマーカーを置くことでピッチを計算するものになります。

リポジトリのリンクはこちらに

iorinu/pitchr
0

背景

まず背景として、陸上の短距離走は最大スピードというものが重要になってきます。 最大スピードというのは最もスピードが出ている時にどれだけの速度が出せているか、ということであり、100mを走る上では大体60mあたりのスピードで、100mのタイムとの相関があります。 例えば加速がすごく速くて最大スピードに乗せるのが速くても、最大スピードが遅いと100mを走り切った時に抜かれてしまう、みたいなイメージです。 野球選手とかはベース間の速さが重要なので加速の方が重要で50m走とかは速いですが、100mを走るとなると最大スピードまで出せるので重要になります。 そのため、最大スピードを高めることが短距離の練習においては重要になります。

その最大スピードを決定する要素はピッチとストライドに分解することができます。 ピッチは脚がどれだけ回るか、ストライドは1歩の大きさになります。 歩幅が大きくてめちゃくちゃ脚回してたら速いとイメージはしやすいと思います。

この2つと最大スピードの3つのうち、2つを求めることができれば残りの要素を計算することができるため、練習の分析をすることができます。

このうち、最大スピードに関しては計測をすることができます。 よく陸上部がやる練習として加速走があり、これは加速をつけてから区間のタイムを測ります。

ここが正確に測れていればピッチがストライドのどちらかが分かれば分析をすることができます。

自分がよくやっていた方法としては加速走の動画を見て、10歩分の区間の時間を見てそこからピッチを出す方法です。

しかしこれを毎回やるのは面倒なのでアプリを作って半自動くらいにして、より詳しく見れるようにしてみました。

求める仕様

まず最初に以下の仕様を想定しました

しかし、この中でWEBアプリの部分のみ入力した動画の扱いがセキュリティやプライバシー的に難しいと判断したので、後からWEBアプリに直しやすい形で今回はPCのローカルでのみ動かせるように変更しました。

使用技術

レイヤ採用したもの採用理由
バックエンドTauri v2Electron より軽い。バックエンドが Rust で書ける。WebView + Rust 連携が標準で揃っている
フロントReact + TypeScriptWEBに最終的に移すため
ビルドViteTauri 公式テンプレが Vite。HMR が速くて UI 調整がストレスなく回る
動画 → 音声ffmpeg (sidecar)ブラウザの decodeAudioData は mp4 動画の音声を読めないことが多いので、ffmpeg を同梱してネイティブで処理
WAV デコードhound (Rust crate)シンプルで依存が少ない

最終的に Web でも動かしたかったので、フロントをWeb 技術(React)で書けるTauriを選びました。 後から Web 版に切り出しやすいしTauri自体がマルチプラットフォームなので、同じフロントコードで Mac/Win/Linux のバイナリが作れるのも便利で好きです。

これらの仕様をClaudeCodeに入れて、細かいところは詰めつつ実装をしてもらいました。

全体のアーキテクチャ

PITCHR/
├── src/ # フロント (React + TS)
│ ├── PitchAnalyzer.tsx # UI とロジックの本体
│ ├── lib/
│ │ ├── platform.ts # Tauri / Web 判定
│ │ ├── openMediaFile.ts # ファイル選択の抽象化
│ │ └── extractWaveform.ts # 波形抽出の抽象化
│ └── index.css # @font-face とリセット
└── src-tauri/ # バックエンド (Rust)
├── src/
│ ├── lib.rs # extract_waveform コマンド
│ └── waveform.rs # WAV デコード
├── binaries/ # ffmpeg-<triple> sidecar
└── tauri.conf.json # externalBin 登録

接地音から接地の瞬間を拾う検出アルゴリズム

ピッチを「予測」しているのではなく、接地音の瞬間(オンセット)を波形から拾って、その間隔の逆数がピッチ という2段構えになっています。

この辺りはAIが提案してくれたものを採用しています。

波形(PCM) ─[1] 短時間RMS────> 音量の時系列
─[2] フラックス────> 「急に音量が増えた量」の時系列
─[3] 統計的閾値───> どこから先をピークとみなすか
─[4] 局所最大+間隔ガード─> オンセット時刻リスト
─[5] 隣接間隔の逆数 = 瞬間ピッチ

順番に。

[1] 短時間 RMS

波形は 1 秒あたり 44100 サンプルあって細かすぎるので、1024 サンプル(≒23ms)の窓を 256 サンプルずつスライドさせて、各窓の RMS (Root Mean Square) を計算します。

let sum = 0;
for (let j = 0; j < frameSize; j++) {
const s = region[start + j];
sum += s * s; // ←波形の値を二乗
}
const rms = Math.sqrt(sum / frameSize); // ←平均してルート = RMS

波形は +1.0 〜 -1.0 で正負を行き来するので、ただ平均すると 0 になります。二乗してから平均してルートを取れば「振幅の大きさ」が出る、という古典的な処理です。

[2] フラックス

接地音は急に立ち上がるのが特徴なので、音量そのものではなく 音量の正方向の変化量 を取ります。

const d = rms - prevRms;
flux[i] = d > 0 ? d : 0; // 増えた時だけ採用、減った時は 0

これを エンベロープ フラックス と呼びます。音楽情報処理ではオンセット検出の定番手法で、ドラム検出とかでもよく出てきます。

[3] 統計的閾値

flux の値は動画ごとに大きさがバラバラなので、固定閾値ではなく動画ごとの分布に合わせます。

const k = 2.2 - sensitivity * 1.9;
const thresh = mean + k * std;

UI の感度スライダーはこの k2.2 → 0.3 で動かしているだけです。k が大きいと厳しく、小さいとゆるい判定になります。

[4] 局所最大 + 最小間隔ガード

閾値を超えた連続区間の中で、山のてっぺん(局所最大)だけを拾います。さらに「直前ピークから 0.13 秒以内のものは捨てる」というガードを入れて、同じ接地の二重検出を防ぎます。

短距離だと最大で 1秒に 5〜6 歩 = 0.18 秒間隔くらいなので、0.13 秒は安全側の下限です。

UI で工夫したところ

ある程度最初の仕様から動くものができたのでここからはUIの修正をしていきました。

スロー再生プリセット

短距離の接地は 0.2 秒間隔くらいなので、通常速度では映像で接地点を追えません。<video>.playbackRateuseEffect で反映するだけで実装は最小ですが、効果は絶大です。

const [playbackRate, setPlaybackRate] = useState(1);
useEffect(() => {
if (mediaRef.current) mediaRef.current.playbackRate = playbackRate;
}, [playbackRate, mediaUrl, isVideo]);

プリセットボタンは 0.1x / 0.25x / 0.5x / 0.75x / 1x の 5 段階。0.1x まで落とせるとフォーム確認にも使えます。

コマ送り

requestVideoFrameCallback を使うのが本格的ですが、まずは videoFps state(既定 60)を使った簡易版で currentTime ± 1/fps を直接動かしています。fps 入力で動画の実フレームレートに合わせられるようにしました。

ショートカット

以下を入れました。

キー動作
Space再生 / 停止
M現在位置にマーカー追加
Shift + M同上、波形ピークに吸着
D現在位置近く (±100ms) のマーカー削除
← / →1 フレーム送り
Shift + ← / →5 フレーム送り

接地タイム表 + 行ジャンプ

各接地について # / 時刻 / 累積 / Δt / 歩/秒 を表で出し、行をクリックするとその時刻にシーク します。再生位置に対応する行は青系でハイライト。「数値見て怪しい行をクリック → 動画で確認 → 修正」という流れを作ります。

難しかった部分「ピッチ一貫性補正」

ここからが入れようとしてできなかった部分を。

動画を実際に入れてみて自動で接地部分にマーカーを置くと風などのノイズで音声が大きくなってしまう部分があります。

![pitchrのスクリーンショット2](/images/posts/post-12/スクリーンショット 2026-05-30 13.24.54.png)

この画像の一番真ん中らへんの一番出ているとことかは風の「ブォ〜」って音が入っちゃってます。

そこでピッチはほぼ一定という仮定をおいて、検出後にマーカー列を整える後処理を入れてみました。

これを入れたら 悪化しました

何が失敗したか

実装後、風が入る動画で試したら逆方向にズレが広がりました。

  1. 抜け補完が誤発火 — 風の余韻ピークに「ここが本物の接地」と誤挿入。その仮想点を基準に以降の補完位置も累積的にズレる
  2. 重複削除も逆効果 — 「ノイズ < 接地音のフラックス」と仮定したが、振幅が暴れる風の方がフラックスが大きい ことがあった。「弱い方を削除」だと本物の接地が消える
  3. リズム整合性ベース に変えても周囲がズレてると target もズレるので、局所修正にしかならない

結論

自動でこの辺を補正するのは厳しいのでこれはなしにしました。

最終的にこういう方針に落ち着きました。

残課題 / 将来やりたいこと

Tags:

programming

Rust