pupil labs (pupil core) をPythonで操作する

# 編集履歴
**2024年9月30日：**
Pupil Capture他のバージョンが3.に上がっているようです。
LSL Relayのインストール方法が少しだけ変わっているっぽいので追記しました。

<details><summary>もっと前</summary>

**2020年7月3日：**
Pupil Capture他のバージョンが2.に上がっていましたね。
.pldataの仕様がちょっと変わったようです。
これに対応するよう「Pythonでデータを取り出す」を修正しました。

**2019年10月17日：**
Pupil Labsという装置名だったと思うのですが、いつの間にかPupil Coreという名前に変わったようですね。 
Pupil Labs社製のPupil Labsだったのが、Pupil Labs社製のPupil Coreになったというわけですね。
タイトルだけ修正しましたが、記事の中身ではPupil Labsのままです。
現在のVer.でも動作するか確認するとともに、微修正しました。
さらに、Pythonで`.pldata`を読み取る方法についても追記しました。
これでPupil Playerを使わなくても、測定情報にアクセスできます。

</details>

# この記事の目的
pupil labsというアイトラッカーを買いました。
Pythonを使ってコントロールすることができるのですが、色々と苦労したので記録します。

# 環境

**2024年9月30日時点：**
- Windows 11
- Python 3.10.11 (64bit)
- Pupil Core（World Camera付き, 両目のカメラ）
- Pupil Capture v3.5.1 (Windows, 64bit)

<details><summary>もっと前</summary>

**2020年7月3日時点：**
- Windows 10
- Python 3.7.7 (64bit)
- pupil labs (pupil coreのこと, World Cameraなし, Mono Eye Camera）
- Pupil v2.0 (Windows, 64bit)

**2019年10月17日時点：**
- Windows 10
- Python 3.7.3 (64bit)
- pupil labs (pupil coreのこと, World Cameraなし, Mono Eye Camera）
- pupil_capture_windows_x64_v1.16

**2019年1月24日時点：**
- Windows 10
- Python 3.7.2 (64bit)
- pupil labs (World Cameraなし, Mono Eye Camera）
- pupil_capture_windows_x64_v1.10

</details>

# pupil labsとは
[pupil labs](https://pupil-labs.com/)は、グラス型の小型アイトラッカーです。
とても軽量（最低限の仕様で29g）である上、トラッキングの精度も高いようです。
なにより、アイトラッカーの中ではかなり安価（最低限の仕様で€1,590）なのが嬉しいです。

ZeroMQというパッケージを使って、PythonからTCPを介してpupil labsをコントロールすることができます。
Recordingの開始・停止やAnnotation（トリガー）の入力がPythonからできるので、PsychoPyを使った心理実験と相性バツグンです。

また、LabStreamingLayerを介してリアルタイムにデータを送ることもできます。
バイオ（？）フィードバックにも便利そうですね。

# 必要なものを揃える
## Pupil Capture
pupil labsで測定を行うためのGUIアプリです。
~~[公式サイト](https://docs.pupil-labs.com/#getting-started)に、`Download Pupil Apps`というリンクがあるので、そこからDLしましょう。~~
[公式サイト](https://docs.pupil-labs.com/core/#_1-put-on-pupil-core)に、`DOWNLOAD PUPIL CORE SOFTWARE`というリンクがあるので、そこからDLしましょう。
DLした.7zファイルを解凍して、pupil_capture的なフォルダの中にある`pupil_capture.exe`を起動します。

一緒に、Pupil PlayerとPupil Serviceというソフトも解凍されるかと思います。
Pupil Playerは、Recordしたアイトラッキングを後から確認したり、.csvなどにexportするものです。
Pupil Serviceは、GUIのないPupil Captureという扱いだそうです。
今回のようにPythonで動かすのであれば、CaptureでなくServiceでよさそうなものですが、なぜかうまくいきませんでした。
今回はCaptureを使います。

## 必要パッケージ
必要なパッケージである`zmq`と`msgpack`をインストールします。
pip一発です。

```
pip install zmq msgpack
```

# TCPで接続する
初めに、TCPでPupil Captureに接続します。

```python
import zmq, msgpack

context = zmq.Context()
socket = context.socket(zmq.REQ)
socket.connect('tcp://localhost:50020')
```

`5002`の部分はポート番号です。
ポート番号を確認するには、`Pupil Capture-World`ウィンドウの右メニューから`Pupil Remote`をクリックしてください。

![Pupil Capture - World 2019_01_23 14_13_09.png](https://qiita-image-store.s3.amazonaws.com/0/304493/eb6f8e0c-d9cf-b310-6800-84ea3ebfce3c.png)

# Stringを送って操作する
## 基本的な使い方
例として、PythonからRecordingの開始を指示してみましょう。

```python
socket.send_string('R')
socket.recv()
```

1行目で、`R`というStringをCaptureに送ります。[^1]
すると、Capture側が「Record始めろ」という命令として解釈してくれるというわけです。

2行目ではCapture側からの返事を受け取っているのだと思いますが、すみません、よくわかってません
（ちなみに、「OK」という文字が帰ってきます）。
しかしこの2行目を入れないと、次の送信をすることができません。

止めるときは、`R`ではなく、`r`を`send_string()`してやります。

```python
socket.send_string('r')
socket.recv()
```
Session名を指定してRecord開始することも可能です。

```python
socket.send_string('R session_no_namae')
socket.recv()
```

としてやると、「session_no_namae」というSession名で測定してくれます。

## そのほかにできること
ほかにも、`send_string()`すると対応してくれるStringがいくつかあります（[参照](https://github.com/pupil-labs/pupil/blob/master/pupil_src/shared_modules/pupil_remote.py)）。

|String |function |
|---|---|
|'R' |Recordを始める |
|'R namae' |namaeというsession名でRecord開始|
|'r' |Recordを止める |
|'C' |キャリブレーション（Screen Marker）を始める |
|'c' |キャリブレーション（Screen Marker）を止める |
|'T 1234.56' |タイムスタンプを1234.56からスタートさせる |
|'t' |タイムスタンプを取得する |
|'v' |ソフトウェアバージョンを取得する |

# より複雑な命令をする
## 基本的な使い方
ほかにも様々な操作をPythonから行うことができますが、書き方が少々ややこしくなります。
例としてまず、Eye Cameraを起動させます。

Eye Cameraは、眼球を写しているカメラです。
仕様によって、一つか二つ備えつけられています
（World Camera のみという仕様の場合はついていません）。
これが起動していると、`Pupil Capture - eye 0` というウィンドウが`Pupil Capture -World`とは別に表示されます。
ちなみに、さらに二つ目のEye Cameraを起動すると、`Pupil Capture - eye 1`が表示されます。


Eye Camera 0を起動させるには、下記のコードを実行します。
すでに`eye 0`が起動しているようなら、そのウィンドウを閉じることでProcessを一旦終了させてから試しましょう。
```python
n = {'subject': 'eye_process.should_start.0',
     'eye_id' : 0}
socket.send_multipart([('notify.eye_process.should_start.0').encode('utf-8'),
                        msgpack.dumps(n)])
socket.recv()
```

辞書型変数`n`の`subject`には、命令文が入っています。
二つ目以降のKeyで、細かい指定をしているようです。

`send_multipart()`を使って、二つのデータを送っています。
一つ目は、`subject`の中身`'eye_process.should_start.0'`に`'notify.'`をつけたものです。
この文字列を、UTF-8にエンコードしています。[^2]
二つ目は、`n`を`msgpack`というパッケージでシリアライズしたものです。

最後に、`socket.recv()`でCaptureからのお返事を聞いています。

この`n`の中身を変えることで、様々な操作をすることができます。
まず、もう少し使いやすいように関数化してしまいましょう。

```python
def send_recv_notification(n, socket):
    socket.send_multipart([('notify.%s'%n['subject']).encode('utf-8'),
                           msgpack.dumps(n)])
    return socket.recv()
```

`subject`の中身に`'notify.'`をつける作業を自動でやらせているだけです。
これを使って、Eye Camera 0 を終了させてやりましょう。

```python
n = {'subject': 'eye_process.should_stop.0',
     'eye_id' : 0}
send_recv_notification(n, socket)
```

## そのほかにできること
`n`の中身を変えてやることで、ほかにも様々な操作ができます。
以下に、列挙します。

```python
# EYE CAMERA 1を起動する
n = {'subject': 'eye_process.should_start.1',
     'eye_id' : 1,}
send_recv_notification(n, socket)

# Recordingを開始する
n = {'subject'     : 'recording.should_start',
     'session_name': 'test_session'}
send_recv_notification(n, socket)
#socket.send_string('R test_session')
#socket.recv()
# と同じ

# Recordingを停止する
n = {'subject'     : 'recording.should_stop',
     'session_name': 'test_session'}
send_recv_notification(n, socket)
#socket.send_string('r')
#socket.recv()
# と同じ

# Plugin（Annotation Capture）を起動する
n = {'subject': 'start_plugin',
     'name'   : 'Annotation_Capture'}
send_recv_notification(n, socket)
```

他にもできることがあるかもしれません。
この「命令文」の一覧が見つからないもので…

### Annotationを入れる
まず、Annotation CaptureのPluginを起動してください。
Annotation（トリガー）を入れる場合は、書き方が少し変わります。

```python
socket.send_string('t')
ts = socket.recv()
n = {'topic'    : 'annotation',
     'label'    : 'raberu_no_namae',
     'timestamp': float(ts)}
socket.send_multipart([(n['topic']).encode('utf-8'),
                        msgpack.dumps(n)])
socket.recv()
```

これで、「raberu_no_namae」というAnnotationが入力されます。
ほかと違う点は二つです。
- `'subject'`ではなく`'topic'`というキー名にする[^3]
- `send_multipart()`に入れるとき、`'notify.'`をつけない。

`'timestamp'`のキーにタイムスタンプを入れてやる必要があります。
`send_string('t')`して、返ってきた数値を入力してやりましょう。

Annotation用の関数を作っておくと便利でしょう。

```python
def send_annotation(n, socket):
    socket.send_string('t')
    ts = socket.recv()
    n['topic'] = 'annotation'
    n['timestamp'] = float(ts)
    socket.send_multipart([(n['topic']).encode('utf-8'),
                            msgpack.dumps(n)])
    socket.recv()
```

ちなみに`n`には、ほかにもいくつかキーを設定できます。

```python
n = {'label'    : 'session_start',
     'duration' : 1.0,
     'source'   : 'a test script',
     'record'   : True}
send_annotation(n, socket)
```

追加したキーはいずれも大事な引数のように見えますが、なくても問題なく動くようです。
それぞれ指定してやると、.csvへAnnotationを出力したとき、`duration`, `record`, `source`の列ができて、指定した内容が記載されます。
うまく使えば便利、くらいのものなのかもしれません。

# Classにまとめてみた
Classにまとめたら使いやすくなるかなと思って、書いてみました。
まだ書き慣れていないので、間違いなどあったら教えてほしいです。

```python
import zmq, msgpack
class PupilLabs:    
    def __init__(self, session_name):

        def send_command(n, socket):
            socket.send_multipart([('notify.%s'%n['subject']).encode('utf-8'), msgpack.dumps(n)])
            return socket.recv()
        
        self.send_command = send_command
        self.session_name = session_name
        context = zmq.Context()
        self.socket = context.socket(zmq.REQ)
        self.socket.connect('tcp://localhost:50020')

        # EYE CAMERA 1 (/2) ACTIVATE
        n = {'subject': 'eye_process.should_start.0',
             'eye_id' : 0}
        send_command(n, self.socket)
        
        # EYE CAMERA 2 (/2) INACTIVATE
        n = {'subject': 'eye_process.should_stop.0',
             'eye_id' : 1}
        send_command(n, self.socket)

        # START PLUGIN
        n = {'subject': 'start_plugin',
             'name'   : 'Annotation_Capture'}
        send_command(n, self.socket)

    def init_recording(self):
        n = {'subject'     : 'recording.should_start',
             'session_name': self.session_name}
        self.send_command(n, self.socket)

    def stop_recording(self):
        n = {'subject'     : 'recording.should_stop'}
        self.send_command(n, self.socket)

    def send_annotation(self, label):
        self.socket.send_string('t')
        ts = float(self.socket.recv())
        n = {'topic'    : 'annotation',
             'label'    : label,
             'timestamp': ts}
        self.socket.send_multipart([(n['topic']).encode('utf-8'), msgpack.dumps(n)])
        self.socket.recv()
```
インスタンスを作ると、
1. Captureとの接続
2. Eye Camera 0の起動
3. Eye Camera 1の停止（僕のにはついていないので）
4. Annotation Capture Pluginの起動
を順に行ってくれます。
必要な準備を一通りやってくれるわけです。

メソッドは以下の通りです。
- `init_recording()` : Recordingの開始
- `stop_recording()` : Recordingの停止
- `send_annotation(label=str)`: strというラベルでのAnnotation入力

使用例としては、こんな感じです。

```python
from time import sleep
pupillabs = PupilLabs('test')
sleep(5)
pupillabs.init_recording()
for i in range(3):
    sleep(1)
    pupillabs.send_annotation('test {}'.format(i))
sleep(1)
pupillabs.stop_recording()
```

# pupil labsからLSLでデータを送る
LabStreamingLayer（LSL)という方式で、pupil labsからPythonへリアルタイムにデータを送れます（[参照](https://github.com/labstreaminglayer/App-PupilLabs)）。
LSLの使い方については別の記事に譲りますが、pupil labsのデータをLSLに流すまでの手順をまとめます。

## 追加で必要なものを揃える
### pylsl
LSLをPythonで使うためのパッケージです。
pipでどうぞ。

```python
pip install pylsl
```

### Pupil LSL Relay Plugin

:::note info

~~2019年10月17日追記：~~
~~アップデートによってなのか、このプラグインもデフォルトで入れてくれているようになったようです。~~
~~恐らくこのセクションに書いてある手続きは不要です。~~
最近のバージョンではDLするモノが変わったので追記しました（2024年9月30日）
:::

Pupil Captureに、LSLへ対応させるためのPluginです。
まず、[こちら](https://github.com/labstreaminglayer/App-PupilLabs)のリポジトリをDLしましょう。
DLしたファイルのうち、`pupil_capture/pupil_capture_lsl_relay`をフォルダごとPupil CaptureのPluginディレクトリに保存します。
（古いバージョンでは、代わりに`pupil_lsl_relay.py`をDLして保存します）

Pluginディレクトリは、下記にあります（[参照](https://docs.pupil-labs.com/#plugin-guide)）。
`C:/Users/（ユーザー名）/pupil_capture_settings/plugins`

続いて、先ほどpipでインストールした`pylsl`を、フォルダごとPluginディレクトリにコピーします。
`pylsl`は、下記ディレクトリにあるはずです。
`C:\Users\（ユーザー名）\AppData\Local\Programs\Python\Python37\Lib\site-packages`

## LSL Pluginを起動する
Pupil Captureを開き、`Plugin Manager`から`Pupil LSL Relay`をクリックします。
あるいは先ほどの応用で、下記のスクリプトを走らせます。

```python
import zmq, msgpack
context = zmq.Context()
socket = context.socket(zmq.REQ)
socket.connect('tcp://localhost:50020')

def send_recv_notification(n, socket):
    socket.send_multipart([('notify.%s'%n['subject']).encode('utf-8'),
                           msgpack.dumps(n)])
    return socket.recv()

n = {'subject': 'start_plugin',
     'name'   : 'Pupil_LSL_Relay'}
print(send_recv_notification(n, socket))
```

オプションとして以下の三つが表示されます。
- Relay pupil data
- Relay gaze data
- Relay notifications
オフにすれば、その情報はLSLで流れてきません。

Pythonからオプションをオフにするためには、下記のようにします。

```python
n = {'subject': 'start_plugin',
     'name'   : 'Pupil_LSL_Relay',
     'args'   : {'relay_pupil'        : True,
                 'relay_gaze'         : True,
                 'relay_notifications': False}}
send_recv_notification(n, socket)
```

これで、瞳孔サイズといった測定データを、リアルタイムにPython上にて取得することができます。
データを受け取るためのPython側からの操作については、別の記事にまとめます。

:::note warn
（2024年9月30日追記）なお、キャリブレーションに成功した後でないとデータが送られないようです。
……昔試したときはそんなことなかった気がするのですが、World Camera付きのやつにしたからかなぁ？
:::

# Pythonでデータを取り出す
（10月17日追記）
Pupil Coreで測定したデータは、`.pldata`や`.npy`といったファイル形式で保存されています。
`.npy`は`np.load()`で読み込めますが、注視位置や瞳孔径といった重要な情報のほとんどは`.pldata`になっているようです。
通常はPupil Playerを起動して、データの保存されているフォルダをドラッグ＆ドロップして、エクスポートを選ぶことで`.csv`へと変換します。

しかし毎回これをやるのはめんどくさい。
PythonからRecord Stopすると同時に`.csv`で指定したディレクトリへ吐き出してほしい。
そこで、`.pldata`を読み取るスクリプトを下記にご紹介します。
詳細なご説明省きますが、だいたいこんな感じで読み込めます。
色々な情報の詰まってる`.pldata`から、タイムスタンプと瞳孔径のデータだe
`pd.DataFrame` として取り出します。

（2020年7月3日追記：
下記のスクリプトは、.pldataの仕様が変わったらしいPupil v2.0では通用しないようです。
修正したものを下に掲載します。）

```python:v1.x用
pldata_dir = （.pldataのあるディレクトリ）
with open(pldata_dir / 'pupil.pldata', 'rb') as f:
    data = [[msgpack.unpackb(payload)[b'timestamp'],
             msgpack.unpackb(payload)[b'diameter'],
             msgpack.unpackb(payload)[b'diameter_3d']]
            for _, payload in msgpack.Unpacker(f)]   
data = pd.DataFrame(data, columns=['timestamp', 'diameter', 'diameter_3d'])
```

Pupil Playerでエクスポートした'gaze_positions.csv'と見比べても一致しています。
…ただし、`gaze_positions.csv`のほうでは始めの3サンプルがなくなっていました。
どういう…？

（2020年7月3日追記）

```python:v2.0用
def pldata2dataframe(plpath, columns, method_is_3d):
    """
    Export .pldata to pandas DataFrame.

    Parameters
    ----------
    plpath: str
        Path to the pupil.pldata.
    columns: list-like
        Items to export from pupil.pldata.
    method_is_3d: bool
        Use data detected by 3D method or 2D method.

    Returns
    -------
    dataframe: pandas.DataFrame
    """

    method = '3' if method_is_3d else '2'
    with open(plpath, 'rb') as f:
        payloads = [payload for _, payload in msgpack.Unpacker(f)
                    if msgpack.unpackb(payload)['method'].startswith('3')]
    data = [[msgpack.unpackb(payload)[col] for col in columns]
            for payload in payloads]
    return pd.DataFrame(data, columns=columns)

plpath = '//XXX//000//pupil.pldata'
columns = ['timestamp', 'diameter', 'diameter_3d']
method_is_3d = True
pldata2dataframe(plpath, columns, method_is_3d)
```

# まとめ
必要となりそうなPupil Captureの操作は、一通りPythonから行うことができました。
わざわざ測定前にPupil Captureをカチカチしなくても、PsychoPyで作った実験課題を起動するだけで準備完了しますね。
Recordの開始忘れといった心配もなくて安心です。

[今回使ったコードのipynbファイル](https://issakuss.github.io/tech/article_10/operate_pupil_by_python.ipynb)


[^1]: 公式ドキュメントには、`send_string()`ではなく`send()`とあるのですが、Python3.xでこれを使うとエラーになります。
[^2]: 公式ドキュメントにエンコードのことは書いていませんが、Python3.xではこれをやらないとエラーになります。
[^3]: `'subject'`でも一見通るのですが、Recording中にAnnotationを入れようとするとフリーズしました。