Pythonでオドボール課題（Psychopy不使用）

筆者，この分野の専門家ではありませんが勉強中の身でありまして，頭の整理と共有，また万一誤解があった際にどなたかが指摘してくださる可能性を期待して投稿します．さらにexcuseですが，Pythonを使うのも初めてなので，**ChatGPTに盛大に協力してもらいながら開発しました**．すごい時代になったもんだ．

# オドボール課題とは

オドボール課題とは，**EEG**（Electroencephalogram，俗にいう**脳波**のことです）に含まれる**事象関連電位**を測定する実験において，被験者にやってもらう一連のタスクです．
　事象関連電位（以降，Event-related potential,**ERP**）とは，読んで字のごとく，被験者が知覚した”事象”に”関連”して発生するEEG波形パターンを指します[^1][^2]．事象は種類とわず何でもよく，他人の笑顔を見たとき，スマホの着信音が鳴ったのを聞いたとき，水をあびたときなど，それぞれに応じた特徴的なEEGが発生します．ERPは事象の発生後，数十から数百[ms]で発生しますので，ERPを測定する実験では，以上のような**事象を人工的に生成し，被験者を繰り返し"刺激"し，被験者の脳に生じたEEGを測定します**．事象発生時刻を基準としたEEG測定結果時系列を平均することで，事象と関係ないEEG成分が取り除かれたERPが取得できる，というわけです．
　さらに，同一内容の事象による刺激に対応したERPでも，頭部表面上の位置に応じて，すなわち前頭部か後頭部か・中心か両サイドかに応じてERPの形状は異なります．いわゆる脳波測定装置はそれをカバーするためにたくさんの電極を備えています．その電極の配置個所は国際10-20法・10%法などのルールで標準化されています．ただしたくさんの電極すべての配置が常に要求されるわけではありません．例えば脳の視覚野は後頭部にあるとわかっているので，視覚についてのERPを測定したい場合は後頭部以外の電極配置の重要性は相対的に下がります．

ERPを説明したところで改めてオドボール課題の説明に移ります．オドボール課題は心理学の世界では典型的で教科書的な実験手法です．ERPを誘発するための短時間（1[s]くらい）の刺激提示を繰り返すのですが，**オドボール課題では一度の実験実施中に複数種類の刺激題材を提示します．そのうち1つは出現確率を高く，それ以外は低くし**（80% VS 20%など），低確率で出現する事象が調査対象となります．
　すると被験者は，高頻度に現れる事象に繰り返し刺激されて慣れたところに不意に普段と違う事象に刺激されることで「おや？」と反応します．その結果，高頻度刺激に対するERPと低頻度刺激に対するERPで異なる波形が得られます．これが，生理現象上のヒトの認知過程を定量化する手段の一つとなるわけです．この実験手法を通して得たERPは，見たものが怖かったり不安になる画像・風景だったり[^3]，被験者がお年寄りだったり[^4]すると波形が変調されるので，感情や身体状況が認知にどう影響するかの調査に応用されています．

今回は，被験者の視覚が画像により刺激された際に生じるERPを測定することにします．そこで，被験者に2種類の画像を高頻度・低頻度で提示するため，PCディスプレイに画像どちらかが1秒表示されては消え，また1秒表示されては消え・・・を何度も繰り返すソフトウェアを開発することにします．

# まずは画像を読み込んで表示してみる

初学者がChatGPTに頼るなら，簡単なことをやるプログラムなはずだと想定していても欲張ってはいけないのです．しっかり分割して，要素ごとに少しずつ実現していくべきです．他人が書いたプログラムは特に慣れないうちは理解が大変だし，また自分の要求が適切に実現されているかを細かくチェックしないまま採用するのは危険です．
　まずは本当にできて当たり前の，「画像を読み込む」「画面に表示する」「表示を閉じる」を実現します．どうやらウィンドウはTkinterを，画像の取り扱いにはPillowというライブラリを使えばいいらしいです．

```py
import tkinter as tk
from PIL import Image, ImageTk
import time

def close_window():
    root.destroy()

# ウィンドウを作成
root = tk.Tk()
root.attributes('-fullscreen', True)

# 画像を読み込み
image = Image.open("image.jpg")
photo = ImageTk.PhotoImage(image)

# キャンバスを作成
canvas = tk.Canvas(root, width=root.winfo_screenwidth(), height=root.winfo_screenheight(), bg='black')
canvas.pack()

# 画像をキャンバスに表示
canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=photo)

# 10秒後にウィンドウを閉じる
root.after(10000, close_window)

root.mainloop()
```

Tkinter関数`tk.Tk()`で`root`という名前のウィンドウを作るわけですね．画像は`PIL`の`Image`モジュールで読み出し，`ImageTk`モジュールの`PhotoImage`関数でTkinter用の画像データに変換します．
　ウィンドウに画像を表示するには，ウィンドウ内を直接画像で埋めるのではなく，ウィンドウ中にcanvasを配置し，そのcanvasが画像を表示することで実現します．canvasについてはこちら：[【Python】Tkinterのcanvasを使ってみる](https://qiita.com/nnahito/items/2ab3ad0f3adacc3314e6)．
　また，Tkinterでつくったウィンドウは時間制御もできます．`.after(（時間[ms]）, （実行する関数名）)`とすれば指定時間後に関数が実行されます．今回はこれを使って，画像表示後10秒経過したらウィンドウを閉じるようにしています．時間待機といえば`Time`を使う手段もあります．

# 画像の表示を繰り返す

`# 画像をキャンバスに表示`と`# 10秒後にウィンドウを閉じる`の個所を書き換えます．意外なことに，ChatGPTが出した答えはforループを使わないやり方でした．

```py
def display_image(times):
    if times > 0:
        canvas.delete("all")  # 既存の画像を削除
        canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=photo)
        root.after(1000, display_image, times - 1)
    else:
        close_window()

# 画像をキャンバスに表示し、3回繰り返す
display_image(3)
```

こういう答えが返ってくるから丁寧に少しづつ組み立てるのが大事ですね．`display_image(3)`自体は3回`display_image`を実行する，というわけではなく，単に引数に3という値を渡しているにすぎません．この`display_image`関数の中でさらに`display_image`が呼び出されることで繰り返し実行が実現されています．そして3という数字は`times`に渡されており，1階の`display_image`関数実行ごとに1減算されます．`times`がゼロになると`display_image`ではなく，ウィンドウを閉じる`close_window`が実行されてループブレイクします．

# 2種類の画像をランダムに出現させる

どんどんオドボール課題らしくしていきます．もう一つの画像を読み込むのは簡単で，`# 画像を読み込み`をまとめて下のように書き換えます．
```py
# 画像を読み込み
photo1 = ImageTk.PhotoImage(Image.open("image1.jpg"))
photo2 = ImageTk.PhotoImage(Image.open("image2.jpg"))
```
この2つの画像どちらを表示するかランダムに決定することにします．自分の予想は，乱数を生成し，出力値がある条件を満足していたら`photo1`を，していなければ`photo2`を表示すると`if`でスイッチするものでした．それなら調べれば書けるなと思いましたが，ここでも興味でChatGPTを頼ってみたら，自分が考えたよりもスマートな答えが返されました．
```py
# 画像リスト
ImageList = [photo1, photo2]

# ランダムに画像を選択して表示
def display_image(times):
    if times > 0:
        canvas.delete("all")  # 既存の画像を削除
        selected_photo = random.choices(ImageList, w, k=1)[0]
        canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=selected_photo)
        root.after(1000, display_image, times - 1)
    else:
        close_window()
```
`random.choice`というものがあるんですね．このほうが短いだけでなく，使う刺激の種類が増えても対応が容易そうです．
　さらに，下のようにすると出現確率を調整することができます．下では高頻度を80%，低頻度を20%としています．
```py
# 出現確率に重みを付けてランダムに画像を選択
weights = [0.8, 0.2]
selected_photo = random.choices(ImageList, weights, k=1)[0]
```

# ひとまず仕上げ：中間画像をはさみこむ
オドボール課題では刺激画像を連続して表示したりせず，刺激画像と刺激画像の間に何も刺激しない時間を設けます．ただし表示をなくして完全に真っ暗にしてしまうと，視覚ERPにとっては明るさも情報となりますので，表示内容切り替わり時のオン/オフによる雑音的なERPが発生してしまいます．そこで，刺激画像と刺激画像の間は中間画像を提示することとし，その画像の輝度は刺激画像と同じに設定します（白黒画像なら平均化したグレー一色の画像となります）．
　まずは中間画像を追加で読み込みます．`photo0`とし，`photo0`1秒→`photo1`または`photo2`1秒→`photo0`→`photo1`または`photo2`→・・・と繰り返します．
```py
# 画像を読み込み
photo0 = ImageTk.PhotoImage(Image.open("image0.jpg"))
photo1 = ImageTk.PhotoImage(Image.open("image1.jpg"))
photo2 = ImageTk.PhotoImage(Image.open("image2.jpg"))
```
さて，Pythonに不慣れとはいえなんとなくわかってきたので，そろそろChatGPTに頼りっぱなしにならず自力でなんとかできないか試みます．まずは中間画像を表示するので，ランダム表示する前のスクリプトを再使用します．
```py
def display_image(times):
    if times > 0:
        canvas.delete("all")  # 既存の画像を削除
        canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=photo0)
        root.after(1000, display_image, times - 1)
    else:
        close_window()
```

この中に次の画像を表示する`canvas.create_image(・・・`を挟みこみたいのですが，`root.after(1000, ・・・`の中に`canvas.delete("all")`と`canvas.create_image(・・・`を入れ子にして記述するのは美しくなさそうです．
　そこで，新しく関数をつくり，それに刺激画像提示をまかせることにします．その関数実行後はdisplay_imageによる中間画像表示またはウィンドウクローズを再実行します．
```py
    canvas.delete("all")  # 既存の画像を削除
    selected_photo = random.choices(ImageList, w, k=1)[0]
    canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=selected_photo)
    root.after(1000, display_image, times - 1)
```

# おわりに
本番用としては完璧ではありませんし最適ソリューションでもないでしょうが，これで目標を達成しました！以上をまとめた全体スクリプトをここに掲載します．ここで，画像表示繰返し数は`N`，中間画像表示時間[ms]は`t1`，刺激画像表示時間[m2]は`t2`として，発生確率`w`とともにスクリプトの上のほうで定義することにしました．
<details><summary>（折り畳み：クリックで展開）</summary><div>

```py
import tkinter as tk
from PIL import Image, ImageTk
import random

N = 5
t1 = 1000
t2 = 1000
w = [0.8, 0.2]

def close_window():
    root.destroy()

# ウィンドウを作成
root = tk.Tk()
root.attributes('-fullscreen', True)

# 画像を読み込み
photo0 = ImageTk.PhotoImage(Image.open("image0.jpg"))
photo1 = ImageTk.PhotoImage(Image.open("image1.jpg"))
photo2 = ImageTk.PhotoImage(Image.open("image2.jpg"))

ImageList = [photo1, photo2]
w = [0.8, 0.2]

# キャンバスを作成
canvas = tk.Canvas(root, width=root.winfo_screenwidth(), height=root.winfo_screenheight(), bg='black')
canvas.pack()

# 関数：グレー画像をキャンバスに表示したあと画像表示関数呼び出し
def display_image(times):
    if times > 0:
        canvas.delete("all")  # 既存の画像を削除
        canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=photo0)
        root.after(t1, display_new_image, times)
    else:
        close_window()

# 関数：画像表示数
def display_new_image(times):
    canvas.delete("all")  # 既存の画像を削除
    selected_photo = random.choices(ImageList, w, k=1)[0]
    canvas.create_image(root.winfo_screenwidth() // 2, root.winfo_screenheight() // 2, image=selected_photo)
    root.after(t2, display_image, times - 1)

display_image(N)

root.mainloop()

```
</div></details>

実施内容はごく簡単で画像の表示を繰り返すだけであり，それを実現するために必要なPython関連記事はまず既存の約10万記事（2023年4月）の中にあるでしょうが，それでも新しく記事にするのは完全に筆者の都合です．筆者が普段最も使う言語はMATLABなのですが，他の武器・選択肢も持っておきたいと思ってPythonでのプログラム開発を着手することにしました．本当にプログラム内容は簡単なので自分にとっては練習問題を解いたノートとなります．
　また，Psychopyという心理学実験用Pythonツールボックスがあり，それを使えば今回記述する内容を実現可能です．筆者もそれを使おうと当初は考えました．しかし，自分で中身をちゃんと把握・制御したいとも考え，今更かつ遠回りながら自分オリジナルを作成することになりました．
　
以上のような背景で，初学者による「心理学実験の中でも特に標準的な実施内容の実現のために」「画像を表示するだけの簡単なPythonプログラムを」「しかも既存のツールボックスも使わずに」という極めて遠回りかつ今更な記事を執筆しました．
　この投稿もどなたかのお力になれるのでしょうか．

**感想：ChatGPTすごい．**

# 参考文献
[^1]: 入戸野，[心理学のための事象関連電位ガイドブック](https://amzn.asia/d/45qlMNo)，2005
[^2]: Luck，[An Introduction to the Event-Related Potential Technique](https://amzn.asia/d/gIjaI21)，2005
[^3]: Delplanqueら，[Modulation of cognitive processing by emotional valence studied through event-related potentials in humans](https://doi.org/10.1016/j.neulet.2003.10.014)，2004
[^4]: 戸田ら，[事象関連電位からみた加齢現象](https://doi.org/10.3143/geriatrics.27.756)，1990