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-2022 Jeremy Hill, Scott Mooney
#
# Shady is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# 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
"""#:
import Shady
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`:
"""#:
print( Shady.SIGFUNC.SinewaveSignal )
"""
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::
stim.signalFunction = Shady.SIGFUNC.SinewaveSignal
Let's look at the source code for `SinewaveSignal` in the actual
shader program:
"""#.
sourceFileName = Shady.PackagePath( 'glsl/FragmentShader.glsl' )
sourceCode = open( sourceFileName ).read()
import re
match = re.search( r'\n\S+\s+SinewaveSignal\s*\(.+?\)\s*\{.+?\n}', sourceCode, re.S)
if match :print( match.group() )
"""
(retrieved from glsl/FragmentShader.glsl) inside the Shady package).
"""#.
"""
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:
"""#:
Shady.AddCustomSignalFunction("""
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:
"""#:
print( Shady.SIGFUNC.SquarewaveSignal )
"""
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
shader.
"""#:
Shady.AddCustomSignalFunction("""
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':
"""#:
Shady.Stimulus.AddCustomUniform( seed=1.0 )
# 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:
"""#:
Shady.Stimulus.AddCustomUniform( plaidAngle=90.0 )
Shady.AddCustomSignalFunction("""
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
functions. What about contrast-modulation functions?
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:
"""#:
Shady.AddCustomModulationFunction("""
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:
"""#:
Shady.AddCustomWindowingFunction("""
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:
"""#:
Shady.Stimulus.AddCustomUniform( gaussianSigmas=3.0 )
Shady.AddCustomWindowingFunction("""
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
windowingFunction = Shady.WINFUNC.BlackmanHarris, # custom
signalAmplitude = 0.5,
signalFunction = Shady.SIGFUNC.SquarewaveSignal, # custom
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
signalFunction = Shady.SIGFUNC.Plaid, # custom
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
signalFunction = Shady.SIGFUNC.RandomSignal,
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
signalFunction = Shady.SIGFUNC.SinewaveSignal,
signalAmplitude = 0.5,
modulationFunction = Shady.MODFUNC.SquarewaveModulation,
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:
"""#:
s1.cx = Shady.Integral( 50 )
s1.windowingFunction = lambda t: \
Shady.WINFUNC.BlackmanHarris if 0 <= ( t % 3 ) < 1 else \
Shady.WINFUNC.Gaussian if 1 <= ( t % 3 ) < 2 else \
Shady.WINFUNC.Hann
s2.plaidAngle = Shady.Oscillator( 0.2 ) * 45 + 45
s3.seed = lambda t: 1 + int( t )
s4.modulationDepth = Shady.Oscillator( 0.2 ) * 0.5 + 0.5
""#.
Shady.AutoFinish( w ) # tidying up in case we didn't get here via `python -m Shady`