Source code for psychopy_visionscience.secondorder

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""Stimulus object for drawing arbitrary bitmap carriers with an arbitrary
second order envelope carrier and envelope can vary independently for
orientation, frequencyand phase. Also does beat stimuli. """

# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2022 Open Science Tools Ltd..
# Additional code provided by Andrew Schofield
# Distributed under the terms of the GNU General Public License (GPL).

# Requires shaders if you don't have them it will just throw and error.
# Ensure setting pyglet.options['debug_gl'] to False is done prior to any
# other calls to pyglet or pyglet submodules, otherwise it may not get picked
# up by the pyglet GL engine and have no effect.
# Shaders will work but require OpenGL2.0 drivers AND PyOpenGL3.0+
import pyglet
pyglet.options['debug_gl'] = False
import ctypes
GL = pyglet.gl

import psychopy  # so we can get the __path__
from psychopy import logging

from psychopy.tools.arraytools import val2array
from psychopy.tools.attributetools import attributeSetter
from psychopy.visual.grating import GratingStim
import numpy

from psychopy.visual import shaders as _shaders

# we need a different shader program for this stimuli (3 textures)
carrierEnvelopeMaskFrag = '''
    uniform sampler2D carrier, envelope, mask;
    uniform float moddepth, offset, ori, add;

    void main() {
        float mid=0.5;
        vec4 carrierFrag = texture2D(carrier,gl_TexCoord[0].st);
        vec4 maskFrag = texture2D(mask,gl_TexCoord[2].st);
        vec2 tex = gl_TexCoord[1].st;

        vec2 rotated = vec2(cos(ori) * (tex.x - mid ) + sin(ori) * (tex.y - mid) + mid , cos(ori) * (tex.y - mid) - sin(ori) * (tex.x - mid) + mid );
        vec4 envFrag = texture2D( envelope,  rotated);
        gl_FragColor.a = gl_Color.a*maskFrag.a;
        gl_FragColor.rgb = ((moddepth*envFrag.rgb + offset)*carrierFrag.rgb*(gl_Color.rgb*2.0 - 1.0) + add)/2.0;
    }
    '''

# we need a different shader program for this (4 textures including the power term)
carrierEnvelopeMaskFragPow = '''
    uniform sampler2D carrier, envelope, mask, power;
    uniform float moddepth, offset, ori, add;

    void main() {
        float mid=0.5;
        vec4 carrierFrag = texture2D(carrier,gl_TexCoord[0].st);
        vec4 maskFrag = texture2D(mask,gl_TexCoord[2].st);
        vec4 powerFrag = texture2D(power,gl_TexCoord[2].st);
        vec2 tex = gl_TexCoord[1].st;

        vec2 rotated = vec2(cos(ori) * (tex.x - mid ) + sin(ori) * (tex.y - mid) + mid , cos(ori) * (tex.y - mid) - sin(ori) * (tex.x - mid) + mid );
        vec4 envFrag = texture2D( envelope,  rotated);
        gl_FragColor.a = gl_Color.a*maskFrag.a;
        gl_FragColor.rgb = (pow((moddepth*envFrag.rgb + offset), powerFrag.rgb)*carrierFrag.rgb*(gl_Color.rgb*2.0 - 1.0) + add)/2.0;
    }
    '''


[docs] class EnvelopeGrating(GratingStim): """Second-order envelope stimuli with 3 textures; a carrier, an envelope and a mask **Examples** env1 = EnvelopeGrating(win,ori=0, carrier='sin', envelope='sin', mask = 'gauss', sf=24, envsf=4, size=1, contrast=0.5, moddepth=1.0, envori=0, pos=[-.5,.5],interpolate=0) # gives a circular patch of high frequency carrier with a # low frequency envelope env2 = EnvelopeGrating(win,ori=0, carrier=noise, envelope='sin', mask = None, sf=1, envsf=4, size=1, contrast=0.5, moddepth=0.8, envori=0, pos=[-.5,-.5],interpolate=0) # If noise is some numpy array containing random values gives a # patch of noise with a low frequency sinewave envelope env4 = EnvelopeGrating(win,ori=90, carrier='sin', envelope='sin', mask = 'gauss', sf=24, envsf=4, size=1, contrast=0.5, moddepth=1.0, envori=0, pos=[.5,.5], beat=True, interpolate=0) # Setting beat will create a second order beat stimulus which # critically contains no net energy at the carrier frequency # even though it appears to be present. In this case carrier # and envelope are at 90 degree to each other With an EnvelopeStim the carrier and envelope can have different spatial frequencies, phases and orientations. Its position can be shifted as a whole. contrast controls the contrast of the carrier and moddepth the modulation depth of the envelope. contrast and moddepth must work together, for moddepth=1 the max carrier contrast is 0.5 otherwise the displayable range will be exceeded. If moddepth < 1 higher contrasts can be accommodated. Opacity controls the transparency of the whole stimulus. Because orientation is implemented very differently for the carrier and envelope using this function without a broadly circular mask may produce unexpected results **Using EnvelopeStim with images from disk (jpg, tif, png, ...)** Ideally texture images to be rendered should be square with 'power-of-2' dimensions e.g. 16x16, 128x128. Any image that is not will be upscaled (with linear interpolation) to the nearest such texture by PsychoPy. The size of the stimulus should be specified in the normal way using the appropriate units (deg, pix, cm, ...). Be sure to get the aspect ratio the same as the image (if you don't want it stretched!). """ def __init__(self, win, carrier="noise", mask="none", envelope="sin", anchor="center", units="", pos=(0.0, 0.0), size=None, sf=None, envsf=None, ori=0.0, envori=0.0, phase=(0.0, 0.0), envphase=(0.0, 0.0), beat=False, texRes=128, rgb=None, dkl=None, lms=None, color=(1.0, 1.0, 1.0), colorSpace='rgb', contrast=0.5, # see doc moddepth=1.0, # modulation depth for envelope power=1.0, # power to raise modulator to. opacity=1.0, depth=0, rgbPedestal=(0.0, 0.0, 0.0), interpolate=False, blendmode='avg', name=None, autoLog=None, autoDraw=False, maskParams=None): """ """ # Empty docstring. All doc is in attributes # what local vars are defined (these are the init params) for use by # __repr__ assert win._haveShaders is True, ("Currently EnvelopeGratings needs " "your graphics card to have shaders" " and yours does not seem to.") self._initParams = dir() for unecess in ['self', 'rgb', 'dkl', 'lms']: self._initParams.remove(unecess) # initialise parent class GratingStim.__init__(self, win, units=units, pos=pos, size=size, sf=sf, ori=ori, phase=phase, anchor=anchor, color=color, colorSpace=colorSpace, contrast=contrast, opacity=opacity, depth=depth, interpolate=interpolate, name=name, autoLog=autoLog, autoDraw=autoDraw, maskParams=None) # UGLY HACK: Some parameters depend on each other for processing. # They are set "superficially" here. # TO DO: postpone calls to _createTexture, setColor and # _calcCyclesPerStim whin initiating stimulus #AJS self.__dict__['carrier'] = carrier self.__dict__['envelope'] = envelope self.__dict__['maskParams'] = maskParams # initialise textures and masks for stimulus self._carrierID = GL.GLuint() GL.glGenTextures(1, ctypes.byref(self._carrierID)) self._envelopeID = GL.GLuint() GL.glGenTextures(1, ctypes.byref(self._envelopeID)) self._powerID = GL.GLuint() GL.glGenTextures(1, ctypes.byref(self._powerID)) self.interpolate = interpolate del self._texID # created by GratingStim.__init__ self.texRes = int(texRes) self.mask = mask self.envelope = envelope self.carrier = carrier self.envsf = val2array(envsf) self.envphase = val2array(envphase, False) self.envori = float(envori) self.moddepth = float(moddepth) self.power = float(power) if beat in ['True','true','Yes','yes','Y','y']: self.beat = True elif beat in ['False','false','No','no','N','n']: self.beat = False else: self.beat = bool(beat) self._needUpdate = True self.blendmode=blendmode self._shaderProgBeat = _shaders.compileProgram( _shaders.vertSimple, carrierEnvelopeMaskFrag) self._shaderProgPow = _shaders.compileProgram( _shaders.vertSimple, carrierEnvelopeMaskFragPow) self.local = numpy.ones((texRes, texRes), dtype=numpy.ubyte) self.local_p = self.local.ctypes # fix scaling to window coords self._calcEnvCyclesPerStim() del self.__dict__['tex'] @attributeSetter def envsf(self, value): """Spatial frequency of the envelope texture Should be a :ref:`x,y-pair <attrib-xy>` or :ref:`scalar <attrib-scalar>` or None. If `units` == 'deg' or 'cm' units are in cycles per deg or cm as appropriate. If `units` == 'norm' then sf units are in cycles per stimulus (and so envsf scales with stimulus size). If texture is an image loaded from a file then envsf=None defaults to 1/stimSize to give one cycle of the image. Note sf is inhertited from GratingStim and controls the spatial frequency of the carrier """ # Recode phase to numpy array if value is None: # set the sf to default e.g. 1./size of the loaded image etc if (self.units in ['pix', 'pixels'] or self._origSize is not None and self.units in ['deg', 'cm']): value = 1.0/self.size # default to one cycle else: value = numpy.array([1.0, 1.0]) else: value = val2array(value) # Set value and update stuff self.__dict__['envsf'] = value self._calcEnvCyclesPerStim() self._needUpdate = True @attributeSetter def envphase(self, value): """Phase of the modulation in each dimension of the envelope texture. Should be an :ref:`x,y-pair <attrib-xy>` or :ref:`scalar <attrib-scalar>` **NB** phase has modulus 1 (rather than 360 or 2*pi) This is a little unconventional but has the nice effect that setting phase=t*n drifts a stimulus at n Hz Note phase in inherited from GratingStim and controls the phase of the carrier """ # Recode phase to numpy array value = val2array(value) self.__dict__['envphase'] = value self._needUpdate = True @attributeSetter def moddepth(self, value): """Modulation depth or 'contrast' for the envelope component (0.0 - 1.0) Sets the range of variation in carrier contrast. MD=(Cmax-Cmin)/(Cma+Cmin) """ self.__dict__['moddepth'] = value self._needUpdate = True @attributeSetter def envori(self, value): """The orientation for the envelope texture (in degrees). Should be a single value (scalar). Operations are supported. Orientation convention is like a clock: 0 is vertical, and positive values rotate clockwise. Beyond 360 and below zero values wrap appropriately. Note ori is inhertied from gratingStim and controls only the orientation of the carrier """ self.__dict__['envori'] = value self._needUpdate = True @attributeSetter def beat(self, value): """Beat (True) stimulus or full moduaiton (False) 'The differences is that the spatial frequency components of the carrier are absent in a beat (although the carrier will still be a visible component of the overall image) whereas they are present in a full modulation. Beats will always appear to have a 100% modulation depth and if sinusoidal the modulation will appear to be twice the requested spatial frequency. The modulation depth of fully modulated stimuli can be varied and they appear at their true frequency. Both beats and full modulations appear in the literature and have specific uses. """ if value in ['True','true','Yes','yes','Y','y']: self.__dict__['beat'] =True elif value in ['False','false','No','no','N','n']: self.__dict__['beat'] =False else: self.__dict__['beat'] =bool(value) self._needUpdate = True #@attributeSetter #def blendmode(self, value): # """The OpenGL mode in which the stimulus is draw # # Can the 'avg' or 'add'. Average (avg) places the new stimulus over the old one # with a transparency given by its opacity. Opaque stimuli will hide other stimuli # transparent stimuli won't. Add performs the arithmetic sum of the new stimulus and the ones # already present. # # """ # self.__dict__['blendmode'] = value # self._needUpdate = True @attributeSetter def carrier(self, value): """Texture to use in the stimulus as a carrier, typically noise array. This can be one of various options: + the name of an image file (most formats supported) + a numpy array (1xN or NxN) ranging -1:1 If specifying your own texture using an image or numpy array you should ensure that the image has square power-of-two dimesnions (e.g. 256 x 256). If not then PsychoPy will upsample your stimulus to the next larger power of two. """ self._createTexture(value, id=self._carrierID, pixFormat=GL.GL_RGB, stim=self, res=self.texRes) # if user requested size=None then update the size for new stim here if hasattr(self, '_requestedSize') and self._requestedSize is None: self.size = None # Reset size do default self.__dict__['carrier'] = value self._needTextureUpdate = False @attributeSetter def envelope(self, value): """Texture to use on the stimulus as a envelope, typically a grating. This can be one of various options: + **'sin'**,'sqr', 'saw', 'tri', None (resets to default) + the name of an image file (most formats supported) + a numpy array (1xN or NxN) ranging -1:1 If specifying your own texture using an image or numpy array you should ensure that the image has square power-of-two dimesnions (e.g. 256 x 256). If not then PsychoPy will upsample your stimulus to the next larger power of two. """ self._createTexture(value, id=self._envelopeID, pixFormat=GL.GL_RGB, stim=self, res=self.texRes) # if user requested size=None then update the size for new stim here if hasattr(self, '_requestedSize') and self._requestedSize is None: self.size = None # Reset size do default self.__dict__['envelope'] = value self._needTextureUpdate = False @attributeSetter def power(self, value): """Will raise the modulator to the given power using the equation S=C*(1+M)^power where C is the carrier and M is the modulator. Only works with AM envelopes (hence +1) in equation. Power is ignored if a beat is requested. This is used to obtain the square root of the modulator (power = 0.5) which is useful if combining two envelope gratings with different carriers and a 180 degree phase shift as the resulting combined signal will not have any reduction in local contrast at any point in the image. This is similar - but not identical to the method used by Landy and Oruc, Vis Res 2002. Note overall contrast (apparent carrier contrast) will be altered. """ self._createTexture( numpy.ones((self.texRes,self.texRes)) * value, id=self._powerID, pixFormat=GL.GL_RGB, stim=self, res=self.texRes) # if user requested size=None then update the size for new stim here if hasattr(self, '_requestedSize') and self._requestedSize is None: self.size = None # Reset size do default self.__dict__['power'] = value self._needTextureUpdate = False @attributeSetter def texRes(self, value): """Power-of-two int. Sets the resolution of the mask and texture. texRes is overridden if an array or image is provided as mask. :ref:`Operations <attrib-operations>` supported. """ self.__dict__['texRes'] = value # ... now rebuild textures (call attributeSetters without logging). if hasattr(self, 'carrier'): self._set('carrier', self.carrier, log=False) if hasattr(self, 'envelope'): self._set('envelope', self.envelope, log=False) #if hasattr(self, 'power'): # self._set('power', self.power, log=False) if hasattr(self, 'mask'): self._set('mask', self.mask, log=False)
[docs] def setTexRes(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('texRes', value, log=log)
[docs] def setEnvori(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('envori', value, log=log)
[docs] def setEnvsf(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('envsf', value, log=log)
[docs] def setEnvphase(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('envphase', value, log=log)
[docs] def setModdepth(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('moddepth', value, log=log)
[docs] def setPower(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self.power = numpy.ones(self.texRes,self.texRes)*value
[docs] def setBeat(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self._set('beat', value, log=log)
[docs] def setCarrier(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self.carrier = value
[docs] def setEnvelope(self, value, log=None): """DEPRECATED. Use 'stim.parameter = value' syntax instead """ self.envelope = value
#def setBlendmode(self, value, log=None): # """DEPRECATED. Use 'stim.parameter = value' syntax instead # """ # self._set('blendmode', value, log=log) #def draw(self, win=None): # """Draw the stimulus in its relevant window. You must call # this method after every MyWin.flip() if you want the # stimulus to appear on that frame and then update the screen # again. # """ # if win is None: # win = self.win # saveBlendMode=win.blendMode # win.blendMode=self.blendmode # self._selectWindow(win) # # super(EnvelopeGrating, self).draw(win) # win.blendMode=saveBlendMode def _updateListShaders(self): """ The user shouldn't need this method since it gets called after every call to .set() Basically it updates the OpenGL representation of your stimulus if some parameter of the stimulus changes. Call it if you change a property manually rather than using the .set() command """ # make some corrections for the envelope:: This could be done whenever # the envelope variables are set using some internal variables, # putting it here is safer but sometimes slower # correct envelope orientation to adjust for link with carrier # orientation, to make it clockwise handed and convert to radians envrad = (self.ori - self.envori) * numpy.pi / 180.0 # adjust envelope phases so that any envelope drift points # in the same direction as the envelope. rph1 = (numpy.cos(envrad) * self.envphase[0] + numpy.sin(envrad) * self.envphase[1]) rph2 = (-numpy.cos(envrad) * self.envphase[1] + numpy.sin(envrad) * self.envphase[0]) # we need a corrective offset when the blendmode is set to average if self.blendmode=='avg': addvalue = 1.0 else: addvalue = 0.0 # setup the shaderprogram if self.beat: self._shaderProg = self._shaderProgBeat else: self._shaderProg = self._shaderProgPow self._needUpdate = False GL.glNewList(self._listID, GL.GL_COMPILE) GL.glUseProgram(self._shaderProg) # set the carrier to be texture unit 0 GL.glUniform1i(GL.glGetUniformLocation(self._shaderProg, b"carrier"), 0) # set the envelope to be texture unit 1 GL.glUniform1i(GL.glGetUniformLocation( self._shaderProg, b"envelope"), 1) GL.glUniform1i(GL.glGetUniformLocation( self._shaderProg, b"mask"), 2) # mask is texture unit 2 GL.glUniform1i(GL.glGetUniformLocation( self._shaderProg, b"power"), 3) # power is texture unit 3 GL.glUniform1f(GL.glGetUniformLocation( self._shaderProg, b"moddepth"), self.moddepth) GL.glUniform1f(GL.glGetUniformLocation( self._shaderProg, b"ori"), envrad) # CM envelopes use (modedepth*envelope+1.0)*carrier. If beat is True # this becomes (moddepth*envelope)*carrier thus maing a second order # 'beat' pattern. if self.beat: GL.glUniform1f(GL.glGetUniformLocation( self._shaderProg, b"offset"),0.0) else: GL.glUniform1f(GL.glGetUniformLocation( self._shaderProg, b"offset"), 1.0) GL.glUniform1f(GL.glGetUniformLocation( self._shaderProg, b"add"), addvalue) # mask GL.glActiveTexture(GL.GL_TEXTURE2) GL.glBindTexture(GL.GL_TEXTURE_2D, self._maskID) GL.glEnable(GL.GL_TEXTURE_2D) # implicitly disables 1D # envelope GL.glActiveTexture(GL.GL_TEXTURE1) GL.glBindTexture(GL.GL_TEXTURE_2D, self._envelopeID) GL.glEnable(GL.GL_TEXTURE_2D) #power GL.glActiveTexture(GL.GL_TEXTURE3) GL.glBindTexture(GL.GL_TEXTURE_2D, self._powerID) GL.glEnable(GL.GL_TEXTURE_2D) # carrier GL.glActiveTexture(GL.GL_TEXTURE0) GL.glBindTexture(GL.GL_TEXTURE_2D, self._carrierID) GL.glEnable(GL.GL_TEXTURE_2D) Lcar = (-self._cycles[0]/2) - self.phase[0] + 0.5 Rcar = (+self._cycles[0]/2) - self.phase[0] + 0.5 Tcar = (+self._cycles[1]/2) - self.phase[1] + 0.5 Bcar = (-self._cycles[1]/2) - self.phase[1] + 0.5 Lenv = (-self._envcycles[0]/2) - rph1 + 0.5 Renv = (+self._envcycles[0]/2) - rph1 + 0.5 Tenv = (+self._envcycles[1]/2) - rph2 + 0.5 Benv = (-self._envcycles[1]/2) - rph2 + 0.5 Lmask = Bmask = 0.0 Tmask = Rmask = 1.0 # mask # access just once because it's slower than basic property vertsPix = self.verticesPix GL.glBegin(GL.GL_QUADS) # draw a 4 sided polygon # right bottom GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Rcar, Bcar) GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Renv, Benv) GL.glMultiTexCoord2f(GL.GL_TEXTURE2, Rmask, Bmask) GL.glVertex2f(vertsPix[0, 0], vertsPix[0, 1]) # left bottom GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Lcar, Bcar) GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Lenv, Benv) GL.glMultiTexCoord2f(GL.GL_TEXTURE2, Lmask, Bmask) GL.glVertex2f(vertsPix[1, 0], vertsPix[1, 1]) # left top GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Lcar, Tcar) GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Lenv, Tenv) GL.glMultiTexCoord2f(GL.GL_TEXTURE2, Lmask, Tmask) GL.glVertex2f(vertsPix[2, 0], vertsPix[2, 1]) # right top GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Rcar, Tcar) GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Renv, Tenv) GL.glMultiTexCoord2f(GL.GL_TEXTURE2, Rmask, Tmask) GL.glVertex2f(vertsPix[3, 0], vertsPix[3, 1]) GL.glEnd() # unbind the textures #power GL.glActiveTexture(GL.GL_TEXTURE3) GL.glBindTexture(GL.GL_TEXTURE_2D, 0) GL.glDisable(GL.GL_TEXTURE_2D) # mask GL.glActiveTexture(GL.GL_TEXTURE2) GL.glBindTexture(GL.GL_TEXTURE_2D, 0) GL.glDisable(GL.GL_TEXTURE_2D) # envelope GL.glActiveTexture(GL.GL_TEXTURE1) GL.glBindTexture(GL.GL_TEXTURE_2D, 0) GL.glDisable(GL.GL_TEXTURE_2D) # main carrier GL.glActiveTexture(GL.GL_TEXTURE0) GL.glBindTexture(GL.GL_TEXTURE_2D, 0) GL.glDisable(GL.GL_TEXTURE_2D) GL.glUseProgram(0) GL.glEndList()
[docs] def clearTextures(self): """This will be used by the __del__ method of EnvelopeGrating """ GL.glDeleteTextures(1, self._carrierID) GL.glDeleteTextures(1, self._envelopeID) GL.glDeleteTextures(1, self._maskID)
def _calcEnvCyclesPerStim(self): """The user should never need to call this function directly as it is called whenever there is a need to recalcuate the spatial frequency of the envelope. """ if self.units in ('norm', 'height'): # self._cycles = self.sf # this is the only form of sf that is # not size dependent self._envcycles = self.envsf else: # self._cycles = self.sf * self.size self._envcycles = self.envsf * self.size
if __name__ == "__main__": pass

Back to top