例題19-5:ioHubを試す

A: だあああああっっ! ふがーーっ!!

B: ぅわっ! なんだなんだ。

A: #$%&’@;:!!

B: なんだAさんか。暑さで熱暴走しましたか。

A: はあはあ、なんだ、B君いたのか。

B: Aさんが入ってくる前からずーっといますが。まあ落ち着いておみやげのずんだプリッツでも食べてくださいよ。

A: う、うむ。すまんな、取り乱して。

B: で、どーしたんですか。

A: ああ、それがだな・・あああああああああっ!ッッ!

B: どうどう。ささ、食べて食べて。

A: はあはあ。そんなわけだ。察しろ。

B: なんだか知らないけどおおっぴらに出来ない事なんですね。

A: おおっぴらに出来ない事なんですよ。それだけでも大変なのに今年度は体調が悪くてね。いや、本当に体調が悪いと仕事もへったくれもないね。猛暑が続きますが読者の皆さんもくれぐれも体調のお気をつけ下さいねってなもんよ。

B: 全然体調が悪い人のように見えませんが。

A: それよ。最近ようやく少し元気が出てきてな。ネタはいろいろあったんだがどうにも気力がわいてこなくて放ったらかしにしていたのだが、元気があるうちに少しでも吐き出しておこうと思って。

B: はあ、そりゃマメなことで。で、今回のお題は?

A: 新たに例題20にするかどうか迷ったんだがな、単発の話題だしPsychoPyの話なんで、例題19の続きという位置漬けに…じゃない、位置づけにした。

B: またPsychoPyですか。で、何を?

A: 今回のお題はPsychoPy 1.77で導入された ioHub である。これはとても大きな変更なのだが、単なるマイナーバージョンアップ扱いのようにいつものごとくさらっと導入されたので面食らった。で、解説を書かないといけないなあと思っていたらもう1.78がリリースされてしまって正直めまぐるしさについていけない感がある。

B: ioHubというのは何ですかね?

A: これは野心的なパッケージで、代表的な市販アイトラッカーを統一的なインターフェースで扱うものだ!ioHubを覚えたら、いちいち個々のアイトラッカーのライブラリに習熟する必要がないという状態になるのだ!!(ばばーん)

B: ふうん。なんかAさんが興奮しているのはわかるんですが、アイトラッカーって、何?どこを見ているか調べるやつですか?

A: うむ。だいたいそれでよいぞよ。

B: んー。Aさんの口ぶりだと凄そうですが、ほとんどの人には関係ない話なんじゃないかなあ。

A: ふっ。いくら私でもアイトラッカーなどというマニアックなデバイスの解説をここでするつもりはないぞ。ioHubはただのアイトラッカー用インターフェースではなく、その実態は 多様なデバイスをディスプレイのリフレッシュレートに縛られず高速ポーリング するためのパッケージなのだ! つまり、 例題11-1 で取り上げた問題に対する 例題13-1 の解決策を通常のキーボードやマウスに対して提供するものなのだッ!!!

B: Aさん、落ち着いて。マニアックとかいうとS先生が泣きますよ。

A: はあはあ。もう息切れしてきた。とにかく、反応時間計測精度に与えるディスプレイリフレッシュレートの悪影響から解放される はず のパッケージなのだ。これが興奮せずにおられるか!

B: うーん。正直ついていけないなあ…。そもそも例題11の時はAさんの方が醒めてたじゃないですか。リフレッシュレートの影響があることは仕方がないんだから、それはそれで付き合えばいいって。あ、読者のみなさんで何の話かさっぱり分からないかたはまず 例題11-1例題11-2 を読んでくださいね。余裕があれば 例題13-1 もお勧めしておきます。

A: うむ。対策がないならうまく付き合っていくしかないが、せっかく素晴らしい対策パッケージが出たんだから使うしかないだろ?

B: まあそりゃそうですが…。ところでさっきの「解放される はず 」てのが気になっているんですが。なんですか「はず」って。

A: ああ… それはだな… その…

B: AさんAさん! しっかり!! ずんだプリッツ食べて!

A: ぶふっ! げふげふっ! 口に突っ込むな。大丈夫だわい。

B: いや、体調悪いとか言ってたから。

A: まあ体調は悪いんだが、それはさておきioHubの話だ。理屈の上ではそうなんだが、実際に使ってみると実に微妙と言うか…。その…。

B: あまり良いないんですか。

A: いや、良い。

B: (椅子からすべり落ちて)なんじゃそりゃ! じゃあなんなんですか。

A: いや、理屈通りに動作しているんだ。そのうえで、微妙と言うかなんというか。

B: あーっっ、じれったい。さっさと話を進めてくださいよ!

A: うむ。今回はPsychoPy 1.77を使用してUSBキーボード入力の反応速度をioHubと従来の入出力で比較したのだ。その結果、確かにioHubによる高速ポーリングの効果は確認できたのだが、平均反応時間で評価する限り従来の入出力からほんのわずかな改善しか見られないこともわかったのだ。以上。

B: ちょ、話進めすぎ!

A: 以下に示すのが今回使用したサンプルプログラムである。4つあるのだが、まずは普通に従来の方法でキー押しの反応時間を計測する19-5-gpc2000.py。

  • 4つまとめてダウンロード→ 19-5.zip
  • 行番号なしのソースファイルをダウンロード→ 19-5-gpc2000.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import psychopy.visual
import psychopy.core
import psychopy.event
import ctypes
import random

win = psychopy.visual.Window(fullscr=True)
msg = psychopy.visual.TextStim(win)
clock = psychopy.core.Clock()

obj = ctypes.windll.LoadLibrary('FbiDio.dll')
device = obj.DioOpen("FBIDIO1",0)
obj.DioOutputByte(device,0,0)

res = []
trialDuration = 1.0
for i in range(100):
    offset = random.random()/2.0+0.2
    msg.setText(str(i+1))
    onFlag = False
    clock.reset()
    while clock.getTime()<trialDuration:
        
        if clock.getTime()>offset and onFlag == False:
            obj.DioOutputByte(device,0,1)
            onTime = clock.getTime()
            onFlag = True
        
        keys = psychopy.event.getKeys()
        if 'space' in keys:
            res.append(1000*(clock.getTime()-onTime))
        
        msg.draw()
        win.flip()
    
    obj.DioOutputByte(device,0,0)

obj.DioClose(device)

fp = open('output_normalgetKey.txt','w')
for r in res:
    fp.write('%f\n' % (r))
fp.close()

A: まあ見ての通りのプログラムだが、ポイントと言うかフツーはまずわかんない点を一つ。今回は反応時間計測の正確さを期すために 自動キー押しマシーン を作成した。といってもテキトーなUSBキーボードをばらしてメカニカルリレーボードに接続しただけである。

../_images/19-5-01.jpg

B: 自動キー押しマシーン? これが?

A: そ。手に持っているのがUSBキーボードをばらした基盤。基盤ちゅうかシートだよな。メンブレン式のキーボードによくあるタイプだ。クリップで押さえているところから赤と黒のケーブルが出ているだろ? これはスペースキーに相当する接点にケーブルを圧着してあるんだが、このケーブルをメカニカルリレーボードにつないで、PCにキーを押させるというわけだ。すごいだろ。

B: なんだかすごく安っぽい工作に見えるんですが。というか工作と言うレベルでもないような…

A: いいんだよ動きゃ。

B: で、メカニカルリレーボードってなんですか?

A: ん。これは文字通り機械式の接点が組み込まれたボードで、電磁石などの力でスイッチを文字通り「押す」。今回使用したのはInterface社のPCI-2503というボードだ。遅延時間は10ms以内とされている。 (2013/8/28追記:詳しくはページ下部の補足その1をご覧ください)

../_images/19-5-02.jpg

B: おお、なんだか知らないけどボードの写真が写るとちょっとすごいことをしているようなイメージになるから不思議だ。

A: プログラムの解説。このボードはメーカーからC言語用のライブラリが提供されているので、 例題9 で紹介した ctypes を使ってpythonから利用する。4行目でctypesをインポート、11行目でライブラリをロードして12行目でデバイスをオープンして使用可能な状態にする。で、13行目のDioOutputByte()という関数だが、第2引数で指定した番号のスイッチの状態を第3引数の値でしている。具体的にはobj.DioOutputByte(device,0,0)でスペースキーを押していない状態、obj.DioOutputByte(device,0,1)で押している状態になる。

B: ふむふむ。13行目ではまず押していない状態にしているんですね?

A: その通り。後は24行目から27行目。試行が始まってからoffsetで指定した時刻が経過したら、DioOutputByte()を使ってスペースキーを押した状態にする。で、その直後に「押した状態にした時刻」をonTimeに保存しておいて、何度もDioOutputByte()を呼ばないようにonFlagをTrueにしておく。

B: …。

A: で、29行目でgetKeys()を使ってキー押しイベントを取得。押されたキーのリストの中にスペースキーがあれば、onTimeからの差を保存しておく。msec単位の値が欲しいけどgetTimeはsecを返すので1000倍しておく。

B: なるほど。

A: 後は特に解説の必要ないね。34行目できっちりflip()していることに注意するくらいか。34行目を実行するとディスプレイの垂直同期を待つので、最大約16.7msec足止めを食らう。このへんの理屈がわからない方は 例題11-2 を復習してください。

B: 今回は[発展]を付けとくべき内容じゃないかなあ。

A: flipを終えたらDioOutputByte()してまたスペースキーを押していない状態にしておく。100回計測を終えたらテキストファイルに結果を書きだして終了。続いてこれをioHubを使うように書き直した19-5-gpc2000_iohub.py。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import psychopy.visual
import psychopy.core
import psychopy.event
import ctypes
import random

#
from psychopy.iohub import launchHubServer,EventConstants
io=launchHubServer(psychopy_monitor_name='default')
display = io.devices.display

win = psychopy.visual.Window(display.getPixelResolution(),
                             monitor=display.getPsychopyMonitorName(), 
                             units=display.getCoordinateType(),
                             screen=display.getIndex(),fullscr=True)

keyboard = io.devices.keyboard
#
msg = psychopy.visual.TextStim(win)
clock = psychopy.core.Clock()

obj = ctypes.windll.LoadLibrary('FbiDio.dll')
device = obj.DioOpen("FBIDIO1",0)
obj.DioOutputByte(device,0,0)

res = []
trialDuration = 1.0
for i in range(100):
    offset = random.random()/2.0+0.2
    msg.setText(str(i+1))
    onFlag = False
    flip_time=win.flip()
    io.clearEvents("all") 
    clock.reset()
    while clock.getTime()<trialDuration:
        
        if clock.getTime()>offset and onFlag == False:
            obj.DioOutputByte(device,0,1)
            onTime = clock.getTime()
            onFlag = True
        
        kb_events = keyboard.getEvents()
        for kb_event in kb_events:
            if kb_event.key == ' ':
                res.append(1000*((kb_event.time-flip_time)-onTime))
                #print kb_event.time, flip_time, onTime
        
        io.clearEvents()
        msg.draw()
        win.flip()
    
    obj.DioOutputByte(device,0,0)
    io.wait(0.1)

obj.DioClose(device)

fp = open('output_iohubgetEvents.txt','w')
for r in res:
    fp.write('%f\n' % (r))
fp.close()

io.quit()
psychopy.core.quit()

A: 正直ioHubのドキュメントやソースをちゃんと読んでないんでサンプルプログラムの丸写しなんだが、8行目でpsychopy.iohubからlaunchHubServerとEventConstantsをインポートする。で、9行目。Hubサーバを初期化してioという変数に格納しておく。モニターは各自がつけている名称に書き換えてください。まあまずdefaultというモニタはあるはず。

B: むむむ。いきなりわからないぞ。Hubサーバってなんですか?

A: 直感的に言うと、ioHubは刺激提示のプログラムと独立に動作して、裏で勝手にキーボードやマウスを監視するのだ。勝手にやっているからこそ垂直同期待ちに捕われないのである。その勝手プログラムをスタートさせるメソッドがlaunchHubServer()である。

B: うーん、わかったような、わからないような。

A: で、次は完全におまじないモードなんだが…12行目。PsychoPyのウィンドウを開くいつものメソッドなんだが、ioHubとリンクさせるためにいろいろと面倒くさい。10行目でioHubが管理しているディスプレイを取得してdisplayという変数に格納しておいて、そこから解像度やモニター名、単位、スクリーン番号などを取得している。まあコピペすりゃいいよ。コピペ。

B: はあ。

A: で、同じように17行目でioHubが管理しているキーボードを取得してkeyboardという変数に放り込んでおく。あとのメカニカルリレー初期化などはさっきと同じ。

B: …。

A: で、次が厄介なんだが、各試行の最初。32行目から34行目。どうもioHubのキー押しイベントが保持している時刻が厄介で、まず試行の開始時に一回flip()を空打ちしておいてflipした時刻を得る。この値がどうもioHubの時刻と起点が一致しているっぽいので、これを反応時間計測の基準とする。一方、メカニカルリレーでスペースキーを「押した」時刻はこの方法で取得するわけにはいかないので、flip時刻を取得した後にclock.reset()して「時計合せ」をしておき、スペースキー押しの時刻はclock.getTime()で測ることにする。「時計合せ」は34行目だね。

B: むむむむむ? 全然わからないぞ?

A: かなり無茶なこと、というか普通の用途ならまず測らない時間を測っているからややこしいのは仕方がない。あと、33行目のclearEvents(‘all’)はioHubにキャッシュされているイベントを全て消去するというもの。消去しておかないとうまく動かなかったので入れてある。このへんは 呪術 の世界だな。おまじない、おまじない。

B: 呪術ですか。

A: 後の違いはキー押しイベントの取得。42行目。従来のgetKeys()ではなくioHubのキーボードオブジェクトのgetEvent()を使う。戻ってきたリストからキー押しイベントオブジェクトを取りだし、keyデータ属性をみればどのキーが押されたのかわかる。キー名は従来のPsychoPyと同じ。多分。

B: 多分って、あーた。

A: 私が試した範囲では同じ。ソースをまだ読んでないから絶対そうとは言わない。ともかく、timeというデータ属性にキー押し時刻が入っているので、これをflip時刻から引けば試行開始時刻を基準としたキー押し時刻がわかる。後はイベントを取得したらclearEvents()。48行目。そしてなぜだかよくわからないんだが一試行終わった後すぐに次の試行に行こうとすると上手くキー押しが拾えなかったので試行間に0.1secの時間をとってある。53行目。後は同じ。

B: ぶふーっ。なんだかよくわかりませんけどおまじないとか多いですね。Aさんもまだ使いこなせていない感じですか?

A: ん。ロクに時間がないのでなあ。で、次のプログラムだが…

B: え、まだあるんですか?

A: 最初に4つあるって言ったじゃないの。

B: そういわれてみれば…。でも、後なんのプログラムがあるんです?

A: ちょっとこれらのプログラムを動かして疑問に思ったことがあったので、確認用に書いたのだ。普段ならまずこの二つを走らせて結果を見て、解説してから次のプログラムに入るところなんだが、正直そこまでていねいにする元気がないので一気にやる。

B: すわ。気合ですな。

A: 次のプログラムは19-5-gpc2000_noflip.py。19-5-gpc2000.pyをベースにしています。

22
23
24
25
26
27
28
29
30
31
32
33
    while clock.getTime()<trialDuration:
        
        if clock.getTime()>offset and onFlag == False:
            obj.DioOutputByte(device,0,1)
            onTime = clock.getTime()
            onFlag = True
        
            while True:
                keys = psychopy.event.getKeys()
                if 'space' in keys:
                    res.append(1000*(clock.getTime()-onTime))
                    break

B: あれ、途中だけ?

A: 29行目から33行目以外は全く19-5-gpc2000.pyと同じだから省略した。このプログラムでは、25行目でスペースキーを押した後、flip()に移らずに全力でgetKeys()しまくってスペースキーが押されるのを待つ。 flip()に影響されない本来のgetKeys()の力を評価するためのもの だな。本来スペースキー押しの反応時間を記録したら直ちに次の試行に移っても問題ないのだが、 面倒くさい ので律儀にdraw()とかflip()とかしてtrialDurationが経過するのを待つ。

B: Aさんらしいですな。

A: まあここまで来たら察していただけると思うが、最後のプログラムは…

B: 19-5-gpc2000_noflip.pyのioHub版、ですか。

A: 御名答。

35
36
37
38
39
40
41
42
43
44
45
46
47
48
    while clock.getTime()<trialDuration:
        
        if clock.getTime()>offset and onFlag == False:
            obj.DioOutputByte(device,0,1)
            onTime = clock.getTime()
            onFlag = True
            
            waitkey = True
            while waitkey:
                kb_events = keyboard.getEvents()
                for kb_event in kb_events:
                    if kb_event.key == ' ':
                        res.append(1000*((kb_event.time-flip_time)-onTime))
                        waitkey = False

B: 例によって19-5-gpc2000_iohub.pyからの変更点だけですね。

A: うむ。スペースキーを押したら直ちに全力でgetEvents()しまくる。で、スペースキーが押されたのを確認したら反応時間を記録する。

B: それだけ?

A: それだけ。さあ、結果を見てみよう。

B: あれ、グラフももう用意してあるんですか? 普段ならぼくが「じゃあ走らせてみましょうか」とか言って…

A: 面倒くさいっつってんだろ。何度も言わせんな。

../_images/19-5-03.png

B: えーっとこれはどう見ればいいんですかね。

A: 反応時間のヒストグラムだ。横軸が反応時間、縦軸が試行数。プログラムをそれぞれ2回走らせてデータを合わせてあるので総試行数は200試行だ。まずは左上の青いグラフと左下の赤いグラフを見たまえ。青がioHubによる結果、赤が従来の方法による結果だ。

B: おおお。従来の方法ってやつは34msecくらいと50msecくらいに集中してるじゃないですか。集中って言うか、そこにしか棒が立っていないし。

A: そう。これが例題11で言ってきたことの別の表現で、従来の方法ではキー押し反応時間の計測はディスプレイのリフレッシュ周期に縛られることがとても良くわかる。今回使用したディスプレイは60Hz、リフレッシュ周期は16.7msec。二つの分布の間隔とほぼ一致しているだろう?

B: なるほど。これはすごくわかりやすいですね。グラフ上の黒い点線は?

A: 縦の点線が反応時間の平均、横の点線が平均値±1SDを示している。

B: なるほどなるほど。

A: で、左上のioHubの結果なんだが、30msec前から50msec位までなだらかに分布している。ちょっと35msec付近が多いかな?と思うけどこれは回数を増やすと消えてしまうかもしれない。その点は後で触れる。

B: ディスプレイのリフレッシュに関係なくキー押しを取得できるから分布がばらけるんですよね。それにしてもキーを押してから実際に検出されるまで平均で38msecもかかるんだなあ。

A: さっき書いたようにメカニカルリレーの動作で10msec未満の遅延があるから、実際にはもう少し速いと思われる。まあこんなもんだろ。

B: 左下と左上のグラフの差は歴然。いやあ、きれいな結果じゃないですか。

A: うむ。ioHubが理屈通りに動作していることが確認できた、というのがこのことよ。問題は右側のプロットなんだが…。

B: 右側?

A: 右上のシアンのグラフがioHubでflip()せずに全力でキー押しを待ったもの、右下のマゼンタのグラフが従来の方法でflip()せずに全力で待ったもの。まずは右下から見てみよう。

B: おお、これは分布がシャープですね。

A: 従来の方法で全力でキー押しを待てば平均反応時間は32.18msec、SDが2.38msec。この分布を約33msecで二つに分割すればちょうど左下のflip()した時と同じような感じになる。つまり、flip()した時はこの約33msecのタイミングのflip()に間に合えば前半の、間に合わなかった時には後半の分布に含まれるというわけだ。

../_images/19-5-04.png

B: ふむふむ。

A: で、右上の図を見てもらいたいのだが。

B: あらら? これはflip()を待たずに全力でキー押しを待った結果なんですよね? ずいぶん遅い試行があるみたいですけど?

A: そう。これが私がすっきりしない理由。 flip()せずに全力で待つと、ioHubの方が成績が悪い のである。最初私のプログラムの書き方が悪いのかと思って悩んだのだが、よく考えてみれば ioHubはディスプレイのリフレッシュをしようがしまいが関係なくキー押しを取得するのだから、flip()しようが全力で待とうが関係ない と理論上は予測すべきなのだ。そういう観点で左上と右上のグラフを見比べてみると、確かにioHubで全力待ちした結果(右上)とflip()した結果(左上)はほとんど変わらない。検定はしていないが、する意味があるとは思えない。

B: …。

A: 右下の従来の方法で全力待ちしたものと比べると、明らかにioHubの結果は遅延している。しかも平均値ベースで判断する限り、遅延量は従来の方法でflip()しながら計測した結果とほとんど差がない。もちろん従来の方法でflip()しながらの時と分布が大きく異なるので、ここに意義を見出す人にとってはioHubは有意義なものかも知れない。だが私は…。

B: 私は?

A: …言葉が出てこないな。ioHubが目指している到達点はすばらしいもので、ぜひそこへ到達できるように応援している。だが、ドキュメントがあまり整っていない現状で、ioHubへの乗り換えを積極的に他人に勧めようという気にはなれないのは確かだ。

B: なんで遅延しちゃうんですかね?

A: まだソースコードも全然読んでないし、憶測で発言するのは控えたい。

B: むー。そうですか。仕方ありませんね。

A: そうそう、そういえば私自身はまだ確認プログラムを書いていないんだが、ioHubは他にもCtrl-Cといったctrlキーとの同時押しや、キーリリースにも対応しているらしいので、むしろこっちの改善点に価値を見出す人にはお勧めしたい。

B: そ、それ重要な拡張じゃないですか!

A: ああ。でも今日はそこまで確認する元気がなくて。ここで締めくくりという事にしたい。

B: なんかしんどそうですね。大丈夫ですか。

A: ああ。ちょっと家に帰って休むわ。そうそう、最後にひとつ、伝言が。

B: なんでしょう?

A: 「この物語はフィクションであり、実在のしがない大学教員などとは一切関係ございません」って書いといて。ずんだプリッツはいただいていくよ。んじゃ。

B: (呆れ顔で)…ホントにあんた体調悪いの?

補足その1

使用した機材は以下の通りです。

CPU Intel Core i7 2600K
マザーボード Intel DZ68DB (Intel Z68 chipset)
OS Windows7(x86) SP1
PsychoPy 1.77.01
キーボード ELECOM製 USB接続キーボード(型番不明)
キーボードドライバ Windows 標準USBキーボードドライバ

「自動キー押しマシーン」の仕組みについて補足します。メンブレン式のキーボードは文字通り中にキーボードの配線をプリントしたシートが二枚入っていて(下図左上)、キーを押しこむと二枚のシートにプリントされている接点が接触して電流が流れることによって「キーが押された」ことになります(下図右上)。キーボードを分解してシートを取りだし、スペースキーの接点のところに導線を取り付けて、これをメカニカルリレーと呼ばれる装置に接続します。メカニカルリレーの中には電磁石が入っていて、電磁石をON/OFFすると中のプレートが動いてスイッチがON/OFFされます。本文中のPC内部の写真のボード(Interface PCI-2503)は基板上にこのメカニカルリレーを搭載していて、プログラム上から高速にスイッチをON/OFFすることが出来ます。このON/OFFをPsychoPyのスクリプト上から行ってキー押しイベントを発生させてそれを検出するのが今回のサンプルプログラムです。

../_images/19-5-05.png

補足その2

本文中のioHubの結果(青いグラフ)と従来の方法の結果(赤いグラフ)のそれぞれにflipなしの結果(マゼンタのグラフ)を重ねた図を以下に示します。従来の方法ではflipの後に回ってしまった65個(32.5%)のサンプルは遅延してしまっていますが、残りの135個のサンプルはflipなしの結果と比べても遅れていません。一方、ioHubでは81個(41%)ものサンプルがflipなしの最遅のbinより遅れています。これでは無条件に「ioHubの結果の方が優れている」とは言えないというのがAさんの判断です。

../_images/19-5-06.png