例題19-1:マウスは◇に含まれますか?

B: あら、例題18が終わったばかりなのにもう例題19ですか。

A: ん。ちょっと思うところがあってな。例題19はPsychoPyで個人的にお気に入りの機能をだらだらと紹介していきたい。場合によっては例題20以降と並行してするかも知れない。

B: なんですか、思うところって。

A: それは秘密。今日はあまり遊んでいる暇はないのでさっさと本題に入るぞ。

B: 僕だってヒマじゃありませんよーだ。

A: さて、今回のお題は以下の通り。

  • PsychoPyで参加者などの情報を入力するダイアログをお手軽に作成する(Coder)

  • PsychoPyでマウスを使う(Coder)

  • PsychoPyでマウスカーソルが刺激の上に乗っているか判定する(Coder)

B: なんだ、全部Coderじゃないですか。

A: うん。しばらくBuilder漬けだったから禁断症状が出てきた。いちいちCoderとか書きたくないんだけどPsychoPyの場合CoderとBuilderをきっちり分けとかないと読者の方にいらぬ期待をさせてしまうかも知れんからな。さて、これが今回のサンプル。よくある視覚探索課題なんだが、マウスでターゲットをクリックして反応するようになっている。

  • 行番号なしのソースファイルをダウンロード→ 19-1.py

  1import psychopy.visual
  2import psychopy.core
  3import psychopy.event
  4import psychopy.gui
  5import psychopy.sound
  6
  7import random
  8
  9
 10color1 = 'red'
 11color2 = 'green'
 12stimWidth = 10
 13stimHeight = 50
 14stimSpacing = 100
 15beepFreq = 1500
 16beepDuration = 0.2
 17gridX = 6
 18gridY = 6
 19
 20if gridX %2 == 0:
 21    offsetX = (gridX-1.0)/2.0
 22else:
 23    offsetX = gridX/2.0
 24
 25if gridY %2 == 0:
 26    offsetY = (gridY-1.0)/2.0
 27else:
 28    offsetY = gridY/2.0
 29
 30posList = [(stimSpacing*(x-offsetX),stimSpacing*(y-offsetY)) for x in range(gridX) for y in range(gridY)]
 31
 32params={'data file':'data.csv',
 33        'full screen':False,
 34        'screen size':'',
 35        'num of stimuli':'8',
 36        'show mouse':False,
 37        'trials':'20'}
 38dlg=psychopy.gui.DlgFromDict(dictionary=params,title='set parameters')
 39if dlg.OK==False: psychopy.core.quit()
 40
 41try:
 42    winSize = map(int, params['screen size'].split('x'))
 43    win = psychopy.visual.Window(size=winSize, fullscr=params['full screen'], units='pix', allowGUI=params['show mouse'])
 44except:
 45    win = psychopy.visual.Window(fullscr=params['full screen'], units='pix', allowGUI=params['show mouse'])
 46
 47mouse = psychopy.event.Mouse(win=win)
 48sound = psychopy.sound.SoundPygame(value=beepFreq, secs=beepDuration)
 49
 50nStim = int(params['num of stimuli'])
 51if nStim > len(posList):
 52    print 'Num of stimuli is too large.'
 53    psychopy.core.quit()
 54
 55nTrials = int(params['trials'])
 56
 57sample = psychopy.visual.Rect(win, width=stimWidth, height=stimHeight, lineColor=None)
 58stimuli = [psychopy.visual.Rect(win, width=stimWidth, height=stimHeight, lineColor=None) for i in range(nStim)]
 59
 60blocks = [[targetColor, targetOri] for targetOri in [-10,10] for targetColor in [color1, color2]]
 61random.shuffle(blocks)
 62
 63fp = open(params['data file'],'w')
 64
 65for blockCondition in blocks:
 66    #set color and orientation of stimuli
 67    for i in range(nStim):
 68        if i < nStim/2: #target color
 69            stimuli[i].setFillColor(blockCondition[0])
 70            
 71            if i == 0:
 72                stimuli[i].setOri(blockCondition[1]) #orientation
 73            else:
 74                stimuli[i].setOri(-blockCondition[1])
 75        
 76        else: #distractor color
 77            if blockCondition[0] == color1:
 78                stimuli[i].setFillColor(color2)
 79            else:
 80                stimuli[i].setFillColor(color1)
 81            
 82            if i%2 == 0:
 83                stimuli[i].setOri(blockCondition[1]) #orientation
 84            else:
 85                stimuli[i].setOri(-blockCondition[1])
 86    
 87    #set color, orientation and position of the sample.
 88    #(color and orientations are equal to stimuli[0])
 89    sample.setFillColor(blockCondition[0])
 90    sample.setOri(blockCondition[1])
 91    
 92    for trial in range(nTrials):
 93        #draw the sample
 94        sample.draw()
 95        win.flip()
 96        
 97        #wait for click on the sample
 98        while True:
 99            if mouse.getPressed()[0]:
100                if sample.contains(mouse):
101                    break
102        
103        #blank screen (1 sec)
104        win.flip()
105        psychopy.core.wait(1)
106        
107        #randomize position list and draw stimuli
108        random.shuffle(posList)
109        for i in range(nStim):
110            stimuli[i].setPos(posList[i])
111            stimuli[i].draw()
112        win.flip()
113        
114        #wait for click on the sample
115        mouse.clickReset()
116        while True:
117            (buttons, rt) = mouse.getPressed(getTime=True)
118            if buttons[0]:
119                for i in range(nStim):
120                    if stimuli[i].contains(mouse):
121                        break
122                else: #no stimuli contained mouse
123                    continue
124                
125                if i==0: #target
126                    sound.play()
127                    psychopy.core.wait(beepDuration)
128                    break
129        
130        fp.write('%s,%d,%.3f\n' % (blockCondition[0],blockCondition[1],rt[0]))
131        fp.flush()
132
133fp.close()

B: ぶはっ! 油断していました。長い!

A: 長いってたったの133行だろ。最近短めのコードばかりだったからたるんどるんじゃないか。

B: いや、PsychoPyはまだなじみがないので…

A: 今回解説する便利機能以外はほとんどVisionEggを使ったスクリプトと変わらないはずである 。今回はビシビシ厳しめに、簡潔な解説だけで進めるので覚悟してついてくるように。

B: うへー。

A: まず、「PsychoPyで参加者などの情報を入力するダイアログをお手軽に作成する」という項目。この目的には psychopy.gui.DlgFromDict という非常に便利なクラスがある。これは実はBuilderでExperiment informationからダイアログを作成するのに利用されていたクラスである。

B: と、いうことはBuilderの時と同じように辞書オブジェクトを作成すれば利用できるというわけですか?

A: その通り。これを使わない手はないな。具体的には32行目からparamsというdictオブジェクトを用意して、38行目でpsychopy.gui.DlgFromDictに渡す。これだけで以下のようなダイアログができあがり。

../_images/19-1-01.png

B: おお、便利!

A: dictオブジェクトの値とダイアログの項目の関連は 例題18-4 の後半を参考にしてください。dictそのものについては 例題3-3 でも触れています。

B: 例題3のころはAさん「dictなんて心理実験で使うことねぇだろ」とか言ってたのにねえ。

A: 若かったのう。さて、このダイアログで設定する項目以外にも、10行目から18行目でもいろいろなパラメータを設定している。まあ簡単に解説しておこうか。

color1, color2

刺激に用いる色。色名か'#FF0088'のような文字列で指定する。

stimWidth, stimHeight

刺激に用いる長方形の幅と高さ。単位はpix。

stimSpacing

刺激図形同士の縦、横の最小距離。単位はpix。

beepFreq, beepDuration

正答の時に鳴らすビープ音の周波数と長さ

gridX, gridY

刺激を配置するグリッドの大きさ。

B: Aさん、どんな実験画面になるのか見てからの方がわかりやすいと思うんですが。

A: おっと、確かに。まず最初に探すべきターゲットが出現する。参加者はこいつをマウスでクリックする。

../_images/19-1-02.png

A: するとこのように刺激が出現するので、さっきのターゲットと同じものを探してクリックする。ターゲットはかならず一つだけ存在する。えーと。読者の皆様もソースコードダウンロードして、ぜひ一度実行してみてくださいませ。

../_images/19-1-03.png

B: なるほど。で、gridXとかgridYとかいうのは…

A: 刺激は仮想的なグリッドの格子点に無作為に配置される。gridX = gridY = 6なら 6×6 = 36箇所の候補があって、その中から無作為に選択されるわけだ。ちなみに20行目から28行目ではgridX、gridYが偶数か奇数かによって刺激の位置を調整している。要はサンプルと全く同じ位置に刺激が出ることが無いようにずらしているんだが、そのことがわかってないとここの処理はちょっと意味不明かもしれんね。全く同じ位置に出ることがあっても構わないのなら偶数奇数にかかわらず1を引いて2.0で割ればいいだろう。そうすればグリッドの中心がスクリーンの中央に一致する。

B: んー、ちょっとややこしいです。後でじっくり考えます。それはそうと、刺激は何個出ているんですかね。1、2、…

A: 刺激の個数はさっきのダイアログで指定できるようになっている。ダイアログの設定項目を示しておこうか。

data file

反応時間を出力するファイル名

full screen

チェックするとフルスクリーンモードで起動

num of stimuli

刺激の個数

screen size

スクリーンサイズを指定する場合は1024x768のように小文字のxではさんで指定する。デフォルトのサイズを使用する場合は空欄で良い。

show mouse

チェックするとマウスカーソルとウィンドウの枠(非フルスクリーンの時)が表示される。

trials

この実験ではひとつのブロック内でターゲットの色と向きは固定されている。ひとつのブロックあたりの試行数を指定する。

B: ふむふむ。っていうかマウスカーソルの表示ってなんですかソレ、マウスで反応するのにマウスカーソルが表示されてなかったらどうやって反応するんですか!

A: いや、じつはこのスクリプトはタッチパネルのスクリーンで実施することを想定している。だから参加者はただターゲットを押せばいいのだ。タッチパネルのないPCでデバッグする時にはそれでは困るんでマウスカーソルを表示するオプションをつけてある。

B: へえ、タッチパネルってマウスと同じ扱いになるんだ。

A: この初期化のあたりで解説しておくことがあるとすれば…。そうだな、まず38行目と39行目。psychopy.gui.DlgFromDictオブジェクトを生成するとダイアログが終了するまでここで処理が止まる。で、終了すれば39行目に進むわけだが、ここでOKというデータ属性がFalseならOKを押さずに終了しているので、psychopy.core.quit()を呼んでスクリプトを終了している。

B: ふむふむ。

A: OKがTrueであればpsychopy.gui.DlgFromDictに渡したdictオブジェクトであるparamsに設定した値が入っているので、params['ほげほげ']として値を取り出す。'full screen'と'show mouse'はそれぞれpsychopy.visual.Windowの引数fullscrとallowGUIに対応している。これは例題18-3の復習だね。あとは47行目、こいつはマウスがらみだから後で詳しく触れる。48行目…。こいつはパス。

B: ええっ、名前からして音を鳴らすための何かですよね。大事なんじゃないんですか?

A: 説明すると長くなるのでこれはまたの機会に。

B: ぶーぶー。

A: 忙しいんじゃなかったのか。また時間があるときに説明してやるから今は先へ進むぞ。57行目と58行目は刺激の準備。ここではPsychoPy.visual.Rectというクラスを使用している。VisionEgg.MoreStimuli.Target2Dのように長方形を描くためのクラスだな。引数widthとheightで幅と高さを指定する。

B: リストじゃないんですね。

A: 長方形の拡大率を設定するsizeというのがあってそっちはリストを受け付けるんだが、混乱の元なのでとりあえずwidthとheightで指定すると覚えておいてくれ。あと、VisionEggと違うのはlineColor、fillColorという引数があって、塗りつぶしの色と輪郭線の色を指定できる。ここでは輪郭線なしで描画するのでlineColor=Noneとしている。ちなみに輪郭線の太さを指定するlineWidthという引数もある。

B: 結構多機能ですね。fillColorは?

A: それは後で指定するのでここでは無視。58行目はリストの内包表現を使って指定された個数の刺激を一気に作成してリストにまとめてある。

B: 結構いろんなテクニックを使ってますね。入門編とは言えないんじゃないですか?

A: 入門編は例題18でおしまい。すでに通常営業に入っとる。60行目でターゲットの色と形の組み合わせで4種類のブロックを作って、で、61行目でrandom.shuffleして順番を無作為に入れ替え。

B: ええと、ターゲットになる刺激の色と方向を示しているということでよろしいですかね?

A: その通り。で、67行目から85行目だが…。ここは練習問題だと思ってぜひ読者の皆さんで解読してほしい。何をしているかというと

  • 刺激の半数をターゲットと同じ色に、残り半数を異なる色に設定している(68行目、76行目のif - else)。色の設定はsetFillColor()というメソッドに色名を引数として与えれば出来る。

  • ターゲットと同色の刺激のうち、最初の1個をターゲットと同じ方向に設定している。すなわちこれがターゲットである。ターゲットと同色の残りの刺激は全てターゲットと異なる方向である(71行目、73行目のif - else)。方向の設定にはsetOri()というメソッドに回転角度を引数として与えればできる。

  • ターゲットと異なる色の刺激は、ひとつずつ交互にターゲットと同じ方向、異なる方向に設定している(82行目、84行目のif - else)。

B: 結構答えそのもののような。このやり方だとターゲットと異なる方向の刺激の個数が同じ方向の刺激よりかなり多くなりそうな気がすんですが。

A: ん。仕様である。その辺りは各自で目的に応じて工夫するがよいぞよ。

B: …要は面倒くさかったんだな。

A: 最後に試行開始前に表示するサンプルの色と方向を決定する。これはblockCondition[0]とblockCondition[1]にそれぞれ格納されているんだからそれぞれsetFillColor()、setOriすればいい()。89行目と90行目。これで各ブロックの初期化作業はおしまい。

B: まだ刺激の位置が決定していませんね。

A: 刺激の位置は試行毎に変わるんだから、各試行の最初に決めればいいんだよ。では早速位置を決めるところを…と言いたいところだが、ここで今回の二つ目の目的、「PsychoPyでマウスを使う」が出てくる。さっき飛ばした47行目。psychopy.event.MouseというのがPsychoPyでの標準的なマウスのクラスで、引数winにpsychopy.visual.Windowのインスタンスを指定する。これでマウスの準備はおしまい。

47mouse = psychopy.event.Mouse(win=win)

B: ええっ!! これだけでマウスが使えるようになるんですか!!!

A: VisionEggでも似たようなもんだっただろ。わざとらしい。

B: へへへ。ここは演技でも驚いておかないと。

A: で、 マウスの状態を知りたいときにはgetPressed()、getPos()というメソッドを使う 。getPressed()はマウスの3つのボタンがそれぞれ押されていれば1、押されていなければ0が格納されたリストを返す。

B: 僕のマウスにはボタンが二つしかありませんが…。

A: 普通のマウスなら左ボタンが1番目、右ボタンが3番目の要素に対応している 。さて、これを踏まえて97行目から見ていこう。

 97        #wait for click on the sample
 98        while True:
 99            if mouse.getPressed()[0]:
100                if sample.contains(mouse):
101                    break

A: 98行目が試行の開始で、まずターゲットのサンプルをdraw()してflip()。これで画面にターゲットのサンプルが表示される。で、100から104行目で参加者がマウスでサンプルをクリックするのを待つんだが、ここで今回紹介したいもうひとつのテクニック、「PsychoPyでマウスカーソルが刺激の上に乗っているか判定する」が出てくる。

A: 実は「タイル状に並べられた画像のうち、どの画像の上にカーソルが乗っているか」を判断するという似たような処理を 例題12-4 で扱っているんだが、例題12-4で挙げたテクニックを実現するクラスメソッドがpsychopy.visual.Rectには備わっているのだ。その名もcontains()。

B: へえ。Aさんがいかんなくおっさんっぷりを自慢していた回ですな。覚えています。

A: そんなこと覚えんな。とにかく、99行目でgetPressed()を呼んで戻り値の最初の要素をチェック。1ならばif文ではTrue扱いになるので100行目に進む。ここでcontains()が出て来る。

B: ふむふむ。

A: contains()は便利な関数で、 マウスオブジェクトそのものを引数に与えると、ちゃんとマウスの位置を取得してマウスカーソルが図形の中に含まれているか判定して、含まれていればTrue、いなければFalseを返してくれる 。だから100行目のようにif文の条件式としてそのまま書けるのだ。含まれていれば101行目のbreakへ進んでループが終了する。

B: なるほど。

A: ループを抜けたら104行目でいったん刺激を消し、105行目のpsychopy.core.wait()で1秒待つ。名前の通り一定時間待つ関数で、引数の単位は秒だ。ここからが試行の本番だが、ただ描画する刺激が多いだけで今の「サンプルのクリック待ち」と変わんないんでわかるでしょ。おしまい。

B: えー。ちょっとそれはいくらなんでも手抜きすぎませんか? さっき話題になった刺激の位置決めもまだ説明してもらってませんし。

A: しょーがないなあ。まず位置決めだが、これはとても簡単。まず30行目で刺激が置かれる可能性がある場所をすべて並べたリストを作成する。

30posList = [(stimSpacing*(x-offsetX),stimSpacing*(y-offsetY)) for x in range(gridX) for y in range(gridY)]

B: すでにこれ自体簡単じゃないような…。

A: これはさすがに自力で解読してほしいな。リストの内包表現をご存知ない方は 例題3-3 をご覧ください。

B: pythonになじみがない方は自分がリストの内包表現がわからないかどうかすらわからないと思いますが。

A: ぐっ、それは確かに。とにかく例題3はpythonの基本的な演算子を網羅しているのでpythonになじみがない方は一度ご覧下さい。

B: ご覧ください。

A: さて、このようなリストを用意しておけば、108行目から110行目のようにするだけで刺激の無作為な配置が出来ます。

108        random.shuffle(posList)
109        for i in range(nStim):
110            stimuli[i].setPos(posList[i])

B: ええと、これは…。そうか。random.shuffle()で刺激位置のリストを並び替えて、先頭から順番に個々の刺激に割り当てていけば、すべての刺激の位置を無作為に決められるのか。

A: その通り。B君もだいぶ頼もしくなってきたな。ここでは続く111行目でdraw()も済ませてしまっている。そして…、おっと。解説しておくべきポイントがひとつ、いや三つあるかな?

B: ほらほらー。解説はていねいにやるもんですよー。で、何がポイントなんです?

A: 第一に117行目、そして115行目。 psychopy.event.Mouse.getPressed()は引数にgetTime=Trueを設定するとボタンの状態を表すリストと反応時間のリストを返す 。117行目ではbuttonsにボタンの状態、rtに反応時間が代入される。反応時間っていつが基準?って疑問に思うところだが、実は 115行目のmouse.clickReset()が反応時間計測開始 の処理である。初めて見たとき「clickResetっていったいクリックの何をリセットするのやら?」と思ったが、マウスに内蔵されているタイマーをリセットするってことだったのさ。

B: マウスに内蔵? マウスってタイマーが内蔵されているんですか?

A: ああ、すまん。正確にはpsychopy.event.Mouseに内蔵されている、だ。psychopy.event.Mouseはインスタンスが生成された時にpsychopy.core.Clockのインスタンスを生成してデータ属性として保持している。こいつを使って反応時間を計測しているというわけだ。

B: ええと、じゃあ例題18-3でやったpsychopy.core.Clockを用意して、getTime()で時間を測って…とかいう処理を自動的にやってくれるという理解でよろしいでしょうか。

A: よろしいんじゃないかと。まあ、自動的にしてくれるのと自分で書くのとどっちがいいのかはよくわからんな。

B: えぇ、便利じゃないですか。自動でやってくれるのは。

A: このタイマーはマウス専用のタイマーなんだよな。タイマーが必要となる場面は刺激の制御などいろいろあるわけで、結局は別途タイマーを用意しないといけない。ていうかむしろタイマーがいくつもあると管理するのがめんどうくさい。自分でひとつのタイマーを宣言して自分で管理する方が私にとっては性に合う。

B: うーん。そういわれるとAさんのいうことももっとものような。

A: psychopy.event.Mouseのデータ属性に手を突っ込めばマウス以外の用途に利用できなくはないが、そんな行儀の悪いプログラムは書くべきじゃないね。

B: ほう。結構「なんでもあり、動けばOK」なAさんがそういう言い方をするのは珍しいですね。

A: 行儀が悪くても処理が速いとか、それに見合うメリットがあればアリかと思うんだが、これはいたずらにプログラムを複雑に読みにくくするだけだからね。まぁ、ある程度pythonに慣れてきて、普段何気なく使っているパッケージというものの内部がどうなっているのか興味が出てきた人は、いい勉強になるから一度やってみたらいいよ。でも一度やったらそれで十分。

B: ふーん。

A: すまん。脱線した。とにかく117行目でgetPressed()の戻り値のうち、2番目の要素は後で反応時間を記録する時に必要なのでrtという変数に代入しておく。で、buttonsという変数にgetTime=TrueなしでgetPressed()を呼んだ時と同様にボタンの状態を保持したリストが代入されているので、118行目で最初の要素が1かどうか判定。そして次が二つ目のポイント。マウスカーソルがいずれかの刺激の上に乗っているかどうかをfor文を使ってひとつずつ判定している。

B: for文を使うのは普通だと思うんですが…。

A: ポイントはfor文を使うことではなく、119行目のforと対になっている122行目のelse。B君はわかるか。

B: ! あの、whileと対になって出てきたことありましたよね! ええと、ええと…。なんだっけ。

A: for文に添えられたelseはfor文による繰り返しが普通に終了したときに実行されるが、for文がbreakで終了したときには実行されない 。以下のコードと同じ動作である。

abortByBreak = False

for i in range(nStim):
    if stimuli.contains(mouse):
        abortByBreak = True
        break

if not abortByBreak:
    continue

B: ああ、そうでした。

A: C言語などから移ってきた読者の方は「?」かも知れませんが、覚えておくと便利ですよ。これを踏まえてB君、116行目から123行目の処理を説明して。

B: え? 119行目じゃなくて116行目?

A: うむ。

B: ええと、116行目はwhile True:だからbreakか何かで中断されるまでひたすら処理を繰り返します。

A: そこから119行目までは飛ばしていいぞ。

B: あ、そうですか。じゃ119行目。刺激のリストに対してfor文を適用してひとつずつ処理します。処理の内容は、マウスカーソルが刺激の上に乗っているかcontainsでチェック。乗っていれば直ちにbreakでfor文を中断。

A: どの刺激にも乗っていなければ?

B: for文による繰り返しが普通に終了すればelseに続く文を実行。ここには、continue…?

A: さあ、ここがポイント。このcontinueは何とペアになっている?

B: ええと。119行目のfor?

A: 違う。 elseに来たときにはfor文による繰り返しはもう終わっている んだから、119行目のforではありえない。

B: じゃあ116行目のwhile?

A: その通り。123行目のcontinueが実行されると、116行目に戻ってwhile文が繰り返される。どの刺激の上にもマウスカーソルが乗っていなかったんだから、ボタンが押されるのを待つ処理へ戻るわけだ。

B: ああ、それで116行目から解説させたんですね。

A: だね。121行目のbreakでforが終了した場合は123行目が実行されずに125行目に飛ぶ。今度は押された刺激がターゲットかどうかを判定している。ターゲットは常に刺激リストの先頭にあるんだから、iが0ならマウスカーソルが乗っているのはターゲットであるはずだ。

B: …。

A: ターゲットだったら126行目から128行目で音を鳴らしてからbreakしてwhileループを抜ける。三つ目のポイントは127行目のpsychopy.core.wait()。音を鳴らす仕組みは解説しなかったが、psychopy.sound.SoundPygame(48行目)という名前通りただpygameを呼んでいるだけなので、 例題2-2 を読んでもらえばだいたいわかるはず。例題2-2で話したようにpygame.soundはplayすると音が鳴りやむ前に処理が戻ってくるので、音を鳴らす長さだけpsychopy.core.wait()して音が鳴りやむのを待つ。この処理をしないとビープ音が鳴っている間に次の試行が始まってしまう。

B: …。

A: これで116行目から128行目のwhileループの解説は終わりだが…。どうした、B君。腑に落ちない顔をして。

B: いや、ターゲットは必ず刺激リストの先頭にあるんですよね?

A: うむ。それが?

B: じゃあ、マウスカーソルがターゲットの上に乗っているかどうかを判定するにはfor文ですべての刺激からcontainsを呼ばずにターゲット刺激からcontainsを実行すればいいんじゃないですか? こんな風に。

while True:
    (buttons, rt) = mouse.getPressed(getTime=True)
    if buttons[0]:
        if stimuli[0].contains(mouse):
            sound.play()
            psychopy.core.wait(beepDuration)
            break

A: むぐっ! た、確かに…

B: …。(まっすぐに見つめている)

A: 確かに、マウスカーソルがターゲットに乗っているかだけを判定すればいいのならB君の言うとおりだが、実験によっては誤反応もすべて記録したいときもあるだろう。そういう場合はすべての刺激からcontainsを実行する必要がある。今回のサンプルでは正答しか記録していないが、そういう将来的な拡張を考慮してだなあ…

B: …怪しいなあ。まあ、そういうことにしておいてあげましょう。で、解説はもうおしまいですか?

A: いや、あと130行目が残っている。128行目のbreakでwhile文を抜けたらrtという変数にターゲットをクリックした時点での反応時間が残っている。刺激の色と方向と反応時間をセットでファイルに書きだしているのが130行目。このあたりは 例題3-4例題4-1 を参考にしてほしい。

B: rt[0]ってなってますけど、rtの中身はリストか何かですか?

A: 3つのボタンそれぞれ別々に反応時間が出力される 。実はclickReset()でボタンごとにタイマーをリセットできるようになっているんだが、さすがにそこまでは今回は触れないのでhelpを見てほしい。左ボタンに対応しているのは1番目の要素なのでrt[0]ということになる。

B: ふむふむ。

A: で、131行目で念のためfp.flush()で直ちにファイルに書きだしておく。これで解説は終了! あーっ、疲れた!

B: お疲れでーす。あ、もうこんな時間! 今日はこれからバイトなので失礼しまーっす!

A: おう、お疲れー。

------------------------------ 後日 ------------------------------

B: あのー、AさんAさん。

A: ん? 何?

B: 先日のマウスで視覚探索のプログラムですが…。

A: おう、あれがどうした?

B: 刺激が小さくてクリックするのが難しいんですよ。きちっと刺激にカーソルを重ねないといけないんで、それでターゲットは見つかってるのに反応できなくて。

A: ふうむ。確かに今回のサンプルはターゲットが細長くてクリックしにくいだろうな。そういう場合はターゲットの近傍をクリックしても反応とみなすようにしてやればいいよ。

B: さらっと言いますけど、それだとcontains()は使えないんですよね? どうすれば…

A: ちょうどその用途にピッタリなoverlaps()というメソッドがあるよ。簡単に書き直せるからちょっと待ってよ。

B: …。

A: …できたっ。そら。

B: えっ、もう?

A: 仕組みが分かりやすいようにちょっと工夫しておくか。ごにょごにょ… よし。これでどうだ。大部分が19-1.pyと重複しているので変更点付近だけ抜粋するぞ。

  • 行番号なしのソースファイルをダウンロード→ 19-2.py

A: まずは32行目から39行目の最初のダイアログを出す部分。ここは全然本質的じゃないんだが、今回の変更のカラクリがわかりやすいように38行目にshow hidden cursorという項目を追加している。

32params={'data file':'data.csv',
33        'full screen':False,
34        'screen size':'',
35        'num of stimuli':'8',
36        'show mouse':False,
37        'trials':'20',
38        'show hidden cursor':False}
39dlg=psychopy.gui.DlgFromDict(dictionary=params,title='set parameters')

B: hidden cursor?

A: 今回の作戦は、自前ででっかいカーソルを作ってやって、その自前カーソルと刺激が重なっているかどうかで判定するというものだ。自前カーソルは画面に描画されたら不自然なので隠してあるんだが、スクリプトの動作を確認するには表示されている方がわかりやすい。それで起動時のオプションで選べるようにしておいた。

B: なるほど。

A: で、以下の58行目から60行目。刺激を定義している部分だが、60行目で「隠れた自前カーソル」となるオブジェクトを生成している。今回はpsychopy.visual.Circleという円を描くクラスを使用した。

58sample = psychopy.visual.Rect(win, width=stimWidth, height=stimHeight, lineColor=None)
59stimuli = [psychopy.visual.Rect(win, width=stimWidth, height=stimHeight, lineColor=None) for i in range(nStim)]
60hiddenCursor = psychopy.visual.Circle(win, radius=stimSpacing/3.0)

B: ええと、この引数のradiusというのは半径でいいんですよね?

A: Yes。刺激の中心間の最短距離がstimSpacing、ということはstimSpacing/2.0で左右や上下に並んだ刺激の中心の間にぴったりはまる円になる。刺激は幅や高さがあるのでそれではちょっとまずいので、適当にstimSpacing/3.0くらいにして二つの刺激に同時に重ならないようにしている。このradiusを小さくすればクリックの判定が厳しくなり、大きくすれば甘くなる。OK?

B: はい、わかります。

A: よし。で、こいつを使ってどうやってマウスカーソルが刺激近傍にあるかを判定するキモが以下の99行目から104行目。

 99        #wait for click on the sample
100        while True:
101            if mouse.getPressed()[0]:
102                hiddenCursor.setPos(mouse.getPos())
103                if sample.overlaps(hiddenCursor):
104                    break

A: 19-1.pyからの変更部分は102行目と103行目。まず、102行目では先程は出てこなかったpsychopy.event.Mouse.getPos()で位置を取得して、psychopy.visual.Circle.setPos()で自前カーソルを本来のカーソルの位置へ移動。続く103行目。 psychopy.visual.Rect.overlaps()を使うと、呼び出し元の刺激と引数に与えた刺激に重なり合ってる部分があるかどうか判定出来る 。重なっていればTrue、重なっていなければFalseが返ってくる。

B: おお、これまた便利な。

A: 書き換えはこれでおしまい。簡単だろ?

B: はい。確かに。

A: ターゲット近傍にマウスカーソルがある状態でクリックされたかどうかを判定する部分も同様に書き換えすればいい。以下の部分の122行目と124行目がそうだね。102行目、104行目と同じことなので解説は不要だろう。

117        #wait for click on the sample
118        mouse.clickReset()
119        while True:
120            (buttons, rt) = mouse.getPressed(getTime=True)
121            if buttons[0]:
122                hiddenCursor.setPos(mouse.getPos())
123                for i in range(nStim):
124                    if stimuli[i].overlaps(hiddenCursor):
125                        break
126                else: #no stimuli contained mouse
127                    continue
128                
129                if i==0: #target
130                    sound.play()
131                    psychopy.core.wait(beepDuration)
132                    break

B: でもfp.write()の前に何かコードが追加されていますね。これは?

134            #for debugging
135            if params['show hidden cursor']:
136                hiddenCursor.setPos(mouse.getPos())
137                for i in range(nStim):
138                    stimuli[i].setPos(posList[i])
139                    stimuli[i].draw()
140                hiddenCursor.draw()
141                win.flip()
142            #for debugging

A: さっきのダイアログ書き換えの時に出てきた話。自前カーソルは見えちゃまずいんで画面に描画しないんだが、スクリプトの動作を理解するには見えている方がいいだろう? 最初のダイアログでshow hidden cursorをチェックしていたら、ここで刺激を再描画して、最後にhiddenCursor.draw()して自前カーソルを描いている。140行目だね。自前カーソルを隠しておくのならこれは不要な処理だ。122行目と136行目の両方でhiddenCursor.setPos(mouse.getPos())しているのはあまり美しくなんだが、19-1.pyに追加という形で書いたので勘弁してほしい。

B: ええと、自前カーソルを描くのになんで刺激を全部描きなおすんですか?

A: 自前カーソルだけ選択的に消して描きなおすなんて出来るわけないだろ。刺激は動かないけど刺激から描きなおさないと。

B: あ、そうか。

A: じゃ、show hidden cursorをチェックして実行してみぃ。

B: はーい。…おお、こうなっているのか。なるほどー。

../_images/19-1-04.png

A: 現在のところ(version 1.74.03)、containsやoverlapsはpsychopy.visual.ShapeStimを継承しているクラスでしか使用できない 。私が把握している限り以下の3つだけだ。

  • psychopy.visual.ShapeStim

  • psychopy.visual.Rect

  • psychopy.visual.Circle

B: あら、便利なのに残念ですね。

A: まあ今回のサンプルが示すように containsやoverlapは描画しなくても有効なので、写真などを描画した上に重ねて「隠れた」RectやCircleを配置すればcontainsやoverlapsが使える 。開発者向けMLを見たところ現在psychopy.visual.TextStimを除くすべての視覚刺激でcontains等が使えるように拡張している様子なので、近いうちに他の視覚刺激で使えるようになるだろう。

B: 開発が活発ってのはこうこうことなんですね。素敵だ。

A: というわけで例題19-1はこれにておしまい。次回は全く未定だけどPsychoPyらしさというところでpsychopy.visual.RatingScaleでも扱いところだね。というわけで、また。

注釈

  • contains、overlapsにはバグがあって、オブジェクトを回転させると画面上ではきちんとオブジェクトが回転しているにもかかわらず、containsやoverlapの「当たり判定」は回転しません。今回のサンプルでは刺激が小さく回転量も小さいので目立った問題が生じませんでしたが、刺激を大きく回転させる場合は注意が必要です。このバグは1.76.00で解消される予定です。