例題12-3:ビジランスの実験(試作)

A: さて、小ネタ在庫一掃セールの第二弾はビジランスの実験プログラムだ。 例によって一番最後にサンプルプログラムを掲載して、ポイントを解説していくぞ。

B: …いきなり突っ込みたいところが満載ですが、まずなんで例題12-3なのにサンプルプログラムは12-2.pyなんでしょうか?

A: …わかってて聞いてるだろ。もともと例題12-2にするつもりだったんだが、グラフの描き方で1回増やしてしまったからずれたんだよ。 突っ込みどころ満載って、ほかにも何かあるのか?

B: ビジランスってなんですか? あと、(試作)って?

A: まずビジランスってのは…ビジランスだよ。心理学辞典にもビジランス(vigilance)という見だしで出とる。 これ以上は自分で確認しなさい。

B: 課題がなんだかわからなかったらプログラムを読んでもわからないと思うんですが。

A: ふふふ。そう来たか。実は今回の小ネタで、スクリプトを実行したら被験者への教示が画面に表示されるようになっているのだ。 課題についてはそれを見たまえ。

B: ぶーぶー。

A: あとは、なんだ。(試作)か。これ、学生さんが持ってきた論文にビジランス課題が出てきて、 自分で実験やってみる?って話になってせこせこと書いたものなんだよな。でも結局実験はしないことになって、一応実験の体裁は整っているけど きちんと刺激の出現間隔とか調整していないままお蔵入りになってたんだ。だからまあ(試作)とつけとくのが無難かな、ということで。

B: はあ、そんなプログラムを持ってきて、何を解説しようと思ったんですか?

A: うむ。まずは今言った通り画面に教示を表示するってこと、後はデータファイルの出力に関することとか、ジョイスティックに関することとか。 まあ順番に行きますか。

ダイアログにビットマップを表示する

A: さて、だらだらしてるとまた長くなってしまうんで、しゃきっといこう。このサンプルプログラムは、ジョイパッドを使って課題を行うように作られている。 で、その操作方法を説明する画像ファイル(vs_inst.png)を最初のダイアログに表示するようになっている。 ダイアログというと例題5-3、5-4で紹介したTkinterの出番だ。Tkinterで画像ファイルを表示するには、ImageTkというモジュールをimportして、ImageTk.PhotoImageを使う。ちなみに実行画面はこんな感じ。

../_images/12-3-01.png

B: ふむふむ。

A: サンプルプログラム20行目がImageTk.PhotoImageのインスタンスを生成しているところだ。まずImage.open()で画像ファイルを開き、それをImageTk.PhotoImage()の引数として突っ込む。 戻り値は普通にTkinter.Labelに表示できる。21行目ね。以上。おしまい。

B: へ? もうおしまい?

A: スクリプト上で画像を拡大縮小したりとかいろいろしようとするならともかく、この例のように単に説明用の画像を表示するだけならこれで十分でしょ。 画像を作成する時点で「ただ表示するだけでOK!」って段階まできちんと作っておくのが一番だと思うよ。はい、次。

B:

データファイルに実験日時や被験者名を記録する

A: 次はデータファイルに実験日時やら被験者IDやらを保存しておく方法。うっかりミスでデータを失ったり、内容がわからなくなってしまったりしないようにするためには重要だね。

B: ぼくはそんなおっちょこちょいじゃありませんよ。

A: そういう根拠のない自信が危ない。人間は間違うものだから、備えておくに越したことはないな。 というわけで、このサンプルではpythonスクリプト名+実験開始日時をファイル名に設定してデータを保存し、さらにファイルの先頭にも実験日時や被験者名を保存するようにしている。 まずスクリプト名を得る部分が40行目。sys.argv[0]にスクリプト名が格納されているのは例題12-1で説明したとおりだが、最後の拡張子”.py”は邪魔なので取り除きたい。そこでos.path.splitext()という関数を使用している。 これは引数に与えられたファイル名を拡張子とその前の部分に分割して返す関数だ。40行目ではbase, extという二つの変数で戻り値を受けているが、baseには拡張子の前の部分、extには拡張子が格納される。 今回の目的では拡張子はいらないので、baseをデータファイル名に使用する。

B: 拡張子の前後で分割って、”.”を見つけて分ければいいだけですよね。いちいち関数を使わなくても簡単に出来そうな気が…。

A: ふむ、これが簡単に出来そう、と言えるのは心強いな。でも、os.path.splitext()という名前の関数を使ってあれば、ずっと後になってプログラムを読み返す時や、 他人がプログラムを読むときに、「ああ、ここでファイル名を拡張子とその前の部分に分割しているんだな」というのがすぐわかる。自前の処理だと「これは何をしてるのかな?」と考える必要がある。 読みさすさまで考慮すると、こういう関数はなかなかありがたいものだよ。

B: あまり自分で書いたプログラムを読み返すなんて経験はないんですが、そんなもんですか。

A: そんなもんさ。続いて実行日時を得る方法だが、それにはdatetimeモジュールを使う。 import datetimeして、datetime.datetime.now()を呼ぶと、現在の日時が格納されたdatetime.datetime型のインスタンスが得られる。

B: datetime.datetimeってなんだかうっとおしいですね。繰り返さなくてもいいじゃんという気が。

A: そうだな。まあここではクラス名をはっきりとさせるために省略せずに書いているが、from datetime import datetimeとかいう具合にimportすれば単にdatetime.now()と書ける。 まあその辺は好きにしてもらうとして、datetime.datetimeのおいしいところは、これを文字列に変換するstrftime()というメソッドが備わっていることだ。 pythonのドキュメントを読むと実行しているプラットフォーム上のC言語のstrftime()に依存すると書いてあるんだが、まあだいたい以下の書式は使用できると思う。

%Y 西暦(4桁) %y 西暦(2桁)
%m 月(10進数) %B 月名
%b 月名(省略形) %d 月内通算日
%A 曜日名 %a 曜日名(省略形)
%H 時(24時間表記) %I 時(12時間表記)
%p 午前・午後の表記 %M
%S    

B: ええと、月は「9月」とかですよね、月名?

A: SeptemberとかSepとかだな。スクリプトを実行している環境のロケール設定に依存するということになっている。 ロケールってのは、Linuxな人なら多分よく知ってると思う。Windowsなら…「コントロールパネル」の「地域と言語」の設定にあたるかな。

B: ふうん。日本語Windowsなのに長月とかにならないんですね。

A: エクスプローラのファイルの更新日時が長月とか神無月とか表示されたら鬱陶しいと思うが…。まあいい。 datetime.datetime.strftime()に与えるフォーマット文字列のうち、上の書式に合致しない文字はそのまま変換されずに出力される。 だから、:とか/とかの文字を間に挟んでおけば、読みやすい出力が得られる。残念ながらUnicode型の文字列を渡せないみたいなんで、u’%m月%d日’とかいった文字列は渡せない。

B: ここでも英語圏優遇が。

A: まあ、pythonの文字列処理は強力なんで、どうしても日本語にしたかったらdatetime.datetime.strftime()の出力を加工すればいいと思うが。 サンプルプログラムに戻って、42行目ではスクリプト名と実行日時を組み合わせたファイル名を生成して、データ保存用ファイルをopenしている。 こうしておけば、少なくともきちんとPCの時刻を合わせていれば、間違えてデータ保存用ファイルを上書きしてしまうことはない。 44行目では、データ保存用ファイルの冒頭にまた現在日時を出力しているが、ここではファイル名に使うことができない/や:を使って読みやすい文字列を得ている。

B: なるほど。

A: あともう一つ触れておきたいのは43行目。 ここでは変数sbjnameに保存された被験者名(被験者ID)をファイルに出力しようとしているが、何も考えずに出力しようとすると以下のようなエラーが出ることがある。

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-2:
ordinal not in range(128)

B: 何ですかこれ? UnicodeEncodeErrorってんだからUnicodeと関係あるのかな。

A: うむ。sbjnameに日本語の文字を含んでいたりすると、出力するときにこういうエラーが出ることがあるんだ。 pythonは文字コードを特に指定しなければASCIIコードで出力しようとする癖があって、日本語の文字列をうまく出力できない。英語なら何も問題はないのだが。

B: むむっ、またまた英語優遇が。ずるい!

A: 落ち着け。2008年にリリースされたpython 3.x系ではこの問題は解決されているんだが、まだまだpython 2.x系も現役で、このコーナーで使っている多くのモジュールはpython 2.x系でなければ動かない。 B: え、もう次のバージョンがpythonが出てるんですか?!

A: ああ。だけど、python 2.x系が使えなくなるのはずっと将来のことになると思われるので、ここではpython 2.x系で解説している。 とにかく、python 2.x系でUnicode文字列を出力するには、文字コードを指定する必要がある。そこで出てくるのがencode()というメソッドだ。 43行目では、sbjname.encode(‘shift-jis’)として、Shift-JISコードで文字列を出力している。このようにしてやれば、pythonは正しく文字列をファイルに出力できる。 Linuxな人でEUC-JPの方が良いとかいう場合は’euc-jp’とすればいい。

B: うーん、面倒くさいなあ。

A: python 2.xでの文字コードの扱いは厄介なんだよ。正直なところ私もあまり自信はないんだが、単に被験者名やら条件名やらをテキストファイルに出力したいだけならこの程度の知識で十分だ。

B: はあ。

A: そうそう、最後にひとつだけ補足しておくと、 ファイルを開く時点で文字コードを指定する方法もある。codecsというモジュールをimportし、open()の代わりにcodecs.open()を使えばいい。 codecs.open()では、3番目の引数に文字コードを指定する。後は普通のopenでファイルを開いた時と同じようにwrite()してclose()してやればよい。

#エラーになる
>>> fp = open('test.txt','w')
>>> fp.write(u'日本語')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-2: ordin
al not in range(128)

#codecsを使うとエラーにならない
>>> import codecs
>>> fp = codecs.open('test.txt','w','shift-jis') #文字コードを指定
>>> fp.write(u'日本語')
>>> fp.close()

B: いちいちencode()とかするよりこっちの方が簡単そうですね。

A: まあ、好みの方法を使えばいい。というわけで、そろそろ次の話題に行くかな。

ジョイスティックからの入力

A: さて、最後はジョイスティックからの入力だ。 私が知る限り、特にVisionEggにはジョイスティックを扱うメソッドはないので、VisionEggが依存しているpygameの機能を使う。 pygameにはpygame.joystickというそのものズバリのクラスがあって、こいつを使えば簡単にジョイスティックを制御できる。 103行目からがその処理なのだが…って、よく見るとすでにimport済みのsysとかpygame.localsとかをimportしているな。 コピペのときに削るのを忘れてた。

B: またちゃんとチェックせずに例題に使っちゃったりして…

A: ほっとけ。とにかく手順としてはこういう感じになる。

  1. pygame.joystickをimport (103行目)
  2. pygame.joystick.init()でジョイスティックを初期化 (107行目)
  3. pygame.joystick.get_count()で接続されているジョイスティック数を確認 (108行目)
  4. pygame.joystick.Joystick(n)でn番目のジョイスティックを制御するためのインスタンスを得る (112行目)
  5. init()でn番目のジョイスティック初期化 (113行目)
  6. pygame.event.get()でイベントを確認し、pygame.locals.JOYAXISMOTION、pygame.locals.JOYBUTTONDOWN等のイベントが発生したらget_axis()、get_button()を用いてジョイスティックの状態を読み出し、適切な処理を行う (196-226行目)

B: n番目のジョイスティックって…、 ああそうか、対戦型のゲームとかだと1台のPCに何個もジョイスティックが刺さってる場合がありますもんね。なるほどなるほど。

A: そう。このサンプルプログラムでは0番目のジョイスティックのみを使うことを想定しているが、 複数の被験者が同時に参加する実験をすることがあれば、必要な数だけジョイスティックを初期化する必要がある。

B: 複数人で参加する実験ってどんなのだろ。

A: いろいろ考えてみると面白そうだな。ジョイスティックの状態の読み取りについて補足しておくと、 ジョイスティックのレバーがアナログ式であろうと、ON/OFFの2段階しかなかろうと、get_axis()の戻り値は-1.0から1.0の実数となる。 なので、197行目から208行目のように、適当な閾値(±0.8とか)を決めて、それを超えているかどうかでレバーを倒しているか否かを判断する必要がある。

B: あの、ひとつ聞きたいんですが。

A: ん? 何?

B: サンプルプログラムを実行したら最初に出てくる説明画面、ジョイスティックじゃなくてゲームパッドだと思うんですけど、pygameでは同じ扱いなんですか?

A: あー。正直なところ、今まで試した範囲ではジョイスティックだろうがジョイパッドだろうがこの方法で制御できるんで、きちんと調べたことはないな。 専用のデバイスドライバが必要なモデルでもない限り、レバーやボタンの形に関係なく全部この方法で制御できると思う。

B: いや、そんな難しい話を聞きたかったんじゃなくて、単に画面に出てくるアレをジョイスティックって言われると気持ち悪いなあってだけなんですが…

A: さて、例題12-1から一気に説明したんでそろそろ疲れてきたな。まだマウスの読み取りとか説明してない小ネタがあるんだけど、例題12はこれでおひらきということにしようかな。 例題12-1も12-3も実験本体にかかわる解説はしていないのでちょっと難しいかもしれないが、練習問題だと思って動作を理解してみてほしい。 ちなみにコメントアウト234行目などのコメントを解除すると、ボタンやレバーを押したときに音が鳴るようになっている。キーボードとジョイスティックの違いはあるけれども一応例題2-2の練習問題の解答例になっているかな。 そんなわけで、次回は…。なにやろうかなあ。予告しても全然その通りに進まないし、ぼちぼち考えます。ではでは。

B: お疲れさまー。

  • 行番号なしのソースファイルをダウンロード→ 12-2.py
  • 実行に必要な画像ファイル→ inst_vg.png
  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
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/usr/bin/env python
# -*- coding: shift-jis -*-

########################################
# 被験者名を得る
import Tkinter
import tkFont
import ImageTk
import Image


import os
import sys

def setparam():
    class ParamWindow(Tkinter.Frame):
        def __init__(self,master=None):
            Tkinter.Frame.__init__(self,master)
            r = 0
            self.instimage = ImageTk.PhotoImage(Image.open('inst_vg.png'))
            Tkinter.Label(self,image=self.instimage).grid(row=r,columnspan=3)
            r += 1
            Tkinter.Label(self,text=u"名前を入力してOKをクリックしてください:",font=tkFont.Font(size=14)).grid(row=r,column=0)
            self.SbjNameEntry = Tkinter.StringVar()
            Tkinter.Entry(self,textvariable=self.SbjNameEntry,font=tkFont.Font(size=14)).grid(row=r,column=1)
            okButton = Tkinter.Button(self,text="OK",font=tkFont.Font(size=14),command=self.quitfunc)
            okButton.bind("<Return>",self.quitfunc)
            okButton.grid(row=r,column=2)
        def quitfunc(self):
            self.sbjname = self.SbjNameEntry.get()
            self.winfo_toplevel().destroy()
            self.quit()
            
    w = ParamWindow()
    w.pack()
    w.mainloop()
    return (w.sbjname)

sbjname = setparam()
base, ext= os.path.splitext(os.path.basename(sys.argv[0]))

fDataFile = open(base+'_'+datetime.datetime.now().strftime("%m%d%H%M")+'.txt','w')
fDataFile.write('# 被験者:%s\n'%sbjname.encode('shift-jis'))
fDataFile.write('# %s\n'%datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))


############################
#  VisionEggの初期化
############################

import VisionEgg

from VisionEgg.Core import *
from VisionEgg.FlowControl import Presentation
from VisionEgg.Text import *
from VisionEgg.MoreStimuli import *
from VisionEgg.WrappedText import *
from pygame.locals import *


from random import *

VisionEgg.start_default_logging(); VisionEgg.watch_exceptions()
VisionEgg.config.VISIONEGG_GUI_INIT = 0
VisionEgg.config.VISIONEGG_FULLSCREEN = 0
VisionEgg.config.VISIONEGG_SCREEN_W = 1024
VisionEgg.config.VISIONEGG_SCREEN_H = 768


screen = get_default_screen()
SX = screen.size[0]
SY = screen.size[1]

stimPos = [(SX/2-200,SY/2-100),(SX/2-300,SY/2),(SX/2-100,SY/2),(SX/2-200,SY/2+100),
           (SX/2+200,SY/2-100),(SX/2+100,SY/2),(SX/2+300,SY/2),(SX/2+200,SY/2+100)]
stimName = [u'下',u'左',u'右',u'上',u'1',u'2',u'3',u'4']

stimlist = []
labellist = []
for i in range(len(stimPos)):
    stimlist.append(Target2D(size =(50,50),position=stimPos[i]))
for i in range(len(stimName)):
    labellist.append(Text(text=stimName[i],position=stimPos[i],color=(0,0,0),
                     font_name=r'C:\Windows\Fonts\MSGOTHIC.TTC',
                     anchor='center'))

viewport = Viewport(screen=screen,stimuli=stimlist+labellist)

conditionlist = []
for p in range(8): #positionが8種類
    for i in range(6): #1つのpositionにつき7回繰り返す
        conditionlist.append([p,0])

shuffle(conditionlist)
for i in range(8): #warningは8回出現
    conditionlist[i][1]=1
shuffle(conditionlist)

############################
#  joystickの準備
############################

from pygame.joystick import *
from pygame.locals import *
import sys

pygame.joystick.init()
if pygame.joystick.get_count()<1:
    print "No Joystick. Abort."
    sys.exit(-1)
    
js = pygame.joystick.Joystick(0)
js.init()

############################
#  pygame.mixerの準備
############################

pygame.mixer.init()
correctSound = pygame.mixer.Sound('correct.wav')
errorSound = pygame.mixer.Sound('error.wav')


######################################
#  メッセージ

messagelist = []
messagelist.append(VisionEgg.Text.Text(text=u"",
    anchor='center',position=(SX/2,SY/2),font_name=r'C:\Windows\Fonts\MSGOTHIC.TTC',font_size=32))
messagelist.append(VisionEgg.Text.Text(text=u"",
    anchor='center',position=(SX/2,SY/2-32*3),font_name=r'C:\Windows\Fonts\MSGOTHIC.TTC',font_size=32))

messageview = Viewport( screen=screen, stimuli=messagelist )

def drawmessage(screen=screen, messageview=messageview, messagelist=messagelist, idx=[0]):
    for i in range(len(messagelist)):
        messagelist[i].parameters.on = False
    for i in idx:
        messagelist[i].parameters.on = True
    screen.clear()
    messageview.draw()
    VisionEgg.Core.swap_buffers()

######################################
#  タイムアウト付きジョイスティックボタン待ち関数

def waitJoyButtonLoopWithTimeout(timeout=1.0):
    wf = True
    flgEscape = False
    t = VisionEgg.time_func()
    while wf:
        if VisionEgg.time_func()-t > timeout:
            wf = False
        for event in pygame.event.get():
            if event.type==JOYBUTTONDOWN:
                flgEscape = True
                wf = False
    return flgEscape


############################
#  実験本体
############################


results = []
ITIbase = 2.0
StimDur = 1.0

messagelist[0].parameters.text = u"準備が出来たらボタンを押してください。"
drawmessage(idx=[0])
waitJoyButtonLoopWithTimeout(3600)

for tn in range(len(conditionlist)):
    trialStartTime = VisionEgg.time_func()
    t = 0
    ITI = ITIbase + (randint(0,3) * 0.5)
    flgButton = False
    while t <= ITI+StimDur:
        t = VisionEgg.time_func()-trialStartTime
        if t <ITI:
            for p in range(len(stimPos)):
                stimlist[p].parameters.color = (1.0,1.0,1.0)
        else:
            for p in range(len(stimPos)):
                if conditionlist[tn][0]==p:
                    if conditionlist[tn][1]==1:
                        stimlist[p].parameters.color = (1.0,1.0,0.0)
                    else:
                        stimlist[p].parameters.color = (1.0,0.0,0.0)                        
                else:
                    stimlist[p].parameters.color = (1.0,1.0,1.0)
        
        for e in pygame.event.get():
            if (not flgButton) and t >= ITI:
                if e.type == JOYAXISMOTION or e.type == JOYBUTTONDOWN:
                    if js.get_axis(0) < -0.8: #十字キーの左
                        response = 1
                        flgButton = True
                    elif js.get_axis(0) > 0.8: #十字キーの右
                        response = 2
                        flgButton = True
                    elif js.get_axis(1) < -0.8: #十字キーの上
                        response = 3
                        flgButton = True
                    elif js.get_axis(1) > 0.8: #十字キーの下
                        response = 0
                        flgButton = True
                    elif js.get_button(0):
                        response = 4
                        flgButton = True
                    elif js.get_button(1):
                        response = 5
                        flgButton = True
                    elif js.get_button(2):
                        response = 6
                        flgButton = True
                    elif js.get_button(3):
                        response = 7
                        flgButton = True
                    else:
                        for bn in [4,5,6,7]:
                            if js.get_button(bn):
                                response = 8
                                flgButton = True
                                break
                    if flgButton:
                        if conditionlist[tn][1] == 1:
                            if response == 8:
                                correctAnswer = 1
                                #correctSound.play()
                            else:
                                correctAnswer = 0
                                #errorSound.play()
                        else:
                            if response == conditionlist[tn][0]:
                                correctAnswer = 1
                                #correctSound.play()
                            else:
                                correctAnswer = 0
                                #errorSound.play()
                        #while pygame.mixer.get_busy():
                        #    pass
                        rt = t-ITI
                        results.append([tn+1,conditionlist[tn][0],conditionlist[tn][1],response,correctAnswer,rt])
        
        screen.clear()
        viewport.draw()
        swap_buffers()
        
    if not flgButton: #ボタンが押されていなければ反応時間(ITI+StimDur)で記録
        results.append([tn,conditionlist[tn][0],conditionlist[tn][1],-1,0,ITI+StimDur])

correctTrials = 0
rtsum = 0

for r in results:
    correctTrials += r[4] #正答率の計算のため
    rtsum += r[5] #平均反応時間の計算のため
    fDataFile.write('%d,%d,%d,%d,%d,%f\n'%tuple(r))
fDataFile.close()

correctRatio = 100*correctTrials/len(results)
meanRT = (1000*rtsum)/len(results)
messagelist[0].parameters.text=u"平均反応時間 " + str(int(meanRT)) + u"ミリ秒/正答率 " + str(int(correctRatio)) + u"%"
messagelist[1].parameters.text=u"ご協力ありがとうございました。"

drawmessage(idx=[0,1])
waitJoyButtonLoopWithTimeout(5)