Coderへの招待¶
この章の実験の概要¶
本書はPsychoPy Builderを対象としていますが、この章では一般的なプログラミングへの橋渡しとしてBuilderの実験とPythonのコードで書いてみるとどうなるかという例を紹介したいと思います。
「 2.8:Builderが作成するファイルを確認しよう 」で述べたように、Builderは実験の実行時に exp_lastrun.py (expはpsyexpファイルの名前によって決まる)というファイルが作成され、そこにはBuilderで作成した実験をPythonのコードに変換したものが書かれています。 ではこのファイルを読めば、自分でPythonのスクリプトを書いて実験を作成する良いお手本になるかというと、残念ながらまったくそんなことはありません。 特にJavaScriptコードの出力に対応した最近のバージョンのBuilderは、ブラウザ上で動作するJavaScriptのコードとの共通化を図るため、とてもクセがあるコードを出力しますので、そちらの知識がないと「なぜこんなコードを出力しているのか」と頭を抱えることになりかねません。 本章では、ブラウザでの動作などは考えず、Pythonでできるだけシンプルなコードを書くことを目指します。
予備知識としては、本書の 第7章 まで、特に変数と関数(第5章)、クラスとif-elif-else文(第5章)、メソッド(第7章)などを押さえておく必要があります。 第4章 からこの章に飛んできても読めるように簡単な解説はするつもりですが、必要に応じて前の章も参照してください。
どこから手をつけるか¶
スクリプトを書いたことがない人にとって、スクリプトの中身には何が書かれているのか、どこからどのように書いたらいいのか、わからないことだらけだと思います。 そんな方は、あまり良い例えではないかも知れませんが料理のレシピを想像してください。 最初に準備する材料が書いてあって、材料を切ったり混ぜたりといった作業が順番に書かれていて、その通りに進めると料理ができあがります。 手順の中には「じゃがいもの皮をむく」と「にんじんの皮をむく」のように、順番を入れ替えても最終的な仕上がりに影響がないものあれば、「みじん切りにしてから飴色になるまで炒める」の「みじん切りにする」と「炒める」のように順番を入れ替えると別物になってしまうものがあります。
実験のスクリプトも同じようなもので、最初に「材料」となるものを書いておき、その材料を使っておこなう作業を順番に書いていきます。 料理のレシピと同様に、順番を入れ替えても問題ないものと、順番を変えてはだめなものがあります。入れ替えても問題ない作業をどのような順番で書くかは、スクリプトを書く人の好みに依りますので、他人が書いたスクリプトを読んで勉強するときは、この辺りの加減が最初はわかりにくいかもしれません。
さて、そんなスクリプトを書くときにどこから手を付けるかですが、筆者のおすすめは「いきなり全体の流れを書くのではなく、個々の要素から書く」です。先に全体の流れを書くのは、全体の見通しがついていないと難しいものです。また、個々の要素から書けば、要素が完成する度に実行して、おかしな点がないか確認することができます。先ほどまで問題が無かったのに、新しい要素を追加しておかしくなったことに気づいたなら、おそらく新しく追加した要素に問題があるか、追加したものとこれまで書いたものの間で辻褄があっていない点があるかどちらかなので、問題を見つけやすいでしょう。
次節から、PsychoPy Coderを使って実際にスクリプトを書いてみましょう。目指すのは 第3章 のサイモン課題です。
下準備をする¶
まず、Builderの実験の時と同様に、今から作る実験用にフォルダを作成することをお勧めします。CoderはBuilderと比べて自由度がとても高いので、「ひとつの実験 = ひとつのフォルダ」が守られていなくても問題ないように作成することも可能ですが、本書ではBuilderと揃えておきます。フォルダを作成したら、Coderを開いてメニューの「ファイル」から「新規」を選びましょう。すると untitled.py というタブが開いてまっさらなファイルが作成されます。ここへ文書作成アプリやメモ帳アプリのようにキーボードで文字を入力していくわけです。 untitled.pyという名前のままにするのもどうかと思いますので、「ファイル」の「名前を付けて保存」を選んで、先ほど作成したフォルダの中に適当な名前(サイモン効果の実験なのでexp_simon.pyとか)を付けて保存しましょう。ファイルの種類は「Pythonスクリプト (*.py)」となっているはずなのでそのままにしましょう。拡張子は .py から変更しないでください。
ファイルを保存したら、まず以下のように1行だけ書いてください。 Pythonは大文字と小文字を区別する ので、大文字小文字もそのまま正確に入力してください(とりあえず全部小文字ですが)。また、かな入力やローマ字入力のときに入ることがある全角文字と、それらをOFFにして入力した半角文字も区別されます。具体的に言うと、本書のフォントではわかりにくいかもしれませんが A と A は別の文字として扱われるということです(前者が全角文字)。日本語の文字などを入力する必要がある時以外は、かな入力やローマ字入力は原則OFFにしておきましょう。
import psychopy.visual as visual
これは「材料の準備」にあたるもので、 import xxx と書くことによってxxxというパッケージを使いますよという意味です。これを import文 と呼びます。 パッケージ とは、さまざまな目的のために誰かが書いてくれたコードを詰め合わせたもので、PsychoPyは元来アプリケーションではなく実験作成に便利なコードを詰め合わせたパッケージとして開発されました。 スクリプト内で import psychopy と書けば、以後そのスクリプト内で pscyhopy という名前を使ってコードを利用することができます。
PsychoPyのように多機能なパッケージの場合、パッケージ内部でさらにいくつかのグループに分割されてコードが収められています。このグループを モジュール と呼び、 psychopy というパッケージに含まれる visual というモジュールを利用したい場合は上記のコードのように psychopy.visual とドット(.)区切りで書きます。 最後の as は、パッケージやモジュールの名前が長い時に、このスクリプト内で通じる別名をつける役割を持ちます。 import psychopy.visual as visual で、 psychopy.visual というモジュールを以後 visual と書くだけで使えるようになります。
続いて、この psychopy.visual パッケージを使って「ウィンドウを準備しなさい」というコードを追加します。
import psychopy.visual as visual
win = visual.Window()
visual.Window() は visual の後に . がありますから、 visual の中にある Window() というものを使うという意味ですね。 ちなみに最初のimport文でasを使っていなかったら、以下のように psychopy.visual.Window() と書く必要があります。 asを使わない書き方は、どのパッケージから持ってきたものかわかりやすいという利点がありますが、タイプ量が多くなってしまいますし、長すぎる、読みにくくなる場合があるといった問題もあります。 どちらを使うかは好みですが、本章ではPsychoPyのモジュールはすべてasを使ってimportします。
import psychopy.visual
win = psychopy.visual.Window()
さて、ここからBuilderと比べて難しい部分です。この Window() は メソッド と呼ばれるもので(第7章 )、PsychoPyの刺激描画などに開くウィンドウを作成して オブジェクト を返します。 「オブジェクトを返す」というのがわかりにくいですが、スクリプト内で「ウィンドウ」とか「キーボード」とかいった対象を表すための仕組みだと思っておいてください。 行頭の win = は、 Window() が返したオブジェクトを win という名前で後々使用できるようにするための操作で、この操作を「 win へ 代入する 」と呼びます。
Window() の () の中には、「ウィンドウを作る時にこういう風にしてほしい」といった設定を書きます。これを 引数 と呼びます。なんでもかんでも自由に書けるわけではなくて、メソッドによって指定できるものが決まっています。 Window() の引数を調べる方法はいろいろありますが、PsychoPy公式ドキュメントの「API」を見るのが確実です。 https://psychopy.org/api/ にアクセスするとPsychoPyに含まれるモジュールがずらりと表示されますが、その中の psychopy.visual を選び、表示されたページからさらに Window を選んでください。 表示されたページの冒頭に以下のように表示されます(2025年11月現在)。
class psychopy.visual.Window(size=(800, 600), pos=None, color=(0, 0, 0), colorSpace='rgb', backgroundImage=None, backgroundFit='cover', rgb=None, dkl=None, lms=None, fullscr=None, allowGUI=None, monitor=None, bitsMode=None, winType=None, units=None, gamma=None, blendMode='avg', screen=0, viewScale=None, viewPos=None, viewOri=0.0, waitBlanking=True, allowStencil=False, multiSample=False, numSamples=2, stereo=False, name='window1', title='PsychoPy', checkTiming=True, useFBO=False, useRetina=True, autoLog=True, gammaErrorPolicy='raise', bpc=(8, 8, 8), depthBits=8, stencilBits=8, backendConf=None, infoMsg=None)
長くてびっくりしますが、先頭から見ていきましょう。 class はここに記されているのがクラス(第5章)であることを示していて、 psychopy.visual.Window はクラス名です。
続く () の中に書いてあるのが、このクラスのオブジェクトを作成するときに指定できる引数の一覧です。引数はカンマ区切りでかかれており、最初の引数は size=(800, 600) 、2番目の引数は pos=None という具合に解釈します。 size=(800, 600) の中に含まれるカンマは引数の区切りじゃないのかと言われるかもしれませんが、これは (800, 600) でひとまとまりの値なので、引数を区切るカンマではありません。
size は作成するウィンドウの画面上での大きさ、 pos はウィンドウの画面上での位置、 color はウィンドウの背景色…といった具合で、これらはBuilderの「実験の設定」ダイアログの「スクリーン」で設定する項目に対応しています。
= の後に書かれているのは、その引数を省略した時に自動的に設定される値(初期値)です。
「引数がたくさんあってこんなの覚えられないぞ!」と思われたかもしれませんが、初期値があるおかげで、 初期値から変更したい引数だけ覚えておけばよい のです。
Window() の引数でよく使うと思われるものを tbl-window-arguments にまとめておきます。
引数 |
説明 |
|---|---|
fullscr |
True ならウィンドウをフルスクリーンで描画します。 False なら通常のウィンドウとして描画します。 None ならPsychoPyの設定に従います。 |
allowGUI |
True ならフルスクリーン時にマウスカーソルを表示されます。 False なら表示されません。 None ならPsychoPyの設定に従います。 |
`color |
`` ウィンドウの背景色を指定します。Builderと同様、色名か3つの数値の組で指定できます。数値の組で指定する場合、色空間は引数 colorSpace で決まります。 |
units |
このウィンドウに描画する際の単位を指定します。 None ならPsychoPyの設定に従います。 |
monitor |
モニターのサイズや解像度、観察距離などの設定を、PsychoPyのモニターセンターで定義したモニター名で指定します。 None ならモニターセンターのデフォルトの設定に従います。 |
size |
monitor で指定した解像度と異なる解像度に設定したいときに指定します。 |
今回は、動作確認中にエラーが起こった時に中断することを考えて、 fullscr=False を指定しておくことにしましょう。また、スクリーンのサイズも小さめの size=(1280, 720) にしておきます。皆さんのPCのモニター解像度が1280×720以下なら size=(1024, 576) などにしてください。単位は念のため units='height' を明示的に指定しておきましょう。 これらの引数を Window() に追加します。
import psychopy.visual as visual
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
重要なことをひとつ補足すると、Pythonでは、この例のように 引数を名前付きで書く場合、順番を入れ替えても構いません(これをキーワード引数と呼びます)。 units から書いても、 size から書いても同じ動作をします。 「では、名前付きではない場合とは?」と思われるかもしれませんが、それは次節で触れます。
ここまで書いたら、Coderの画面上部のリボンにBuilderに似た実行ボタンがあるのでそれをクリックしてみましょう。画面中央に灰色のウィンドウが現れて、すぐに消えてしまえば成功です。 おそらく単純なタイプミス以外に躓くところはないので、万一動かない場合は綴りが間違っていないか、大文字と小文字、カンマとピリオドを間違えていないか、行頭に余分なスペース文字が入っていないかなどを確認してください。 これで実験を作成する下準備ができました。次節では刺激を描画してみましょう。
- チェックリスト
視覚刺激を描画する¶
視覚刺激の描画には、 psychopy.visual に含まれている視覚刺激オブジェクトを使用します。長方形なら psychopy.visual.Rect 、円なら psychopy.visual.Circle 、 正多角形なら psychopy.visual.Polygon 、 一般的な多角形なら psychopy.visual.ShapeStim 、文字なら psychopy.visual.TextStim 、画像ファイルなら psychopy.visual.ImageStim という具合です。BuilderのPolygonコンポーネントは Polygon 、 ShapeStim など複数のオブジェクトに対応しています(PsychoPyのバージョンによって異なります)。Textコンポーネントは TextStim 、Imageコンポーネントは ImageStim ですね。 これらの名前を知っている(または公式ドキュメントやサンプルコードから探してこられる)ことがCoderでスクリプトを書ける前提であり、Builderと比べてとっつきにくい理由でしょう。
これらのオブジェクトを作成するメソッドの引数は似ているので、代表として ShapeStim を取り上げましょう。
class psychopy.visual.circle.Circle(win, radius=0.5, edges='circle', units='', lineWidth=1.5, lineColor=None, fillColor='white', colorSpace='rgb', pos=(0, 0), size=1.0, anchor=None, ori=0.0, opacity=None, contrast=1.0, depth=0, interpolate=True, draggable=False, name=None, autoLog=None, autoDraw=False, lineRGB=undefined, fillRGB=undefined, color=undefined, fillColorSpace=undefined, lineColorSpace=undefined)
Builderになる程度慣れている人なら、引数を眺めていると「ああ、これはPolygonコンポーネントのあの項目に対応しているな」と想像できるのではないかと思います。
tbl-visualstim-arguments に、多くの視覚刺激オブジェクトに共通する引数を示します。
注意が必要なのは最初の引数の win で、 win には 初期値が設定されていません (= を伴っていない)。 初期値が設定されていない引数は省略することができません。 引数 win には先ほど Window() で作成したウィンドウオブジェクトを指定するのですが、このオブジェクトはスクリプトを実行する度に生成するので、初期値を設定することができないのです。 従って、最もシンプルな Circle の呼び出しは以下のようになります。
import psychopy.visual as visual
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
stim = visual.Circle(win)
引数が win=win ではなく単に win とだけ書かれている点に注意してください。これは 位置引数 と呼ばれるもので、公式ドキュメントの引数一覧(正確にはメソッドの定義)に書かれている順番に引数に対応します。引数一覧では最初の引数が win なので、引数名なしで書かれた最初の引数は win に対応していると解釈されます。引数名も変数名も win なのでややこしいと思いますが、そのうち慣れると思います。
Circle の引数のうち、 radius は半径、 edges は頂点数(実は円ではなく頂点数が多い多角形を描いて円に見えるようにしている)に対応していますが、それ以後の引数は Rect や Polygon など、他の視覚刺激オブジェクトと共通です。ここでは 第3章 と合わせてサイズを (0.1, 0.1) 、 位置を (0.7, 0.0) 、 塗りつぶしと枠線の色を red にしてみましょう。 radius は半径なので、 (0.1, 0.1) の大きさの円を描くには半径は 0.05 でないといけない点に注意してください。
import psychopy.visual as visual
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red', lineColor='red')
さらに固視点も描いてしまいます。BuilderのPolygonコンポーネントで選択できる「十字」は ShapeStim で描画します。引数 vertices には図形の頂点座標を並べたリストを指定しますが、いつかの形状はこの例のように cross と文字列で指定することができます。 位置を表す pos が省略されているため初期値の (0.0, 0.0) 、つまりスクリーン中心になる点に注意しましょう。
import psychopy.visual as visual
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red', lineColor='red')
cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white', lineColor='white')
これで材料が用意できました。あとは盛り付けです。 刺激を描画するには視覚刺激オブジェクトの draw() メソッドを使用します。 draw() は引数なしで使用します(引数がないわけではないのですが、特殊な状況でしか使いません)。
import psychopy.visual as visual
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red', lineColor='red')
cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white', lineColor='white')
stim.draw()
cross.draw()
win.flip()
draw() メソッドを実行した順番にスクリーンに描画されますので、上記のコードだとstim(円)が描かれてからcross(十字)が描かれます。 これは Builderでコンポーネントがルーチンに配置された順番に描画されることに対応しています。
最後に flip() というウィンドウオブジェクトのメソッドが実行されていますが、これは少し解説が必要でしょう。
fig-flip をご覧ください。PCの画面はバッファーと呼ばれる領域に描画され、モニターに転送するという過程を経て表示されます。
バッファーの情報をモニターに転送している途中でバッファーを書き換えてしまうと、画面の一部分は前の画面、残りの部分は新しい画面といった事態になってしまいます。具体的には、画面がちらついて非常に見づらい状態になります。
これを防ぐため、 fig-flip のように表示用(転送用)と描画用の2枚のバッファーを用意して、表示用バッファーの転送中に描画用バッファーに描き込みを行います。
そして、表示用バッファーの転送が終了すると、一時的に転送が停止するので、その間にすかさず表示用と転送用のバッファーを入れ替えます。
このようにすれば、転送と描画を並行して効率的に実行できるので、ちらつかせることなく滑らかなアニメーションを実現できるというわけです(1秒当たりのフレーム数が多いほどアニメーションは滑らかになる)。
`flip()`の働き。モニターへの転送用示用(=表示用)と描画用のバッファーがあり、描画用バッファーに次のフレームを描いた後、転送が一時停止するタイミングでバッファーを入れ替える。¶
PsychoPyにおいては、各視覚オブジェクトの draw() は描画用バッファーにその刺激を描き込む働きをします。 そしてウィンドウオブジェクトの flip() は、バッファーの入れ替え可能なタイミングまで待機して、可能になったら入れ替えをおこなう働きをします。 draw() と flip() のどちらが欠けても視覚刺激は表示されません。
さて、以上のスクリプトを実行して刺激が表示されるところを確認しましょう…と言いたいところですが、実際に実行すると一瞬だけ表示されてすぐに消えてしまいます(注意深く見ていればウィンドウ中央に十字、右に赤い丸が描かれるのが確認できるはずです)。スクリプトには「バッファーに描画して入れ替えをおこなう」という作業が1回分しか書かれていないので、1回この作業をおこなってすぐにスクリプトが終了したのです。 次節では、一瞬だけ表示するのではなく、指定した時間だけ表示されるようにしてみましょう。
- チェックリスト
ルーチンに相当するコードを書く¶
第3章 の実験では、固視点(十字)は実験の間ずっとスクリーンに描画されていた一方、刺激(円)は各試行が始まってから0.5秒後に表示されていました。 こういったタイミングの制御はBuilderだとルーチンに配置されたコンポーネントの [開始] や [終了] で制御されてたのでしたよね。 本節ではこれをスクリプトで再現してみましょう。
刺激の出現タイミングを制御するためには、試行が始まってから何秒経過したのかをスクリプト内で知る必要があります。 PsychoPyでは、高精度の時間測定が必要な心理学実験にも使用できる測定機能が提供されています。 この機能はpsychopy.clockというモジュールに含まれているので、このモジュールを追加しましょう。 スクリプトの冒頭部に以下のように書き足してください。 #より右に書かれているのはコメント で、スクリプトを実行するときには無視されるので、入力しなくて結構です。 コメントは将来自分がスクリプトを読み直すときのためのメモとして便利なので、皆さんも自由に活用してください。
import psychopy.visual as visual
import psychopy.clock as clock # この行を追加
# 以下省略
psychopy.clockモジュールに含まれるClockオブジェクトは、ストップウォッチのように、スタートしてからの時刻を測定します。 Clockオブジェクトを生成するには、以下のように Clock() を実行します。戻り値はClockオブジェクトです。
import psychopy.visual as visual
import psychopy.clock as clock
routineClock = clock.Clock() # この行を追加
# 以下省略
Clock() が実行されてClockオブジェクトが生成されると、直ちに時間測定が始まります。 Clockオブジェクトの reset() メソッドで測定をリセット、 getTime() メソッドで測定を開始してから(またはリセットしてから)の時間を得ることができます(時間の単位は「秒」)。 Clockオブジェクトには他にもいろいろな機能がありますが、とりあえずこの2つを覚えておけば十分です。
さて、この getTime() を使って時間を確認しながら、
corssは必ず描く
stimは0.5秒経過したら描く
ということを繰り返せば、 第3章 の実験と同様な刺激を描画できそうです。 第3章 の実験では参加者がキーを押すまで待ちましたが、まだキー押しの検出はできないので、とりあえず10秒経過したら終了することにしましょう。
Pythonで条件を満たすまで同じ作業を繰り返すには、while文を使用します。 while文は以下のように書き、while に続けて書かれた条件式が真である間、後続の字下げされた文を繰り返し実行します。
while 条件式:
繰り返し実行したい作業
字下げは通常、半角スペース4字を用います。字下げについて詳しくは
import psychopy.visual as visual
import psychopy.clock as clock
routineClock = clock.Clock()
win = visual.Window(fullscr=False, size=(1280, 720), units='height')
stim = visual.Circle(win, radius=0.05, pos=(0.7, 0.0), fillColor='red', lineColor='red')
cross = visual.ShapeStim(win, vertices='cross', size=(0.05, 0.05), fillColor='white', lineColor='white')
t = 0
routineClock.reset()
while t < 10.0:
stim.draw()
cross.draw()
win.flip()
t = routineClock.getTime()
win.close()