PsychoPyで フレームレート関係の整理 / M2 MacBook Airでおかしい

使用環境のリフレッシュレートを確認したい。

使用環境
 - MacBook Air M2, 2022
 - OS : Ventura 13.6.7

MacBook Air 2022はLiquid Retinaディスプレイを採用していて、そのリフレッシュレートは可変で、最大120Hzでるらしい。（しかし、出典がみつけられず60Hzかもしれない）

というわけで下記のコードで試してみた。

    from psychopy import visual

    myWin = visual.Window([600,600], monitor="testMonitor", units="deg")

    rr  = myWin.getActualFrameRate(nIdentical=100, nMaxFrames=1000, nWarmUpFrames=100, threshold=10)
    print("Refresh Rate", rr)

引数をデフォルトの状態で実行したら、結果が None になってしまったので、すべて10倍にした（適当すぎ）。nMaxFramesを増やしたり、キツすぎるtheresholdを緩和したりするとよろしいと思います。
引数の説明は以下。

 - https://psychopy.org/api/visual/window.html
 
 - https://psychopy.org/_modules/psychopy/visual/window.html#Window.getActualFrameRate


デフォルト値： 
 - nIdentical=10, 
 - nMaxFrames=100, 
 - nWarmUpFrames=10, 
 - threshold=1, 
 - infoMsg=None （必須パラメータではない）

このフレームレートの計測は、連続した nIdentical枚 のフレームの間、同一のフレーム時間になるまで待つ（ただし最大でnMaxFrames枚）こよでなされる。同一のフレーム時間とは標準偏差がthreshold（閾値） ミリ秒より下であるという事。

Parameters:
 - nIdentical (int, optional): 評価のための連続したフレームの数。数値が大きいほど正確で、小さいほど早い
 - nMaxFrames (int, optional): 一連の nIdentical枚のフレームにマッチするまでに待つ最大のフレーム数
 - nWarmUpFrames (int, optional): テストをスタートする前に表示するフレームの数（これははじめてwindowを開いたあとにシステムが安定させるためにおかれます）
 - threshold (int or float, optional): 一連のフレームがマッチしたと判定するための標準偏差に対する閾値（ミリ秒）
 - infoMsg: 計測中に表示するメッセージ（指定しなければ「Attempting to measure frame rate of screen, please wait ...」が表示される。

結果はこんな感じである。

    ########### Running: /Users/<usr>/git/<repo>/refresh_rate.py ############
    2024-06-24 17:51:34.028 python[81016:4561891]     
    ApplePersistenceIgnoreState: Existing state will not be touched. New state will be written to /var/folders/nn/c08xd2bx3nxcrpgyqrxtydhr0000gn/T/org.opensciencetools.psychopy.savedState
    Refresh Rate 119.12778211356974
    3.7681     WARNING     Couldn't measure a consistent frame rate!
      - Is your graphics card set to sync to vertical blank?
      - Are you running other processes on your computer?

というわけで、 119.1277... とほぼ 120Hz である。ApplePersistenceIgnoreState〜 とか、WARNING は気にしない方向で。

しかし、どうも120Hzはでていないようなのである。次に、実際にflipさせてその差分を計測する。

    #coding: utf-8
    from psychopy import visual, core
    
    myWin = visual.Window([300,300], monitor="testMonitor", units="deg")
    
    waiting_time = 1.0
    msg = visual.TextStim(myWin, text = u'%.1f秒待ってね' % waiting_time, pos=(0, 0), font = "Hiragino Kaku Gothic Pro W3")
    # windows だと fontのところは削除しないとだめかも

    #計測用タイマー
    timer = core.Clock()

    t = 0
    last_time = timer.getTime()

    while t < waiting_time:
        #テキストを描画してflip
        msg.draw()
        myWin.flip()
    
        #flip直後の時間を取得して一個前のループの時と比較
        t = timer.getTime()
        loop_delta = (( t - last_time )*1000)
        fps = 1000/loop_delta
        print( 'delta %2.2f ms, ' % loop_delta, 'fps(calculated) %2.2f Hz' % fps )
    
        #次のループのためにlast_timeを更新
        last_time = t

これを実行すると次のような結果になる。

    delta 5.95 ms. fps(calculated) 168.06 Hz
    delta 10.65 ms, fps(calculated) 93.89 Hz
    delta 4.67 ms, fps(calculated) 214.22 Hz
    delta 11.97 ms, fps(calculated) 83.54 Hz
    delta 2.26 ms, fps(calculated) 443.38 Hz
    delta 14.43 ms, fps(calculated) 69.30 Hz

というわけで、flipの時間間隔が振動するのだ。これ、２つ足すと16.7msぐらいになるので、プログラムの一部を次のように書き換えて、１ループで２回flipするようにしてみた。

    #テキストを描画してflip
    msg.draw()
    myWin.flip()
    msg.draw() # ２回flip
    myWin.flip()

すると、

    delta 16.53 ms, fps(calculated) 60.50 Hz
    delta 16.63 ms, fps(calculated) 60.12 Hz
    delta 16.70 ms, fps(calculated) 59.88 Hz
    delta 16.67 ms, fps(calculated) 60.00 Hz

と、ほぼ理論値の16.66...ms、60Hzで安定した。このことから、Macbook Airの可変なリフレッシュレートが影響していると思われる。ここでは示さないが、全画面表示すると動きが速くなったり遅くなったりした。これは、Macが勝手に必要なリフレッシュレートを判断して制御しているからだと思われる。

M2 MacBook Airで刺激提示する場合は、１ループで２回描画する処理をする対策で、リフレッシュレートを安定させる方法が良さそう。120Hz出すのは諦めよう。

ちなみに、 このpsychopy.visual.windowオブジェクトには fps() という fpsを計測する関数があり、実行されるたびに、前に実行し時との差分からfpsを計算して出力するものである。

これを、flip １回で実行すると

    while t < waiting_time:
        #テキストを描画してflip
        msg.draw()
        myWin.flip()
        print(myWin.fps())

結果はガタガタ

    245.85627676426404
    79.69741760818927
    528.4247686570736
    67.47789931341735

これを、flip 2回で実行すると

    while t < waiting_time:
        #テキストを描画してflip
        msg.draw()
        myWin.flip()
        msg.draw()
        myWin.flip()
        print(myWin.fps())

なぜか120Hzになる

    119.931643496873
    120.16432480433498
    119.80621332724283
    120.29894293676324

ああ、fps()の実装がこんな感じだからか・・・（実行されるたびに、経過したフレーム数を経過時間で割るから）

    def fps(self):
        """Report the frames per second since the last call to this function
        (or since the window was created if this is first call)"""
        fps = self.frames / self.frameClock.getTime()
        self.frameClock.reset()
        self.frames = 0
        return fps


Macに直接リフレッシュレートなり描画のタイミングを指示できてばこの問題を回避できると思いますが、今の私の知識ではわからないであります。PsychoPyにそういうコマンドがあるのか、より低レベルなpygameのコードを書くのかそれ以外か。

Macを使っていて似たような症状の方がいたら、コメントいただけると幸いです。