In [1]:
import sys
sys.path.append('../../pyrtools/')
import pyrtools as pt
import numpy as np
sys.path.append('..')
import sfp
%matplotlib inline
import seaborn as sns
import pandas as pd
from scipy import signal
import fractions
import matplotlib.pyplot as plt
import pickle

%load_ext autoreload
%autoreload 2


Failed to import duecredit due to No module named 'duecredit'

The following demonstrates some of the stimuli that can be made with the log_polar_grating command. The key characteristic of these stimuli is that the frequency (in cycles per pixel) increases as you approach the center of the image. This encodes our hypothesis that the preferred spatial frequency of neurons (and thus, voxels) whose receptive fields are located at the fovea (here, the center of the image) will have a higher preferred spatial frequency than those whose receptive fields are located in the periphery.

However, with these parameters, we notice that there is strange-looking aliasing happening in the center of the image.


In [4]:
ims = []
freq=2
phase = 0
ampl=1
shape=64
origin = None
ims.append(sfp.stimuli.log_polar_grating(shape, freq,phi=phase,ampl=ampl, origin=origin))
ims.append(sfp.stimuli.log_polar_grating(shape, w_a=freq,phi=phase,ampl=ampl))
ims.append(sfp.stimuli.log_polar_grating(shape, freq/2,freq,phi=phase,ampl=ampl))
ims.append(sfp.stimuli.log_polar_grating(shape,-freq,freq,phi=phase,ampl=ampl))
ims.append(sfp.stimuli.log_polar_grating(shape,freq/2,freq,phi=phase,ampl=ampl))
ims.append(sfp.stimuli.log_polar_grating(shape,freq,freq/4,phi=phase,ampl=ampl))
ims.append(ims[0]+ims[1])
# ims.append(sfp.stimuli._create_better_sampled_grating(shape, freq, phi=phase, ampl=ampl, orig_origin=origin, check_scale_factor=3))
# ims.append(sfp.stimuli._create_better_sampled_grating(shape, w_a=freq, phi=phase, ampl=ampl, orig_origin=origin, check_scale_factor=3))
# ims.append(sfp.stimuli._create_better_sampled_grating(shape, freq, phi=phase, ampl=ampl, check_scale_factor=50, orig_origin=origin))
# ims.append(sfp.stimuli._create_better_sampled_grating(shape, freq, phi=phase, ampl=ampl, check_scale_factor=200, orig_origin=origin))

# Example stimuli
pt.imshow(ims, col_wrap=min(len(ims), 4),zoom=5);


In order to investigate this a little more, we create compare the second, radial stimulus with a more heavily-sampled version.

The first plot shows the central slice of the 64 x 64 image in orange and the central slice of the grating 11 times larger in blue. We clearly see some very high frequency signal that is being missed.


In [14]:
stim, over_sampled_stim = sfp.stimuli.check_aliasing(64, 0, w_a=6, check_scale_factor=11)


This second plot shows the two images, and again we can see the aliasing at the center


In [21]:
pt.imshow([stim, over_sampled_stim], zoom=6/11);


To deal with this issue, we create a mask that we'll lay at the center of the image, completely masking out the aliased portion of the stimuli and then fading gradually to invisible as we move away from it.

This first plot shows the same slice as above for the un-masked, fade-masked, and binary-masked stimuli.


In [24]:
tmps = sfp.stimuli.check_aliasing_with_mask(64, 0, w_a=6, )


This here shows the 64 x 64 image with no mask, the faded mask, and the binary mask. We can see that the masking hides the aliased portion of the image.


In [31]:
pt.imshow([tmps[0], tmps[1] * tmps[0], tmps[2] * tmps[0]], col_wrap=3, zoom=5);


Now that we know the stimuli we want to create and are convinced that masking takes care of their aliasing, we can create the set of stimuli for experiment. We do this using the gen_stim_set function, which requires that the size, origin, and number_of_fade_pixels parameters be constant across all stimuli, but allows the others to vary. We create all the stimuli and then apply the largest mask to them, so that all stimuli have the same mask and none show any aliasing. The function returns both the unmasked (stim) and masked (mstim) versions of the stimuli, but we only want the masked versions (the unmasked are only returned so they can be double-checked).

The following two plots show an example set of (smaller) stimuli and one full-sized one.

To create the exact set of stimuli wanted for the experiment, use the stimuli.main function (but note that this can take some time and will save the outputs; it can also be run on the command-line).


In [37]:
mstim, stim, mask = sfp.stimuli.gen_log_polar_stim_set(32, freqs_ra=[(4,0),(0,4),(4,4)], phi=np.array(range(8))/8.*2*np.pi, )

pt.imshow(mstim, col_wrap=6, zoom=5);



In [40]:
mstim, _, _ = sfp.stimuli.gen_log_polar_stim_set(1080, [(0, 128)], bytescale=True)# phi=np.array(range(10))/10.*2*np.pi, )

pt.imshow(mstim, col_wrap=4, );


Stimulus properties

For the purposes of creating our stimuli, we're interested in a couple of numbers that aren't apparent from the plots above:

  • how big the anti-aliasing mask in the center will be
  • what the minimum spatial frequency in the stimulus will be
  • what the maximum spatial frequency (after applying the mask) will be

for the spatial frequencies, we're interested in this in units of cycles per visual degree, so we create the full-size stimulus (1080 x 1080 pixels) and input the diameter of the stimulus in visual degrees (28).


In [60]:
# Note that this takes a while to run
mask_df, sf_df = sfp.stimuli.check_stim_properties(1080, None, 24, w_r=range(0,181,5), w_a=range(0,181,5))

We see that the mask grows with the frequencies. It gets pretty big.

Note that the mask size is only dependent on the frequency content the image, not the size of the image. So if you have $\omega_a=500$, your mask will always be 186 pixels wide, so if your image is only 150 pixels per side, then your entire image will be blank.


In [70]:
sfp.stimuli.plot_stim_properties(mask_df, size=8, data_label='mask_radius_pix', title_text='Mask radius in degrees')


/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/seaborn/axisgrid.py:230: UserWarning: The `size` paramter has been renamed to `height`; please update your code.
  warnings.warn(msg, UserWarning)
/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/matplotlib/figure.py:2022: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  warnings.warn("This figure includes Axes that are not compatible "
/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/matplotlib/figure.py:2022: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  warnings.warn("This figure includes Axes that are not compatible "

This next plot isn't as interesting, but it's a sanity check to make sure the mask is working: basically, we want to see that the mask is working and so nowhere do we get frequencies exceeding .5 cycles per pixel. This shows that that's the case: no matter how high the frequencies go in either direction, they always stay below .5


In [71]:
sfp.stimuli.plot_stim_properties(mask_df, data_label='cpp_masked_max', title_text="Max masked frequency in cpp", size=8)


/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/seaborn/axisgrid.py:230: UserWarning: The `size` paramter has been renamed to `height`; please update your code.
  warnings.warn(msg, UserWarning)
/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/matplotlib/figure.py:2022: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  warnings.warn("This figure includes Axes that are not compatible "
/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/matplotlib/figure.py:2022: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  warnings.warn("This figure includes Axes that are not compatible "

What we want to do is ensure that our mask has a radius of about one degree of visual angle, so we plot the distance from the origin in frequency space (that is, $\sqrt{\omega_\alpha^2+\omega_r^2}$) against the radius of the mask, in degrees. Because of how we constructed these stimuli, the local spatial frequency will only depend on this distance (i.e., it's the same in all directions: the stimuli with $\omega_\alpha=0, \omega_r=100$ has the same local spatial frequency everywhere as the one with $\omega_\alpha=100, \omega_r=0$) and the radius of the mask depends on this local spatial frequency (it will mask everywhere the local spatial frequency exceeds our Nyquist frequency limit, which I've set to a slightly conservative .475), this plot will be a single straight line (if you add the hue argument that's currently commented out, you'll see that they all lie on top of each other).

What we want to do is find the distance in frequency space where we cross the mask_radius_deg=1 line, which I show below the figure


In [73]:
g = sns.FacetGrid(mask_df, size=7,)# hue='w_r')
g.map(plt.scatter, 'freq_distance', 'mask_radius_deg')
#sns.plt.scatter(mask_df['freq_distance'], mask_df['mask_radius_deg'])
g.add_legend()
g.ax.plot(g.ax.get_xlim(), [1, 1], 'k--')
g.ax.set_xlim((0, 300))
_=g.ax.set_ylim((0, 2))


/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/seaborn/axisgrid.py:230: UserWarning: The `size` paramter has been renamed to `height`; please update your code.
  warnings.warn(msg, UserWarning)

In [77]:
mask_df.iloc[abs(mask_df.mask_radius_deg-1).argmin()]


/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/ipykernel_launcher.py:1: FutureWarning: 'argmin' is deprecated, use 'idxmin' instead. The behavior of 'argmin'
will be corrected to return the positional minimum in the future.
Use 'series.values.argmin' to get the position of the minimum now.
  """Entry point for launching an IPython kernel.
Out[77]:
mask_radius_pix      44.949972
w_r                  60.000000
w_a                 120.000000
freq_distance       134.164079
mask_radius_deg       0.999814
cpp_max              30.197527
cpp_min               0.027987
cpp_masked_max        0.474802
cpd_max            1358.888727
cpd_min               1.259396
cpd_masked_max       21.366069
Name: 468, dtype: float64

Thus we want to go out to a frequency distance of about 135. Since we're $\log_2$ spacing our stimuli, we want to know the following and see that our stimuli should lie at most about $2^7$ from the origin of our frequency space.


In [75]:
np.log2(134.164079)


Out[75]:
7.06785464681596

Constant stimuli

We also want to create stimuli whose local spatial frequency is the same everywhere, i.e., normal 2d sine waves. To compare them effectively to our stimuli, we construct the spatial frequency dataframe.

Because only the distance from the origin in frequency space matters (as discussed above), we set $\omega_r$ to 0 and only look at different values of $\omega_a$, for simplicity. We also use the actual values we will use in this experiment: $\omega_a=[2^{2.5}, 2^3, 2^{3.5}, 2^4, 2^{4.5}, 2^5, 2^{5.5}, 2^6, 2^{6.5}, 2^7]$. We use these values rounded to the nearest integer, because they need to be integers in order to not have weird discontinuities

In order to determine what spatial frequencies are constant grating stimuli should have, we look at the local period (which is equivalent to looking at the local spatial frequency, since they're just reciprocals of each other, and more interpretable) at eccentricity 4.5 degrees. If we construct a constant grating with those periods, we guarantee that at one eccentricity we have the same local spatial frequency in both sets of stimuli. We choose 4.5 because then there are roughly equal numbers of larger and smaller log polar gratings.


In [79]:
_, sf_df = sfp.stimuli.check_stim_properties(1080, None, 24, 0, np.round([2**i for i in np.arange(2.5, 7.5, .5)]))

In [82]:
plot_func = [plt.plot, plt.semilogy]
plot_kwargs = [{}, {'basey': 2}]
plot_idx = 1
g = sns.FacetGrid(sf_df, hue='w_a', size=7)
g.map(plot_func[plot_idx], 'eccen', 'local_period_ppc', **plot_kwargs[plot_idx])
g.add_legend()
constant_grads = sf_df[sf_df.eccen==4.5].local_period_ppc.values
for val in constant_grads:
    plot_func[plot_idx]([1.5, 11.5], [val, val], 'k--', **plot_kwargs[plot_idx])
plot_func[plot_idx]([4.5, 4.5], [2**1.5, 2**9], 'k--', **plot_kwargs[plot_idx])


/home/billbrod/miniconda3/envs/sfp/lib/python3.6/site-packages/seaborn/axisgrid.py:230: UserWarning: The `size` paramter has been renamed to `height`; please update your code.
  warnings.warn(msg, UserWarning)
Out[82]:
[<matplotlib.lines.Line2D at 0x7fddd349ec18>]

Unfortunately, these numbers are ugly, but they're the ones we want to use.


In [84]:
print(1./constant_grads)
print(np.log2(1./constant_grads))


[0.00472007 0.00629343 0.00865347 0.01258686 0.01809361 0.02517372
 0.03540054 0.05034744 0.07158777 0.10069488]
[-7.72697528 -7.31193778 -6.85250616 -6.31193778 -5.78837582 -5.31193778
 -4.82008468 -4.31193778 -3.80414314 -3.31193778]

Experiment stimuli

Your stimuli should be created from the command line (in the spatial_frequency_preferences directory, call python -m sfp.stimuli -h to view the helpstring), but this here loads in the unshuffled stimuli and descriptive dataframe and examines some of them.


In [85]:
stim = np.load("../data/stimuli/unshuffled.npy")
df = pd.read_csv("../data/stimuli/unshuffled_stim_description.csv")
constant_stim = np.load("../data/stimuli/constant_unshuffled.npy")
constant_df = pd.read_csv("../data/stimuli/constant_unshuffled_stim_description.csv")

In [86]:
pt.imshow([stim[0], stim[100], stim[354]]);



In [87]:
pt.imshow([constant_stim[0], constant_stim[100], constant_stim[300]]);



In [88]:
pt.imshow([i for i in stim[:10]], col_wrap=4);



In [90]:
pt.imshow([i for i in stim[300:304]], col_wrap=4);


Let's save some of these stimuli


In [91]:
w_y = constant_df[constant_df.w_x==0].w_y.unique()
stim_idx = constant_df[(constant_df.w_x==0)&(constant_df.w_y.isin(w_y))&(constant_df.phi==0)].index
pt.imshow([i for i in constant_stim[stim_idx]], col_wrap=4);



In [97]:
fig = pt.imshow(stim[:8,:,:],zoom=180/1080,)
fig.savefig('stim_phases.svg')


Inverting the display MTF

At a certain point during this experiment, we realized that the scanner projector, like an display system, will lose contrast for higher spatial frequencies. Since it has a pointspread function, this will act as a small amount of blur, which will reduce contrast as the spatial frequency increases. In a side project, we measured the Modulation Transfer Function of the projector for different presentation frequencies. We now load in that MTF (as a spline object) and create stimuli that invert it. Because the values of the stimuli must lie between 0 and 255 in order to be displayed, we must decrease the amplitude for low frequencies (because we cannot increase the amplitude for high frequencies) in order to present all frequencies at the same contrast


In [3]:
with open('/mnt/prince_scratch/spatial_frequency_preferences/stimuli/mtf_func.pkl', 'rb') as f:
    mtf = pickle.load(f)

In [9]:
sfs = np.linspace(0, 1)
plt.semilogx(sfs, mtf(sfs),basex=2)
plt.xlabel('spatial frequency (cpp)')
plt.ylabel('contrast')
plt.title('MTF of the display')


Out[9]:
Text(0.5, 1.0, 'MTF of the display')

In [75]:
mask = []
stim = []
masked_stim = []
rescaled_stim = []
for f in [2**5, 2**7]:
    stim.append(sfp.stimuli.log_polar_grating(1080, f, 0))
    _, _, sf_map, _ = sfp.stimuli.create_sf_maps_cpp(1080, w_r=f, w_a=0)
    # this is the max frequency we generate, so we use it here
    mask.append(sfp.stimuli.create_antialiasing_mask(1080, 128, 0)[1])
    mask.append(sfp.stimuli.create_outer_mask(1080, None)[1])
    anti_mtf_map = 1./mtf(sf_map)
    rescaled_stim.append(anti_mtf_map * stim[-1])
mask = np.logical_and.reduce(mask)
mask = sfp.stimuli._fade_mask(mask, 3, 3, None)
masked_stim = np.array([mask*s for s in stim])
rescaled_stim = np.array([mask*s for s in rescaled_stim])
masked_stim = sfp.utils.bytescale(masked_stim)
rescaled_stim = sfp.utils.bytescale(rescaled_stim)

In [77]:
pt.imshow([mask*sf_map, mtf(mask*sf_map), 1./mtf(mask*sf_map)], zoom=.5);


Notice that the lower frequency grating no longer uses the full dynamic range [0, 255]. This is how it should work.


In [95]:
pt.imshow(np.concatenate([masked_stim, rescaled_stim]), 'auto1', zoom=.5, col_wrap=2);



In [94]:
fig, axes = plt.subplots(1,2,figsize=(30,10))
for i in range(2):
    axes[i].plot(masked_stim[i,:,540])
    axes[i].plot(rescaled_stim[i,:,540], '--')


We do this for all stimuli, which we'll now load and and look at several mid-slices


In [141]:
rescaled_stim = np.load('/mnt/winawerlab/Projects/spatial_frequency_preferences/BIDS/stimuli/task-sfprescaled_stimuli.npy')
stim = np.load('/mnt/winawerlab/Projects/spatial_frequency_preferences/BIDS/stimuli/task-sfp_stimuli.npy')
stim_df = pd.read_csv('/mnt/winawerlab/Projects/spatial_frequency_preferences/BIDS/stimuli/task-sfp_stim_description.csv')
stim_df = stim_df.drop_duplicates('class_idx')

The following plots are a little strange, since this isn't always the best way to slice through one of these gratings. You'll also notice that, for relatively low frequencies, there's not a huge difference in the amount they're re-scaled. This is because of the shape of the MTF. You don't start to really lose contrast until the frequency gets higher, so the relatively low frequencies are all presented at roughly the same contrast and, thus, their amplitudes are rescaled by roughly the same amount.


In [177]:
fig, axes = plt.subplots(6, 8, True, True, figsize=(40, 30))
for i, (ax, s, rs) in enumerate(zip(axes.flatten(), stim[:48*8:8], rescaled_stim[:48*8:8])):
    ax.plot(s[:,540])
    ax.plot(rs[:,540], '--')
    ax.set_title('class_idx %02d' % i)
plt.savefig('rescaled_stim.svg')