例題12-1:Müller-Lyer錯視の実験¶
A: 今回は久々に実験プログラムを題材に取り上げます。例題5-5以来ですね。こういうのこそまさに「例題」という名称にふさわしいと思います。
B: ちょ、Aさん、なんですかいきなり。
A: B君のそのツッコミも例題11-2からほとんど変化がありませんね。作者が投げやりになっている様子が目に浮かぶようです。 さて、ではさっそく解説に入りましょう。今回の題材は…
B: ははぁ、また次回予告と違うこと始めたのが後ろめたいんですね?
A: っとっと、ごふごふ。い、いったい何の話かね。それは。
B: はいはい。グラフの描画は自分で勉強しときますんで、気の済むようにしてください。
A: むむっ、グラフの描画は今回の例題でも取り上げるぞ。確かに予告していた通りの内容ではないんだが…
B: まあまあ。実際の実験プログラムの例を出すことも大事でしょうしね。
A: B君にそんなフォローされるようではおしまいだな。まあ、実際問題B君の言う通りなんだが。 今回の題材は錯視といえば必ず出てくる:Müller-Lyer錯視、大学心理学を学んだ人のほとんどは初級実験でお目にかかったことだろう。
B: ぼくが受けた時はやりませんでしたが…
A: だから「ほとんど」って言ってるだろ。たった一件の反例で否定するな。
B: へえ、じゃあAさんは何例くらい知ってるんですか?
A: ええと、私の出身校と、初めて非常勤をした大学と、…、4校かな。
B: Aさんこそ「ほとんど」っていうには無理があるような気がしますが。
A: うるさい。とにかく始めるぞ。ええと、読者の皆様、サンプルプログラムは長くなるので最後にまとめて掲載して、解説しておきたいポイントをこれから挙げていきます。 私自身がpythonを勉強し始めたころに書いたものなので、今ならもっとうまい書き方があるよなあと思う点もありますが、敢えてそのまま残してあります。
B: 書きなおすのが面倒くさいだけじゃないのかねぇ。
コマンドライン引数¶
B: ええと、まず最初のこれは何ですかね。
A: これはWindowsのコマンドシェルなどのCUI(Character User Interface)が好きな人向けだね。 こんな風にコマンドを打ち込んでpythonスクリプトを起動したときに、スクリプト名の後ろにつけた引数をスクリプトから参照する方法だ。
D:\work>python experiment.py SubjectName color 120
A: この例ではexperiment.pyがpythonスクリプトのファイル名で、後ろに続く"SubjectName"、"color"、"120"の3つが引数だ。
B: 引数というのは関数のところでも出てきましたね。同じようなものだと思えばいいんですかね。
A: まあ、そうかな。ただ、pythonの関数と違って渡された引数をスクリプト内で何という名前の変数で受け取ればいいのかこれではわからないよね。 pythonでコマンドライン引数を受け取るには、sysモジュールimportして、sys.argvというリストを参照すればいい。上の例では、以下のような値が格納されている。
sys.argv[0] |
experiment.py |
sys.argv[1] |
SubjectName |
sys.argv[2] |
color |
sys.argv[3] |
120 |
B: ふむふむ。sys.argv[0]にはスクリプト名そのものが入ってるんですね。
A: そう。コマンドライン引数がひとつもない(0個)の場合でもスクリプト名は必ず存在するので、 引数が0個の時のsys.argvの長さは1になる という点に注意してほしい。同様に、 n個の引数がある時はsys.argvの長さはn+1個 だ。 引数の個数で処理を振り分けるときにうっかり間違えやすい。まあ、間違えてたらプログラムが正常に動かないのですぐ気付くとは思うが。 B: なるほど。メモメモ。
A: この例で注意してほしいのは、最後の"120"だ。 sys.argvでは引数が文字列として渡される 。 つまり、「1」と「2」と「0」という三文字の文字列として渡されているんだね。数値として処理するためにはint(sys.argv[3])などとする必要がある。
B: 面倒くさいですねえ。
A: プログラマが文字列と数値のどっちを意図してんのなんかなんてpythonインタプリタにわかるわけないだろ。 コマンドラインは文字列なんだから、余計なことはせずにそのまま渡してくれる方がいい。
B: はあ、そんなもんですかね。
A: サンプルプログラムでは、53から64行目でコマンドライン引数がなければ被験者名などの入力するダイアログを表示し、あれば第1引数の名前でデータ出力ファイルを開くという処理をしているので見てみてほしい。
プラットフォームの判別¶
B: 続いてプラットフォームの判別ということですが、これは?
A: この場合のプラットフォームってのはWindowsとかLinuxとか、pythonスクリプトを実行している環境のことだ。 例えば日本語を表示するときのフォントファイルなど、OSによってスクリプトの実行に必要なパラメータが異なる場合がある。 どちらのプラットフォームで実行できるスクリプトを用意しなければいけない時に便利な機能だ。
B: うーん、便利と言われると便利そうな気もしますが、そもそもWindowsとLinuxで同じプログラムを実行しなきゃいけないなんてことあるんですかね?
A: 複数の研究拠点で共同研究する場合とか、サンプルプログラムを配布する場合とかなんかがそうだな。
B: じゃ、このコーナーのサンプルプログラムなんてまさにぴったりじゃないですか。なんで今までのサンプルではそのプラットフォームの判別?とやらをしてなかったんですか?
A: そんなの、面倒くさいからに決まっておろう。
B: あー、開き直りましたね。
A: おうよ。そこまで気を使ってたら面倒くさくってここまで続かなかっただろうよ。とにかく、プラットフォームを判定するにはsysモジュールをimportしてsys.platformを参照する。
B: またsysモジュールですか。
A: サンプルプログラムの69から76行目で、プラットフォームがWin32か否かでフォントファイル名を切り替える例を示している。 Win32じゃなければUbuntuと決め打ちしているので、Macとか使ってる人はうまくやっちゃってください。
B: 相変わらずAさんはMacに厳しいなあ。なんか恨みでもあるんですか?
A: だってMac持ってないんだもの。このコーナーのためだけにMac買うほど裕福じゃないし。さて、次行くぞ、次。
刺激の位置と回転角度の指定¶
B: ええと、これは今までの例題で出てきませんでしたっけ。
A: うーむ。例題1でちらっと触れて、その後ろくな解説なしに例題7や8で使ったりしていたんだが、ちゃんと解説したことがなかったなと思って。 特にanchorとorientationを両方指定したい場合にちょっと混乱することがあるので、いつかちゃんと触れておかなきゃなと思っていた。
B: anchorは刺激の位置を指定するときにどこを基準にするか、でしたね。orientationはどれだけ回転するか。
A: そう。まず、anchorに指定できる「位置」にはどのような種類があるかって点なんだが、これ、VisionEggのhelpに書いてあると思ってたんで詳しく解説していなかったんだけど、改めて確認したら書いてないのよね。 じゃあ私はどこで見たんだったけな?と思ってあれこれ調べたらVisionEggのメーリングリストだった。 これは大事な情報なのにhelpに書かれていないってのはちょっとまずいので、ここにちゃんと載せておこうと思って。こんな感じだ。
anchorに指定できる位置 左上 |
'upperleft' |
上 |
'top' |
右上 |
'upperright' |
左 |
'left' |
中央 |
'center' |
右 |
'right' |
左下 |
'lowerleft' |
下 |
'bottom' |
右下 |
'lowerright' |
B: はあ、覚えてさえいれば、特に難しいところはなさそうですね。
A: ところがだな、これがorientationとかangleと組み合わされるとちょっと厄介なんだ。この例を見てくれ。 VisionEgg.Text.Text(左の"A")とVisionEgg.MoreStimuli.Target2D(右の正方形)を表示したところなのだが、それぞれanchorはcenter、淡い黄色はangle=0、黄色はangle=45、淡い青色はorientation=0、青色はangle=45が指定されている。
anchor='center'の場合
B: へ、angleとorientationって何が違うんでしたっけ?
A: ややこしいんだが、angleはVisionEgg.Text.Textで回転角度を指定する引数、orientationはVisionEgg.MoreStimuli.Target2Dで回転角度を指定する引数だ。
B: 指定する引数の名前が違うなんて全然気づいてなかった。
A: とにかく、黒い線の交点がpositionに指定されている位置で、いずれの刺激も黒い線の交点に中心が一致していて(anchor='center')、濃い色の刺激は淡い刺激より45度回転している。それはいいかな?
B: はい、そりゃそういう風に指定したんですから当たり前ですよね。
A: うむ。では続いてanchor='lowerright'にするとどうなるか見てみよう。
anchor='lowerright'の場合
B: ん? なんだか変だな。でも何が変なんだろう?
A: まず、anchorを右下に指定したんだから、刺激の右下が黒線の交点と一致するように刺激が配置される。そこまではOK?
B: はい。
A: 問題はここからだが、VisionEgg.Text.Textでは文字列の右下を中心に回転している。それに対して、VisionEgg.MoreStimuli.Target2Dは図形の中央を中心にして回転しているんだ。
B: あー、なるほど。でも、なんで?
A: うーん、正直なところ意図がよくわからんな。もう一例見ておこうか。次はanchor='top'だ。
anchor='top'の場合
B: やっぱり文字は上を中心に回転していて、正方形は中央を中心にして回転してますね…。やっぱり納得いかないなあ。
A: とにかく、回転中心の決め方が違うので、正方形の上に文字を重ねた刺激を制作して、それを回転させたいとか思った時には注意する必要がある。
B: きちんと重なるように座標を計算しないといけないってことですよね。うげぇ、面倒くさそう。
A: anchor='center'なら回転中心は文字でも正方形でも黒線の交点(=position)と一致するんだから、回転させた刺激を重ねるときは全部'center'にすればいいんだよ 。
B: あ、そうか。なるほど。
A: サンプルプログラムでは、148から151行目、Müller-Lyer図形の矢羽を描画するところがこの問題と関係がある。 もしTarget2DもTextと同じようにanchorの位置が回転の中心となるならば、右側の矢羽のanchorをleft、左側をrightにしてpositionを主線の端に一致するように指定しておけば、もっと簡単に描画できるんだが、 残念ながらそのようになっていないので、anchor='center'として矢羽と主線がぴったり合うように矢羽の中心の座標を計算している。 面倒だがまあ仕方がないな。なお、anchorとorientation、positionの関係がいまいちよくわからない人のために、上の図を描画するサンプルプログラム( 12-1a.py )を用意しておいたので参考にしてほしい。
B: 三角関数ですね。高校生の時はこんなの大学生になっても使うとは思ってなかったなあ。
A: サンプルプログラムの残りの部分は今までの例題を見てきた人なら大体わかるはず。
B: ええと…、これ、例題7で出てきたPresentationを使ってたりとか、例題5のキー入力待ち関数っぽいのとか、いろいろ入り混じってますねえ。 キー押しをチェックしてるところ(175行目以降)のgKeys['UP']ってのはあまり見たことがないような?
A: それは例題3-3で出てきた「辞書型」の変数だな。最初に言ったように、私自身が試行錯誤していたころのプログラムだから、とにかくいろいろな機能を使ってみている。
B: …。最後のグラフの描画の部分がよくわかりませんねえ。っていうか、Aさん「次はグラフの描き方をやるぞ」って何度も予告しては放棄して、まだほとんど解説してくれてないじゃないですか!
A: うむ。実はそこも今回解説するつもりだったのだがな。間抜けな作者がこの原稿を書き始めてから「ちょっと例題12-1で全部解説するのはムリ」って気づいたらしいんだな。だから本来予定していなかった例題12-2を設定して、そこでグラフの描画の部分を解説するらしい。
B: らしい、ってAさん…。
A: そんなわけで、次回に続きます。
行番号なしのソースファイルをダウンロード→ 12-1.py
1#!/usr/bin/python
2# -*- coding: shift-jis -*-
3
4import random
5import math
6import sys
7import os
8
9import VisionEgg
10import VisionEgg.Core
11import VisionEgg.MoreStimuli
12import VisionEgg.Text
13
14import pygame.locals
15import pygame.joystick
16
17
18import Tkinter
19
20########################################
21# データファイルの設定
22
23def setparam():
24 class ParamWindow(Tkinter.Frame):
25 def __init__(self,master=None):
26 Tkinter.Frame.__init__(self,master)
27 r = 0
28 Tkinter.Label(self,text=u'保存ファイル名:').grid(row=r,column=0)
29 self.DataFileEntry = Tkinter.StringVar()
30 Tkinter.Entry(self,textvariable=self.DataFileEntry).grid(row=r,column=1)
31 r = 1
32 Tkinter.Label(self,text=u'ファイル名を空欄にすると\nデータは出力されません').grid(row=r,columnspan=2)
33 r = 2
34 self.fPlotCheckbutton = Tkinter.BooleanVar()
35 Tkinter.Checkbutton(self,variable=self.fPlotCheckbutton,text=u'グラフをプロットする').grid(row=r,columnspan=2)
36 r = 3
37 okButton = Tkinter.Button(self,text='OK',command=self.quitfunc)
38 okButton.grid(row=r,columnspan=2)
39 def quitfunc(self):
40 self.DataFile = self.DataFileEntry.get()
41 self.fPlot = self.fPlotCheckbutton.get()
42 self.winfo_toplevel().destroy()
43 self.quit()
44
45 w = ParamWindow()
46 w.pack()
47 w.mainloop()
48 fname = w.DataFile
49 fplot = w.fPlot
50 return (fname,fplot)
51
52
53if len(sys.argv)==1: # 引数がない
54 (fd,fp) = setparam()
55 if fd:
56 fid=open(fd,'w')
57 fDataFile = True
58 else:
59 fDataFile = False
60 fPlotData = fp
61if len(sys.argv)==2: # 引数がひとつ指定されている
62 fid=open(sys.argv[1], 'w') #第一引数をデータファイル名として結果をファイルに保存
63 fDataFile = True
64 fPlotData = False
65
66########################################
67# フォントの設定
68
69if sys.platform == 'win32':
70 font_name = r'C:\Windows\Fonts\msgothic.ttc'
71else:
72 font_name = '/usr/share/fonts/truetype/ttf-japanese-gothic.ttf' #Ubuntu9.04
73
74if not os.path.exists(font_name):
75 print 'WARNING: font_name "%s" does not exist, using default font' % font_name
76 font_name = None
77
78########################################
79# 条件の設定
80
81cnd = []
82
83for i in (30,60,90,120,150): #羽の角度は30、60、90、120、150の5種類
84 for j in ('L','R'): # プローブが左右どちらに出てくるか
85 for k in range(5): # 各条件を5試行
86 cnd.append([i, j])
87
88nTrials = len(cnd)
89cidx = range(nTrials)
90random.shuffle(cidx)
91
92########################################
93# グローバル変数
94
95cMidLineLength = 200
96cWingLength = 75
97cOffset = 200
98gvLength = 100
99
100gKeys = {'UP':False,
101 'DOWN':False,
102 'LEFT':False,
103 'RIGHT':False,
104 'SPACE':False,
105 'ESCAPE':False}
106
107gData = []
108
109########################################
110# VisionEggとPyGameの準備
111VisionEgg.config.VISIONEGG_GUI_INIT = 1
112VisionEgg.start_default_logging();
113VisionEgg.watch_exceptions()
114
115scrn = VisionEgg.Core.get_default_screen()
116scrn.set(bgcolor=(0.5,0.5,0.5)) # gray background
117
118SX = scrn.size[0]
119SY = scrn.size[1]
120
121pygame.joystick.init()
122if pygame.joystick.get_count() > 0: #ジョイスティックがある
123 joystick = pygame.joystick.Joystick(0)
124 joystick.init()
125
126########################################
127# 刺激の準備
128stimLU = VisionEgg.MoreStimuli.Target2D(size=(cWingLength,1.0), color=(1.0,1.0,1.0,1.0))
129stimLD = VisionEgg.MoreStimuli.Target2D(size=(cWingLength,1.0), color=(1.0,1.0,1.0,1.0))
130stimRU = VisionEgg.MoreStimuli.Target2D(size=(cWingLength,1.0), color=(1.0,1.0,1.0,1.0))
131stimRD = VisionEgg.MoreStimuli.Target2D(size=(cWingLength,1.0), color=(1.0,1.0,1.0,1.0))
132stimCL = VisionEgg.MoreStimuli.Target2D(size=(cMidLineLength,1.0), color=(1.0,1.0,1.0,1.0))
133stimline = VisionEgg.MoreStimuli.Target2D(size=(gvLength,1.0), color=(1.0,1.0,1.0,1.0))
134message = VisionEgg.Text.Text(text=u'カーソルの左右で長さを調節してスペースキーを押してください',
135 anchor='center',position=(SX/2,SY/2-100),
136 font_name=font_name,font_size=24)
137
138viewport = VisionEgg.Core.Viewport(screen=scrn, stimuli=[stimLU,stimLD,stimCL,stimRU,stimRD,stimline,message])
139p = VisionEgg.Core.Presentation(viewports=[viewport])
140
141def setStim(angle=90, pos=(0,0)):
142 global stimLU, stimLD, stimRU, stimRD, stimCL, cWingLength, cMidLineLength
143 stimLU.parameters.orientation = angle
144 stimLD.parameters.orientation = -angle
145 stimRU.parameters.orientation = -angle
146 stimRD.parameters.orientation = angle
147 radangle = angle/180.0*math.pi
148 stimLU.parameters.position=(pos[0]-(cMidLineLength/2)+cWingLength/2*math.cos(radangle),pos[1]+cWingLength/2*math.sin(radangle))
149 stimLD.parameters.position=(pos[0]-(cMidLineLength/2)+cWingLength/2*math.cos(radangle),pos[1]-cWingLength/2*math.sin(radangle))
150 stimRU.parameters.position=(pos[0]+(cMidLineLength/2)-cWingLength/2*math.cos(radangle),pos[1]+cWingLength/2*math.sin(radangle))
151 stimRD.parameters.position=(pos[0]+(cMidLineLength/2)-cWingLength/2*math.cos(radangle),pos[1]-cWingLength/2*math.sin(radangle))
152 stimCL.parameters.position=pos
153
154def stimVisible(stim_list,flg=True):
155 for s in stim_list:
156 s.parameters.on = flg
157
158
159########################################
160# presentation.go()中のコールバック関数
161def call_every_frame_func(t=None):
162 global gvLength, gKeys
163 if gKeys['RIGHT']:
164 if gvLength < cMidLineLength+100:
165 gvLength = gvLength + 2
166 stimline.parameters.size = (gvLength,1)
167 elif gKeys['LEFT']:
168 if gvLength > cMidLineLength-100:
169 gvLength = gvLength - 2
170 stimline.parameters.size = (gvLength,1)
171
172p.add_controller(None, None,
173 VisionEgg.FlowControl.FunctionController(during_go_func=call_every_frame_func) )
174
175def call_keydown(event):
176 global gKeys
177 if event.key == pygame.locals.K_UP:
178 gKeys['UP'] = True
179 elif event.key == pygame.locals.K_DOWN:
180 gKeys['DOWN'] = True
181 elif event.key == pygame.locals.K_RIGHT:
182 gKeys['RIGHT'] = True
183 elif event.key == pygame.locals.K_LEFT:
184 gKeys['LEFT'] = True
185 elif event.key == pygame.locals.K_SPACE:
186 p.parameters.go_duration = (0.0, 'seconds')
187
188def call_keyup(event):
189 global gKeys
190 if event.key == pygame.locals.K_UP:
191 gKeys['UP'] = False
192 elif event.key == pygame.locals.K_DOWN:
193 gKeys['DOWN'] = False
194 elif event.key == pygame.locals.K_RIGHT:
195 gKeys['RIGHT'] = False
196 elif event.key == pygame.locals.K_LEFT:
197 gKeys['LEFT'] = False
198
199
200p.parameters.handle_event_callbacks = [(pygame.locals.KEYDOWN, call_keydown),
201 (pygame.locals.KEYUP, call_keyup)]
202
203def waitKeyLoopWithTimeout(exitkey=pygame.locals.K_SPACE,
204 escapekey=pygame.locals.K_ESCAPE, timeout=2.0):
205 wf = True
206 flgEscape = False
207 t = VisionEgg.time_func()
208 while wf:
209 if VisionEgg.time_func()-t > timeout:
210 wf = False
211 for event in pygame.event.get():
212 if event.type==pygame.locals.KEYDOWN and event.key==exitkey:
213 wf = False
214 elif event.type==pygame.locals.KEYDOWN and event.key==escapekey:
215 flgEscape = True
216 wf = False
217 return flgEscape
218
219
220########################################
221# 実験本体
222
223for tn in range(nTrials):
224 angle = cnd[cidx[tn]][0]
225
226 if cnd[cidx[tn]][1] == 'L':
227 csPos = 'L'
228 setStim(angle=angle,pos=(SX/2+cOffset,SY/2))
229 stimline.parameters.position=(SX/2-cOffset,SY/2);
230 else:
231 csPos = 'R'
232 setStim(angle=angle,pos=(SX/2-cOffset,SY/2))
233 stimline.parameters.position=(SX/2+cOffset,SY/2);
234
235 initgvLength = gvLength = cMidLineLength + 30*(random.randint(0,5)-2.5)
236 stimline.parameters.size = (gvLength,1)
237 p.parameters.go_duration = ('forever',)
238
239 stimVisible([stimLU,stimLD,stimRU,stimRD,stimCL,stimline],False)
240 scrn.clear()
241 viewport.draw()
242 VisionEgg.Core.swap_buffers()
243
244 if not waitKeyLoopWithTimeout(timeout=0.5):
245 stimVisible([stimLU,stimLD,stimRU,stimRD,stimCL,stimline],True)
246 p.go()
247 if fDataFile:
248 fid.write('%d\t%c\t%d\t%d\r\n' % (angle,csPos,initgvLength,gvLength))
249 if csPos == 'L':
250 gData.append((0,angle,gvLength))
251 else:
252 gData.append((1,angle,gvLength))
253 else:
254 break
255
256
257scrn.close()
258
259########################################
260# 実験結果のグラフを出力
261
262if fPlotData:
263 import pylab
264 import matplotlib.font_manager
265
266 fontprop = matplotlib.font_manager.FontProperties(fname=font_name)
267
268 data = pylab.array(gData)
269 mL = pylab.zeros(5)
270 sL = pylab.zeros(5)
271 mR = pylab.zeros(5)
272 sR = pylab.zeros(5)
273
274 for i in range(5):
275 idx = (data[:,0]==0) & (data[:,1]==30*(i+1))
276 mL[i] = pylab.mean(data[idx,2])
277 sL[i] = pylab.std(data[idx,2])
278 idx = (data[:,0]==1) & (data[:,1]==30*(i+1))
279 mR[i] = pylab.mean(data[idx,2])
280 sR[i] = pylab.std(data[idx,2])
281
282 pylab.plot([30,60,90,120,150],mL,'bs-',label=u'左にプローブ')
283 pylab.plot([30,60,90,120,150],mR,'rs-',label=u'右にプローブ')
284 for i in range(5):
285 pylab.plot([30*(i+1),30*(i+1)],[mL[i]-sL[i],mL[i]+sL[i]],'b-')
286 pylab.plot([30*(i+1),30*(i+1)],[mR[i]-sR[i],mR[i]+sR[i]],'r-')
287 pylab.plot([10,185],[cMidLineLength,cMidLineLength],'k:')
288 pylab.text(20,200,u'主線の物理的な長さ',fontproperties=fontprop)
289 pylab.xlabel(u'矢羽の角度(度)',fontproperties=fontprop)
290 pylab.ylabel(u'主線の主観的な長さ(ピクセル)',fontproperties=fontprop)
291 pylab.xticks((30,60,90,120,150))
292 pylab.legend(prop=fontprop,loc='lower right')
293
294 pylab.show()