examples/custom-functions.py¶

This is one of the example scripts included with Shady. These scripts can be run conventionally like any normal Python script, or you can choose to run them as interactive tutorials, for example with python -m Shady demo custom-functions

# $BEGIN_SHADY_LICENSE$
#
# This file is part of the Shady project, a Python framework for
# real-time manipulation of psychophysical stimuli for vision science.
#
# Copyright (c) 2017-2020 Jeremy Hill, Scott Mooney
#
# Shady is free software: you can redistribute it and/or modify it
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see http://www.gnu.org/licenses/ .
#
# $END_SHADY_LICENSE$

#: How to customize signal, modulation or windowing functions
"""
This script demonstrates how you can easily extend the GPU shader
program with custom variables (called "uniform variables"), custom
carrier signal functions, custom contrast modulation functions,
and custom windowing functions.  You write the functions as small
pieces of GL Shading Language code.

It is similarly possible to write snippets of GLSL code to
implement custom color transformations (see the
color-transformation demo).

Assuming you use the ShaDyLib binary accelerator as a back-end,
this demo does not use *any* third-party Python packages.
"""#.

if __name__ == '__main__':

"""
Let's get the command-line arguments out the way first
"""#:
cmdline = Shady.WorldConstructorCommandLine( canvas=True, reportVersions=True )
cmdline.Help().Finalize()

"""
Customization is performed *before* any instances
of World or Stimulus are created.  Let's start by defining
a custom signal function.  A signal function is written in
GLSL and follows one of the following two prototypes::

float func( vec2 coords ) { ... }   // monochromatic output
vec3  func( vec2 coords ) { ... }   // RGB output

where coords is a 2-D coordinate in pixels measured from the
center of the stimulus.
"""#.

"""
The one pre-existing signal function is called SinewaveSignal,
which has the associated index number 1.  Signal function names
are mapped to numbers in the namespace Shady.SIGFUNC:
"""#:

"""
More names will appear in this namespace as you define more
functions yourself. The idea is that, while you can activate the
sine-wave signal function for your stimulus instance stim by
saying::

stim.signalFunction = 1

it makes for more transparent, readable code if you express
that as::

Let's look at the source code for SinewaveSignal in the actual
"""#.

import re
match = re.search( r'\n\S+\s+SinewaveSignal\s*$$.+?$$\s*\{.+?\n}', sourceCode, re.S)
if match :print( match.group() )

"""
"""#.

"""
You'll see that SinewaveSignal uses a variable called
uSignalParameters. This is a "uniform" variable in the shader,
meaning that its value does not change from pixel to pixel in a
given stimulus and that we are able to change its value from the
CPU side. According to Shady's naming conventions, the
uSignalParameters variable receives its value from a managed
property called .signalParameters (which, in this case, belongs
only to the Stimulus class).

The function also uses the sinusoid() helper function. For our
first example, let's use both of these pre-existing tools to
implement an antialiased square-wave signal function, as a
finite sum of sinusoid() components. We'll do this with the
global function AddCustomSignalFunction, to which we need to
pass a multi-line string containing the GLSL shader code:
"""#:

float SquarewaveSignal( vec2 coords )
{
float y = 0.0;
for( float harmonic = 1.0; ; harmonic += 2.0 )
{
float cyclesPerPixel = uSignalParameters[ 1 ] * harmonic;
if( cyclesPerPixel > 0.5 ) break;
y += sinusoid(
coords,
cyclesPerPixel,
uSignalParameters[ 2 ],
harmonic * uSignalParameters[ 3 ]
) * uSignalParameters[ 0 ] / harmonic;
}
return y;
}

""")

"""
Note that this has automatically added a new index to the
Shady.SIGFUNC namespace:
"""#:

"""
For our next trick, let's make a signal function that displays
a frozen random uniform noise.  We can use the helper function
random() which takes a 2-dimensional coordinate as its seed,
and returns a pseudo-random number from the uniform distribution
over the range [-1, +1]. For scaling we'll use the existing
.signalAmplitude property which is a shortcut to the first
element of the .signalParameters property, and hence to the
first element of the uSignalParameters uniform variable in the
"""#:

float RandomSignal( vec2 coords )
{
vec3 seed3 = vec3( ( 1.0 + coords ) / ( 2.0 + uTextureSize ),  uSeed );
return random( seed3 ) * uSignalParameters[ 0 ]; // output of random() is the range [-1, +1]
}
""")

"""
Note that we also introduced a customizable random-seed as the
uniform variable uSeed. This does not exist yet, but it can
be created, and manipulated as a Stimulus property, if we use
the property name 'seed':
"""#:

# Note that this is not a global function, but rather a class
# method of the Stimulus class.  You can also add custom
# properties/uniform variables to the World class if you want.

"""
Let's do another one.  What else do psychophysicists like?
Of course: plaids!  Let's create a Plaid() signal function,
parameterizing the angle between plaid components with another
new uniform:
"""#:

float Plaid( vec2 coords )
{
return uSignalParameters[ 0 ] * (
sinusoid( coords, uSignalParameters[ 1 ], uSignalParameters[ 2 ] - uPlaidAngle / 2.0, uSignalParameters[ 3 ] )
+ sinusoid( coords, uSignalParameters[ 1 ], uSignalParameters[ 2 ] + uPlaidAngle / 2.0, uSignalParameters[ 3 ] )
);
}
""")

"""
Hopefully you've got the idea how to customize signal
You may have noticed that we carefully included the
word "Signal" in the name SquarewaveSignal.  This
allows us to mirror the existing distinction, in the
shader, between SinewaveSignal and
SinewaveModulation, which is important because the
two functions use different uniform variables for their
parameters, and a different convention for interpreting
the amplitude parameter.

Modulation functions always have scalar output, so their
prototype is always::

float f( vec2 coords ) { ... }  # scalar output only

With all of this in mind, let's make the analogous
SquarewaveModulation function:
"""#:

float SquarewaveModulation( vec2 coords )
{
// NB: *not* spatially antialiased (that's left as an exercise for the reader)
float y = sign( sinusoid( coords, uModulationParameters[ 1 ], uModulationParameters[ 2 ], uModulationParameters[ 3 ] ) );
return 1.0 + uModulationParameters[ 0 ] * ( y - 1.0 ) / 2.0;
}
""") # result will be registered in the Shady.MODFUNC namespace

"""
Finally, the last class of functions that is customizable
is the windowing function. The prototype for a custom
windowing function is::

float f( float r ) { ... }

where r varies between 0 at the peak (or throughout the
plateau, if any) and 1 at the edge of the stimulus. So,
if you absolutely positively have to have, say,
Blackman-Harris windows instead of Hann windows:
"""#:

float BlackmanHarris( float r )
{
r += 1.0;
r *= PI;
float w = 0.35875;
w += -0.48829 * cos( r );
w += +0.14128 * cos( r * 2.0 );
w += -0.01168 * cos( r * 3.0 );
return w;
}
""") # result will be registered in the Shady.WINFUNC namespace

"""
...or Gaussian windows, extending out to a configurable
number of sigmas:
"""#:
float Gaussian( float r )
{
r *= uGaussianSigmas;
float v = exp( -0.5 * r * r );
// uncomment the following to ensure the window really comes down to zero:
// float tailThickness = exp( -0.5 * uGaussianSigmas * uGaussianSigmas );
// v = ( v - tailThickness ) / ( 1.0 - tailThickness );
return v;
}
""") # result will be registered in the Shady.WINFUNC namespace

"""
# All of this customization had to be done *before* World
# initialization. Now let's put it all together, and test each
# of our custom additions.  First, create a World:
"""#:

w = Shady.World( **cmdline.opts ).Set( gamma=2.2 )

"""
Now the stimuli.  First, a square-wave signal patch,
windowed a la Blackman-Harris:
"""#:

s1 = w.Stimulus(
size = 500,
x = -250,
y = +250,
plateauProportion = 0, # non-negative: turns windowing on
signalAmplitude = 0.5,
atmosphere = w,
)

"""
Then a plaid, with the usual Hann window:
"""#:

s2 = w.Stimulus(
size = 500,
x = +250,
y = +250,
plateauProportion = 0, # non-negative: turns windowing on
signalAmplitude = 0.25,
signalOrientation = 45,
atmosphere = w,
)

"""
Now a frozen noise, again in a Hann window:
"""#:

s3 = w.Stimulus(
size = 500,
x = -250,
y = -250,
plateauProportion = 0, # non-negative: turns windowing on
signalAmplitude = 0.5,
atmosphere = w,
)

"""
And finally a Gabor with additional square-wave
contrast modulation:
"""#:

s4 = w.Stimulus(
size = 500,
x = +250,
y = -250,
plateauProportion = 0, # non-negative: turns windowing on
signalAmplitude = 0.5,
modulationDepth = 1.0,
modulationFrequency = 0.01,
modulationOrientation = 45,
atmosphere = w,
)

"""
Now let's animate some aspects of our stimuli, including
the new property .plaidAngle in the plaid stimulus:
"""#:

Shady.AutoFinish( w ) # tidying up in case we didn't get here via python -m Shady