WPF 記一個特別簡單的點集濾波平滑方法

lindexi發表於2024-08-30

本文記錄我想要解決自己從視窗接收 WM_Pointer 訊息時,獲取到的觸控點不平滑的問題而使用的特別簡單且效能垃圾的點集濾波平滑方法

我的本質錯誤是使用 WM_POINTER 訊息的 ptPixelLocationRaw 欄位而不是 ptHimetricLocationRaw 欄位

由於後面在 walterlv 的幫助之下修復了觸控點收集,附帶他也給 Avalonia 做了貢獻,詳細請看 https://github.com/AvaloniaUI/Avalonia/pull/16850

故事的開始是我用 Avalonia 使用的是 WM_Pointer 的 ptPixelLocation 欄位,此欄位是帶預測的,這不符合我預期。為了減少 Avalonia 的干擾,我就寫了一個簡單的 WPF 程式去接收 WM_Pointer 訊息,自己處理訊息。然而這個過程裡面我發現寫出來的筆跡不平滑,遠遠不如從 Touch 事件裡面收到的點平滑

以下是微軟 官方文件ptPixelLocation 的描述

ptPixelLocation

Type: POINT

The predicted screen coordinates of the pointer, in pixels.

The predicted value is based on the pointer position reported by the digitizer and the motion of the pointer. This correction can compensate for visual lag due to inherent delays in sensing and processing the pointer location on the digitizer. This is applicable to pointers of type PT_TOUCH. For other pointer types, the predicted value will be the same as the non-predicted value (see ptPixelLocationRaw).

如果做一個筆跡應用,那自然帶觸控的點不能作為最終的筆跡的構成,否則會出現毛刺問題。但是根據我的印象,在 Win10 或 Win11 下,筆跡預測不是在 WM_Pointer 層做的,也不知道為什麼會在這裡放這樣的欄位。印象裡面是在 Ink 模組才會做預測

一般在觸控框硬體層面會做一次平滑演算法,但這只是比較粗略的平滑演算法,受限於觸控框的計算晶片的效能,也不會做比較複雜的平滑。在 Windows 10 或 11 的 WISP 模組也會做一次平滑,過濾一些雜點,然後就透過 WM_Pointer 扔給應用

這時候理論上應用收到的點應該就是平滑的了,只不過我錯誤使用了 ptPixelLocationRaw 欄位,此欄位是 int 型別的,丟失了精度,導致了寫出來的筆跡有鋸齒

本文是在此基礎上進行的最佳化,編寫了一個簡單的平滑濾波演算法。演算法就是當前點的 X 和 Y 分開計算,當前點的 X 取前後各 5 個點的 X 的平均值,然後 Y 也取前後各 5 個點的 Y 的平均值

為了方便我編寫這個簡單的演算法,我從 WM_Pointer 收到的觸控點資訊存放到 txt 檔案裡面,這個檔案被我放在 github 上。接下來我的程式碼將根據這個 output.txt 檔案編寫演算法

演算法程式碼十分簡單,程式碼如下

    public static List<double> ApplyMeanFilter(List<double> list, int step)
    {
        var newList = new List<double>(list.Take(step / 2));
        for (int i = step / 2; i < list.Count - step + step / 2; i++)
        {
            newList.Add(list.Skip(i - step / 2).Take(step).Sum() / step);
        }
        newList.AddRange(list.Skip(list.Count - (step - step / 2)));
        return newList;
    }

相信這個程式碼大家一下就看明白了,就是傳入一個 list 陣列,這個陣列是其中一個分量,即使 X 分量或 Y 分量。然後這個 step 在我呼叫程式碼裡面會傳入 10 的值,也就是中間的點應該使用前後各 5 個點的平均值,也就是核心的 list.Skip(i - step / 2).Take(step).Sum() / step 程式碼的含義

以上核心程式碼就是先使用 Skip 跳過當前的點倒數 step 的一半,也就是之前的 5 個點開始,再使用 Take 取 step 個點,也就是 10 個點,最後呼叫 Sum 方法獲取這 step 個點的和的值,除以 step 獲取平均值

前後的 var newList = new List<double>(list.Take(step / 2));newList.AddRange(list.Skip(list.Count - (step - step / 2))); 僅僅只是因為平滑每個點需要取前後 step / 2 個點,即 5 個點,在最前面的 5 個點和最後面的 5 個點沒有地方可以取,於是就簡單直接加入好了

再對此方法進行封裝,允許傳入 Point 型別,自動拆分 X 和 Y 分量,程式碼如下

    public static List<Point> ApplyMeanFilter(List<Point> pointList, int step = 10)
    {
        var xList = ApplyMeanFilter(pointList.Select(t => t.X).ToList(), step);
        var yList = ApplyMeanFilter(pointList.Select(t => t.Y).ToList(), step);

        var newPointList = new List<Point>();
        for (int i = 0; i < xList.Count && i < yList.Count; i++)
        {
            newPointList.Add(new Point(xList[i], yList[i]));
        }

        return newPointList;
    }

上面程式碼的效能是比較差的,如果大家想要使用,還請自行最佳化

我從檔案讀取了點的資訊,然後應用了上面的演算法,可以直接使用折線畫出比較好看的介面效果

    public MainWindow()
    {
        InitializeComponent();

        Loaded += MainWindow_Loaded;
    }

    private void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        var file = "output.txt";
        var lines = System.IO.File.ReadAllLines(file);
        var pointList = new List<Point>();
        foreach (var line in lines)
        {
            var match = Regex.Match(line, @"(\d+),(\d+)");
            if (match.Success)
            {
                pointList.Add(new Point(double.Parse(match.Groups[1].ValueSpan), double.Parse(match.Groups[2].ValueSpan)));
            }
        }

        var applyMeanFilter = ApplyMeanFilter(pointList);

        var polyline = new Polyline();
        polyline.Stroke = Brushes.Black;
        polyline.StrokeThickness = 2;
        polyline.Points = new PointCollection(applyMeanFilter);

        RootCanvas.Children.Add(polyline);
    }

大家可以自行註釋掉平滑過濾,測試前後的平滑度變化

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼。我整個程式碼倉庫比較龐大,使用以下命令列可以進行部分拉取,拉取速度比較快

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 68654061ac8cced2abe6736141c37bbb6303ccdb

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼。如果依然拉取不到程式碼,可以發郵件向我要程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 68654061ac8cced2abe6736141c37bbb6303ccdb

獲取程式碼之後,進入 WPFDemo/FalwhekaylearchaKuhiyehakemchaije 資料夾,即可獲取到原始碼

更多觸控請看 WPF 觸控相關

更多技術部落格,請參閱 部落格導航

相關文章