How to generate gratings (Gabor patches) with two arbitrary colors

OS (e.g. Win10): Win10
PsychoPy version (e.g. 1.84.x): 2021.2.3
Standard Standalone? (y/n) If not then what?: y
What are you trying to achieve?:

Create a grating stimuli with two arbitrary colors (e.g., red and green).

Conceptually, I’m trying to create a method to determine perceptual isoluminance values for red/green using the minimum motion technique. This requires me to sustain a constant luminance for one color (red) while adjusting the luminance of the other color (green) until there is a change in perceived grating motion – http://wexler.free.fr/library/files/anstis%20(1983)%20a%20minimum%20motion%20technique%20for%20judging%20equiluminance.pdf

What did you try to make it work?:
I’ve referred to this google group thread (https://groups.google.com/g/psychopy-users/c/WQfw2PKUHg4), but I don’t think I follow. Jonathan says “No, I’m afraid that gratings alternating between arbitrary colours does require you to create them yourself using an array or an image. PsychoPy’s grating colours are designed to balance around the mean grey.” What does it mean to create an array – how do I create individual pixels such that some are adjustable?

What specifically went wrong when you tried that?:
I don’t know how to follow the above instructions to create a grating (gabor patch) with two arbitrary colors, such that I can manipulate one of them such that it changes from trial to trial. This has also been referred to as “creating a custom texture” in other posts but I cannot figure out how to apply two different colors to a grating.

I’m attaching a sample stimulus I created, except with incorrect colors.

image

Thanks!

Updating here, in case anyone comes across this, that I am making some progress! Will post my solutions here later if it is helpful to anyone.


Just wanted to add my two attempts.
image

test1 is my attempt to generate “custom” gratings where I created rectangles of different colors, and lined them up so they would look like gratings. The downside is that this is very resource-consuming, and slow. Due to this, I am only showing 1/2 a cycle.

Another problem is that I need to create a circular mask to overlay with my “grating”, which I have not figured out how to do yet.

test 2 is cleaner, and runs more smoothly. However, I cannot adjust the luminance of one color independently of the second, unrelated color.

Please let me know if you have any thoughts. Thank you so much!
Minimum Motion Technique_test.psyexp (49.7 KB)

Here is some demo code for one way in which it might be approached (sorry, I’m not sure how to use Builder). This creates a single square-wave GratingStim and then manipulates its parameters and modifies how stimuli are drawn to the screen to produce the desired effects. It particularly relies on using a ‘colour mask’ so that drawing can be limited to a subset of the RGB channels.

It would need a lot of testing to check that it does what is required and it assumes that the monitor is linearised, but might be a starting point for further exploration.

import psychopy.visual
import psychopy.event

import pyglet.gl


def run():

    # stimulus parameters
    red_gain = 0.7
    green_gain = 0.3
    ybrown_contrast = 0.2

    with psychopy.visual.Window(color="black") as win:

        grating = psychopy.visual.GratingStim(
            win=win,
            tex="sqr",
            mask="circle",
            sf=10.0,
            units="height",
            size=0.75,
        )

        # draw a red-green grating
        grating.phase = 0
        draw_rg_grating(
            grating=grating,
            red_gain=red_gain,
            green_gain=green_gain,
        )
        win.flip()
        psychopy.event.waitKeys()

        # advance the phase and draw a yellow-brown grating
        grating.phase += 0.25
        draw_ybrown_grating(grating=grating, contrast=ybrown_contrast)
        win.flip()
        psychopy.event.waitKeys()

        # advance the phase and draw a green-red grating
        grating.phase += 0.25
        draw_rg_grating(
            grating=grating,
            red_gain=red_gain,
            green_gain=green_gain,
        )
        win.flip()
        psychopy.event.waitKeys()

        # advance the phase and draw a brown-yellow grating
        grating.phase += 0.25
        draw_ybrown_grating(grating=grating, contrast=ybrown_contrast)
        win.flip()
        psychopy.event.waitKeys()


def draw_rg_grating(grating, red_gain=1.0, green_gain=1.0):

    # we will be changing these, so keep track of where they start
    start_phase = float(grating.phase[0])
    start_blendmode = grating.blendmode
    start_opacity = grating.opacity

    # when drawing, add to what is currently there
    grating.blendmode = "add"

    # red component

    # only affect the red channel
    pyglet.gl.glColorMask(1, 0, 0, 0)

    # use the opacity to set the gain
    grating.opacity = red_gain

    # draw *twice* to get full luminance at full opacity
    for _ in range(2):
        grating.draw()

    # green component

    # only affect the green channel
    pyglet.gl.glColorMask(0, 1, 0, 0)

    # set the gain of the green bars
    grating.opacity = green_gain

    # change the phase to draw in the gaps between the red bars
    grating.phase += 0.5

    # draw the green bars
    for _ in range(2):
        grating.draw()

    # reset the drawing changes
    pyglet.gl.glColorMask(1, 1, 1, 1)
    grating.blendmode = start_blendmode
    grating.phase = start_phase
    grating.opacity = start_opacity


def draw_ybrown_grating(grating, contrast=0.2):

    # we will be changing these, so keep track of where they start
    start_blendmode = grating.blendmode
    start_contrast = grating.contrast

    grating.blendmode = "avg"
    grating.contrast = contrast

    # only affect the red and green channels
    pyglet.gl.glColorMask(1, 1, 0, 0)

    grating.draw()

    # reset
    pyglet.gl.glColorMask(1, 1, 1, 1)
    grating.blendmode = start_blendmode
    grating.contrast = start_contrast


if __name__ == "__main__":
    run()

Wow, thank you so much, this is exactly what I was trying to do! Thank you for even including the yellow-brown counterpart to the task. Sorry for the many messages across several threads, I was trying to narrow down my question to be more clear. I’m very grateful for your help.

1 Like

No worries. I just noticed an error in my demo in which the grating was being drawn too many times - I have edited the code, please update any working copies that you might have.

Specifically:

    # draw *twice* to get full luminance at full opacity
    for _ in range(2):
        grating.draw()
-       grating.draw()
    # draw the green bars
    for _ in range(2):
        grating.draw()
-       grating.draw()
1 Like

Will do, thank you!

Even with drawing the grating four times, I was able to create a minimum motion technique task to achieve red-green isoluminance, but I’ll delete the second line.

If you have time, I have one more question. From your code, I had assumed that changing the red_gain or green_gain modulated luminance, on a scale from 0 to 1 (in rgb space). Playing around with Microsoft Paint, when these 0 -1 range RGB values are converted to HSL, Hue and Saturation are held constant, while Luminance changes the color from black to highest luminance (for this reason, I had decided not to look into DKL color space, if this is a good rationale?). But I had sort of expected highest luminance to be white, but this ends up also changing Hue/Saturation and luminance is no longer an independent variable.

What is the correct way to achieve isoluminance? Is modulating the colors between black-red, or black-green, on a single dimension sufficient? If there is reading material/papers that you recommend, I would appreciate that as well. Thank you so much.

djmannion.
Why does one need to draw() the grating object TWICE? Thanks for any info provided in response to this inquiry.
FS

Why does one need to draw() the grating object TWICE?

It is a bit odd and a pretty inelegant part of my demo.

I think it is due to psychopy’s pixel representation going from -1 to +1. The background is black (-1) and the square-wave grating is -1 and +1 in the black and white bars, so drawing it once (with blendmode="add") would take the pixels corresponding to the white bars from -1 to 0. Drawing it a second time then takes it from 0 to +1, so to peak output.

Just to check that it is doing what I think it is:

import psychopy.visual
import psychopy.event

with psychopy.visual.Window(color="black") as win:

    grating = psychopy.visual.GratingStim(
        win=win,
        tex="sqr",
        mask="circle",
        sf=10.0,
        units="height",
        size=0.75,
        blendmode="add",
    )

    # draw once, wait
    grating.draw()
    win.flip()
    psychopy.event.waitKeys()

    # draw twice, wait
    grating.draw()
    grating.draw()
    win.flip()
    psychopy.event.waitKeys()
1 Like

Yes, it affects the luminance by changing the strength of the output of the red and green channels, respectively.

It is not something that I am all that familiar with, but I think it would depend on your goal - that is, what it is that you are seeking to be perceptually isoluminant. I think this application of the technique would give you a single red-green pair that is perceptually isoluminant (or perhaps more strictly a single square-wave grating that has a red-green oscillation with a minimal apparent luminance oscillation). For more details on isoluminance, this blog post by Daniel Baker may be useful.

1 Like

Thank you for the clarification on manipulating luminance!

And I appreciate the blog link, that’s the most concise yet thorough explanation of isoluminance I’ve come across. It seems that there are several ways to achieve isoluminance, traversing across a variety of colors spaces. But once isoluminance is achieved, the illusion (either via MM or HFP) is stable. I hope that interpretation makes sense.

And from reading related papers that use isoluminant stimuli, they don’t seem to care about spatial frequency of their stimuli (in the way that people that calibrate contrast sensitivity do), so I hope this assumption is correct and the isoluminance of the square-wave grating is generalizable to all red-green stimuli, at least to that subject – if anyone comes across this and thinks this is incorrect, please let me know.