📈

SwiftでvDSPを用いたメディアンフィルタを実装する

2023/12/10に公開

Core Bluetoothで取得したRSSIから距離推定を行う際に、

https://qiita.com/shu223/items/7c4e87c47eca65724305

RSSIの変化を少しでも安定させるために、時系列データの外れ値を除去するメディアンフィルタを実装してみることにした。

なおiOS/macOSのフレームワークとしては画像処理用にCore ImageのCIFilterにメディアンフィルタが用意されているが、

https://developer.apple.com/documentation/coreimage/cifilter/3228358-medianfilter

今回はRSSIの時系列データという**「一次元データ」に対して適用したい**ので、上記のものは使えない。

Accelerateフレームワークの信号処理ライブラリである vDSP にそういうものが用意されてそうに思ったが、どうやらないらしい。(FFTとかはある)

https://developer.apple.com/documentation/accelerate/vdsp

というわけで自前で実装したのでメモしておく。

vDSP関連の過去記事:

https://note.com/shu223/n/ne4ce203f744b

https://note.com/shu223/n/nc65fbc626a3b

目次

実装1

ChatGPTの力も借りつつ実装したのが以下:

func applyMedianFilter(input: [Float], windowSize: UInt, output: inout [Float]) {
    let inputLength = vDSP_Length(input.count)
    let halfWindowSize = vDSP_Length(windowSize / 2)
    
    for i in halfWindowSize..<inputLength - halfWindowSize {
        // ウィンドウ範囲内のデータを抽出
        // インデックスが0から開始するように、スライスを新しい配列に変換している
        var values = Array(input[Int(i - halfWindowSize)..<Int(i + halfWindowSize + 1)])
        
        // ウィンドウ内のデータを降順にソート
        vDSP.sort(&values, sortOrder: .descending)
        
        // ウィンドウ内の中央値(メディアン)を選択し、出力配列に格納
        output[Int(i)] = values[Int(windowSize / 2)]
    }
    
    // 入力データの両端にあるデータポイントは、フィルタリングを適用せずにコピー
    output[0..<Int(halfWindowSize)] = input[0..<Int(halfWindowSize)]
    output[Int(inputLength - halfWindowSize)..<Int(inputLength)] = input[Int(inputLength - halfWindowSize)..<Int(inputLength)]
}

vDSPはソートぐらいでしか活用できていない…

実装の概要としては、

  • 配列に対して一定のwindowSizeを定め、そのサイズのウィンドウごとに中央値を取る

  • ウィンドウをずらしながらoutput配列に格納していく。

    • output 配列の i 番目の要素には、inputi を挟んで -halfWindowSize+halfWindowSizeの範囲のウィンドウ内の中央値が入る

    • ウィンドウが適用できない入力データの両端には、入力データをそのままコピー

問題点

この実装を上述の「RSSIの安定化」に使用するには問題があった。

コメントにも 入力データの両端にあるデータポイントは、フィルタリングを適用せずにコピー とあるが、inputoutput の要素数を一致させるために、ウィンドウが適用できない入力データの両端には、中央値をとっていない素の値がそのまま入っているので、結局はそこに入ってくる外れ値の影響を大きく受けるのだ。

それを抑えたくてメディアンフィルタを適用しようとしているのに、これでは意味がない。

実装2

そこで、input 配列と output 配列のサイズが一致しなくてもいいので、中央値だけを集めた output を出力するように変えたのがこちら:

func applyMedianFilter(input: [Float], windowSize: UInt) -> Float {
    let inputLength = vDSP_Length(input.count)
    let halfWindowSize = vDSP_Length(windowSize / 2)
    
    var output = [Float](repeating: 0, count: input.count - Int(windowSize) + 1)
    
    for i in halfWindowSize..<inputLength - halfWindowSize + 1 {
        // ウィンドウ範囲内のデータを抽出
        // インデックスが0から開始するように、スライスを新しい配列に変換している
        var values = Array(input[Int(i - halfWindowSize)..<Int(i + halfWindowSize + 1)])
        
        // ウィンドウ内のデータを降順にソート
        vDSP.sort(&values, sortOrder: .descending)
        
        // ウィンドウ内の中央値(メディアン)を選択し、出力配列に格納
        output[Int(i)] = values[Int(windowSize / 2)]
    }
    
    // フィルタリングが適用されたデータポイントの平均値を計算して返す
    var mean: Float = 0
    vDSP_meanv(output, 1, &mean, vDSP_Length(output.count))
    return mean
}

これで output の要素すべてがメディアンフィルタを通っていることになる。

input に対して output の要素数が変わるのだが、やりたいこととしてはこの関数を呼んだ時点でのRSSIを決めたいわけなので、vDSP_meanvoutput の要素の平均値を取るようにした。

実装3

そもそもRSSIの時系列データ自体が一定時間のウィンドウをもって集めた配列なので、メディアンを計算する際にあらためてウィンドウの概念を持ち込まなくてもいいか、ということで ソートして中央値を取り出す処理だけを抽出した:

func calculateMedian(_ values: [Float], upperFraction: Float = 0.5) -> Float {
    var sortedValues = values
    // 降順にソートし、中央値(メディアン)を選択
    vDSP.sort(&sortedValues, sortOrder: .descending)
    let medianIndex = Int(Float(values.count - 1) * upperFraction)
    return sortedValues[medianIndex]
}

なお、「完全に中央」の値ではなく、「上から20%」みたいに最大値寄りの値を取り出したい場合もあるなと思い、upperFraction という引数を追加した。0.5を指定すると中央値、0.2を指定すると上から20%の位置の値を取り出す。

実装4

実装2(のウィンドウごとのループ)から、実装3のメソッドを呼ぶようにしたもの:

func applyMedianFilterAndReturnMean(input: [Float], windowSize: UInt, upperFraction: Float = 0.5) -> Float {
    let inputLength = vDSP_Length(input.count)
    let halfWindowSize = vDSP_Length(windowSize / 2)
    var output = [Float](repeating: 0, count: input.count - Int(windowSize) + 1)
    
    for i in halfWindowSize..<inputLength - halfWindowSize + 1 {
        // ウィンドウ範囲内のデータを抽出
        // インデックスが0から開始するように、スライスを新しい配列に変換している
        let values = Array(input[Int(i - halfWindowSize)..<Int(i + halfWindowSize)])
        
        // ウィンドウ内の中央値(メディアン)を選択し、出力配列に格納
        output[Int(i - halfWindowSize)] = calculateMedian(values, upperFraction: upperFraction)
    }
    
    // フィルタリングが適用されたデータポイントの平均値を計算して返す
    var mean: Float = 0
    vDSP_meanv(output, 1, &mean, vDSP_Length(output.count))
    return mean
}

まとめ

SwiftでvDSPを用いたメディアンフィルタを実装した。

なお、「BLEを用いた距離推定のためにRSSIを安定化させたい」という意図で実装したのだが、結果としては使用しなかった。

理由としてはこちら:

  • RSSIの移動平均を利用する?
    - 「連続性をもって変動する」ので、平均値を取っても変動は抑えられない
    - 標準偏差を用いても同様
  • 同様の理由で中央値を用いても変動を抑える効果はない

https://buildersbox.corp-sansan.com/entry/2023/08/04/120104

iOSアドベントカレンダー

本記事はiOS Advent Calendar 2023の11日目の記事です。
https://qiita.com/advent-calendar/2023/ios

Discussion