例題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が指定されている。

../_images/12-1-01.png

anchor='center'の場合

B: へ、angleとorientationって何が違うんでしたっけ?

A: ややこしいんだが、angleはVisionEgg.Text.Textで回転角度を指定する引数、orientationはVisionEgg.MoreStimuli.Target2Dで回転角度を指定する引数だ。

B: 指定する引数の名前が違うなんて全然気づいてなかった。

A: とにかく、黒い線の交点がpositionに指定されている位置で、いずれの刺激も黒い線の交点に中心が一致していて(anchor='center')、濃い色の刺激は淡い刺激より45度回転している。それはいいかな?

B: はい、そりゃそういう風に指定したんですから当たり前ですよね。

A: うむ。では続いてanchor='lowerright'にするとどうなるか見てみよう。

../_images/12-1-02.png

anchor='lowerright'の場合

B: ん? なんだか変だな。でも何が変なんだろう?

A: まず、anchorを右下に指定したんだから、刺激の右下が黒線の交点と一致するように刺激が配置される。そこまではOK?

B: はい。

A: 問題はここからだが、VisionEgg.Text.Textでは文字列の右下を中心に回転している。それに対して、VisionEgg.MoreStimuli.Target2Dは図形の中央を中心にして回転しているんだ。

B: あー、なるほど。でも、なんで?

A: うーん、正直なところ意図がよくわからんな。もう一例見ておこうか。次はanchor='top'だ。

../_images/12-1-03.png

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()