視線入力で将棋やチェスやカードゲームを遊べるようにしてみたい その3
前回サンプルコードを動かし、アイトラッカーから得られた注視座標を実際の画面上の座標に直す必要があることが判明したところから。
System.Windows.SystemParameters.PrimaryScreenHeightを使うと、メイン画面に設定しているモニターの解像度が取得できるが、試した結果これは画面の拡大率の影響を受ける。例えば自分は普段画面表示を125%に拡大した状態で1920×1080のモニターに表示させているが、この場合1536が返ってくる。今は自分が使っているモニターの解像度を知っているため変換できるが、どのモニターに接続しても使用できるようにするため、現在設定されているDPIの倍率を調べる必要があることが分かった。
DPIの倍率を調べる方法については以下に記載があったためこちらを参考にした。
(しかし何故これで取得できるのかいまいちわからなかった。特にPresentationSource.FromVisual(this)の部分。thisが何を指すのかはっきり示せない。)
そのまま真似することで取得は出来たので、次はどのようにOnGazePointにDPIの倍率を渡す方法を考える。
単純にOnGazePoint内に倍率取得の操作を放り込んでも、「CS0026:キーワード'this'は、静的プロパティ、静的メソッド、または性的フィールド初期化子では無効です。」とコンパイルエラーが出てPresentationSource.FromVisual(this)が動かない。前述の通りthisについてよくわかっていないため、これ以上現状弄れない。
アプリケーションの起動時にグローバル変数に保存してそれを使い続けるのが簡単だが、この場合起動中に画面の倍率を変更した場合に座標の変換が上手くいかなくなってしまう。試したところmiyasuku EyeConLT2も同様の問題があるようなので、今は起動時にだけ読み込んでグローバル変数に保存するようにして一旦保留することにした。
後々修正したい。
起動時に倍率を取得させるため、
public MainWindow() { InitializeComponent(); }
のInitializeComponent();の直後に放り込んでみたが、これもまた動かない。この場合はまだMainWindowが表示されていないため駄目らしい。
こちらに関してはMainWindowのLoaded要素にDPI倍率を取得する関数を指定することで解決した。
MainWindowでのイベント発生時に呼び出せる関数は、関数名(object sender, RoutedEventArgs e)の形になっている必要があるらしいので、これもそのまま真似をしてそのように記載して使用した。
視線入力で座標を取得できるようになったため、取得した位置にマウスを移動させるようにする。
カーソルの制御は以下を参考にWin32APIを用いて行う。
APIの引数に特別な構造体が使用されている場合は、まず同じものをC#側で定義する必要があるらしい。
.NET TIPS Win32 APIやDLL関数に構造体を渡すには? - C# - @IT
この際構造体のメンバ(要素)の並び順がメモリ上でDLL側と同じになっていないといけないらしいので、Cと同じく宣言された順にメモリ上に並ぶようにStructLayout属性のLayoutKind.Sequentialを付けて宣言する必要があるらしい。
INPUT構造体で使われているunionは共用体と呼ばれるものだそうで、構造体と似ているが一つのメモリ領域に複数の変数が割り当てられるため、使用できる変数は定義されたものの中で1つのみ。C#には無いのでこれも自分で定義する。
DLLに書かれている型とC#で定義する際の型の対応は以下にまとまっていたので参考にした。
dwFlagsは適切な組み合わせをビット和(|)で書くことにより複数の操作をまとめることが可能。確認した限りでは公式に記載が無かったが動作した。
C#のout修飾子を使用して、メソッドの引数を宣言すると、変数の参照渡しができる。
参照渡しをすると、メソッド呼び出し元の変数の値を、呼び出し先のメソッド中で変更できるらしい。
つまりインスタンス化した構造体を渡して、そのメンバーを関数側で変更して返すみたいなことが出来るということだと思う。GetCursorPos()を使う時に必要。
dllからGetCursorPosを読み込む時に引数のPOINT構造体にoutを付けるのを忘れない。忘れると当然動かない(一敗)。
これでマウスの操作が可能になったため、マウスの位置に視認性向上のための円形マーカーを表示させる機能を追加する。
調べた結果オーバーレイ用の全画面ウインドウを作成して常に最前面に表示、不要な部分は全て透過する形にすると良さそうだと分かったため、これでやってみる。
自分のやりたいことほぼそのままの内容をまとめてくれている記事があったため、これを参考にする。
オーバーレイ用のウインドウ(OverLayと命名)はMainWindowを終了した時に一緒に消えてアプリケーションが終了して欲しいため、MainWindowの子要素に指定する。
また、全画面かつ透過したいのでOverLayのWindowプロパティに
WindowStyle="None" AllowsTransparency="True" Background="Transparent" ResizeMode="NoResize" WindowState="Maximized" Topmost="True" ShowInTaskbar="False"
を追加。
また、App.xamlにShutdownMode="OnMainWindowClose"を設定するとMainWindowを閉じた時にアプリケーションが終了するようになるため、これも設定しておく。
これで描画用の透明なウィンドウの作成が出来たので、次はここにマウス位置に合わせた円を描画させる。
public partial class MainWindow : Window内に直接
OverLay overlay = new OverLay();
を書いてインスタンス化すると、別の関数内からOverLay内のImage要素にアクセスできることが分かったが、真下に
Window mainwindow = System.Windows.Application.Current.MainWindow; overlay.Owner = mainwindow;
を書いても、「IDE1007:名前'overlay.Owner'は、現在のコンテキストに存在しません」のエラーが出てOwnerを設定できない。
MainWindowのLoadedイベントで呼び出している関数(GetDpiFactorAndShowOverLay)の中に入れた場合は、「型 'System.ArgumentException' のハンドルされていない例外が PresentationFramework.dll で発生しました
Owner プロパティをそれ自体に設定することはできません。」が出てこれも駄目。
色々触ってみた結果、どうやらOverLay側がMainWindow扱いになっているせいでこの書き方だとOwnerの設定が出来ないっぽいことが発覚。多分呼び出し位置の問題だと思われる。
1日かかっても解決できず困ったが、Twitterで頂いたアドバイスを元に、OverLayのインスタンス化位置をpublic partial class MainWindow : Windowの直下に置いてどの関数からでもアクセスできるようにし、GetDpiFactorAndShowOverLay内でのOwnerの設定方法をthisに変更。
OverLay内のImage要素を弄る部分に関しては、OverLayクラス内に関数を作成し、それをMainWindowから呼び出す形に変更したところ動くようになった。
別ウィンドウ操作系は子に投げてやって貰うといいらしい。
こうしてOverLayのImage要素を弄れるようになったため、いよいよマウス座標を渡してその位置に円を描画させる。描画方法は以下を参考にした。
WPF -drawing a circle with the help of known point
但しそのままでは図形の左上の座標としてマウス座標が渡っており、描画した円の中心がマウスとズレてしまったため、座標を円の半径分ズラすことで修正した。
さらに、描画を無限ループさせることでマウスの移動に円が追従するようにする。
非同期処理で別スレッドに描画の無限ループを投げたいが、UIの操作はそれが呼び出されたスレッド上でしかできないという制約があるらしく、何も考えずに書くと「このオブジェクトは別のスレッドに所有されているため、呼び出しスレッドはこのオブジェクトにアクセスできません。」のエラーが出る。
.NET開発における非同期処理の基礎と歴史(2/2) - @IT
そこで、
と
を参考に描画だけUIスレッドに戻す形で書いてみたが、ここで描画が行われずプロセスメモリが増加し続ける現象が発生した。
無限ループの最下部にThread.Sleep(10);を追加し、ループの速度を下げたところ、描画が行われメモリの増加も無くなった。しかし待機時間をさらに短くしてマウスを動かすと、プロセスメモリが増加してしまうようだった。
原因は不明。ループが早すぎると描画している余裕が無くなる?
最後に
と
の記載を真似して拡張ウィンドウスタイルをOverLayに設定。マウスクリックが透過するようにした。
これでマウスの移動と視認用の円の描画が出来るようになったため、同じ場所を見つめ続けて注視するとクリックを行う機能を導入する。
マウスの現在座標と保存しておいた1つ手前の座標を比較し、その距離が一定値以内であればフラグを立てる。そしてそのフラグが立っている時間が設定した値を超えた場合、クリックを行う形で実装してみた。
現在の状況は以下の通り。
動画では視線に追従してマウスが動き、一定時間注視を行うと左クリックを行う様子を撮影した。シングルクリックと書かれているボタンをクリックすると、ボタンが赤色に変化する。猫の画像はOverLayをわかりやすくするため描画している。
今回はここまで。
注視完了までの時間を視認できるようにするため、注視中にカウントダウンのように円が縮んでいく演出を付けようと調整中。
視線入力で将棋やチェスやカードゲームを遊べるようにしてみたい その2
いわゆる進捗報告。
1週間かかったが、無事tobii社からTobii Stream Engine APIの使用許可が頂けた。
申請は以下のような内容で送信した。
企業ではなく個人での申請だし、実名ではなくイニシャルで名前を書いているし、申請内容の英語も間違っている(個人開発と書きたかったが多分これだと自己啓発…w)状態でも許可を貰えたので、もし個人で視線入力デバイスを使ったソフトを作ろうと考えている方の参考になれば幸いだ。
現在Tobii Stream Engine APIを導入し、マウスの移動をTobii Eye Tracker 5で読み取った視線で行うところまで進めている。
コード諸々はgithubのリポジトリに。
以下には公式のサンプルコードを動かすまでに必要だったことを記載しておく。
まず、サンプルコード「Super simple C# .NET application」をVisualStudioで作成中のWPFアプリケーションに投入しそのまま実行すると
エラー CS0246 型または名前空間の名前 'tobii_calibration_stimulus_points_t' が見つかりませんでした (using ディレクティブまたはアセンブリ参照が指定されていることを確認してください) eye_controller_for_game\検証用\stream_engine_windows_x64_4.1.0.3\bindings\Tobii.StreamEngine.Interop.cs 475 アクティブ
が出る。サンプルコードにはこの関数を使用する部分は無かったため、該当部分をコメントアウトして対処。
次に、Debug.Assertが.NETだとそのままだと使えずコンパイルが通らないので
using System.Diagnostics;
を追加。
さらに、
エラー CS1503 引数 2: は 'out System.Windows.Documents.List' から 'out System.Collections.Generic.List<string>' へ変換することはできません
がサンプルコード中の
List urls; result = Interop.tobii_enumerate_local_device_urls(apiContext, out urls);
部分に出るので、Tobii.StreamEngine.Interop.cs内のtobii_enumerate_local_device_urlsの定義に従い
List<String> urls;
に変更。
最後に、
例外がスローされました: 'System.DllNotFoundException' (検証用.dll の中) 型 'System.DllNotFoundException' のハンドルされていない例外が 検証用.dll で発生しました Unable to load DLL 'tobii_stream_engine' or one of its dependencies: 指定されたモジュールが見つかりません。 (0x8007007E)
のエラーが発生するため、tobii_stream_engine.dllを参照できるようにする。
参照を追加しようとVisualソリューションエクスプローラーでプロジェクトを右クリック→追加→プロジェクト参照→参照からtobii_stream_engine.dllを追加しようとしても、「参照が無効であるか、サポートされていません」と表示されて追加出来ない。
従って、以下を参考にtobii_stream_engine.dllをプロジェクト内のexeファイルと同じディレクトリ(\bin\Debug\net5.0-windows)に直接置くと、無事動作するようになる。
このAPIから得られる画面の注視座標は、左上を(0,0)、右下を(1,1)と設定してあるようなので、実際の画面上の座標に直すには画面の解像度を乗算する必要がある。
今回はここまで。