Edited at

TouchDesignerで作る心理物理実験

この記事はTouchDesigner Advent Calendar 2018 –14日目の記事です。

はいわゆるインタラクティブシステムなどを作りつつ、それを用いた心理物理実験などを行う事があるのですが、前者をTouchDesignerで組むようになって、これって心理物理実験のシステム構築にもかなり使えるのでは無いか。と思い、最近使うようになりました。

この記事では、ゴリガリの3Dグラフィックやアニメーション等は扱いません。TouchDesignerで心理物理実験システムを組むと結構良かった。という、果たして対象読者はいるのであろうかという記事です。TouchDesignerを学習する上で、多くのQiita記事や@satoruhiagさんの映像音響処理概論 2018 講義資料、に助けられてきたので、今後何かの拍子にTouchDesignerで実験システムを組む人が現れた時に参考になれば幸いです。

今回のサンプルはこちら

https://github.com/shks/PsychoTDsample


心理物理実験とは

心理物理実験が行われる、心理物理学(精神物理学)は、人間に対する外的な刺激と人間の内的な感覚や知覚の対応関係を測定し、定量的な計測をしようとする学問とされています。詳しい定義や分類は別の文献や記事を見てください。

多くは人間を対象とした実験で、反応速度や弁別実験など単純な実験を様々な条件で沢山行うことで、人間が持つ知覚や認知の特性、感覚と物理量との関係性などを検証します。もし、心理実験等に興味がある方は基礎心理学実験法ハンドブックなどをご参照ください。この本から、ハンドブックというのは片手で持てる本という意味では無いのだという事が身をもって理解できます。


ストループ効果を題材に

今回はストループ効果を題材にした実験のためのシステムを組んで見ます。ストループ効果とは、文字の意味と文字色のように同時に目にするふたつの情報が干渉しあう現象です。

例として、「赤・青・黄」の文字がそれぞれの文字の意味と一致した色で表示されている場合には問題なくすばやく(音読)読めます。一方で、「赤・青・黄」が文字の意味と、一致しない色で表示されている場合には脳内で情報が干渉して、素早く音読することができなくなります。こちらに、体験できるデモがあります。

このストループ効果を題材にして、文字と文字色が一致している場合と、不一致である場合の文字選択の反応速度はどのにように変わるか、ということを検証するために、心理物理実験のシステムをTouchDesignerを用いて構築してみたいと思います。


実験内容

実験では、スクリーンに表示された左右に表示した文字(RED /BLUE)から、指定された文字の方がどちら側にあるかを”できるだけ早く”回答する、というタスクを想定し、その反応時間を測定します。

そのタスクにおいて、文字と文字色が一致している場合 (congruent 条件)と、不一致である場合(incongruent 条件)において、その反応時間を測定します。被験者の回答は、キーボード入力で計測することにします。

実験タスクでは

- 文字と色は一致しているか? [congruent / incongruent ]条件

の他に

- RED、BLUEどちらの文字を選択させるか [RED / BLUE]

- 文字の配置 [RED は右にあるか左にあるか]

変化させて十分な回数テストを行い、反応時間を記録してその傾向を分析することになると思います。(本来は、正答率なども記録しますが今回は割愛します)

それでは、今回は下記のような実験シーケンス想定します。


実験システムをTouchDesignerで構築する。


タイマー使い実験シーケンスを作る

上記のような実験シーケンスを永遠と回し続けるシステムの構築を、TimerCHOPを用いて作ります。TimerCHOPには、segmentという複数の小分けのタイマーのようなものを定義できるようなので、それを利用します。

まずは、実験シーケンスをTableDATにタイムテーブルとして記述します。ここでは、簡単に[ pre, instruction, fixation cross, visualStim, end ]の5つのシーンを想定します。それぞれのシーンの開始時間とシーンの長さをそれぞれ beginlengthとして上記のように指定します。なお、section列にはわかりやすいようにそれぞれのシーンの名前を入れましたが、ここは"section"でなくても動作しました。

新しくtimer1(TimerCHOP)を配置して、segment tabSegment DATから、上記で作成したテーブルtimer_table(TableDAT)を指定します。

timer1Outputタブで、SegmentRunning Time CountSecondに指定。これで、上記の「シーン」と呼んでしたものが segment に対応し、いまどこのsegmentかが出力されるようになります。

また、running_secondsとして、シーケンス開始からの経過時間が取得できるようになります。


実験シーケンスをループさせる

しかし、このまま、TimerCHOPをStartさせても1回シーケンスが再生されて終わってしまうので、ループするようにします。

timer1のOn DoneRe-startに指定したうえで、timer1のTimerタブで、Init, Startをクリックすることで、指定したシーケンスが永遠とループし続ける事が確認できると思います。


実験シーケンスに応じた挙動を作る

このtimer1の後にselectCHOPを接続して、segmentを指定すると現在どこのsegment(シーン)にいるのかが取り出せます。これを後ほど、画面切り替えに利用します。

TimerCHOPの右下にある下矢印のボタンをクリックすることで、callbackのスクリプトを記述することができます。例えば、「segment 4に入ったら観測データを記録して、ファイルに保存する」「あるセグメントに入ったら、外部装置にシリアルを送る、OSCメッセージを送信する」等、シーケンスの進行に応じた挙動をスクリプトで記述できます。

def onSegmentEnter(timerOp, segment, interrupt):

print('TimerCHOP Entering segment', segment)
return


実験条件進行の設計

心理物理実験では、上記で組んだシーケンスを実験条件を変えて繰り返し行う場合が多いです。そして、その実験条件は様々な方法で順序が決められますが今回はランダムと想定して、実験条件の進行を設計します。

実験条件の進行も同じくTableDATで管理することにします。

今回の実験では


  • RED、BLUEどちらの文字を選択させるか [RED / BLUE]

  • 文字と色は一致しているか? [congruent / incongruent ]

  • 文字の配置 [RED は右にあるか左にあるか]

のそれぞれ2x2x2 = 8つ条件が考えれます。

例えばこれらをそれぞれ10回づつ行い、かつそれら条件の進行はランダムに出現するようにシーケンスを組みたいと思います。


実験条件進行のテーブルを作成する

ここで、TableDATの中身を生成するpythonスクリプトを作ります。

入れ物のTableDATは trialTable、スクリプトはTextDATで generateConditionSequenceにします。


generateConditionSequence


import random
def initSequenceList():
#ここで、TableDATを取得
trialTable = op('trialTable')
trialTable.clear()
trialTable.appendRow(['index', 'inst_color', 'congruency', 'location','reaction time'])

  #ランダムシーケンスを作るための一時的配列
trialseq = []
#'inst_color', 'congruency', 'location'
for i in range(10):
trialseq.append([ 'RED', 'congruent', 'redleft'])
trialseq.append([ 'RED', 'congruent', 'redright'])

trialseq.append([ 'RED', 'incongruent', 'redleft'])
trialseq.append([ 'RED', 'incongruent', 'redright'])

trialseq.append([ 'BLUE', 'congruent', 'redleft'])
trialseq.append([ 'BLUE', 'congruent', 'redright'])

trialseq.append([ 'BLUE', 'incongruent', 'redleft'])
trialseq.append([ 'BLUE', 'incongruent', 'redright'])

#ランダム
random.shuffle(trialseq)
index = 1
for seq in trialseq:
newRow = [index, seq[0], seq[1], seq[2], -1]
trialTable.appendRow(newRow)
index += 1

initSequenceList()


この後にTextDATをRunScriptするとTableDATの中身は更新されます。

ちなみに、TableDATの列に記録するデータ列も作っておくと後々、進行状況を確認したり、解析の際に役に立つので、Reaction Timeの列も追加してあります。今のところ、データはないので、-1にしておきます。


実験条件の進行を制御する

次に、この実験条件進行の何番目を実験しているのかを制御するために、TimerCHOPTableDATの関係をつなげていきます。

(よりこなれた方法もありそうですが、今のところ使っている方法です)

実験進行の順番を表す、ConstantCHOPを用意します。名前はINDEXにしています。

そして、timer1callbackスクリプトで onDone のタイミングで、INDEXが保持する値を1増やします。これによって、実験シーケンスが1つおわると、実験進行のインデックスが1つふえる、という挙動になります。

def onDone(timerOp, segment, interrupt):

op('INDEX').par.value0 += 1
return


実験条件進行に応じてパラメータを取り出す

次に、実験進行のインデックス (INDEX) を指定することで、その実験進行で用いる実験条件を読み出す部分を先述のTableDATを使って作ります。

先述のtrialTable(TableDAT)から、SelectDATを新しく作って接続して、trialTableから現在のINDEXに対応するデータ列を取り出します。SelectDATは図のように、Select Rowsby Indexにして、Start Row Index, End Row Indexともにop(‘INDEX’)[0]を指定します。

また、Include First Rowを指定して、後で可読性を上げるために残してあります。

直接このSelectDATを参照する方法もありますが、読みやすいように3つの条件


  • 文字と色は一致しているか? [congruent / incongruent]をcongruent_dat

  • 文字の配置 [RED は右にあるか左にあるか]をredLeft_dat

  • RED、BLUEどちらの文字を選択させるか [RED / BLUE]をtargetText_dat

にぞれぞれSelectDATで取り出します。

さらに、[congruent / incongruent]、[RED は右にあるか左にあるか]は視覚刺激生成に後ほど使うので、この2つの条件は [0 or 1]に変換しておきます。

congruent_dat(文字と色は一致しているか?)は、一致していれば1 に一致していなければ 0 とします。この条件式はpythonスクリプトとして、ConstCHOPのvalue0に記述できるので、

1 if(op('congruent_dat')[0,0] == 'congruent') else 0

とします。

同じく、文字の配置 [RED は右にあるか左にあるか]をredLeft_datを、ConstCHOPvalue0から参照して、下記にようにします。

1 if(op('redLeft_dat')[0,0] == 'redleft') else 0

ここまでで、永遠とシーケンスのループが回り、実験進行のインデックスが1づつ増え、その際の実験条件に関するデータが取得できました。


画面シーンの生成と切り替え

今回の実験シーケンスは5つのシーン(Segment)から構成されます。ここでは、それぞれの画面映像を作るのにいくつかのTOPと、それらを切り替えるのに、SwitchTOPを用います。

SwitchTOPには、実験シーケンスを構成する5つそれぞれのシーンの映像が入力されています。これらのどれを選択して、出力するかを現在のsegmentの値で指定します。

TimerCHOPからSelectCHOPで取り出したsegmentの数値を、timer_segmentと命名したNullCHOPへ入力し、5つのTOPが入力されているSwitchTOPIndexの値を、下記のように、timer_segmentの値で指定することで、画面遷移を簡単に作ることができます。


TableDATのセル文字列を使って画面表示を作る

実験条件に合わせた、各シーンの表示作成

ここでは単純にTextTOPを使用します。

'CHOOSE ' + op('target_text')[0,0] + ' TEXT'

これで、RED文字を選択するか、BLUE文字を選択するのかの実験条件を反映した映像が作れます。

たとえば、BLUEが選択対象の場合でも反映されています。


実験条件を入力として、画面を出力する COMPを作る

今回の実験シーケンスで調査対象となるシーンを作ります。ここは割愛しますが、例えば文字色と文字の一致isCongruentと RED文字が左右のどちらになるのかisRedLeftを指定して、その画面をTOPとして出力するようなパッチをまとめてCOMPにするとスッキリするかと思います。

まとめたCOMPの中身は複雑そうに見えますが、単純にisCongruentisRedLeftの全4パターンをSwitchで切り替えているだけです。(この辺もスクリプトを駆使すればもう少しスッキリ書けるとは思いますが、作成速さを優先して。)


反応時間の表示シーン

おそらく、普通の心理物理実験では一般的ではないかもしれませんが、ここではゲーム性も兼ねて反応時間の表示を行います。

まだ、反応時間を測る部分は作っていないので、仮にConstantCHOPNullCHOPでダミーで組んでおきます。NullCHOPreaction_timeを命名し、TextTOPからその数値を参照して、テキストとして表示します。

ここまでで、実験シーケンスの基本的な画面遷移が作れました。この後に、まだ終わっていない昨日を作って行きます。


反応時間の測定

この実験では、指定された文字(BLUE / RED)を上記のような視覚刺激が表示されてどのくらいの速さで選択できるかと計測します。ここで、選択行動は、キーボードで取得することにします。

つまり、この視覚刺激が表示されてから、キーボードが押されるまでの時間(反応時間)を計測します。

反応時間を測るためには


  • 視覚刺激が表示された時間

  • キーボードが押された時間

の二つを計測し、差分を出すことで経過時間を測定できます。


時間計測における注意

ここでTouchDesignerでよく使われるabsTime.secondsや、上記のTimerから出力されるrunning_secondsを当初使おうとしていたのですが、気になる点があったので、現在は pythonスクリプト上でtime.time()を呼んで経過時間を測定しています。

absTime.secondsは、 TouchDesignerが起動してからの経過時間を取得できるものです。TouchDesigner上ですでに用意されており、TouchDesignerのコンポートや pythonコード上から読み出して使えます。しかし、このデータの更新頻度が TouchDesigner上の フレームレートに依存しているようので、 フレームレートとは関係なく時間を計測したい場合には注意が必要です。

こちらが、テストした場合の様子です。

Keyboard In.CHOPで’L’のキーを押してから離すまでの経過時間を計測するようにしています。

比較は、 pythonの timeモジュールのtime.time()関数、time.perf_counter()関数そして、absTime.secondsの3つを比較しています。画面右に出力しているのが、経過時間の表示です。

print('KEY CHOP -> chopexec - OnToOff duration time.time        ', 1000 * duration_time)

print('KEY CHOP -> chopexec - OnToOff duration time.perf_counter', 1000 * duration_perf_counter)
print('KEY CHOP -> chopexec - OnToOff duration absTime ', 1000 * duration_absTime_on)

ここで、 FPS = 60で計測している場合には、それぞれ 2-3msecレベルの誤差なのですが、 FPS=20にした場合、absTime.secondsを用いた計測結果のみ他と大きく異なることがわかります。また、計測値も 50msecの倍数で表示されていることから、 20Fps は 1000msec / 20 = 50msecの時間分解能でしか経過時間を測定できていないことがわかります。なお、これと同じ現象はTimerCHOPrunning_timeを用いた計測をテストしても同様の結果が発生します。

どうやら、KeyboardIn.CHOPのイベントはTouchDesignerのフレームレートとは関係なく発生し、pythonスクリプトのそのタイミングに準じて実行されますが、absTime.secondsTimerCHOPの更新は、画面更新速度に依存するようです。

なので、例えばフレームレートが著しく低下するような実験状況でこれら、absTime.secondsTimerCHOPの経過時間を用いて 画面更新以外のタイミングで発生する時間を計測する場合には注意が必要になります。

対応としては下記の選択肢があります。


  1. pythonのtime.time()関数を用いて時間計測を行う。

  2. TouchDesignerのフレームレートの低下が起きないように注意する


フレームレートに依存しない時間計測

ここでは、1.pythonのtime.time()関数を用いて時間計測を行う。で作ります。まず TextDatにpythonスクリプト、 計測出力用にConstantCHOPを用意します。

TextDatには、下記のようなスクリプトを書いて、mypyTimerという名前にします。本来は色々な例外処理等を書くべきですが、とりあえずシンプルに書いておきます。startTimer関数では、開始時間を保持しておいて、measureDuration()が呼ばれた時に、現時刻との差分を経過時間として、timer_durationの値として反映させています。

import time

t_startTime = 0

def startTimer():
global t_startTime
t_startTime = time.time()

def measureDuration():
global t_startTime
duration = time.time() - t_startTime
op('timer_duration').par.value0 = duration * 1000

他のTextDATなどのPythonスクリプトから、

import mypyTimerとすれば、mypyTimerにある関数を呼ぶことができます。


視覚刺激が表示されたらタイマーを開始

まず時間計測の開始のタイミングを先に作ってある、実験シーケンスを制御する timer1のコールバックで、シーン segmentが3になった時に、mypyTimerの計測を開始します。


timer1_callback

import mypyTimer

def onSegmentEnter(timerOp, segment, interrupt):
if(segment == 3):
mypyTimer.startTimer()
return


キーボード入力で経過時間を測定

次にキーボード入力された時、経過時間を取得します。

keyboard InCHOPNullCHOP(null3)につなげて、keyboardのLが押された場合の値の変化をnull3に伝えます(null3の3は特に意味はありません) 。値を変化を検出した場合にコールバックを実行するCHOP Execute (chooexec3)で、null3を指定してかつ、CHOP ExecuteOff to Onをアクティブにします 。これで、null3が0から1になった時に、chooexec3に書いた次のスクリプトで、先ほどのmypyTimermeasureDuration関数を実行します。

すると、経過時間が、Constant CHOPtimer_durationに反映されます。


CHOPExcuteDAT

import mypyTimer

def onOffToOn(channel, sampleIndex, val, prev):
mypyTimer.measureDuration()
return

こうして得られた経過時間が反映された、Constant CHOPをそのまますでに作成してあった、reaction_time(NullCHOP)へ接続することで、画面内容への反映も完了します。


計測データの保存

ここまでで、指定した実験条件進行の Indexで指定された条件で行なった実験シーケンスでの反応時間を得ることができました。

最後に、この結果を実験条件進行のテーブルである trialTable(TableDAT) にデータとして書き込んで、途中状態を保存します。

計測データの反映・保存のタイミングをどこにするかは、用途にもよりますが、ここでは、その実験シーケンスが終わったら、つまり、timer1onDoneのコールバックで行うことにします。


timer1_callback


def onDone(timerOp, segment, interrupt):
#現在の実験インデックス
currentIndex = op('INDEX')[0]
if(currentIndex > 0):
#timer_durationの値を、trialTableテーブルのcurrentIndex行、 4列目に入れる。
op('trialTable')[int(currentIndex) , 4] = op('timer_duration')[0]
#タブ区切り形式でデータを保存する。 #上書きに注意
op('trialTable').save('data.tsv')

#INDEXを一つ前に進める
op('INDEX').par.value0 += 1
return


すでに、反応時間の計測結果を格納している、timer_duration (ConstantCHOP)の値を、 trialTable (TableDAT)の現在のIndexの位置に合わせて反映して、その後 tsvファイル形式で保存します。

ちなみに、このままだと同じファイル名で実験シーケンスごとにファイルを上書きします。悲しいことが起きる前に、本番はファイル名を変えたり、バックアップなどを取るような運用にしましょう。 大切なものは失ってから気付く事が多いです。


実験シーケンスの初期化

ここまでで、一通りの実験システムが構築できたと思います。最後に実験シーケンスの初期化に関して記載します。しかし、実験条件進行のIndexは単純増加するだけです。また、実験条件の進行もランダム化されているとは言え、固定化されたまま。trial_tableのデータも初期化しなければなりません。そこで、一発初期化・実験シーケンス開始ボタンをつくります。

panelexec1のスクリプトで、INDEXと実験条件進行のテーブルの再生成を行う。


panelexec1


def onOffToOn(panelValue):
#INDEX を初期値の0にする
op('INDEX').par.value0 = 0

#実験条件進行のテーブルを再生成する
op('generateConditionSequence').run()
return


また、スクリプトを組まずに、Timerのスタートも制御できます。たとえば、ボタンからNullCHOPへ接続し、そのNullCHOPの値を、timer1ActiveStartから参照することで、ボタンを押すとアクティブ化して、かつスタートする。という挙動を作る事ができます。

ここまでで、一通りの実験を執り行えるネットワークを作る事ができました。もちろん、細かい機能や割愛した部分もありますが、なれるとパパッと作成してパイロット試験をしてみることができると思います。


TouchDesignerで組むと嬉しいところ


実験オペレーター用ウインドウを作成する。

ここがTouchDesignerを使っていて、大変ありがたいと思うところです。 TouchDesignerではそれぞれのノード(ブロック)は、OPViewer.COMPを用いて参照することができます。かつ、それらをさらにまとめて一つのContainer.COMPとしてレイアウトするとができます。つまり、デバックやモニタリング画面を秒速で作成することができます。

例えばtrial_tableでシーケンスの情報を見たい場合は、OPViewer.COMPで参照先をtrial_tableに指定するとViewerとして見えるようになります。

同じように、実験条件の進行デーブルや、現在のDisplay提示の映像も、同じように対応するコンポーネントの名前を指定するだけでそれぞれを観測するViewerが作れます。

さらに個別に用意したCOMPをまとめるため、空のContainer.COMPを用意して、そこに下記のように上下で参照を繋ぐとつなぎ元のOPViewerの内容をまとめて一つのContainer.COMPで見る事ができます。それぞれの配置は、元のOPViewerのLayoutタブのX,Y,Width,Heightを変更することで変更できます。まとめたContainerCOMPを右クリック→Viewで、個別のウィンドウとして開けます。ボタンのクリックなどはこのウィンドウから行う事ができるので、実験のオペレーションはこのウィンドウのみでできるようになります。


実験結果の即時可視化

例えば、DatToCHOPTrailCHOPなどを使用すると、実験での観測データや値の時系列の変化をリアルタイムで観察する事ができます。

これは、別の実験を行なった時の観測データとその可視化です。左のTableDATを右のDatToCHOPで可視化しています。このように、データのリアルタイムな可視化を割と簡単に組む事ができるので、実験を行いながらも、その計測したデータの傾向と被験者の様子を一緒に観測する事ができます。実行しながら、気になったデータ列の可視化を実験を止めることなく行う事すら可能です。


おしまい

心理物理実験のためのフレームワークには pythonでのPsychoPyやMatlabのPsychtoolboxなどがありますので、業界の標準?に準拠するべきシチュエーションではそちらを使用した方が良いかもしれません。しかし、 TouchDesignerの映像やシステムを実行しながら改変したり、実験データのリアルタイムでの可視化、観察ができる利点も大いにあるのでは無いかと考えます。

瞬間的に生まれた事象を逃さず観測し、熱いうちに実装し検証する

というのは、様々なことに通じる重要な姿勢と思いますし、 TouchDesignerは結構それに向いるなと思ったのでした。

それでは、Happy Designing!