Windows 10 Display Timing

Originally posted on  March 22, 2019.

It looks like you need to be careful with your display settings in Windows 10, otherwise Windows 10 could be adding an extra frame delay to the display update times reported by your experiment software.

We have found that if the Windows 10 Display -> Scale (and layout) setting is not set to 100%, the Windows 10 operating system adds an extra frame delay to the actual monitor update time. When using a 70 Hz monitor for example, this adds an extra 14.3 msec delay, causing the reported display update time to be off by one retrace as well.

Windows 10 Monitor Scale Setting @ 100% results in expected display change timing.
Windows 10 Monitor Scale Setting @ 125% results in extra 1 frame (monitor retrace) delay.

Procedure

Using the MilliKey DeLux light sensor upgrade and PsychoPy3 we compared the display update time reported by psychopy.visual.Window.flip() to the time the experiment received a 1000 Hz USB serial event triggered by the MilliKey DeLux light sensor in response to each screen brightness change.

In this test we used was a Dell G5 15 laptop running Windows 10 with NVIDIA graphics connected to a SyncMaster P2770 LCD monitor. The stated response time of the SyncMaster P2770 is 1 msec, which probably means it is closer to 2 msec peak to peak.

A Python 3 test script used the open source PsychoPy3 experiment package to make 1000 dark -> light display changes, recording the flip() time of each dark->light change. The same script was connected to a MilliKey with the DeLux light sensor via USB Serial.

The MilliKey DeLux was configured to generate a USB Serial event after the DeLux light sensor detected the monitor brightness crossed a digital threshold level. The appropriate light sensor threshold level for the monitor being tested was set by the test script itself.

The MilliKey DeLux can be configured to send the USB Serial event a fixed number of msec after the actual light sensor trigger time, called the trigger offset. In this test we set the trigger offset to 7 msec. This allows the USB Serial event to be sent, and received, by the experiment program at a time when the program can rapidly receive and process the event.

The same test script and hardware was used in both a 100% and 125% scaling condition. The time difference between receiving the DeLux USB serial trigger event and the time reported from the win.flip() for each dark-light transition was calculated, subtracting the 7 msec offset the DeLux was configured to use.

Results

USB serial events sent by the MilliKey DeLux will add an average of 0.5 msec to these display change delay results. This has not been corrected for in the following plots.

Average Display Change Delay (Error) Bars and CI lines. Using 125% scaling causes an extra frame delay.

100% Scaling

Display change latency of SyncMaster P2770 LCD monitor @ 100% scaling setting is reasonable given LCD response time . Tested using MilliKey DeLux light sensor.

Display Update Delay Stats
Count: (1000,)
Average: 2.335 msec
Median: 2.231 msec
Min: 1.000 msec
Max: 4.556 msec
Stdev: 0.517 msec

When 100% scaling is being used, the display update time reported by win.flip() looks accurate given the LCD monitor response time.

Note that the bi-modal nature of the above delay distribution, with peaks at approximately 2 and 3 msec, is likely a result of quantization caused by the 1000 Hz MilliKey USB serial report rate.

125% Scaling

Extra frame delay can be seen when Windows 10 Display Scaling setting is @ 125%. Tested using MilliKey DeLux light sensor.

The results clearly show that the Windows 10 operating system is adding an extra frame delay when the Display Scaling setting is equal to 125%.

Code

The test script is based on one of the MilliKey DeLux Python examples that can be downloaded from our website. The test procedure logic is in the runDisplayTimingTest() function.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function
from psychopy import visual, event, core
import json
import serial
import numpy
import time

# Serial Port of MilliKey.
#mK_seria1_port = '/dev/cu.usbmodem4998701'
mK_seria1_port = 'COM112'

# Number of dark -> light display change tests to run
test_count = 1000

# Number of light frames to display after dark->light transition
light_frame_count = 10

# Number seconds to wait between each test iteration.
iti=0.4

# Number of msec MilliKey waits before sending serial trigger after 
# light threshold is crossed. 
trig_delay=7

# Maximum number of trigger events MilliKey will send on each test iteration. 
# Useful for limiting the trigger repeating every retrace on CRT monitors for
# example.
max_trig_count =  2

# True == Run simple auto threshold leveling routine before running the test.
# False == Use threshold level stored in MilliKey device.
autothresh = True

# Multiplier for calculating threshold level from dark screen max reading
# For LCD, suggest 1.5 - 2.5
# For CRT, suggest 5.0 - 10.0
thresh_scaling_factor = 2

TRIG_RISING_SERIAL = 1

def runDisplayTimingTest():
    results = numpy.zeros(test_count, dtype=numpy.float64)
    mk_sconn = init_millikey_delux()
    win, (white_stim, dark_stim, txt_stim1, txt_stim2) = initPsychoPyGraphics()

    start_key = event.waitKeys()

    if autothresh:
        lowlight_stats = getLightSensorStats(mk_sconn, win, dark_stim)
        ls_threshold = lowlight_stats.get('max')*thresh_scaling_factor
        print("Using threshold level %d for test.\n"%(ls_threshold))

    print("test_num\ttrig_rx_time\tflip_time\ttrig_delay\tdisplay_latency\ttrig_msg")

    # run test_count loops
    count=0
    while count < test_count:
        # Draw black screen and flip to it.
        dark_stim.draw()
        win.flip()
        # Wait ~iti seconds with display black, starting light sensor
        # 1/2 way through.
        time.sleep(iti/2)        
        mk_sconn.write(b"START_AIN %d\n"%(ls_threshold))
        core.wait(iti/2)
        flush_serial_rx(mk_sconn)
        # Draw white (target) stim and flip to it,
        # getting time flip occurred (stim onset)
        # time from psychopy.
        white_stim.draw()
        ftime = win.flip()

        reading = mk_sconn.readline()
        if reading:
                t, d = core.getTime(), json.loads(reading[:-2])
                tdelay = d['delay']/1000.0
                results[count]= t-ftime-tdelay
                if d['count'] > 1:
                    print("Warning: Trigger number %d received; expecting 1"%(d['count']))
                print("%d\t%.4f\t%.4f\t%.4f\t%.4f\t%s"%(count+1, t, ftime,
                                                    tdelay, results[count],str(d)))
        else:
            print("Error: MilliKey Serial Timeout.")
            win.close()
            mk_sconn.close()
            raise RuntimeError("Error: MilliKey Serial Timeout.")


        # Display white screen for n frames.
        for lf in range(light_frame_count):
            white_stim.draw()
            win.flip()

        mk_sconn.write(b"STOP_AIN\n")
        count+=1

    win.close()
    mk_sconn.close()
    return results

def sendSerial(mk_sconn, txdata, wait=0.050, is_json=True):
    rx = None
    mk_sconn.write("{}\n".format(txdata).encode('utf-8'))
    stime = core.getTime()
    rx = mk_sconn.readline()
    while len(rx) == 0 and core.getTime()-stime < wait:
        rx = mk_sconn.readline()
    if rx and is_json:
        try:
            return json.loads(rx[:-2])
        except:
            return rx[:-2]
    if rx:
        return rx[:-2]

def flush_serial_rx(mk_sconn):
    while mk_sconn.readline():
        pass
    
def getLightSensorStats(mk_sconn, win, stim):
    for i in range(4):
        stim.draw()
        win.flip()
    mk_sconn.write(b"START_AIN\n")
    for i in range(10):
        stim.draw()
        win.flip()
    mk_sconn.write(b"STOP_AIN\n")
    mk_sconn.write(b"GET AIN_STATS\n")
    stats = mk_sconn.readline()
    return json.loads(stats)

def init_millikey_delux():
    mk_sconn = serial.Serial(mK_seria1_port, baudrate=128000, timeout=0.05)
    print(sendSerial(mk_sconn, 'SET AIN_TRIG_DELAY %d'%(trig_delay),
                     is_json=False))
    print(sendSerial(mk_sconn, 'SET AIN_TRIG_MODE %d %d'%(TRIG_RISING_SERIAL,
                                                          max_trig_count),
                                                          is_json=False))

    ain_stats= sendSerial(mk_sconn, "GET AIN_STATS")
    ain_res = ain_stats.get('res')
    if ain_res > 100: # ain_res < 100 means light sensor is connected
        print("Error Light Sensor is not connected to Analog Input Jack.")
        mk_sconn.close()
        raise RuntimeError("Analog Input Error. Check connection.")
    return mk_sconn

def initPsychoPyGraphics():
    # Create a full screen window and two full screen stim.
    win = visual.Window(fullscr=True, screen=0)
    white_stim = visual.ShapeStim(win, lineColor='white', fillColor='white',
                                  vertices=((-1,-1),(1,-1),(1,1),(-1,1)))
    dark_stim = visual.ShapeStim(win, lineColor='black', fillColor='black',
                                 vertices=((-1,-1),(1,-1),(1,1),(-1,1)))
    txt_stim1 = visual.TextStim(win,
                                "Place DeLux Light Sensor in Top Left Corner.",
                                color=(0.0,1.0,0.0), height=0.05)
    txt_stim2 = visual.TextStim(win, "Press any Key to Start Test.",
                                pos=(0.0,-0.1), color=(0.0,1.0,0.0),
                                height=0.05)
    white_stim.draw()
    dark_stim.draw()
    txt_stim1.draw()
    txt_stim2.draw()
    win.flip()
    return win, (white_stim, dark_stim, txt_stim1, txt_stim2)

# Plot Results
def createHistogram(data, title="Display\ Update\ Delay", 
                    xlabel="Delay (milliseconds)",
                    ylabel="Probability density", nbins = 50):
    try:
        import matplotlib.pyplot as plt
        mu, sigma = data.mean(), data.std()
        # the histogram of the data
        n, bins, patches = plt.hist(data, nbins, density=1,
                                    facecolor='green', alpha=0.75)
        # add a 'best fit' line
        y = ((1 / (numpy.sqrt(2 * numpy.pi) * sigma)) *
             numpy.exp(-0.5 * (1 / sigma * (bins - mu))**2))
        plt.plot(bins, y, '--')
        plt.xlabel(xlabel)
        plt.ylabel(ylabel)
        if title is None:
            title = r"Histogram"
        title = r'$\mathrm{%s:}\ \mu=%.3f,\ \sigma=%.3f$'%(title,mu,sigma)
        plt.title(title)
        plt.grid(True)
        plt.tight_layout()
        plt.show()
    except:
        print("Could not create Histogram:\n\ttitle: {}\n\tlabel: {}\n\tylabel: {}".format(title, xlabel, ylabel))
    
if __name__ == '__main__':
 results = runDisplayTimingTest()
 
    # Convert times to msec.
    results = results * 1000.0
    
    print('\n\n')
    print("Display Update Delay Stats")
    print("\tCount: {}".format(results.shape))
    print("\tAverage: {:.3f} msec".format(results.mean()))
    print("\tMedian: {:.3f} msec".format(numpy.median(results)))
    print("\tMin: {:.3f} msec".format(results.min()))
    print("\tMax: {:.3f} msec".format(results.max()))
    print("\tStdev: {:.3f} msec".format(results.std()))
    
    createHistogram(results)

Conclusions

Next time you go to collect data on a Windows 10 machine, we would suggest you first make sure to check the Windows 10 Display Scaling setting and ensure it is at 100%.

It is probably worth pointing out that the extra delay seen in the 125% scaling condition does not effect stimulus duration: it adds one retrace interval to the start and end time of a stimulus.

There are likely other conditions that could cause the display timing of your experiment computer to be different than what you think or assume it to be. Therefore, the best option in our opinion is to test your own experiment setup on a regular basis. This is not as hard to do, time consuming, or expensive as you might think. Please checkout the MilliKey DeLux and consider using it the next time you need to do some timing validation of your experiment setup. Testing display change timing has never been easier and more affordable!

Happy Hacking!