はじめに

「XPT2046_Touchscreen ライブラリ で問題ないだろう」と高を括ってましたが、甘かったです。前回 試した以下のパネルのうち、特に 2.4 インチ版で問題が出まくりでした 😩

今回はその問題点の分析と対策、およびキャリブレーションアルゴリズムについて報告したいと思います。

使用したソフトウェアは以下の通りです。

前回 の続きとしたかったのですが、SPI 版 ST7789 は XPT2046 との両立が確立できず、また 8bit パラレル版 MCUFRIEND タイプのタッチ機能もまだ検証できていません 😓

タッチパネルの向きについて

まず言及すべきは 2.4” 版と 2.8” 版の向きについてです。例えばディスプレイパネルの向きを TFT_ROTATION(0 〜3)とすると、2.4” 版タッチパネルの向き TOUCH_ROTATION は次のようにディスプレイパネルに対して 90° 回転させた

#define TOUCH_ROTATION  ((TFT_ROTATION + 1) & 3)

となり、一方 2.8” 版は 180° 回転させた

#define TOUCH_ROTATION  ((TFT_ROTATION + 2) & 3)

になります。

「赤い基板」の LCD は類似品が多そうなので、たまたま私が購入したタイプだけなのかも知れません。初めて扱うデバイスの場合、ライブラリの 例題 で動作確認するのが基本です。

動作上の問題点とその対策

試行錯誤しながらの動作テストで判明した問題点を「SPI バスクロックの設定値」と「過渡応答と時定数」とに整理し、その対策として「後処理の実装方針」を検討しました。

SPI バスクロックの設定値

配線が長いとノイズまみれに
配線が長いとノイズまみれに

UNO R4 と 2.4” 版 LCD の間にレベル変換モジュールを挟み、それらをデュポンワイヤで接続したところ、画面の至る所で「ノイズ」が検知されるという事象に遭遇しました。

そこでデュポンワイヤをジャンパ線に変え短く繋いだところ、その頻度はかなり減りました。

さらによく調べると、そもそもの原因は「GitHub の issues #60」で指摘された SPI バスクロックの設定にあることが判明しました。XPT2046 のデータシート には3つのノイズ対策が挙げられていて、そのうちの1つが「サンプリング中はクロックを下げる」というものです。

オリジナルのコード では、SPI バスクロックが SPISettings() を介して 2MHz に設定されていますが、これを 1MHz に下げたところ「ノイズ」が出まくる状態は見事に解消されました(ちなみに LovyanGFX の設定 も 1MHz でした)。

#define SPI_SETTING  SPISettings(1000000, MSBFIRST, SPI_MODE0) // 2MHz → 1MHz

XPT2046_Touchscreen は5年も放置されているので、インストール先のソースコードを直接変えちゃって良さそうですネ。

過渡応答と時定数

パネルサイズによる過渡応答の違い
パネルサイズによる過渡応答の違い

次に 2.4” 版と 2.8” 版の過渡応答を調べてみました。グラフは画面の左上端をタッチした時のそれぞれの応答です。前者は整定するまで数十ミリ秒かかるのに対し、後者はタッチ直後から比較的安定した値が測定されています。

タッチ場所による応答の違い
タッチ場所による応答の違い

そこで 2.4” 版についてタッチ時の過渡応答を四隅で測定すると、特にタッチ圧 (z) の整定値が場所ごとに異なりました。

これは、x や y の安定性を z で判定する場合に閾値の調整が必要なこと意味します。実際微妙なチューニングが必要でした。

過渡応答でバラけた点
過渡応答の影響でバラけた点

さらに後述するキャリブレーション後の影響を調べると、一点だけをタッチしたにも関わらず、短時間で広範囲にバラけた点が「外れ値」として測定される結果となりました。

例えば冒頭のリンク先ページに掲載されたデモでは LCDWIKI のライブラリ を使っていて、「5回のサンプリング後、両端を切り捨てた残りで平均値を算出」、即ち「中央値付近を平均化」しています。中央値(メディアン)は「外れ値」を除外する妥当な手法です。

一方 XPT2046_Touchscreen では、2回のサンプリング値を単純平均 しているだけなので、こういったハードウェア側の特性違いによる「外れ値」的なノイズを弾くことが出来ません。それはアプリ側の「後処理」の役割と割り切っているのだと思います。

後処理の実装方針

前章での分析を踏まえれば、「後処理」としては次の要件を満たすことが理想的です。

  • ハードウェアの特性の違いに左右されないこと
  • 微妙な閾値の調整に頼ることなく、ロジックで対処すること

これらを満たすには、「少なくとも x と y が整定したことを判定する」のが妥当でしょう。例えば、一定の窓枠を時系列方向にスライドさせ、移動平均と出力値との差や、分散や標準偏差を判定の基準とする方法に落とし込むことが考えられます。

今回は計算を軽くするため、移動平均の代替として一次ローパルフィルタを使ってみました。サンプリング周期を 1msec、時定数 を 10msec とすることで、タッチ開始から座標の確定まで 20msec 程度という、ソコソコの結果が得られました。ゲームなどリアルタイム性を重視するアプリへの適用は苦しいですが、UNO R4 の GUI なら実用上は問題ないレベルだと思います。

参考情報

キャリブレーションのアルゴリズムと実装

幾何学的補正モデルとアルゴリズムの概要

アフィン変換のモデル例
アフィン変換のモデル例

Arduino 用の小型 LCD のキャリブレーションには、ディスプレイパネルとタッチパネルの物理的ズレや歪みを補正する幾何学的モデルにアフィン変換を使うのが一般的です。そのモデルと解法を組み合わせた代表的な実例を以下に示します。

最大範囲/最小範囲に基づく単純な線形変換によるキャリブレーション

タッチパネル座標系の最大範囲と最小範囲をディスプレイパネルの座標系にマッピングすることで「平行移動」と「拡大縮小」の物理的なズレを補正します。

具体的には、ディスプレイパネルの四隅でタッチパネルの出力値を拾い、x、y それぞれ独立に平均化した最大値と最小値を map 関数 などでディスプレイパネルの範囲内に変換します。

このアルゴリズムは、TFT_eSPI のキャリブレーション で採用されています。

2点、または3点キャリブレーション

それぞれのキャリブレーションの原理と解法の概要は、以下の Google 検索で確認してください(Google って便利 😁)。

2点キャリブレーションのプログラムが「Calibrating and Coding Your Arduino Touchscreen – Bytes N Bits」に公開されています。

4点(またはそれ以上)キャリブレーション

「平行移動/拡大縮小」に「回転」と「剪断(四角形を平行四辺形にする変形)」を加えたモデルで、4点以上のサンプル点から6個のアフィン変換係数を求めます。

LovyanGFX のキャリブレーション では、最小二乗法から導き出される正規方程式 の解をガウスの消去法で求めるアルゴリズムを実装しています。

キャリブレーション プログラムの実装

今回は「単純な線形変換」と「4点キャリブレーション」を実装してみました。後者は適当なプロンプトを ChatGPT に投げ、アルゴリズムの解説とプログラムを作成させました。

学生の頃は面倒な数式の処理に Maple や Mathematica を使ってましたが、今や AI で何とかなる、本当に便利な世の中になったものです 😙

ChatGPT に投げたプロンプト

抵抗膜式タッチパネルをキャリブレーションする数式を導出し、プログラムを作成してください。問題を解くためのプロセスの概要をプロンプトとしてテキストファイルに記述したので、アップロードします。 同プロンプトを解読し、誤りがあれば指摘し、また可能か否かを回答してください。 ただし、プロンプトに含まれる数学記号「Σ」には、それに付随する「i = 0」や「n」などの記号を省略しているので注意してください。

  1. 想定する座標 (X, Y) に対し、実際に観測された値が (x, y) であったとします。
  2. キャリブレーションは X = ax + by + c、Y = dx + ey + f で表されるアフィン変換で行うとします。
  3. タッチパネルの4隅の座標を複数回観測し、それらの観測値から最小二乗法で a, b, c, d, e, f を算出するものとします。
  4. X, Yに関する残差平方和 E はそれぞれ次になります。 Ex = Σ(Xi - (axi + byi + c))^2、Ey = Σ(Yi - (dxi + eyi + f))^2
  5. これら残差平方和が最小になるための条件を ∂Ex/∂a = 0、∂Ex/∂b = 0、∂Ex/∂c = 0、∂Ey/∂d = 0、∂Ey/∂e = 0、∂Ey/∂f = 0 とします。
  6. これら6本の連立方程式をC言語で算出するためのプログラムを記述してください。
  7. ただし連立方程式の解は、ガウスの消去法以外の方法で算出することとします。
  8. また以上の各段階で検証可能な式を導出し、最終的に a ~ f を算出するアルゴリズムをまとめて下さい。

プロンプトで「ガウスの消去法」以外を指示したのは、過去に同法をプログラムした際、面倒だった記憶があるからです。この指示に ChatGPT はクラメルの公式を提案してきましたが、「行列で連立一次方程式を解く方法~計算の手順~|数学の景色」によれば、同公式は

注意ですが,これは計算量が非常に多く,実際の計算に適用することは,あまり現実的ではありません。あくまで理論的なものだと思った方が良いです。

とのことです。まぁリアルタイム性は必要ないので、とりあえずはヨシとしています 😅

また ChatGPT が吐き出したアリゴリズムの検証は、以下のサイトを参考にしました。

ChatGPT によるクラメルの公式を使ったアルゴリズムの解説
1. アフィン変換モデル
X=ax+by+c,Y=dx+ey+fX=ax+by+c,\qquad Y=dx+ey+f
  • (x,y)(x,y):タッチパネルから読み取った観測座標(12bit ADC 出力値で範囲は 0〜4095)
  • (X,Y)(X,Y):その測定に対応する期待座標
  • a,b,c,d,e,fa,b,c,d,e,f:求める6個の係数

測定回数を NN、測定番号を i=0,…,N−1i=0,\ldots,N-1 とします。

2. 最小二乗法の目的関数
EX=∑i=0N−1[Xi−(axi+byi+c)]2E_X=\sum_{i=0}^{N-1}\left[X_i-(ax_i+by_i+c)\right]^2 EY=∑i=0N−1[Yi−(dxi+eyi+f)]2E_Y=\sum_{i=0}^{N-1}\left[Y_i-(dx_i+ey_i+f)\right]^2

EXE_X は a,b,ca,b,c のみ、 EYE_Y は d,e,fd,e,f のみに依存するため、3変数の連立方程式を2組解きます。6×6の連立方程式にまとめる必要はありません。

3. 偏微分から得られる正規方程式

EXE_X の偏微分を0と置くと、

∂EX∂a=−2∑ixi(Xi−axi−byi−c)=0\frac{\partial E_X}{\partial a} =-2\sum_i x_i(X_i-ax_i-by_i-c)=0 ∂EX∂b=−2∑iyi(Xi−axi−byi−c)=0\frac{\partial E_X}{\partial b} =-2\sum_i y_i(X_i-ax_i-by_i-c)=0 ∂EX∂c=−2∑i(Xi−axi−byi−c)=0\frac{\partial E_X}{\partial c} =-2\sum_i (X_i-ax_i-by_i-c)=0

展開して整理すると、

a∑ixi2+b∑ixiyi+c∑ixi=∑ixiXia∑ixiyi+b∑iyi2+c∑iyi=∑iyiXia∑ixi+b∑iyi+cN=∑iXi\begin{aligned} a\sum_i x_i^2+b\sum_i x_iy_i+c\sum_i x_i&=\sum_i x_iX_i\\ a\sum_i x_iy_i+b\sum_i y_i^2+c\sum_i y_i&=\sum_i y_iX_i\\ a\sum_i x_i+b\sum_i y_i+cN&=\sum_i X_i \end{aligned}

同様に EYE_Y から、

d∑ixi2+e∑ixiyi+f∑ixi=∑ixiYid∑ixiyi+e∑iyi2+f∑iyi=∑iyiYid∑ixi+e∑iyi+fN=∑iYi\begin{aligned} d\sum_i x_i^2+e\sum_i x_iy_i+f\sum_i x_i&=\sum_i x_iY_i\\ d\sum_i x_iy_i+e\sum_i y_i^2+f\sum_i y_i&=\sum_i y_iY_i\\ d\sum_i x_i+e\sum_i y_i+fN&=\sum_i Y_i \end{aligned}
4. 総和の略記と共通行列
Sxx=∑ixi2,Sxy=∑ixiyi,Syy=∑iyi2Sx=∑ixi,Sy=∑iyi\begin{aligned} S_{xx}&=\sum_i x_i^2,&S_{xy}&=\sum_i x_iy_i,&S_{yy}&=\sum_i y_i^2\\ S_x&=\sum_i x_i,&S_y&=\sum_i y_i \end{aligned}

右辺の総和を、

Px=∑ixiXi,Py=∑iyiXi,P0=∑iXiQx=∑ixiYi,Qy=∑iyiYi,Q0=∑iYi\begin{aligned} P_x&=\sum_i x_iX_i,&P_y&=\sum_i y_iX_i,&P_0&=\sum_i X_i\\ Q_x&=\sum_i x_iY_i,&Q_y&=\sum_i y_iY_i,&Q_0&=\sum_i Y_i \end{aligned}

とします。係数行列は共通です。

M=[SxxSxySxSxySyySySxSyN]M=\begin{bmatrix} S_{xx}&S_{xy}&S_x\\ S_{xy}&S_{yy}&S_y\\ S_x&S_y&N \end{bmatrix} M[abc]=[PxPyP0],M[def]=[QxQyQ0]M\begin{bmatrix}a b c\end{bmatrix} =\begin{bmatrix}P_x P_y P_0\end{bmatrix}, \qquad M\begin{bmatrix}d e f\end{bmatrix} =\begin{bmatrix}Q_x Q_y Q_0\end{bmatrix}
5. 行列式とクラメルの公式
Δ=det⁡(M)=∣SxxSxySxSxySyySySxSyN∣\Delta=\det(M)= \begin{vmatrix} S_{xx}&S_{xy}&S_x\\ S_{xy}&S_{yy}&S_y\\ S_x&S_y&N \end{vmatrix}

Δ≠0\Delta\ne0 なら係数は一意に定まります。観測点が一直線上に並ぶ場合などは一意に決まらないため、計算を失敗として扱います。

各列を対応する右辺ベクトルに置き換えた行列式を Δa,Δb,…,Δf\Delta_a,\Delta_b,\ldots,\Delta_f と定義します。

Δa=∣PxSxySxPySyySyP0SyN∣,Δb=∣SxxPxSxSxyPySySxP0N∣\Delta_a= \begin{vmatrix} P_x&S_{xy}&S_x\\ P_y&S_{yy}&S_y\\ P_0&S_y&N \end{vmatrix}, \quad \Delta_b= \begin{vmatrix} S_{xx}&P_x&S_x\\ S_{xy}&P_y&S_y\\ S_x&P_0&N \end{vmatrix} Δc=∣SxxSxyPxSxySyyPySxSyP0∣\Delta_c= \begin{vmatrix} S_{xx}&S_{xy}&P_x\\ S_{xy}&S_{yy}&P_y\\ S_x&S_y&P_0 \end{vmatrix} Δd=∣QxSxySxQySyySyQ0SyN∣,Δe=∣SxxQxSxSxyQySySxQ0N∣\Delta_d= \begin{vmatrix} Q_x&S_{xy}&S_x\\ Q_y&S_{yy}&S_y\\ Q_0&S_y&N \end{vmatrix}, \quad \Delta_e= \begin{vmatrix} S_{xx}&Q_x&S_x\\ S_{xy}&Q_y&S_y\\ S_x&Q_0&N \end{vmatrix} Δf=∣SxxSxyQxSxySyyQySxSyQ0∣\Delta_f= \begin{vmatrix} S_{xx}&S_{xy}&Q_x\\ S_{xy}&S_{yy}&Q_y\\ S_x&S_y&Q_0 \end{vmatrix}

3×3行列の行列式は以下のように直接展開できます。

∣ABCDEFGHI∣=A(EI−FH)−B(DI−FG)+C(DH−EG)\begin{vmatrix} A&B&C\\ D&E&F\\ G&H&I \end{vmatrix} =A(EI-FH)-B(DI-FG)+C(DH-EG)

最終的な係数は、

a=ΔaΔ,b=ΔbΔ,c=ΔcΔa=\frac{\Delta_a}{\Delta},\quad b=\frac{\Delta_b}{\Delta},\quad c=\frac{\Delta_c}{\Delta} d=ΔdΔ,e=ΔeΔ,f=ΔfΔd=\frac{\Delta_d}{\Delta},\quad e=\frac{\Delta_e}{\Delta},\quad f=\frac{\Delta_f}{\Delta}

となります。

作成したプログラムは embedded-kiddie/Arduino_GFX_Lab に上げました。

TouchScreen
├── TouchScreen.ino
├── XPT2046_TouchCalibrate.h
├── XPT2046_Touchscreen.cpp
├── XPT2046_Touchscreen.h
├── gfx_config.hpp
└── src
    ├── README.md
    ├── touch_calibration.h
    └── touch_calibration.hpp
  • XPT2046_TouchCalibrate.h
    クラス XPT2046_Touchscreen を継承し、キャリブレーション用メソッド calibrate() を追加したクラス XPT2046_TouchCalibrate を定義しています。グラフィックライブラリへの依存を避けるため、calibrate() にはマーカーを描画する関数へのポインタを渡す仕様としています。詳しい使い方は TouchScreen.ino を参照して下さい。
  • XPT2046_Touchscreen.{cpp|h}
    SPI バスクロックの設定を 1MHz に下げたバージョンをローカルに配置しています。
  • gfx_config.hpp
    UNO R4 と CYD 用に Arduino_GFX または Adafruit-GFX-Library を設定します。
  • touch_calibration{h|hpp}
    ChatGPT が吐き出した4点キャリブレーションのプログラムです。関数のインターフェースは次の通りです。

    TouchCalibrationStatus touch_calibrate(
        const TouchCalibrationPoint *points,
        size_t count,
        TouchCalibration *out
    );
    

    四隅の測定点は次の構造体で渡します。

    typedef struct {
        float X; // 期待されるディスプレイ座標系の X 座標
        float Y; // 期待されるディスプレイ座標系の Y 座標
        float x; // 測定されたタッチパネルの x 座標
        float y; // 測定されたタッチパネルの y 座標
    } TouchCalibrationPoint;
    

    計算結果は TouchCalibration に格納され、アフィン変換の係数が取得できます。

    typedef struct {
        float a, b, c, d, e, f;
    } TouchCalibration;
    

キャリブレーション精度の検証

精度の検証は、定量的に検証する方法が思いつけなかったので、キャリブレーション後に表示される四角形をペンでなぞり、目視で行いました。また比較対象として LovyanGFX の例題 を実行したところ、若干劣るものの(手持ちの 2.4” LCD で、特定の1辺の合いが悪い)、まぁまぁの出来 🙄 かと思います。

キャリブレーション結果の比較
キャリブレーション結果の比較

まとめ

UNO R4 で動作する LCD と定番の XPT2046_Touchscreen ライブラリ 組み合わせたところ、特に 2.4” 版 LCD で酷いノイズとタッチ後の立ち上がりの遅さに悩まされました。

これに対処するため、同ライブラリを継承したクラスを作成し、ノイズを除去するローパスフィルタと4点キャリブレーションを組み込んだところ、何とか実用に耐えられそうな(?)ところまで漕ぎ着くことが出来ました。

しかしより根本的には、XPT2046 のデータシート に記載されたノイズ対策を試すべきだと思います。いや、いっその事、Lovyan03 さんのタッチ関連コードを丸ごと移植した方が良いかも知れません。本編では述べませんでしたが、今回作成したキャリブレーションはパネルの向き(ローテーション)を変える毎に再実行が必要ですが、LovyanGFX ではパネルの向きに依存しない工夫をしています。さらに ビットバン方式もサポート している優れものです。

誰かが移植してくれることを願いつつ(他力本願 😮‍💨)、本記事を〆たいと思います。