#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Stimulus object for drawing arbitrary bitmaps that can repeat (cycle)
in either dimension. One of the main stimuli for PsychoPy.
"""
# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).
# 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
from psychopy.colors import Color
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.basevisual import (
BaseVisualStim, DraggingMixin, ColorMixin, ContainerMixin, TextureMixin
)
import numpy
[docs]class GratingStim(BaseVisualStim, DraggingMixin, TextureMixin, ColorMixin,
ContainerMixin):
"""Stimulus object for drawing arbitrary bitmaps that can repeat (cycle) in
either dimension. This is a lazy-imported class, therefore import using
full path `from psychopy.visual.grating import GratingStim` when inheriting
from it.
One of the main stimuli for PsychoPy.
Formally `GratingStim` is just a texture behind an optional transparency
mask (an 'alpha mask'). Both the texture and mask can be arbitrary bitmaps
and their combination allows an enormous variety of stimuli to be drawn in
realtime.
A `GratingStim` can be rotated scaled and shifted in position, its texture
can be drifted in X and/or Y and it can have a spatial frequency in X
and/or Y (for an image file that simply draws multiple copies in the patch).
Also since transparency can be controlled, two `GratingStim` objects can be
combined (e.g. to form a plaid.)
**Using GratingStim with images from disk (jpg, tif, png, ...)**
Ideally texture images to be rendered should be square with 'power-of-2'
dimensions e.g. 16 x 16, 128 x 128. Any image that is not will be up-scaled
(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!).
Parameters
----------
win : :class:`~psychopy.visual.Window`
Window this shape is being drawn to. The stimulus instance will allocate
its required resources using that Windows context. In many cases, a
stimulus instance cannot be drawn on different windows unless those
windows share the same OpenGL context, which permits resources to be
shared between them.
tex : str or None
Texture to use for the primary carrier. Values may be one of `'sin'`,
`'sin'`, `'sqr'`, `'saw'`, `'tri'`, or `None`.
mask : str or None
Optional mask to control the shape of the grating. Values may be one of
`'circle'`, `'sin'`, `'sqr'`, `'saw'`, `'tri'`, or `None`.
units : str
Units to use when drawing. This will affect how parameters and
attributes `pos`, `size` and `radius` are interpreted.
anchor : str
Anchor string to specify the origin of the stimulus.
pos : array_like
Initial position (`x`, `y`) of the shape on-screen relative to the
origin located at the center of the window or buffer in `units`. This
can be updated after initialization by setting the `pos` property. The
default value is `(0.0, 0.0)` which results in no translation.
size : array_like, float, int or None
Width and height of the shape as `(w, h)` or `[w, h]`. If a single value
is provided, the width and height will be set to the same specified
value. If `None` is specified, the `size` will be set with values passed
to `width` and `height`.
sf : float
Spatial frequency for the grating. Values are dependent on the units in
use to draw the stimuli.
ori : float
Initial orientation of the shape in degrees about its origin. Positive
values will rotate the shape clockwise, while negative values will
rotate counterclockwise. The default value for `ori` is 0.0 degrees.
phase : ArrayLike
Initial phase of the grating along the vertical and horizontal dimension
`(x, y)`.
texRes : int
Resolution of the texture. The higher the resolutions, the less
aliasing artifacts will be visible. However, this comes at the expense
of higher video memory use. Power-of-two values are recommended
(e.g. 256, 512, 1024, etc.)
color : array_like, str, :class:`~psychopy.colors.Color` or None
Sets both the initial `lineColor` and `fillColor` of the shape.
colorSpace : str
Sets the colorspace, changing how values passed to `lineColor` and
`fillColor` are interpreted.
contrast : float
Contrast level of the shape (0.0 to 1.0). This value is used to modulate
the contrast of colors passed to `lineColor` and `fillColor`.
opacity : float
Opacity of the shape. A value of 1.0 indicates fully opaque and 0.0 is
fully transparent (therefore invisible). Values between 1.0 and 0.0 will
result in colors being blended with objects in the background. This
value affects the fill (`fillColor`) and outline (`lineColor`) colors of
the shape.
depth : int
Depth layer to draw the shape when `autoDraw` is enabled. *DEPRECATED*
rgbPedestal : ArrayLike
Pedestal color `(r, g, b)`, presently unused.
interpolate : bool
Enable smoothing (anti-aliasing) when drawing shape outlines. This
produces a smoother (less-pixelated) outline of the shape.
draggable : bool
Can this stimulus be dragged by a mouse click?
lineRGB, fillRGB: ArrayLike, :class:`~psychopy.colors.Color` or None
*Deprecated*. Please use `lineColor` and `fillColor`. These arguments
may be removed in a future version.
name : str
Optional name of the stimuli for logging.
autoLog : bool
Enable auto-logging of events associated with this stimuli. Useful for
debugging and to track timing when used in conjunction with `autoDraw`.
autoDraw : bool
Enable auto drawing. When `True`, the stimulus will be drawn every frame
without the need to explicitly call the
:py:meth:`~psychopy.visual.shape.ShapeStim.draw()` method.
Examples
--------
Creating a circular grating with a sinusoidal pattern::
myGrat = GratingStim(tex='sin', mask='circle')
Create a 'Gabor'::
myGabor = GratingStim(tex='sin', mask='gauss')
"""
def __init__(self,
win,
tex="sin",
mask="none",
units=None,
anchor="center",
pos=(0.0, 0.0),
size=None,
sf=None,
ori=0.0,
phase=(0.0, 0.0),
texRes=128,
rgb=None,
dkl=None,
lms=None,
color=(1.0, 1.0, 1.0),
colorSpace='rgb',
contrast=1.0,
opacity=None,
depth=0,
rgbPedestal=(0.0, 0.0, 0.0),
interpolate=False,
draggable=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__
self._initParams = dir()
for unecess in ['self', 'rgb', 'dkl', 'lms']:
self._initParams.remove(unecess)
# initialise parent class
super(GratingStim, self).__init__(win, units=units, name=name,
autoLog=False)
self.draggable = draggable
# 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
self.__dict__['contrast'] = 1
self.__dict__['size'] = 1
self.__dict__['sf'] = 1
self.__dict__['tex'] = tex
self.__dict__['maskParams'] = maskParams
# initialise textures and masks for stimulus
self._texID = GL.GLuint()
GL.glGenTextures(1, ctypes.byref(self._texID))
self._maskID = GL.GLuint()
GL.glGenTextures(1, ctypes.byref(self._maskID))
self.__dict__['texRes'] = texRes # must be power of 2
self.interpolate = interpolate
self._needTextureUpdate = True
# NB Pedestal isn't currently being used during rendering - this is a
# place-holder
self.rgbPedestal = val2array(rgbPedestal, False, length=3)
# No need to invoke decorator for color updating. It is done just
# below.
self.colorSpace = colorSpace
self.color = color
if rgb is not None:
logging.warning("Use of rgb arguments to stimuli are deprecated."
" Please use color and colorSpace args instead")
self.color = Color(rgb, 'rgb')
elif dkl is not None:
logging.warning("Use of dkl arguments to stimuli are deprecated."
" Please use color and colorSpace args instead")
self.color = Color(dkl, 'dkl')
elif lms is not None:
logging.warning("Use of lms arguments to stimuli are deprecated."
" Please use color and colorSpace args instead")
self.color = Color(lms, 'lms')
# set other parameters
self.ori = float(ori)
self.phase = val2array(phase, False)
self._origSize = None # updated if an image texture is loaded
self._requestedSize = size
self.size = size
self.sf = val2array(sf)
self.pos = val2array(pos, False, False)
self.depth = depth
self.anchor = anchor
# self.tex = tex
self.mask = mask
self.contrast = float(contrast)
self.opacity = opacity
self.autoLog = autoLog
self.autoDraw = autoDraw
self.blendmode = blendmode
# fix scaling to window coords
self._calcCyclesPerStim()
# generate a displaylist ID
self._listID = GL.glGenLists(1)
# JRG: doing self._updateList() here means MRO issues for RadialStim,
# which inherits from GratingStim but has its own _updateList code.
# So don't want to do the update here (= ALSO the init of RadialStim).
# Could potentially define a BaseGrating class without
# updateListShaders code, and have GratingStim and RadialStim
# inherit from it and add their own _updateList stuff.
# Seems unnecessary. Instead, simply defer the update to the
# first .draw(), should be fast:
# self._updateList() # ie refresh display list
self._needUpdate = True
# set autoLog now that params have been initialised
wantLog = autoLog is None and self.win.autoLog
self.__dict__['autoLog'] = autoLog or wantLog
if self.autoLog:
logging.exp("Created {} = {}".format(self.name, self))
@attributeSetter
def sf(self, value):
"""Spatial frequency of the grating 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 SF scales with stimulus size). If texture is an image
loaded from a file then sf=None defaults to 1/stimSize to give one cycle
of the image.
"""
# Recode phase to numpy array
if value is None:
# Set the sf to default (e.g. to the 1.0/size of the loaded image
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__['sf'] = value
self._calcCyclesPerStim()
self._needUpdate = True
@attributeSetter
def phase(self, value):
"""Phase of the stimulus in each dimension of the 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.
"""
# Recode phase to numpy array
value = val2array(value)
self.__dict__['phase'] = value
self._needUpdate = True
@attributeSetter
def tex(self, value):
"""Texture to used on the stimulus as a grating (aka carrier).
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 dimensions (e.g. 256 x
256). If not then PsychoPy will up-sample your stimulus to the next
larger power of two.
"""
self._createTexture(
value,
id=self._texID,
pixFormat=GL.GL_RGB,
stim=self,
res=self.texRes,
maskParams=self.maskParams)
self.__dict__['tex'] = value
self._needTextureUpdate = False
@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
[docs] def setSF(self, value, operation='', log=None):
"""DEPRECATED. Use 'stim.parameter = value' syntax instead
"""
self._set('sf', value, operation, log=log)
[docs] def setPhase(self, value, operation='', log=None):
"""DEPRECATED. Use 'stim.parameter = value' syntax instead
"""
self._set('phase', value, operation, log=log)
[docs] def setTex(self, value, log=None):
"""DEPRECATED. Use 'stim.parameter = value' syntax instead
"""
self.tex = value
[docs] def setBlendmode(self, value, log=None):
"""DEPRECATED. Use 'stim.parameter = value' syntax instead
"""
self._set('blendmode', value, log=log)
[docs] 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.
Parameters
----------
win : `~psychopy.visual.Window` or `None`
Window to draw the stimulus to. Context sharing must be enabled if
any other window beside the one specified during creation of this
stimulus is specified.
"""
if win is None:
win = self.win
self._selectWindow(win)
saveBlendMode = win.blendMode
win.setBlendMode(self.blendmode, log=False)
# do scaling
GL.glPushMatrix() # push before the list, pop after
win.setScale('pix')
# the list just does the texture mapping
GL.glColor4f(*self._foreColor.render('rgba1'))
if self._needTextureUpdate:
self.setTex(value=self.tex, log=False)
if self._needUpdate:
self._updateList()
GL.glCallList(self._listID)
# return the view to previous state
GL.glPopMatrix()
win.setBlendMode(saveBlendMode, log=False)
[docs] 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
"""
self._needUpdate = False
GL.glNewList(self._listID, GL.GL_COMPILE)
# setup the shaderprogram
_prog = self.win._progSignedTexMask
GL.glUseProgram(_prog)
# set the texture to be texture unit 0
GL.glUniform1i(GL.glGetUniformLocation(_prog, b"texture"), 0)
# mask is texture unit 1
GL.glUniform1i(GL.glGetUniformLocation(_prog, b"mask"), 1)
# mask
GL.glActiveTexture(GL.GL_TEXTURE1)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._maskID)
GL.glEnable(GL.GL_TEXTURE_2D) # implicitly disables 1D
# main texture
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, self._texID)
GL.glEnable(GL.GL_TEXTURE_2D)
Ltex = (-self._cycles[0] / 2) - self.phase[0] + 0.5
Rtex = (+self._cycles[0] / 2) - self.phase[0] + 0.5
Ttex = (+self._cycles[1] / 2) - self.phase[1] + 0.5
Btex = (-self._cycles[1] / 2) - self.phase[1] + 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, Rtex, Btex)
GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Rmask, Bmask)
GL.glVertex2f(vertsPix[0, 0], vertsPix[0, 1])
# left bottom
GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Ltex, Btex)
GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Lmask, Bmask)
GL.glVertex2f(vertsPix[1, 0], vertsPix[1, 1])
# left top
GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Ltex, Ttex)
GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Lmask, Tmask)
GL.glVertex2f(vertsPix[2, 0], vertsPix[2, 1])
# right top
GL.glMultiTexCoord2f(GL.GL_TEXTURE0, Rtex, Ttex)
GL.glMultiTexCoord2f(GL.GL_TEXTURE1, Rmask, Tmask)
GL.glVertex2f(vertsPix[3, 0], vertsPix[3, 1])
GL.glEnd()
# unbind the textures
GL.glActiveTexture(GL.GL_TEXTURE1)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
GL.glDisable(GL.GL_TEXTURE_2D) # implicitly disables 1D
# main texture
GL.glActiveTexture(GL.GL_TEXTURE0)
GL.glBindTexture(GL.GL_TEXTURE_2D, 0)
GL.glDisable(GL.GL_TEXTURE_2D)
GL.glUseProgram(0)
GL.glEndList()
def __del__(self):
try:
GL.glDeleteLists(self._listID, 1)
except Exception:
pass # probably we don't have a _listID property
try:
# remove textures from graphics card to prevent crash
self.clearTextures()
except Exception:
pass
def _calcCyclesPerStim(self):
if self.units in ('norm', 'height'):
# this is the only form of sf that is not size dependent
self._cycles = self.sf
else:
self._cycles = self.sf * self.size